diff --git a/docs/features.md b/docs/features.md index b33005eb..f96ad93a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -33,7 +33,7 @@ headline differentiators, see the top-level [README](../README.md). - **Model-aware steering**: the harness detects the active model family and tailors guidance to it. DeepSeek **chat** models (v3/v4) get an extra preamble fragment — a Plan-Execute-Verify working method, structural-constraint framing (name files/functions/order, not "be modular"), an explicit success/never contract, and an anti-repetition rule (accept an errored or truncated tool result and adapt rather than re-issuing the same call) — appended last so it sits closest to the action boundary, where rules resist drift in long tool-calling loops. Other models, and the DeepSeek reasoner (which ignores the system prompt), are unaffected. Baked-in and automatic; no config key. See [prompts.md](prompts.md#model-aware-steering). - **Subagent support**: `task` tool spawns a subagent for research or general analysis subtasks. Optionally run the subagent under a named [agent profile](agents.md) (`task(agent="")`) to give it its own model + system prompt, and opt a profile into **tooled subagents** (`subagent.tools: readonly` for read/grep/glob, or `readwrite` to also let the subagent edit/write/bash the repo directly), plus grant selected [MCP tools](mcp.md) via `subagent_mcp`. - **Background shells** (Claude-Code-style): the `bash` tool accepts `background: true` to run a command **detached and unbounded** (for dev servers, watch builds, long-running jobs) — it returns immediately with a shell id. The model reads accumulated output incrementally with the **`bash_output`** tool and stops the shell with **`kill_shell`** (both take the id); an optional `timeout` auto-kills after N seconds. Shells are tracked in a dedicated registry, capped at 8 concurrent, listed by `/tasks`, and killed when the session ends. The status bar shows live counts when any are running: `agents:N` (background subagents) and `shells:N` (background shells). -- **Memory & self-improvement**: persistent per-project memory in the session DB (`.dirge/sessions/state.db`, `memories` table — facts under the `memory` target, anti-patterns under `pitfalls`), injected into the system prompt as a frozen snapshot. Memory is two-tiered: hot entries inline verbatim; when the inline budget fills, the least-salient entries demote to a breadcrumb index (one line of id + preview each) that the agent dereferences with `memory(action='expand')` or queries with `memory(action='search')` (FTS5). Removal archives (tombstones) rather than deletes — `restore` brings entries back. Legacy `MEMORY.md`/`PITFALLS.md` files are imported automatically on first load and parked as `*.imported`. A **global, cross-project memory tier** (a single store in the user data dir, default on) holds durable user preferences that follow you across every repo; it's injected into the prompt under its own header, and the `memory` tool writes/searches it with `scope: "global"` (default scope stays project). After an idle session, a unified post-session orchestrator runs (in order, fire-and-forget): a background review that extracts learnings into memory + skills, then a skills curator and a memory curator (stale-detection + lifecycle + LLM consolidation, with audit reports under each store's `.curator_reports/`). Procedural memories can carry a **falsifiable expectation** — a trigger plus the outcome expected when it fires (a command that should have run, or the run ending verified-green) — settled deterministically in the post-session pass against the session digest and the run's verification status, with no LLM in that path. Success, failure, and "the situation never arose" stay three distinct verdicts: collapsing the last two would make the expectation impossible to refute, and only a refutable one is worth recording. Memories without an expectation are untouched, and settling only moves the existing bounded effectiveness counters — it never deletes. +- **Memory & self-improvement**: persistent per-project memory in the session DB (`.dirge/sessions/state.db`, `memories` table — facts under the `memory` target, anti-patterns under `pitfalls`), injected into the system prompt as a frozen snapshot. Memory is two-tiered: hot entries inline verbatim; when the inline budget fills, the least-salient entries demote to a breadcrumb index (one line of id + preview each) that the agent dereferences with `memory(action='expand')` or queries with `memory(action='search')` (FTS5). Removal archives (tombstones) rather than deletes — `restore` brings entries back. Legacy `MEMORY.md`/`PITFALLS.md` files are imported automatically on first load and parked as `*.imported`. A **global, cross-project memory tier** (a single store in the user data dir, default on) holds durable user preferences that follow you across every repo; it's injected into the prompt under its own header, and the `memory` tool writes/searches it with `scope: "global"` (default scope stays project). After an idle session, a unified post-session orchestrator runs (in order, fire-and-forget): a background review that extracts learnings into memory + skills, then a skills curator and a memory curator (stale-detection + lifecycle + LLM consolidation, with audit reports under each store's `.curator_reports/`). Procedural memories can carry a **falsifiable expectation** — a trigger plus the outcome expected when it fires (a command that should have run, or the run ending verified-green) — settled deterministically in the post-session pass against the session digest and the run's verification status, with no LLM in that path. Success, failure, and "the situation never arose" stay three distinct verdicts: collapsing the last two would make the expectation impossible to refute, and only a refutable one is worth recording. Memories without an expectation are untouched, and settling only moves the existing bounded effectiveness counters — it never deletes. Inspect what is stored with `/memory`, and change it with `/memory edit`, which opens the whole store in `$EDITOR` — reword a line to reword the memory, delete a block to forget it (archived, not destroyed), add a block to record something new. Each entry is anchored on its id so an edit is an in-place update: lineage, use counts, confidence and the procedural outcome counters survive being reworded. - **MCP support**: connect MCP servers for extended tooling (optional compile-time feature). - **dirge as an MCP server** (`dirge mcp`): run dirge itself as an MCP server so another agent (e.g. Claude Code) can **delegate implementation tasks to dirge and review them** — the caller plans/architects, dirge implements. Keeps a persistent per-project session (`delegate` extends it, `new_session` rotates); each delegation returns a summary + the files it changed for review. Built into the binary (`mcp-server` feature, default on). See [mcp-server.md](mcp-server.md). - **File-state rewind**: `Esc-Esc` opens the rewind picker, which rolls back the working tree, not just the conversation. Every write/edit/edit_lines/apply_patch (incl. delete/rename) snapshots the touched file's pre-mutation content keyed by the triggering user prompt; rewinding to a prompt restores all files to their pre-prompt state in lockstep with the conversation truncation, so a long autonomous run is safe to unwind. A file created in the rewound region is deleted on restore; a deleted file is recreated. Content is deduplicated through a content-addressed pool. In-memory and process-scoped (works within a live session, not across a restart). diff --git a/src/extras/memory_db.rs b/src/extras/memory_db.rs index 8a970dcc..e5a594d6 100644 --- a/src/extras/memory_db.rs +++ b/src/extras/memory_db.rs @@ -308,6 +308,24 @@ fn truncate_for_error(s: &str) -> String { // ── Store ──────────────────────────────────────────────────────────── +/// One entry as `/memory` shows it. +/// +/// Carries the uid because that is what `/memory edit` anchors on, and the +/// cheap usefulness signals (`tier`, `use_count`) so a reader can tell an +/// inlined fact the agent leans on from a breadcrumb it has never touched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowseEntry { + pub uid: String, + /// `"memory"` or `"pitfalls"`. + pub target: String, + pub kind: String, + pub content: String, + /// `"hot"` (inlined in the prompt) or `"breadcrumb"` (index only). + pub tier: String, + pub use_count: i64, + pub updated_at: String, +} + /// One active row, as the matching/eviction logic sees it. #[derive(Clone)] struct ActiveRow { @@ -1222,6 +1240,29 @@ impl SqliteMemoryStore { .map_err(|e| format!("Failed to begin transaction: {e}"))?; let rows = Self::active_rows(&tx, target)?; let idx = find_unique_match(&rows, old_text)?; + Self::apply_replacement(&tx, target, &rows, idx, &new_entry, kind)?; + tx.commit().map_err(|e| format!("Failed to commit: {e}"))?; + Ok(()) + } + + /// The in-place half of a replace, once the target row is known. + /// + /// Split out so `replace_entry` (matched by substring) and + /// `replace_entry_by_uid` (matched by stable id, used by `/memory edit`) + /// share one definition of what replacing means. The distinction that + /// matters is preserved here: this UPDATEs the row, so uid, lineage, + /// `created_at`, `use_count` and `confidence` all survive. Only a + /// *re-classification* resets the outcome counters, and only because a + /// procedural entry re-kinded to semantic must not keep a track record + /// that no longer applies to it. + fn apply_replacement( + tx: &Connection, + target: &str, + rows: &[ActiveRow], + idx: usize, + new_entry: &str, + kind: Option, + ) -> Result<(), String> { let id = rows[idx].id; // dirge-catw: reject a replacement that duplicates a DIFFERENT active @@ -1267,7 +1308,7 @@ impl SqliteMemoryStore { .map_err(|e| format!("Failed to reindex entry: {e}"))?; tx.execute( "INSERT INTO memories_fts(rowid, content) VALUES (?1, ?2)", - params![id, redact_for_fts(&new_entry)], + params![id, redact_for_fts(new_entry)], ) .map_err(|e| format!("Failed to reindex entry: {e}"))?; @@ -1280,19 +1321,116 @@ impl SqliteMemoryStore { let effective_kind = kind.unwrap_or_else(|| parse_kind(&rows[idx].kind).unwrap_or_default()); let demoted = Self::make_room_in_hot( - &tx, + tx, target, 0, matches!(effective_kind, MemoryKind::Working), )?; if demoted > 0 { - Self::compact_breadcrumbs(&tx, target)?; + Self::compact_breadcrumbs(tx, target)?; } } - tx.commit().map_err(|e| format!("Failed to commit: {e}"))?; Ok(()) } + /// Every active entry across both targets, for `/memory` and + /// `/memory edit`. Ordered target-then-id so the rendering is stable + /// between runs — an editable document that reshuffles itself is + /// unreviewable. + pub fn list_all(&self) -> Result, String> { + let conn = self.conn.lock_ignore_poison(); + let mut stmt = conn + .prepare( + "SELECT uid, target, kind, content, tier, use_count, updated_at + FROM memories WHERE status = 'active' ORDER BY target, id", + ) + .map_err(|e| format!("Failed to read memories: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok(BrowseEntry { + uid: row.get(0)?, + target: row.get(1)?, + kind: row.get(2)?, + content: row.get(3)?, + tier: row.get(4)?, + use_count: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| format!("Failed to read memories: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Failed to read memories: {e}"))?; + Ok(rows) + } + + /// Reword an entry identified by its stable uid. + /// + /// The uid is what makes `/memory edit` safe: matching on content would + /// mean an edit could not find the row it just changed, and re-creating + /// the row would silently reset `use_count`, `confidence`, the procedural + /// outcome counters and the supersession chain. This is an UPDATE, so all + /// of that survives. + // Unix-only: reached solely from `/memory edit`, which needs $EDITOR. + #[cfg(unix)] + pub fn replace_entry_by_uid( + &self, + uid: &str, + new_entry: &str, + kind: Option, + ) -> Result<(), String> { + scan_for_threats(new_entry)?; + let trimmed = new_entry.trim(); + if trimmed.is_empty() { + return Err("Cannot replace with empty entry".to_string()); + } + let new_entry = redact_for_fts(trimmed); + + let mut conn = self.conn.lock_ignore_poison(); + let tx = conn + .transaction() + .map_err(|e| format!("Failed to begin transaction: {e}"))?; + + for target in ["memory", "pitfalls"] { + let rows = Self::active_rows(&tx, target)?; + if let Some(idx) = rows.iter().position(|r| r.uid == uid) { + let char_limit = char_limit_for(target); + if new_entry.len() > char_limit { + return Err(format!( + "Entry is {} chars but the entire memory budget is {char_limit}; \ + split it into smaller entries.", + new_entry.len(), + )); + } + Self::apply_replacement(&tx, target, &rows, idx, &new_entry, kind)?; + tx.commit().map_err(|e| format!("Failed to commit: {e}"))?; + return Ok(()); + } + } + Err(format!("No active memory with id {uid}")) + } + + /// Tombstone an entry by uid. Archives rather than deletes, exactly like + /// `remove_entry`, so `restore` can still bring it back — deleting a + /// line in an editor should not be more destructive than the tool's own + /// removal. + // Unix-only: reached solely from `/memory edit`, which needs $EDITOR. + #[cfg(unix)] + pub fn remove_entry_by_uid(&self, uid: &str) -> Result<(), String> { + let mut conn = self.conn.lock_ignore_poison(); + let tx = conn + .transaction() + .map_err(|e| format!("Failed to begin transaction: {e}"))?; + for target in ["memory", "pitfalls"] { + let rows = Self::active_rows(&tx, target)?; + if let Some(row) = rows.iter().find(|r| r.uid == uid) { + Self::tombstone_row(&tx, row.id)?; + tx.commit().map_err(|e| format!("Failed to commit: {e}"))?; + return Ok(()); + } + } + Err(format!("No active memory with id {uid}")) + } + /// Supersede an active entry with a newer fact (dirge-fa10). Where /// `replace` is an in-place UPDATE for a reworded SAME fact (keeps /// uid, lineage, outcome record), supersession is for a diff --git a/src/ui/external_editor.rs b/src/ui/external_editor.rs new file mode 100644 index 00000000..6c71d149 --- /dev/null +++ b/src/ui/external_editor.rs @@ -0,0 +1,104 @@ +//! Hand a block of text to `$EDITOR` and read back what the user saved. +//! +//! Extracted from `Input::open_in_external_editor` so `/edit` and the memory +//! review share one implementation. The tricky parts are all here: +//! +//! * The temp file is created with `create_new` (`O_EXCL`), so a symlink +//! pre-planted at the predictable path fails the open instead of being +//! written through. +//! * dirge points fds 1/2 at its log file for the TUI session. They are +//! redirected to `/dev/tty` for the child's lifetime and restored after, +//! or the editor draws into the log. +//! * The path is passed as a positional arg via `"$@"` rather than +//! interpolated, so spaces and metacharacters in it cannot break out. +//! `$EDITOR` itself still word-splits, which is what makes +//! `EDITOR="code --wait"` work. +//! +//! The caller MUST suspend the TUI first — see +//! [`suspend_tui_for_subprocess`](crate::ui::terminal::suspend_tui_for_subprocess). + +#[cfg(unix)] +use std::io::Write; + +/// Open `$EDITOR` on `seed` and return the saved contents. +/// +/// `tag` distinguishes concurrent temp files and gives the editor a useful +/// filename to syntax-highlight from (e.g. `"input"`, `"memory-review"`). +/// +/// Returns `None` if the editor could not be spawned or exited non-zero — +/// which is the deliberate way to abort (`:cq` in vim). Callers must treat +/// `None` as "change nothing"; a failed edit must never be read as an empty +/// document, or aborting would silently discard the caller's data. Errors +/// are reported to the user before returning. +#[cfg(unix)] +pub(crate) fn edit_text(seed: &str, tag: &str) -> Option { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + + let path = std::env::temp_dir().join(format!("dirge-{tag}-{}.md", std::process::id())); + let _ = std::fs::remove_file(&path); + let write_result = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .and_then(|mut f| f.write_all(seed.as_bytes())); + if let Err(e) = write_result { + crate::ui::notifications::notify_send(crate::ui::notifications::Notification::Error( + format!("External editor: failed to write temp file: {e}"), + )); + return None; + } + + let saved: Option<(i32, i32, i32)> = unsafe { + let tty = libc::open(c"/dev/tty".as_ptr(), libc::O_RDWR); + if tty < 0 { + None + } else { + let so = libc::dup(1); + let se = libc::dup(2); + libc::dup2(tty, 1); + libc::dup2(tty, 2); + Some((tty, so, se)) + } + }; + + let status = std::process::Command::new("sh") + .arg("-c") + .arg(format!("{editor} \"$@\"")) + .arg(&editor) // $0 + .arg(&path) // $1 → "$@" + .status(); + + if let Some((tty, so, se)) = saved { + unsafe { + libc::dup2(so, 1); + libc::dup2(se, 2); + libc::close(so); + libc::close(se); + libc::close(tty); + } + } + + let result = match status { + Ok(s) if s.success() => std::fs::read_to_string(&path).ok(), + Ok(s) => { + crate::ui::notifications::notify_send(crate::ui::notifications::Notification::Error( + format!( + "External editor exited with: {}", + s.code() + .map(|c| format!("code {c}")) + .unwrap_or_else(|| "signal".into()) + ), + )); + None + } + Err(e) => { + crate::ui::notifications::notify_send(crate::ui::notifications::Notification::Error( + format!("External editor: failed to spawn {editor}: {e}"), + )); + None + } + }; + + let _ = std::fs::remove_file(&path); + result +} diff --git a/src/ui/input.rs b/src/ui/input.rs index 2e74a53f..a827077b 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -1085,9 +1085,6 @@ impl InputEditor { /// `terminal.rs`. #[cfg(unix)] pub(crate) fn open_in_external_editor(&mut self) -> Option { - use std::io::Write; - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); - // Seed with the EXPANDED text (paste bodies inline, image markers // dropped) rather than the raw buffer: the raw buffer carries // invisible `\x01\x01` sentinel bytes that render as garbage in @@ -1096,92 +1093,13 @@ impl InputEditor { // (dirge-vpma.5). let seed = self.editor_seed(); - let path = std::env::temp_dir().join(format!("dirge-input-{}.md", std::process::id())); - // Clear any leftover from an interrupted prior edit, then create the - // file with O_EXCL (`create_new`): if an attacker pre-planted a symlink - // at this predictable path, the open fails safely instead of writing - // through it. - let _ = std::fs::remove_file(&path); - let write_result = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .and_then(|mut f| f.write_all(seed.as_bytes())); - if let Err(e) = write_result { - crate::ui::notifications::notify_send(crate::ui::notifications::Notification::Error( - format!("External editor: failed to write temp file: {e}"), - )); - return None; - } - - // dirge redirects fd 1/2 to the log file for the TUI session; point - // them at /dev/tty for the child editor's lifetime so it draws on the - // real terminal, then restore. fd 0 (stdin) is already the terminal. - let saved: Option<(i32, i32, i32)> = unsafe { - let tty = libc::open(c"/dev/tty".as_ptr(), libc::O_RDWR); - if tty < 0 { - None - } else { - let so = libc::dup(1); - let se = libc::dup(2); - libc::dup2(tty, 1); - libc::dup2(tty, 2); - Some((tty, so, se)) - } - }; - - // git-style invocation: pass the temp path as a positional arg ($1 via - // "$@") instead of interpolating it into the command string, so a path - // with spaces/metacharacters can't break the command. `$EDITOR` still - // word-splits (e.g. `EDITOR="code --wait"`). - let status = std::process::Command::new("sh") - .arg("-c") - .arg(format!("{editor} \"$@\"")) - .arg(&editor) // $0 - .arg(&path) // $1 → "$@" - .status(); - - // Restore fd 1/2 to the log file and close the saved/tty fds (no leak). - if let Some((tty, so, se)) = saved { - unsafe { - libc::dup2(so, 1); - libc::dup2(se, 2); - libc::close(so); - libc::close(se); - libc::close(tty); - } - } - - let content = match status { - Ok(s) if s.success() => std::fs::read_to_string(&path).unwrap_or_else(|_| seed.clone()), - Ok(s) => { - crate::ui::notifications::notify_send( - crate::ui::notifications::Notification::Error(format!( - "External editor exited with: {}", - s.code() - .map(|c| format!("code {c}")) - .unwrap_or_else(|| "signal".into()) - )), - ); - seed.clone() - } - Err(e) => { - crate::ui::notifications::notify_send( - crate::ui::notifications::Notification::Error(format!( - "External editor: failed to spawn {editor}: {e}" - )), - ); - seed.clone() - } - }; - - let _ = std::fs::remove_file(&path); - // Compare against the seed, not the raw buffer — the seed is what the // editor was handed, so this correctly detects "no change" even though - // the raw buffer differs (markers expanded). On the error paths above - // `content == seed`, so nothing is applied. - if content != seed { + // the raw buffer differs (markers expanded). A failed or aborted edit + // yields `None` and applies nothing, as before. + if let Some(content) = crate::ui::external_editor::edit_text(&seed, "input") + && content != seed + { self.apply_editor_result(&content); } diff --git a/src/ui/memory_document.rs b/src/ui/memory_document.rs new file mode 100644 index 00000000..2ce29dd6 --- /dev/null +++ b/src/ui/memory_document.rs @@ -0,0 +1,515 @@ +//! The memory store as an editable document. +//! +//! `/memory` renders it; `/memory edit` hands the same text to `$EDITOR` and +//! applies whatever comes back. Reword a line to reword the memory, delete a +//! block to forget it, type a new one to add it. +//! +//! **Every entry is anchored on its uid**, printed in the document as +//! `[a1b2c3d4]`. That is not decoration. A dirge memory row carries far more +//! than its text — lineage, `created_at`, `use_count`, `confidence`, the +//! procedural success/failure counters that the post-session expectation pass +//! moves, and the supersession audit chain. Matching edited text back to rows +//! by content, or applying an edit as delete-then-recreate, would silently +//! reset all of it. With the uid we can UPDATE in place and keep it. +//! +//! A block with no uid is new. A uid present in the store but absent from the +//! document was deleted, and is tombstoned (not hard-deleted) so `restore` +//! still works — removing a line in an editor should not be more destructive +//! than the tool's own removal. + +use crate::extras::memory_db::BrowseEntry; +#[cfg(unix)] +use crate::extras::memory_db::SqliteMemoryStore; + +/// How much of a uid to show. Stored uids are `urn:ump:<26 chars>`, which is +/// far too wide to put in front of every line — the anchor would out-shout +/// the memory. Rendered short and resolved by prefix, the same way session +/// ids are handled elsewhere. +const SHORT_UID_LEN: usize = 8; + +fn short_uid(uid: &str) -> String { + let tail = uid.rsplit(':').next().unwrap_or(uid); + tail.chars().take(SHORT_UID_LEN).collect() +} + +/// Resolve a rendered short id back to a stored entry. +/// +/// Ambiguity is an error rather than a guess: picking one of two candidates +/// would edit the wrong memory, and the user would have no way to tell. +#[cfg(unix)] +fn resolve_uid<'a>(token: &str, stored: &'a [BrowseEntry]) -> Result<&'a BrowseEntry, String> { + let matches: Vec<&BrowseEntry> = stored + .iter() + .filter(|e| e.uid == token || short_uid(&e.uid) == token) + .collect(); + match matches.len() { + 1 => Ok(matches[0]), + 0 => Err(format!( + "unknown memory id [{token}] — leave the ids as rendered, or drop the \ + whole `[id]` marker to add the line as a new memory" + )), + n => Err(format!("memory id [{token}] is ambiguous ({n} matches)")), + } +} + +#[cfg(unix)] +const HEADER: &str = "\ +# dirge memory +# +# Reword a line to reword the memory. Delete a block to forget it (it is +# archived, not destroyed). Add a block to record something new. +# +# The [id] on each entry is what ties an edit back to the stored memory — +# leave it alone. A block without one is treated as new. +# +# Kinds: semantic, episodic, procedural, working, identity, overview. +"; + +/// What a parsed document says should happen. +#[derive(Debug, Default, PartialEq, Eq)] +#[cfg(unix)] +pub struct Plan { + /// (uid, new content, kind) — reworded in place. + pub updates: Vec<(String, String, Option)>, + /// uids present in the store but no longer in the document. + pub removals: Vec, + /// (target, kind, content) — blocks with no uid. + pub additions: Vec<(String, Option, String)>, + /// Entries whose text and kind were untouched. + pub unchanged: usize, +} + +#[cfg(unix)] +fn section_heading(target: &str) -> String { + format!("## {target}") +} + +/// Render the store. Grouped by target, uid-anchored, with the usage signal +/// as a trailing comment so it survives a round trip without being parsed +/// back — it is information for the reader, not an editable field. +#[cfg(unix)] +pub fn render(entries: &[BrowseEntry]) -> String { + let mut out = String::from(HEADER); + let mut current: Option<&str> = None; + for entry in entries { + if current != Some(entry.target.as_str()) { + out.push_str(&format!("\n{}\n", section_heading(&entry.target))); + current = Some(entry.target.as_str()); + } + let usage = if entry.use_count > 0 { + format!(" # used {}x", entry.use_count) + } else { + String::new() + }; + let tier = if entry.tier == "breadcrumb" { + " # breadcrumb" + } else { + "" + }; + out.push_str(&format!( + "\n[{}] [{}] {}{}{}\n", + short_uid(&entry.uid), + entry.kind, + entry.content.trim(), + usage, + tier + )); + } + out +} + +/// A short, non-editable listing for `/memory` with no arguments. +pub fn summarize(entries: &[BrowseEntry]) -> Vec { + if entries.is_empty() { + return vec!["no memories stored".to_string()]; + } + let mut lines = Vec::new(); + let mut current: Option<&str> = None; + for entry in entries { + if current != Some(entry.target.as_str()) { + lines.push(format!("{}:", entry.target)); + current = Some(entry.target.as_str()); + } + let flags = match (entry.tier.as_str(), entry.use_count) { + ("breadcrumb", _) => " (breadcrumb)".to_string(), + (_, n) if n > 0 => format!(" ({n}x)"), + _ => String::new(), + }; + lines.push(format!( + " [{}] {} {}{}", + short_uid(&entry.uid), + entry.kind, + one_line(&entry.content), + flags + )); + } + lines +} + +fn one_line(content: &str) -> String { + let flat = content.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= 96 { + return flat; + } + format!("{}…", flat.chars().take(96).collect::()) +} + +/// Diff an edited document against what is stored. +/// +/// Errors rather than guessing: a block before any heading has no target, and +/// an unknown uid means the anchor was mangled — applying that as a fresh +/// insert would duplicate the memory it was meant to edit. +#[cfg(unix)] +pub fn parse(text: &str, stored: &[BrowseEntry]) -> Result { + let mut plan = Plan::default(); + let mut seen: Vec = Vec::new(); + let mut target: Option = None; + + for raw in text.lines() { + let line = strip_comment(raw); + let trimmed = line.trim(); + if trimmed.is_empty() { + if raw.trim_start().starts_with("## ") { + target = Some(parse_heading(raw.trim())?); + } + continue; + } + if raw.trim_start().starts_with('#') { + if raw.trim_start().starts_with("## ") { + target = Some(parse_heading(raw.trim())?); + } + continue; + } + + // The first bracket is the uid — unless it names a kind. The header + // invites `[identity] something new` for a fresh entry, and a user + // following it should not be told their kind is an unknown id. Kinds + // are a closed set, so this is decidable rather than a guess. + let (mut uid, mut rest) = split_bracket(trimmed); + if uid + .as_deref() + .and_then(crate::extras::memory_db::parse_kind) + .is_some() + { + rest = trimmed; + uid = None; + } + let (kind, content) = split_bracket(rest.trim()); + let content = content.trim().to_string(); + if content.is_empty() { + continue; + } + + match uid { + Some(token) => { + let existing = resolve_uid(&token, stored)?; + seen.push(existing.uid.clone()); + let kind_changed = kind.as_deref().is_some_and(|k| k != existing.kind); + if existing.content.trim() != content || kind_changed { + // Only forward the kind when it actually changed. The + // document always echoes it back, and the store treats a + // supplied kind as a re-classification — which resets the + // procedural success/failure counters. Passing it + // unconditionally meant a plain rewording silently wiped + // the track record this whole module exists to preserve. + let kind = if kind_changed { kind } else { None }; + plan.updates.push((existing.uid.clone(), content, kind)); + } else { + plan.unchanged += 1; + } + } + None => { + let target = target.clone().ok_or_else(|| { + format!( + "entry {content:?} appears before any `## memory` heading — \ + add one above it so it has a home" + ) + })?; + plan.additions.push((target, kind, content)); + } + } + } + + for entry in stored { + if !seen.contains(&entry.uid) { + plan.removals.push(entry.uid.clone()); + } + } + Ok(plan) +} + +#[cfg(unix)] +fn parse_heading(line: &str) -> Result { + match line.strip_prefix("## ").map(str::trim) { + Some("memory") => Ok("memory".to_string()), + Some("pitfalls") => Ok("pitfalls".to_string()), + _ => Err(format!( + "unrecognized heading {line:?} — expected `## memory` or `## pitfalls`" + )), + } +} + +/// Strip a trailing ` # ...` annotation (the usage/tier hints `render` adds). +/// Only after at least one non-space character, so a `#` opening the line is +/// still a comment. +#[cfg(unix)] +fn strip_comment(line: &str) -> &str { + match line.find(" # ") { + Some(idx) if !line[..idx].trim().is_empty() => &line[..idx], + _ => line, + } +} + +/// Pull a leading `[token]` off a line. +#[cfg(unix)] +fn split_bracket(line: &str) -> (Option, &str) { + if let Some(rest) = line.strip_prefix('[') + && let Some((token, tail)) = rest.split_once(']') + { + return (Some(token.trim().to_string()), tail); + } + (None, line) +} + +/// What applying a plan did. +#[derive(Debug, Default, PartialEq, Eq)] +#[cfg(unix)] +pub struct ApplyReport { + pub updated: usize, + pub removed: usize, + pub added: usize, + pub unchanged: usize, + pub failures: Vec, +} + +#[cfg(unix)] +impl ApplyReport { + pub fn summary(&self) -> String { + let mut parts = Vec::new(); + if self.updated > 0 { + parts.push(format!("{} reworded", self.updated)); + } + if self.added > 0 { + parts.push(format!("{} added", self.added)); + } + if self.removed > 0 { + parts.push(format!("{} forgotten", self.removed)); + } + if parts.is_empty() { + return "no changes".to_string(); + } + let mut summary = parts.join(", "); + if !self.failures.is_empty() { + summary.push_str(&format!(", {} failed", self.failures.len())); + } + summary + } +} + +/// Apply a plan. Each operation is independent: one rejected entry (a +/// duplicate, something over budget) is reported and the rest still apply, +/// because failing the whole edit would throw away every other correction the +/// user just made. +#[cfg(unix)] +pub fn apply(store: &SqliteMemoryStore, plan: &Plan) -> ApplyReport { + let mut report = ApplyReport { + unchanged: plan.unchanged, + ..Default::default() + }; + + for (uid, content, kind) in &plan.updates { + let parsed = kind + .as_deref() + .and_then(crate::extras::memory_db::parse_kind); + match store.replace_entry_by_uid(uid, content, parsed) { + Ok(()) => report.updated += 1, + Err(e) => report.failures.push(format!("[{uid}]: {e}")), + } + } + for (target, kind, content) in &plan.additions { + let parsed = kind + .as_deref() + .and_then(crate::extras::memory_db::parse_kind); + match store.add_entry(target, content, parsed) { + Ok(_) => report.added += 1, + Err(e) => report + .failures + .push(format!("{:?}: {e}", one_line(content))), + } + } + // Removals last: an edit that both reworded and deleted should not lose + // the rewording because a delete failed first. + for uid in &plan.removals { + match store.remove_entry_by_uid(uid) { + Ok(()) => report.removed += 1, + Err(e) => report.failures.push(format!("[{uid}]: {e}")), + } + } + report +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn entry(uid: &str, content: &str) -> BrowseEntry { + BrowseEntry { + uid: uid.into(), + target: "memory".into(), + kind: "semantic".into(), + content: content.into(), + tier: "hot".into(), + use_count: 0, + updated_at: "2026-01-01T00:00:00Z".into(), + } + } + + #[test] + fn an_untouched_document_changes_nothing() { + let stored = vec![entry("aaa1", "first"), entry("bbb2", "second")]; + let plan = parse(&render(&stored), &stored).unwrap(); + assert_eq!(plan.unchanged, 2); + assert!(plan.updates.is_empty()); + assert!(plan.removals.is_empty()); + assert!(plan.additions.is_empty()); + } + + #[test] + fn rewording_updates_in_place() { + let stored = vec![entry("aaa1", "first")]; + let doc = render(&stored).replace("first", "first, corrected"); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!( + plan.updates, + vec![("aaa1".into(), "first, corrected".into(), None)] + ); + assert!(plan.removals.is_empty()); + } + + /// The store resets procedural outcome counters on a re-classification. + /// A rewording must therefore NOT forward an unchanged kind. + #[test] + fn rewording_does_not_forward_an_unchanged_kind() { + let stored = vec![entry("aaa1", "first")]; + let doc = render(&stored).replace("first", "first, corrected"); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!( + plan.updates[0].2, None, + "unchanged kind would reset counters" + ); + } + + #[test] + fn deleting_a_block_forgets_it() { + let stored = vec![entry("aaa1", "keep me"), entry("bbb2", "drop me")]; + let doc = render(&stored) + .lines() + .filter(|l| !l.contains("drop me")) + .collect::>() + .join("\n"); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!(plan.removals, vec!["bbb2".to_string()]); + assert_eq!(plan.unchanged, 1); + } + + #[test] + fn a_block_without_an_id_is_new() { + let stored = vec![entry("aaa1", "existing")]; + let doc = format!("{}\n[procedural] something new\n", render(&stored)); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!( + plan.additions, + vec![( + "memory".to_string(), + Some("procedural".to_string()), + "something new".to_string() + )] + ); + assert!(plan.removals.is_empty()); + } + + /// The header invites `[identity] ...` for a new entry. Reading that + /// leading bracket as a uid told the user their kind was an unknown id. + #[test] + fn a_new_entry_may_lead_with_its_kind() { + let stored = vec![entry("aaa1", "existing")]; + let doc = format!("{}\n[identity] added by hand\n", render(&stored)); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!( + plan.additions, + vec![( + "memory".to_string(), + Some("identity".to_string()), + "added by hand".to_string() + )] + ); + assert!(plan.removals.is_empty(), "the existing entry must survive"); + } + + #[test] + fn changing_the_kind_counts_as_an_update() { + let stored = vec![entry("aaa1", "first")]; + let doc = render(&stored).replace("[semantic]", "[procedural]"); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!(plan.updates.len(), 1); + assert_eq!(plan.updates[0].2, Some("procedural".to_string())); + } + + /// The usage/tier hints are output, not input — they must not come back + /// as part of the memory text. + #[test] + fn trailing_annotations_are_not_part_of_the_content() { + let mut e = entry("aaa1", "a fact"); + e.use_count = 7; + let stored = vec![e]; + let doc = render(&stored); + assert!(doc.contains("# used 7x")); + let plan = parse(&doc, &stored).unwrap(); + assert_eq!(plan.unchanged, 1, "annotation leaked into the content"); + } + + /// Silently treating a mangled id as a new memory would duplicate the + /// entry it was meant to edit. + #[test] + fn an_unknown_id_is_an_error() { + let stored = vec![entry("aaa1", "first")]; + let err = parse("## memory\n\n[zzz9] [semantic] first\n", &stored).unwrap_err(); + assert!(err.contains("unknown memory id"), "{err}"); + } + + #[test] + fn an_entry_before_any_heading_is_an_error() { + let err = parse("[semantic] homeless\n", &[]).unwrap_err(); + assert!(err.contains("before any"), "{err}"); + } + + #[test] + fn an_empty_document_forgets_everything() { + let stored = vec![entry("aaa1", "first"), entry("bbb2", "second")]; + let plan = parse("", &stored).unwrap(); + assert_eq!(plan.removals.len(), 2); + } + + #[test] + fn summary_reads_naturally() { + let report = ApplyReport { + updated: 1, + added: 2, + removed: 1, + unchanged: 4, + failures: vec![], + }; + assert_eq!(report.summary(), "1 reworded, 2 added, 1 forgotten"); + assert_eq!(ApplyReport::default().summary(), "no changes"); + } + + #[test] + fn pitfalls_round_trip_under_their_own_heading() { + let mut e = entry("ccc3", "never force push"); + e.target = "pitfalls".into(); + let stored = vec![entry("aaa1", "a fact"), e]; + let doc = render(&stored); + assert!(doc.contains("## pitfalls")); + assert_eq!(parse(&doc, &stored).unwrap().unchanged, 2); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 633c4bb7..efbffbbe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -12,12 +12,14 @@ pub(crate) mod desktop_notify; pub(crate) mod done_phase; mod editor_follow; pub(crate) mod events; +pub(crate) mod external_editor; pub(crate) mod gitstatus; mod highlight; pub(crate) mod input; pub(crate) mod input_reader; pub(crate) mod keymap; mod markdown; +pub(crate) mod memory_document; pub(crate) mod notifications; pub(crate) mod panel_data; mod panel_render; diff --git a/src/ui/slash/cmd/memory.rs b/src/ui/slash/cmd/memory.rs index 2a5835a3..34fa7b2c 100644 --- a/src/ui/slash/cmd/memory.rs +++ b/src/ui/slash/cmd/memory.rs @@ -1,4 +1,4 @@ -//! /memory handler — reload the frozen snapshot mid-session. +//! /memory handler — show what is remembered, edit it, reload the snapshot. use crate::ui::slash::{SlashCtx, c_agent, c_error}; @@ -25,12 +25,27 @@ pub(crate) async fn cmd_memory(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow } } } - "" => { + #[cfg(unix)] + "edit" => { + cmd_memory_edit(ctx).await?; + } + "help" => { + ctx.renderer + .write_line("/memory — list what is remembered", c_agent())?; + #[cfg(unix)] + ctx.renderer + .write_line("/memory edit — open the store in $EDITOR", c_agent())?; ctx.renderer.write_line( "/memory reload — refresh the frozen snapshot so recent writes appear in the prompt", c_agent(), )?; } + // Bare `/memory` shows the store. It used to print its own help, + // which meant there was no way at all to see what dirge had + // remembered about you without asking the agent to go and look. + "" => { + cmd_memory_list(ctx)?; + } other => { ctx.renderer .write_line(&format!("unknown /memory sub-command: {other}"), c_error())?; @@ -38,3 +53,122 @@ pub(crate) async fn cmd_memory(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow } Ok(()) } + +/// Open the project's memory store, or explain why not. +/// +/// Opened by path rather than through the agent's `MemoryProvider`: the +/// provider may be a hybrid wrapper, and this must work in a session whose +/// memory tool failed to load — that is exactly when you want to look. +fn open_store() -> Result { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let paths = crate::extras::dirge_paths::ProjectPaths::new(&cwd); + crate::extras::memory_db::SqliteMemoryStore::load(&paths) +} + +fn cmd_memory_list(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()> { + let store = match open_store() { + Ok(s) => s, + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open memory store: {e}"), c_error())?; + return Ok(()); + } + }; + let entries = match store.list_all() { + Ok(e) => e, + Err(e) => { + ctx.renderer + .write_line(&format!("cannot read memories: {e}"), c_error())?; + return Ok(()); + } + }; + for line in crate::ui::memory_document::summarize(&entries) { + ctx.renderer.write_line(&line, c_agent())?; + } + #[cfg(unix)] + if !entries.is_empty() { + ctx.renderer + .write_line("(/memory edit to change them)", c_agent())?; + } + Ok(()) +} + +/// Open the whole store in `$EDITOR` and apply what comes back. +#[cfg(unix)] +async fn cmd_memory_edit(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()> { + use crate::ui::memory_document; + + let store = match open_store() { + Ok(s) => s, + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open memory store: {e}"), c_error())?; + return Ok(()); + } + }; + let stored = match store.list_all() { + Ok(e) => e, + Err(e) => { + ctx.renderer + .write_line(&format!("cannot read memories: {e}"), c_error())?; + return Ok(()); + } + }; + if stored.is_empty() { + ctx.renderer + .write_line("no memories stored — nothing to edit", c_agent())?; + return Ok(()); + } + + let doc = memory_document::render(&stored); + let drained_stdin = match crate::ui::terminal::suspend_tui_for_subprocess(ctx.user_tx) { + Some(d) => d, + None => { + ctx.renderer + .write_line("no /dev/tty available — cannot open an editor", c_error())?; + return Ok(()); + } + }; + let edited = crate::ui::external_editor::edit_text(&doc, "memory"); + crate::ui::terminal::resume_tui_after_subprocess(ctx.renderer, ctx.user_tx); + drop(drained_stdin); + + // Aborting the editor (`:cq`) must leave the store untouched, which is + // why `edit_text` distinguishes a failed edit from an empty document — + // an empty document legitimately means "forget everything". + let Some(edited) = edited else { + ctx.renderer + .write_line("edit aborted — nothing changed", c_agent())?; + return Ok(()); + }; + + let plan = match memory_document::parse(&edited, &stored) { + Ok(p) => p, + Err(e) => { + ctx.renderer + .write_line(&format!("could not parse the edit: {e}"), c_error())?; + ctx.renderer + .write_line("nothing changed — run /memory edit again", c_agent())?; + return Ok(()); + } + }; + + let report = memory_document::apply(&store, &plan); + ctx.renderer + .write_line(&format!("memory: {}", report.summary()), c_agent())?; + for failure in &report.failures { + ctx.renderer + .write_line(&format!(" {failure}"), c_error())?; + } + + // The prompt snapshot is frozen at build time, so without this the agent + // keeps using the memories as they were before the edit. + if (report.updated > 0 || report.added > 0 || report.removed > 0) + && let Some(provider) = ctx.agent.memory_provider() + && let Err(e) = provider.refresh_snapshot() + { + ctx.renderer + .write_line(&format!("snapshot refresh failed: {e}"), c_error())?; + } + Ok(()) +} diff --git a/src/ui/slash/mod.rs b/src/ui/slash/mod.rs index 7fae2475..dec9686c 100644 --- a/src/ui/slash/mod.rs +++ b/src/ui/slash/mod.rs @@ -879,7 +879,10 @@ fn slash_commands() -> Vec<(&'static str, &'static str)> { "/learn", "distill sources or this session into a reusable skill", ), - ("/memory", "reload the memory snapshot mid-session"), + ( + "/memory", + "show what is remembered; `edit` to change it, `reload` to refresh", + ), ("/mode", "view or set the permission/security mode"), ("/model", "list configured models, or switch to one"), ("/panel", "toggle the side panels on or off"),