mirror-mind 0.36.2 → 0.37.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 (50) hide show
  1. package/.pi/extensions/mirror-logger.ts +293 -64
  2. package/.pi/skills/mm-help/SKILL.md +4 -1
  3. package/.pi/skills/mm-mirror/SKILL.md +25 -6
  4. package/README.md +3 -3
  5. package/docs/releases/index.md +2 -0
  6. package/docs/releases/v0.36.3.md +142 -0
  7. package/docs/releases/v0.37.0.md +221 -0
  8. package/package.json +1 -1
  9. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  10. package/plugins/mirror-mind/plugin.json +1 -1
  11. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  12. package/ts/src/builder/commands.ts +9 -6
  13. package/ts/src/builder/load.ts +14 -2
  14. package/ts/src/conversation/commandTurn.ts +3 -2
  15. package/ts/src/conversation/logger.ts +12 -6
  16. package/ts/src/conversation/piSkillTurns.ts +267 -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/frontDoor/cli.ts +58 -2
  24. package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -2
  25. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  26. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  27. package/ts/src/frontDoor/routing.ts +26 -15
  28. package/ts/src/frontDoor/skillTurnsRoute.ts +213 -0
  29. package/ts/src/frontDoor/soulRoute.ts +4 -4
  30. package/ts/src/frontDoor/stdinSource.ts +5 -0
  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/mirrorForm.ts +4 -1
  39. package/ts/src/mirror/orchestration.ts +22 -3
  40. package/ts/src/mirror/render.ts +7 -1
  41. package/ts/src/mirror/runtimeSession.ts +66 -8
  42. package/ts/src/mode/operatingMode.ts +22 -4
  43. package/ts/src/providers/transport.ts +14 -0
  44. package/ts/src/runtime/diagnose.ts +32 -7
  45. package/ts/src/runtime/pluginWiring.ts +6 -4
  46. package/ts/src/runtime/status.ts +3 -2
  47. package/ts/src/runtime/update.ts +17 -1
  48. package/ts/src/runtime/wiring.ts +14 -1
  49. package/ts/src/search/memorySearch.ts +38 -18
  50. package/ts/src/search/ranker.ts +16 -2
@@ -36,18 +36,36 @@ export function modeLabel(state: OperatingModeState): string {
36
36
  export interface OperatingModeState {
37
37
  mode: string;
38
38
  journey: string | null;
39
+ /**
40
+ * Present, and true, only on a session's stamp written under the session's
41
+ * NAME (`--session-id` or the harness's variable), never on a guessed one
42
+ * or the global row (CR008 option B, landed by CV9.DS8.US5). A lifecycle
43
+ * command binds a journey from a named session's Builder stamp only when
44
+ * this is set: `build load` with no name still stamps a guessed session,
45
+ * so another window's load can land on this one's row.
46
+ */
47
+ namedSession?: true;
39
48
  }
40
49
 
41
50
  export function activateOperatingMode(
42
51
  db: WritableDatabase,
43
- input: { mode: string; journey?: string | null; sessionId?: string | null },
52
+ input: { mode: string; journey?: string | null; sessionId?: string | null; named?: boolean },
44
53
  nowIso: string,
45
54
  ): OperatingModeState {
46
55
  const mode = input.mode.trim();
47
56
  if (!mode) throw new Error("mode must not be empty");
48
57
  const journey = input.journey?.trim() || null;
49
- const state = { mode, journey };
50
- const payload = { active_mode: mode, active_journey: journey };
58
+ const named = Boolean(input.sessionId) && input.named === true;
59
+ const state: OperatingModeState = named
60
+ ? { mode, journey, namedSession: true }
61
+ : { mode, journey };
62
+ // The key only when true: an unnamed stamp's bytes are what they were
63
+ // (`operating-mode-metadata.golden.json`).
64
+ const payload = {
65
+ active_mode: mode,
66
+ active_journey: journey,
67
+ ...(named ? { named_session: true } : {}),
68
+ };
51
69
  if (input.sessionId) {
52
70
  const session = getRuntimeSession(db, input.sessionId);
53
71
  const metadata = decodeMetadata(session?.metadata ?? null);
@@ -133,7 +151,7 @@ function stateFromPayload(payload: unknown): OperatingModeState | null {
133
151
  typeof record.active_journey === "string" && record.active_journey.trim()
134
152
  ? record.active_journey.trim()
135
153
  : null;
136
- return { mode, journey };
154
+ return record.named_session === true ? { mode, journey, namedSession: true } : { mode, journey };
137
155
  }
138
156
 
139
157
  export function renderModeActivation(state: OperatingModeState): string {
@@ -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
@@ -39,6 +39,7 @@ import { configFilePath } from "./config.ts";
39
39
  import { extensionPluginStates } from "./extensionPluginWiring.ts";
40
40
  import type { GitWorktreeEntry } from "./git.ts";
41
41
  import {
42
+ agyPluginCopyState,
42
43
  codexPluginCacheState,
43
44
  grokPluginCopyState,
44
45
  installedPluginVersion,
@@ -46,7 +47,11 @@ import {
46
47
  } from "./pluginWiring.ts";
47
48
 
48
49
  import type { RuntimeStatusReport } from "./status.ts";
49
- import { CODEX_PLUGIN_ADD_COMMAND, grokPluginReinstallCommand } from "./wiring.ts";
50
+ import {
51
+ agyPluginInstallCommand,
52
+ CODEX_PLUGIN_ADD_COMMAND,
53
+ grokPluginReinstallCommand,
54
+ } from "./wiring.ts";
50
55
 
51
56
  export interface DriftFinding {
52
57
  code: string;
@@ -514,18 +519,19 @@ export function diagnoseRuntime(
514
519
 
515
520
  /**
516
521
  * 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
522
+ * CV21.DS10.US1; agy in CV21.DS11.TS4). Codex, agy, and Grok copy the plugin,
523
+ * so an npm update leaves the copy behind; Claude Code links it in place, so
524
+ * its failure mode is a dangling link, and Grok's rules link fails the same
525
+ * way. Grok adds one
520
526
  * failure of its own: a respelt install path keys a SECOND copy, and Grok
521
527
  * loads one of the two without saying which. Absence is not drift: no
522
528
  * cache, no registry, and no link fire nothing, so an unwired machine stays
523
529
  * green.
524
530
  *
525
531
  * 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).
532
+ * else ~/.codex, GROK_HOME else ~/.grok, ~/.claude, and ~/.gemini — never
533
+ * inside the readers, so every reader test is hermetic on a maintainer's
534
+ * machine with real wiring (CR123/128/137/142).
529
535
  */
530
536
  export function pluginWiringFindings(
531
537
  installRoot: string | null,
@@ -550,6 +556,25 @@ export function pluginWiringFindings(
550
556
  }
551
557
  }
552
558
 
559
+ // agy stages a copy under ~/.gemini/config/plugins/mirror-mind/ (agy 1.4.7,
560
+ // DS11.TS4's route), read the way the extensions plugin's copy is read: the
561
+ // `.gemini` home from HOME, with no Mirror variable to override it.
562
+ const geminiHome = home ? join(home, ".gemini") : null;
563
+ if (geminiHome && installedVersion && installRoot) {
564
+ const copy = agyPluginCopyState(geminiHome, installedVersion, "mirror-mind");
565
+ if (copy.state === "behind") {
566
+ const install = agyPluginInstallCommand(installRoot);
567
+ findings.push({
568
+ code: "agy_plugin_copy_behind",
569
+ severity: "warning",
570
+ subject: copy.copyPath,
571
+ detail: `copied ${copy.copiedVersion} < installed ${installedVersion}; agy runs the copy it staged at install`,
572
+ recommendation: `run \`${install}\` to bring the copy level (an update leaves agy's copy as it was, and installing again replaces it)`,
573
+ repair_route: install,
574
+ });
575
+ }
576
+ }
577
+
553
578
  if (home) {
554
579
  const link = pluginLinkState(join(home, ".claude", "skills", "mirror-mind"));
555
580
  if (link.state === "dangling") {
@@ -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,
@@ -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,
@@ -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
  }