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
|
@@ -280,9 +280,9 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
280
280
|
absentPaths: [],
|
|
281
281
|
forbiddenPatterns: [
|
|
282
282
|
// The flags and the two service methods. NOT the bare word `backfill`:
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
283
|
+
// the transcript backfill (`ts/src/conversation/transcriptBackfill.ts`)
|
|
284
|
+
// is a different feature, ported and staying. (The Pi session backfill
|
|
285
|
+
// that stood beside it is retired by its own row, `pi-session-backfill`.)
|
|
286
286
|
"--metadata-backfill-",
|
|
287
287
|
"metadata_backfill",
|
|
288
288
|
"preview_metadata_backfill",
|
|
@@ -492,8 +492,6 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
492
492
|
exemptions: {
|
|
493
493
|
"docs/getting-started.md":
|
|
494
494
|
"the un-wiring guidance: a user who started codex through the retired wrapper needs its name to undo that wiring",
|
|
495
|
-
"ts/src/conversation/backfill.ts":
|
|
496
|
-
"the header names the retired importer to say where it went, beside the Pi importer that stays",
|
|
497
495
|
},
|
|
498
496
|
},
|
|
499
497
|
{
|
|
@@ -545,6 +543,45 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
545
543
|
],
|
|
546
544
|
exemptions: {},
|
|
547
545
|
},
|
|
546
|
+
{
|
|
547
|
+
surfaceId: "pi-session-backfill",
|
|
548
|
+
story: "CR153",
|
|
549
|
+
// Session maintenance's Pi backfill: a walk of Pi's session directory that
|
|
550
|
+
// imported every file this home's `runtime_sessions` did not track. That
|
|
551
|
+
// held while a machine had one home. With two, each imported the other's
|
|
552
|
+
// sessions -- 543 conversations on the maintainer's machine, both ways,
|
|
553
|
+
// each titled again through the provider -- and both imported the sessions
|
|
554
|
+
// of scratch homes and of Pi run without Mirror, while the gap it existed
|
|
555
|
+
// to fill, a session the home's own extension never logged, came up once
|
|
556
|
+
// in three months. A home's Pi conversations are the ones the Pi extension
|
|
557
|
+
// logged into it, and nothing reads a Pi session file. `importClosedConversation`
|
|
558
|
+
// went with it: the Codex importer that also wrote through it retired with
|
|
559
|
+
// DS4.TS5. `PI_SESSIONS_DIR` is inert, and only the records that say so,
|
|
560
|
+
// and the test that proves it, may name it.
|
|
561
|
+
absentPaths: [
|
|
562
|
+
"ts/src/conversation/backfill.ts",
|
|
563
|
+
"ts/src/conversation/sessionImport.ts",
|
|
564
|
+
"ts/test/conversation/backfill.test.ts",
|
|
565
|
+
"ts/test/db/homeBornAt.test.ts",
|
|
566
|
+
"ts/test/fixtures/backfill/pi-sessions/",
|
|
567
|
+
],
|
|
568
|
+
forbiddenPatterns: [
|
|
569
|
+
"backfillPiSessions",
|
|
570
|
+
"resolvePiSessionsDir",
|
|
571
|
+
"homeBornAt",
|
|
572
|
+
"importClosedConversation",
|
|
573
|
+
"PI_SESSIONS_DIR",
|
|
574
|
+
"Backfilled Pi sessions",
|
|
575
|
+
],
|
|
576
|
+
exemptions: {
|
|
577
|
+
"docs/reference/configuration.md":
|
|
578
|
+
"the inert-values record: a `.env` may still carry PI_SESSIONS_DIR, and the reference says nothing reads it",
|
|
579
|
+
".env.example.advanced":
|
|
580
|
+
"marks PI_SESSIONS_DIR inert, so a leftover line is recognized rather than trusted",
|
|
581
|
+
"ts/test/conversation/loggerCli.test.ts":
|
|
582
|
+
"sets PI_SESSIONS_DIR to a directory holding a fresh session and proves maintenance imports nothing from it",
|
|
583
|
+
},
|
|
584
|
+
},
|
|
548
585
|
];
|
|
549
586
|
|
|
550
587
|
export interface SurfaceProblem {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// argparse-shaped strict parsing: every option declared, anything else refused.
|
|
2
|
+
//
|
|
3
|
+
// Moved from `dbSafetyToolsRoute.ts`, where `backup` and `repair-encoding` use
|
|
4
|
+
// it, when CR154 needed the same strictness for `conversations imported`. The
|
|
5
|
+
// reason is the same in all three: these commands write, and a lenient parser
|
|
6
|
+
// turns a typo into a different command. Under the `conversations` listing's
|
|
7
|
+
// parser, which ignores what it does not know, `remove --all --projet <folder>
|
|
8
|
+
// --apply` would drop the misspelled filter and remove every import in the
|
|
9
|
+
// home. Here the typo is refused, and nothing runs.
|
|
10
|
+
//
|
|
11
|
+
// Positionals are opt-in, for a grammar that takes them (CR154's ids). A token
|
|
12
|
+
// that starts with `-` is never a positional: an option nobody declared is a
|
|
13
|
+
// typo, not a value.
|
|
14
|
+
|
|
15
|
+
export interface StrictArgsSpec {
|
|
16
|
+
/** Options that stand alone. */
|
|
17
|
+
readonly flags: readonly string[];
|
|
18
|
+
/** Options that take the next token as their value, whatever it looks like. */
|
|
19
|
+
readonly valued: readonly string[];
|
|
20
|
+
/** Whether bare tokens are accepted, in order; otherwise they are unrecognized. */
|
|
21
|
+
readonly positionals?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ParsedArgs {
|
|
25
|
+
flags: Set<string>;
|
|
26
|
+
values: Map<string, string>;
|
|
27
|
+
positionals: string[];
|
|
28
|
+
/** argparse's message for the first problem, or null. */
|
|
29
|
+
error: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseArgs(args: readonly string[], spec: StrictArgsSpec): ParsedArgs {
|
|
33
|
+
const flags = new Set<string>();
|
|
34
|
+
const values = new Map<string, string>();
|
|
35
|
+
const positionals: string[] = [];
|
|
36
|
+
const unrecognized: string[] = [];
|
|
37
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
38
|
+
const arg = args[index] as string;
|
|
39
|
+
if (spec.flags.includes(arg)) {
|
|
40
|
+
flags.add(arg);
|
|
41
|
+
} else if (spec.valued.includes(arg)) {
|
|
42
|
+
const value = args[index + 1];
|
|
43
|
+
if (value === undefined)
|
|
44
|
+
return { flags, values, positionals, error: `argument ${arg}: expected one argument` };
|
|
45
|
+
values.set(arg, value);
|
|
46
|
+
index += 1;
|
|
47
|
+
} else if (spec.positionals === true && !arg.startsWith("-")) {
|
|
48
|
+
positionals.push(arg);
|
|
49
|
+
} else {
|
|
50
|
+
unrecognized.push(arg);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
flags,
|
|
55
|
+
values,
|
|
56
|
+
positionals,
|
|
57
|
+
error: unrecognized.length > 0 ? `unrecognized arguments: ${unrecognized.join(" ")}` : null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
// Session backfills from runtime-written JSONL (CV22.DS7.US10 slice E).
|
|
2
|
-
//
|
|
3
|
-
// Ports `backfill_pi_sessions` from `src/memory/cli/conversation_logger.py`:
|
|
4
|
-
// it imports closed sessions the live hooks never saw, through the atomic
|
|
5
|
-
// seam in `sessionImport.ts`, and is graded against
|
|
6
|
-
// `ts/test/goldens/backfill.golden.json`. (The Codex importer,
|
|
7
|
-
// `backfill_codex_session`, was ported beside it and retired with the
|
|
8
|
-
// v0.32.1 Codex wrapper by CV21.DS4.TS5: its only caller was the wrapper's
|
|
9
|
-
// session-end hook, and the plugin logs Codex turns live.)
|
|
10
|
-
//
|
|
11
|
-
// Pi titles through `set_provisional_title` (whitespace collapse plus
|
|
12
|
-
// `title_source`/`title_status` provenance in metadata), tolerates a corrupt
|
|
13
|
-
// or structurally invalid file by skipping THAT file and continuing the
|
|
14
|
-
// walk, and parses every timestamp form (millisecond epoch, string, absent).
|
|
15
|
-
//
|
|
16
|
-
// Nothing here touches a provider or the clock directly: `now` is injected
|
|
17
|
-
// through `LoggerDeps`, which is what makes "no timestamp -> now" pinnable.
|
|
18
|
-
//
|
|
19
|
-
// CR121 (2026-10-11): a home imports only the Pi sessions newer than itself.
|
|
20
|
-
// Python imported every untracked session on the machine, which for a new
|
|
21
|
-
// home meant the person's whole Pi history, copied and spent on before the
|
|
22
|
-
// first prompt. The floor is explicit (`notBefore`), never defaulted, and
|
|
23
|
-
// the rule fails closed: there is no value that means "everything" except
|
|
24
|
-
// the epoch, which says so.
|
|
25
|
-
|
|
26
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
27
|
-
import { join, relative, sep } from "node:path";
|
|
28
|
-
import { type LoggerDeps, setProvisionalTitle } from "#conversation/logger.ts";
|
|
29
|
-
import { type ImportedMessage, importClosedConversation } from "#conversation/sessionImport.ts";
|
|
30
|
-
import type { WritableDatabase } from "#db/database.ts";
|
|
31
|
-
import { expandHome } from "#util/paths.ts";
|
|
32
|
-
import { comparePathComponents, sliceCodePoints } from "#util/pythonText.ts";
|
|
33
|
-
|
|
34
|
-
/** Python `_DEFAULT_PI_SESSIONS_DIR`: `~/.pi/agent/sessions`. */
|
|
35
|
-
export function defaultPiSessionsDir(homeDir: string): string {
|
|
36
|
-
return join(homeDir, ".pi", "agent", "sessions");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Python's source-directory resolution for the Pi backfill, in order:
|
|
41
|
-
* explicit argument -> `PI_SESSIONS_DIR` -> `~/.pi/agent/sessions`.
|
|
42
|
-
* (Python's module-level `_PI_SESSIONS_DIR` override exists only for its own
|
|
43
|
-
* test monkey-patching and has no runtime input; it is not a contract.)
|
|
44
|
-
*/
|
|
45
|
-
export function resolvePiSessionsDir(
|
|
46
|
-
explicit: string | null | undefined,
|
|
47
|
-
env: { PI_SESSIONS_DIR?: string },
|
|
48
|
-
homeDir: string,
|
|
49
|
-
): string {
|
|
50
|
-
if (explicit) return expandHome(explicit);
|
|
51
|
-
if (env.PI_SESSIONS_DIR) return expandHome(env.PI_SESSIONS_DIR);
|
|
52
|
-
return defaultPiSessionsDir(homeDir);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Python `dict.get(key, default)` on a value that MUST be a dict: anything
|
|
61
|
-
* else raises AttributeError, which each parser turns into its own failure
|
|
62
|
-
* mode. The default applies only when the key is ABSENT -- a present `null`
|
|
63
|
-
* is returned as null, exactly as Python returns None, so a later `.get` on
|
|
64
|
-
* it raises rather than silently reading from the default.
|
|
65
|
-
*/
|
|
66
|
-
function getOr(container: unknown, key: string, fallback: unknown): unknown {
|
|
67
|
-
if (!isRecord(container)) throw new TypeError(`expected an object with '${key}'`);
|
|
68
|
-
return key in container ? container[key] : fallback;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function getFrom(container: unknown, key: string): unknown {
|
|
72
|
-
return getOr(container, key, undefined);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Python `_parse_pi_content`: a string as-is, text blocks joined by `\n`, else `""`. */
|
|
76
|
-
export function parsePiContent(content: unknown): string {
|
|
77
|
-
if (typeof content === "string") return content;
|
|
78
|
-
if (Array.isArray(content)) {
|
|
79
|
-
const parts: string[] = [];
|
|
80
|
-
for (const block of content) {
|
|
81
|
-
if (!isRecord(block) || block.type !== "text") continue;
|
|
82
|
-
const text = block.text ?? "";
|
|
83
|
-
// `"\n".join(...)` raises on a non-str item; the file is skipped.
|
|
84
|
-
if (typeof text !== "string") throw new TypeError("text block is not a string");
|
|
85
|
-
parts.push(text);
|
|
86
|
-
}
|
|
87
|
-
return parts.join("\n");
|
|
88
|
-
}
|
|
89
|
-
return "";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Python `datetime.fromtimestamp(ms / 1000, tz=utc).isoformat()`:
|
|
94
|
-
* `+00:00` suffix (not `Z`), microseconds only when non-zero, rounded from
|
|
95
|
-
* the fractional second the way CPython does (`round(frac * 1e6)`).
|
|
96
|
-
*/
|
|
97
|
-
function pythonUtcIsoFromEpochMs(epochMs: number): string {
|
|
98
|
-
const seconds = epochMs / 1000;
|
|
99
|
-
let whole = Math.floor(seconds);
|
|
100
|
-
let micros = Math.round((seconds - whole) * 1e6);
|
|
101
|
-
if (micros >= 1_000_000) {
|
|
102
|
-
whole += 1;
|
|
103
|
-
micros -= 1_000_000;
|
|
104
|
-
}
|
|
105
|
-
const base = new Date(whole * 1000).toISOString().slice(0, 19);
|
|
106
|
-
const fraction = micros === 0 ? "" : `.${String(micros).padStart(6, "0")}`;
|
|
107
|
-
return `${base}${fraction}+00:00`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Python `_parse_pi_timestamp`: a number above 1e10 is a millisecond epoch;
|
|
112
|
-
* a string is stored verbatim; anything else (absent, null, a seconds epoch,
|
|
113
|
-
* a boolean -- which is an int in Python but never exceeds 1e10) is `now`.
|
|
114
|
-
*/
|
|
115
|
-
export function parsePiTimestamp(value: unknown, now: () => string): string {
|
|
116
|
-
if (typeof value === "number" && value > 1e10) return pythonUtcIsoFromEpochMs(value);
|
|
117
|
-
if (typeof value === "string") return value;
|
|
118
|
-
return now();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Parse one Pi session file into importable messages. Throws on any line
|
|
123
|
-
* that is not valid JSON or not the expected shape -- Python wraps the whole
|
|
124
|
-
* file loop in one `try` and skips the file on the first failure, even after
|
|
125
|
-
* earlier lines parsed cleanly.
|
|
126
|
-
*/
|
|
127
|
-
function parsePiSessionFile(path: string, now: () => string): ImportedMessage[] {
|
|
128
|
-
const messages: ImportedMessage[] = [];
|
|
129
|
-
for (const line of readFileSync(path, "utf8").split(/\r?\n|\r/)) {
|
|
130
|
-
if (!line.trim()) continue;
|
|
131
|
-
const entry: unknown = JSON.parse(line);
|
|
132
|
-
if (getFrom(entry, "type") !== "message") continue;
|
|
133
|
-
const message = getOr(entry, "message", {});
|
|
134
|
-
const role = getOr(message, "role", "");
|
|
135
|
-
if (role !== "user" && role !== "assistant") continue;
|
|
136
|
-
const content = parsePiContent(getOr(message, "content", ""));
|
|
137
|
-
if (!content.trim()) continue;
|
|
138
|
-
const createdAt = parsePiTimestamp(getFrom(message, "timestamp"), now);
|
|
139
|
-
messages.push({ role, content, createdAt });
|
|
140
|
-
}
|
|
141
|
-
return messages;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Python `sorted(pi_sessions_dir.rglob("*.jsonl"))`: every `.jsonl` file
|
|
146
|
-
* under the directory, ordered by path components (see
|
|
147
|
-
* `comparePathComponents` for why that is not a string sort).
|
|
148
|
-
*/
|
|
149
|
-
function listPiSessionFiles(sessionsDir: string): string[] {
|
|
150
|
-
const entries = readdirSync(sessionsDir, { recursive: true, withFileTypes: true });
|
|
151
|
-
const found: string[][] = [];
|
|
152
|
-
for (const entry of entries) {
|
|
153
|
-
if (!entry.name.endsWith(".jsonl")) continue;
|
|
154
|
-
const parents = relative(sessionsDir, entry.parentPath).split(sep).filter(Boolean);
|
|
155
|
-
found.push([...parents, entry.name]);
|
|
156
|
-
}
|
|
157
|
-
found.sort(comparePathComponents);
|
|
158
|
-
return found.map((components) => join(sessionsDir, ...components));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export interface PiBackfillOptions {
|
|
162
|
-
/** Already resolved through `resolvePiSessionsDir`. */
|
|
163
|
-
sessionsDir: string;
|
|
164
|
-
/**
|
|
165
|
-
* An ISO instant: a session that ended at or before it is not imported
|
|
166
|
-
* (CR121). Production passes the home's birth (`homeBornAt`); a caller
|
|
167
|
-
* that wants no floor passes the epoch and says so.
|
|
168
|
-
*/
|
|
169
|
-
notBefore: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* When this home was born: the first `applied_at` in the `_migrations`
|
|
174
|
-
* ledger. A fresh database stamps its whole ledger at its first open, and a
|
|
175
|
-
* database from any earlier era carries the ledger from its own first open,
|
|
176
|
-
* so the minimum is the birth of every home there is -- with no new table,
|
|
177
|
-
* column, or marker. Read once per maintenance run.
|
|
178
|
-
*
|
|
179
|
-
* The ledger means "which migrations ran, when"; reading its minimum as a
|
|
180
|
-
* birth is an inference, accepted because every plausible edit to the ledger
|
|
181
|
-
* moves the minimum forward and imports less, never more. `migrations.ts`
|
|
182
|
-
* names this consumer at the ledger's definition. A second consumer of
|
|
183
|
-
* "birth" is the trigger to model it as a row of its own.
|
|
184
|
-
*
|
|
185
|
-
* Fails closed: an empty ledger is an error, not a home born at the epoch.
|
|
186
|
-
*/
|
|
187
|
-
export function homeBornAt(db: WritableDatabase): string {
|
|
188
|
-
const row = db.prepare("SELECT MIN(applied_at) AS born FROM _migrations").get() as
|
|
189
|
-
| { born: string | null }
|
|
190
|
-
| undefined;
|
|
191
|
-
if (typeof row?.born !== "string" || !row.born) {
|
|
192
|
-
throw new Error("the _migrations ledger is empty: this home's birth is unknown");
|
|
193
|
-
}
|
|
194
|
-
return row.born;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** The instant of an ISO timestamp, or NaN for anything `Date.parse` rejects. */
|
|
198
|
-
function instant(iso: string): number {
|
|
199
|
-
return Date.parse(iso);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Python `backfill_pi_sessions`: import every untracked Pi session with at
|
|
204
|
-
* least two messages as a closed `pi` conversation, titled provisionally from
|
|
205
|
-
* the first user line. Returns the number imported.
|
|
206
|
-
*
|
|
207
|
-
* The session id is the session file's path, exactly as Python's
|
|
208
|
-
* `str(session_file)` -- which is also what the live Pi extension binds, so a
|
|
209
|
-
* session the hooks already track is skipped before its file is even read.
|
|
210
|
-
*
|
|
211
|
-
* CR121: a session is Mirror's only if it ended after `notBefore`. Two
|
|
212
|
-
* checks, in cost order. A file whose mtime is at or before the floor is
|
|
213
|
-
* skipped unread: its last message was written at or before its last write,
|
|
214
|
-
* so it cannot be newer. (This is what keeps a skipped session, which is
|
|
215
|
-
* never tracked, from being parsed again at every start for the rest of the
|
|
216
|
-
* home's life.) A file written after the floor is parsed and judged by its
|
|
217
|
-
* last message, the same instant it stores as `endedAt` -- a copy or a sync
|
|
218
|
-
* can touch a file long after its conversation ended. A session whose
|
|
219
|
-
* messages carry no parseable instant dates to `now` under the existing rule
|
|
220
|
-
* and is imported: undated is newer, not older.
|
|
221
|
-
*/
|
|
222
|
-
export function backfillPiSessions(
|
|
223
|
-
db: WritableDatabase,
|
|
224
|
-
options: PiBackfillOptions,
|
|
225
|
-
deps: LoggerDeps,
|
|
226
|
-
): number {
|
|
227
|
-
const floor = instant(options.notBefore);
|
|
228
|
-
if (Number.isNaN(floor)) {
|
|
229
|
-
throw new Error(`notBefore is not an instant: ${JSON.stringify(options.notBefore)}`);
|
|
230
|
-
}
|
|
231
|
-
if (!existsSync(options.sessionsDir)) return 0;
|
|
232
|
-
|
|
233
|
-
let count = 0;
|
|
234
|
-
for (const sessionFile of listPiSessionFiles(options.sessionsDir)) {
|
|
235
|
-
const sessionId = sessionFile;
|
|
236
|
-
if (db.prepare("SELECT 1 FROM runtime_sessions WHERE session_id = ?").get(sessionId)) {
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
if (statSync(sessionFile).mtimeMs <= floor) continue;
|
|
240
|
-
let messages: ImportedMessage[];
|
|
241
|
-
try {
|
|
242
|
-
messages = parsePiSessionFile(sessionFile, deps.nowIso);
|
|
243
|
-
} catch {
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (messages.length < 2) continue;
|
|
247
|
-
const endedAt = instant((messages.at(-1) as ImportedMessage).createdAt);
|
|
248
|
-
if (endedAt <= floor) continue;
|
|
249
|
-
|
|
250
|
-
const conversationId = importClosedConversation(
|
|
251
|
-
db,
|
|
252
|
-
sessionId,
|
|
253
|
-
{ interface: "pi", messages, endedAt: (messages.at(-1) as ImportedMessage).createdAt },
|
|
254
|
-
deps,
|
|
255
|
-
);
|
|
256
|
-
if (conversationId === null) {
|
|
257
|
-
// A concurrent writer (a live hook) bound this session while the import
|
|
258
|
-
// waited for the write lock -- their binding wins.
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
const firstUser = messages.find((message) => message.role === "user");
|
|
262
|
-
if (firstUser) {
|
|
263
|
-
// Python: `content.strip().split("\n")[0][:60]` -- cut BEFORE the
|
|
264
|
-
// provisional-title whitespace collapse, by code point.
|
|
265
|
-
const title = sliceCodePoints(firstUser.content.trim().split("\n")[0] ?? "", 60);
|
|
266
|
-
setProvisionalTitle(db, conversationId, title);
|
|
267
|
-
}
|
|
268
|
-
count += 1;
|
|
269
|
-
}
|
|
270
|
-
return count;
|
|
271
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// Atomic closed-session import (CV22.DS7.US10 slice E).
|
|
2
|
-
//
|
|
3
|
-
// Ports `RuntimeSessionService.import_closed_conversation` from
|
|
4
|
-
// `src/memory/services/runtime_session.py`, the seam both backfills write
|
|
5
|
-
// through. It is the sibling of `getOrCreateSessionConversation` in
|
|
6
|
-
// `logger.ts` and shares its transaction discipline for the same reason:
|
|
7
|
-
// a backfill imports against the SAME `runtime_sessions` key a live hook
|
|
8
|
-
// binds (Pi's live session id is the session file path), and the two can
|
|
9
|
-
// interleave.
|
|
10
|
-
//
|
|
11
|
-
// Resolved decision 4B (plan.md): the binding check runs INSIDE the
|
|
12
|
-
// `BEGIN IMMEDIATE` transaction, so a binding a concurrent hook committed
|
|
13
|
-
// while this import waited for the write lock is seen and honored -- the
|
|
14
|
-
// import returns null instead of clobbering a live session with
|
|
15
|
-
// `active=0`/`closed_at`. The same atomicity removes the crash-window
|
|
16
|
-
// duplicate-import path: either the conversation, every message, and the
|
|
17
|
-
// binding all land, or nothing does.
|
|
18
|
-
//
|
|
19
|
-
// Titles are deliberately NOT set here. The two callers title differently
|
|
20
|
-
// (Pi through `setProvisionalTitle`, Codex through a bare column write) and
|
|
21
|
-
// Python applies both after commit; reproducing that asymmetry is the
|
|
22
|
-
// callers' job.
|
|
23
|
-
|
|
24
|
-
import type { LoggerDeps } from "#conversation/logger.ts";
|
|
25
|
-
import { type WritableDatabase, withTransaction } from "#db/database.ts";
|
|
26
|
-
|
|
27
|
-
export interface ImportedMessage {
|
|
28
|
-
role: string;
|
|
29
|
-
content: string;
|
|
30
|
-
createdAt: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ClosedSessionImport {
|
|
34
|
-
interface: string;
|
|
35
|
-
messages: readonly ImportedMessage[];
|
|
36
|
-
endedAt: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Import a closed session transcript as one conversation bound to
|
|
41
|
-
* `sessionId`. Returns the new conversation id, or null when the session is
|
|
42
|
-
* already tracked -- including by a writer that won the binding while this
|
|
43
|
-
* call waited for the lock.
|
|
44
|
-
*/
|
|
45
|
-
export function importClosedConversation(
|
|
46
|
-
db: WritableDatabase,
|
|
47
|
-
sessionId: string,
|
|
48
|
-
session: ClosedSessionImport,
|
|
49
|
-
deps: LoggerDeps,
|
|
50
|
-
): string | null {
|
|
51
|
-
return withTransaction(db, () => {
|
|
52
|
-
const tracked = db
|
|
53
|
-
.prepare("SELECT 1 FROM runtime_sessions WHERE session_id = ?")
|
|
54
|
-
.get(sessionId);
|
|
55
|
-
if (tracked) return null;
|
|
56
|
-
|
|
57
|
-
// Python's Conversation(...) draws its id before its started_at; keep the
|
|
58
|
-
// same generator order so injected sequences line up with the oracle.
|
|
59
|
-
const conversationId = deps.newId();
|
|
60
|
-
const startedAt = deps.nowIso();
|
|
61
|
-
db.prepare(
|
|
62
|
-
`INSERT INTO conversations
|
|
63
|
-
(id, title, started_at, ended_at, interface, persona, journey, summary, tags, metadata)
|
|
64
|
-
VALUES (?, NULL, ?, ?, ?, NULL, NULL, NULL, NULL, NULL)`,
|
|
65
|
-
).run(conversationId, startedAt, session.endedAt, session.interface);
|
|
66
|
-
|
|
67
|
-
const insertMessage = db.prepare(
|
|
68
|
-
`INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
|
|
69
|
-
VALUES (?, ?, ?, ?, ?, NULL, NULL)`,
|
|
70
|
-
);
|
|
71
|
-
for (const message of session.messages) {
|
|
72
|
-
insertMessage.run(
|
|
73
|
-
deps.newId(),
|
|
74
|
-
conversationId,
|
|
75
|
-
message.role,
|
|
76
|
-
message.content,
|
|
77
|
-
message.createdAt,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
db.prepare(
|
|
82
|
-
`INSERT INTO runtime_sessions
|
|
83
|
-
(session_id, conversation_id, interface, mirror_active, persona, journey,
|
|
84
|
-
hook_injected, active, started_at, updated_at, closed_at, metadata)
|
|
85
|
-
VALUES (?, ?, ?, 0, NULL, NULL, 0, 0, ?, ?, ?, NULL)`,
|
|
86
|
-
).run(sessionId, conversationId, session.interface, startedAt, startedAt, session.endedAt);
|
|
87
|
-
|
|
88
|
-
return conversationId;
|
|
89
|
-
});
|
|
90
|
-
}
|