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.
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.36.1.md +137 -0
- package/docs/releases/v0.36.2.md +130 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +1 -1
- package/ts/src/conversation/conversationDeletion.ts +45 -0
- package/ts/src/conversation/importedConversations.ts +288 -0
- package/ts/src/conversation/logger.ts +4 -18
- package/ts/src/conversation/loggerRuntime.ts +5 -18
- package/ts/src/conversation/sessionComposites.ts +5 -7
- package/ts/src/conversation/transcriptBackfill.ts +5 -1
- package/ts/src/db/migrations.ts +4 -7
- package/ts/src/frontDoor/cli.ts +22 -0
- package/ts/src/frontDoor/dbSafetyToolsRoute.ts +1 -35
- package/ts/src/frontDoor/importedConversationsRoute.ts +337 -0
- package/ts/src/frontDoor/routing.ts +12 -0
- package/ts/src/guards/retiredSurfaces.ts +42 -5
- package/ts/src/util/strictArgs.ts +59 -0
- package/ts/src/conversation/backfill.ts +0 -271
- package/ts/src/conversation/sessionImport.ts +0 -90
package/docs/releases/index.md
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|