mirror-mind 0.36.3 → 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.
Files changed (52) hide show
  1. package/.pi/extensions/mirror-logger.ts +173 -46
  2. package/.pi/skills/mm-mirror/SKILL.md +25 -6
  3. package/README.md +2 -2
  4. package/docs/releases/index.md +2 -0
  5. package/docs/releases/v0.37.0.md +221 -0
  6. package/docs/releases/v0.38.0.md +215 -0
  7. package/package.json +1 -1
  8. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  9. package/plugins/mirror-mind/plugin.json +1 -1
  10. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  11. package/ts/src/builder/commands.ts +9 -6
  12. package/ts/src/builder/load.ts +14 -2
  13. package/ts/src/conversation/closeTail.ts +7 -3
  14. package/ts/src/conversation/extraction.ts +1 -11
  15. package/ts/src/conversation/logger.ts +12 -6
  16. package/ts/src/conversation/userName.ts +44 -0
  17. package/ts/src/db/migrateOnOpen.ts +1 -1
  18. package/ts/src/db/migrations.ts +16 -0
  19. package/ts/src/db/schema.ts +0 -1
  20. package/ts/src/db/schemaInventorySnapshot.ts +1 -8
  21. package/ts/src/db/schemaState.ts +2 -0
  22. package/ts/src/db/schemaTsDivergence.ts +25 -4
  23. package/ts/src/extraction/conversationMetadata.ts +53 -8
  24. package/ts/src/extraction/prompts.ts +8 -3
  25. package/ts/src/frontDoor/cli.ts +42 -2
  26. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  27. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  28. package/ts/src/frontDoor/routing.ts +16 -15
  29. package/ts/src/frontDoor/runtimeRoute.ts +4 -0
  30. package/ts/src/frontDoor/soulRoute.ts +4 -4
  31. package/ts/src/guards/testTempHomes.ts +92 -0
  32. package/ts/src/hooks/agy.ts +136 -59
  33. package/ts/src/hooks/claude.ts +29 -90
  34. package/ts/src/hooks/mirrorState.ts +10 -22
  35. package/ts/src/mirror/context.ts +19 -4
  36. package/ts/src/mirror/defaultResolution.ts +2 -1
  37. package/ts/src/mirror/doorman.ts +420 -0
  38. package/ts/src/mirror/orchestration.ts +22 -3
  39. package/ts/src/mirror/render.ts +7 -1
  40. package/ts/src/mirror/runtimeSession.ts +66 -8
  41. package/ts/src/mode/operatingMode.ts +22 -4
  42. package/ts/src/providers/config.ts +105 -1
  43. package/ts/src/providers/cost.ts +5 -1
  44. package/ts/src/providers/llm.ts +40 -0
  45. package/ts/src/providers/transport.ts +14 -0
  46. package/ts/src/runtime/diagnose.ts +64 -7
  47. package/ts/src/runtime/pluginWiring.ts +6 -4
  48. package/ts/src/runtime/status.ts +24 -4
  49. package/ts/src/runtime/update.ts +17 -1
  50. package/ts/src/runtime/wiring.ts +14 -1
  51. package/ts/src/search/memorySearch.ts +38 -18
  52. package/ts/src/search/ranker.ts +16 -2
@@ -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 }),
@@ -321,6 +321,20 @@ export const MIRROR_QUERY_TRANSPORT: ProviderTransportSpec = {
321
321
  liveReason: "DS8.US3 mirror load --query live",
322
322
  };
323
323
 
324
+ /**
325
+ * The mirror-query spec as the environment narrows it: with
326
+ * `MEMORY_RECEPTION=0` the classifier never runs, so the chat fixture stops
327
+ * being required and the embedding fixture alone is a complete replay. The
328
+ * router decided this way for `load --query` since CV22.DS8.US3; the doorman's
329
+ * route (CV9.DS8.TS2) resolves its providers from the same answer, so the
330
+ * route and the resolution cannot disagree about what "complete" means.
331
+ */
332
+ export function mirrorQueryTransportFor(env: { MEMORY_RECEPTION?: string }): ProviderTransportSpec {
333
+ return env.MEMORY_RECEPTION === "0"
334
+ ? { ...MIRROR_QUERY_TRANSPORT, replay: { embedding: MIRROR_QUERY_TRANSPORT.replay?.embedding } }
335
+ : MIRROR_QUERY_TRANSPORT;
336
+ }
337
+
324
338
  /**
325
339
  * `consolidate scan` and `shadow scan` -- the two cultivation leaves that send
326
340
  * a PROMPT. Everything around the call landed in CV22.DS8.US3; the flip
@@ -34,11 +34,13 @@ 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";
40
41
  import type { GitWorktreeEntry } from "./git.ts";
41
42
  import {
43
+ agyPluginCopyState,
42
44
  codexPluginCacheState,
43
45
  grokPluginCopyState,
44
46
  installedPluginVersion,
@@ -46,7 +48,11 @@ import {
46
48
  } from "./pluginWiring.ts";
47
49
 
48
50
  import type { RuntimeStatusReport } from "./status.ts";
49
- import { CODEX_PLUGIN_ADD_COMMAND, grokPluginReinstallCommand } from "./wiring.ts";
51
+ import {
52
+ agyPluginInstallCommand,
53
+ CODEX_PLUGIN_ADD_COMMAND,
54
+ grokPluginReinstallCommand,
55
+ } from "./wiring.ts";
50
56
 
51
57
  export interface DriftFinding {
52
58
  code: string;
@@ -514,18 +520,19 @@ export function diagnoseRuntime(
514
520
 
515
521
  /**
516
522
  * The plugin wiring findings (CV21.DS4.TS6; the debt US1 carried; Grok in
517
- * CV21.DS10.US1). Codex and Grok copy the plugin, so an npm update leaves
518
- * the copy behind; Claude Code links it in place, so its failure mode is a
519
- * dangling link, and Grok's rules link fails the same way. Grok adds one
523
+ * CV21.DS10.US1; agy in CV21.DS11.TS4). Codex, agy, and Grok copy the plugin,
524
+ * so an npm update leaves the copy behind; Claude Code links it in place, so
525
+ * its failure mode is a dangling link, and Grok's rules link fails the same
526
+ * way. Grok adds one
520
527
  * failure of its own: a respelt install path keys a SECOND copy, and Grok
521
528
  * loads one of the two without saying which. Absence is not drift: no
522
529
  * cache, no registry, and no link fire nothing, so an unwired machine stays
523
530
  * green.
524
531
  *
525
532
  * The defaulting happens HERE, from the env the caller hands in — CODEX_HOME
526
- * else ~/.codex, GROK_HOME else ~/.grok, and ~/.claude — never inside the
527
- * readers, so every reader test is hermetic on a maintainer's machine with
528
- * real wiring (CR123/128/137/142).
533
+ * else ~/.codex, GROK_HOME else ~/.grok, ~/.claude, and ~/.gemini — never
534
+ * inside the readers, so every reader test is hermetic on a maintainer's
535
+ * machine with real wiring (CR123/128/137/142).
529
536
  */
530
537
  export function pluginWiringFindings(
531
538
  installRoot: string | null,
@@ -550,6 +557,25 @@ export function pluginWiringFindings(
550
557
  }
551
558
  }
552
559
 
560
+ // agy stages a copy under ~/.gemini/config/plugins/mirror-mind/ (agy 1.4.7,
561
+ // DS11.TS4's route), read the way the extensions plugin's copy is read: the
562
+ // `.gemini` home from HOME, with no Mirror variable to override it.
563
+ const geminiHome = home ? join(home, ".gemini") : null;
564
+ if (geminiHome && installedVersion && installRoot) {
565
+ const copy = agyPluginCopyState(geminiHome, installedVersion, "mirror-mind");
566
+ if (copy.state === "behind") {
567
+ const install = agyPluginInstallCommand(installRoot);
568
+ findings.push({
569
+ code: "agy_plugin_copy_behind",
570
+ severity: "warning",
571
+ subject: copy.copyPath,
572
+ detail: `copied ${copy.copiedVersion} < installed ${installedVersion}; agy runs the copy it staged at install`,
573
+ recommendation: `run \`${install}\` to bring the copy level (an update leaves agy's copy as it was, and installing again replaces it)`,
574
+ repair_route: install,
575
+ });
576
+ }
577
+ }
578
+
553
579
  if (home) {
554
580
  const link = pluginLinkState(join(home, ".claude", "skills", "mirror-mind"));
555
581
  if (link.state === "dangling") {
@@ -746,6 +772,37 @@ export function staleRevertGateFindings(env: NodeJS.ProcessEnv): DriftFinding[]
746
772
  ];
747
773
  }
748
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
+
749
806
  /**
750
807
  * Where a hook wrapper looks for Node after `MIRROR_NODE` and `PATH`, in order.
751
808
  * The generated wrappers carry the same list (`for candidate in ...`), and
@@ -1,4 +1,5 @@
1
- // The plugin wiring readers (CV21.DS4.TS6; Grok in CV21.DS10.US1).
1
+ // The plugin wiring readers (CV21.DS4.TS6; Grok in CV21.DS10.US1; agy's
2
+ // reader in CV21.DS5.US2, read for the canonical plugin since CV21.DS11.TS4).
2
3
  //
3
4
  // Three runtimes hold the plugin outside the install tree. Codex copies it
4
5
  // into a versioned cache (`$CODEX_HOME/plugins/cache/mirror-mind/mirror-mind/
@@ -226,9 +227,10 @@ export interface AgyCopyState {
226
227
  * Read agy's staged copy of a plugin below a `.gemini` home, read-only:
227
228
  * `agy plugin install <dir>` stages the tree under `config/plugins/<name>/`
228
229
  * (CV21.DS7.US1), and the copy's own `.claude-plugin/plugin.json` carries
229
- * its version. Written for the extensions plugin (CV21.DS5.US2); the
230
- * canonical plugin has no agy finding yet, and this reader serves it when
231
- * one is added.
230
+ * its version. Written for the extensions plugin (CV21.DS5.US2); since
231
+ * CV21.DS11.TS4 it reads the canonical plugin's copy too, which agy 1.4.7
232
+ * stages the same way (`mirror-mind/.claude-plugin/plugin.json`, measured in
233
+ * a scratch HOME on 2026-10-24).
232
234
  */
233
235
  export function agyPluginCopyState(
234
236
  geminiHome: string,
@@ -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";
@@ -119,7 +119,8 @@ function errorMessage(error: unknown): string {
119
119
  *
120
120
  * One rule, `assertSchemaState`'s, so the codebase keeps ONE definition of a
121
121
  * healthy ledger: every id in `KNOWN_MIGRATION_IDS` is required, and an id
122
- * outside it is unknown. A current database renders `current (17/17)`.
122
+ * outside it is unknown. A current database renders `current (18/18)` (CV9.DS8.US5's
123
+ * 018 is the eighteenth; the history below is TS5's).
123
124
  *
124
125
  * Until CV22.DS10.TS5 this grader kept a second rule for one migration.
125
126
  * `017_journey_parent_column` is the first one TypeScript authored, and the
@@ -131,7 +132,7 @@ function errorMessage(error: unknown): string {
131
132
  * the forgiveness hid a pending migration behind "ready" on exactly the
132
133
  * databases `runtime migrate` exists to bring forward. TS5 finding F19 (a
133
134
  * removal the comment here promised for plateau 3 and nobody performed) made
134
- * the denominator honest: seventeen, everywhere.
135
+ * the denominator honest: every known migration, everywhere.
135
136
  */
136
137
  export function inspectCoreMigrations(
137
138
  dbPath: string | null,
@@ -528,6 +529,24 @@ function intEnv(raw: string | undefined, fallback: number): number {
528
529
  return Number.isNaN(parsed) ? fallback : parsed;
529
530
  }
530
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
+
531
550
  /** Port of `render_runtime_status`. */
532
551
  export function renderRuntimeStatus(
533
552
  report: RuntimeStatusReport,
@@ -569,7 +588,8 @@ export function renderRuntimeStatus(
569
588
  `embedding=${formatG(embedding)}, extraction=${formatG(extraction)}; retries=${retries}`,
570
589
  );
571
590
  lines.push(
572
- `Models: extraction=${env.MEMORY_EXTRACTION_MODEL ?? DEFAULT_EXTRACTION_MODEL}, ` +
591
+ `Models: extraction=${resolveExtractionReasoning({ env }).model}, ` +
592
+ reasoningClause(env) +
573
593
  `embedding=${env.MEMORY_EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL}`,
574
594
  );
575
595
  lines.push("");
@@ -30,6 +30,7 @@ import { join } from "node:path";
30
30
  import { verifyBackupArchive } from "#runtime/backup.ts";
31
31
  import type { InstallKind } from "#runtime/installKind.ts";
32
32
  import {
33
+ agyPluginCopyState,
33
34
  codexPluginCacheState,
34
35
  grokPluginCopyState,
35
36
  installedPluginVersion,
@@ -43,7 +44,11 @@ import {
43
44
  type UpdateResult,
44
45
  type UpdateStage,
45
46
  } from "#runtime/updatePipeline.ts";
46
- import { CODEX_PLUGIN_ADD_COMMAND, grokPluginReinstallCommand } from "#runtime/wiring.ts";
47
+ import {
48
+ agyPluginInstallCommand,
49
+ CODEX_PLUGIN_ADD_COMMAND,
50
+ grokPluginReinstallCommand,
51
+ } from "#runtime/wiring.ts";
47
52
  import { commandOnPath } from "#util/paths.ts";
48
53
  import { PROGRAM } from "#util/program.ts";
49
54
  import { shellWord } from "#util/shellWord.ts";
@@ -456,6 +461,17 @@ export function postUpdateHints(
456
461
  );
457
462
  }
458
463
  }
464
+ // CV21.DS11.TS4: agy stages a copy too, and an update leaves it as it was;
465
+ // installing again from the path init printed brings it level.
466
+ const geminiHome = home ? join(home, ".gemini") : null;
467
+ if (installRoot && geminiHome && landed) {
468
+ const copy = agyPluginCopyState(geminiHome, landed, "mirror-mind");
469
+ if (copy.state === "behind") {
470
+ hints.push(
471
+ `Antigravity's copy of the plugin is behind the update (${copy.copiedVersion} < ${landed}): run \`${agyPluginInstallCommand(installRoot)}\` to bring it level.`,
472
+ );
473
+ }
474
+ }
459
475
 
460
476
  return hints;
461
477
  }
@@ -83,6 +83,19 @@ function grokPluginInstallCommand(root: string): string {
83
83
  return `grok plugin install "${pluginDirectory(root)}" --trust`;
84
84
  }
85
85
 
86
+ /**
87
+ * The one agy install command (CV21.DS7.US1), which is also its level-up
88
+ * (CV21.DS11.TS4). agy stages a copy of the plugin under
89
+ * `~/.gemini/config/plugins/mirror-mind/` (agy 1.4.7), an update leaves that
90
+ * copy as it was, and installing again replaces it (by name, from any path). A
91
+ * function of the root, so the line `init` prints, the recommendation
92
+ * `diagnose` prints, and the hint `update` prints cannot disagree (the CR071
93
+ * lesson at the string level).
94
+ */
95
+ export function agyPluginInstallCommand(root: string): string {
96
+ return `agy plugin install "${pluginDirectory(root)}"`;
97
+ }
98
+
86
99
  export interface RuntimeWiring {
87
100
  /** The runtime, as a person names it. */
88
101
  readonly title: string;
@@ -121,7 +134,7 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
121
134
  // the Claude surfaces and overwrites the layer's hooks file). Per-turn
122
135
  // injection and logging land with DS7's TS2/TS3; this line promises
123
136
  // the install, nothing more.
124
- (root) => [`agy plugin install "${root}/plugins/mirror-mind"`],
137
+ (root) => [agyPluginInstallCommand(root)],
125
138
  ],
126
139
  },
127
140
  grok: {
@@ -54,9 +54,22 @@ export interface FreshSearchOptions extends FreshSearchFilters {
54
54
  * tool: search never learns it exists.
55
55
  */
56
56
  recordEmbeddingLedger?: boolean | ((info: EmbeddingAttemptInfo) => void);
57
+ /**
58
+ * The query's embedding, already generated (CV9.DS8.TS2). The doorman embeds
59
+ * a turn's prompt once and hands the vector to this search and to the
60
+ * attachment search, so a Mirror Mode turn costs one embedding call, counted
61
+ * once (DS8 decision 1). When given, no provider is called and nothing is
62
+ * recorded here: the caller that embedded owns the ledger row.
63
+ */
64
+ queryEmbedding?: readonly number[];
57
65
  frozenNowMs?: number;
58
66
  now?: string;
59
- provider: EmbeddingProvider;
67
+ /**
68
+ * Absent only with `queryEmbedding` given, or on a turn with no provider at
69
+ * all (the doorman without a key, CV9.DS8.TS2): the search then degrades to
70
+ * FTS as it does when the provider fails, with `no_provider` as the kind.
71
+ */
72
+ provider?: EmbeddingProvider;
60
73
  /** Test-injectable override for generateEmbeddingSafely's retry backoff
61
74
  * (real timers by default) -- avoids paying real backoff wait time in tests
62
75
  * that exercise the transient-retry path. */
@@ -111,26 +124,33 @@ export async function searchMemoriesWithStatus(
111
124
  options: FreshSearchOptions,
112
125
  ): Promise<FreshSearchOutcome> {
113
126
  const limit = options.limit ?? 5;
114
- let queryEmbedding: readonly number[] = [];
127
+ let queryEmbedding: readonly number[] = options.queryEmbedding ?? [];
115
128
  let degraded = false;
116
129
  let degradedKind: string | undefined;
117
- try {
118
- // generateEmbeddingSafely (CR043) retries a transient empty response up to
119
- // its default budget before giving up -- matching Python's search, which
120
- // calls the same generate_embedding used everywhere else, not a bespoke
121
- // single-shot attempt. Any exhausted/permanent/provider-exception failure
122
- // still maps to degraded=true here, preserving CR037's contract exactly.
123
- queryEmbedding = await generateEmbeddingSafely(options.provider, options.query, {
124
- onAttempt: resolveLedgerSink(db, options.recordEmbeddingLedger),
125
- sleep: options.embeddingRetrySleep,
126
- });
127
- } catch (error) {
130
+ if (options.queryEmbedding !== undefined) {
131
+ // The caller embedded: nothing to generate, nothing to record.
132
+ } else if (!options.provider) {
128
133
  degraded = true;
129
- // Keep the CLASS of failure (AI-18 taxonomy) so an operator can tell an
130
- // expired key from a timeout from a rate limit. Without this the front
131
- // door only ever says "offline or no API key", which is a guess -- and
132
- // the taxonomy the live transport builds would die here unseen.
133
- degradedKind = classifyDegradedCause(error);
134
+ degradedKind = "no_provider";
135
+ } else {
136
+ try {
137
+ // generateEmbeddingSafely (CR043) retries a transient empty response up to
138
+ // its default budget before giving up -- matching Python's search, which
139
+ // calls the same generate_embedding used everywhere else, not a bespoke
140
+ // single-shot attempt. Any exhausted/permanent/provider-exception failure
141
+ // still maps to degraded=true here, preserving CR037's contract exactly.
142
+ queryEmbedding = await generateEmbeddingSafely(options.provider, options.query, {
143
+ onAttempt: resolveLedgerSink(db, options.recordEmbeddingLedger),
144
+ sleep: options.embeddingRetrySleep,
145
+ });
146
+ } catch (error) {
147
+ degraded = true;
148
+ // Keep the CLASS of failure (AI-18 taxonomy) so an operator can tell an
149
+ // expired key from a timeout from a rate limit. Without this the front
150
+ // door only ever says "offline or no API key", which is a guess -- and
151
+ // the taxonomy the live transport builds would die here unseen.
152
+ degradedKind = classifyDegradedCause(error);
153
+ }
134
154
  }
135
155
 
136
156
  const memories = listSearchMemoryRows(db, options);
@@ -33,12 +33,22 @@ export interface RankableMemory {
33
33
 
34
34
  export interface RankedMemory {
35
35
  id: string;
36
+ /** The hybrid score the order is by. */
36
37
  score: number;
38
+ /**
39
+ * The semantic component alone: the cosine of the query and the memory, 0 on
40
+ * a degraded search. Reported, not ranked by (CV9.DS8.US5): the doorman's
41
+ * memory threshold reads it, because half of `score` is recency,
42
+ * reinforcement, relevance, and lexical, and a fresh, reinforced memory
43
+ * floors near 0.35 with no semantic match at all.
44
+ */
45
+ semantic: number;
37
46
  }
38
47
 
39
48
  interface Candidate {
40
49
  id: string;
41
50
  score: number;
51
+ semantic: number;
42
52
  embedding: Float32Array;
43
53
  }
44
54
 
@@ -131,7 +141,7 @@ export function rankMemories(
131
141
  lexical: memory.lexical_score ?? 0,
132
142
  weights: config.weights,
133
143
  });
134
- return { id: memory.id, score, embedding };
144
+ return { id: memory.id, score, semantic, embedding };
135
145
  });
136
146
 
137
147
  candidates.sort((a, b) => b.score - a.score);
@@ -150,5 +160,9 @@ export function rankMemories(
150
160
  if (selected.length >= config.limit) break;
151
161
  }
152
162
 
153
- return selected.map((candidate) => ({ id: candidate.id, score: candidate.score }));
163
+ return selected.map((candidate) => ({
164
+ id: candidate.id,
165
+ score: candidate.score,
166
+ semantic: candidate.semantic,
167
+ }));
154
168
  }