mirror-mind 0.39.5 → 0.40.0

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.
@@ -1,23 +1,33 @@
1
- // Assistant-turn backfill from a Claude transcript (CV22.DS7.US10 slice E).
1
+ // Assistant-turn backfill from a Claude transcript (CV22.DS7.US10 slice E,
2
+ // rescoped by CR151).
2
3
  //
3
- // Ports `backfill_assistant_messages` from
4
+ // Started as a port of `backfill_assistant_messages` from
4
5
  // `src/memory/cli/conversation_logger.py` together with the two helpers it
5
6
  // borrows from `src/memory/cli/transcript_export.py` (`parse_jsonl`,
6
- // `_assistant_text`). `hook_session_end` calls it after ending the session --
7
- // and, when the payload carries no session id at all, INSTEAD of ending one:
8
- // that session-less route is the path US5's hook port deliberately left out.
7
+ // `_assistant_text`). `hook_session_end` calls it after ending the session.
9
8
  //
10
- // This is not an import: nothing is created or bound. It appends assistant
11
- // turns to conversations that overlap the transcript's time window and logged
12
- // no assistant message of their own, stamping each with `now` (Python's
13
- // `Message` default), not with the transcript timestamp. Python commits every
14
- // message as it goes and swallows nothing here; a malformed entry reached
15
- // mid-walk raises with the earlier appends already committed. The port keeps
16
- // that sequence so partial state matches, and leaves swallowing to the hook.
9
+ // It now DIVERGES from that Python on purpose (CR151). Python chose the
10
+ // conversations to fill by time window alone, so a session's replies were
11
+ // copied into every open, reply-less conversation overlapping its transcript
12
+ // -- a session killed mid-first-turn, or a concurrent one on another runtime --
13
+ // and stamped them with `now`. Do not restore either behavior:
14
+ //
15
+ // * the caller names the ONE conversation the transcript belongs to (the
16
+ // ending session's bound conversation); nothing else is touched, and a
17
+ // transcript no session owns fills nothing;
18
+ // * an inserted reply carries the transcript's own timestamp, normalized to
19
+ // the logger's microsecond ISO form, so the reply sorts between the prompts
20
+ // around it for every reader that orders by `created_at`.
21
+ //
22
+ // This is not an import: nothing is created or bound. A conversation that
23
+ // already logged an assistant turn of its own (the Stop hook's path) is left
24
+ // alone. Appends commit as they go; a malformed entry reached mid-walk raises
25
+ // with the earlier appends already committed, and the hook swallows it.
17
26
 
18
27
  import { readFileSync } from "node:fs";
19
28
  import type { LoggerDeps } from "#conversation/logger.ts";
20
29
  import type { WritableDatabase } from "#db/database.ts";
30
+ import { toMicrosecondIso } from "#util/pyGenerators.ts";
21
31
 
22
32
  function isRecord(value: unknown): value is Record<string, unknown> {
23
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -76,70 +86,67 @@ export function assistantText(contentBlocks: unknown): string {
76
86
  return parts.join("\n\n");
77
87
  }
78
88
 
79
- /** Python `str` truthiness on the timestamp: absent, null, or empty is skipped. */
89
+ const UTC_ISO = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/;
90
+
91
+ /**
92
+ * A timestamp in the logger's microsecond ISO-`Z` form, or null when it cannot
93
+ * be read as an instant. Claude writes milliseconds (`.123Z`), the logger
94
+ * microseconds (`.123000Z`); compared as strings the two disagree (`.123Z`
95
+ * sorts after `.123456Z`), so every bound and every insert goes through here.
96
+ * A UTC string is re-padded rather than parsed, keeping microseconds intact.
97
+ */
98
+ export function normalizeTimestamp(value: string): string | null {
99
+ const utc = UTC_ISO.exec(value);
100
+ if (utc) return `${utc[1]}.${(utc[2] ?? "").padEnd(6, "0").slice(0, 6)}Z`;
101
+ const parsed = new Date(value);
102
+ return Number.isNaN(parsed.getTime()) ? null : toMicrosecondIso(parsed);
103
+ }
104
+
105
+ /** An entry's normalized timestamp; absent, empty, non-string, or unreadable is null. */
80
106
  function timestampOf(entry: unknown): string | null {
81
107
  const value = getOr(entry, "timestamp", "");
82
- return typeof value === "string" && value ? value : null;
108
+ return typeof value === "string" && value ? normalizeTimestamp(value) : null;
83
109
  }
84
110
 
85
111
  /**
86
- * Python `backfill_assistant_messages`. The transcript window is
87
- * `[min(timestamp), max(timestamp)]` over entries that carry one; a
88
- * conversation is in range when `started_at <= window end` and it either
89
- * ended at or after the window start or is still open. Within an in-range
90
- * conversation that has no assistant turn, every `assistant` entry whose
91
- * timestamp falls inside `[started_at, ended_at or window end]` is appended.
112
+ * Append the transcript's assistant turns to `conversationId` -- the one
113
+ * conversation the transcript's session is bound to, and only it (CR151).
114
+ * Skipped when that conversation does not exist or already logged an
115
+ * assistant turn. An `assistant` entry lands when its timestamp falls inside
116
+ * the conversation's own `[started_at, ended_at]` (open-ended while the
117
+ * conversation is open), and it lands stamped with that timestamp.
92
118
  */
93
119
  export function backfillAssistantMessages(
94
120
  db: WritableDatabase,
95
121
  transcriptPath: string,
96
- deps: LoggerDeps,
122
+ conversationId: string,
123
+ deps: Pick<LoggerDeps, "newId">,
97
124
  ): void {
98
- const entries = parseJsonl(transcriptPath);
99
- if (entries.length === 0) return;
125
+ const conversation = db
126
+ .prepare("SELECT started_at, ended_at FROM conversations WHERE id = ?")
127
+ .get(conversationId) as { started_at: string; ended_at: string | null } | undefined;
128
+ if (!conversation) return;
129
+ const hasAssistant = db
130
+ .prepare("SELECT 1 FROM messages WHERE conversation_id = ? AND role = 'assistant' LIMIT 1")
131
+ .get(conversationId);
132
+ if (hasAssistant) return;
100
133
 
101
- const timestamps = entries.map(timestampOf).filter((value): value is string => value !== null);
102
- if (timestamps.length === 0) return;
103
- // Python's min()/max() on str compare by code point; ISO timestamps are
104
- // ASCII, so plain comparison is the same order.
105
- let startTime = timestamps[0] as string;
106
- let endTime = timestamps[0] as string;
107
- for (const value of timestamps) {
108
- if (value < startTime) startTime = value;
109
- if (value > endTime) endTime = value;
110
- }
111
-
112
- const conversations = db
113
- .prepare(
114
- `SELECT id, started_at, ended_at FROM conversations
115
- WHERE started_at <= ? AND (ended_at >= ? OR ended_at IS NULL)`,
116
- )
117
- .all(endTime, startTime) as { id: string; started_at: string; ended_at: string | null }[];
134
+ const start = normalizeTimestamp(conversation.started_at) ?? conversation.started_at;
135
+ const end =
136
+ conversation.ended_at === null
137
+ ? null
138
+ : (normalizeTimestamp(conversation.ended_at) ?? conversation.ended_at);
118
139
 
119
- const hasAssistant = db.prepare(
120
- "SELECT 1 FROM messages WHERE conversation_id = ? AND role = 'assistant' LIMIT 1",
121
- );
122
140
  const insert = db.prepare(
123
141
  `INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
124
142
  VALUES (?, ?, 'assistant', ?, ?, NULL, NULL)`,
125
143
  );
126
-
127
- for (const conversation of conversations) {
128
- if (hasAssistant.get(conversation.id)) continue;
129
- const conversationStart = conversation.started_at;
130
- const conversationEnd = conversation.ended_at ?? endTime;
131
-
132
- for (const entry of entries) {
133
- const timestamp = timestampOf(entry);
134
- if (!timestamp || !(conversationStart <= timestamp && timestamp <= conversationEnd)) continue;
135
- if (getFrom(entry, "type") !== "assistant") continue;
136
- const message = getOr(entry, "message", {});
137
- const text = assistantText(getOr(message, "content", []));
138
- if (text) {
139
- // Python: Message(...) draws its id before its created_at.
140
- const id = deps.newId();
141
- insert.run(id, conversation.id, text, deps.nowIso());
142
- }
143
- }
144
+ for (const entry of parseJsonl(transcriptPath)) {
145
+ const timestamp = timestampOf(entry);
146
+ if (!timestamp || timestamp < start || (end !== null && timestamp > end)) continue;
147
+ if (getFrom(entry, "type") !== "assistant") continue;
148
+ const message = getOr(entry, "message", {});
149
+ const text = assistantText(getOr(message, "content", []));
150
+ if (text) insert.run(deps.newId(), conversationId, text, timestamp);
144
151
  }
145
152
  }
@@ -87,8 +87,9 @@ export const GLOBAL_BIN_DIRS = ['"$HOME/.nvm/current/bin"', "/opt/homebrew/bin",
87
87
  * `scripts/generate_hook_wrappers.sh` and into the locator below, and held
88
88
  * equal by `mirrorNodeRule.test.ts`. It classifies exactly as
89
89
  * `path.win32.isAbsolute`, on every platform: under Git Bash the Windows
90
- * installer writes `C:\Program Files\nodejs\node.exe`, which a POSIX-only
91
- * pattern would refuse on every hook (PR #47). On POSIX a drive or backslash
90
+ * installer writes a drive-letter path,
91
+ * `C:\Users\…\AppData\Local\Programs\MirrorMind\node\node.exe`, which a
92
+ * POSIX-only pattern would refuse on every hook (PR #47). On POSIX a drive or backslash
92
93
  * value names no file, and is refused as missing.
93
94
  */
94
95
  export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
@@ -500,8 +500,10 @@ export const RETIRED: readonly RetiredSurface[] = [
500
500
  // Windows spells `C:\...` as `/C:/...` -- and read as one it made every
501
501
  // install under such a directory `unknown` (PR #42, CR140). The fix is
502
502
  // `import.meta.dirname` or `fileURLToPath`; this row keeps the idiom from
503
- // coming back in code. Tests may spell the bug to pin it, so the scope is
504
- // the product and its scripts.
503
+ // coming back in code. The tests and smokes are in scope too (issue #44):
504
+ // twenty fixture and CLI paths there read a URL's pathname, so the suite
505
+ // failed when run from a checkout under such a directory. Only the files
506
+ // that spell the bug to pin it are exempt.
505
507
  surfaceId: "url-pathname-as-path",
506
508
  story: "PR #42 (CR140)",
507
509
  absentPaths: [],
@@ -509,8 +511,11 @@ export const RETIRED: readonly RetiredSurface[] = [
509
511
  // off a variable (`url.pathname`). Prose writes `URL.pathname`, capital U,
510
512
  // which neither pattern matches, so a comment may still name the cause.
511
513
  forbiddenPatterns: ["\\)\\.pathname\\b", "\\b[a-z_$][\\w$]*\\.pathname\\b"],
512
- residueScope: ["ts/src/", "ts/scripts/"],
513
- exemptions: {},
514
+ residueScope: ["ts/src/", "ts/scripts/", "ts/test/", "ts/smoke/"],
515
+ exemptions: {
516
+ "ts/test/runtime/installKind.test.ts":
517
+ "asserts what a URL's pathname spells for a non-ASCII checkout, the bug it pins",
518
+ },
514
519
  },
515
520
  {
516
521
  surfaceId: "gemini-cli",
@@ -49,12 +49,19 @@ export type JourneyCallRejection =
49
49
  | "unknown_parent"
50
50
  | "unknown_journey"
51
51
  | "journey_changed"
52
- | "journey_not_empty";
52
+ | "journey_not_empty"
53
+ // `deleteJourneyWithRecords` (journeyDeletion.ts):
54
+ | "journey_has_children"
55
+ | "extension_rows_held"
56
+ | "backup_failed";
53
57
 
54
58
  /** A call's refusal, named with the administration's codes where it had one. Nothing was written. */
55
59
  export class JourneyCallRejectedError extends Error {
56
60
  readonly reason: JourneyCallRejection;
57
- /** For `journey_not_empty`: the association classes that hold records. */
61
+ /**
62
+ * For `journey_not_empty`: the association classes that hold records. For
63
+ * `journey_has_children`: the child Journeys. For `extension_rows_held`: the table.
64
+ */
58
65
  readonly populated: string[];
59
66
 
60
67
  constructor(reason: JourneyCallRejection, populated: string[] = []) {