mirror-mind 0.36.1 → 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.
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.36.2 — Clearing Out What Another Home Copied](v0.36.2.md) — patch release of one Change Request, CR154, finishing what v0.36.1 began: `mirror conversations imported` lists the Pi conversations an earlier version imported, by the folder each session came from and beside how many sessions the home logged live there, and `remove` deletes the ones you name, only on `--apply` and after a verified backup. `discard-current` no longer fails on a conversation a Soul Mode integration names. No migration.
15
16
  - [v0.36.1 — A Home Keeps Only Its Own Pi Sessions](v0.36.1.md) — patch release of one Change Request, CR153: session maintenance imports no Pi session, so the Mirror homes on one machine no longer copy each other's Pi conversations; a home's Pi conversations are the ones the Pi extension logged into it. `PI_SESSIONS_DIR` is inert. Conversations an earlier version imported stay, and the note shows how to list them. Update every install that has a home. No migration.
16
17
  - [v0.36.0 — Five Runtimes, and a Home's Extensions on Every One](v0.36.0.md) — minor release closing CV21.DS10: Grok Build is the fifth runtime, at its honest L3 through the canonical plugin in two printed steps, measured live on grok 1.0.62. With it, CV21.DS5.US2: a home's installed extensions reach Claude Code, Codex, Antigravity, and Grok Build as one generated plugin per home, chosen by measurement on all four. Three Change Requests found by use: an extension's settings file (`extensions/<id>.env`, merged under the environment for the one spawned process), a quiet exit on a closed pipe, and a `bind` that refuses what it cannot bind. No migration.
17
18
  - [v0.35.0 — Four Runtimes, One Successor](v0.35.0.md) — minor release closing CV21.DS8: the Gemini CLI integration no longer ships and Antigravity stands in its place, the gate met by DS7's live L4 on agy 1.4.2. Four runtimes — Pi, Claude Code, Codex, Antigravity — named everywhere; the cutoff recorded in full, with the four hook entries a Gemini CLI user deletes and v0.34.0 as the last release that carries the integration; a retired-surface guard keeps it removed. No migration.
@@ -0,0 +1,130 @@
1
+ ---
2
+ digest: >
3
+ v0.36.2 is a patch release of one Change Request, CR154, which finishes what
4
+ v0.36.1 began. v0.36.1 stopped Mirror importing Pi sessions, but the
5
+ conversations an earlier version had imported stayed, and the only way to
6
+ see them was a SQL query. `mirror conversations imported` now lists them by
7
+ the folder each session came from, beside how many Pi sessions the home
8
+ logged live there, and `remove` deletes the ones you name, only on
9
+ `--apply` and only after a verified backup. With it, `discard-current` no
10
+ longer fails on a conversation a Soul Mode integration names. No migration.
11
+ ---
12
+
13
+ # v0.36.2 — Clearing Out What Another Home Copied
14
+
15
+ **Date:** 2026-10-21
16
+
17
+ ## Highlights
18
+
19
+ - **`mirror conversations imported` shows what an earlier version imported**
20
+ ([CR154](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr154-a-home-cannot-list-or-remove-the-pi-conversations-it-imported.md)):
21
+ one line per folder the session came from, with how many conversations were imported
22
+ from it and how many Pi sessions this home logged live there. A folder where the home
23
+ logged nothing live is most likely another home's work. `--project <text>` lists a
24
+ folder's conversations.
25
+ - **`remove` deletes what you name, and nothing else.** Ids, or every import of one
26
+ folder, written exactly as the listing prints it. It is a dry run until `--apply`;
27
+ `--apply` takes a dated backup, verifies it, and removes in one transaction. Memories
28
+ and provider-call records that named a removed conversation are kept, unlinked, and
29
+ the report names the backups that still hold copies.
30
+ - **A typo cannot widen a removal.** Every option is declared, and one the command does
31
+ not know is refused before the database opens.
32
+ - **`discard-current` works on a Soul Mode conversation.** It failed with `FOREIGN KEY
33
+ constraint failed` on any conversation a Soul Mode integration named. Deleting a
34
+ conversation now decides every table that references one, and a test reads the
35
+ schema so the next such table cannot be missed.
36
+
37
+ ## Where We Started
38
+
39
+ v0.36.1 stopped session maintenance from importing Pi sessions, the defect that let the
40
+ Mirror homes on one machine copy each other's conversations. It removed the cause and
41
+ said so: the copies stayed, and the note gave a SQL query to list them. Deleting them
42
+ was the person's decision, and no command could carry it out — `discard-current`
43
+ removes only the current conversation.
44
+
45
+ ## What Changed
46
+
47
+ **One command, folders first.** A home can hold hundreds of imports, and the decision a
48
+ person makes is about where they came from, so the listing leads with folders. The
49
+ folder is the directory the Pi session file lived in, read from the path the home
50
+ stored; Mirror reads no Pi file to learn it. Beside each folder, the number of Pi
51
+ sessions the home logged live from it. On the maintainer's production home, 96 imports
52
+ came from one development worktree and 76 from the development checkout, where it had
53
+ logged none live, and 3 from its own workspace, where it had logged 714. In a home made
54
+ by copying another, the copied rows count as its own, which blurs the number.
55
+
56
+ **Removal is careful by construction.** A dry run first, with the exact command that
57
+ applies it to the same home. On `--apply`, the set is decided read-only; a refusal or
58
+ an empty set stops there, with no backup. Then the dated archive, verified; then one
59
+ transaction that decides the set again and refuses if it moved. An id must name exactly
60
+ one imported conversation, by its full id or by a prefix of at least eight characters:
61
+ a live conversation, an unknown id, or an ambiguous prefix refuses the whole command.
62
+
63
+ **A review before any code.** Five personas reviewed the plan. The database architect
64
+ found that the deletion `discard-current` used, the one this command meant to reuse,
65
+ missed one of the six tables that reference a conversation, so with foreign keys on, a
66
+ conversation a Soul Mode integration named could not be deleted at all — measured on a
67
+ scratch home, then fixed for both commands. The security engineer found that under the
68
+ listing's lenient parser, a typo such as `--projet` would drop the folder filter and
69
+ remove every import in the home; the command parses strictly, with the parser `backup`
70
+ and `repair-encoding` already used.
71
+
72
+ **Validated on a copy of the real thing.** On a scratch home made from a fresh backup of
73
+ the maintainer's development home, the command listed 515 imports from 22 folders
74
+ and 5 of unknown origin, refused the typo, and removed the 418 from the production workspace's folder with their
75
+ 21,901 messages. Live conversations, memories, and provider-call records kept their
76
+ counts.
77
+
78
+ ### What you will notice
79
+
80
+ - A new command, `mirror conversations imported`, and its `remove`.
81
+ - `discard-current` deletes a conversation that a Soul Mode integration names.
82
+
83
+ ## Upgrading
84
+
85
+ ```bash
86
+ mirror runtime update # or: npm install -g mirror-mind
87
+ ```
88
+
89
+ There is no database migration, and no runtime wiring changes. Then, if you ran more
90
+ than one home:
91
+
92
+ ```bash
93
+ mirror conversations imported
94
+ mirror conversations imported --project <text>
95
+ mirror conversations imported remove --all --project <folder>
96
+ mirror conversations imported remove --all --project <folder> --apply
97
+ ```
98
+
99
+ Start with the folders where "logged live here" is 0, and look at a folder's
100
+ conversations before removing them. The dated backups made since the import, and the
101
+ pre-write backup, keep copies until you delete them or they age out (30 days for an
102
+ archive).
103
+
104
+ ## Conscious Exclusions
105
+
106
+ - **Telling another home's copy from a gap this home filled for itself.** That takes
107
+ both homes' databases, and no home reads another's. The folders and the logged-live
108
+ counts are the evidence a person decides on.
109
+ - **Removing anything from backups.**
110
+ - **Options before the subcommand.** `mirror conversations --db-path X imported` shows
111
+ the plain listing and removes nothing; that is
112
+ [CR096](../project/refinement/rs009-cv22-front-door-routing-correctness/cr096-conversations-lists-instead-of-appending-when-options-come-first.md)'s
113
+ class.
114
+ - **A tool for models.** No MCP tool and no skill runs this command: a person does.
115
+
116
+ ## What We Learned
117
+
118
+ Reusing a routine is a claim about it. The deletion `discard-current` used was correct
119
+ for the tables it knew, and wrong the day another table began to reference a
120
+ conversation. The question that found it — which tables reference a conversation? — is
121
+ now a test that asks the schema, not a person's memory.
122
+
123
+ A lenient parser is harmless in a listing and dangerous in a deletion: the same ignored
124
+ token is a no-op in one and a wider removal in the other.
125
+
126
+ ## Next Horizon
127
+
128
+ CV21.DS5.TS4, then DS11 and CV21's own release. Captured and open: CR155, `runtime
129
+ diagnose` reading the Pi extension's error lines; CR156, what copying a home makes;
130
+ CR157, three settings `REFERENCE.md` lists that nothing reads.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.36.1",
4
+ "version": "0.36.2",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -0,0 +1,45 @@
1
+ // What deleting a conversation means, in one place (CR154, review findings D1
2
+ // and E2).
3
+ //
4
+ // Ported as Python's `ConversationService.delete_conversations` for one id, and
5
+ // private to `logger.ts` while `discard-current` was its only caller. CR154's
6
+ // removal of imported Pi conversations is the second, so it lives here now.
7
+ //
8
+ // Six tables reference a conversation, and each gets a decision: what is only
9
+ // the conversation's own is deleted with it, and what outlives it is kept with
10
+ // its link cleared -- a memory extracted from the conversation is still a
11
+ // memory, a provider call still happened, a session row still names its
12
+ // session, an identity integration is still part of the identity. Foreign keys
13
+ // are on (`database.ts`), so a table without a decision makes the conversation
14
+ // undeletable: before CR154 the routine missed `identity_integrations`, and
15
+ // `discard-current` failed with `FOREIGN KEY constraint failed` on any
16
+ // conversation a Soul Mode integration named. `conversationDeletion.test.ts`
17
+ // reads the schema's foreign keys and fails when a table appears that the
18
+ // lists below do not decide.
19
+
20
+ import type { WritableDatabase } from "#db/database.ts";
21
+
22
+ /** Every table that references `conversations(id)`, by what deletion does to its rows. */
23
+ export const CONVERSATION_REFERENCES = {
24
+ /** The conversation's own rows: deleted with it. */
25
+ deleted: ["conversation_embeddings", "messages"],
26
+ /** Rows that outlive it: kept, their `conversation_id` set to null. */
27
+ unlinked: ["identity_integrations", "llm_calls", "memories", "runtime_sessions"],
28
+ } as const;
29
+
30
+ /**
31
+ * Delete one conversation: its own rows first, then the links that outlive
32
+ * it, then the conversation. Runs no transaction of its own -- every caller
33
+ * wraps it in one with whatever else must move together.
34
+ */
35
+ export function deleteConversation(db: WritableDatabase, conversationId: string): void {
36
+ for (const table of CONVERSATION_REFERENCES.deleted) {
37
+ db.prepare(`DELETE FROM ${table} WHERE conversation_id = ?`).run(conversationId);
38
+ }
39
+ for (const table of CONVERSATION_REFERENCES.unlinked) {
40
+ db.prepare(`UPDATE ${table} SET conversation_id = NULL WHERE conversation_id = ?`).run(
41
+ conversationId,
42
+ );
43
+ }
44
+ db.prepare("DELETE FROM conversations WHERE id = ?").run(conversationId);
45
+ }
@@ -0,0 +1,288 @@
1
+ // The Pi conversations an earlier version imported rather than logged (CR154).
2
+ //
3
+ // Until v0.36.1, session maintenance copied Pi session files into the home and
4
+ // imported each as a closed conversation (CR153 retired that). On a machine
5
+ // with two homes, each took the other's sessions, along with sessions of Pi run
6
+ // without Mirror and of scratch homes. This module lets a person see them and
7
+ // remove the ones they choose. It reads no Pi file: everything it knows, the
8
+ // home already holds.
9
+ //
10
+ // An import is told apart by construction: the import stamped `started_at` when
11
+ // it copied the session and `ended_at` from the session's last message, so an
12
+ // import starts at or after its own end, and a live conversation never does.
13
+ // Both instants must carry a zone. A conversation with a zone-less timestamp is
14
+ // never an import, whatever the order says: the comparison would be a guess,
15
+ // and the guess would offer a live conversation for removal.
16
+ //
17
+ // Where an import came from is the directory of its session file, read from
18
+ // the path its runtime-session row stored (Pi names a project's directory
19
+ // `--<path with / as ->--`; the wrapping dashes are dropped). Beside it, how
20
+ // many Pi sessions this home logged live from the same directory -- the one
21
+ // number that tells another home's work (none logged live here) from this
22
+ // home's own gaps. It blurs in a home made by copying another: the copied rows
23
+ // count as its own.
24
+
25
+ import { basename, dirname } from "node:path";
26
+ import { deleteConversation } from "#conversation/conversationDeletion.ts";
27
+ import { type Database, type WritableDatabase, withTransaction } from "#db/database.ts";
28
+
29
+ /** The folder of an import with no runtime-session row left to say where it came from. */
30
+ export const UNKNOWN_FOLDER = "(unknown)";
31
+
32
+ /** How much of an id a person must give to name one conversation: what the listing prints. */
33
+ export const MIN_ID_PREFIX = 8;
34
+
35
+ export interface ImportedConversation {
36
+ id: string;
37
+ title: string | null;
38
+ /** When it was imported: the conversation's `started_at`. */
39
+ importedAt: string;
40
+ /** The session's last message: the conversation's `ended_at`. */
41
+ endedAt: string;
42
+ messageCount: number;
43
+ folder: string;
44
+ }
45
+
46
+ export interface FolderSummary {
47
+ folder: string;
48
+ imported: number;
49
+ messages: number;
50
+ /** Pi sessions this home logged live from the folder; null for the unknown folder. */
51
+ loggedLive: number | null;
52
+ }
53
+
54
+ export interface ImportsSummary {
55
+ conversations: number;
56
+ messages: number;
57
+ firstImportedAt: string | null;
58
+ lastImportedAt: string | null;
59
+ /** Most imported first, then by name; the unknown folder last. */
60
+ folders: FolderSummary[];
61
+ }
62
+
63
+ /** Ids, each a full id or a prefix of at least `MIN_ID_PREFIX`; or every import, of one folder or all. */
64
+ export type ImportRemovalRequest =
65
+ | { readonly ids: readonly string[] }
66
+ | { readonly all: true; readonly folder: string | null };
67
+
68
+ export type ImportRemovalPlan =
69
+ | { ok: true; conversations: ImportedConversation[] }
70
+ | { ok: false; refusal: string };
71
+
72
+ export interface ImportRemovalResult {
73
+ conversations: ImportedConversation[];
74
+ messages: number;
75
+ }
76
+
77
+ /** The plan refused the request inside the removal's transaction. */
78
+ export class ImportRemovalRefusedError extends Error {
79
+ constructor(refusal: string) {
80
+ super(refusal);
81
+ this.name = "ImportRemovalRefusedError";
82
+ }
83
+ }
84
+
85
+ /** The set the transaction found is not the set the person was shown. */
86
+ export class ImportRemovalSetMovedError extends Error {
87
+ constructor() {
88
+ super("the imports to remove changed since they were listed");
89
+ this.name = "ImportRemovalSetMovedError";
90
+ }
91
+ }
92
+
93
+ const ZONED = /(?:Z|[+-]\d{2}:\d{2})$/;
94
+
95
+ /** An import starts at or after its own last message; both instants carry a zone. */
96
+ export function isImportShaped(startedAt: string, endedAt: string | null): boolean {
97
+ if (endedAt === null || !ZONED.test(startedAt) || !ZONED.test(endedAt)) return false;
98
+ const started = Date.parse(startedAt);
99
+ const ended = Date.parse(endedAt);
100
+ if (Number.isNaN(started) || Number.isNaN(ended)) return false;
101
+ return started >= ended;
102
+ }
103
+
104
+ /** The directory a Pi session file lived in, from its stored path; Pi's wrapping dashes dropped. */
105
+ export function sessionFolder(sessionPath: string | null): string {
106
+ if (!sessionPath) return UNKNOWN_FOLDER;
107
+ const parent = dirname(sessionPath);
108
+ const name = basename(parent);
109
+ if (parent === "." || parent === sessionPath || !name) return UNKNOWN_FOLDER;
110
+ return name.length > 4 && name.startsWith("--") && name.endsWith("--") ? name.slice(2, -2) : name;
111
+ }
112
+
113
+ function nullableString(value: unknown): string | null {
114
+ return typeof value === "string" ? value : null;
115
+ }
116
+
117
+ /** Every Pi import in the home, newest import first. */
118
+ export function listImportedPiConversations(db: Database): ImportedConversation[] {
119
+ // SQL narrows to the shape; `isImportShaped` decides, zones included.
120
+ const rows = db
121
+ .prepare(
122
+ `SELECT c.id, c.title, c.started_at, c.ended_at,
123
+ (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count,
124
+ (SELECT r.session_id FROM runtime_sessions r
125
+ WHERE r.conversation_id = c.id ORDER BY r.rowid LIMIT 1) AS session_path
126
+ FROM conversations c
127
+ WHERE c.interface = 'pi' AND c.ended_at IS NOT NULL
128
+ AND julianday(c.started_at) >= julianday(c.ended_at)
129
+ ORDER BY julianday(c.started_at) DESC, c.id`,
130
+ )
131
+ .all();
132
+ const imports: ImportedConversation[] = [];
133
+ for (const row of rows) {
134
+ const startedAt = String(row.started_at);
135
+ const endedAt = nullableString(row.ended_at);
136
+ if (endedAt === null || !isImportShaped(startedAt, endedAt)) continue;
137
+ imports.push({
138
+ id: String(row.id),
139
+ title: nullableString(row.title),
140
+ importedAt: startedAt,
141
+ endedAt,
142
+ messageCount: Number(row.message_count),
143
+ folder: sessionFolder(nullableString(row.session_path)),
144
+ });
145
+ }
146
+ return imports;
147
+ }
148
+
149
+ /** Pi sessions this home logged live, by the folder their session file lived in. */
150
+ export function countLiveSessionsByFolder(db: Database): Map<string, number> {
151
+ const rows = db
152
+ .prepare(
153
+ `SELECT r.session_id, c.started_at, c.ended_at
154
+ FROM runtime_sessions r JOIN conversations c ON c.id = r.conversation_id
155
+ WHERE r.interface = 'pi' AND c.interface = 'pi'`,
156
+ )
157
+ .all();
158
+ const counts = new Map<string, number>();
159
+ for (const row of rows) {
160
+ if (isImportShaped(String(row.started_at), nullableString(row.ended_at))) continue;
161
+ const folder = sessionFolder(nullableString(row.session_id));
162
+ counts.set(folder, (counts.get(folder) ?? 0) + 1);
163
+ }
164
+ return counts;
165
+ }
166
+
167
+ /** Totals and one line per folder, for the listing. */
168
+ export function summarizeImports(
169
+ imports: readonly ImportedConversation[],
170
+ loggedLive: ReadonlyMap<string, number>,
171
+ ): ImportsSummary {
172
+ const byFolder = new Map<string, FolderSummary>();
173
+ let messages = 0;
174
+ let first: string | null = null;
175
+ let last: string | null = null;
176
+ for (const item of imports) {
177
+ messages += item.messageCount;
178
+ if (first === null || Date.parse(item.importedAt) < Date.parse(first)) first = item.importedAt;
179
+ if (last === null || Date.parse(item.importedAt) > Date.parse(last)) last = item.importedAt;
180
+ const summary = byFolder.get(item.folder) ?? {
181
+ folder: item.folder,
182
+ imported: 0,
183
+ messages: 0,
184
+ loggedLive: item.folder === UNKNOWN_FOLDER ? null : (loggedLive.get(item.folder) ?? 0),
185
+ };
186
+ summary.imported += 1;
187
+ summary.messages += item.messageCount;
188
+ byFolder.set(item.folder, summary);
189
+ }
190
+ const folders = [...byFolder.values()].sort((a, b) => {
191
+ if ((a.folder === UNKNOWN_FOLDER) !== (b.folder === UNKNOWN_FOLDER)) {
192
+ return a.folder === UNKNOWN_FOLDER ? 1 : -1;
193
+ }
194
+ return b.imported - a.imported || a.folder.localeCompare(b.folder);
195
+ });
196
+ return {
197
+ conversations: imports.length,
198
+ messages,
199
+ firstImportedAt: first,
200
+ lastImportedAt: last,
201
+ folders,
202
+ };
203
+ }
204
+
205
+ function refuse(refusal: string): ImportRemovalPlan {
206
+ return { ok: false, refusal };
207
+ }
208
+
209
+ /**
210
+ * What a request names, decided once for the dry run and again inside the
211
+ * removal's transaction. Any id that does not name exactly one import refuses
212
+ * the whole request: nothing is removed on a partial match.
213
+ */
214
+ export function planImportRemoval(db: Database, request: ImportRemovalRequest): ImportRemovalPlan {
215
+ const imports = listImportedPiConversations(db);
216
+ if ("all" in request) {
217
+ if (request.folder === null) return { ok: true, conversations: imports };
218
+ const named = imports.filter((item) => item.folder === request.folder);
219
+ if (named.length === 0) {
220
+ return refuse(
221
+ `no imported conversations from folder '${request.folder}' in this home: the listing names the folders that have them, exactly`,
222
+ );
223
+ }
224
+ return { ok: true, conversations: named };
225
+ }
226
+
227
+ const byId = new Map(imports.map((item) => [item.id, item]));
228
+ const chosen = new Map<string, ImportedConversation>();
229
+ // `substr` rather than LIKE: an id the person types is matched as written,
230
+ // never as a pattern.
231
+ const matching = db.prepare("SELECT id FROM conversations WHERE substr(id, 1, ?) = ?");
232
+ for (const given of request.ids) {
233
+ if (given.length < MIN_ID_PREFIX) {
234
+ return refuse(
235
+ `'${given}' is too short: give at least ${MIN_ID_PREFIX} characters of a conversation id, as the listing prints them`,
236
+ );
237
+ }
238
+ const matches = matching.all(given.length, given).map((row) => String(row.id));
239
+ if (matches.length === 0) return refuse(`no conversation '${given}' in this home`);
240
+ if (matches.length > 1) {
241
+ return refuse(`'${given}' matches ${matches.length} conversations: give more of the id`);
242
+ }
243
+ const id = matches[0] as string;
244
+ const imported = byId.get(id);
245
+ if (imported === undefined) {
246
+ return refuse(
247
+ `conversation ${id.slice(0, MIN_ID_PREFIX)} is not an imported Pi conversation; this command removes only those`,
248
+ );
249
+ }
250
+ chosen.set(id, imported);
251
+ }
252
+ return { ok: true, conversations: imports.filter((item) => chosen.has(item.id)) };
253
+ }
254
+
255
+ function sameIds(a: readonly string[], b: readonly string[]): boolean {
256
+ if (a.length !== b.length) return false;
257
+ const sortedA = [...a].sort();
258
+ const sortedB = [...b].sort();
259
+ return sortedA.every((id, index) => id === sortedB[index]);
260
+ }
261
+
262
+ /**
263
+ * Remove what the request names, in one transaction: all or nothing. The set is
264
+ * decided again inside the transaction and must be the one the person was
265
+ * shown (`expectedIds`); if it moved, nothing is removed. Each import's session
266
+ * row goes with it -- it bound nothing but the import -- and the conversation
267
+ * goes through the shared deletion.
268
+ */
269
+ export function removeImportedConversations(
270
+ db: WritableDatabase,
271
+ request: ImportRemovalRequest,
272
+ expectedIds: readonly string[],
273
+ ): ImportRemovalResult {
274
+ return withTransaction(db, () => {
275
+ const plan = planImportRemoval(db, request);
276
+ if (!plan.ok) throw new ImportRemovalRefusedError(plan.refusal);
277
+ const ids = plan.conversations.map((item) => item.id);
278
+ if (!sameIds(ids, expectedIds)) throw new ImportRemovalSetMovedError();
279
+ const removeSessionRows = db.prepare("DELETE FROM runtime_sessions WHERE conversation_id = ?");
280
+ let messages = 0;
281
+ for (const item of plan.conversations) {
282
+ removeSessionRows.run(item.id);
283
+ deleteConversation(db, item.id);
284
+ messages += item.messageCount;
285
+ }
286
+ return { conversations: plan.conversations, messages };
287
+ });
288
+ }
@@ -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,
@@ -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` };
@@ -0,0 +1,59 @@
1
+ // argparse-shaped strict parsing: every option declared, anything else refused.
2
+ //
3
+ // Moved from `dbSafetyToolsRoute.ts`, where `backup` and `repair-encoding` use
4
+ // it, when CR154 needed the same strictness for `conversations imported`. The
5
+ // reason is the same in all three: these commands write, and a lenient parser
6
+ // turns a typo into a different command. Under the `conversations` listing's
7
+ // parser, which ignores what it does not know, `remove --all --projet <folder>
8
+ // --apply` would drop the misspelled filter and remove every import in the
9
+ // home. Here the typo is refused, and nothing runs.
10
+ //
11
+ // Positionals are opt-in, for a grammar that takes them (CR154's ids). A token
12
+ // that starts with `-` is never a positional: an option nobody declared is a
13
+ // typo, not a value.
14
+
15
+ export interface StrictArgsSpec {
16
+ /** Options that stand alone. */
17
+ readonly flags: readonly string[];
18
+ /** Options that take the next token as their value, whatever it looks like. */
19
+ readonly valued: readonly string[];
20
+ /** Whether bare tokens are accepted, in order; otherwise they are unrecognized. */
21
+ readonly positionals?: boolean;
22
+ }
23
+
24
+ export interface ParsedArgs {
25
+ flags: Set<string>;
26
+ values: Map<string, string>;
27
+ positionals: string[];
28
+ /** argparse's message for the first problem, or null. */
29
+ error: string | null;
30
+ }
31
+
32
+ export function parseArgs(args: readonly string[], spec: StrictArgsSpec): ParsedArgs {
33
+ const flags = new Set<string>();
34
+ const values = new Map<string, string>();
35
+ const positionals: string[] = [];
36
+ const unrecognized: string[] = [];
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const arg = args[index] as string;
39
+ if (spec.flags.includes(arg)) {
40
+ flags.add(arg);
41
+ } else if (spec.valued.includes(arg)) {
42
+ const value = args[index + 1];
43
+ if (value === undefined)
44
+ return { flags, values, positionals, error: `argument ${arg}: expected one argument` };
45
+ values.set(arg, value);
46
+ index += 1;
47
+ } else if (spec.positionals === true && !arg.startsWith("-")) {
48
+ positionals.push(arg);
49
+ } else {
50
+ unrecognized.push(arg);
51
+ }
52
+ }
53
+ return {
54
+ flags,
55
+ values,
56
+ positionals,
57
+ error: unrecognized.length > 0 ? `unrecognized arguments: ${unrecognized.join(" ")}` : null,
58
+ };
59
+ }