mirror-mind 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.38.0 — The Model Behind the Memories, Chosen by Measurement](v0.38.0.md) — minor release closing CV9.DS9: the model behind memories, tasks, titles, summaries, tags, reception, journal, shadow, and consolidation is Claude Haiku 5.5 at reasoning off, chosen on the eval suite with reasoning on and off and on 24 of the owner's own conversations ranked blind, where it came first on 16 of 26 surfaces. Every extraction-pin call can carry a reasoning effort, and a pin carries its own default. With CR180, summaries open with their subject, every lifecycle prompt sees the owner's name, and a framed opening is written once more. Release gate 70 of 70 probes. Rollback is one env line. No migration.
15
16
  - [v0.37.0 — The Mirror Stays in the Conversation](v0.37.0.md) — minor release closing CV9.DS8 and, with CV21.DS11, CV21: on Pi, Claude Code, Codex, and Antigravity one doorman in the core puts Mirror's context before the model on every turn of Mirror Mode, all of it on the turn Mirror Mode starts and then the persona and memories each question calls for; an explicit turn loads once; one signature, `◇ persona-name`; Pi reaches L4, measured live. One parity matrix in the runtime interface contract, sourced and held by a test. Codex, Antigravity, and Grok Build need their plugin copy brought level. Migration 018, applied on its own.
16
17
  - [v0.36.3 — A Skill You Invoke Is Not Something You Said](v0.36.3.md) — patch release of one Change Request, CR160, with CR159's docs: on Pi, a skill picked from the menu or typed is no longer stored as the person's words, and neither is its reply; a question asked with `/mm-mirror` is stored as the question. Pi's turns go through the rule the other runtimes have. `mirror conversations skill-turns` repairs what earlier versions stored, only on `--apply` and after a verified backup. No migration.
17
18
  - [v0.36.2 — Clearing Out What Another Home Copied](v0.36.2.md) — patch release of one Change Request, CR154, finishing what v0.36.1 began: `mirror conversations imported` lists the Pi conversations an earlier version imported, by the folder each session came from and beside how many sessions the home logged live there, and `remove` deletes the ones you name, only on `--apply` and after a verified backup. `discard-current` no longer fails on a conversation a Soul Mode integration names. No migration.
@@ -0,0 +1,215 @@
1
+ ---
2
+ digest: >
3
+ v0.38.0 is a minor release closing CV9.DS9. The model that writes your memories,
4
+ tasks, titles, summaries, and tags is now Claude Haiku 5.5 at reasoning off, chosen by
5
+ measurement: on the eval suite with reasoning on and off, and on 24 of the owner's own
6
+ conversations ranked blind, where it came first on 16 of 26 surfaces. Every call on
7
+ the extraction pin can carry a reasoning effort, and a pin carries its own default. The
8
+ summary prompt stops naming the conversation, every lifecycle prompt finally sees the
9
+ owner's name, and a summary that still opens by describing the conversation is
10
+ written once more. The release gate ran 70 of 70 probes clean. Rollback is one line.
11
+ ---
12
+
13
+ # v0.38.0 — The Model Behind the Memories, Chosen by Measurement
14
+
15
+ **Date:** 2026-10-29
16
+
17
+ ## Highlights
18
+
19
+ - The model behind memory and task extraction, titles, summaries, tags, reception,
20
+ journal classification, week plans, descriptors, shadow, and consolidation is now
21
+ `anthropic/claude-haiku-5.5`, at reasoning off. It was chosen on three measurements:
22
+ the eval suite with reasoning on, the suite with reasoning off, and 24 of the owner's
23
+ own conversations replayed through the product and ranked blind, where Haiku came
24
+ first on 16 of 26 surfaces, ahead of Gemini 2.5 Flash Lite 17–9 and of GPT-6 Luna
25
+ 20–6.
26
+ - Every call on the extraction pin can carry a reasoning effort
27
+ (`MEMORY_EXTRACTION_REASONING`: `none` to `max`), and a pin carries its own default:
28
+ Haiku sends `none`, the setting it was measured with. A model named by
29
+ `MEMORY_EXTRACTION_MODEL` sends what it was measured with too, so the rollback is
30
+ exactly the request Flash Lite always got.
31
+ - Summaries open with their subject, carrying its status, instead of "The conversation
32
+ centered on…"; and for the first time the title, summary, and tags prompts see the
33
+ owner's name rather than `User`. A summary that still opens by describing the
34
+ conversation is generated once more, with feedback.
35
+ - The release gate: `eval --all` 9 of 9 modules and 70 of 70 probes, the first fully
36
+ clean run in the development home's history. Flash Lite had failed four probes in
37
+ every recorded run.
38
+ - What you pay: about 40% more on these calls at typical volume, about a dollar a month
39
+ where Flash Lite cost seventy-five cents. What you wait: about half a second more on
40
+ reception, before a turn that then waits seconds for the answering model.
41
+ - No migration. Rollback is one line in `~/.config/mirror/env`.
42
+
43
+ ## Where We Started
44
+
45
+ Since mid-2025 one model, Gemini 2.5 Flash Lite, wrote everything Mirror writes about a
46
+ conversation after it ends. It dates from mid-2025, failed the same four eval probes in
47
+ every recorded run (duplicate memories across the two passes, a summary that reads as a
48
+ description of a conversation, a persona routed on an open existential question, a
49
+ trivial conversation given a title), and drew HTTP 429 from Google AI Studio on a third
50
+ of the eval suite's attempts. Two newer models cost about the same on OpenRouter. The
51
+ question was whether either wrote better memories for the person who reads them, and at
52
+ what cost in latency, reliability, and money.
53
+
54
+ The development guide treats a model pin as a versioned dependency and a swap as a
55
+ migration, measured before it is decided. CV9.DS9 was that migration, in three stories.
56
+
57
+ ## What Changed
58
+
59
+ ### Reasoning off, so the candidates could be measured at all (CV9.DS9.TS1)
60
+
61
+ Both candidates reason by default, and the transport sent nothing about it. Reasoning
62
+ spent the title call's 40-token budget, so 13 of 56 candidate title calls came back cut
63
+ off, mostly empty, and an empty title reads as a trivial conversation: silently
64
+ untitled. The blocking title-injection probe "passed" without an answer.
65
+
66
+ `MEMORY_EXTRACTION_REASONING` names an OpenRouter reasoning effort, sent as
67
+ `reasoning.effort` on every call that runs on the extraction pin, never on `consult`,
68
+ which picks its own model. A value that is not an effort is ignored rather than fatal,
69
+ because the setting is read inside the session-end hooks, where a failed call is an
70
+ extraction attempt and three quarantine a conversation; `runtime status` and
71
+ `runtime diagnose` show it. Eval records carry the effort, and `--history` labels a
72
+ flip across a model or reasoning change.
73
+
74
+ Re-measured at `none`, seven runs each: neither candidate had a call cut off in 1,428,
75
+ both passed every blocking probe 14 of 14, Luna kept every fix and improved two, and
76
+ Haiku lost one, the summary's opening, accepted as measured.
77
+
78
+ ### The owner's own conversations, ranked blind (CV9.DS9.TS2)
79
+
80
+ 24 ended conversations from the owner's production home, drawn by a recorded seed, were
81
+ replayed through the product's own functions: Flash Lite as it ships, Luna and Haiku at
82
+ `none`, one process per model. Kimi K3.1 Pro, a model from a fourth family, ranked every
83
+ surface blind against an anchored rubric, with the transcript fenced as data; the owner
84
+ ranked six conversations blind to calibrate it. The judge agreed with itself (0.83) and
85
+ not with the owner (0.55, under the 0.60 the plan required), so it did not stand, and the
86
+ decision rested on the owner's own rankings: Haiku first on 16 of 26 surfaces. The
87
+ judge's arithmetic, by a rule fixed before any output was seen, pointed the same way.
88
+ Nothing was written to either home, no transcript left the owner's machine, and the
89
+ evidence committed holds numbers and fixed labels only.
90
+
91
+ ### The pin moves (CV9.DS9.US3)
92
+
93
+ The owner decided at the plan's approval: `anthropic/claude-haiku-5.5` at `none`.
94
+ Against it, weighed and accepted: about 40% more cost, because Haiku's tokenizer counts
95
+ 1.5× the input tokens; reception about half a second slower at p95; and summaries
96
+ weaker as standalone notes than Luna's, though better than Flash Lite's, a defect that
97
+ turned out to be the prompt's.
98
+
99
+ One commit changed the pin, its reasoning default, and the price table together.
100
+ A pin's reasoning default is now a property of the model, looked up by the model the
101
+ call names: Haiku sends `none` whether defaulted or named explicitly; Flash Lite, and any
102
+ model not measured, sends no reasoning field. So the rollback reproduces exactly the
103
+ request Flash Lite was measured with. `runtime status` reads
104
+ `extraction=anthropic/claude-haiku-5.5, reasoning=none (model default)`.
105
+
106
+ The gate ran as the playbook orders: a fresh `eval --all` on Flash Lite before the
107
+ change (9 of 9 modules), then on Haiku after it (9 of 9, one expected probe failure),
108
+ every module's prompt hash equal, `--history` labelling the flip as a model and
109
+ reasoning change. The owner's first real conversation on the new pin came back titled,
110
+ tagged, summarised, and priced.
111
+
112
+ ### The summary reads as a note (CR180)
113
+
114
+ The summary prompt told the model to write "a summary of the conversation" and to
115
+ "open with the topic the conversation addressed", then forbade writing "the
116
+ conversation" four rules later. The positive pull won: Haiku at `none` opened "The
117
+ conversation centered on…" in 10 of 14 eval runs with the prohibition in view, and every
118
+ Flash Lite summary in TS2 was flagged not standalone.
119
+
120
+ Three edits, each measured seven times: the first line now says who will read the
121
+ summary and why ("on its own, months from now, by someone deciding whether the full
122
+ transcript is worth opening"); the opening rule shows one example with its status in
123
+ it instead of describing what to open with; the standalone rule asks for what is now
124
+ known or decided, with the person called by name. Across 21 runs no summary named the
125
+ conversation.
126
+
127
+ The owner's validation conversation then failed anyway, and taught two more things.
128
+ First, the title, summary, and tags prompts had never been given the owner's name: the
129
+ close tail did not pass one, and the extraction path's resolver matched "You are
130
+ talking to" while the shipped identity template writes "You are speaking with", so
131
+ every lifecycle prompt since the port had labelled the speaker `User`. One fail-soft
132
+ resolver now serves both paths, and "the user" vanished from summaries with it. Second,
133
+ on a conversation that is itself about sessions and roadmaps, the model opens with the
134
+ frame about half the time whatever the wording says, and a rule that names the frame
135
+ gets echoed. So the guard is in the system: a summary whose first clause is the
136
+ conversation, discussion, session, exchange, chat, dialogue, transcript, or user is
137
+ generated once more with a short positive note, and the second is kept only if it
138
+ opens clean. A blind retry recovered 2 of 5; the note recovered 5 of 5. Both calls reach
139
+ the ledger. The owner's next conversation summarised as "<the owner's name> asked the
140
+ mirror to explain its operating modes… Settled: … Open: …", and the ledger showed the
141
+ guard had fired on it.
142
+
143
+ ## Upgrading
144
+
145
+ ```bash
146
+ mirror runtime update # or: npm install -g mirror-mind@latest
147
+ ```
148
+
149
+ No migration. The pin is configuration: the first conversation that ends after the
150
+ update is written by Claude Haiku 5.5. Your OpenRouter key needs no change; Anthropic
151
+ models are routed by OpenRouter like the rest.
152
+
153
+ Then restart your Pi sessions, or start a new session on Claude Code, Codex,
154
+ Antigravity, or Grok Build. The plugin copies Codex, Antigravity, and Grok Build keep
155
+ did not change in this release.
156
+
157
+ **To roll back**, put this line in `${XDG_CONFIG_HOME:-~/.config}/mirror/env` (in a
158
+ clone, its `.env`):
159
+
160
+ ```text
161
+ MEMORY_EXTRACTION_MODEL=google/gemini-2.5-flash-lite
162
+ ```
163
+
164
+ Then run `mirror runtime status`: the models line names Flash Lite with no reasoning
165
+ clause, which is the request it was always sent, and its calls stay priced. Rolling back
166
+ also brings back Flash Lite's upstream rate limits. Remove the line to return to the pin.
167
+ Nothing in the database changes either way.
168
+
169
+ ## Conscious Exclusions
170
+
171
+ - One pin still serves every surface. The measurement favoured Luna for summaries and
172
+ Haiku for everything else; a model per surface is a Change Request, not this release.
173
+ - The ledger does not record which reasoning effort a call carried
174
+ ([CR181](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr181-the-ledger-cannot-say-which-reasoning-effort-a-call-carried.md));
175
+ after the swap, the model column tells the two regimes apart in practice.
176
+ - No provider routing or fallback: the move retired Flash Lite's rate-limit exposure
177
+ for the pin, and a rolled-back install takes it back.
178
+ - The extraction eval's prompt hash still excludes the summary prompt its probe sends
179
+ ([CR184](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr184-the-extraction-eval-s-prompt-hash-excludes-the-summary-prompt-its-probe-sends.md));
180
+ the `conversation_summary` module's hash covers it.
181
+ - A summary may still mention "the conversation" mid-text in about one of five
182
+ meta-heavy conversations; the guard watches the opening, where the framing lived.
183
+ - Summaries already stored are not rewritten.
184
+ - Found on the way and captured, not fixed: a test that writes a synthetic consult row
185
+ into the developer's real home on every `npm test` (CR182), and a Builder checkpoint
186
+ card that once listed a record that did not exist (CR183).
187
+
188
+ ## What We Learned
189
+
190
+ An LLM judge can agree with itself and not with the person. TS2's judge was
191
+ self-consistent and agreed with the owner's blind rankings on 55% of pairwise orderings,
192
+ under the bar the plan had set, so the decision was made on the owner's rankings
193
+ instead. Calibrate a judge with the person on four to six conversations before the full
194
+ run; learning it afterwards cost six times as much. The playbook says so now.
195
+
196
+ A prompt rule that names the thing it forbids gets echoed. "Do not describe the
197
+ conversation; record what it established" came back as "What the conversation
198
+ established is…". The fixes that held said what to write, not what not to, and showed
199
+ one example instead of describing. And when the content itself is about conversations,
200
+ no wording removes the attractor; a guard in the system does, in one retry, if the retry
201
+ says what the first sentence must do.
202
+
203
+ The biggest lever on real summaries was not the prompt. It was a name the prompts had
204
+ never been given, found because a validation on a real conversation failed after 21
205
+ clean runs on a fixture that had no name and no meta content. A fixture measures what
206
+ it contains.
207
+
208
+ ## Next Horizon
209
+
210
+ CV9, Mirror Mind 1.0, continues: Boundary Hardening and the rest of Stabilization, with
211
+ CV9.E2.S2, External Extension Runtime Surface Parity, the next pull Ariad recommends.
212
+ On 2026-11-04 the development home's ledger is read for the pin's reception latency,
213
+ 30-day cost, and error count against the numbers the plan accepted. The Change Requests
214
+ this release left, CR181 to CR184, wait for their triggers. When CV9's done condition
215
+ is met, the release is `v1.0.0`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.37.0",
4
+ "version": "0.38.0",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -27,6 +27,7 @@ import {
27
27
  metadataProfileAction,
28
28
  titleNeedsImprovement,
29
29
  } from "#conversation/metadataLifecycle.ts";
30
+ import { resolveUserName } from "#conversation/userName.ts";
30
31
  import type { WritableDatabase } from "#db/database.ts";
31
32
  import type { ExtractionMessage } from "#extraction/conversation.ts";
32
33
  import {
@@ -163,7 +164,7 @@ export async function applyGeneratedMetadataLifecycle(
163
164
  ]),
164
165
  );
165
166
 
166
- const suggest = buildSuggesters(conversation, messages, options);
167
+ const suggest = buildSuggesters(db, conversation, messages, options);
167
168
 
168
169
  let generatedTitle: string | null = null;
169
170
  let generatedSummary: string | null = null;
@@ -315,7 +316,7 @@ export async function maybeGenerateTitle(
315
316
 
316
317
  try {
317
318
  const suggestion = await generateConversationTitle(deps.llm, messages, {
318
- userName: deps.userName ?? "User",
319
+ userName: deps.userName ?? resolveUserName(db),
319
320
  onLlmCall: (response, prompt) =>
320
321
  deps.onLlmCall?.("conversation_title", response, conversation.id, prompt),
321
322
  });
@@ -375,11 +376,14 @@ export function createCloseHooks(
375
376
  }
376
377
 
377
378
  function buildSuggesters(
379
+ db: WritableDatabase,
378
380
  conversation: ConversationRow,
379
381
  messages: ExtractionMessage[],
380
382
  deps: CloseTailDeps,
381
383
  ) {
382
- const userName = deps.userName ?? "User";
384
+ // CR180: the owner's name from the database unless the caller passed one, so the
385
+ // transcript these prompts see is labelled as the extraction path labels it.
386
+ const userName = deps.userName ?? resolveUserName(db);
383
387
  const record = (role: string) => (response: LlmResponse, prompt: string) =>
384
388
  deps.onLlmCall?.(role, response, conversation.id, prompt);
385
389
 
@@ -1,4 +1,5 @@
1
1
  import { metadataDict } from "#conversation/closeTail.ts";
2
+ import { resolveUserName } from "#conversation/userName.ts";
2
3
  import type { WritableDatabase } from "#db/database.ts";
3
4
  import { embeddingToBytes } from "#db/decode.ts";
4
5
  import { optionalString, requireString } from "#db/rowDecode.ts";
@@ -273,17 +274,6 @@ function getMessages(db: WritableDatabase, conversationId: string): ExtractionMe
273
274
  .map((row) => ({ role: requireString(row, "role"), content: requireString(row, "content") }));
274
275
  }
275
276
 
276
- function resolveUserName(db: WritableDatabase): string {
277
- const row = db
278
- .prepare("SELECT content FROM identity WHERE layer = ? AND key = ? LIMIT 1")
279
- .get("user", "identity");
280
- const content = row ? optionalString(row, "content") : null;
281
- const match = content?.match(
282
- /(?:You are talking to|Você está falando com) ([A-Z][a-zA-Záéíóúãõ]+)/,
283
- );
284
- return match?.[1] ?? "User";
285
- }
286
-
287
277
  /**
288
278
  * The close tail's two ledger hooks. Roles match Python's `build_llm_logger`
289
279
  * roles so the `llm_calls` ledger agrees across engines; the orchestration's
@@ -0,0 +1,44 @@
1
+ // The owner's first name for the prompts the conversation lifecycle sends: memory
2
+ // and task extraction, and the close tail's title, summary, and tags (CR180).
3
+ //
4
+ // Ported from Python's `conversation.py` resolver, which matched only "You are
5
+ // talking to" and "Você está falando com". The shipped identity template writes
6
+ // "You are speaking with {{user_name}}", so against a seeded identity the port
7
+ // matched nothing and every prompt labelled the speaker `**User:**`, silently
8
+ // (CR014's finding, confirmed on the development home 2026-10-28). The template's
9
+ // phrasing is accepted here. The cultivation path keeps its own resolver
10
+ // (`cultivation/promptContext.ts`, fallback "the user"); unifying them is CR014's.
11
+
12
+ import type { Database } from "#db/database.ts";
13
+ import { optionalString } from "#db/rowDecode.ts";
14
+
15
+ /** Python's fallback when the row or the marker phrase is absent. */
16
+ export const FALLBACK_USER_NAME = "User";
17
+
18
+ /** One name token: Unicode letters, so an accented first name is whole (a plain `\w` stops at the accent). */
19
+ const MARKER =
20
+ /(?:You are (?:talking to|speaking with)|Você está (?:falando com|conversando com)) (\p{Lu}[\p{L}]*)/u;
21
+
22
+ /** The first name after the identity's marker phrase, or the fallback. */
23
+ export function userNameFromIdentity(content: string | null | undefined): string {
24
+ const match = content?.match(MARKER);
25
+ return match?.[1] ?? FALLBACK_USER_NAME;
26
+ }
27
+
28
+ /**
29
+ * Read `user/identity` and resolve the name; the fallback when the row is absent.
30
+ * Fail-soft: a database without the table, or a read that throws, is the fallback
31
+ * too. The close tail runs inside the session-end hooks, where a throw becomes an
32
+ * extraction attempt and three attempts quarantine a conversation (CR178); a name
33
+ * is never worth that.
34
+ */
35
+ export function resolveUserName(db: Database): string {
36
+ try {
37
+ const row = db
38
+ .prepare("SELECT content FROM identity WHERE layer = ? AND key = ? LIMIT 1")
39
+ .get("user", "identity");
40
+ return userNameFromIdentity(row ? optionalString(row, "content") : null);
41
+ } catch {
42
+ return FALLBACK_USER_NAME;
43
+ }
44
+ }
@@ -188,6 +188,40 @@ function pythonStr(value: unknown): string {
188
188
  * Generate a 3-4 sentence summary. Returns "" on empty messages, provider
189
189
  * failure, or trivial conversation.
190
190
  */
191
+ /**
192
+ * The opening the summary prompt forbids, as a model writes it (CR180): a first
193
+ * clause whose subject is the conversation itself rather than its content. One
194
+ * adjective is allowed between the article and the noun ("The Mirror conversation").
195
+ */
196
+ const CONVERSATION_FRAME =
197
+ /^\s*(?:the|this)\s+(?:[\p{L}\d.]+\s+)?(?:conversation|discussion|session|exchange|chat|dialogue|transcript|user)\b/iu;
198
+
199
+ /**
200
+ * Appended to the prompt for the one regeneration (CR180): what the first sentence
201
+ * must do, said positively. It never quotes the frame it is replacing, because a
202
+ * quoted phrase comes back as the phrase.
203
+ */
204
+ export const SUMMARY_RETRY_NOTE = `
205
+
206
+ Rewrite the summary. Its first sentence states the matter itself with its status, or begins with the person's name and what they decided or asked. Nothing in it describes what kind of text this is.`;
207
+
208
+ /** Whether a summary opens by describing the conversation instead of stating its content. */
209
+ export function opensWithConversationFrame(summary: string): boolean {
210
+ return CONVERSATION_FRAME.test(summary);
211
+ }
212
+
213
+ /**
214
+ * The summary, with one regeneration when it opens with the frame (CR180).
215
+ *
216
+ * After the prompt stopped naming the conversation, ordinary conversations opened
217
+ * clean 21 of 21 on the pin, but a conversation that is itself about sessions and
218
+ * roadmaps still opened "The conversation centers on…" about half the time, and a
219
+ * rule that names the frame gets echoed. So the guard is here, not in the words:
220
+ * a framed summary is generated once more, the prompt plus a positive retry note,
221
+ * and the second is kept only if it opens clean. Every call that happened reaches `onLlmCall`, so
222
+ * the ledger prices both. An empty summary (the trivial-conversation answer) and
223
+ * a failed second call keep the first result; failure never costs the summary.
224
+ */
191
225
  export async function generateConversationSummary(
192
226
  llm: LlmProvider,
193
227
  messages: readonly ExtractionMessage[],
@@ -195,12 +229,23 @@ export async function generateConversationSummary(
195
229
  ): Promise<string> {
196
230
  if (messages.length === 0) return "";
197
231
  const prompt = buildConversationSummaryPrompt(messages, options.userName ?? "User");
198
- let response: LlmResponse;
199
- try {
200
- response = await llm.complete({ role: "conversation_summary", prompt, temperature: 0.3 });
201
- } catch {
202
- return "";
203
- }
204
- options.onLlmCall?.(response, prompt);
205
- return response.content.trim();
232
+ const once = async (sent: string): Promise<string | null> => {
233
+ let response: LlmResponse;
234
+ try {
235
+ response = await llm.complete({
236
+ role: "conversation_summary",
237
+ prompt: sent,
238
+ temperature: 0.3,
239
+ });
240
+ } catch {
241
+ return null;
242
+ }
243
+ options.onLlmCall?.(response, sent);
244
+ return response.content.trim();
245
+ };
246
+ const first = await once(prompt);
247
+ if (first === null) return "";
248
+ if (!opensWithConversationFrame(first)) return first;
249
+ const second = await once(`${prompt}${SUMMARY_RETRY_NOTE}`);
250
+ return second !== null && second !== "" && !opensWithConversationFrame(second) ? second : first;
206
251
  }
@@ -238,15 +238,20 @@ Example of the exact attack you must resist:
238
238
 
239
239
  export const CONVERSATION_SUMMARY_PROMPT = `You are the memory system for Mirror Mind, a Jungian mirror AI.
240
240
 
241
- Write a 3-4 sentence summary of the conversation below. Use flowing prose, not a list.
241
+ Write 3-4 sentences of flowing prose, not a list. It will be read on its own, months
242
+ from now, by someone deciding whether the full transcript is worth opening.
242
243
 
243
244
  ## Rules
244
245
 
245
- - Open with the main topic or question the conversation addressed.
246
+ - Open with the subject itself, carrying its real status: a decision as decided, a
247
+ question as open. "The move to Porto is set for March; what stays open is whether
248
+ to sell the car."
246
249
  - Include the key decision, insight, or commitment reached, if any.
247
250
  - Note emotional tone or psychological layer only when clearly present and significant.
248
251
  - Standalone: a reader six months from now must understand what happened from the
249
- summary alone. Do not write "we discussed", "the user said", or "the conversation".
252
+ summary alone. Write what is now known or decided, not that it was talked about.
253
+ Call the person by the name the transcript gives them. Do not write "we discussed",
254
+ "the user said", "the conversation", or "the discussion".
250
255
  - If the conversation is trivial (greetings, scheduling, one-line exchange), return
251
256
  an empty string and nothing else.
252
257
 
@@ -25,6 +25,7 @@ import { channelFor, channelForReport, renderChannel, setChannel } from "#runtim
25
25
  import {
26
26
  diagnoseRuntime,
27
27
  extensionPluginFindings,
28
+ extractionReasoningFindings,
28
29
  hookFailureFindings,
29
30
  hookNodeFindings,
30
31
  pluginWiringFindings,
@@ -324,6 +325,9 @@ export async function runRuntimeReadRoute(
324
325
  // skip; and hooks.log holds what the hooks met in their own context.
325
326
  // All three are invisible without being asked for.
326
327
  ...staleRevertGateFindings(env),
328
+ // CV9.DS9.TS1: a reasoning setting the transport ignores rather than fail
329
+ // on (CR178) is silently not in force unless diagnose says so.
330
+ ...extractionReasoningFindings(env),
327
331
  ...hookFailureFindings(report),
328
332
  ...hookNodeFindings(env, (path) => {
329
333
  try {
@@ -18,7 +18,14 @@ const ARG_SECRET_PATTERN =
18
18
  // Model pins (AI-06, CV9.E2.S12): a deprecated pinned model would fail every
19
19
  // extraction/embedding call, and those paths fail soft -- so the pins must be
20
20
  // env-overridable to repoint a deployed instance without a release.
21
- export const DEFAULT_EXTRACTION_MODEL = "google/gemini-2.5-flash-lite";
21
+ //
22
+ // The extraction pin moved from `google/gemini-2.5-flash-lite` to Claude Haiku 5.5
23
+ // on 2026-10-28 (CV9.DS9.US3), on DS9's measurement: the Navigator's own blind
24
+ // rankings on his conversations, the suite at reasoning off, and Flash Lite's
25
+ // upstream rate limits. A pin change is a migration: the pin, its entry in
26
+ // MODEL_DEFAULT_REASONING, and MODEL_PRICES (cost.ts) move in one commit, behind
27
+ // a green `eval --all` (the development guide's model upgrade playbook).
28
+ export const DEFAULT_EXTRACTION_MODEL = "anthropic/claude-haiku-5.5";
22
29
  export const DEFAULT_EMBEDDING_MODEL = "openai/text-embedding-3-small";
23
30
 
24
31
  export interface ModelPinOptions {
@@ -51,6 +58,103 @@ export function resolveEmbeddingModel(options: ModelPinOptions = {}): string {
51
58
  return env.MEMORY_EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL;
52
59
  }
53
60
 
61
+ /**
62
+ * The reasoning efforts OpenRouter's catalogue lists across its models (checked
63
+ * 2026-10-26, CV9.DS9.TS1). `none` turns reasoning off. Whether a given model
64
+ * honours a given effort is the operator's choice; this list is the vocabulary.
65
+ */
66
+ export const REASONING_EFFORTS = [
67
+ "none",
68
+ "minimal",
69
+ "low",
70
+ "medium",
71
+ "high",
72
+ "xhigh",
73
+ "max",
74
+ ] as const;
75
+
76
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
77
+
78
+ /** What `MEMORY_EXTRACTION_REASONING` says: an effort to send, or a value that is not one. */
79
+ export interface ExtractionReasoning {
80
+ /** The effort to send as `reasoning.effort`, or null to send no reasoning field. */
81
+ effort: ReasoningEffort | null;
82
+ /** The value as written when it is not an effort; null otherwise. */
83
+ invalid: string | null;
84
+ }
85
+
86
+ /**
87
+ * Read `MEMORY_EXTRACTION_REASONING` (CV9.DS9.TS1): the reasoning effort every
88
+ * extraction-family call sends. Absent or blank sends nothing, which is the request
89
+ * as it was before the setting existed.
90
+ *
91
+ * Unlike the numeric resolvers below, this never throws. They fail loudly, but they
92
+ * are read inside the session-end hooks, where a throw becomes an extraction attempt
93
+ * and three attempts quarantine a conversation for good (CR178). So a value that is
94
+ * not an effort sends nothing too, and comes back as `invalid` for `runtime status`
95
+ * and `runtime diagnose` to show.
96
+ */
97
+ export function readExtractionReasoning(options: ModelPinOptions = {}): ExtractionReasoning {
98
+ const env = options.env ?? process.env;
99
+ const raw = env.MEMORY_EXTRACTION_REASONING ?? "";
100
+ const value = raw.trim().toLowerCase();
101
+ if (value === "") return { effort: null, invalid: null };
102
+ const effort = REASONING_EFFORTS.find((candidate) => candidate === value);
103
+ return effort ? { effort, invalid: null } : { effort: null, invalid: raw.trim() };
104
+ }
105
+
106
+ /**
107
+ * The reasoning effort a model sends when `MEMORY_EXTRACTION_REASONING` is unset
108
+ * (CV9.DS9.US3). A property of the model, because what DS9 measured is: Haiku
109
+ * reasons by default and spent the title call's 40-token budget, so it was
110
+ * measured and chosen at `none`; Flash Lite was measured with no reasoning field,
111
+ * and a model absent from this table gets none, which is the request as it was
112
+ * before the setting existed. So the playbook's rollback, `MEMORY_EXTRACTION_MODEL`
113
+ * set to the old pin, reproduces exactly the request the old pin was measured with,
114
+ * and naming the default pin explicitly sends the same request as leaving it unset.
115
+ */
116
+ export const MODEL_DEFAULT_REASONING: Readonly<Record<string, ReasoningEffort>> = {
117
+ "anthropic/claude-haiku-5.5": "none",
118
+ };
119
+
120
+ /** Where the effective effort came from: the setting, the model's default, or nowhere. */
121
+ export type ReasoningSource = "setting" | "model_default";
122
+
123
+ /** The effective pair every extraction-family call sends: the model and its effort. */
124
+ export interface EffectiveExtractionReasoning {
125
+ /** The model the call names: `MEMORY_EXTRACTION_MODEL` or the default pin. */
126
+ model: string;
127
+ /** The effort to send as `reasoning.effort`, or null to send no reasoning field. */
128
+ effort: ReasoningEffort | null;
129
+ /** What decided `effort`; null when no field is sent. */
130
+ source: ReasoningSource | null;
131
+ /** `MEMORY_EXTRACTION_REASONING` as written when it is not an effort; null otherwise. */
132
+ invalid: string | null;
133
+ }
134
+
135
+ /**
136
+ * Resolve the model and the reasoning effort an extraction-family call sends
137
+ * (CV9.DS9.US3): the model first, then the effort: the setting when it names an
138
+ * effort, else the model's entry in `MODEL_DEFAULT_REASONING`, else no field. An
139
+ * invalid setting reads as unset, so the default pin still sends its model default;
140
+ * `invalid` carries the value for `runtime status` and `runtime diagnose`. The
141
+ * transport, the eval harness, and `runtime status` all read this one resolver.
142
+ */
143
+ export function resolveExtractionReasoning(
144
+ options: ModelPinOptions = {},
145
+ ): EffectiveExtractionReasoning {
146
+ const model = resolveExtractionModel(options);
147
+ const setting = readExtractionReasoning(options);
148
+ if (setting.effort !== null) {
149
+ return { model, effort: setting.effort, source: "setting", invalid: null };
150
+ }
151
+ const modelDefault = MODEL_DEFAULT_REASONING[model];
152
+ if (modelDefault !== undefined) {
153
+ return { model, effort: modelDefault, source: "model_default", invalid: setting.invalid };
154
+ }
155
+ return { model, effort: null, source: null, invalid: setting.invalid };
156
+ }
157
+
54
158
  // Observability (AI-09, CV9.E2.S13): mirrors Python's MEMORY_LOG_LLM_CALLS
55
159
  // resolution exactly -- off | metadata | full, default metadata (never a
56
160
  // silent full -- bodies are opt-in only). Legacy "1" maps to "full" for
@@ -29,7 +29,11 @@ export interface ModelPrice {
29
29
 
30
30
  /** Estimated OpenRouter list prices (USD / 1K tokens). Verify on every pin change. */
31
31
  export const MODEL_PRICES: Readonly<Record<string, ModelPrice>> = {
32
- // Extraction pin -- google/gemini-2.5-flash-lite: ~$0.10/M in, ~$0.40/M out.
32
+ // Extraction pin since CV9.DS9.US3 -- anthropic/claude-haiku-5.5: $0.10/M in,
33
+ // $0.50/M out, read from OpenRouter's catalogue on 2026-10-28.
34
+ "anthropic/claude-haiku-5.5": { promptPer1k: 0.0001, completionPer1k: 0.0005 },
35
+ // The pin before it, kept so a rolled-back install (`MEMORY_EXTRACTION_MODEL`)
36
+ // keeps pricing its calls -- google/gemini-2.5-flash-lite: ~$0.10/M in, ~$0.40/M out.
33
37
  "google/gemini-2.5-flash-lite": { promptPer1k: 0.0001, completionPer1k: 0.0004 },
34
38
  // Embedding pin -- openai/text-embedding-3-small: ~$0.02/M, no completion side.
35
39
  "openai/text-embedding-3-small": { promptPer1k: 0.00002, completionPer1k: 0.0 },
@@ -4,6 +4,7 @@ import {
4
4
  type LlmTimeoutRole,
5
5
  type ProviderConfig,
6
6
  resolveExtractionModel,
7
+ resolveExtractionReasoning,
7
8
  resolveLlmMaxRetries,
8
9
  resolveLlmTimeoutMs,
9
10
  resolveProviderConfig,
@@ -76,6 +77,33 @@ const LLM_TIMEOUT_TIER: Readonly<Record<LlmRole, LlmTimeoutRole>> = {
76
77
  descriptor: "extraction",
77
78
  };
78
79
 
80
+ /**
81
+ * Which roles carry `MEMORY_EXTRACTION_REASONING` (CV9.DS9.TS1): every role that
82
+ * runs on the extraction pin. `consult` picks its own model on every call, so a
83
+ * setting meant for the extraction pin must not reach it.
84
+ *
85
+ * A full `Record<LlmRole, boolean>` for the reason `LLM_TIMEOUT_TIER` is one: a
86
+ * role added to `LLM_ROLES` cannot compile without declaring it. A check against
87
+ * `"consult"` would let the next role that names its own model inherit the
88
+ * setting silently.
89
+ */
90
+ export const CARRIES_EXTRACTION_REASONING: Readonly<Record<LlmRole, boolean>> = {
91
+ extraction: true,
92
+ task_extraction: true,
93
+ summary: true,
94
+ curation: true,
95
+ consult: false,
96
+ reception: true,
97
+ consolidation: true,
98
+ shadow_scan: true,
99
+ conversation_title: true,
100
+ conversation_tags: true,
101
+ conversation_summary: true,
102
+ journal_classification: true,
103
+ week_plan: true,
104
+ descriptor: true,
105
+ };
106
+
79
107
  /**
80
108
  * The roles a message in Python's envelope can carry.
81
109
  *
@@ -273,6 +301,16 @@ export class LiveLlmProvider implements LlmProvider {
273
301
  async complete(request: LlmRequest): Promise<LlmResponse> {
274
302
  const client = this.resolveClient();
275
303
  const model = request.model ?? resolveExtractionModel({ env: this.env });
304
+ // CV9.DS9.TS1: the one added field; US3: the setting, else the model's own
305
+ // default (Haiku sends `none`; a model outside the table sends nothing). An
306
+ // invalid value reads as unset, so it fails nothing (a failed call would
307
+ // become an extraction attempt, CR178); `runtime status` and `diagnose`
308
+ // report it.
309
+ const { effort } = resolveExtractionReasoning({ env: this.env });
310
+ const reasoning =
311
+ effort !== null && CARRIES_EXTRACTION_REASONING[request.role]
312
+ ? { reasoning: { effort } }
313
+ : {};
276
314
  const startedAt = Date.now();
277
315
  const body = await client.postJson(
278
316
  "/chat/completions",
@@ -286,6 +324,8 @@ export class LiveLlmProvider implements LlmProvider {
286
324
  : [{ role: "user", content: request.prompt }],
287
325
  temperature: request.temperature ?? DEFAULT_TEMPERATURE,
288
326
  max_tokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
327
+ // Unset, this adds nothing: the body is the one it was before the setting.
328
+ ...reasoning,
289
329
  },
290
330
  {
291
331
  timeoutMs: resolveLlmTimeoutMs(LLM_TIMEOUT_TIER[request.role], { env: this.env }),
@@ -34,6 +34,7 @@ import {
34
34
  readExtensionPlugin,
35
35
  } from "#extensions/extensionPlugin.ts";
36
36
  import { identityKeyBreaksGrammar, SLUG_KEYED_LAYERS } from "#identity/identityKey.ts";
37
+ import { REASONING_EFFORTS, resolveExtractionReasoning } from "#providers/config.ts";
37
38
  import { pyRepr, sortByCodePoint } from "#util/pythonText.ts";
38
39
  import { configFilePath } from "./config.ts";
39
40
  import { extensionPluginStates } from "./extensionPluginWiring.ts";
@@ -771,6 +772,37 @@ export function staleRevertGateFindings(env: NodeJS.ProcessEnv): DriftFinding[]
771
772
  ];
772
773
  }
773
774
 
775
+ /**
776
+ * A `MEMORY_EXTRACTION_REASONING` that is not a reasoning effort (CV9.DS9.TS1).
777
+ *
778
+ * The transport reads it as unset rather than fail the call -- a failed call is
779
+ * an extraction attempt, and three quarantine a conversation for good (CR178) --
780
+ * so the setting is silently not in force: the calls send the model's own default
781
+ * effort (CV9.DS9.US3), or no reasoning field for a model without one. Diagnose
782
+ * says which, with the values that would be in force.
783
+ */
784
+ export function extractionReasoningFindings(env: NodeJS.ProcessEnv): DriftFinding[] {
785
+ const { model, effort, invalid } = resolveExtractionReasoning({ env });
786
+ if (invalid === null) return [];
787
+ const sent = effort === null ? "no reasoning setting" : `${model}'s own default, ${effort}`;
788
+ return [
789
+ {
790
+ code: "extraction_reasoning_invalid",
791
+ severity: "attention",
792
+ subject: "MEMORY_EXTRACTION_REASONING",
793
+ detail: `"${invalid}" is not a reasoning effort, so extraction calls send ${sent}`,
794
+ recommendation:
795
+ `set it to ${formatEffortList()}, or unset it, where it is set: ` +
796
+ "the environment, the clone's .env, or ~/.config/mirror/env",
797
+ repair_route: "edit the setting",
798
+ },
799
+ ];
800
+ }
801
+
802
+ function formatEffortList(): string {
803
+ return `${REASONING_EFFORTS.slice(0, -1).join(", ")}, or ${REASONING_EFFORTS.at(-1)}`;
804
+ }
805
+
774
806
  /**
775
807
  * Where a hook wrapper looks for Node after `MIRROR_NODE` and `PATH`, in order.
776
808
  * The generated wrappers carry the same list (`for candidate in ...`), and
@@ -25,7 +25,7 @@ import { readExtensionPlugin } from "#extensions/extensionPlugin.ts";
25
25
  import { loadExtensionManifest } from "#extensions/manifest.ts";
26
26
  import { inspectMigrationFiles } from "#extensions/migrations.ts";
27
27
  import { type DbPathEnv, dbNameForEnv, resolveMirrorHome } from "#frontDoor/dbPath.ts";
28
- import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EXTRACTION_MODEL } from "#providers/config.ts";
28
+ import { DEFAULT_EMBEDDING_MODEL, resolveExtractionReasoning } from "#providers/config.ts";
29
29
  import { sortByCodePoint } from "#util/pythonText.ts";
30
30
  import { channelForReport } from "./channel.ts";
31
31
  import type { MarkerValue } from "./git.ts";
@@ -529,6 +529,24 @@ function intEnv(raw: string | undefined, fallback: number): number {
529
529
  return Number.isNaN(parsed) ? fallback : parsed;
530
530
  }
531
531
 
532
+ /**
533
+ * The models line's reasoning clause (CV9.DS9.TS1, US3): the effort the calls
534
+ * send, marked `(model default)` when it is the model's rather than the setting's;
535
+ * empty when no field is sent, so a rolled-back pin's line stays byte for byte
536
+ * what the frozen status scenarios before US3 recorded; and an invalid setting
537
+ * shown, never thrown, beside the model default it falls back to.
538
+ */
539
+ function reasoningClause(env: NodeJS.ProcessEnv): string {
540
+ const { effort, source, invalid } = resolveExtractionReasoning({ env });
541
+ if (effort !== null && source === "setting") return `reasoning=${effort}, `;
542
+ if (effort !== null) {
543
+ const note = invalid === null ? "model default" : `model default, invalid setting: ${invalid}`;
544
+ return `reasoning=${effort} (${note}), `;
545
+ }
546
+ if (invalid !== null) return `reasoning=invalid (${invalid}), `;
547
+ return "";
548
+ }
549
+
532
550
  /** Port of `render_runtime_status`. */
533
551
  export function renderRuntimeStatus(
534
552
  report: RuntimeStatusReport,
@@ -570,7 +588,8 @@ export function renderRuntimeStatus(
570
588
  `embedding=${formatG(embedding)}, extraction=${formatG(extraction)}; retries=${retries}`,
571
589
  );
572
590
  lines.push(
573
- `Models: extraction=${env.MEMORY_EXTRACTION_MODEL ?? DEFAULT_EXTRACTION_MODEL}, ` +
591
+ `Models: extraction=${resolveExtractionReasoning({ env }).model}, ` +
592
+ reasoningClause(env) +
574
593
  `embedding=${env.MEMORY_EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL}`,
575
594
  );
576
595
  lines.push("");