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.
@@ -12,6 +12,8 @@ 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.
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.
15
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.
16
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.
17
19
  - [v0.34.0 — Codex Through the Canonical Plugin](v0.34.0.md) — minor release closing CV21.DS4: Codex runs Mirror through the canonical plugin, installed in two commands from a marketplace the package ships, measured live at L4 on Codex 0.163.0. The v0.32.1 wrapper wiring no longer ships; `runtime diagnose` and `runtime update` say when Codex's cached plugin copy is behind and name the re-add that brings it level. No migration.
@@ -0,0 +1,137 @@
1
+ ---
2
+ digest: >
3
+ v0.36.1 is a patch release of one Change Request, CR153. Session
4
+ maintenance used to read Pi's session directory and import every session a
5
+ home did not already track, so on a machine with more than one Mirror home
6
+ each home copied the other's Pi conversations and titled them again through
7
+ the provider. Maintenance now imports no Pi session: a home's Pi
8
+ conversations are the ones the Pi extension logged into it, as they
9
+ happened, and `PI_SESSIONS_DIR` is no longer read. Conversations an earlier
10
+ version imported stay where they are, and this note shows how to list them.
11
+ Update every install that has a home. No migration.
12
+ ---
13
+
14
+ # v0.36.1 — A Home Keeps Only Its Own Pi Sessions
15
+
16
+ **Date:** 2026-10-21
17
+
18
+ ## Highlights
19
+
20
+ - **Session maintenance imports no Pi session**
21
+ ([CR153](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr153-a-home-s-pi-backfill-imports-another-home-s-sessions.md)).
22
+ It used to read every file in Pi's session directory and import each one the home
23
+ did not track, every session another Mirror home on the same machine had logged
24
+ among them. A home's Pi conversations are now exactly the ones the Pi extension
25
+ logged into it, as they happened.
26
+ - **Nothing in Mirror reads Pi's session files.** Not your history from before Mirror,
27
+ not sessions you run without it, and not another home's. `PI_SESSIONS_DIR` is inert:
28
+ a `.env` that sets it keeps working, and it changes nothing.
29
+ - **What was already imported stays.** This release removes the cause, not the copies;
30
+ [Upgrading](#upgrading) shows how to list them.
31
+ - **Update every install that has a home.** A home is safe from a neighbour's
32
+ maintenance only once the install that neighbour runs carries this release.
33
+
34
+ ## Where We Started
35
+
36
+ The import is older than the idea of two homes on one machine. It existed to fill a
37
+ gap: a Pi session the extension never logged reached the home anyway, at the next
38
+ maintenance. v0.32.0 narrowed it to sessions newer than the home, so a new home would
39
+ not swallow a lifetime of Pi history. Nothing scoped it to the home.
40
+
41
+ CV21.DS5.US3's live route exposed it. A Claude Code cell ran on the real `HOME` against
42
+ a scratch home born that evening, and two seconds into its first session, maintenance
43
+ imported four of the person's Pi sessions from that day, from both of their real homes,
44
+ and titled them through the provider. The route closed its own hole the same evening;
45
+ the product's was CR153.
46
+
47
+ Measured before planning, read-only, on the maintainer's two homes, one a copy of the
48
+ other since July: each had taken the other's Pi conversations all along, 543 between
49
+ them, and both were still doing it. Titling them took 411 provider calls. No memory
50
+ came from any of them, because extraction needs a journey and an import carries none,
51
+ but every one listed as the newest conversation, since an import's start is the moment
52
+ it was imported. The gap the import existed to fill came up once in three months.
53
+
54
+ ## What Changed
55
+
56
+ **The decision.** Three ways out were weighed: a claim recorded at session start, so a
57
+ home imports only sessions it saw begin; a mark written into each Pi session file, so a
58
+ home imports only files carrying its name; or no import at all. The claim would have
59
+ kept the one recovery in three months for a table, a migration, and a claim lifecycle.
60
+ The mark would have left every home reading every other home's files at every start.
61
+ Retiring the import is the only option in which no home reads Pi's files at all, and
62
+ it gives up almost nothing
63
+ ([decision](../project/decisions.md#session-maintenance-imports-no-pi-session-a-home-holds-the-pi-conversations-it-logged-live)).
64
+
65
+ **The change.** The Pi backfill, the import seam only it still used, and v0.32.0's
66
+ birth floor are deleted, and a retired-surface guard keeps them deleted. The tests came
67
+ first: three cases — a session in Pi's default directory, one under `PI_SESSIONS_DIR`,
68
+ and the full `session-start` the SessionStart hooks run — failed on the old code, each
69
+ importing a session another home had logged, and pass now. The Navigator then ran the
70
+ published 0.36.0 beside the change on the same session: the old version took it and
71
+ the new one did not. On the real Pi history the new one took nothing, and a live Pi
72
+ session against a scratch home was logged as before.
73
+
74
+ ### What you will notice
75
+
76
+ - The maintenance report has four lines: `Closed stale conversations`, `Retitled pending
77
+ conversations`, `Extracted pending conversations`, and `Finished deferred close tails`.
78
+ - If you have run more than one home on this machine, each may hold Pi conversations
79
+ the other logged. They are not removed.
80
+ - A Pi session you run without Mirror's extension never appears in Mirror. Run Pi with
81
+ the Mirror package, and its sessions are logged as they happen.
82
+
83
+ ## Upgrading
84
+
85
+ ```bash
86
+ mirror runtime update # or: npm install -g mirror-mind
87
+ ```
88
+
89
+ Update every install that has a home: the package, and each clone. There is no
90
+ database migration, and no runtime wiring changes.
91
+
92
+ To list the Pi conversations an earlier version imported into a home, read-only:
93
+
94
+ ```bash
95
+ sqlite3 ~/.mirror-minds/<user>/memory.db \
96
+ "SELECT id, title, started_at FROM conversations
97
+ WHERE interface = 'pi' AND julianday(started_at) >= julianday(ended_at)
98
+ ORDER BY started_at"
99
+ ```
100
+
101
+ An import gives itself away: it started when it was imported, after its last message.
102
+ The home's dated backups and its pre-write backup hold the same rows.
103
+
104
+ ## Conscious Exclusions
105
+
106
+ - **Removing what was imported.** A command that lists and removes those conversations
107
+ is [CR154](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr154-a-home-cannot-list-or-remove-the-pi-conversations-it-imported.md).
108
+ - **A session whose logging never reached the core** is now simply missing from the
109
+ home, with an error line in `mirror-logger.log` and nowhere else. `runtime diagnose`
110
+ reading those lines is
111
+ [CR155](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr155-runtime-diagnose-does-not-read-the-pi-extension-s-error-lines.md).
112
+ - **A home made by copying another's database** is the same home twice — the same ids,
113
+ the same history. The docs saying so is
114
+ [CR156](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr156-a-home-made-by-copying-another-s-database-is-the-same-home-twice.md).
115
+ - **Importing a Pi session on request.** If it is wanted, it is a feature with its own
116
+ story, not something maintenance does in the background.
117
+ - **The transcript backfill** matches a session's replies by time window — the same
118
+ shape, inside one home. That is
119
+ [CR151](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr151-the-transcript-backfill-writes-a-session-s-replies-into-other-sessions-conversations.md),
120
+ its own change.
121
+
122
+ ## What We Learned
123
+
124
+ The import decided ownership from an absence: a session the home did not track was the
125
+ home's to take. That was true while a machine had one home, and it stopped being true
126
+ on the day a second home appeared, with nothing to mark the day.
127
+
128
+ The leak was also invisible to the check that should have caught it. The first look at
129
+ the real homes asked by journey, and an import has no journey. Measured by construction
130
+ instead — an import starts after its own last message — the same homes held 543. A
131
+ measurement taken before planning turned "consider pulling it first" into a fact about
132
+ the maintainer's own data.
133
+
134
+ ## Next Horizon
135
+
136
+ CR154: a home lists, and on request removes, the Pi conversations an earlier version
137
+ imported. Then CV21.DS5.TS4 and DS11, and CV21's own release.
@@ -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.0",
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.0",
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.0",
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
+ }