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
|
@@ -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,
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Everything the `conversation-logger` subcommands need beyond the database
|
|
4
4
|
// is assembled here, once per invocation, from an EXPLICIT environment: the
|
|
5
|
-
// clock and id generator, the transcript/session resolution inputs, the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// clock and id generator, the transcript/session resolution inputs, the
|
|
6
|
+
// pre-write backup, and -- only for the subcommands that cross the LLM close
|
|
7
|
+
// tail -- the replay providers and the maintenance dependencies built on them.
|
|
8
|
+
// (No Pi sessions directory: maintenance reads no Pi session file, CR153.)
|
|
9
9
|
//
|
|
10
10
|
// The providers are loaded lazily. `diagnose-journeys`
|
|
11
11
|
// or `session-start --fast` must never fail because a replay fixture is not
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
// Nothing here reads `process.env` or `os.homedir()`; the front door passes
|
|
18
18
|
// them in, which is what keeps every test of this module hermetic.
|
|
19
19
|
|
|
20
|
-
import { backfillPiSessions, homeBornAt, resolvePiSessionsDir } from "#conversation/backfill.ts";
|
|
21
20
|
import { createCloseHooks, maybeGenerateTitle } from "#conversation/closeTail.ts";
|
|
22
21
|
import { runConversationExtraction } from "#conversation/extraction.ts";
|
|
23
22
|
import { runExtractionWithAccounting } from "#conversation/extractionRun.ts";
|
|
@@ -47,7 +46,6 @@ import {
|
|
|
47
46
|
export type LoggerRuntimeEnv = {
|
|
48
47
|
MIRROR_SESSION_ID?: string;
|
|
49
48
|
CLAUDE_PROJECT_DIR?: string;
|
|
50
|
-
PI_SESSIONS_DIR?: string;
|
|
51
49
|
MIRROR_TS_CONVERSATION_LLM_REPLAY?: string;
|
|
52
50
|
MIRROR_TS_CONVERSATION_EMBEDDING_REPLAY?: string;
|
|
53
51
|
/** Read by the live providers when the close tail runs against a real model. */
|
|
@@ -100,12 +98,11 @@ export interface LoggerRuntime {
|
|
|
100
98
|
readonly homeDir: string;
|
|
101
99
|
readonly claudeProjectDir: string | null;
|
|
102
100
|
readonly environmentSessionId: string | null;
|
|
103
|
-
readonly piSessionsDir: string;
|
|
104
101
|
/** Which transport answers the close tail here: replay, incomplete replay, or live. */
|
|
105
102
|
readonly transportMode: ProviderTransportMode;
|
|
106
103
|
/** The close hooks (extraction + finalization) behind the replay transport. */
|
|
107
104
|
closeHooks(): Promise<CloseHooks>;
|
|
108
|
-
/** The full maintenance wiring
|
|
105
|
+
/** The full maintenance wiring. */
|
|
109
106
|
maintenanceDeps(): Promise<MaintenanceDeps>;
|
|
110
107
|
/** The dated zip backup for the mutating journey repair; see `LoggerRuntimeOptions.backup`. */
|
|
111
108
|
readonly backup: ((stdout: (line: string) => void) => string | null) | null;
|
|
@@ -135,7 +132,6 @@ function liveTailOverrides(
|
|
|
135
132
|
|
|
136
133
|
export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntime {
|
|
137
134
|
const { db, env, deps } = options;
|
|
138
|
-
const piSessionsDir = resolvePiSessionsDir(null, env, options.homeDir);
|
|
139
135
|
|
|
140
136
|
// One precedence, shared with the router so the two cannot disagree about
|
|
141
137
|
// which transport is live: replay -> incomplete -> live. The pair rule that
|
|
@@ -225,7 +221,6 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
225
221
|
homeDir: options.homeDir,
|
|
226
222
|
claudeProjectDir: env.CLAUDE_PROJECT_DIR || null,
|
|
227
223
|
environmentSessionId: env.MIRROR_SESSION_ID?.trim() || null,
|
|
228
|
-
piSessionsDir,
|
|
229
224
|
transportMode: transport.mode,
|
|
230
225
|
backup: options.backup ?? null,
|
|
231
226
|
closeHooks,
|
|
@@ -246,14 +241,6 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
246
241
|
// CV21.DS4.TS2: the finalization a bounded runtime's SessionEnd deferred.
|
|
247
242
|
finalizeCloseTail: (database, conversationId) =>
|
|
248
243
|
hooks.finalizeMetadata(database, conversationId),
|
|
249
|
-
// CR121: only the sessions newer than this home; its birth is read from
|
|
250
|
-
// the ledger once per maintenance run, and an unknown birth is an error.
|
|
251
|
-
backfillPiSessions: (database) =>
|
|
252
|
-
backfillPiSessions(
|
|
253
|
-
database,
|
|
254
|
-
{ sessionsDir: piSessionsDir, notBefore: homeBornAt(database) },
|
|
255
|
-
deps,
|
|
256
|
-
),
|
|
257
244
|
extractionLimit: resolveMaintenanceMaxExtractions({ env }),
|
|
258
245
|
staleOrphanMinutes: resolveStaleOrphanMinutes({ env }),
|
|
259
246
|
monotonic: options.monotonic,
|
|
@@ -48,12 +48,6 @@ export interface MaintenanceDeps {
|
|
|
48
48
|
* only for callers written before the step existed; the front door wires it.
|
|
49
49
|
*/
|
|
50
50
|
finalizeCloseTail?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
51
|
-
/**
|
|
52
|
-
* `backfillPiSessions` (slice E) bound to its resolved sessions directory.
|
|
53
|
-
* Injected rather than resolved here so the composite never reads the
|
|
54
|
-
* environment itself; the front door owns that resolution.
|
|
55
|
-
*/
|
|
56
|
-
backfillPiSessions: (db: WritableDatabase) => Promise<number> | number;
|
|
57
51
|
/** Python's `MEMORY_MAINTENANCE_MAX_EXTRACTIONS` (AI-05); defaults to the released budget. */
|
|
58
52
|
extractionLimit?: number;
|
|
59
53
|
/** `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4); defaults to the 30-minute threshold. */
|
|
@@ -231,6 +225,11 @@ function formatElapsed(elapsed: number): string {
|
|
|
231
225
|
* warning tails. The step order is behavior, not presentation — closing stale
|
|
232
226
|
* orphans first is what makes their conversations eligible for the extraction
|
|
233
227
|
* step in the same run.
|
|
228
|
+
*
|
|
229
|
+
* CR153: there is no Pi backfill step. Python's walked Pi's session directory
|
|
230
|
+
* and imported every file this home did not track, which on a machine with two
|
|
231
|
+
* homes meant each other's sessions; a home's Pi conversations are the ones the
|
|
232
|
+
* Pi extension logged into it, and maintenance reads no Pi session file.
|
|
234
233
|
*/
|
|
235
234
|
export async function sessionMaintenance(
|
|
236
235
|
db: WritableDatabase,
|
|
@@ -246,7 +245,6 @@ export async function sessionMaintenance(
|
|
|
246
245
|
thresholdMinutes: deps.staleOrphanMinutes ?? STALE_ORPHAN_THRESHOLD_MINUTES,
|
|
247
246
|
}),
|
|
248
247
|
),
|
|
249
|
-
await timedStep("Backfilled Pi sessions", monotonic, () => deps.backfillPiSessions(db)),
|
|
250
248
|
await timedStep("Retitled pending conversations", monotonic, () =>
|
|
251
249
|
retitlePendingConversations(db, { retitleConversation: deps.retitleConversation }),
|
|
252
250
|
),
|
|
@@ -23,7 +23,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
23
23
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Python `dict.get(key, default)` on a value that MUST be a dict: anything else
|
|
28
|
+
* raises, as Python's AttributeError does. The default applies only when the
|
|
29
|
+
* key is ABSENT -- a present `null` comes back as null, as Python returns None.
|
|
30
|
+
*/
|
|
27
31
|
function getOr(container: unknown, key: string, fallback: unknown): unknown {
|
|
28
32
|
if (!isRecord(container)) throw new TypeError(`expected an object with '${key}'`);
|
|
29
33
|
return key in container ? container[key] : fallback;
|
package/ts/src/db/migrations.ts
CHANGED
|
@@ -548,13 +548,10 @@ export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
|
|
|
548
548
|
* contract.
|
|
549
549
|
*/
|
|
550
550
|
export function runMigrations(db: WritableDatabase): void {
|
|
551
|
-
// The ledger
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
// re-inserts them with a later date. Retiring a migration id moves the
|
|
556
|
-
// minimum forward at worst, which imports less; re-stamping could move it
|
|
557
|
-
// and would be a lie about the home's age.
|
|
551
|
+
// The ledger records which migrations ran, and when -- nothing more. Do not
|
|
552
|
+
// infer a home's birth from its first `applied_at`: a database copied to
|
|
553
|
+
// make a second home carries its source's ledger, so two homes would share
|
|
554
|
+
// one birth (CR153 retired the reader that did, CR156 records the copy).
|
|
558
555
|
db.exec(`
|
|
559
556
|
CREATE TABLE IF NOT EXISTS _migrations (
|
|
560
557
|
id TEXT PRIMARY KEY,
|
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 {
|
|
@@ -1759,6 +1765,22 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
|
|
|
1759
1765
|
if (argv[0] === "soul") return runSoulWrite(argv);
|
|
1760
1766
|
if (argv[0] === "explore") return runExploreWrite(argv);
|
|
1761
1767
|
if (isConversationsAppend(argv)) return runConversationsAppend(argv);
|
|
1768
|
+
// CR154: allowlisted by name, like `append`, so the listing never answers it.
|
|
1769
|
+
// The listing and the dry run read; `--apply` writes behind the pre-write
|
|
1770
|
+
// backup, after its own dated, verified archive.
|
|
1771
|
+
if (isImportedConversationsCommand(argv)) {
|
|
1772
|
+
return runImportedConversationsRoute(argv, {
|
|
1773
|
+
withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
|
|
1774
|
+
withLiveWriteDatabase: (write) => {
|
|
1775
|
+
const dbPath = resolveDbPathForCli(argv.slice(1));
|
|
1776
|
+
if (dbPath === null) return 2;
|
|
1777
|
+
return withLiveWriteDbAt(dbPath, "conversations", (db) => write(db, dbPath));
|
|
1778
|
+
},
|
|
1779
|
+
backup: (dbPath) =>
|
|
1780
|
+
createZipBackup({ dbPath, mirrorHome: dirname(dbPath), backupDir: null, silent: true }),
|
|
1781
|
+
verifyBackup: (archive) => verifyBackupArchive(archive),
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1762
1784
|
// CV22.DS7.TS1: the DB safety tools. `backup` never opens or bootstraps the
|
|
1763
1785
|
// database; `repair-encoding --apply` rides the live-write seam.
|
|
1764
1786
|
if (argv[0] === "backup") {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
scanDatabase,
|
|
28
28
|
} from "#repair/encodingRepair.ts";
|
|
29
29
|
import { expandHome } from "#util/paths.ts";
|
|
30
|
+
import { parseArgs } from "#util/strictArgs.ts";
|
|
30
31
|
import { MirrorHomeNotConfiguredError } from "./dbPath.ts";
|
|
31
32
|
|
|
32
33
|
const BACKUP_USAGE =
|
|
@@ -34,41 +35,6 @@ const BACKUP_USAGE =
|
|
|
34
35
|
const REPAIR_USAGE =
|
|
35
36
|
"usage: repair-encoding [-h] [--mirror-home MIRROR_HOME] [--apply] [--no-backup] [--limit LIMIT]";
|
|
36
37
|
|
|
37
|
-
interface ParsedArgs {
|
|
38
|
-
flags: Set<string>;
|
|
39
|
-
values: Map<string, string>;
|
|
40
|
-
error: string | null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** argparse-shaped parsing: known flags, known valued options, nothing else. */
|
|
44
|
-
function parseArgs(
|
|
45
|
-
args: readonly string[],
|
|
46
|
-
spec: { flags: readonly string[]; valued: readonly string[] },
|
|
47
|
-
): ParsedArgs {
|
|
48
|
-
const flags = new Set<string>();
|
|
49
|
-
const values = new Map<string, string>();
|
|
50
|
-
const unrecognized: string[] = [];
|
|
51
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
52
|
-
const arg = args[index] as string;
|
|
53
|
-
if (spec.flags.includes(arg)) {
|
|
54
|
-
flags.add(arg);
|
|
55
|
-
} else if (spec.valued.includes(arg)) {
|
|
56
|
-
const value = args[index + 1];
|
|
57
|
-
if (value === undefined)
|
|
58
|
-
return { flags, values, error: `argument ${arg}: expected one argument` };
|
|
59
|
-
values.set(arg, value);
|
|
60
|
-
index += 1;
|
|
61
|
-
} else {
|
|
62
|
-
unrecognized.push(arg);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return {
|
|
66
|
-
flags,
|
|
67
|
-
values,
|
|
68
|
-
error: unrecognized.length > 0 ? `unrecognized arguments: ${unrecognized.join(" ")}` : null,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
38
|
function usageError(usage: string, program: string, message: string): number {
|
|
73
39
|
process.stderr.write(`${usage}\n${program}: error: ${message}\n`);
|
|
74
40
|
return 2;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// `mirror conversations imported` (CR154): see the Pi conversations an earlier
|
|
2
|
+
// version imported rather than logged, and remove the ones a person names.
|
|
3
|
+
//
|
|
4
|
+
// conversations imported [--project TEXT]
|
|
5
|
+
// conversations imported remove (ID... | --all [--project FOLDER]) [--apply]
|
|
6
|
+
//
|
|
7
|
+
// Folders first: a home holds hundreds of imports, and the decision a person
|
|
8
|
+
// makes is about where they came from. Removal is a dry run until `--apply`,
|
|
9
|
+
// which decides the set first (refusals and an empty set stop there, with no
|
|
10
|
+
// backup), takes the dated backup and verifies it, and only then removes, in
|
|
11
|
+
// one transaction that decides the set again and refuses if it moved. Every
|
|
12
|
+
// option is declared: a typo is refused before anything opens, because under a
|
|
13
|
+
// lenient parser a misspelled `--project` would widen a removal to every import
|
|
14
|
+
// in the home.
|
|
15
|
+
//
|
|
16
|
+
// The module that decides what an import is, and removes it, is
|
|
17
|
+
// `#conversation/importedConversations.ts`; this file parses, opens, and
|
|
18
|
+
// prints. Nothing here is offered to a model: no MCP tool, no skill.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { parseArchiveStamp, RETENTION_DAYS } from "#backup/zipBackup.ts";
|
|
23
|
+
import {
|
|
24
|
+
countLiveSessionsByFolder,
|
|
25
|
+
type ImportedConversation,
|
|
26
|
+
ImportRemovalRefusedError,
|
|
27
|
+
type ImportRemovalRequest,
|
|
28
|
+
ImportRemovalSetMovedError,
|
|
29
|
+
listImportedPiConversations,
|
|
30
|
+
planImportRemoval,
|
|
31
|
+
removeImportedConversations,
|
|
32
|
+
summarizeImports,
|
|
33
|
+
} from "#conversation/importedConversations.ts";
|
|
34
|
+
import type { Database, WritableDatabase } from "#db/database.ts";
|
|
35
|
+
import { PROGRAM } from "#util/program.ts";
|
|
36
|
+
import { shellWord } from "#util/shellWord.ts";
|
|
37
|
+
import { parseArgs } from "#util/strictArgs.ts";
|
|
38
|
+
import { backupPathFor } from "./liveBackup.ts";
|
|
39
|
+
|
|
40
|
+
const USAGE = [
|
|
41
|
+
`usage: ${PROGRAM} conversations imported [--project TEXT] [--mirror-home PATH] [--db-path PATH]`,
|
|
42
|
+
` ${PROGRAM} conversations imported remove (ID ... | --all [--project FOLDER]) [--apply] [--mirror-home PATH] [--db-path PATH]`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
/** How argparse names the program in an error line. */
|
|
46
|
+
const PROG = `${PROGRAM} conversations imported`;
|
|
47
|
+
|
|
48
|
+
const WHAT_AN_IMPORT_IS =
|
|
49
|
+
"Mirror copied these from Pi's session files instead of logging them as they happened. " +
|
|
50
|
+
"Since v0.36.1 it imports none.";
|
|
51
|
+
|
|
52
|
+
const HOME_OPTIONS = ["--mirror-home", "--db-path"] as const;
|
|
53
|
+
|
|
54
|
+
export interface ImportedConversationsIo {
|
|
55
|
+
/** The home's database, read-only: the listing and the dry run. */
|
|
56
|
+
withReadOnlyDatabase: (read: (db: Database) => number) => number;
|
|
57
|
+
/** The home's database behind the front door's pre-write backup: `--apply` only. */
|
|
58
|
+
withLiveWriteDatabase: (write: (db: WritableDatabase, dbPath: string) => number) => number;
|
|
59
|
+
/** The dated archive `repair-journeys --apply` gates on; its path, or null on failure. */
|
|
60
|
+
backup: (dbPath: string) => string | null;
|
|
61
|
+
verifyBackup: (archivePath: string) => { valid: boolean; note: string | null };
|
|
62
|
+
stdout?: (line: string) => void;
|
|
63
|
+
stderr?: (line: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isImportedConversationsCommand(argv: readonly string[]): boolean {
|
|
67
|
+
return argv[0] === "conversations" && argv[1] === "imported";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function plural(count: number, word: string): string {
|
|
71
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function day(instant: string): string {
|
|
75
|
+
return instant.slice(0, 10);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function localDay(date: Date): string {
|
|
79
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
80
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function byFolder(conversations: readonly ImportedConversation[]): [string, number][] {
|
|
84
|
+
const counts = new Map<string, number>();
|
|
85
|
+
for (const item of conversations) counts.set(item.folder, (counts.get(item.folder) ?? 0) + 1);
|
|
86
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function messagesIn(conversations: readonly ImportedConversation[]): number {
|
|
90
|
+
return conversations.reduce((sum, item) => sum + item.messageCount, 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function runImportedConversationsRoute(
|
|
94
|
+
argv: readonly string[],
|
|
95
|
+
io: ImportedConversationsIo,
|
|
96
|
+
): number {
|
|
97
|
+
const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
|
|
98
|
+
const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
99
|
+
const usageError = (message: string): number => {
|
|
100
|
+
err(USAGE);
|
|
101
|
+
err(`${PROG}: error: ${message}`);
|
|
102
|
+
return 2;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const rest = argv.slice(2);
|
|
106
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
107
|
+
out(USAGE);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
const removing = rest[0] === "remove";
|
|
111
|
+
const parsed = parseArgs(removing ? rest.slice(1) : rest, {
|
|
112
|
+
flags: removing ? ["--all", "--apply"] : [],
|
|
113
|
+
valued: ["--project", ...HOME_OPTIONS],
|
|
114
|
+
positionals: removing,
|
|
115
|
+
});
|
|
116
|
+
if (parsed.error) return usageError(parsed.error);
|
|
117
|
+
const project = parsed.values.get("--project") ?? null;
|
|
118
|
+
|
|
119
|
+
if (!removing) return list(io, out, project);
|
|
120
|
+
|
|
121
|
+
const ids = parsed.positionals;
|
|
122
|
+
const all = parsed.flags.has("--all");
|
|
123
|
+
if (ids.length > 0 && all) return usageError("give conversation ids or --all, not both");
|
|
124
|
+
if (ids.length === 0 && !all) return usageError("give conversation ids, or --all");
|
|
125
|
+
if (project !== null && !all) {
|
|
126
|
+
return usageError("--project narrows --all; give it with --all, not with ids");
|
|
127
|
+
}
|
|
128
|
+
const request: ImportRemovalRequest = all ? { all: true, folder: project } : { ids };
|
|
129
|
+
|
|
130
|
+
if (!parsed.flags.has("--apply")) {
|
|
131
|
+
// The command that applies this dry run, to the same home, every value quoted.
|
|
132
|
+
const words = ["conversations", "imported", "remove"];
|
|
133
|
+
if (all) {
|
|
134
|
+
words.push("--all");
|
|
135
|
+
if (project !== null) words.push("--project", project);
|
|
136
|
+
} else {
|
|
137
|
+
words.push(...ids);
|
|
138
|
+
}
|
|
139
|
+
for (const option of HOME_OPTIONS) {
|
|
140
|
+
const value = parsed.values.get(option);
|
|
141
|
+
if (value !== undefined) words.push(option, value);
|
|
142
|
+
}
|
|
143
|
+
words.push("--apply");
|
|
144
|
+
const applyCommand = [PROGRAM, ...words.map(shellWord)].join(" ");
|
|
145
|
+
return dryRun(io, out, err, request, applyCommand);
|
|
146
|
+
}
|
|
147
|
+
return apply(io, out, err, request);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function list(
|
|
151
|
+
io: ImportedConversationsIo,
|
|
152
|
+
out: (line: string) => void,
|
|
153
|
+
project: string | null,
|
|
154
|
+
): number {
|
|
155
|
+
return io.withReadOnlyDatabase((db) => {
|
|
156
|
+
const imports = listImportedPiConversations(db);
|
|
157
|
+
if (imports.length === 0) {
|
|
158
|
+
out("No imported Pi conversations in this home.");
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
const needle = project?.toLowerCase() ?? null;
|
|
162
|
+
const shown =
|
|
163
|
+
needle === null
|
|
164
|
+
? imports
|
|
165
|
+
: imports.filter((item) => item.folder.toLowerCase().includes(needle));
|
|
166
|
+
if (shown.length === 0) {
|
|
167
|
+
out(`No imported Pi conversations from a folder matching '${project}'.`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
const summary = summarizeImports(shown, countLiveSessionsByFolder(db));
|
|
171
|
+
const scope = project === null ? "" : `, from folders matching '${project}'`;
|
|
172
|
+
out(
|
|
173
|
+
`Imported Pi conversations: ${summary.conversations} (${plural(summary.messages, "message")}), ` +
|
|
174
|
+
`imported ${day(summary.firstImportedAt ?? "")} to ${day(summary.lastImportedAt ?? "")}${scope}.`,
|
|
175
|
+
);
|
|
176
|
+
out(WHAT_AN_IMPORT_IS);
|
|
177
|
+
out("");
|
|
178
|
+
out(" imported logged live here folder");
|
|
179
|
+
for (const folder of summary.folders) {
|
|
180
|
+
const live = folder.loggedLive === null ? "-" : String(folder.loggedLive);
|
|
181
|
+
out(`${String(folder.imported).padStart(10)}${live.padStart(18)} ${folder.folder}`);
|
|
182
|
+
}
|
|
183
|
+
if (project !== null) {
|
|
184
|
+
out("");
|
|
185
|
+
for (const item of shown) {
|
|
186
|
+
out(
|
|
187
|
+
` ${day(item.importedAt)} ${item.id} ${plural(item.messageCount, "msg")} ${item.folder} ${item.title ?? "(untitled)"}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
out("");
|
|
192
|
+
out(`To see a folder's conversations: ${PROGRAM} conversations imported --project <text>`);
|
|
193
|
+
out(
|
|
194
|
+
`To remove a folder's imports: ${PROGRAM} conversations imported remove --all --project <folder>`,
|
|
195
|
+
);
|
|
196
|
+
out(" (it shows what it would remove; add --apply to remove)");
|
|
197
|
+
return 0;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function dryRun(
|
|
202
|
+
io: ImportedConversationsIo,
|
|
203
|
+
out: (line: string) => void,
|
|
204
|
+
err: (line: string) => void,
|
|
205
|
+
request: ImportRemovalRequest,
|
|
206
|
+
applyCommand: string,
|
|
207
|
+
): number {
|
|
208
|
+
return io.withReadOnlyDatabase((db) => {
|
|
209
|
+
const plan = planImportRemoval(db, request);
|
|
210
|
+
if (!plan.ok) {
|
|
211
|
+
err(`${plan.refusal}. Nothing was removed.`);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
if (plan.conversations.length === 0) {
|
|
215
|
+
out("Nothing to remove.");
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
out(
|
|
219
|
+
`Would remove ${plural(plan.conversations.length, "imported Pi conversation")} ` +
|
|
220
|
+
`(${plural(messagesIn(plan.conversations), "message")}):`,
|
|
221
|
+
);
|
|
222
|
+
for (const [folder, count] of byFolder(plan.conversations)) {
|
|
223
|
+
out(`${String(count).padStart(10)} ${folder}`);
|
|
224
|
+
}
|
|
225
|
+
out("Nothing was removed. To remove them, run:");
|
|
226
|
+
out(` ${applyCommand}`);
|
|
227
|
+
return 0;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function apply(
|
|
232
|
+
io: ImportedConversationsIo,
|
|
233
|
+
out: (line: string) => void,
|
|
234
|
+
err: (line: string) => void,
|
|
235
|
+
request: ImportRemovalRequest,
|
|
236
|
+
): number {
|
|
237
|
+
// Decide first, read-only: a refusal or an empty set stops here, and takes no
|
|
238
|
+
// backup. A holder rather than a variable: it is assigned inside a callback.
|
|
239
|
+
const decided: { conversations: ImportedConversation[] | null } = { conversations: null };
|
|
240
|
+
const decidedCode = io.withReadOnlyDatabase((db) => {
|
|
241
|
+
const plan = planImportRemoval(db, request);
|
|
242
|
+
if (!plan.ok) {
|
|
243
|
+
err(`${plan.refusal}. Nothing was removed.`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
if (plan.conversations.length === 0) {
|
|
247
|
+
out("Nothing to remove.");
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
decided.conversations = plan.conversations;
|
|
251
|
+
return 0;
|
|
252
|
+
});
|
|
253
|
+
const expected = decided.conversations;
|
|
254
|
+
if (decidedCode !== 0 || expected === null) return decidedCode;
|
|
255
|
+
|
|
256
|
+
return io.withLiveWriteDatabase((db, dbPath) => {
|
|
257
|
+
let archive: string | null;
|
|
258
|
+
try {
|
|
259
|
+
archive = io.backup(dbPath);
|
|
260
|
+
} catch {
|
|
261
|
+
archive = null;
|
|
262
|
+
}
|
|
263
|
+
if (archive === null) {
|
|
264
|
+
err("The backup failed, so nothing was removed.");
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
const verification = io.verifyBackup(archive);
|
|
268
|
+
if (!verification.valid) {
|
|
269
|
+
err(
|
|
270
|
+
`The backup ${archive} did not verify (${verification.note ?? "no reason given"}), so nothing was removed.`,
|
|
271
|
+
);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
let removed: { conversations: ImportedConversation[]; messages: number };
|
|
275
|
+
try {
|
|
276
|
+
removed = removeImportedConversations(
|
|
277
|
+
db,
|
|
278
|
+
request,
|
|
279
|
+
expected.map((item) => item.id),
|
|
280
|
+
);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error instanceof ImportRemovalSetMovedError) {
|
|
283
|
+
err(
|
|
284
|
+
"The imports changed while the backup was taken, so nothing was removed. Run it again.",
|
|
285
|
+
);
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
if (error instanceof ImportRemovalRefusedError) {
|
|
289
|
+
err(`${error.message}. Nothing was removed.`);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
out(`Backup: ${archive} (verified).`);
|
|
295
|
+
out(
|
|
296
|
+
`Removed ${plural(removed.conversations.length, "imported Pi conversation")} ` +
|
|
297
|
+
`(${plural(removed.messages, "message")}) from ${plural(byFolder(removed.conversations).length, "folder")}.`,
|
|
298
|
+
);
|
|
299
|
+
out("Memories and provider-call records that named them were kept, unlinked.");
|
|
300
|
+
for (const line of backupsHoldingCopies(dbPath, removed.conversations)) out(line);
|
|
301
|
+
return 0;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Where copies of what was just removed still live: the dated archives made
|
|
307
|
+
* since the earliest removed import, and the pre-write backup. Named so a person
|
|
308
|
+
* can delete them; nothing here deletes them.
|
|
309
|
+
*/
|
|
310
|
+
function backupsHoldingCopies(dbPath: string, removed: readonly ImportedConversation[]): string[] {
|
|
311
|
+
// An archive made before the earliest removed import cannot hold any of them.
|
|
312
|
+
const since = Math.min(...removed.map((item) => Date.parse(item.importedAt)));
|
|
313
|
+
const backupsDir = join(dirname(dbPath), "backups");
|
|
314
|
+
const archives = existsSync(backupsDir)
|
|
315
|
+
? readdirSync(backupsDir)
|
|
316
|
+
.map((name) => ({ name, made: parseArchiveStamp(name) }))
|
|
317
|
+
.filter((entry): entry is { name: string; made: Date } => entry.made !== null)
|
|
318
|
+
.filter((entry) => entry.made.getTime() >= since)
|
|
319
|
+
.sort((a, b) => a.made.getTime() - b.made.getTime())
|
|
320
|
+
: [];
|
|
321
|
+
const preWrite = backupPathFor(dbPath);
|
|
322
|
+
const lines: string[] = [];
|
|
323
|
+
if (archives.length > 0) {
|
|
324
|
+
const oldest = localDay((archives[0] as { made: Date }).made);
|
|
325
|
+
const newest = localDay((archives[archives.length - 1] as { made: Date }).made);
|
|
326
|
+
const made = oldest === newest ? oldest : `${oldest} to ${newest}`;
|
|
327
|
+
lines.push(
|
|
328
|
+
` ${plural(archives.length, "dated archive")} in ${backupsDir}, made ${made} ` +
|
|
329
|
+
`(archives are removed after ${RETENTION_DAYS} days);`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (existsSync(preWrite)) {
|
|
333
|
+
lines.push(` the pre-write backup ${preWrite} (replaced by the next write).`);
|
|
334
|
+
}
|
|
335
|
+
if (lines.length === 0) return ["No backup in this home holds a copy."];
|
|
336
|
+
return ["Copies remain in this home's backups until you delete them or they age out:", ...lines];
|
|
337
|
+
}
|
|
@@ -579,6 +579,18 @@ export function routeByFamily(
|
|
|
579
579
|
reason: "DS7.US10 conversations append boundary ported to TS",
|
|
580
580
|
};
|
|
581
581
|
}
|
|
582
|
+
// CR154: allowlisted by name for the same reason, before it shipped. Its
|
|
583
|
+
// apply is named apart, so the front-door log tells a removal from a look.
|
|
584
|
+
if (argv[1] === "imported") {
|
|
585
|
+
const applying = argv[2] === "remove" && argv.includes("--apply");
|
|
586
|
+
return {
|
|
587
|
+
command,
|
|
588
|
+
engine: "ts",
|
|
589
|
+
reason: applying
|
|
590
|
+
? "CR154 conversations imported remove --apply"
|
|
591
|
+
: "CR154 conversations imported",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
582
594
|
const writeFlag = TS4_LIFECYCLE_WRITE_FLAGS.find((flag) => argv.includes(flag));
|
|
583
595
|
if (writeFlag) {
|
|
584
596
|
return { command, engine: "ts", reason: `DS7.TS4 ${writeFlag} ported to TS` };
|