mirror-mind 0.36.0 → 0.36.2

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.
@@ -13,6 +13,7 @@
13
13
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { isCommandTurn } from "#conversation/commandTurn.ts";
16
+ import { deleteConversation } from "#conversation/conversationDeletion.ts";
16
17
  import { markCloseTailPending } from "#conversation/deferredCloseTail.ts";
17
18
  import {
18
19
  lastAssistantMessageOf,
@@ -453,25 +454,10 @@ export async function endSession(
453
454
  }
454
455
 
455
456
  /**
456
- * Python `ConversationService.delete_conversations` for one resolved id:
457
- * extracted memories survive with a nulled FK rather than cascading away.
457
+ * Python `discard_current_conversation`: delete and mark the session discarded.
458
+ * The deletion is `conversationDeletion.ts`'s, shared with CR154's removal of
459
+ * imported Pi conversations.
458
460
  */
459
- function deleteConversation(db: WritableDatabase, conversationId: string): void {
460
- db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversationId);
461
- db.prepare("DELETE FROM conversation_embeddings WHERE conversation_id = ?").run(conversationId);
462
- db.prepare("UPDATE memories SET conversation_id = NULL WHERE conversation_id = ?").run(
463
- conversationId,
464
- );
465
- db.prepare("UPDATE llm_calls SET conversation_id = NULL WHERE conversation_id = ?").run(
466
- conversationId,
467
- );
468
- db.prepare("UPDATE runtime_sessions SET conversation_id = NULL WHERE conversation_id = ?").run(
469
- conversationId,
470
- );
471
- db.prepare("DELETE FROM conversations WHERE id = ?").run(conversationId);
472
- }
473
-
474
- /** Python `discard_current_conversation`: delete and mark the session discarded. */
475
461
  export function discardCurrentConversation(
476
462
  db: WritableDatabase,
477
463
  sessionId: string | null,
@@ -2,10 +2,10 @@
2
2
  //
3
3
  // Everything the `conversation-logger` subcommands need beyond the database
4
4
  // is assembled here, once per invocation, from an EXPLICIT environment: the
5
- // clock and id generator, the transcript/session resolution inputs, the Pi
6
- // sessions directory, the pre-write backup, and -- only for the subcommands
7
- // that cross the LLM close tail -- the replay providers and the maintenance
8
- // dependencies built on them.
5
+ // clock and id generator, the transcript/session resolution inputs, the
6
+ // pre-write backup, and -- only for the subcommands that cross the LLM close
7
+ // tail -- the replay providers and the maintenance dependencies built on them.
8
+ // (No Pi sessions directory: maintenance reads no Pi session file, CR153.)
9
9
  //
10
10
  // The providers are loaded lazily. `diagnose-journeys`
11
11
  // or `session-start --fast` must never fail because a replay fixture is not
@@ -17,7 +17,6 @@
17
17
  // Nothing here reads `process.env` or `os.homedir()`; the front door passes
18
18
  // them in, which is what keeps every test of this module hermetic.
19
19
 
20
- import { backfillPiSessions, homeBornAt, resolvePiSessionsDir } from "#conversation/backfill.ts";
21
20
  import { createCloseHooks, maybeGenerateTitle } from "#conversation/closeTail.ts";
22
21
  import { runConversationExtraction } from "#conversation/extraction.ts";
23
22
  import { runExtractionWithAccounting } from "#conversation/extractionRun.ts";
@@ -47,7 +46,6 @@ import {
47
46
  export type LoggerRuntimeEnv = {
48
47
  MIRROR_SESSION_ID?: string;
49
48
  CLAUDE_PROJECT_DIR?: string;
50
- PI_SESSIONS_DIR?: string;
51
49
  MIRROR_TS_CONVERSATION_LLM_REPLAY?: string;
52
50
  MIRROR_TS_CONVERSATION_EMBEDDING_REPLAY?: string;
53
51
  /** Read by the live providers when the close tail runs against a real model. */
@@ -100,12 +98,11 @@ export interface LoggerRuntime {
100
98
  readonly homeDir: string;
101
99
  readonly claudeProjectDir: string | null;
102
100
  readonly environmentSessionId: string | null;
103
- readonly piSessionsDir: string;
104
101
  /** Which transport answers the close tail here: replay, incomplete replay, or live. */
105
102
  readonly transportMode: ProviderTransportMode;
106
103
  /** The close hooks (extraction + finalization) behind the replay transport. */
107
104
  closeHooks(): Promise<CloseHooks>;
108
- /** The full maintenance wiring, including the real Pi backfill. */
105
+ /** The full maintenance wiring. */
109
106
  maintenanceDeps(): Promise<MaintenanceDeps>;
110
107
  /** The dated zip backup for the mutating journey repair; see `LoggerRuntimeOptions.backup`. */
111
108
  readonly backup: ((stdout: (line: string) => void) => string | null) | null;
@@ -135,7 +132,6 @@ function liveTailOverrides(
135
132
 
136
133
  export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntime {
137
134
  const { db, env, deps } = options;
138
- const piSessionsDir = resolvePiSessionsDir(null, env, options.homeDir);
139
135
 
140
136
  // One precedence, shared with the router so the two cannot disagree about
141
137
  // which transport is live: replay -> incomplete -> live. The pair rule that
@@ -225,7 +221,6 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
225
221
  homeDir: options.homeDir,
226
222
  claudeProjectDir: env.CLAUDE_PROJECT_DIR || null,
227
223
  environmentSessionId: env.MIRROR_SESSION_ID?.trim() || null,
228
- piSessionsDir,
229
224
  transportMode: transport.mode,
230
225
  backup: options.backup ?? null,
231
226
  closeHooks,
@@ -246,14 +241,6 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
246
241
  // CV21.DS4.TS2: the finalization a bounded runtime's SessionEnd deferred.
247
242
  finalizeCloseTail: (database, conversationId) =>
248
243
  hooks.finalizeMetadata(database, conversationId),
249
- // CR121: only the sessions newer than this home; its birth is read from
250
- // the ledger once per maintenance run, and an unknown birth is an error.
251
- backfillPiSessions: (database) =>
252
- backfillPiSessions(
253
- database,
254
- { sessionsDir: piSessionsDir, notBefore: homeBornAt(database) },
255
- deps,
256
- ),
257
244
  extractionLimit: resolveMaintenanceMaxExtractions({ env }),
258
245
  staleOrphanMinutes: resolveStaleOrphanMinutes({ env }),
259
246
  monotonic: options.monotonic,
@@ -48,12 +48,6 @@ export interface MaintenanceDeps {
48
48
  * only for callers written before the step existed; the front door wires it.
49
49
  */
50
50
  finalizeCloseTail?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
51
- /**
52
- * `backfillPiSessions` (slice E) bound to its resolved sessions directory.
53
- * Injected rather than resolved here so the composite never reads the
54
- * environment itself; the front door owns that resolution.
55
- */
56
- backfillPiSessions: (db: WritableDatabase) => Promise<number> | number;
57
51
  /** Python's `MEMORY_MAINTENANCE_MAX_EXTRACTIONS` (AI-05); defaults to the released budget. */
58
52
  extractionLimit?: number;
59
53
  /** `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4); defaults to the 30-minute threshold. */
@@ -231,6 +225,11 @@ function formatElapsed(elapsed: number): string {
231
225
  * warning tails. The step order is behavior, not presentation — closing stale
232
226
  * orphans first is what makes their conversations eligible for the extraction
233
227
  * step in the same run.
228
+ *
229
+ * CR153: there is no Pi backfill step. Python's walked Pi's session directory
230
+ * and imported every file this home did not track, which on a machine with two
231
+ * homes meant each other's sessions; a home's Pi conversations are the ones the
232
+ * Pi extension logged into it, and maintenance reads no Pi session file.
234
233
  */
235
234
  export async function sessionMaintenance(
236
235
  db: WritableDatabase,
@@ -246,7 +245,6 @@ export async function sessionMaintenance(
246
245
  thresholdMinutes: deps.staleOrphanMinutes ?? STALE_ORPHAN_THRESHOLD_MINUTES,
247
246
  }),
248
247
  ),
249
- await timedStep("Backfilled Pi sessions", monotonic, () => deps.backfillPiSessions(db)),
250
248
  await timedStep("Retitled pending conversations", monotonic, () =>
251
249
  retitlePendingConversations(db, { retitleConversation: deps.retitleConversation }),
252
250
  ),
@@ -23,7 +23,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
23
23
  return typeof value === "object" && value !== null && !Array.isArray(value);
24
24
  }
25
25
 
26
- /** Python `dict.get` on something that must be a dict; see `backfill.ts`. */
26
+ /**
27
+ * Python `dict.get(key, default)` on a value that MUST be a dict: anything else
28
+ * raises, as Python's AttributeError does. The default applies only when the
29
+ * key is ABSENT -- a present `null` comes back as null, as Python returns None.
30
+ */
27
31
  function getOr(container: unknown, key: string, fallback: unknown): unknown {
28
32
  if (!isRecord(container)) throw new TypeError(`expected an object with '${key}'`);
29
33
  return key in container ? container[key] : fallback;
@@ -548,13 +548,10 @@ export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
548
548
  * contract.
549
549
  */
550
550
  export function runMigrations(db: WritableDatabase): void {
551
- // The ledger has a second reader: `homeBornAt` (conversation/backfill.ts,
552
- // CR121) takes MIN(applied_at) as the instant this home was born, and
553
- // session maintenance imports only the Pi sessions newer than it. Never
554
- // rewrite history here -- no re-stamping, no squash that deletes rows and
555
- // re-inserts them with a later date. Retiring a migration id moves the
556
- // minimum forward at worst, which imports less; re-stamping could move it
557
- // and would be a lie about the home's age.
551
+ // The ledger records which migrations ran, and when -- nothing more. Do not
552
+ // infer a home's birth from its first `applied_at`: a database copied to
553
+ // make a second home carries its source's ledger, so two homes would share
554
+ // one birth (CR153 retired the reader that did, CR156 records the copy).
558
555
  db.exec(`
559
556
  CREATE TABLE IF NOT EXISTS _migrations (
560
557
  id TEXT PRIMARY KEY,
@@ -22,6 +22,7 @@
22
22
  import { accessSync, constants, existsSync, readFileSync } from "node:fs";
23
23
  import { basename, dirname } from "node:path";
24
24
  import { pathToFileURL } from "node:url";
25
+ import { createZipBackup } from "#backup/zipBackup.ts";
25
26
  import {
26
27
  AppendRejectedError,
27
28
  type AppendRejectionReason,
@@ -68,6 +69,7 @@ import {
68
69
  JOURNAL_TRANSPORT,
69
70
  WEEK_PLAN_TRANSPORT,
70
71
  } from "#providers/transport.ts";
72
+ import { verifyBackupArchive } from "#runtime/backup.ts";
71
73
  import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
72
74
  import {
73
75
  configFilePath,
@@ -107,6 +109,10 @@ import { runBackupRoute, runRepairEncodingRoute } from "./dbSafetyToolsRoute.ts"
107
109
  import { defaultExploreRouteDeps, runExploreRoute } from "./exploreRoute.ts";
108
110
  import { frontDoorLogPath, logFrontDoor } from "./frontDoorLog.ts";
109
111
  import { applyIdentitySet } from "./identityWrite.ts";
112
+ import {
113
+ isImportedConversationsCommand,
114
+ runImportedConversationsRoute,
115
+ } from "./importedConversationsRoute.ts";
110
116
  import { applyJourneySetPath } from "./journeyWriteRoute.ts";
111
117
  import { openLiveWriteDatabase } from "./liveBackup.ts";
112
118
  import {
@@ -1759,6 +1765,22 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1759
1765
  if (argv[0] === "soul") return runSoulWrite(argv);
1760
1766
  if (argv[0] === "explore") return runExploreWrite(argv);
1761
1767
  if (isConversationsAppend(argv)) return runConversationsAppend(argv);
1768
+ // CR154: allowlisted by name, like `append`, so the listing never answers it.
1769
+ // The listing and the dry run read; `--apply` writes behind the pre-write
1770
+ // backup, after its own dated, verified archive.
1771
+ if (isImportedConversationsCommand(argv)) {
1772
+ return runImportedConversationsRoute(argv, {
1773
+ withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
1774
+ withLiveWriteDatabase: (write) => {
1775
+ const dbPath = resolveDbPathForCli(argv.slice(1));
1776
+ if (dbPath === null) return 2;
1777
+ return withLiveWriteDbAt(dbPath, "conversations", (db) => write(db, dbPath));
1778
+ },
1779
+ backup: (dbPath) =>
1780
+ createZipBackup({ dbPath, mirrorHome: dirname(dbPath), backupDir: null, silent: true }),
1781
+ verifyBackup: (archive) => verifyBackupArchive(archive),
1782
+ });
1783
+ }
1762
1784
  // CV22.DS7.TS1: the DB safety tools. `backup` never opens or bootstraps the
1763
1785
  // database; `repair-encoding --apply` rides the live-write seam.
1764
1786
  if (argv[0] === "backup") {
@@ -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,18 @@ 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
+ }
582
594
  const writeFlag = TS4_LIFECYCLE_WRITE_FLAGS.find((flag) => argv.includes(flag));
583
595
  if (writeFlag) {
584
596
  return { command, engine: "ts", reason: `DS7.TS4 ${writeFlag} ported to TS` };