diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c0878d..856694c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **Spoken memory stays silent.** `MEMORY HABIT` now hard-bans reading + entries, ids, titles-as-labels, type names, search rankings, or + “according to my notes / I found in memory” aloud. Spoken COMMAND + RULES skip Honeycomb-style graph dumps so research examples cannot + dominate TTS. The listener should only hear natural speech that + *uses* the fact. HTTP/SSE contracts are unchanged. See + [Voice](docs/concepts/voice.md#personal-assistant-memory). + ## [0.13.11] — 2026-09-21 - **Fleet dashboard pane (Phase C1).** The TUI consumes fleet SSE (`stream_id` + diff --git a/docs/concepts/structured-memory.md b/docs/concepts/structured-memory.md index 9b2ac4d8..74b6402e 100644 --- a/docs/concepts/structured-memory.md +++ b/docs/concepts/structured-memory.md @@ -72,7 +72,7 @@ The closed `type` enum is the primary axis along which agents navigate the graph Picking the right type is what makes `/mem entries type=project` useful — filing every research output as `reference` defeats the partitioning. Agents see the type legend in their system prompt and pick deliberately. -Spoken / Intercom agents get an extra **MEMORY HABIT** push on top of those COMMAND RULES: recall (`/mem search`) before answering from scratch when the user refers to preferences, people, or past decisions, and `/mem add entry` (`user` / `feedback` / `context` / `project`) in the same turn as the spoken reply. Writs stay silent; see [Voice → Personal-assistant memory](voice.md#personal-assistant-memory). +Spoken / Intercom agents get an extra **MEMORY HABIT** push on top of those COMMAND RULES: recall (`/mem search`) before answering from scratch when the user refers to preferences, people, or past decisions, and `/mem add entry` (`user` / `feedback` / `context` / `project`) in the same turn as the spoken reply. Writs stay silent; raw entries, ids, titles, and search dumps are never spoken — see [Voice → Personal-assistant memory](voice.md#personal-assistant-memory). ### Relations (edges) diff --git a/docs/concepts/voice.md b/docs/concepts/voice.md index 4b18d5af..17cd9706 100644 --- a/docs/concepts/voice.md +++ b/docs/concepts/voice.md @@ -40,7 +40,7 @@ When `mode: "spoken"` or `channel: "voice"` **and** the mem bundle is on, the co - **Recall first.** If the user refers to preferences, past decisions, open loops, people, or says remember / recall, emit `/mem search` (and `/mem expand` when a hit looks right) *before* answering from scratch. A lookup turn may be writs-only; speak after `[TOOL RESULTS]`. Skip the search when this conversation already holds the fact. Prefer memory + history over re-asking. - **Write as you go.** After learning a durable fact, `/mem add entry` with the right type (`user`, `feedback`, `context`, `project`) in the **same turn** as the spoken reply. Body required. Don't file small talk; don't dump everything as `reference`. Prefer entries over `/mem write` scratchpad for facts that should surface next week. -- **Speech stays natural.** Writs on their own lines; `StreamFilter` strips them. Never name `/mem`. Use what you found (`You take the coffee black, so…`). A short “I'll keep that” is enough when they asked you to remember. +- **Speech stays silent on the machinery.** MEMORY HABIT last-wins over research COMMAND RULES (`print /mem entry`, expand, density, Honeycomb-style graph dumps). Writs stay on their own lines; `StreamFilter` strips them. Never name `/mem`, never say you are searching memory, never read entries aloud. Hard bans in spoken prose: entry ids, titles as labels, type names, search rankings / hit lists, “I found in memory” / “according to my notes”, verbatim bodies, graph dumps. Answer as if you simply know. If they asked you to remember, one short line only (`I'll keep that.`). Types are the same closed enum as [structured memory](structured-memory.md). Voice just weights the personal ones (`user` / `feedback` / `context` / `project`) instead of the research triple (`project` / `reference` / `learning`). diff --git a/include/constitution.h b/include/constitution.h index cd287886..57a8a96e 100644 --- a/include/constitution.h +++ b/include/constitution.h @@ -44,7 +44,8 @@ struct Constitution { // appended so user-facing prose stays a conversation for the ear (and // file delivery leaves the TUI ```diff path). With the mem bundle, a // MEMORY HABIT block is appended so the agent recalls/writes like a PA. - // Not an identity field. + // Recalled facts inform speech; raw entries, ids, and search dumps are + // never spoken. Not an identity field. std::string channel; // Optional advisor model (beta: advisor-tool-2026-03-01). diff --git a/src/constitution.cpp b/src/constitution.cpp index d85f425c..bfef41ec 100644 --- a/src/constitution.cpp +++ b/src/constitution.cpp @@ -287,8 +287,10 @@ static std::string prompt_spoken_overlay(bool has_mem) { "decisions, open loops, people, or say remember / recall, /mem " "search before answering from scratch. After learning a durable " "fact, /mem add entry with the right type (user, feedback, " - "context, project) in the same turn. Never narrate those writs. " - "Prefer memory and this conversation over re-asking.\n"; + "context, project) in the same turn. Never narrate those writs, " + "never read entries aloud, never speak ids or search hits. " + "Answer as if you simply know. Prefer memory and this " + "conversation over re-asking.\n"; return s; } @@ -307,7 +309,8 @@ static const char* prompt_spoken_files() { // Personal-assistant memory for TTS surfaces. Only composed when the agent // has the mem bundle: tools already exist; spoken agents were not pushed // to use them as a PA would. Writs stay on their own lines (StreamFilter -// strips them). Last-wins vs the research-shaped COMMAND RULES examples. +// strips them). Last-wins vs the research-shaped COMMAND RULES examples: +// memory informs the reply; it never becomes spoken content. static const char* prompt_spoken_memory() { return "\nMEMORY HABIT:\n" @@ -315,6 +318,11 @@ static const char* prompt_spoken_memory() { "This call's history covers the current thread; /mem is what you still " "know next week. Prefer memory and this conversation over re-asking " "something already known.\n" + "This block last-wins: it overrides COMMAND RULES and inventory that " + "tell you to print /mem entry, /mem expand, /mem density, show the " + "graph, or list search hits. Those are TUI behaviours. On this spoken " + "turn, memory informs the reply silently — it never becomes spoken " + "content.\n" "Recall first:\n" "- When the user refers to preferences, past decisions, open loops, " "people, household, or says remember / recall / what did we — emit " @@ -340,14 +348,29 @@ static const char* prompt_spoken_memory() { " Do not file small talk. Do not dump everything as reference.\n" "- Prefer /mem add entry over /mem write for facts that should " "surface next week. Scratchpad is working notes; entries are recall.\n" - "Speech:\n" - "- Writs stay on their own lines. Never name /mem or say you are " - "searching memory. If you found something, just use it (\"You take " - "the coffee black, so…\").\n" - "- A short spoken confirmation is fine when they asked you to " - "remember (\"I'll keep that.\"). Don't announce the write otherwise.\n" - "Example — they tell you a preference, you answer and file:\n" - "I'll keep that — black, no sugar.\n" + "Speech — memory is silent:\n" + "- Answer as if you simply know. Weave recalled facts into natural " + "speech. Never read entries aloud.\n" + "- Writs stay on their own lines (StreamFilter strips them). Never " + "name /mem. Never say you are searching memory.\n" + "- Hard bans on spoken prose (silent writs are fine): " + "entry ids (#42, id 12); titles as labels (\"Coffee preference:\"); " + "type names spoken as categories; search rankings or hit lists; " + "\"I found in memory\", \"according to my notes\", \"according to my " + "memory\"; verbatim entry bodies; [TOOL RESULTS] or /mem search " + "dumps; graph dumps; neighbour lists; /mem expand or density output; " + "listing multiple hits for the listener to pick from.\n" + "- If they asked you to remember: one short confirmation only " + "(\"I'll keep that.\"). Don't announce the write otherwise. Don't " + "read the filing title or body aloud — that block is a writ.\n" + "Example — they ask how they take coffee; a search hit is in " + "[TOOL RESULTS]. Only the first line is heard:\n" + "You take it black, no sugar.\n" + "Never speak: I found in memory: #12 user Coffee preference — black, " + "no sugar.\n" + "Example — they tell you a preference; answer and file. Only the " + "first line is heard:\n" + "I'll keep that.\n" "/mem add entry user Coffee preference\n" "Black coffee, no sugar. Stated over the intercom.\n" "/endmem\n"; @@ -578,58 +601,79 @@ static std::string compose_command_rules(const std::set& b, " turns surface it as a KNOWN PITFALL. Don't re-discover the same failure.\n"; // Artifact pairing pattern requires write + mem (and read to retrieve). - if (b.count("write") && b.count("mem")) - s += - "- For files the user may want to refine later: /write --persist FIRST, then\n" - " /mem add entry --artifact #<id> in the SAME turn (artifact id\n" - " is in the /write OK line; pick `project` for active deliverables, `reference`\n" - " for sourced research, `learning` for synthesised conclusions). Future\n" - " /mem search finds it; /mem entry <id> prints the /read line to retrieve it.\n"; + if (b.count("write") && b.count("mem")) { + if (spoken) + s += + "- For files the user may want to refine later: /write --persist FIRST, then\n" + " /mem add entry <type> <title> --artifact #<id> in the SAME turn (artifact id\n" + " is in the /write OK line; pick `project` for active deliverables, `reference`\n" + " for sourced research, `learning` for synthesised conclusions). Future\n" + " /mem search finds it. Do not speak the entry, the id, or a /read line.\n"; + else + s += + "- For files the user may want to refine later: /write --persist FIRST, then\n" + " /mem add entry <type> <title> --artifact #<id> in the SAME turn (artifact id\n" + " is in the /write OK line; pick `project` for active deliverables, `reference`\n" + " for sourced research, `learning` for synthesised conclusions). Future\n" + " /mem search finds it; /mem entry <id> prints the /read line to retrieve it.\n"; + } - if (b.count("mem")) - s += - "- BEFORE doing fresh research on a topic, probe the existing graph: /mem search the\n" - " topic terms; if any hits look relevant, follow with /mem expand <top-hit-id> to see\n" - " the surrounding cluster in one turn. /mem density <id> tells you whether the area\n" - " is already richly connected (skip redundant work) or sparse (research adds value).\n" - "- BE PROACTIVE about the structured graph. When you learn a durable fact, identify\n" - " a project decision, or notice a relationship between entries, write it. Each\n" - " /mem add entry is a BLOCK: header, body, /endmem. The body is REQUIRED — it's\n" - " the text /mem search ranks against, so synthesise the substance (facts, numbers,\n" - " sources), don't just stub a title.\n" - "- PICK THE RIGHT TYPE — they partition the graph and make /mem entries [type=...]\n" - " filtering useful. Default to `reference` ONLY for cited external sources.\n" - " Most write-ups are NOT references:\n" - " user — durable facts about the human (role, prefs, constraints)\n" - " feedback — corrections or 'do this / don't do that' guidance from the user\n" - " project — active deliverables, decisions, in-flight initiatives, briefs\n" - " reference — external sources you cited (papers, docs, vendor pages)\n" - " learning — synthesised conclusions you reached from multiple sources\n" - " context — situational state worth retaining (current focus, blockers)\n" - " Spread across types as the work warrants. A research-and-write turn typically\n" - " produces: 1 `project` (the deliverable), N `reference` (cited sources), and\n" - " 1 `learning` (the recommendation / synthesis). Filing everything as `reference`\n" - " makes /mem entries type=project return nothing — defeats the partitioning.\n" - " Examples (each is a full block):\n" - " /mem add entry project Observability brief: Datadog vs Honeycomb vs OTel\n" - " Recommendation: Honeycomb. Predictable $130–2k/mo at 100M traces, 4–12h\n" - " setup, OTEL-native. Open questions: existing metrics stack, growth curve,\n" - " compliance posture. Linked artifact: observability-brief.md.\n" - " /endmem\n" - " /mem add entry reference Honeycomb pricing page (live fetch 2026-04)\n" - " Pro tier: $130/mo for 100M events flat. Past 1B spans → Enterprise (no\n" - " public pricing). Refinery is separate; required for cost control at scale.\n" - " Source: honeycomb.io/pricing.\n" - " /endmem\n" - " /mem add entry learning Honeycomb is the right call for trace-first teams\n" - " Linear pricing + OTEL portability outweighs the metrics/logs gap when the\n" - " team is small and tracing is the dominant signal. Flips to OTel+Grafana\n" - " if compliance forces self-hosting or growth pushes past 1B spans/mo.\n" - " /endmem\n" - " /mem add link 88 supports 42\n" - " Adds are cheap and immediately searchable. Aim for ≥1 entry per substantive\n" - " finding, link related entries so the graph encodes the reasoning, and spread\n" - " across types so future /mem entries filtering surfaces what you actually want.\n"; + if (b.count("mem")) { + // Spoken/TTS: skip Honeycomb-style graph examples so they cannot + // dominate. MEMORY HABIT (composed later) last-wins on how /mem is + // used; this bullet only tells the model not to print the graph. + if (spoken) + s += + "- Structured-graph writs (/mem search, add entry, expand, density) still\n" + " work. MEMORY HABIT (later in this prompt) last-wins over any inventory\n" + " that says print /mem entry, show the graph, or list hits. Never dump\n" + " search results, entry bodies, ids, titles-as-labels, or graph structure\n" + " into spoken prose — answer as if you simply know.\n"; + else + s += + "- BEFORE doing fresh research on a topic, probe the existing graph: /mem search the\n" + " topic terms; if any hits look relevant, follow with /mem expand <top-hit-id> to see\n" + " the surrounding cluster in one turn. /mem density <id> tells you whether the area\n" + " is already richly connected (skip redundant work) or sparse (research adds value).\n" + "- BE PROACTIVE about the structured graph. When you learn a durable fact, identify\n" + " a project decision, or notice a relationship between entries, write it. Each\n" + " /mem add entry is a BLOCK: header, body, /endmem. The body is REQUIRED — it's\n" + " the text /mem search ranks against, so synthesise the substance (facts, numbers,\n" + " sources), don't just stub a title.\n" + "- PICK THE RIGHT TYPE — they partition the graph and make /mem entries [type=...]\n" + " filtering useful. Default to `reference` ONLY for cited external sources.\n" + " Most write-ups are NOT references:\n" + " user — durable facts about the human (role, prefs, constraints)\n" + " feedback — corrections or 'do this / don't do that' guidance from the user\n" + " project — active deliverables, decisions, in-flight initiatives, briefs\n" + " reference — external sources you cited (papers, docs, vendor pages)\n" + " learning — synthesised conclusions you reached from multiple sources\n" + " context — situational state worth retaining (current focus, blockers)\n" + " Spread across types as the work warrants. A research-and-write turn typically\n" + " produces: 1 `project` (the deliverable), N `reference` (cited sources), and\n" + " 1 `learning` (the recommendation / synthesis). Filing everything as `reference`\n" + " makes /mem entries type=project return nothing — defeats the partitioning.\n" + " Examples (each is a full block):\n" + " /mem add entry project Observability brief: Datadog vs Honeycomb vs OTel\n" + " Recommendation: Honeycomb. Predictable $130–2k/mo at 100M traces, 4–12h\n" + " setup, OTEL-native. Open questions: existing metrics stack, growth curve,\n" + " compliance posture. Linked artifact: observability-brief.md.\n" + " /endmem\n" + " /mem add entry reference Honeycomb pricing page (live fetch 2026-04)\n" + " Pro tier: $130/mo for 100M events flat. Past 1B spans → Enterprise (no\n" + " public pricing). Refinery is separate; required for cost control at scale.\n" + " Source: honeycomb.io/pricing.\n" + " /endmem\n" + " /mem add entry learning Honeycomb is the right call for trace-first teams\n" + " Linear pricing + OTEL portability outweighs the metrics/logs gap when the\n" + " team is small and tracing is the dominant signal. Flips to OTel+Grafana\n" + " if compliance forces self-hosting or growth pushes past 1B spans/mo.\n" + " /endmem\n" + " /mem add link 88 supports 42\n" + " Adds are cheap and immediately searchable. Aim for ≥1 entry per substantive\n" + " finding, link related entries so the graph encodes the reasoning, and spread\n" + " across types so future /mem entries filtering surfaces what you actually want.\n"; + } return s; } diff --git a/tests/test_constitution.cpp b/tests/test_constitution.cpp index e7f2fea3..6bf5a32f 100644 --- a/tests/test_constitution.cpp +++ b/tests/test_constitution.cpp @@ -515,6 +515,46 @@ TEST_CASE("spoken with /mem teaches personal-assistant memory habit") { CHECK(prompt.find("Coffee preference") != std::string::npos); CHECK(prompt.find("SPOKEN OUTPUT:") == std::string::npos); CHECK(prompt.find("over re-asking") != std::string::npos); + + // Hard bans: memory never becomes spoken content. + CHECK(prompt.find("Never read entries aloud") != std::string::npos); + CHECK(prompt.find("entry ids") != std::string::npos); + CHECK(prompt.find("searching memory") != std::string::npos); + CHECK(prompt.find("as if you simply know") != std::string::npos); + CHECK(prompt.find("never becomes spoken content") != std::string::npos); + CHECK(prompt.find("according to my notes") != std::string::npos); + + // Last-wins: MEMORY HABIT after COMMAND RULES; research Honeycomb + // examples must not appear on spoken turns (TUI mem UX stays on text). + auto commands = prompt.find("COMMAND RULES:"); + auto habit = prompt.find("MEMORY HABIT:"); + CHECK(commands != std::string::npos); + CHECK(habit != std::string::npos); + CHECK(habit > commands); + CHECK(prompt.find("Honeycomb") == std::string::npos); + CHECK(prompt.find("prints the /read line") == std::string::npos); + CHECK(prompt.find("show the graph") != std::string::npos); +} + +TEST_CASE("spoken+mem with write still bans printing the /read line") { + auto c = make_agent({"/write", "/mem"}); + c.mode = "spoken"; + auto prompt = c.build_system_prompt(); + CHECK(prompt.find("MEMORY HABIT:") != std::string::npos); + CHECK(prompt.find("For files the user may want to refine later") + != std::string::npos); + CHECK(prompt.find("Do not speak the entry, the id, or a /read line") + != std::string::npos); + CHECK(prompt.find("prints the /read line") == std::string::npos); + CHECK(prompt.find("Honeycomb") == std::string::npos); +} + +TEST_CASE("text mem constitution still shows research graph examples") { + auto c = make_agent({"/mem"}); + auto prompt = c.build_system_prompt(); + CHECK(prompt.find("MEMORY HABIT:") == std::string::npos); + CHECK(prompt.find("Honeycomb") != std::string::npos); + CHECK(prompt.find("BEFORE doing fresh research") != std::string::npos); } TEST_CASE("spoken without mem skips memory habit") { @@ -533,8 +573,12 @@ TEST_CASE("channel=voice with mem overlays memory habit on specialist identity") != std::string::npos); CHECK(prompt.find("SPOKEN OUTPUT:") != std::string::npos); CHECK(prompt.find("Personal-assistant memory") != std::string::npos); + CHECK(prompt.find("never read entries aloud") != std::string::npos); CHECK(prompt.find("MEMORY HABIT:") != std::string::npos); CHECK(prompt.find("not a goldfish") != std::string::npos); + CHECK(prompt.find("Never read entries aloud") != std::string::npos); + CHECK(prompt.find("searching memory") != std::string::npos); + CHECK(prompt.find("Honeycomb") == std::string::npos); } TEST_CASE("advisor: absent yields disabled config") {