From d2df6cf7fc5c7c32f028d961214668b8e54bbd1c Mon Sep 17 00:00:00 2001 From: Wayne Date: Mon, 24 Aug 2026 09:21:45 +0000 Subject: [PATCH 1/3] memory: let a user see and edit what dirge remembers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/memory` had one subcommand, `reload`. There was no way to see what dirge had remembered about you — the store is SQLite with an FTS index, so the options were to ask the agent to call the `memory` tool and hope, or open sqlite3 and risk desyncing the index against the content. Memory is injected verbatim into the system prompt of every session in the project, and under global scope every project, so "you cannot read it" is a real gap. `/memory` now lists the store. `/memory edit` opens it in $EDITOR: reword a line to reword the memory, delete a block to forget it, add a block to record something new. Each entry is anchored on its id, rendered short (`[n7x4bhbp]`) and resolved by prefix. That is the load-bearing part. A memory row carries far more than its text — uid lineage, created_at, use_count, confidence, the supersession audit chain, and the procedural success/failure counters that the post-session expectation pass exists to move. Matching edited text back by content, or applying an edit as delete-then-recreate, silently resets all of it. With the id we UPDATE in place through the same path `replace_entry` already used, so it survives. `replace_entry`'s body is split into `apply_replacement` so the substring and by-id paths share one definition of what replacing means, rather than growing a second one that drifts. Deleting a block tombstones rather than destroys, so `restore` still works — removing a line in an editor should not be more destructive than the tool's own removal. An unparseable document aborts the whole edit intact; aborting the editor (`:cq`) changes nothing. $EDITOR handling is extracted from `Input::open_in_external_editor` into `ui::external_editor` and shared: the O_EXCL temp file, the /dev/tty fd juggling and the git-style argv are each easy to get subtly wrong twice. `/edit` should be retested by hand. Verified end to end against the built binary. Two bugs found by running it, both now covered by tests: a new entry written as `[identity] ...` — which the document's own header invites — was read as an unknown id; and because the document always echoes the kind back, every rewording looked like a re-classification and reset the outcome counters. A rewording now preserves uid, use_count, confidence and success_count while changing the text. --- docs/features.md | 2 +- src/extras/memory_db.rs | 140 ++++++++++- src/ui/external_editor.rs | 104 ++++++++ src/ui/input.rs | 92 +------ src/ui/memory_document.rs | 501 +++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 2 + src/ui/slash/cmd/memory.rs | 138 +++++++++- src/ui/slash/mod.rs | 5 +- 8 files changed, 890 insertions(+), 94 deletions(-) create mode 100644 src/ui/external_editor.rs create mode 100644 src/ui/memory_document.rs diff --git a/docs/features.md b/docs/features.md index b33005ebd..f96ad93af 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 8a970dcc4..cd5a24c18 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 @@ -1280,19 +1321,112 @@ 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. + 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. + 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 000000000..6c71d149a --- /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 2e74a53fc..a827077bf 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 000000000..4397d18c6 --- /dev/null +++ b/src/ui/memory_document.rs @@ -0,0 +1,501 @@ +//! 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, 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. +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)")), + } +} + +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)] +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, +} + +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. +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. +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) +} + +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. +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. +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)] +pub struct ApplyReport { + pub updated: usize, + pub removed: usize, + pub added: usize, + pub unchanged: usize, + pub failures: Vec, +} + +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. +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(test)] +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 633c4bb7c..efbffbbe5 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 2a5835a30..34fa7b2c2 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 7fae2475f..dec9686c1 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"), From 37ae0962614fa78a0df02a2f66c22cfd180c2918 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Mon, 24 Aug 2026 10:14:45 -0400 Subject: [PATCH 2/3] gate the /memory edit path to unix for the windows build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render/parse/apply and the *_by_uid store methods are only reached from /memory edit, which needs $EDITOR (edit_text is unix-only), so windows flagged them dead. summarize stays cross-platform — the /memory listing works everywhere. also drops a needless borrow clippy flagged. --- src/extras/memory_db.rs | 6 +++++- src/ui/memory_document.rs | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/extras/memory_db.rs b/src/extras/memory_db.rs index cd5a24c18..e5a594d61 100644 --- a/src/extras/memory_db.rs +++ b/src/extras/memory_db.rs @@ -1308,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}"))?; @@ -1370,6 +1370,8 @@ impl SqliteMemoryStore { /// 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, @@ -1411,6 +1413,8 @@ impl SqliteMemoryStore { /// `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 diff --git a/src/ui/memory_document.rs b/src/ui/memory_document.rs index 4397d18c6..67124e03c 100644 --- a/src/ui/memory_document.rs +++ b/src/ui/memory_document.rs @@ -34,6 +34,7 @@ fn short_uid(uid: &str) -> String { /// /// 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() @@ -49,6 +50,7 @@ fn resolve_uid<'a>(token: &str, stored: &'a [BrowseEntry]) -> Result<&'a BrowseE } } +#[cfg(unix)] const HEADER: &str = "\ # dirge memory # @@ -63,6 +65,7 @@ const HEADER: &str = "\ /// 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)>, @@ -74,6 +77,7 @@ pub struct Plan { pub unchanged: usize, } +#[cfg(unix)] fn section_heading(target: &str) -> String { format!("## {target}") } @@ -81,6 +85,7 @@ fn section_heading(target: &str) -> String { /// 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; @@ -152,6 +157,7 @@ fn one_line(content: &str) -> String { /// 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(); @@ -230,6 +236,7 @@ pub fn parse(text: &str, stored: &[BrowseEntry]) -> Result { Ok(plan) } +#[cfg(unix)] fn parse_heading(line: &str) -> Result { match line.strip_prefix("## ").map(str::trim) { Some("memory") => Ok("memory".to_string()), @@ -243,6 +250,7 @@ fn parse_heading(line: &str) -> Result { /// 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], @@ -251,6 +259,7 @@ fn strip_comment(line: &str) -> &str { } /// 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(']') @@ -262,6 +271,7 @@ fn split_bracket(line: &str) -> (Option, &str) { /// What applying a plan did. #[derive(Debug, Default, PartialEq, Eq)] +#[cfg(unix)] pub struct ApplyReport { pub updated: usize, pub removed: usize, @@ -270,6 +280,7 @@ pub struct ApplyReport { pub failures: Vec, } +#[cfg(unix)] impl ApplyReport { pub fn summary(&self) -> String { let mut parts = Vec::new(); @@ -297,6 +308,7 @@ impl ApplyReport { /// 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, @@ -334,7 +346,7 @@ pub fn apply(store: &SqliteMemoryStore, plan: &Plan) -> ApplyReport { report } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; From 826723fc79b9313ab52b7a19aba428c138a2cec2 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Mon, 24 Aug 2026 10:24:37 -0400 Subject: [PATCH 3/3] gate the SqliteMemoryStore import to unix too apply() was its only consumer left on windows after the previous commit. --- src/ui/memory_document.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/memory_document.rs b/src/ui/memory_document.rs index 67124e03c..2ce29dd6d 100644 --- a/src/ui/memory_document.rs +++ b/src/ui/memory_document.rs @@ -17,7 +17,9 @@ //! 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, SqliteMemoryStore}; +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