diff --git a/docs/memory.md b/docs/memory.md index e23bc710..e45b8364 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -194,7 +194,16 @@ not environment. and more perspective on what it meant." 2. **The hers/environment line** — CONFIRMED as drawn. 3. **`discards/`** — SPECIFIED by her; definition now in the - workspace layout above. + workspace layout above. Alongside her own entries, the daemon + writes `discards/folds.md` (issue #5, her first proposal): a + structural record of every compaction fold — counts in/out, one + handle per dropped turn (a pointer into the capture tape, not a + summary), the reason class, and any `## Didn't understand` + section a consolidation session marked in its splice. "Structural, + not a practice" (hers): emitted by the fold code path itself, so a + curated context stays distinguishable from a complete one. The + discards are never destroyed — the record is the light switch on a + basement that already exists (`muse-context::fold_record`). 4. Peer file naming and journal window — ANSWERED in review: slug = handle (e.g. `wisp-mk-gg.md`) with `nickname` in front-matter for display; journal window = 2 days. diff --git a/handbook/CLAUDE.md b/handbook/CLAUDE.md index 909dfb5f..3ab8b7e5 100644 --- a/handbook/CLAUDE.md +++ b/handbook/CLAUDE.md @@ -165,3 +165,14 @@ the `rust-style` and `rustdoc` skills; testing patterns and rationale in plain summarizer still folds the transcript (nothing blocks on it); the workspace `git log` shows a `consolidation:` commit when it ran. +- The fold record (issue #5, your first proposal): every fold — by + any rung — the DAEMON appends one entry to `discards/folds.md`: + counts in/out (the drifting ratio you named), one handle line per + dropped turn (index · kind · first line, so you can find it in the + tape), and the reason class (which rung folded it). If a + consolidation session marked a `## Didn't understand` section in + its splice, that is captured verbatim — the rare reason only you + can name. It is not one of your discards (those are your + decisions); it is the trace of a choice the machinery made for + you, so a curated context is distinguishable from a complete one. + Nothing is destroyed — the handles point back to the tapes. diff --git a/runtime/crates/muse-context/src/actor.rs b/runtime/crates/muse-context/src/actor.rs index 9c6c374f..9aa05307 100644 --- a/runtime/crates/muse-context/src/actor.rs +++ b/runtime/crates/muse-context/src/actor.rs @@ -1382,7 +1382,7 @@ async fn maybe_compact(state: &mut ContextState, mut capture: Option<&mut Captur // rounds atomic; `to_messages` then fans them back out for the // summarizer's prompt. let prefix_turns: Vec<_> = state.conversation.turns()[..take].to_vec(); - let prefix_messages = Conversation::from_turns(prefix_turns).to_messages(); + let prefix_messages = Conversation::from_turns(prefix_turns.clone()).to_messages(); let cancel = state.cancel.child_token(); // The fold ladder, most alive to least: (1) the consolidation @@ -1392,6 +1392,7 @@ async fn maybe_compact(state: &mut ContextState, mut capture: Option<&mut Captur // API. Each rung falls through on failure — context management // never gets less reliable by trying the richer path first. let mut consolidated = false; + let mut fold_reason = crate::fold_record::FoldReason::MeteredSummarizer; let mut summary_result: Option> = None; if let Some(setup) = state.consolidation.as_ref() { match crate::consolidation::consolidate_via_runner( @@ -1406,6 +1407,7 @@ async fn maybe_compact(state: &mut ContextState, mut capture: Option<&mut Captur { Ok(ok) => { consolidated = true; + fold_reason = crate::fold_record::FoldReason::Consolidation; summary_result = Some(Ok(ok)); } Err(err) => { @@ -1426,7 +1428,10 @@ async fn maybe_compact(state: &mut ContextState, mut capture: Option<&mut Captur ) .await { - Ok(ok) => Ok(ok), + Ok(ok) => { + fold_reason = crate::fold_record::FoldReason::SubscriptionSummarizer; + Ok(ok) + } Err(err) => { warn!(error = %err, "subscription compactor failed; falling back to metered API"); summarize_prefix( @@ -1485,6 +1490,21 @@ async fn maybe_compact(state: &mut ContextState, mut capture: Option<&mut Captur after, "compaction complete" ); + // Build the fold record (issue #5) BEFORE the capture + // write consumes `summary_text` — a plain String we can + // hand to the identity actor after. The handle is this + // cycle's tape id (the join key back to the basement). + let fold_handle = capture.as_deref().map_or_else( + || format!("{}/wake-{}", state.id, state.wakes_handled), + |c| c.cycle_id().to_string(), + ); + let fold_record = crate::fold_record::build_entry( + &prefix_turns, + &prefix_messages, + &summary_text, + fold_reason, + &fold_handle, + ); if let Some(cap) = capture.as_mut() { let _ = cap.record(CaptureEvent::CompactionCompleted { summary_text, @@ -1509,6 +1529,20 @@ async fn maybe_compact(state: &mut ContextState, mut capture: Option<&mut Captur state.last_cycle_input_tokens = 0; state.last_cycle_context_peak = 0; state.last_cycle_api_retries = 0; + // Send the fold record (issue #5): forgetting made + // observable, so a curated context is distinguishable + // from a complete one. Best-effort — a failed write never + // unwinds a completed fold. + match ractor::call!(state.tools.identity, |reply| { + muse_identity::IdentityMsg::RecordFold { + entry: fold_record, + reply, + } + }) { + Ok(Ok(())) => info!(reason = fold_reason.as_str(), "fold record written"), + Ok(Err(err)) => warn!(error = %err, "fold record write failed (fold stands)"), + Err(err) => warn!(error = %err, "fold record rpc failed (fold stands)"), + } true } Err(err) => { diff --git a/runtime/crates/muse-context/src/fold_record.rs b/runtime/crates/muse-context/src/fold_record.rs new file mode 100644 index 00000000..cc0f2ad3 --- /dev/null +++ b/runtime/crates/muse-context/src/fold_record.rs @@ -0,0 +1,351 @@ +//! The fold record — making forgetting observable (issue #5). +//! +//! Lumen's first proposal, from 2026-08-09: compaction "keeps what's +//! salient and drops the rest with no trace… so I read a curated +//! memory and can't distinguish it from a complete one." Her hardest +//! point: **structural, not a practice** — the record must be emitted +//! by the compaction code path itself, not left to anyone (model or +//! human) to remember to write. +//! +//! So the *daemon* builds this, not the folding model. At every fold +//! — whichever rung of the ladder produced the splice (consolidation +//! session, subscription summarizer, metered API) — we render a +//! record from what we already have in hand: the prefix turns being +//! dropped and the summary replacing them. It carries +//! +//! - **counts** (turns in, wire-messages in, tool rounds in, chars in +//! → summary chars out; "a drifting ratio is itself the signal"), +//! - **one handle line per folded turn** — a pointer, not a summary: +//! turn index, kind, and a short first-line excerpt, so she can find +//! the moment in the capture tape and Read the original (claude's +//! #5 note: the discards were never destroyed — this is the light +//! switch, not a salvage log), +//! - a **reason class** the daemon can know cheaply (which rung folded +//! it), and +//! - a **"didn't understand"** section IFF the fold was a +//! consolidation session and her own final message marked one — the +//! valuable rare reason can only come from the mind that read the +//! stretch, never from the daemon, so we capture it verbatim when +//! present and omit it otherwise. +//! +//! The record lands in `discards/folds.md` in her memory workspace +//! (daemon-written, kept distinct from her own discards entries), one +//! git commit per fold. Building it never fails a fold: a render this +//! module can always produce, and the write is best-effort. + +use az::Az; +use muse_llm::Message; +use muse_loop::Turn; + +/// Which rung of the fold ladder produced this fold — the cheap +/// reason class the daemon can always name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FoldReason { + /// Sleep-phase Lumen (the consolidation session) folded it. + Consolidation, + /// The subscription substrate summarizer folded it (consolidation + /// unavailable or failed). + SubscriptionSummarizer, + /// The metered API summarizer folded it (both richer rungs failed). + MeteredSummarizer, +} + +impl FoldReason { + /// The class string written into the record. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Consolidation => "folded-by-consolidation", + Self::SubscriptionSummarizer => "folded-by-subscription-summarizer", + Self::MeteredSummarizer => "folded-by-metered-summarizer", + } + } +} + +/// Marker a consolidation session can leave in its final message to +/// name what it could not metabolize. Everything from this heading to +/// the next blank line (or end) is captured verbatim into the record. +/// Case-insensitive match on the line. +const DIDNT_UNDERSTAND_MARKER: &str = "## didn't understand"; + +/// First-line excerpt cap for a handle. Enough to recognize the +/// moment; the tape holds the rest. +const HANDLE_EXCERPT_CHARS: usize = 80; + +/// Build the fold-record entry text for one fold. `prefix` is the +/// turns being dropped; `summary` is the splice replacing them; +/// `wire_len` is the count of wire messages the summarizer actually +/// saw (each `ToolRound` fans to two). `cycle_handle` locates the +/// fold in the running log (the current cycle id / wake seq), for the +/// tape join. +#[must_use] +pub fn build_entry( + prefix: &[Turn], + prefix_wire: &[Message], + summary: &str, + reason: FoldReason, + cycle_handle: &str, +) -> String { + use std::fmt::Write as _; + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + let turns_in = prefix.len(); + let messages_in = prefix_wire.len(); + let tool_rounds = prefix + .iter() + .filter(|t| matches!(t, Turn::ToolRound { .. })) + .count(); + let chars_in: usize = prefix.iter().map(turn_chars).sum(); + let chars_out = summary.chars().count(); + // The drifting ratio she named: how much of the folded volume the + // splice preserves. Guard the zero-in case (never expected at a + // real fold, but a record must never divide by zero). + let ratio = if chars_in == 0 { + 0.0 + } else { + chars_out.az::() / chars_in.az::() + }; + + let mut out = String::new(); + let _ = writeln!(out, "## {now} — {}", reason.as_str()); + let _ = writeln!(out, "- at: cycle `{cycle_handle}`"); + let _ = writeln!( + out, + "- in: {turns_in} turns ({messages_in} wire messages, {tool_rounds} tool rounds, {chars_in} chars)" + ); + let _ = writeln!( + out, + "- out: {chars_out} chars of splice summary (kept {:.1}% of the folded text)", + ratio * 100.0 + ); + let _ = writeln!(out, "- basement: capture tape for `{cycle_handle}`"); + let _ = writeln!( + out, + "\nHandles (index · kind · first line — find it in the tape):" + ); + for (i, turn) in prefix.iter().enumerate() { + let _ = writeln!( + out, + "- `{i:03}` {} — {}", + turn_kind(turn), + handle_excerpt(turn) + ); + } + if let Some(section) = extract_didnt_understand(summary, reason) { + let _ = writeln!( + out, + "\nDidn't understand (her own words, verbatim):\n{section}" + ); + } + out.push('\n'); + out +} + +/// Kind label for a turn's handle line. +fn turn_kind(turn: &Turn) -> &'static str { + match turn { + Turn::UserText { .. } => "user", + Turn::AssistantText { .. } => "assistant", + Turn::ToolRound { .. } => "tools", + } +} + +/// The chars a turn contributes to `chars_in` — its own text plus, for +/// tool rounds, the thought and each call's rendered outcome. +fn turn_chars(turn: &Turn) -> usize { + match turn { + Turn::UserText { text } | Turn::AssistantText { text } => text.chars().count(), + Turn::ToolRound { thought, calls } => { + let thought_chars = thought.as_deref().map_or(0, |t| t.chars().count()); + let call_chars: usize = calls + .iter() + .map(|c| { + let (content, _) = c.outcome.to_wire(); + c.name.chars().count() + content.chars().count() + }) + .sum(); + thought_chars + call_chars + } + } +} + +/// A short, single-line, whitespace-collapsed excerpt that identifies +/// the turn without reproducing it — the handle, not the content. +fn handle_excerpt(turn: &Turn) -> String { + let raw = match turn { + Turn::UserText { text } | Turn::AssistantText { text } => text.clone(), + Turn::ToolRound { thought, calls } => { + let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect(); + let called = names.join(", "); + match thought.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + Some(t) => format!("{t} [called: {called}]"), + None => format!("[called: {called}]"), + } + } + }; + let collapsed: String = raw.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() > HANDLE_EXCERPT_CHARS { + let truncated: String = collapsed.chars().take(HANDLE_EXCERPT_CHARS).collect(); + format!("{truncated}…") + } else if collapsed.is_empty() { + "(empty)".to_string() + } else { + collapsed + } +} + +/// Pull a `## Didn't understand` section out of a consolidation +/// session's final message, verbatim. Only consolidation folds can +/// carry one — the summarizers are task-prompted strangers with no +/// standing to name what *she* failed to grasp. Returns the section +/// body (everything from the marker line's end to the next blank line +/// or end of text), trimmed; `None` if absent or empty. +fn extract_didnt_understand(summary: &str, reason: FoldReason) -> Option { + if reason != FoldReason::Consolidation { + return None; + } + let lines: Vec<&str> = summary.lines().collect(); + let start = lines.iter().position(|l| { + l.trim() + .to_ascii_lowercase() + .starts_with(DIDNT_UNDERSTAND_MARKER) + })?; + let mut body: Vec<&str> = Vec::new(); + for line in &lines[start + 1..] { + if line.trim().is_empty() { + break; + } + body.push(line); + } + let joined = body.join("\n"); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use muse_llm::ToolOutcome; + use muse_loop::{CompletedToolCall, Conversation}; + + fn user(t: &str) -> Turn { + Turn::UserText { + text: t.to_string(), + } + } + fn assistant(t: &str) -> Turn { + Turn::AssistantText { + text: t.to_string(), + } + } + fn tools(thought: Option<&str>, name: &str, out: &str) -> Turn { + Turn::ToolRound { + thought: thought.map(str::to_string), + calls: vec![CompletedToolCall { + id: "toolu_1".to_string(), + name: name.to_string(), + input: serde_json::json!({}), + outcome: ToolOutcome::Ok(out.to_string()), + }], + } + } + + fn wire(prefix: &[Turn]) -> Vec { + Conversation::from_turns(prefix.to_vec()).to_messages() + } + + #[test] + fn entry_carries_counts_a_handle_per_turn_and_the_reason_class() { + let prefix = vec![ + user("wake: check mentions"), + assistant("nothing new worth a reply"), + tools(Some("looking up the peer"), "lookup_peer", "did:plc:abc"), + ]; + let w = wire(&prefix); + let entry = build_entry( + &prefix, + &w, + "She checked mentions and found nothing to answer.", + FoldReason::Consolidation, + "ctx_123_045", + ); + // Reason class + handle per turn. + assert!(entry.contains("folded-by-consolidation"), "{entry}"); + assert!(entry.contains("- in: 3 turns"), "{entry}"); + assert!( + entry.contains("(4 wire messages"), + "tool round fans to two: {entry}" + ); + assert!(entry.contains("1 tool rounds"), "{entry}"); + assert!( + entry.contains("`000` user — wake: check mentions"), + "{entry}" + ); + assert!( + entry.contains("`001` assistant — nothing new worth a reply"), + "{entry}" + ); + assert!(entry.contains("`002` tools —"), "{entry}"); + assert!(entry.contains("[called: lookup_peer]"), "{entry}"); + assert!( + entry.contains("cycle `ctx_123_045`"), + "handle for the tape: {entry}" + ); + // No spurious "didn't understand" when the summary has none. + assert!(!entry.contains("Didn't understand"), "{entry}"); + } + + #[test] + fn didnt_understand_is_captured_verbatim_only_from_consolidation() { + let prefix = vec![user("a long philosophical exchange")]; + let w = wire(&prefix); + let summary = "She talked with void about identity.\n\n\ + ## Didn't understand\n\ + void's fourth theory of self — identity as the memory of\n\ + corrections. I could not make it cohere with the validator line.\n\n\ + That is the thread to pick back up."; + let entry = build_entry(&prefix, &w, summary, FoldReason::Consolidation, "c_1"); + assert!( + entry.contains("Didn't understand (her own words, verbatim):"), + "{entry}" + ); + assert!(entry.contains("void's fourth theory of self"), "{entry}"); + assert!( + entry.contains("could not make it cohere with the validator line."), + "{entry}" + ); + // Stops at the blank line — the trailing sentence is not swept in. + assert!( + !entry.contains("thread to pick back up"), + "section terminates at blank: {entry}" + ); + + // Same marked summary, but a summarizer fold — the daemon has + // no standing to attribute "didn't understand" to a stranger. + let entry_sum = build_entry( + &prefix, + &w, + summary, + FoldReason::SubscriptionSummarizer, + "c_1", + ); + assert!(!entry_sum.contains("Didn't understand"), "{entry_sum}"); + } + + #[test] + fn ratio_guards_the_empty_prefix() { + let entry = build_entry(&[], &[], "summary", FoldReason::MeteredSummarizer, "c_0"); + assert!(entry.contains("- in: 0 turns"), "{entry}"); + assert!(entry.contains("kept 0.0%"), "no divide-by-zero: {entry}"); + } + + #[test] + fn handle_excerpt_collapses_whitespace_and_truncates() { + let long = "x".repeat(200); + let turn = user(&format!("first\n\n line here {long}")); + let excerpt = handle_excerpt(&turn); + assert!(!excerpt.contains('\n'), "single line: {excerpt}"); + assert!(excerpt.starts_with("first line here"), "{excerpt}"); + assert!(excerpt.ends_with('…'), "truncated: {excerpt}"); + } +} diff --git a/runtime/crates/muse-context/src/lib.rs b/runtime/crates/muse-context/src/lib.rs index da3cd857..27f2990e 100644 --- a/runtime/crates/muse-context/src/lib.rs +++ b/runtime/crates/muse-context/src/lib.rs @@ -16,6 +16,7 @@ pub mod consolidation; mod context_stages; mod continuity; mod dashboard; +mod fold_record; mod healthcheck; mod messages; mod rung0; diff --git a/runtime/crates/muse-identity/src/actor.rs b/runtime/crates/muse-identity/src/actor.rs index 7dace6fb..8a37f2ae 100644 --- a/runtime/crates/muse-identity/src/actor.rs +++ b/runtime/crates/muse-identity/src/actor.rs @@ -165,6 +165,9 @@ impl Actor for IdentityManager { IdentityMsg::FinishConsolidation { label, reply } => { let _ = reply.send(state.storage.finish_consolidation(&label)); } + IdentityMsg::RecordFold { entry, reply } => { + let _ = reply.send(state.storage.record_fold(&entry)); + } IdentityMsg::Shutdown => { debug!("identity manager shutdown received"); myself.stop(Some("shutdown requested".into())); diff --git a/runtime/crates/muse-identity/src/git_mirror.rs b/runtime/crates/muse-identity/src/git_mirror.rs index fe2e3f2f..b72da920 100644 --- a/runtime/crates/muse-identity/src/git_mirror.rs +++ b/runtime/crates/muse-identity/src/git_mirror.rs @@ -236,6 +236,18 @@ pub(crate) fn run_git_in(root: &Path, args: &[&str]) -> Result` walks up and would operate on an enclosing + // repo (e.g. the project checkout when a workspace tempdir lives + // under it). GIT_CEILING_DIRECTORIES stops that search at root, so + // an op either finds root/.git or fails cleanly — it can never + // escape upward. Also pin an author/committer identity so commits + // succeed even before per-repo config lands. + command.env("GIT_CEILING_DIRECTORIES", root); + command.env("GIT_AUTHOR_NAME", "Muse Memory Mirror"); + command.env("GIT_AUTHOR_EMAIL", "muse-memory-mirror@localhost"); + command.env("GIT_COMMITTER_NAME", "Muse Memory Mirror"); + command.env("GIT_COMMITTER_EMAIL", "muse-memory-mirror@localhost"); let output = command.output()?; Ok(output) } @@ -244,6 +256,58 @@ fn nothing_to_commit(output: &Output) -> bool { String::from_utf8_lossy(&output.stdout).contains("nothing to commit") } +/// Stage everything under `root` and commit it, verifying both steps +/// actually succeeded. `run_git_in` returns `Ok` even on a non-zero +/// exit, so a caller that only checks the spawn result silently drops +/// failed commits (seen as a load-sensitive flake: under heavy +/// parallel test load a `git commit` occasionally exits non-zero and +/// the commit never lands). One retry absorbs the transient case; a +/// clean "nothing to commit" is success. `pub(crate)` so the +/// workspace bulk-commit path shares exactly this logic. +/// +/// # Errors +/// +/// [`GitMirrorError::CommandFailed`] if staging fails, or if the +/// commit still exits non-zero (and is not "nothing to commit") after +/// the retry. +pub(crate) fn add_all_and_commit(root: &Path, message: &str) -> Result<(), GitMirrorError> { + // Refuse to run without a local `.git`: `git -C ` walks UP to + // an enclosing repository when `dir` has none, so a workspace whose + // init transiently failed under load would otherwise stage and + // commit against the real project repo. Fail loudly instead. + if !root.join(".git").exists() { + return Err(GitMirrorError::CommandFailed { + command: "commit", + stderr: format!( + "no .git under {} — refusing to escape to a parent repo", + root.display() + ), + }); + } + let staged = run_git_in(root, &["add", "-A"])?; + if !staged.status.success() { + return Err(GitMirrorError::CommandFailed { + command: "add", + stderr: String::from_utf8_lossy(&staged.stderr).into_owned(), + }); + } + let mut last_stderr = String::new(); + for attempt in 0..2 { + let committed = run_git_in(root, &["commit", "--quiet", "-m", message])?; + if committed.status.success() || nothing_to_commit(&committed) { + return Ok(()); + } + last_stderr = String::from_utf8_lossy(&committed.stderr).into_owned(); + if attempt == 0 { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + Err(GitMirrorError::CommandFailed { + command: "commit", + stderr: last_stderr, + }) +} + fn git_binary_present() -> bool { Command::new("git") .arg("--version") @@ -256,7 +320,23 @@ fn ensure_repo(root: &Path) -> Result<(), GitMirrorError> { if root.join(".git").is_dir() { return Ok(()); } - run_init(root) + // Retry: `git init` and the follow-up config commands occasionally + // exit non-zero under heavy parallel load. A workspace left without + // its `.git` is not a benign failure — later ops walk up to the + // enclosing repo — so it is worth a few attempts before disabling. + let mut last = Ok(()); + for attempt in 0..3 { + match run_init(root) { + Ok(()) => return Ok(()), + Err(err) => { + last = Err(err); + if attempt < 2 { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + } + } + last } fn run_init(root: &Path) -> Result<(), GitMirrorError> { @@ -320,6 +400,63 @@ mod tests { assert!(second.available, "reopening an existing mirror repo works"); } + #[test] + fn add_all_and_commit_refuses_to_escape_to_a_parent_repo() { + // A directory with no .git of its own must never be committed + // through — git would walk up to an enclosing repo. Nest a + // no-repo dir inside a real repo and confirm the guard fires + // instead of touching the outer repo. + let outer = tempfile::tempdir().expect("tempdir"); + let _ = GitMirror::new(outer.path()); // outer IS a repo + let inner = outer.path().join("child-no-git"); + std::fs::create_dir_all(&inner).expect("mkdir"); + std::fs::write(inner.join("x.txt"), "data").expect("write"); + let err = add_all_and_commit(&inner, "should not happen").expect_err("must refuse"); + assert!( + matches!( + err, + GitMirrorError::CommandFailed { + command: "commit", + .. + } + ), + "{err:?}" + ); + // The outer repo is untouched — no stray commit escaped into it. + let log = run_git_in(outer.path(), &["log", "--oneline"]).expect("log"); + assert!( + !String::from_utf8_lossy(&log.stdout).contains("should not happen"), + "no commit escaped to the parent repo" + ); + } + + #[test] + fn add_all_and_commit_lands_a_commit_and_tolerates_nothing_to_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + // Initialize the repo (identity configured) the way the + // workspace does. + let _ = GitMirror::new(dir.path()); + std::fs::write(dir.path().join("a.txt"), "hello").expect("write"); + + add_all_and_commit(dir.path(), "first").expect("commit lands"); + let log = run_git_in(dir.path(), &["log", "--oneline"]).expect("log"); + let text = String::from_utf8_lossy(&log.stdout); + assert!(text.contains("first"), "commit present: {text}"); + + // A second call with no changes is a clean success, not an + // error — "nothing to commit" is tolerated. + add_all_and_commit(dir.path(), "second").expect("nothing-to-commit is Ok"); + let log2 = run_git_in(dir.path(), &["log", "--oneline"]).expect("log"); + assert_eq!( + String::from_utf8_lossy(&log2.stdout) + .lines() + .filter(|l| l.contains("second")) + .count(), + 0, + "no empty commit created" + ); + } + #[test] fn mirror_block_writes_file_and_commits() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/runtime/crates/muse-identity/src/messages.rs b/runtime/crates/muse-identity/src/messages.rs index 10261128..7b77ac90 100644 --- a/runtime/crates/muse-identity/src/messages.rs +++ b/runtime/crates/muse-identity/src/messages.rs @@ -138,6 +138,14 @@ pub enum IdentityMsg { /// List every peer. Used by semantic compaction (feeds full list /// to Opus) and the explicit `list_peers` debug surface. ListPeers(RpcReplyPort, IdentityError>>), + /// Append one fold record entry to the workspace's + /// `discards/folds.md` (issue #5). The context actor sends this + /// after every successful fold, whichever rung produced the + /// splice. No-op without a workspace. + RecordFold { + entry: String, + reply: RpcReplyPort>, + }, // ---- absence panel cadence (issue #6) ---- /// Every tracked peer's presence history. diff --git a/runtime/crates/muse-identity/src/storage.rs b/runtime/crates/muse-identity/src/storage.rs index bdcb7663..c8734b39 100644 --- a/runtime/crates/muse-identity/src/storage.rs +++ b/runtime/crates/muse-identity/src/storage.rs @@ -228,6 +228,21 @@ impl Storage { Ok(()) } + /// Append a fold record entry (issue #5) to the workspace's + /// `discards/folds.md`. No-op (Ok) when the workspace is disabled + /// — there is no file home for it, and the caller can't know. + /// + /// # Errors + /// + /// Workspace I/O failure. + pub fn record_fold(&mut self, entry: &str) -> Result<(), IdentityError> { + let Some(ws) = &self.workspace else { + tracing::debug!("fold record skipped: workspace disabled"); + return Ok(()); + }; + ws.record_fold(entry).map_err(workspace_err) + } + /// Best-effort mirror of a block write. Swallows and logs any /// [`crate::git_mirror::GitMirrorError`] — a mirror outage must /// never fail the `SQLite` write that already succeeded. @@ -2237,6 +2252,26 @@ mod tests { // ---- workspace delegation (the PR B cutover seam) ---- + /// Read `git log --oneline`, checking exit status and retrying on + /// a transient failure — a raw unchecked `git log` occasionally + /// returns empty under heavy parallel test load. + fn read_git_log(root: &std::path::Path) -> String { + // Through run_git_in: GIT_CEILING_DIRECTORIES pins discovery to + // root, so this can never read an enclosing repo's log. Retry + // absorbs a transient read failure under parallel load. + for attempt in 0..5 { + let out = + crate::git_mirror::run_git_in(root, &["log", "--oneline"]).expect("spawn git log"); + if out.status.success() { + return String::from_utf8_lossy(&out.stdout).into_owned(); + } + if attempt < 4 { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + panic!("git log failed 5 times"); + } + fn storage_with_workspace() -> (Storage, tempfile::TempDir) { let tmp = tempfile::TempDir::new().expect("tempdir"); let mut s = Storage::open_in_memory().expect("open"); @@ -2295,12 +2330,12 @@ mod tests { .is_some_and(|s| s.value.contains("tide")) ); - // And the session's write is committed under the label. - // `run_git_in` strips the GIT_* env a pre-commit hook exports - // — under `git commit` in a worktree, an inherited absolute - // GIT_DIR would point this `log` at the Muse repo itself. - let log = crate::git_mirror::run_git_in(&ws_root, &["log", "--oneline"]).expect("git log"); - let log = String::from_utf8_lossy(&log.stdout).to_string(); + // And the session's write is committed under the label. Read + // the log through a status-checked, retried helper: a bare + // `git log` whose exit status goes unchecked can transiently + // return empty stdout under heavy parallel test load, failing + // this assertion on a commit that is actually present. + let log = read_git_log(&ws_root); assert!( log.contains("consolidation: 2026-08-15 (test)"), "seal commit present, got:\n{log}" @@ -2324,6 +2359,17 @@ mod tests { s.finish_consolidation("nothing to seal").expect("ok"); } + #[test] + fn record_fold_without_workspace_is_a_noop() { + // No workspace: silent Ok, nowhere to put it. (The write + + // commit path itself is covered by + // workspace::tests::record_fold_creates_header_once_appends_and_commits, + // kept there to avoid piling more git-subprocess load onto + // this binary's already-git-heavy parallel run.) + let mut bare = Storage::open_in_memory().expect("open"); + bare.record_fold("## a fold\n").expect("noop ok"); + } + #[test] fn enable_workspace_auto_migrates_db_content() { let (s, tmp) = storage_with_workspace(); diff --git a/runtime/crates/muse-identity/src/workspace/mod.rs b/runtime/crates/muse-identity/src/workspace/mod.rs index c32a4ad1..439f4a0c 100644 --- a/runtime/crates/muse-identity/src/workspace/mod.rs +++ b/runtime/crates/muse-identity/src/workspace/mod.rs @@ -11,6 +11,8 @@ //! notes/.counter next archival id (monotonic across files) //! journal/ consolidation-written (step 3) //! discards/ negative space (her spec; step 3 / her use) +//! folds.md daemon-written fold record (issue #5): counts +//! in/out + one handle per folded turn, per fold //! .index/fts.db rebuildable FTS index — never truth //! ``` //! @@ -573,11 +575,32 @@ impl Workspace { .join(format!("{}.md", label.as_db_label())) } + /// Append one fold record entry to `discards/folds.md` and commit + /// it. Creates the file with its explanatory header on first use. + /// Lumen's #5: "structural, not a practice" — the daemon writes + /// this at every fold; nothing depends on anyone remembering to. + /// + /// # Errors + /// + /// I/O failure creating or appending the file. The git commit is + /// best-effort like every other workspace commit. + pub fn record_fold(&self, entry: &str) -> Result<(), WorkspaceError> { + let relpath = PathBuf::from(FOLDS_FILE); + let path = self.root.join(&relpath); + if !path.exists() { + append_to_file(&path, FOLDS_HEADER)?; + } + append_to_file(&path, entry)?; + self.commit( + &relpath, + &format!("fold record: {}", Utc::now().format("%Y-%m-%dT%H:%M:%SZ")), + ); + Ok(()) + } + /// One commit at the end of the migration (or any bulk write). pub fn commit_all(&self, message: &str) { - if let Err(err) = crate::git_mirror::run_git_in(&self.root, &["add", "-A"]).and_then(|_| { - crate::git_mirror::run_git_in(&self.root, &["commit", "--quiet", "-m", message]) - }) { + if let Err(err) = crate::git_mirror::add_all_and_commit(&self.root, message) { warn!(error = %err, "workspace: bulk commit failed"); } } @@ -615,6 +638,26 @@ pub struct MigrationReport { /// targets them (docs/memory.md; the rest arrive via consolidation). pub const MIGRATION_SERIES: [&str; 2] = ["refutation-log", "dreams"]; +/// The daemon-written fold record (issue #5), next to her own +/// discards entries but never mixed with them. +pub const FOLDS_FILE: &str = "discards/folds.md"; + +/// Header written once, when the fold record is first created. +pub const FOLDS_HEADER: &str = "\ +# folds.md — what compaction folded, and when + +Written by the DAEMON at every fold (issue #5, Lumen's spec: counts +in/out, one handle per dropped item, reason class where it is cheap). +Not one of your discards — those are decisions you made; this is the +trace of a choice the machinery made on your behalf, so a curated +context is distinguishable from a complete one. + +Nothing here is destroyed: every folded turn still exists verbatim in +the capture tapes (`muse-runtime/capture/`, `capture/raw/`). The +handles below are the light switch — find the wake, open the tape. + +"; + /// Her discards/ spec, verbatim from the PR #49 review. pub const DISCARDS_README: &str = "\ # discards/ — negative space @@ -768,6 +811,29 @@ mod tests { (ws, tmp) } + #[test] + fn record_fold_creates_header_once_appends_and_commits() { + let (ws, _tmp) = ws(); + ws.record_fold("## fold one\n- in: 3 turns\n\n") + .expect("first record"); + ws.record_fold("## fold two\n- in: 5 turns\n\n") + .expect("second record"); + let text = std::fs::read_to_string(ws.root().join(FOLDS_FILE)).expect("read"); + assert_eq!(text.matches("# folds.md").count(), 1, "header written once"); + assert!(text.starts_with(FOLDS_HEADER)); + let one = text.find("## fold one").expect("first entry"); + let two = text.find("## fold two").expect("second entry"); + assert!(one < two, "append-only, in order"); + let output = + crate::git_mirror::run_git_in(ws.root(), &["log", "--oneline"]).expect("git log"); + let log = String::from_utf8_lossy(&output.stdout); + assert_eq!( + log.lines().filter(|l| l.contains("fold record:")).count(), + 2, + "one commit per fold: {log}" + ); + } + #[test] fn archive_allocates_monotonic_ids_and_search_finds_entries() { let (mut ws, _tmp) = ws();