mirror-mind 0.36.1 → 0.36.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/mirror-logger.ts +128 -26
- package/.pi/skills/mm-help/SKILL.md +4 -1
- package/README.md +1 -1
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.36.2.md +130 -0
- package/docs/releases/v0.36.3.md +142 -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/commandTurn.ts +3 -2
- 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/piSkillTurns.ts +267 -0
- package/ts/src/frontDoor/cli.ts +38 -0
- package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -2
- package/ts/src/frontDoor/dbSafetyToolsRoute.ts +1 -35
- package/ts/src/frontDoor/importedConversationsRoute.ts +337 -0
- package/ts/src/frontDoor/routing.ts +22 -0
- package/ts/src/frontDoor/skillTurnsRoute.ts +213 -0
- package/ts/src/frontDoor/stdinSource.ts +5 -0
- package/ts/src/mirror/mirrorForm.ts +4 -1
- package/ts/src/util/strictArgs.ts +59 -0
|
@@ -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 `
|
|
457
|
-
*
|
|
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,
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// The Pi user turns an earlier version stored as a skill's body, and their repair
|
|
2
|
+
// (CR160).
|
|
3
|
+
//
|
|
4
|
+
// Pi turns a prompt that opens with `/skill:<name>`, which is also what its command
|
|
5
|
+
// menu sends, into the skill's whole body before its extensions see it. Until CR160
|
|
6
|
+
// Pi's extension logged that prompt, so a skill invocation was stored as
|
|
7
|
+
// `<skill name="…" location="…">` and the body, as if the person had said it; every
|
|
8
|
+
// later reader -- the title, the embedding, extraction -- read it so.
|
|
9
|
+
//
|
|
10
|
+
// The repair applies the rule the core logs by (`commandTurn.ts`): a Mirror
|
|
11
|
+
// question keeps its question, without the block (`mirrorForm.ts`); any other skill
|
|
12
|
+
// invocation is not conversation, and neither are its arguments (a `mm-build`
|
|
13
|
+
// slug), so its row goes. A conversation the repair leaves with no user turn goes
|
|
14
|
+
// too, through the shared deletion, which keeps its memories unlinked (the
|
|
15
|
+
// Navigator's decision, A7). A conversation titled from a block, and not titled by
|
|
16
|
+
// hand, gets the title every conversation gets from its first user turn, marked
|
|
17
|
+
// provisional so the title paths can improve it. Replies stay: they are what the
|
|
18
|
+
// mirror said, and a reply to a skipped turn cannot be told from another. Summaries
|
|
19
|
+
// and their embeddings stay: they describe a session in which a skill was invoked
|
|
20
|
+
// (A6).
|
|
21
|
+
//
|
|
22
|
+
// Only Pi's whole wrapper, only in Pi conversations (A8): a person may type or
|
|
23
|
+
// paste `<skill name=` at the start of a message anywhere. One more shape counts:
|
|
24
|
+
// the wrapper the old logger cut at its size limit, whose ending only it writes.
|
|
25
|
+
|
|
26
|
+
import { isCommandTurn } from "#conversation/commandTurn.ts";
|
|
27
|
+
import { deleteConversation } from "#conversation/conversationDeletion.ts";
|
|
28
|
+
import { generateTitle, setProvisionalTitle } from "#conversation/logger.ts";
|
|
29
|
+
import { type Database, type WritableDatabase, withTransaction } from "#db/database.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pi's expansion of `/skill:<name> args`, whole: the opening tag, Pi's line naming
|
|
33
|
+
* where the skill's references resolve, the body, the closing tag, and the
|
|
34
|
+
* arguments after a blank line when there are any (Pi 1.3.0 and 1.4.0,
|
|
35
|
+
* `_expandSkillCommand`). Pi's own extension reads the same shape back
|
|
36
|
+
* (`.pi/extensions/mirror-logger.ts`); `piSkillTurns.test.ts` holds the two alike.
|
|
37
|
+
*/
|
|
38
|
+
export const PI_SKILL_WRAPPER =
|
|
39
|
+
/^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]*))?$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same block when the old logger cut it: the extension kept the first 50,000
|
|
43
|
+
* characters and added its own marker (`truncate`, `.pi/extensions/mirror-logger.ts`),
|
|
44
|
+
* so the closing tag never arrived. Pi's opening lines, then an ending only the
|
|
45
|
+
* logger writes: no person types that. Whatever followed the block was cut with it.
|
|
46
|
+
*/
|
|
47
|
+
const PI_SKILL_WRAPPER_CUT =
|
|
48
|
+
/^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*\n\[… truncated\]$/;
|
|
49
|
+
|
|
50
|
+
/** The one skill whose turn is conversation: its question is the turn. */
|
|
51
|
+
const MIRROR_SKILL = "mm-mirror";
|
|
52
|
+
|
|
53
|
+
export interface StoredSkillTurn {
|
|
54
|
+
messageId: string;
|
|
55
|
+
conversationId: string;
|
|
56
|
+
skill: string;
|
|
57
|
+
/** What followed the block, trimmed; "" when nothing did. */
|
|
58
|
+
tail: string;
|
|
59
|
+
/** The Mirror question the row keeps, or null when the row goes. */
|
|
60
|
+
question: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ConversationToDelete {
|
|
64
|
+
id: string;
|
|
65
|
+
title: string | null;
|
|
66
|
+
messageCount: number;
|
|
67
|
+
startedAt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ConversationToRetitle {
|
|
71
|
+
id: string;
|
|
72
|
+
title: string;
|
|
73
|
+
newTitle: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SkillTurnRepairPlan {
|
|
77
|
+
/** Every stored skill turn, in the order the rows were written. */
|
|
78
|
+
turns: StoredSkillTurn[];
|
|
79
|
+
/** The Mirror questions: each row keeps its question. */
|
|
80
|
+
kept: StoredSkillTurn[];
|
|
81
|
+
/** Every other skill turn: each row goes. */
|
|
82
|
+
removed: StoredSkillTurn[];
|
|
83
|
+
conversationsToDelete: ConversationToDelete[];
|
|
84
|
+
conversationsToRetitle: ConversationToRetitle[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SkillTurnRepairResult {
|
|
88
|
+
kept: number;
|
|
89
|
+
removed: number;
|
|
90
|
+
conversationsDeleted: number;
|
|
91
|
+
/** Messages that went with the deleted conversations, beyond the removed turns. */
|
|
92
|
+
messagesDeletedWithConversations: number;
|
|
93
|
+
conversationsRetitled: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The set the transaction found is not the set the person was shown. */
|
|
97
|
+
export class SkillTurnRepairSetMovedError extends Error {
|
|
98
|
+
constructor() {
|
|
99
|
+
super("the stored skill turns changed since they were listed");
|
|
100
|
+
this.name = "SkillTurnRepairSetMovedError";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The skill turn a stored row holds, or null when the row is not Pi's whole wrapper. */
|
|
105
|
+
export function readSkillTurn(content: string): { skill: string; tail: string } | null {
|
|
106
|
+
const match = PI_SKILL_WRAPPER.exec(content);
|
|
107
|
+
if (match) return { skill: match[1] as string, tail: (match[2] ?? "").trim() };
|
|
108
|
+
const cut = PI_SKILL_WRAPPER_CUT.exec(content);
|
|
109
|
+
if (cut) return { skill: cut[1] as string, tail: "" };
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isManualTitle(rawMetadata: unknown): boolean {
|
|
114
|
+
if (typeof rawMetadata !== "string") return false;
|
|
115
|
+
try {
|
|
116
|
+
const metadata = JSON.parse(rawMetadata) as Record<string, unknown>;
|
|
117
|
+
return metadata.title_status === "manual" || metadata.title_source === "manual";
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What the repair would change, decided once for the dry run and again inside the
|
|
125
|
+
* repair's transaction.
|
|
126
|
+
*/
|
|
127
|
+
export function planSkillTurnRepair(db: Database): SkillTurnRepairPlan {
|
|
128
|
+
// SQL narrows to the prefix; `readSkillTurn` decides on the whole wrapper.
|
|
129
|
+
const candidates = db
|
|
130
|
+
.prepare(
|
|
131
|
+
`SELECT m.id, m.conversation_id, m.content
|
|
132
|
+
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
|
133
|
+
WHERE c.interface = 'pi' AND m.role = 'user' AND substr(m.content, 1, 13) = '<skill name="'
|
|
134
|
+
ORDER BY m.created_at, m.rowid`,
|
|
135
|
+
)
|
|
136
|
+
.all();
|
|
137
|
+
const turns: StoredSkillTurn[] = [];
|
|
138
|
+
for (const row of candidates) {
|
|
139
|
+
const read = readSkillTurn(String(row.content));
|
|
140
|
+
if (read === null) continue;
|
|
141
|
+
const question = read.skill === MIRROR_SKILL && read.tail !== "" ? read.tail : null;
|
|
142
|
+
turns.push({
|
|
143
|
+
messageId: String(row.id),
|
|
144
|
+
conversationId: String(row.conversation_id),
|
|
145
|
+
skill: read.skill,
|
|
146
|
+
tail: read.tail,
|
|
147
|
+
question,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const kept = turns.filter((turn) => turn.question !== null);
|
|
151
|
+
const removed = turns.filter((turn) => turn.question === null);
|
|
152
|
+
const removedIds = new Set(removed.map((turn) => turn.messageId));
|
|
153
|
+
const keptQuestion = new Map(kept.map((turn) => [turn.messageId, turn.question as string]));
|
|
154
|
+
|
|
155
|
+
// A conversation the repair leaves with no user turn goes. Otherwise a block-born
|
|
156
|
+
// title is rebuilt from its first remaining user turn that is not a command: a
|
|
157
|
+
// command is not conversation (`commandTurn.ts`), so it names one only when
|
|
158
|
+
// nothing else remains.
|
|
159
|
+
const touched = [...new Set(turns.map((turn) => turn.conversationId))];
|
|
160
|
+
const userRows = db.prepare(
|
|
161
|
+
`SELECT id, content FROM messages WHERE conversation_id = ? AND role = 'user'
|
|
162
|
+
ORDER BY created_at, rowid`,
|
|
163
|
+
);
|
|
164
|
+
const remainingUserTurns = (conversationId: string, removing: ReadonlySet<string>): string[] =>
|
|
165
|
+
userRows
|
|
166
|
+
.all(conversationId)
|
|
167
|
+
.filter((row) => !removing.has(String(row.id)))
|
|
168
|
+
.map((row) => keptQuestion.get(String(row.id)) ?? String(row.content))
|
|
169
|
+
.filter((content) => content.trim() !== "");
|
|
170
|
+
const titleSource = (remaining: readonly string[]): string | null =>
|
|
171
|
+
remaining.find((content) => !isCommandTurn(content)) ?? remaining[0] ?? null;
|
|
172
|
+
const remainingFirstUser = new Map<string, string | null>();
|
|
173
|
+
for (const conversationId of touched) {
|
|
174
|
+
remainingFirstUser.set(
|
|
175
|
+
conversationId,
|
|
176
|
+
titleSource(remainingUserTurns(conversationId, removedIds)),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const conversationRow = db.prepare(
|
|
181
|
+
`SELECT id, title, started_at, metadata,
|
|
182
|
+
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count
|
|
183
|
+
FROM conversations c WHERE id = ?`,
|
|
184
|
+
);
|
|
185
|
+
const conversationsToDelete: ConversationToDelete[] = [];
|
|
186
|
+
for (const conversationId of touched) {
|
|
187
|
+
if (remainingFirstUser.get(conversationId) !== null) continue;
|
|
188
|
+
const row = conversationRow.get(conversationId);
|
|
189
|
+
if (!row) continue;
|
|
190
|
+
conversationsToDelete.push({
|
|
191
|
+
id: conversationId,
|
|
192
|
+
title: typeof row.title === "string" ? row.title : null,
|
|
193
|
+
messageCount: Number(row.message_count),
|
|
194
|
+
startedAt: String(row.started_at),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const deleting = new Set(conversationsToDelete.map((item) => item.id));
|
|
198
|
+
|
|
199
|
+
// Titles born of a block, in any Pi conversation that stays, unless set by hand.
|
|
200
|
+
const titled = db
|
|
201
|
+
.prepare(
|
|
202
|
+
`SELECT id, title, metadata FROM conversations
|
|
203
|
+
WHERE interface = 'pi' AND lower(substr(trim(title), 1, 6)) = '<skill'
|
|
204
|
+
ORDER BY started_at, id`,
|
|
205
|
+
)
|
|
206
|
+
.all();
|
|
207
|
+
const conversationsToRetitle: ConversationToRetitle[] = [];
|
|
208
|
+
for (const row of titled) {
|
|
209
|
+
const id = String(row.id);
|
|
210
|
+
if (deleting.has(id) || isManualTitle(row.metadata)) continue;
|
|
211
|
+
const source = remainingFirstUser.has(id)
|
|
212
|
+
? remainingFirstUser.get(id)
|
|
213
|
+
: titleSource(remainingUserTurns(id, new Set()));
|
|
214
|
+
if (!source || readSkillTurn(source) !== null) continue;
|
|
215
|
+
const newTitle = generateTitle(source).trim();
|
|
216
|
+
if (newTitle === "") continue;
|
|
217
|
+
conversationsToRetitle.push({ id, title: String(row.title), newTitle });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { turns, kept, removed, conversationsToDelete, conversationsToRetitle };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One string that names everything a plan changes, to compare a plan with the one shown. */
|
|
224
|
+
export function skillTurnRepairKey(plan: SkillTurnRepairPlan): string {
|
|
225
|
+
const sorted = (values: readonly string[]) => [...values].sort().join(",");
|
|
226
|
+
return [
|
|
227
|
+
sorted(plan.kept.map((turn) => turn.messageId)),
|
|
228
|
+
sorted(plan.removed.map((turn) => turn.messageId)),
|
|
229
|
+
sorted(plan.conversationsToDelete.map((item) => item.id)),
|
|
230
|
+
sorted(plan.conversationsToRetitle.map((item) => `${item.id}=${item.newTitle}`)),
|
|
231
|
+
].join("|");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Repair what the plan names, in one transaction: all or nothing. The plan is
|
|
236
|
+
* decided again inside the transaction and must be the one the person was shown
|
|
237
|
+
* (`expectedKey`); if it moved, nothing changes.
|
|
238
|
+
*/
|
|
239
|
+
export function applySkillTurnRepair(
|
|
240
|
+
db: WritableDatabase,
|
|
241
|
+
expectedKey: string,
|
|
242
|
+
): SkillTurnRepairResult {
|
|
243
|
+
return withTransaction(db, () => {
|
|
244
|
+
const plan = planSkillTurnRepair(db);
|
|
245
|
+
if (skillTurnRepairKey(plan) !== expectedKey) throw new SkillTurnRepairSetMovedError();
|
|
246
|
+
const keepQuestion = db.prepare("UPDATE messages SET content = ? WHERE id = ?");
|
|
247
|
+
for (const turn of plan.kept) keepQuestion.run(turn.question as string, turn.messageId);
|
|
248
|
+
const removeTurn = db.prepare("DELETE FROM messages WHERE id = ?");
|
|
249
|
+
for (const turn of plan.removed) removeTurn.run(turn.messageId);
|
|
250
|
+
const remainingMessages = db.prepare(
|
|
251
|
+
"SELECT COUNT(*) AS n FROM messages WHERE conversation_id = ?",
|
|
252
|
+
);
|
|
253
|
+
let messagesDeletedWithConversations = 0;
|
|
254
|
+
for (const item of plan.conversationsToDelete) {
|
|
255
|
+
messagesDeletedWithConversations += Number(remainingMessages.get(item.id)?.n ?? 0);
|
|
256
|
+
deleteConversation(db, item.id);
|
|
257
|
+
}
|
|
258
|
+
for (const item of plan.conversationsToRetitle) setProvisionalTitle(db, item.id, item.newTitle);
|
|
259
|
+
return {
|
|
260
|
+
kept: plan.kept.length,
|
|
261
|
+
removed: plan.removed.length,
|
|
262
|
+
conversationsDeleted: plan.conversationsToDelete.length,
|
|
263
|
+
messagesDeletedWithConversations,
|
|
264
|
+
conversationsRetitled: plan.conversationsToRetitle.length,
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -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 {
|
|
@@ -166,6 +172,7 @@ import { runRuntimeReadRoute, runWelcomeRoute } from "./runtimeRoute.ts";
|
|
|
166
172
|
import { runMemorySearchRoute } from "./searchRoute.ts";
|
|
167
173
|
import { resolveSeedPaths } from "./seedPaths.ts";
|
|
168
174
|
import { isSessionStartWelcome, runSessionStartWelcomeRoute } from "./sessionStartWelcomeRoute.ts";
|
|
175
|
+
import { isSkillTurnsCommand, runSkillTurnsRoute } from "./skillTurnsRoute.ts";
|
|
169
176
|
import { runSoulRoute } from "./soulRoute.ts";
|
|
170
177
|
import {
|
|
171
178
|
applyTasksImport,
|
|
@@ -1759,6 +1766,37 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
|
|
|
1759
1766
|
if (argv[0] === "soul") return runSoulWrite(argv);
|
|
1760
1767
|
if (argv[0] === "explore") return runExploreWrite(argv);
|
|
1761
1768
|
if (isConversationsAppend(argv)) return runConversationsAppend(argv);
|
|
1769
|
+
// CR154: allowlisted by name, like `append`, so the listing never answers it.
|
|
1770
|
+
// The listing and the dry run read; `--apply` writes behind the pre-write
|
|
1771
|
+
// backup, after its own dated, verified archive.
|
|
1772
|
+
if (isImportedConversationsCommand(argv)) {
|
|
1773
|
+
return runImportedConversationsRoute(argv, {
|
|
1774
|
+
withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
|
|
1775
|
+
withLiveWriteDatabase: (write) => {
|
|
1776
|
+
const dbPath = resolveDbPathForCli(argv.slice(1));
|
|
1777
|
+
if (dbPath === null) return 2;
|
|
1778
|
+
return withLiveWriteDbAt(dbPath, "conversations", (db) => write(db, dbPath));
|
|
1779
|
+
},
|
|
1780
|
+
backup: (dbPath) =>
|
|
1781
|
+
createZipBackup({ dbPath, mirrorHome: dirname(dbPath), backupDir: null, silent: true }),
|
|
1782
|
+
verifyBackup: (archive) => verifyBackupArchive(archive),
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
// CR160: the same shape -- the dry run reads; `--apply` writes behind the
|
|
1786
|
+
// pre-write backup, after its own dated, verified archive.
|
|
1787
|
+
if (isSkillTurnsCommand(argv)) {
|
|
1788
|
+
return runSkillTurnsRoute(argv, {
|
|
1789
|
+
withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
|
|
1790
|
+
withLiveWriteDatabase: (write) => {
|
|
1791
|
+
const dbPath = resolveDbPathForCli(argv.slice(1));
|
|
1792
|
+
if (dbPath === null) return 2;
|
|
1793
|
+
return withLiveWriteDbAt(dbPath, "conversations", (db) => write(db, dbPath));
|
|
1794
|
+
},
|
|
1795
|
+
backup: (dbPath) =>
|
|
1796
|
+
createZipBackup({ dbPath, mirrorHome: dirname(dbPath), backupDir: null, silent: true }),
|
|
1797
|
+
verifyBackup: (archive) => verifyBackupArchive(archive),
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1762
1800
|
// CV22.DS7.TS1: the DB safety tools. `backup` never opens or bootstraps the
|
|
1763
1801
|
// database; `repair-encoding --apply` rides the live-write seam.
|
|
1764
1802
|
if (argv[0] === "backup") {
|