mirror-mind 0.36.1 → 0.36.3

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.
@@ -35,6 +35,24 @@ function subcommandOf(args: readonly string[]): string {
35
35
  */
36
36
  const readStdin = takeStdin;
37
37
 
38
+ /**
39
+ * `--payload <json>`: a hook subcommand's payload in argv rather than on stdin
40
+ * (CR160). Pi's extension reaches the core through `pi.exec`, which cannot write
41
+ * a child's stdin, and since CR160 it sends Pi's turns to `user-prompt` and
42
+ * `stop`. Pi's prompt has travelled in argv since CV9, as `log-user`'s argument,
43
+ * so this is the exposure Pi always had, no wider (`stdinSource.ts`). The option
44
+ * is taken out of argv before the subcommand sees it, and a route given one
45
+ * never reads fd 0: a stdin pipe nobody closes cannot hang it.
46
+ */
47
+ function takePayloadOption(args: readonly string[]): { payload: string | null; rest: string[] } {
48
+ const index = args.indexOf("--payload");
49
+ if (index === -1 || index + 1 >= args.length) return { payload: null, rest: [...args] };
50
+ return {
51
+ payload: args[index + 1] as string,
52
+ rest: [...args.slice(0, index), ...args.slice(index + 2)],
53
+ };
54
+ }
55
+
38
56
  /**
39
57
  * Where the route's lines go. The default is the process's own streams; the
40
58
  * `session-start --welcome` composite (CV9.E2.S32) captures them instead, so
@@ -51,7 +69,7 @@ export async function runConversationLoggerRoute(
51
69
  argv: readonly string[],
52
70
  io: ConversationLoggerRouteIo = {},
53
71
  ): Promise<number> {
54
- const args = argv.slice(1);
72
+ const { payload, rest: args } = takePayloadOption(argv.slice(1));
55
73
  const subcommand = subcommandOf(args);
56
74
  const runtime = createLoggerRuntime({
57
75
  db,
@@ -73,7 +91,7 @@ export async function runConversationLoggerRoute(
73
91
  }),
74
92
  });
75
93
  const result = await runConversationLoggerCommand(db, args, runtime, {
76
- stdin: STDIN_SUBCOMMANDS.has(subcommand) ? readStdin() : undefined,
94
+ stdin: STDIN_SUBCOMMANDS.has(subcommand) ? (payload ?? readStdin()) : undefined,
77
95
  });
78
96
  const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
79
97
  const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
@@ -27,6 +27,7 @@ import {
27
27
  scanDatabase,
28
28
  } from "#repair/encodingRepair.ts";
29
29
  import { expandHome } from "#util/paths.ts";
30
+ import { parseArgs } from "#util/strictArgs.ts";
30
31
  import { MirrorHomeNotConfiguredError } from "./dbPath.ts";
31
32
 
32
33
  const BACKUP_USAGE =
@@ -34,41 +35,6 @@ const BACKUP_USAGE =
34
35
  const REPAIR_USAGE =
35
36
  "usage: repair-encoding [-h] [--mirror-home MIRROR_HOME] [--apply] [--no-backup] [--limit LIMIT]";
36
37
 
37
- interface ParsedArgs {
38
- flags: Set<string>;
39
- values: Map<string, string>;
40
- error: string | null;
41
- }
42
-
43
- /** argparse-shaped parsing: known flags, known valued options, nothing else. */
44
- function parseArgs(
45
- args: readonly string[],
46
- spec: { flags: readonly string[]; valued: readonly string[] },
47
- ): ParsedArgs {
48
- const flags = new Set<string>();
49
- const values = new Map<string, string>();
50
- const unrecognized: string[] = [];
51
- for (let index = 0; index < args.length; index += 1) {
52
- const arg = args[index] as string;
53
- if (spec.flags.includes(arg)) {
54
- flags.add(arg);
55
- } else if (spec.valued.includes(arg)) {
56
- const value = args[index + 1];
57
- if (value === undefined)
58
- return { flags, values, error: `argument ${arg}: expected one argument` };
59
- values.set(arg, value);
60
- index += 1;
61
- } else {
62
- unrecognized.push(arg);
63
- }
64
- }
65
- return {
66
- flags,
67
- values,
68
- error: unrecognized.length > 0 ? `unrecognized arguments: ${unrecognized.join(" ")}` : null,
69
- };
70
- }
71
-
72
38
  function usageError(usage: string, program: string, message: string): number {
73
39
  process.stderr.write(`${usage}\n${program}: error: ${message}\n`);
74
40
  return 2;
@@ -0,0 +1,337 @@
1
+ // `mirror conversations imported` (CR154): see the Pi conversations an earlier
2
+ // version imported rather than logged, and remove the ones a person names.
3
+ //
4
+ // conversations imported [--project TEXT]
5
+ // conversations imported remove (ID... | --all [--project FOLDER]) [--apply]
6
+ //
7
+ // Folders first: a home holds hundreds of imports, and the decision a person
8
+ // makes is about where they came from. Removal is a dry run until `--apply`,
9
+ // which decides the set first (refusals and an empty set stop there, with no
10
+ // backup), takes the dated backup and verifies it, and only then removes, in
11
+ // one transaction that decides the set again and refuses if it moved. Every
12
+ // option is declared: a typo is refused before anything opens, because under a
13
+ // lenient parser a misspelled `--project` would widen a removal to every import
14
+ // in the home.
15
+ //
16
+ // The module that decides what an import is, and removes it, is
17
+ // `#conversation/importedConversations.ts`; this file parses, opens, and
18
+ // prints. Nothing here is offered to a model: no MCP tool, no skill.
19
+
20
+ import { existsSync, readdirSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { parseArchiveStamp, RETENTION_DAYS } from "#backup/zipBackup.ts";
23
+ import {
24
+ countLiveSessionsByFolder,
25
+ type ImportedConversation,
26
+ ImportRemovalRefusedError,
27
+ type ImportRemovalRequest,
28
+ ImportRemovalSetMovedError,
29
+ listImportedPiConversations,
30
+ planImportRemoval,
31
+ removeImportedConversations,
32
+ summarizeImports,
33
+ } from "#conversation/importedConversations.ts";
34
+ import type { Database, WritableDatabase } from "#db/database.ts";
35
+ import { PROGRAM } from "#util/program.ts";
36
+ import { shellWord } from "#util/shellWord.ts";
37
+ import { parseArgs } from "#util/strictArgs.ts";
38
+ import { backupPathFor } from "./liveBackup.ts";
39
+
40
+ const USAGE = [
41
+ `usage: ${PROGRAM} conversations imported [--project TEXT] [--mirror-home PATH] [--db-path PATH]`,
42
+ ` ${PROGRAM} conversations imported remove (ID ... | --all [--project FOLDER]) [--apply] [--mirror-home PATH] [--db-path PATH]`,
43
+ ].join("\n");
44
+
45
+ /** How argparse names the program in an error line. */
46
+ const PROG = `${PROGRAM} conversations imported`;
47
+
48
+ const WHAT_AN_IMPORT_IS =
49
+ "Mirror copied these from Pi's session files instead of logging them as they happened. " +
50
+ "Since v0.36.1 it imports none.";
51
+
52
+ const HOME_OPTIONS = ["--mirror-home", "--db-path"] as const;
53
+
54
+ export interface ImportedConversationsIo {
55
+ /** The home's database, read-only: the listing and the dry run. */
56
+ withReadOnlyDatabase: (read: (db: Database) => number) => number;
57
+ /** The home's database behind the front door's pre-write backup: `--apply` only. */
58
+ withLiveWriteDatabase: (write: (db: WritableDatabase, dbPath: string) => number) => number;
59
+ /** The dated archive `repair-journeys --apply` gates on; its path, or null on failure. */
60
+ backup: (dbPath: string) => string | null;
61
+ verifyBackup: (archivePath: string) => { valid: boolean; note: string | null };
62
+ stdout?: (line: string) => void;
63
+ stderr?: (line: string) => void;
64
+ }
65
+
66
+ export function isImportedConversationsCommand(argv: readonly string[]): boolean {
67
+ return argv[0] === "conversations" && argv[1] === "imported";
68
+ }
69
+
70
+ function plural(count: number, word: string): string {
71
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
72
+ }
73
+
74
+ function day(instant: string): string {
75
+ return instant.slice(0, 10);
76
+ }
77
+
78
+ function localDay(date: Date): string {
79
+ const pad = (value: number) => String(value).padStart(2, "0");
80
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
81
+ }
82
+
83
+ function byFolder(conversations: readonly ImportedConversation[]): [string, number][] {
84
+ const counts = new Map<string, number>();
85
+ for (const item of conversations) counts.set(item.folder, (counts.get(item.folder) ?? 0) + 1);
86
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
87
+ }
88
+
89
+ function messagesIn(conversations: readonly ImportedConversation[]): number {
90
+ return conversations.reduce((sum, item) => sum + item.messageCount, 0);
91
+ }
92
+
93
+ export function runImportedConversationsRoute(
94
+ argv: readonly string[],
95
+ io: ImportedConversationsIo,
96
+ ): number {
97
+ const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
98
+ const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
99
+ const usageError = (message: string): number => {
100
+ err(USAGE);
101
+ err(`${PROG}: error: ${message}`);
102
+ return 2;
103
+ };
104
+
105
+ const rest = argv.slice(2);
106
+ if (rest.includes("-h") || rest.includes("--help")) {
107
+ out(USAGE);
108
+ return 0;
109
+ }
110
+ const removing = rest[0] === "remove";
111
+ const parsed = parseArgs(removing ? rest.slice(1) : rest, {
112
+ flags: removing ? ["--all", "--apply"] : [],
113
+ valued: ["--project", ...HOME_OPTIONS],
114
+ positionals: removing,
115
+ });
116
+ if (parsed.error) return usageError(parsed.error);
117
+ const project = parsed.values.get("--project") ?? null;
118
+
119
+ if (!removing) return list(io, out, project);
120
+
121
+ const ids = parsed.positionals;
122
+ const all = parsed.flags.has("--all");
123
+ if (ids.length > 0 && all) return usageError("give conversation ids or --all, not both");
124
+ if (ids.length === 0 && !all) return usageError("give conversation ids, or --all");
125
+ if (project !== null && !all) {
126
+ return usageError("--project narrows --all; give it with --all, not with ids");
127
+ }
128
+ const request: ImportRemovalRequest = all ? { all: true, folder: project } : { ids };
129
+
130
+ if (!parsed.flags.has("--apply")) {
131
+ // The command that applies this dry run, to the same home, every value quoted.
132
+ const words = ["conversations", "imported", "remove"];
133
+ if (all) {
134
+ words.push("--all");
135
+ if (project !== null) words.push("--project", project);
136
+ } else {
137
+ words.push(...ids);
138
+ }
139
+ for (const option of HOME_OPTIONS) {
140
+ const value = parsed.values.get(option);
141
+ if (value !== undefined) words.push(option, value);
142
+ }
143
+ words.push("--apply");
144
+ const applyCommand = [PROGRAM, ...words.map(shellWord)].join(" ");
145
+ return dryRun(io, out, err, request, applyCommand);
146
+ }
147
+ return apply(io, out, err, request);
148
+ }
149
+
150
+ function list(
151
+ io: ImportedConversationsIo,
152
+ out: (line: string) => void,
153
+ project: string | null,
154
+ ): number {
155
+ return io.withReadOnlyDatabase((db) => {
156
+ const imports = listImportedPiConversations(db);
157
+ if (imports.length === 0) {
158
+ out("No imported Pi conversations in this home.");
159
+ return 0;
160
+ }
161
+ const needle = project?.toLowerCase() ?? null;
162
+ const shown =
163
+ needle === null
164
+ ? imports
165
+ : imports.filter((item) => item.folder.toLowerCase().includes(needle));
166
+ if (shown.length === 0) {
167
+ out(`No imported Pi conversations from a folder matching '${project}'.`);
168
+ return 0;
169
+ }
170
+ const summary = summarizeImports(shown, countLiveSessionsByFolder(db));
171
+ const scope = project === null ? "" : `, from folders matching '${project}'`;
172
+ out(
173
+ `Imported Pi conversations: ${summary.conversations} (${plural(summary.messages, "message")}), ` +
174
+ `imported ${day(summary.firstImportedAt ?? "")} to ${day(summary.lastImportedAt ?? "")}${scope}.`,
175
+ );
176
+ out(WHAT_AN_IMPORT_IS);
177
+ out("");
178
+ out(" imported logged live here folder");
179
+ for (const folder of summary.folders) {
180
+ const live = folder.loggedLive === null ? "-" : String(folder.loggedLive);
181
+ out(`${String(folder.imported).padStart(10)}${live.padStart(18)} ${folder.folder}`);
182
+ }
183
+ if (project !== null) {
184
+ out("");
185
+ for (const item of shown) {
186
+ out(
187
+ ` ${day(item.importedAt)} ${item.id} ${plural(item.messageCount, "msg")} ${item.folder} ${item.title ?? "(untitled)"}`,
188
+ );
189
+ }
190
+ }
191
+ out("");
192
+ out(`To see a folder's conversations: ${PROGRAM} conversations imported --project <text>`);
193
+ out(
194
+ `To remove a folder's imports: ${PROGRAM} conversations imported remove --all --project <folder>`,
195
+ );
196
+ out(" (it shows what it would remove; add --apply to remove)");
197
+ return 0;
198
+ });
199
+ }
200
+
201
+ function dryRun(
202
+ io: ImportedConversationsIo,
203
+ out: (line: string) => void,
204
+ err: (line: string) => void,
205
+ request: ImportRemovalRequest,
206
+ applyCommand: string,
207
+ ): number {
208
+ return io.withReadOnlyDatabase((db) => {
209
+ const plan = planImportRemoval(db, request);
210
+ if (!plan.ok) {
211
+ err(`${plan.refusal}. Nothing was removed.`);
212
+ return 1;
213
+ }
214
+ if (plan.conversations.length === 0) {
215
+ out("Nothing to remove.");
216
+ return 0;
217
+ }
218
+ out(
219
+ `Would remove ${plural(plan.conversations.length, "imported Pi conversation")} ` +
220
+ `(${plural(messagesIn(plan.conversations), "message")}):`,
221
+ );
222
+ for (const [folder, count] of byFolder(plan.conversations)) {
223
+ out(`${String(count).padStart(10)} ${folder}`);
224
+ }
225
+ out("Nothing was removed. To remove them, run:");
226
+ out(` ${applyCommand}`);
227
+ return 0;
228
+ });
229
+ }
230
+
231
+ function apply(
232
+ io: ImportedConversationsIo,
233
+ out: (line: string) => void,
234
+ err: (line: string) => void,
235
+ request: ImportRemovalRequest,
236
+ ): number {
237
+ // Decide first, read-only: a refusal or an empty set stops here, and takes no
238
+ // backup. A holder rather than a variable: it is assigned inside a callback.
239
+ const decided: { conversations: ImportedConversation[] | null } = { conversations: null };
240
+ const decidedCode = io.withReadOnlyDatabase((db) => {
241
+ const plan = planImportRemoval(db, request);
242
+ if (!plan.ok) {
243
+ err(`${plan.refusal}. Nothing was removed.`);
244
+ return 1;
245
+ }
246
+ if (plan.conversations.length === 0) {
247
+ out("Nothing to remove.");
248
+ return 0;
249
+ }
250
+ decided.conversations = plan.conversations;
251
+ return 0;
252
+ });
253
+ const expected = decided.conversations;
254
+ if (decidedCode !== 0 || expected === null) return decidedCode;
255
+
256
+ return io.withLiveWriteDatabase((db, dbPath) => {
257
+ let archive: string | null;
258
+ try {
259
+ archive = io.backup(dbPath);
260
+ } catch {
261
+ archive = null;
262
+ }
263
+ if (archive === null) {
264
+ err("The backup failed, so nothing was removed.");
265
+ return 1;
266
+ }
267
+ const verification = io.verifyBackup(archive);
268
+ if (!verification.valid) {
269
+ err(
270
+ `The backup ${archive} did not verify (${verification.note ?? "no reason given"}), so nothing was removed.`,
271
+ );
272
+ return 1;
273
+ }
274
+ let removed: { conversations: ImportedConversation[]; messages: number };
275
+ try {
276
+ removed = removeImportedConversations(
277
+ db,
278
+ request,
279
+ expected.map((item) => item.id),
280
+ );
281
+ } catch (error) {
282
+ if (error instanceof ImportRemovalSetMovedError) {
283
+ err(
284
+ "The imports changed while the backup was taken, so nothing was removed. Run it again.",
285
+ );
286
+ return 1;
287
+ }
288
+ if (error instanceof ImportRemovalRefusedError) {
289
+ err(`${error.message}. Nothing was removed.`);
290
+ return 1;
291
+ }
292
+ throw error;
293
+ }
294
+ out(`Backup: ${archive} (verified).`);
295
+ out(
296
+ `Removed ${plural(removed.conversations.length, "imported Pi conversation")} ` +
297
+ `(${plural(removed.messages, "message")}) from ${plural(byFolder(removed.conversations).length, "folder")}.`,
298
+ );
299
+ out("Memories and provider-call records that named them were kept, unlinked.");
300
+ for (const line of backupsHoldingCopies(dbPath, removed.conversations)) out(line);
301
+ return 0;
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Where copies of what was just removed still live: the dated archives made
307
+ * since the earliest removed import, and the pre-write backup. Named so a person
308
+ * can delete them; nothing here deletes them.
309
+ */
310
+ function backupsHoldingCopies(dbPath: string, removed: readonly ImportedConversation[]): string[] {
311
+ // An archive made before the earliest removed import cannot hold any of them.
312
+ const since = Math.min(...removed.map((item) => Date.parse(item.importedAt)));
313
+ const backupsDir = join(dirname(dbPath), "backups");
314
+ const archives = existsSync(backupsDir)
315
+ ? readdirSync(backupsDir)
316
+ .map((name) => ({ name, made: parseArchiveStamp(name) }))
317
+ .filter((entry): entry is { name: string; made: Date } => entry.made !== null)
318
+ .filter((entry) => entry.made.getTime() >= since)
319
+ .sort((a, b) => a.made.getTime() - b.made.getTime())
320
+ : [];
321
+ const preWrite = backupPathFor(dbPath);
322
+ const lines: string[] = [];
323
+ if (archives.length > 0) {
324
+ const oldest = localDay((archives[0] as { made: Date }).made);
325
+ const newest = localDay((archives[archives.length - 1] as { made: Date }).made);
326
+ const made = oldest === newest ? oldest : `${oldest} to ${newest}`;
327
+ lines.push(
328
+ ` ${plural(archives.length, "dated archive")} in ${backupsDir}, made ${made} ` +
329
+ `(archives are removed after ${RETENTION_DAYS} days);`,
330
+ );
331
+ }
332
+ if (existsSync(preWrite)) {
333
+ lines.push(` the pre-write backup ${preWrite} (replaced by the next write).`);
334
+ }
335
+ if (lines.length === 0) return ["No backup in this home holds a copy."];
336
+ return ["Copies remain in this home's backups until you delete them or they age out:", ...lines];
337
+ }
@@ -579,6 +579,28 @@ export function routeByFamily(
579
579
  reason: "DS7.US10 conversations append boundary ported to TS",
580
580
  };
581
581
  }
582
+ // CR154: allowlisted by name for the same reason, before it shipped. Its
583
+ // apply is named apart, so the front-door log tells a removal from a look.
584
+ if (argv[1] === "imported") {
585
+ const applying = argv[2] === "remove" && argv.includes("--apply");
586
+ return {
587
+ command,
588
+ engine: "ts",
589
+ reason: applying
590
+ ? "CR154 conversations imported remove --apply"
591
+ : "CR154 conversations imported",
592
+ };
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
+ }
582
604
  const writeFlag = TS4_LIFECYCLE_WRITE_FLAGS.find((flag) => argv.includes(flag));
583
605
  if (writeFlag) {
584
606
  return { command, engine: "ts", reason: `DS7.TS4 ${writeFlag} ported to TS` };
@@ -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
+ }
@@ -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