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
@@ -1,12 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import type { Database, WritableDatabase } from "#db/database.ts";
4
+ import {
5
+ type DoormanDecision,
6
+ decideDoorman,
7
+ doormanPersonas,
8
+ forgetDoormanDelivery,
9
+ type MemoryScore,
10
+ readDoormanSessionState,
11
+ runDoorman,
12
+ } from "#mirror/doorman.ts";
4
13
  import {
5
14
  deactivateMirrorState,
6
15
  listActiveMirrorJourneys,
7
16
  logMirrorResponse,
8
17
  runMirrorLoad,
9
18
  } from "#mirror/orchestration.ts";
19
+ import { environmentSessionId, resolveNamedRuntimeSessionId } from "#mirror/runtimeSession.ts";
10
20
  import {
11
21
  activateOperatingMode,
12
22
  deactivateOperatingMode,
@@ -16,7 +26,7 @@ import {
16
26
  } from "#mode/operatingMode.ts";
17
27
  import type { OnProviderCallOutcome } from "#observability/callOutcome.ts";
18
28
  import { resolveFamilyProviders } from "#providers/familyProviders.ts";
19
- import { MIRROR_QUERY_TRANSPORT } from "#providers/transport.ts";
29
+ import { MIRROR_QUERY_TRANSPORT, mirrorQueryTransportFor } from "#providers/transport.ts";
20
30
  import { newId, nowIso } from "#util/pyGenerators.ts";
21
31
  import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
22
32
 
@@ -28,6 +38,8 @@ import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
28
38
  */
29
39
  export type MirrorRouteEnvironment = {
30
40
  MIRROR_SESSION_ID?: string;
41
+ CLAUDE_CODE_SESSION_ID?: string;
42
+ CODEX_SESSION_ID?: string;
31
43
  MEMORY_RECEPTION?: string;
32
44
  MIRROR_TS_MIRROR_LLM_REPLAY?: string;
33
45
  MIRROR_TS_MIRROR_EMBEDDING_REPLAY?: string;
@@ -37,14 +49,119 @@ export function isMirrorWrite(argv: readonly string[]): boolean {
37
49
  return argv[0] === "mirror" && ["load", "deactivate", "log"].includes(argv[1] ?? "");
38
50
  }
39
51
 
52
+ /**
53
+ * `mirror doorman` is a write surface with its own handle discipline
54
+ * (CV9.DS8.TS2): it is not `isMirrorWrite`, because the front door's shared
55
+ * write seam opens the live-write path -- a database snapshot -- before any
56
+ * route runs, and a doorman turn that owes nothing must never pay that.
57
+ */
58
+ export function isMirrorDoorman(argv: readonly string[]): boolean {
59
+ return argv[0] === "mirror" && argv[1] === "doorman";
60
+ }
61
+
62
+ export interface DoormanRouteDeps {
63
+ /** The database, read-only: enough to decide. */
64
+ withReadOnlyDatabase: (read: (db: Database) => DoormanDecision) => DoormanDecision | number;
65
+ /** The database behind the pre-write snapshot: only for a turn that delivers. */
66
+ withLiveWriteDatabase: (
67
+ write: (db: WritableDatabase, dbPath: string) => Promise<number>,
68
+ ) => Promise<number>;
69
+ onReceptionOutcome?: OnProviderCallOutcome;
70
+ /** Content-free: the turn's memory scores, hybrid and semantic, for the front-door log. */
71
+ onMemoryScores?: (scores: readonly MemoryScore[]) => void;
72
+ }
73
+
74
+ /**
75
+ * `mirror doorman --prompt "<text>" [--session-id <id>]`: the one doorman as a
76
+ * command, for the Pi extension (US5). Prints the block or nothing; exit 0.
77
+ * `mirror doorman --forget [--session-id <id>]`: the runtime compacted the
78
+ * transcript; the named session's next ordinary turn is full again (US5's
79
+ * debt). Nothing printed; an unnamed session is nothing to mark.
80
+ *
81
+ * Two steps on purpose. The decision is read from a read-only handle; the
82
+ * live-write handle -- `VACUUM INTO` first, 95 ms measured on a 53 MB database
83
+ * -- opens only when the decision is `full` or `delta`. The ordinary turn
84
+ * outside Mirror Mode costs a read and nothing else, as the inject hook's does.
85
+ */
86
+ export async function runMirrorDoormanRoute(
87
+ argv: readonly string[],
88
+ deps: DoormanRouteDeps,
89
+ env: MirrorRouteEnvironment = process.env,
90
+ ): Promise<number> {
91
+ const args = argv.slice(2);
92
+ const sessionId = resolveNamedRuntimeSessionId(
93
+ optionValue(args, "--session-id") ?? null,
94
+ environmentSessionId(env),
95
+ );
96
+ if (hasOption(args, "--forget")) {
97
+ if (!sessionId) return 0;
98
+ return deps.withLiveWriteDatabase(async (db) => {
99
+ forgetDoormanDelivery(db, sessionId, nowIso());
100
+ return 0;
101
+ });
102
+ }
103
+ const prompt = optionValue(args, "--prompt");
104
+ if (prompt === null || prompt === undefined) {
105
+ console.error("mirror doorman requires --prompt");
106
+ return 2;
107
+ }
108
+ const decided = deps.withReadOnlyDatabase((db) =>
109
+ decideDoorman({
110
+ prompt,
111
+ state: readDoormanSessionState(db, sessionId),
112
+ personas: doormanPersonas(db),
113
+ }),
114
+ );
115
+ if (typeof decided === "number") return decided;
116
+ if (decided.kind === "nothing") return 0;
117
+
118
+ // Providers only now: a turn that delivers may embed (delta) or route
119
+ // (full); the turn above resolved none, let alone a key.
120
+ const receptionEnabled = env.MEMORY_RECEPTION !== "0";
121
+ const family = await resolveFamilyProviders(env, mirrorQueryTransportFor(env));
122
+ return deps.withLiveWriteDatabase(async (db, dbPath) => {
123
+ const mirrorHome = dirname(dbPath);
124
+ const rendered = await runDoorman(db, decided, {
125
+ identity: basename(mirrorHome),
126
+ databasePath: dbPath,
127
+ mirrorHome,
128
+ user: basename(mirrorHome),
129
+ sessionId,
130
+ receptionEnabled,
131
+ onReceptionOutcome: deps.onReceptionOutcome,
132
+ llmProvider: receptionEnabled ? family.llm : undefined,
133
+ embeddingProvider: family.embedding,
134
+ onMemoryScores: deps.onMemoryScores,
135
+ newId,
136
+ nowIso,
137
+ });
138
+ process.stdout.write(rendered.stdout);
139
+ process.stderr.write(rendered.stderr);
140
+ return 0;
141
+ });
142
+ }
143
+
40
144
  export function isModeWrite(argv: readonly string[]): boolean {
41
145
  if (argv[0] !== "mode") return false;
42
146
  const args = modePositionals(argv.slice(1));
43
147
  return args[0] === "activate" || args[0] === "deactivate";
44
148
  }
45
149
 
46
- export function mirrorDeactivateMissingSession(argv: readonly string[]): boolean {
47
- return argv[0] === "mirror" && argv[1] === "deactivate" && !optionValue(argv, "--session-id");
150
+ /**
151
+ * `mirror deactivate` with nobody naming a session: `--session-id` absent AND
152
+ * no harness variable (CV9.DS8.US5 -- until then `--session-id` alone, so
153
+ * `/mm-new` on a Pi whose extension had named the session still refused).
154
+ */
155
+ export function mirrorDeactivateMissingSession(
156
+ argv: readonly string[],
157
+ env: MirrorRouteEnvironment = process.env,
158
+ ): boolean {
159
+ return (
160
+ argv[0] === "mirror" &&
161
+ argv[1] === "deactivate" &&
162
+ !optionValue(argv, "--session-id") &&
163
+ environmentSessionId(env) === null
164
+ );
48
165
  }
49
166
 
50
167
  export function runMirrorRead(db: Database, args: readonly string[]): number {
@@ -70,7 +187,10 @@ export async function runMirrorWriteRoute(
70
187
  const args = argv.slice(2);
71
188
  const sessionId = optionValue(args, "--session-id");
72
189
  if (sub === "deactivate") {
73
- deactivateMirrorState(db, sessionId as string, nowIso());
190
+ // The session the caller named, else the harness's (TS2's reader): the
191
+ // unnamed case was refused before this route ran.
192
+ const named = resolveNamedRuntimeSessionId(sessionId ?? null, environmentSessionId(env));
193
+ deactivateMirrorState(db, named as string, nowIso());
74
194
  console.error("Mirror Mode deactivated.");
75
195
  return 0;
76
196
  }
@@ -112,7 +232,11 @@ export async function runMirrorWriteRoute(
112
232
  org: hasOption(args, "--org"),
113
233
  contextOnly: hasOption(args, "--context-only"),
114
234
  sessionId,
115
- environmentSessionId: env.MIRROR_SESSION_ID ?? null,
235
+ // The harness's variable, whichever runtime set it (CV9.DS8.TS2): the
236
+ // skill's own load on Claude Code or Codex names the session the hooks
237
+ // write. `mirror log` keeps reading `MIRROR_SESSION_ID` alone: its
238
+ // fallback is a guess, and widening that is a CR008 question.
239
+ environmentSessionId: environmentSessionId(env),
116
240
  receptionEnabled,
117
241
  onReceptionOutcome: options.onReceptionOutcome,
118
242
  llmProvider: receptionEnabled ? family?.llm : undefined,
@@ -8,7 +8,7 @@ import {
8
8
  CULTIVATION_SCAN_TRANSPORT,
9
9
  DESCRIPTOR_TRANSPORT,
10
10
  JOURNAL_TRANSPORT,
11
- MIRROR_QUERY_TRANSPORT,
11
+ mirrorQueryTransportFor,
12
12
  type ProviderTransportSpec,
13
13
  resolveComposedProviderTransport,
14
14
  resolveProviderTransport,
@@ -591,6 +591,16 @@ export function routeByFamily(
591
591
  : "CR154 conversations imported",
592
592
  };
593
593
  }
594
+ // CR160: allowlisted by name like CR154's, its apply named apart.
595
+ if (argv[1] === "skill-turns") {
596
+ return {
597
+ command,
598
+ engine: "ts",
599
+ reason: argv.includes("--apply")
600
+ ? "CR160 conversations skill-turns --apply"
601
+ : "CR160 conversations skill-turns",
602
+ };
603
+ }
594
604
  const writeFlag = TS4_LIFECYCLE_WRITE_FLAGS.find((flag) => argv.includes(flag));
595
605
  if (writeFlag) {
596
606
  return { command, engine: "ts", reason: `DS7.TS4 ${writeFlag} ported to TS` };
@@ -735,22 +745,23 @@ export function routeByFamily(
735
745
 
736
746
  if (command === "mirror") {
737
747
  const sub = argv[1];
738
- if (sub === "load") {
739
- const hasQuery = argv.includes("--query");
748
+ // CV9.DS8.TS2: the doorman decides before it resolves a provider, and a
749
+ // `nothing` turn resolves none -- but a `full` turn is `load --query`, so
750
+ // the route answers for the doorman exactly as it answers for that.
751
+ if (sub === "load" || sub === "doorman") {
752
+ const hasQuery = sub === "doorman" || argv.includes("--query");
740
753
  if (!hasQuery) {
741
754
  return { command, engine: "ts", reason: "DS7.US4 deterministic mirror load ported to TS" };
742
755
  }
743
- // `MEMORY_RECEPTION=0` skips the classifier on both engines, so the
744
- // chat fixture stops being required for a replay run; the embedding half
745
- // still is, because the query still searches attachments and journeys.
746
- const spec =
747
- env.MEMORY_RECEPTION === "0"
748
- ? {
749
- ...MIRROR_QUERY_TRANSPORT,
750
- replay: { embedding: MIRROR_QUERY_TRANSPORT.replay?.embedding },
751
- }
752
- : MIRROR_QUERY_TRANSPORT;
753
- return providerRoute(command, env, spec, "load --query");
756
+ // `MEMORY_RECEPTION=0` skips the classifier, so the chat fixture stops
757
+ // being required for a replay run; the embedding half still is, because
758
+ // the query still searches attachments and journeys.
759
+ return providerRoute(
760
+ command,
761
+ env,
762
+ mirrorQueryTransportFor(env),
763
+ sub === "doorman" ? "doorman" : "load --query",
764
+ );
754
765
  }
755
766
  if (sub === "deactivate" || sub === "log" || sub === "journeys") {
756
767
  return { command, engine: "ts", reason: `DS7.US4 mirror ${sub} ported to TS` };
@@ -942,7 +953,7 @@ const TASKS_SUBCOMMANDS = [
942
953
  const WEEK_SUBCOMMANDS = ["view", "plan", "save"];
943
954
  const CONSOLIDATE_SUBCOMMANDS = ["scan", "apply", "reject", "list"];
944
955
  const SHADOW_SUBCOMMANDS = ["scan", "apply", "reject", "list", "show"];
945
- const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys"];
956
+ const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys", "doorman"];
946
957
  const MODE_SUBCOMMANDS = ["activate", "deactivate", "status"];
947
958
 
948
959
  /** `mode`'s first positional, past the two options its parser declares; "" when none. */
@@ -0,0 +1,213 @@
1
+ // `mirror conversations skill-turns` (CR160): see the Pi user turns an earlier
2
+ // version stored as a skill's body, and repair them.
3
+ //
4
+ // conversations skill-turns [--apply] [--mirror-home PATH] [--db-path PATH]
5
+ //
6
+ // A dry run until `--apply`. The dry run names what the repair would change: by
7
+ // skill, how many rows keep their Mirror question and how many go; each
8
+ // conversation it would delete because it leaves it with no user turn, with its
9
+ // message count (A7); how many titles it would rebuild. `--apply` decides first
10
+ // (nothing to repair stops there, with no backup), takes the dated backup and
11
+ // verifies it, and only then repairs, in one transaction that decides again and
12
+ // refuses if anything moved. Every option is declared: a typo is refused before
13
+ // anything opens. Shaped as CR154's `conversations imported remove`.
14
+ //
15
+ // The module that decides what a stored skill turn is, and repairs it, is
16
+ // `#conversation/piSkillTurns.ts`; this file parses, opens, and prints. Nothing
17
+ // here is offered to a model: no MCP tool, no skill.
18
+
19
+ import { RETENTION_DAYS } from "#backup/zipBackup.ts";
20
+ import {
21
+ applySkillTurnRepair,
22
+ planSkillTurnRepair,
23
+ type SkillTurnRepairPlan,
24
+ type SkillTurnRepairResult,
25
+ SkillTurnRepairSetMovedError,
26
+ skillTurnRepairKey,
27
+ } from "#conversation/piSkillTurns.ts";
28
+ import type { Database, WritableDatabase } from "#db/database.ts";
29
+ import { PROGRAM } from "#util/program.ts";
30
+ import { shellWord } from "#util/shellWord.ts";
31
+ import { parseArgs } from "#util/strictArgs.ts";
32
+
33
+ const USAGE = `usage: ${PROGRAM} conversations skill-turns [--apply] [--mirror-home PATH] [--db-path PATH]`;
34
+
35
+ /** How argparse names the program in an error line. */
36
+ const PROG = `${PROGRAM} conversations skill-turns`;
37
+
38
+ const WHAT_A_SKILL_TURN_IS =
39
+ "Until CR160, Mirror recorded the skill Pi expanded, instead of what was typed.";
40
+
41
+ const HOME_OPTIONS = ["--mirror-home", "--db-path"] as const;
42
+
43
+ export interface SkillTurnsIo {
44
+ /** The home's database, read-only: the dry run, and the decision before `--apply`. */
45
+ withReadOnlyDatabase: (read: (db: Database) => number) => number;
46
+ /** The home's database behind the front door's pre-write backup: `--apply` only. */
47
+ withLiveWriteDatabase: (write: (db: WritableDatabase, dbPath: string) => number) => number;
48
+ /** The dated archive; its path, or null on failure. */
49
+ backup: (dbPath: string) => string | null;
50
+ verifyBackup: (archivePath: string) => { valid: boolean; note: string | null };
51
+ stdout?: (line: string) => void;
52
+ stderr?: (line: string) => void;
53
+ }
54
+
55
+ export function isSkillTurnsCommand(argv: readonly string[]): boolean {
56
+ return argv[0] === "conversations" && argv[1] === "skill-turns";
57
+ }
58
+
59
+ function plural(count: number, word: string): string {
60
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
61
+ }
62
+
63
+ function isEmpty(plan: SkillTurnRepairPlan): boolean {
64
+ return plan.turns.length === 0 && plan.conversationsToRetitle.length === 0;
65
+ }
66
+
67
+ export function runSkillTurnsRoute(argv: readonly string[], io: SkillTurnsIo): number {
68
+ const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
69
+ const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
70
+
71
+ const rest = argv.slice(2);
72
+ if (rest.includes("-h") || rest.includes("--help")) {
73
+ out(USAGE);
74
+ return 0;
75
+ }
76
+ const parsed = parseArgs(rest, { flags: ["--apply"], valued: [...HOME_OPTIONS] });
77
+ if (parsed.error) {
78
+ err(USAGE);
79
+ err(`${PROG}: error: ${parsed.error}`);
80
+ return 2;
81
+ }
82
+ if (!parsed.flags.has("--apply")) {
83
+ // The command that applies this dry run, to the same home, every value quoted.
84
+ const words = ["conversations", "skill-turns"];
85
+ for (const option of HOME_OPTIONS) {
86
+ const value = parsed.values.get(option);
87
+ if (value !== undefined) words.push(option, value);
88
+ }
89
+ words.push("--apply");
90
+ return dryRun(io, out, [PROGRAM, ...words.map(shellWord)].join(" "));
91
+ }
92
+ return apply(io, out, err);
93
+ }
94
+
95
+ function dryRun(io: SkillTurnsIo, out: (line: string) => void, applyCommand: string): number {
96
+ return io.withReadOnlyDatabase((db) => {
97
+ const plan = planSkillTurnRepair(db);
98
+ if (isEmpty(plan)) {
99
+ out("No Pi user turns stored as a skill's body in this home.");
100
+ return 0;
101
+ }
102
+ if (plan.turns.length > 0) {
103
+ const conversations = new Set(plan.turns.map((turn) => turn.conversationId)).size;
104
+ out(
105
+ `Pi user turns stored as a skill's body: ${plan.turns.length}, in ${plural(conversations, "conversation")}.`,
106
+ );
107
+ out(WHAT_A_SKILL_TURN_IS);
108
+ out("");
109
+ out(" question kept removed skill");
110
+ for (const [skill, kept, removed] of bySkill(plan)) {
111
+ out(`${String(kept).padStart(15)}${String(removed).padStart(9)} ${skill}`);
112
+ }
113
+ out("");
114
+ }
115
+ if (plan.conversationsToDelete.length > 0) {
116
+ out(
117
+ `Would delete ${plural(plan.conversationsToDelete.length, "conversation")} left with no user turn:`,
118
+ );
119
+ for (const item of plan.conversationsToDelete) {
120
+ out(
121
+ ` ${item.startedAt.slice(0, 10)} ${item.id.slice(0, 8)} ${plural(item.messageCount, "message")} ${item.title ?? "(untitled)"}`,
122
+ );
123
+ }
124
+ }
125
+ const retitles = plan.conversationsToRetitle.length;
126
+ if (retitles > 0) {
127
+ out(
128
+ `Would retitle ${plural(retitles, "conversation")} from ${retitles === 1 ? "its" : "their"} first remaining user turn.`,
129
+ );
130
+ }
131
+ out("Replies, summaries, and memories stay.");
132
+ out("Nothing was changed. To repair them, run:");
133
+ out(` ${applyCommand}`);
134
+ return 0;
135
+ });
136
+ }
137
+
138
+ /** By skill: [skill, rows that keep their question, rows that go]; most first, then by name. */
139
+ function bySkill(plan: SkillTurnRepairPlan): [string, number, number][] {
140
+ const counts = new Map<string, [number, number]>();
141
+ for (const turn of plan.turns) {
142
+ const [kept, removed] = counts.get(turn.skill) ?? [0, 0];
143
+ counts.set(turn.skill, turn.question !== null ? [kept + 1, removed] : [kept, removed + 1]);
144
+ }
145
+ return [...counts.entries()]
146
+ .map(([skill, [kept, removed]]): [string, number, number] => [skill, kept, removed])
147
+ .sort((a, b) => b[1] + b[2] - (a[1] + a[2]) || a[0].localeCompare(b[0]));
148
+ }
149
+
150
+ function apply(io: SkillTurnsIo, out: (line: string) => void, err: (line: string) => void): number {
151
+ // Decide first, read-only: nothing to repair stops here, and takes no backup. A
152
+ // holder rather than a variable: it is assigned inside a callback.
153
+ const decided: { key: string | null } = { key: null };
154
+ const decidedCode = io.withReadOnlyDatabase((db) => {
155
+ const plan = planSkillTurnRepair(db);
156
+ if (isEmpty(plan)) {
157
+ out("Nothing to repair.");
158
+ return 0;
159
+ }
160
+ decided.key = skillTurnRepairKey(plan);
161
+ return 0;
162
+ });
163
+ const expectedKey = decided.key;
164
+ if (decidedCode !== 0 || expectedKey === null) return decidedCode;
165
+
166
+ return io.withLiveWriteDatabase((db, dbPath) => {
167
+ let archive: string | null;
168
+ try {
169
+ archive = io.backup(dbPath);
170
+ } catch {
171
+ archive = null;
172
+ }
173
+ if (archive === null) {
174
+ err("The backup failed, so nothing was changed.");
175
+ return 1;
176
+ }
177
+ const verification = io.verifyBackup(archive);
178
+ if (!verification.valid) {
179
+ err(
180
+ `The backup ${archive} did not verify (${verification.note ?? "no reason given"}), so nothing was changed.`,
181
+ );
182
+ return 1;
183
+ }
184
+ let result: SkillTurnRepairResult;
185
+ try {
186
+ result = applySkillTurnRepair(db, expectedKey);
187
+ } catch (error) {
188
+ if (error instanceof SkillTurnRepairSetMovedError) {
189
+ err(
190
+ "The stored skill turns changed while the backup was taken, so nothing was changed. Run it again.",
191
+ );
192
+ return 1;
193
+ }
194
+ throw error;
195
+ }
196
+ out(`Backup: ${archive} (verified).`);
197
+ out(
198
+ `Kept ${plural(result.kept, "Mirror question")}; removed ${plural(result.removed, "skill turn")}.`,
199
+ );
200
+ if (result.conversationsDeleted > 0) {
201
+ out(
202
+ `Deleted ${plural(result.conversationsDeleted, "conversation")} left with no user turn, ` +
203
+ `with ${plural(result.messagesDeletedWithConversations, "more message")}.`,
204
+ );
205
+ out("Memories and provider-call records that named them were kept, unlinked.");
206
+ }
207
+ if (result.conversationsRetitled > 0) {
208
+ out(`Retitled ${plural(result.conversationsRetitled, "conversation")}.`);
209
+ }
210
+ out(`Copies remain in this home's backups until they age out (${RETENTION_DAYS} days).`);
211
+ return 0;
212
+ });
213
+ }
@@ -15,7 +15,7 @@
15
15
  import type { Database, WritableDatabase } from "#db/database.ts";
16
16
  import { getIdentityContent } from "#identity/identityRead.ts";
17
17
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
18
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
18
+ import { environmentSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
19
19
  import { activateOperatingMode } from "#mode/operatingMode.ts";
20
20
  import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
21
21
  import { generateEmbeddingSafely } from "#providers/embedding.ts";
@@ -310,7 +310,7 @@ function runLoad(
310
310
  const resolvedSessionId = resolveRuntimeSessionId(
311
311
  db,
312
312
  sessionId,
313
- process.env.MIRROR_SESSION_ID ?? null,
313
+ environmentSessionId(process.env),
314
314
  );
315
315
  activateOperatingMode(
316
316
  db,
@@ -355,7 +355,7 @@ function runFruit(
355
355
  sessionId: string | null,
356
356
  deps: SoulRouteDeps,
357
357
  ): number {
358
- const resolved = resolveCliSoulSessionId(db, sessionId, process.env.MIRROR_SESSION_ID ?? null);
358
+ const resolved = resolveCliSoulSessionId(db, sessionId, environmentSessionId(process.env));
359
359
  if (action === "set") {
360
360
  if (fruit === null) return usageError("soul fruit set requires a fruit");
361
361
  const state = setFruitInMaturation(db, fruit, resolved, deps.nowIso());
@@ -384,7 +384,7 @@ async function runHarvest(
384
384
  const resolved = resolveCliSoulSessionId(
385
385
  db,
386
386
  option("--session-id"),
387
- process.env.MIRROR_SESSION_ID ?? null,
387
+ environmentSessionId(process.env),
388
388
  );
389
389
  if (action === "set") {
390
390
  const state = harvestFruit(db, { fruit, sessionId: resolved }, deps.nowIso());
@@ -12,6 +12,11 @@
12
12
  // caller uses, and a payload does not belong in argv: argv is what `ps`
13
13
  // shows, and what the Gemini CLI hooks (retired by CV21.DS8.US1) put a prompt
14
14
  // into is the exception this seam avoids repeating.
15
+ //
16
+ // One exception stands, and is no wider than before: Pi's extension reaches the
17
+ // core through `pi.exec`, which cannot write a child's stdin, so it passes the
18
+ // payload as `--payload` (CR160, `conversationLoggerRoute.ts`). Pi's prompt has
19
+ // travelled in argv since CV9, as `log-user`'s argument.
15
20
 
16
21
  import { readFileSync } from "node:fs";
17
22
 
@@ -0,0 +1,92 @@
1
+ // CR163 — no test makes a temp home under a literal /tmp.
2
+ //
3
+ // The suite's temp homes are made with `tempDir` (ts/test/helpers/tempDir.ts),
4
+ // under os.tmpdir(), and removed when the test file's process exits. The
5
+ // run-level check (scripts/test_leaves_nothing.sh) proves a run leaves nothing
6
+ // by pointing TMPDIR at an empty directory; it sees every home only because
7
+ // none is made anywhere else. A home under a literal /tmp would escape it, so
8
+ // this guard keeps one from coming back: every `.ts` under ts/test, helpers and
9
+ // support included, in each form the tree has used.
10
+ //
11
+ // A line that is a comment is not a finding, nor is a /tmp that names a path
12
+ // no test creates (an asserted string, a golden's literal). An exception names
13
+ // its reason, and one that no longer matches anything is reported as stale, so
14
+ // it cannot outlive that reason.
15
+
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { join, relative, sep } from "node:path";
18
+
19
+ /** Files allowed a literal /tmp temp home, each with the reason it must stay. */
20
+ export const EXCEPTIONS: Readonly<Record<string, string>> = {
21
+ "ts/test/helpers/builderWorld.ts":
22
+ "CR164: pathsWalk sees a leaked absolute path only under a short root",
23
+ "ts/test/scripts/testTempHomes.test.ts": "this guard's own cases, held as strings",
24
+ };
25
+
26
+ /** Each form the tree has used to make a temp home under a literal /tmp. */
27
+ const FORMS: readonly RegExp[] = [
28
+ // mkdtempSync("/tmp/…"), a template, the async mkdtemp; the argument may open a new line.
29
+ /\bmkdtemp(?:Sync)?\(\s*["'`]\/tmp\//g,
30
+ // mkdtempSync(join("/tmp", …))
31
+ /\bmkdtemp(?:Sync)?\(\s*join\(\s*["'`]\/tmp["'`]/g,
32
+ // realpathSync("/tmp"), as in mkdtempSync(join(realpathSync("/tmp"), …))
33
+ /\brealpathSync\(\s*["'`]\/tmp["'`]\s*\)/g,
34
+ ];
35
+
36
+ export interface TempHomeFinding {
37
+ /** Repository-relative, with forward slashes. */
38
+ readonly file: string;
39
+ readonly line: number;
40
+ readonly text: string;
41
+ }
42
+
43
+ export interface TempHomeSweep {
44
+ readonly findings: readonly TempHomeFinding[];
45
+ /** Exceptions whose file no longer makes such a home. */
46
+ readonly staleExceptions: readonly string[];
47
+ }
48
+
49
+ function isComment(line: string): boolean {
50
+ const text = line.trimStart();
51
+ return text.startsWith("//") || text.startsWith("*") || text.startsWith("/*");
52
+ }
53
+
54
+ function testFiles(repoRoot: string): string[] {
55
+ const testRoot = join(repoRoot, "ts", "test");
56
+ return readdirSync(testRoot, { recursive: true, withFileTypes: true })
57
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
58
+ .map((entry) => relative(repoRoot, join(entry.parentPath, entry.name)).split(sep).join("/"))
59
+ .sort();
60
+ }
61
+
62
+ function scan(source: string): { line: number; text: string }[] {
63
+ const lines = source.split("\n");
64
+ const hits = new Map<number, string>();
65
+ for (const form of FORMS) {
66
+ for (const match of source.matchAll(form)) {
67
+ const line = source.slice(0, match.index).split("\n").length;
68
+ if (!isComment(lines[line - 1])) hits.set(line, lines[line - 1].trim());
69
+ }
70
+ }
71
+ return [...hits].sort(([a], [b]) => a - b).map(([line, text]) => ({ line, text }));
72
+ }
73
+
74
+ /** Every temp home under a literal /tmp in `ts/test` below `repoRoot`, but the exceptions. */
75
+ export function findLiteralTmpHomes(
76
+ repoRoot: string,
77
+ exceptions: Readonly<Record<string, string>> = EXCEPTIONS,
78
+ ): TempHomeSweep {
79
+ const findings: TempHomeFinding[] = [];
80
+ const excepted = new Set<string>();
81
+ for (const file of testFiles(repoRoot)) {
82
+ const hits = scan(readFileSync(join(repoRoot, file), "utf8"));
83
+ if (hits.length === 0) continue;
84
+ if (file in exceptions) {
85
+ excepted.add(file);
86
+ continue;
87
+ }
88
+ for (const hit of hits) findings.push({ file, ...hit });
89
+ }
90
+ const staleExceptions = Object.keys(exceptions).filter((file) => !excepted.has(file));
91
+ return { findings, staleExceptions };
92
+ }