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.
@@ -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
- // `conversation-logger backfill-pi-sessions` and
284
- // `ts/src/conversation/backfill.ts` are the TRANSCRIPT backfills, a
285
- // different feature that is ported and staying.
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
- }