Compare commits

...
9 Commits
Author SHA1 Message Date
Richard Zink 6d656a355d Release v202611-00009 2026-11-09 19:46:54 +01:00
Richard ZinkandClaude Opus 5.5 ca55404f99 Sandbox: mount /lib64 in the bubblewrap start-up check
On x86_64 the dynamic loader lives in /lib64, so the probe failed with
"execvp /bin/true: No such file or directory" and shell commands of non-admin
users were disabled on amd64 hosts. The sandbox itself already mounted it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
Richard ZinkandClaude Opus 5.5 0241f6031b Deploy script for remote installations; move-server: --exclude, quoted .env values
scripts/deploy.sh rolls a published image out to an installation set up with
move-server.sh (DEPLOY_TARGET in .env): checks the image for the target's
architecture, waits until no agent or terminal runs (--force skips), pulls and
recreates, runs the smoke test and goes back to the previous version on failure.

move-server.sh: --exclude leaves paths out of the workspace copy; values in .env
may be quoted (WORKSPACE='/home/...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
Richard Zink 4244b9370c Release v202611-00008 2026-11-09 18:43:38 +01:00
Richard ZinkandClaude Opus 5.5 dae76a5636 OpenAI wire format: no name on tool messages (opencode/Anthropic rejected it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:42:00 +01:00
Richard ZinkandClaude Opus 5.5 ff8ff585a2 Instructions per chat (system prompt addition; UI dialog and Telegram /prompt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:14:20 +01:00
Richard ZinkandClaude Opus 5.5 434aa84d40 Telegram: find the token in pasted text, no password autofill, clear error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:33:12 +01:00
Richard Zink 0a368e493d Release v202611-00007 2026-11-09 16:53:51 +01:00
Richard ZinkandClaude Opus 5.5 011756ca3d Telegram bot as a chat interface (per user, paired, CLI-like commands)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:50:51 +01:00
24 changed files with 1695 additions and 21 deletions

No files matched your search

+37
View File
@@ -3,6 +3,43 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00009] – 2026-11-09
### Fixed
- **Sandbox on x86_64:** the start-up check of bubblewrap did not mount `/lib64`, so it failed
on amd64 hosts and shell commands of non-admin users were disabled.
## [v202611-00008] – 2026-11-09
### Added
- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules
that apply to every message of that chat on top of the mode – language, tone, the part of the
project to work on. A chip in the header shows when a chat has them; forks keep them.
### Fixed
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
preserve named conversation participants".
- Telegram: the token field is no longer filled in by the browser's password manager, the bot
token is found in whatever is pasted (also the whole BotFather message), and a wrong format
gets a clear message instead of "HTTP 404".
## [v202611-00007] – 2026-11-09
### Added
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
account – other chats are ignored). Messages, photos and files go to the current session; the
agent's steps show live, the answer comes formatted, images from tools come as photos, and
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
webhook needed.
## [v202611-00006] – 2026-11-09 ## [v202611-00006] – 2026-11-09
### Added ### Added
Generated
+1
View File
@@ -3040,6 +3040,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
+4 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00006" src="https://img.shields.io/badge/version-v202611--00006-d69e2e?style=flat-square"> <img alt="Version v202611-00009" src="https://img.shields.io/badge/version-v202611--00009-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -213,6 +213,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
## Integrations ## Integrations
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
bot lists the commands.
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client. - **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with - **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider `Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
+21 -2
View File
@@ -47,10 +47,13 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
Part::Image { image } => parts.push(json!({ Part::Image { image } => parts.push(json!({
"type": "image_url", "image_url": {"url": data_url(image)} "type": "image_url", "image_url": {"url": data_url(image)}
})), })),
Part::ToolResult { call_id, name, content, images, is_error, .. } => { Part::ToolResult { call_id, content, images, is_error, .. } => {
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() }; let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
// No "name": it is not part of the tool message in the Chat
// Completions API, and gateways that translate to Anthropic
// (opencode) reject named participants.
out.push(json!({ out.push(json!({
"role": "tool", "tool_call_id": call_id, "name": name, "role": "tool", "tool_call_id": call_id,
"content": if text.is_empty() { "(no output)".into() } else { text } "content": if text.is_empty() { "(no output)".into() } else { text }
})); }));
tool_images.extend(images.iter().cloned()); tool_images.extend(images.iter().cloned());
@@ -328,6 +331,22 @@ mod tests {
(text, reasoning) (text, reasoning)
} }
#[test]
fn tool_messages_have_no_name() {
use crate::types::{Message, Part, Role};
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
let msgs = vec![
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
Message::new(Role::Assistant, vec![call]),
Message::new(Role::User, vec![result]),
];
let wire = super::to_wire_messages("sys", &msgs);
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
assert_eq!(tool["tool_call_id"], "c1");
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
}
#[test] #[test]
fn splits_think_tags_across_chunks() { fn splits_think_tags_across_chunks() {
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]); let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
+3
View File
@@ -166,6 +166,9 @@ pub struct Session {
/// User-defined group in the session list. /// User-defined group in the session list.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>, pub folder: Option<String>,
/// Instructions the user set for this chat; added to the system prompt of every turn.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub instructions: String,
} }
impl Session { impl Session {
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Roll out a published CodeCraft image to a remote installation that was set up with
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
#
# scripts/release.sh # build and publish a new version (amd64 + arm64)
# scripts/deploy.sh # roll the newest tag out to the target
#
# Steps:
# 1. check that the image exists in the registry for the target's architecture,
# 2. wait until no agent or terminal runs on the target (unless --force),
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
# (or start.sh on hosts without Compose),
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
# started again.
#
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
# [--wait MINUTES] [--dry-run]
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
# --force restart even if agents or terminals are running (they end)
# --wait N how long to wait for an idle server before giving up (default 15)
# --dry-run show the steps only
#
# Environment:
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
# CONTAINER container name on the target (default codecraft)
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
# from the target's .env, else 8787)
set -euo pipefail
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
CONTAINER="${CONTAINER:-codecraft}"
tag="" target="" force=0 wait_min=15 dry=0
while (($#)); do
case "$1" in
--target) target="$2"; shift ;;
--force) force=1 ;;
--wait) wait_min="$2"; shift ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) tag="$1" ;;
esac
shift
done
cd "$(git rev-parse --show-toplevel)"
step() { printf '\n==> %s\n' "$*"; }
fail() { echo "error: $*" >&2; exit 1; }
q() { printf '%q' "$1"; }
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
target="${target:-$(env_get DEPLOY_TARGET || true)}"
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
host="${target%%:*}"
dir="${target#*:}"; dir="${dir%/}"
remote() { ssh -o BatchMode=yes "$host" "$1"; }
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
if [[ -z "$tag" ]]; then
git fetch --tags --quiet origin || true
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
[[ -n "$tag" ]] || fail "no release tag found"
fi
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
step "Checking $host:$dir and the image $REGISTRY:$tag"
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
case "$(remote 'uname -m')" in
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
esac
if [[ -n "$arch" ]]; then
docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null | grep -q "Platform:.*linux/$arch" \
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
fi
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
deadline=$(( $(date +%s) + wait_min * 60 ))
while busy; do
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
sleep 15
done
echo "idle"
fi
step "Updating to $tag"
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
step "Smoke test"
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
if (( dry )); then
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
else
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
if [[ -n "$current" && "$current" != "$tag" ]]; then
echo "smoke test failed – going back to $current" >&2
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
fi
fail "deploy of $tag failed"
fi
fi
step "Done"
echo "$host runs CodeCraft $tag."
+10 -5
View File
@@ -37,12 +37,15 @@
# --source-container C container to stop here (default codecraft) # --source-container C container to stop here (default codecraft)
# --source-volume V data volume here (default codecraft-data) # --source-volume V data volume here (default codecraft-data)
# --source-workspace D project folder here (default: WORKSPACE of .env) # --source-workspace D project folder here (default: WORKSPACE of .env)
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
# workspace, e.g. /old-project/ or /app/target/); repeatable
# --no-stop do not stop the source (only for tests – the copy may be inconsistent) # --no-stop do not stop the source (only for tests – the copy may be inconsistent)
# --dry-run show the steps only # --dry-run show the steps only
set -euo pipefail set -euo pipefail
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft" target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0 src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
excludes=()
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--workspace) ws_target="$2"; shift ;; --workspace) ws_target="$2"; shift ;;
@@ -56,9 +59,10 @@ while (($#)); do
--source-container) src_container="$2"; shift ;; --source-container) src_container="$2"; shift ;;
--source-volume) src_volume="$2"; shift ;; --source-volume) src_volume="$2"; shift ;;
--source-workspace) ws_source="$2"; shift ;; --source-workspace) ws_source="$2"; shift ;;
--exclude) excludes+=(--exclude "$2"); shift ;;
--no-stop) stop=0 ;; --no-stop) stop=0 ;;
--dry-run) dry=1 ;; --dry-run) dry=1 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;; -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;; -*) echo "unknown option $1" >&2; exit 2 ;;
*) target="$1" ;; *) target="$1" ;;
esac esac
@@ -68,7 +72,8 @@ done
cd "$(git rev-parse --show-toplevel)" cd "$(git rev-parse --show-toplevel)"
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; } [[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" .env | tail -1; } # Value of a .env variable, without surrounding quotes.
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
host="${target%%:*}" host="${target%%:*}"
dir="${target#*:}" dir="${target#*:}"
@@ -93,9 +98,9 @@ remote() {
} }
q() { printf '%q' "$1"; } q() { printf '%q' "$1"; }
sync_workspace() { sync_workspace() {
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/" if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
} }
put_file() { # put_file <remote path> < content put_file() { # put_file <remote path> < content
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
+1 -1
View File
@@ -28,7 +28,7 @@ shell-words = "1"
libc = "0.2" libc = "0.2"
ignore = "0.4" ignore = "0.4"
regex = "1" regex = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] } reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
rusqlite = { version = "0.41", features = ["bundled"] } rusqlite = { version = "0.41", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+66 -11
View File
@@ -138,6 +138,7 @@ pub fn router(app: AppState) -> Router {
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/account/modes", get(get_personal_modes).put(put_personal_modes)) .route("/account/modes", get(get_personal_modes).put(put_personal_modes))
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
.route("/templates", get(crate::cli_api::get_templates)) .route("/templates", get(crate::cli_api::get_templates))
.route("/templates/{scope}", put(crate::cli_api::put_templates)) .route("/templates/{scope}", put(crate::cli_api::put_templates))
.route("/cli/config", get(crate::cli_api::config)) .route("/cli/config", get(crate::cli_api::config))
@@ -327,6 +328,44 @@ async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Q
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id)))) Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
} }
/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden).
async fn get_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s = crate::telegram::load(&app, &user.id);
let (running, error) = crate::telegram::status(&user.id);
Ok(Json(json!({
"configured": !s.token.is_empty(),
"bot": s.bot,
"paired": s.chat_id.is_some(),
"pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() },
"running": running,
"error": error,
})))
}
#[derive(Deserialize)]
struct TelegramBody {
token: String,
}
/// Connect a bot: check the token, start polling and create a pairing code.
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
let token = crate::telegram::extract_token(&b.token)
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
app.store.set_user_setting(&user.id, "telegram", &s)?;
crate::telegram::restart(&app, &user.id);
app.audit(Some(&user), "telegram.connect", &s.bot, "");
get_telegram(Extension(user), State(app)).await
}
async fn delete_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
crate::telegram::stop(&user.id);
app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?;
app.audit(Some(&user), "telegram.disconnect", "", "");
Ok(Json(json!({"ok": true})))
}
/// The user's personal modes (name -> settings). /// The user's personal modes (name -> settings).
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(prompts::personal_modes(&app, &user.id)))) Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
@@ -535,6 +574,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(),
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -743,12 +783,17 @@ struct UpdateSession {
archived: Option<bool>, archived: Option<bool>,
/// "" removes the folder. /// "" removes the folder.
folder: Option<String>, folder: Option<String>,
/// Instructions for this chat ("" clears them).
instructions: Option<String>,
} }
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult { async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
writable(&app, &user, &id)?; writable(&app, &user, &id)?;
// Organising the list must not move the session to "today". // Organising the list must not move the session to "today".
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none(); let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none();
if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into()));
}
if let Some(m) = &body.model { if let Some(m) = &body.model {
app.registry().resolve(m)?; app.registry().resolve(m)?;
} }
@@ -785,6 +830,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(p) = body.permission_mode { if let Some(p) = body.permission_mode {
s.permission_mode = p; s.permission_mode = p;
} }
if let Some(i) = body.instructions {
s.instructions = i.trim().to_string();
}
})?; })?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
} }
@@ -858,41 +906,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult { async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
let session = writable(&app, &user, &id)?; let session = writable(&app, &user, &id)?;
let text = body.text.trim().to_string(); send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json)
}
/// Handle a user message for a session (web UI and Telegram): built-in commands,
/// slash command expansion, then start or queue the turn.
pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
let id = session.id.clone();
let text = text.trim().to_string();
// Built-in commands. // Built-in commands.
if text == "/compact" { if text == "/compact" {
if app.is_busy(&id) { if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
} }
tokio::spawn(do_compact(app.clone(), id.clone())); tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(Json(json!({"ok": true, "command": "compact"}))); return Ok(json!({"ok": true, "command": "compact"}));
} }
let cwd = PathBuf::from(&session.cwd); let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone(); let mut prompt_text = text.clone();
if text.starts_with('/') { if text.starts_with('/') {
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) { if let Some((expanded, agent, clean_room)) = prompts::expand_command(app, &root, &cwd, &text) {
prompt_text = expanded; prompt_text = expanded;
if agent.is_some() || clean_room.is_some() { if agent.is_some() || clean_room.is_some() {
let mut s = session.clone(); app.update_session(&id, |s| {
if let Some(a) = agent { if let Some(a) = agent {
s.agent = a; s.agent = a;
} }
if clean_room.is_some() { if clean_room.is_some() {
s.clean_room = clean_room; s.clean_room = clean_room;
} }
app.save_session(s)?; })?;
} }
} }
} }
set_turn_effort(&app, &id, body.effort.as_deref())?; set_turn_effort(app, &id, effort)?;
let mut parts = vec![Part::Text { text: prompt_text }]; let mut parts = vec![Part::Text { text: prompt_text }];
for image in body.images { for image in images {
parts.push(Part::Image { image }); parts.push(Part::Image { image });
} }
match runner::submit(app.clone(), id, parts, "user")? { match runner::submit(app.clone(), id, parts, "user")? {
runner::Submitted::Started => Ok(Json(json!({"ok": true}))), runner::Submitted::Started => Ok(json!({"ok": true})),
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))), runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
} }
} }
@@ -916,7 +971,7 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?}))) Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
} }
async fn do_compact(app: AppState, id: String) { pub(crate) async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new(); let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone())); app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true }); app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
+1
View File
@@ -164,6 +164,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(),
}; };
let session = app.save_session(session)?; let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?; app.store.job_started(&job.id, &session.id, now)?;
+2
View File
@@ -30,6 +30,7 @@ mod runner;
mod sandbox; mod sandbox;
mod shells; mod shells;
mod store; mod store;
mod telegram;
mod terminal; mod terminal;
mod tools; mod tools;
mod types; mod types;
@@ -73,6 +74,7 @@ async fn main() -> Result<()> {
health::spawn_checker(app.clone()); health::spawn_checker(app.clone());
digest::spawn(app.clone()); digest::spawn(app.clone());
budget::spawn_alerts(app.clone()); budget::spawn_alerts(app.clone());
telegram::spawn(app.clone());
backup::spawn(app.clone()); backup::spawn(app.clone());
let router = api::router(app); let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?; let listener = tokio::net::TcpListener::bind(&bind).await?;
+11
View File
@@ -216,6 +216,17 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
/// The plain chat mode: no coding instructions, project files or memory – only the /// The plain chat mode: no coding instructions, project files or memory – only the
/// date, the model and what the user keeps in their personal memory. /// date, the model and what the user keeps in their personal memory.
/// Add the instructions the user set for this chat (they come last, so they win).
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
if !instructions.trim().is_empty() {
system.push_str(&format!(
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
instructions.trim()
));
}
system
}
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String { fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
let mut s = agent.prompt.clone(); let mut s = agent.prompt.clone();
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d"))); s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
+2 -1
View File
@@ -293,7 +293,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let tools = available_tools(&app, &agent, owner.as_ref()); let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?); let history = active_history(app.store.messages(session_id)?);
let req = ChatRequest { let req = ChatRequest {
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
messages: history, messages: history,
tools: tools.iter().map(|t| t.def()).collect(), tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000), max_tokens: model.max_output.unwrap_or(16_000),
@@ -704,6 +704,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(),
}; };
let child = app.save_session(child)?; let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
+2
View File
@@ -37,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
} }
let res = match Command::new("bwrap") let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"]) .args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"]) .args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear() .env_clear()
.output() .output()
File diff suppressed because it is too large. Load diff
+18
View File
@@ -13,6 +13,7 @@ import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer"; import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog"; import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog"; import { RenameDialog } from "./RenameDialog";
import { InstructionsDialog } from "./InstructionsDialog";
import { RegenerateButton } from "./Regenerate"; import { RegenerateButton } from "./Regenerate";
import { WorktreeChip } from "./WorktreeChip"; import { WorktreeChip } from "./WorktreeChip";
import { QueueList } from "./QueueList"; import { QueueList } from "./QueueList";
@@ -151,6 +152,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const isAdmin = useStore((s) => s.me?.role === "admin"); const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
const [editingInstructions, setEditingInstructions] = useState(false);
const models = useModels(); const models = useModels();
const [loadError, setLoadError] = useState<string>(); const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
@@ -307,6 +309,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ShellsChip sessionId={sessionId} busy={busy} /> <ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />} {isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>} {session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{!!session.instructions && (
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
<Icon name="edit" /> {t("instructions.chip")}
</button>
)}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>} {readOnly && <span className="chip">{t("chat.readOnly")}</span>}
{!readOnly && ( {!readOnly && (
<> <>
@@ -335,6 +342,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="more" /> <Icon name="more" />
</summary> </summary>
<div className="menu-pop-body"> <div className="menu-pop-body">
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setEditingInstructions(true);
}}
>
<Icon name="edit" /> {t("instructions.title")}
</a>
<a href={`/api/sessions/${sessionId}/export?format=md`} download> <a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")} <Icon name="download" /> {t("chat.exportMd")}
</a> </a>
@@ -527,6 +544,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</div> </div>
)} )}
</div> </div>
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />} {editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />} {sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />} {rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from "react";
import { patch } from "../api";
import { useT } from "../i18n";
/** Instructions for one chat: added to the system prompt of every turn. */
export function InstructionsDialog({ sessionId, value, onClose }: { sessionId: string; value: string; onClose: () => void }) {
const t = useT();
const [text, setText] = useState(value);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const save = async (next: string) => {
setBusy(true);
try {
await patch(`/sessions/${sessionId}`, { instructions: next });
onClose();
} catch (e: any) {
setError(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal instructions-modal" role="dialog" aria-label={t("instructions.title")} onClick={(e) => e.stopPropagation()}>
<h3>{t("instructions.title")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("instructions.hint")}
</div>
<textarea
autoFocus
rows={10}
style={{ width: "100%" }}
value={text}
placeholder={t("instructions.placeholder")}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => (e.ctrlKey || e.metaKey) && e.key === "Enter" && !busy && void save(text)}
/>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end", gap: 6 }}>
{value && (
<button className="danger" onClick={() => void save("")} disabled={busy}>
{t("instructions.clear")}
</button>
)}
<span className="spacer" />
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" onClick={() => void save(text)} disabled={busy || text.trim() === value.trim()}>
{t("common.save")}
</button>
</div>
</div>
</div>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens"; import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall"; import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory"; import { Memory } from "./Memory";
import { Telegram } from "./Telegram";
export function Account() { export function Account() {
const t = useT(); const t = useT();
@@ -51,6 +52,7 @@ export function Account() {
<Memory /> <Memory />
<GitAccount /> <GitAccount />
<CliInstall /> <CliInstall />
<Telegram />
<ApiTokens /> <ApiTokens />
</> </>
); );
+104
View File
@@ -0,0 +1,104 @@
import { useState } from "react";
import { del, put } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import { Icon } from "../Icon";
interface Status {
configured: boolean;
bot: string;
paired: boolean;
pair_code: string;
running: boolean;
error?: string | null;
}
/** Connect a personal Telegram bot as a chat interface. */
export function Telegram() {
const t = useT();
const { data, reload } = useFetch<Status>("/account/telegram");
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const connect = async () => {
setBusy(true);
try {
await put("/account/telegram", { token: token.trim() });
setToken("");
reload();
} catch (e) {
toast(e);
} finally {
setBusy(false);
}
};
const disconnect = async () => {
if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return;
await del("/account/telegram").catch((e) => toast(e));
reload();
};
return (
<div className="panel">
<h3>
<Icon name="share" /> {t("telegram.title")}
<span className="spacer" />
{data?.configured && (
<button className="small ghost danger" onClick={disconnect}>
{t("telegram.disconnect")}
</button>
)}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("telegram.intro")}
</div>
{data && !data.configured && (
<>
<ol className="cli-steps">
<li>{t("telegram.step1")}</li>
<li>{t("telegram.step2")}</li>
</ol>
<div className="row" style={{ gap: 6 }}>
<input
type="text"
name="telegram-bot-token"
value={token}
placeholder="123456789:AAH…"
autoComplete="off"
spellCheck={false}
className="mono"
onChange={(e) => setToken(e.target.value)}
style={{ flex: 1 }}
/>
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
{busy ? t("telegram.checking") : t("telegram.connect")}
</button>
</div>
</>
)}
{data?.configured && (
<>
<div className="row" style={{ gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<span className="chip">@{data.bot}</span>
<span className={`chip ${data.running && !data.error ? "ok" : "warn"}`}>{data.running && !data.error ? t("telegram.running") : t("telegram.stopped")}</span>
{data.paired && <span className="chip ok">{t("telegram.paired")}</span>}
</div>
{!data.paired && data.pair_code && (
<div className="notice" style={{ marginTop: 10 }}>
{t("telegram.pairHint", { bot: data.bot })}{" "}
<a href={`https://t.me/${data.bot}?start=${data.pair_code}`} target="_blank" rel="noreferrer">
t.me/{data.bot}
</a>
<div style={{ marginTop: 6 }}>
<code className="token-value">/start {data.pair_code}</code>
</div>
</div>
)}
{data.error && <div className="notice err" style={{ marginTop: 10 }}>{data.error}</div>}
{data.paired && <div className="hint" style={{ marginTop: 10 }}>{t("telegram.commands")}</div>}
</>
)}
</div>
);
}
+18
View File
@@ -405,6 +405,19 @@ export const de: Messages = {
"tokens.title": "API-Tokens", "tokens.title": "API-Tokens",
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).", "tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:", "tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
"telegram.title": "Telegram",
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
"telegram.step2": "Das Token von BotFather hier einfügen.",
"telegram.connect": "Verbinden",
"telegram.checking": "Prüfe…",
"telegram.running": "läuft",
"telegram.stopped": "läuft nicht",
"telegram.paired": "gekoppelt",
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
"telegram.disconnect": "Trennen",
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
"tokens.copy": "Kopieren", "tokens.copy": "Kopieren",
"tokens.copied": "Kopiert", "tokens.copied": "Kopiert",
"tokens.name": "Name", "tokens.name": "Name",
@@ -580,6 +593,11 @@ export const de: Messages = {
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert", "chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert", "chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter", "chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"instructions.title": "Anweisungen für diesen Chat",
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
"instructions.clear": "Entfernen",
"instructions.chip": "Anweisungen",
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung", "chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
"chat.context": "Kontext", "chat.context": "Kontext",
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage", "chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
+18
View File
@@ -404,6 +404,19 @@ export const en = {
"tokens.title": "API tokens", "tokens.title": "API tokens",
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).", "tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
"tokens.copyNow": "Copy the token now – it is shown only once:", "tokens.copyNow": "Copy the token now – it is shown only once:",
"telegram.title": "Telegram",
"telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.",
"telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.",
"telegram.step2": "Paste the token BotFather gives you here.",
"telegram.connect": "Connect",
"telegram.checking": "Checking…",
"telegram.running": "running",
"telegram.stopped": "not running",
"telegram.paired": "paired",
"telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:",
"telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …",
"telegram.disconnect": "Disconnect",
"telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.",
"tokens.copy": "Copy", "tokens.copy": "Copy",
"tokens.copied": "Copied", "tokens.copied": "Copied",
"tokens.name": "Name", "tokens.name": "Name",
@@ -579,6 +592,11 @@ export const en = {
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically", "chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
"chat.compactingManual": "Compacting the conversation", "chat.compactingManual": "Compacting the conversation",
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards", "chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
"instructions.title": "Instructions for this chat",
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
"instructions.clear": "Remove",
"instructions.chip": "Instructions",
"chat.compacted": "Conversation compacted – summary", "chat.compacted": "Conversation compacted – summary",
"chat.context": "Context", "chat.context": "Context",
"chat.contextHint": "{tokens} tokens in the last request", "chat.contextHint": "{tokens} tokens in the last request",
+3
View File
@@ -3770,3 +3770,6 @@ span.chat-tab > button {
display: none; display: none;
} }
} }
.instructions-modal {
width: min(640px, calc(100vw - 32px));
}
+2
View File
@@ -67,6 +67,8 @@ export interface Session {
pinned?: boolean; pinned?: boolean;
archived?: boolean; archived?: boolean;
folder?: string | null; folder?: string | null;
/** Instructions for this chat (added to the system prompt). */
instructions?: string;
} }
export interface QuestionOption { export interface QuestionOption {
+8
View File
@@ -3,6 +3,14 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00008": {
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
},
"202611-00007": {
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
},
"202611-00006": { "202611-00006": {
en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"], en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"],
de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"], de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],