Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247ddcf14a | ||
|
|
f0cd404f59 | ||
|
|
ec95f9b8ad | ||
|
|
99ba3835f3 | ||
|
|
92a99c6a65 | ||
|
|
4c7d67cabf |
No files matched your search
@@ -3,6 +3,27 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00032] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the
|
||||
environment for them; the system prompt explains `git push` just works and how to read the token
|
||||
for REST calls without printing it. Claude Code runs get the user's git credentials and identity too.
|
||||
|
||||
## [v202611-00031] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
|
||||
|
||||
## [v202611-00030] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
|
||||
the cut. Also starts a fresh session with Claude Code.
|
||||
|
||||
## [v202611-00029] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00029" src="https://img.shields.io/badge/version-v202611--00029-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00032" src="https://img.shields.io/badge/version-v202611--00032-d69e2e?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="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
@@ -1128,6 +1128,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
|
||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||
return Ok(json!({"ok": true, "command": "compact"}));
|
||||
}
|
||||
if text == "/clear" {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||
return Ok(json!({"ok": true, "command": "clear"}));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::config::ModelConfig;
|
||||
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
|
||||
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||
use crate::store::UsageRecord;
|
||||
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||
use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
@@ -97,7 +97,14 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
|
||||
c.env(k, v);
|
||||
}
|
||||
}
|
||||
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args);
|
||||
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args);
|
||||
// The user's git credentials and identity (the CLI runs git for the agent).
|
||||
if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) {
|
||||
if let Some(cfg) = crate::gitcreds::global_config(app, &user) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
@@ -286,6 +293,20 @@ fn result_text(content: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Images inside a tool result (the CLI's Read of a picture): base64 blocks.
|
||||
fn result_images(content: &Value) -> Vec<ImageData> {
|
||||
content
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|b| b["type"] == "image" && b["source"]["type"] == "base64")
|
||||
.filter_map(|b| {
|
||||
let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?;
|
||||
Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
|
||||
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
||||
use base64::Engine;
|
||||
@@ -343,8 +364,12 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
if let Some(r) = &resume {
|
||||
args.extend(["--resume".into(), r.clone()]);
|
||||
}
|
||||
if !session.instructions.trim().is_empty() {
|
||||
args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]);
|
||||
let mut appended = session.instructions.trim().to_string();
|
||||
if let Some(git) = owner.as_deref().and_then(|u| crate::gitcreds::prompt_section(&app, u)) {
|
||||
appended = format!("{appended}\n{git}").trim().to_string();
|
||||
}
|
||||
if !appended.is_empty() {
|
||||
args.extend(["--append-system-prompt".into(), appended]);
|
||||
}
|
||||
|
||||
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
|
||||
@@ -458,7 +483,7 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
||||
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
||||
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
|
||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta });
|
||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let m = Message::new(Role::User, parts);
|
||||
@@ -852,6 +877,15 @@ pub async fn update() -> Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn tool_result_images_are_kept() {
|
||||
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
|
||||
let imgs = result_images(&content);
|
||||
assert_eq!(imgs.len(), 1);
|
||||
assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD"));
|
||||
assert!(result_images(&json!("plain text")).is_empty());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||
env
|
||||
}
|
||||
|
||||
/// What an agent needs to know about the stored git access (no secrets): git already has the
|
||||
/// credentials, so it must not look for them. None when nothing is stored.
|
||||
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
|
||||
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
|
||||
}
|
||||
|
||||
fn render_prompt(s: &GitSettings) -> Option<String> {
|
||||
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
|
||||
if creds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
|
||||
for c in &creds {
|
||||
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
|
||||
}
|
||||
t.push_str(
|
||||
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
|
||||
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
|
||||
Use https:// remote URLs (SSH keys are not set up). \
|
||||
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
|
||||
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
|
||||
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
|
||||
);
|
||||
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
|
||||
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
|
||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||
app.data_dir().join("users").join(&user.username).join("home")
|
||||
}
|
||||
@@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_names_hosts_but_never_tokens() {
|
||||
assert!(render_prompt(&GitSettings::default()).is_none());
|
||||
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
|
||||
let p = render_prompt(&settings).unwrap();
|
||||
println!("{p}");
|
||||
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
|
||||
assert!(!p.contains("s3cret-token"));
|
||||
// Entries without a token are not offered.
|
||||
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
|
||||
assert!(render_prompt(&none).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_masked_tokens() {
|
||||
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
|
||||
|
||||
@@ -298,6 +298,9 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
|
||||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) {
|
||||
s.push_str(&git);
|
||||
}
|
||||
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
|
||||
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
|
||||
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
|
||||
|
||||
@@ -1723,10 +1723,43 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
|
||||
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
|
||||
|
||||
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
|
||||
/// compaction without a summary: history before the marker is not sent any more.
|
||||
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
|
||||
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
|
||||
app.update_session(session_id, |s| {
|
||||
s.last_usage = None;
|
||||
s.todos = Value::Null;
|
||||
})?;
|
||||
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||
// Claude Code continues its own conversation: start a new one.
|
||||
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
|
||||
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cleared_marker_starts_a_new_history() {
|
||||
let mk = |role, parts| Message::new(role, parts);
|
||||
let all = vec![
|
||||
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
|
||||
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
|
||||
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
|
||||
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
|
||||
];
|
||||
let active = active_history(all);
|
||||
assert_eq!(active.len(), 2);
|
||||
assert!(!active.iter().any(|m| message_text(m).contains("old")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_keeps_head_and_tail_within_budget() {
|
||||
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
|
||||
|
||||
@@ -459,13 +459,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
||||
return (
|
||||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary>
|
||||
{summary.auto
|
||||
{summary.text.startsWith("[Context cleared")
|
||||
? t("chat.cleared")
|
||||
: summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
{summaryOpen && <Markdown text={summary.text} />}
|
||||
{summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Suggestion {
|
||||
|
||||
const BUILTIN: { name: string; key: Key }[] = [
|
||||
{ name: "compact", key: "command.compact" },
|
||||
{ name: "clear", key: "command.clear" },
|
||||
{ name: "new", key: "command.new" },
|
||||
];
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
const open = toggled ?? openByDefault;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1144,4 +1144,6 @@ export const de: Messages = {
|
||||
"stats.table.agent": "nach Modus",
|
||||
"stats.table.user": "nach Benutzer",
|
||||
"stats.table.session": "teuerste Chats",
|
||||
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
|
||||
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
|
||||
};
|
||||
@@ -1143,6 +1143,8 @@ export const en = {
|
||||
"stats.table.agent": "by agent",
|
||||
"stats.table.user": "by user",
|
||||
"stats.table.session": "top chats",
|
||||
"command.clear": "Forget the conversation so far (the chat stays visible)",
|
||||
"chat.cleared": "Context cleared – the model no longer sees the messages above",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
@@ -3,6 +3,18 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00032": {
|
||||
en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"],
|
||||
de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"],
|
||||
},
|
||||
"202611-00031": {
|
||||
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
|
||||
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
|
||||
},
|
||||
"202611-00030": {
|
||||
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
|
||||
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
|
||||
},
|
||||
"202611-00029": {
|
||||
en: [
|
||||
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
|
||||
|
||||
Reference in new issue
Block a user