mirror-mind 0.35.0 → 0.36.1

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.
Files changed (38) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +3 -3
  3. package/docs/releases/index.md +2 -0
  4. package/docs/releases/v0.36.0.md +222 -0
  5. package/docs/releases/v0.36.1.md +137 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  8. package/plugins/mirror-mind/plugin.json +11 -1
  9. package/plugins/mirror-mind/rules/AGENTS.md +2 -2
  10. package/ts/src/conversation/loggerRuntime.ts +5 -18
  11. package/ts/src/conversation/runtimePolicy.ts +26 -3
  12. package/ts/src/conversation/sessionComposites.ts +5 -7
  13. package/ts/src/conversation/transcriptBackfill.ts +5 -1
  14. package/ts/src/db/migrations.ts +4 -7
  15. package/ts/src/extensions/bindings.ts +53 -2
  16. package/ts/src/extensions/catalogCommands.ts +82 -23
  17. package/ts/src/extensions/catalogWrites.ts +12 -0
  18. package/ts/src/extensions/contextRuntime.ts +36 -1
  19. package/ts/src/extensions/dispatch.ts +43 -10
  20. package/ts/src/extensions/extensionPlugin.ts +325 -0
  21. package/ts/src/extensions/manifest.ts +7 -1
  22. package/ts/src/extensions/settings.ts +147 -0
  23. package/ts/src/frontDoor/cli.ts +16 -1
  24. package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
  25. package/ts/src/frontDoor/runtimeRoute.ts +11 -0
  26. package/ts/src/guards/claudePlugin.ts +24 -7
  27. package/ts/src/guards/retiredSurfaces.ts +42 -5
  28. package/ts/src/hooks/claude.ts +6 -0
  29. package/ts/src/hooks/main.ts +12 -1
  30. package/ts/src/runtime/brokenPipe.ts +52 -0
  31. package/ts/src/runtime/diagnose.ts +122 -13
  32. package/ts/src/runtime/extensionPluginWiring.ts +222 -0
  33. package/ts/src/runtime/pluginWiring.ts +168 -29
  34. package/ts/src/runtime/status.ts +28 -1
  35. package/ts/src/runtime/update.ts +25 -11
  36. package/ts/src/runtime/wiring.ts +135 -3
  37. package/ts/src/conversation/backfill.ts +0 -271
  38. package/ts/src/conversation/sessionImport.ts +0 -90
@@ -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
- }