sessionmem 1.5.0 → 1.7.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +148 -79
  3. package/dist/cli/autostart/index.js +246 -0
  4. package/dist/cli/commands/autostart.js +24 -0
  5. package/dist/cli/commands/config.js +1 -0
  6. package/dist/cli/commands/doctor.js +28 -0
  7. package/dist/cli/commands/install.js +10 -0
  8. package/dist/cli/commands/uninstall.js +4 -0
  9. package/dist/cli/commands/watch.js +14 -2
  10. package/dist/cli/index.js +16 -7
  11. package/dist/cli/transcriptSweep.js +103 -143
  12. package/dist/cli/transcripts/claudeCode.js +62 -0
  13. package/dist/cli/transcripts/codex.js +111 -0
  14. package/dist/cli/transcripts/cursor.js +295 -0
  15. package/dist/cli/transcripts/index.js +118 -0
  16. package/dist/cli/transcripts/types.js +15 -0
  17. package/dist/cli/watchCheckpoint.js +35 -0
  18. package/dist/cli/watchLog.js +23 -0
  19. package/dist/core/api/contracts.js +3 -0
  20. package/dist/core/api/memoryCoreService.js +37 -1
  21. package/dist/core/capture/toolEvent.js +14 -2
  22. package/dist/core/config/policyConfig.js +3 -0
  23. package/dist/core/embed/localSemanticEmbedder.js +62 -0
  24. package/dist/core/retrieve/retrieveMemories.js +18 -2
  25. package/dist/core/schema/migrations/016_semantic_embeddings.sql +12 -0
  26. package/dist/core/storage/memorySearchRepo.js +11 -0
  27. package/dist/core/storage/semanticSearchRepo.js +56 -0
  28. package/dist/core/storage/sessionEventsRepo.js +12 -3
  29. package/docs/architecture.md +13 -1
  30. package/docs/benchmark.md +9 -0
  31. package/docs/local-semantic.md +56 -0
  32. package/docs/research/automatic-capture.md +62 -0
  33. package/docs/troubleshooting.md +5 -0
  34. package/package.json +21 -2
@@ -0,0 +1,111 @@
1
+ import { buildFunctionCallEvent } from "../../core/capture/toolEvent.js";
2
+ import { createdAt, object, stableId, unsupported } from "./types.js";
3
+ /** Remove known host context while preserving human text in the same chunk. */
4
+ function humanText(text) {
5
+ if (/^gAAAAA[A-Za-z0-9_=-]+\s*$/.test(text.trim()))
6
+ return "";
7
+ if (/^\s*# AGENTS\.md instructions for\b/.test(text)
8
+ || /^\s*#{1,3} (?:Skills|Plugins|Plugin catalog(?:ue)?)\s*\n+\s*(?:A skill is|The following (?:skills|plugins)|(?:#{1,3} )?Available (?:skills|plugins))/i.test(text))
9
+ return "";
10
+ return text.replace(/<(environment_context|plugin_catalogue|plugin_catalog|available_plugins|available_skills|skills_instructions)>[\s\S]*?<\/\1>/gi, "").trim();
11
+ }
12
+ export function parseCodexTranscript(_path, records) {
13
+ const result = { host: "codex", sessionId: "", events: [], malformed: false };
14
+ const occurrences = new Map();
15
+ for (const { value: record, line } of records) {
16
+ // agent_message envelopes can hold opaque Fernet strings. Never interpret them.
17
+ if (record.type === "agent_message")
18
+ continue;
19
+ const payload = object(record.payload);
20
+ if (typeof record.timestamp !== "string" || typeof record.type !== "string" || !payload) {
21
+ unsupported(result.host, `invalid rollout envelope on line ${line + 1}`);
22
+ }
23
+ if (record.type === "session_meta") {
24
+ if (typeof payload.id !== "string" || !payload.id.trim()
25
+ || typeof payload.cwd !== "string" || !payload.cwd.trim()) {
26
+ unsupported(result.host, `session_meta needs id and cwd on line ${line + 1}`);
27
+ }
28
+ if (result.sessionId && (result.sessionId !== payload.id || result.cwd !== payload.cwd)) {
29
+ unsupported(result.host, "conflicting session_meta records");
30
+ }
31
+ result.sessionId = payload.id;
32
+ result.cwd = payload.cwd;
33
+ }
34
+ else if (record.type === "response_item") {
35
+ if (typeof payload.type !== "string")
36
+ unsupported(result.host, `invalid response_item on line ${line + 1}`);
37
+ if (payload.type === "message") {
38
+ if (typeof payload.role !== "string" || !Array.isArray(payload.content)) {
39
+ unsupported(result.host, `invalid message on line ${line + 1}`);
40
+ }
41
+ if (payload.role !== "user" && payload.role !== "assistant") {
42
+ if (payload.role !== "system" && payload.role !== "developer") {
43
+ unsupported(result.host, `unrecognized message role on line ${line + 1}`);
44
+ }
45
+ continue;
46
+ }
47
+ const chunks = [];
48
+ for (const raw of payload.content) {
49
+ const chunk = object(raw);
50
+ if (!chunk || typeof chunk.type !== "string")
51
+ unsupported(result.host, `invalid content on line ${line + 1}`);
52
+ if (chunk.type !== "input_text" && chunk.type !== "output_text") {
53
+ if (!["input_image", "output_image", "image", "input_audio", "audio"].includes(chunk.type)) {
54
+ unsupported(result.host, `unrecognized content type ${chunk.type} on line ${line + 1}`);
55
+ }
56
+ continue;
57
+ }
58
+ if (typeof chunk.text !== "string")
59
+ unsupported(result.host, `invalid text on line ${line + 1}`);
60
+ const text = payload.role === "user" ? humanText(chunk.text)
61
+ : /^gAAAAA[A-Za-z0-9_=-]+\s*$/.test(chunk.text.trim()) ? "" : chunk.text.trim();
62
+ if (text)
63
+ chunks.push(text);
64
+ }
65
+ // IDs are absent in some CLI releases. Content plus occurrence preserves
66
+ // repeated turns and survives added metadata and changed replay timestamps.
67
+ const identity = stableId(payload.role, JSON.stringify(chunks));
68
+ const occurrence = occurrences.get(identity) ?? 0;
69
+ if (!chunks.length)
70
+ continue;
71
+ occurrences.set(identity, occurrence + 1);
72
+ const key = typeof payload.id === "string" ? payload.id
73
+ : typeof record.id === "string" ? record.id : `${identity}-${occurrence}`;
74
+ for (const [index, text] of chunks.entries())
75
+ result.events.push({
76
+ id: `message-${key}-${index}`, eventType: `message:${payload.role}`,
77
+ createdAt: createdAt(record.timestamp), payloadJson: JSON.stringify({ text: text.slice(0, 4000) }),
78
+ });
79
+ }
80
+ else if (payload.type === "function_call") {
81
+ if (typeof payload.name !== "string" || !payload.name.trim()
82
+ || typeof payload.call_id !== "string" || !payload.call_id.trim() || typeof payload.arguments !== "string") {
83
+ unsupported(result.host, `invalid function_call on line ${line + 1}`);
84
+ }
85
+ let input;
86
+ try {
87
+ input = object(JSON.parse(payload.arguments));
88
+ }
89
+ catch {
90
+ unsupported(result.host, `invalid function_call arguments on line ${line + 1}`);
91
+ }
92
+ if (!input)
93
+ unsupported(result.host, `function_call arguments must be an object on line ${line + 1}`);
94
+ result.events.push({
95
+ id: `tool-${payload.call_id}`,
96
+ ...buildFunctionCallEvent(payload.name, input, payload.call_id, result.cwd), createdAt: createdAt(record.timestamp),
97
+ });
98
+ }
99
+ else if (!["reasoning", "function_call_output", "custom_tool_call", "custom_tool_call_output",
100
+ "web_search_call", "local_shell_call", "compaction", "agent_message"].includes(payload.type)) {
101
+ unsupported(result.host, `unrecognized response_item type ${payload.type} on line ${line + 1}`);
102
+ }
103
+ }
104
+ else if (!["event_msg", "turn_context", "compacted"].includes(record.type)) {
105
+ unsupported(result.host, `unrecognized record type ${record.type} on line ${line + 1}`);
106
+ }
107
+ }
108
+ if (records.length && !result.sessionId)
109
+ unsupported(result.host, "missing session_meta record");
110
+ return result;
111
+ }
@@ -0,0 +1,295 @@
1
+ import BetterSqlite3 from "better-sqlite3";
2
+ import { copyFile, mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { createdAt, object, unsupported } from "./types.js";
7
+ export function cursorUserDirectory(platform = process.platform) {
8
+ if (platform === "darwin")
9
+ return join(homedir(), "Library", "Application Support", "Cursor", "User");
10
+ if (platform === "win32")
11
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Cursor", "User");
12
+ return join(homedir(), ".config", "Cursor", "User");
13
+ }
14
+ async function fileStamp(path) {
15
+ try {
16
+ const info = await stat(path, { bigint: true });
17
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
18
+ }
19
+ catch (error) {
20
+ if (error.code === "ENOENT")
21
+ return undefined;
22
+ throw error;
23
+ }
24
+ }
25
+ /** Never give SQLite a connection to the source, including its WAL/SHM files. */
26
+ async function snapshot(path, read) {
27
+ const temp = await mkdtemp(join(tmpdir(), "sessionmem-cursor-"));
28
+ try {
29
+ const target = join(temp, "state.vscdb");
30
+ // Copy the WAL too: immutable connections to a live database ignore it and
31
+ // would silently miss recently committed messages. Detect checkpoints and
32
+ // concurrent writes during the copy, then retry on a later sweep if busy.
33
+ const suffixes = ["", "-wal", "-journal"];
34
+ for (let attempt = 0; attempt < 3; attempt++) {
35
+ const before = await Promise.all(suffixes.map(suffix => fileStamp(path + suffix)));
36
+ if (!before[0])
37
+ throw new Error(`Cursor database disappeared: ${path}`);
38
+ try {
39
+ for (const [index, suffix] of suffixes.entries()) {
40
+ await rm(target + suffix, { force: true });
41
+ if (before[index])
42
+ await copyFile(path + suffix, target + suffix);
43
+ }
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT")
47
+ continue;
48
+ throw error;
49
+ }
50
+ const after = await Promise.all(suffixes.map(suffix => fileStamp(path + suffix)));
51
+ if (JSON.stringify(before) !== JSON.stringify(after))
52
+ continue;
53
+ let db;
54
+ try {
55
+ // better-sqlite3 normally compiles with SQLITE_USE_URI=0. Try immutable
56
+ // only on a copy without sidecars; otherwise use a read-only temp copy
57
+ // where SQLite can reconstruct its private WAL index safely.
58
+ if (!before[1] && !before[2]) {
59
+ try {
60
+ db = new BetterSqlite3(`${pathToFileURL(target).href}?mode=ro&immutable=1`, { readonly: true, fileMustExist: true });
61
+ }
62
+ catch { /* URI opens are unavailable in the bundled SQLite build. */ }
63
+ }
64
+ db ??= new BetterSqlite3(target, { readonly: true, fileMustExist: true, timeout: 0 });
65
+ return read(db);
66
+ }
67
+ finally {
68
+ db?.close();
69
+ }
70
+ }
71
+ throw new Error("Cursor database changed while copying; will retry");
72
+ }
73
+ finally {
74
+ await rm(temp, { recursive: true, force: true });
75
+ }
76
+ }
77
+ function json(value, label) {
78
+ if (Buffer.isBuffer(value))
79
+ value = value.toString("utf8");
80
+ if (typeof value !== "string")
81
+ unsupported("cursor", `${label} is not JSON text`);
82
+ try {
83
+ return JSON.parse(value);
84
+ }
85
+ catch {
86
+ unsupported("cursor", `invalid JSON in ${label}`);
87
+ }
88
+ }
89
+ function requireTable(db, table) {
90
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
91
+ if (!["key", "value"].every(name => columns.some(column => column.name === name))) {
92
+ unsupported("cursor", `missing ${table} table or key/value columns`);
93
+ }
94
+ }
95
+ function item(db, key) {
96
+ const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
97
+ return row ? json(row.value, key) : undefined;
98
+ }
99
+ function workspacePath(value) {
100
+ const uri = object(value);
101
+ if (typeof uri?.fsPath === "string" && isAbsolute(uri.fsPath))
102
+ return uri.fsPath;
103
+ if (typeof value === "string" && isAbsolute(value))
104
+ return value;
105
+ if (typeof value === "string" && value.startsWith("file:")) {
106
+ try {
107
+ return fileURLToPath(value);
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
113
+ // URI components are used in some workspaceIdentifier entries.
114
+ if (uri?.scheme === "file" && typeof uri.path === "string") {
115
+ try {
116
+ return fileURLToPath(`file://${typeof uri.authority === "string" ? uri.authority : ""}${uri.path}`);
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+ function composers(value, key) {
125
+ const entries = Array.isArray(value) ? value : object(value)?.allComposers;
126
+ if (!Array.isArray(entries))
127
+ unsupported("cursor", `${key} needs an array of composers`);
128
+ return entries.map(entry => {
129
+ const composer = object(entry);
130
+ if (!composer || typeof composer.composerId !== "string" || !composer.composerId.trim() || composer.composerId.length > 200) {
131
+ unsupported("cursor", `invalid composerId in ${key}`);
132
+ }
133
+ return composer;
134
+ });
135
+ }
136
+ /** Resolve a User directory or a global/workspace state.vscdb override. */
137
+ export async function cursorDatabasePath(directory) {
138
+ try {
139
+ if ((await stat(directory)).isFile()) {
140
+ if (basename(dirname(dirname(directory))) === "workspaceStorage") {
141
+ return join(dirname(dirname(dirname(directory))), "globalStorage", "state.vscdb");
142
+ }
143
+ return directory;
144
+ }
145
+ }
146
+ catch (error) {
147
+ if (error.code === "ENOENT")
148
+ return undefined;
149
+ throw error;
150
+ }
151
+ const path = join(directory, "globalStorage", "state.vscdb");
152
+ return await fileStamp(path) ? path : undefined;
153
+ }
154
+ function parseBubble(value, id) {
155
+ const bubble = object(json(value, `bubble ${id}`));
156
+ if (!bubble)
157
+ unsupported("cursor", `invalid bubble ${id}`);
158
+ // Cursor uses 1=user and 2=assistant. Tool, system and internal bubbles are
159
+ // not conversation messages. Never stringify code, tool results or context.
160
+ const role = bubble.type === 1 || bubble.type === "user" ? "user"
161
+ : bubble.type === 2 || bubble.type === "assistant" ? "assistant" : undefined;
162
+ if (!role) {
163
+ if (bubble.type === undefined)
164
+ unsupported("cursor", `missing message type in bubble ${id}`);
165
+ return undefined;
166
+ }
167
+ if (bubble.text === undefined || bubble.text === null)
168
+ return undefined;
169
+ if (typeof bubble.text !== "string")
170
+ unsupported("cursor", `invalid text in bubble ${id}`);
171
+ if (!bubble.text.trim())
172
+ return undefined;
173
+ const timestamp = typeof bubble.createdAt === "number" && Number.isFinite(bubble.createdAt)
174
+ ? new Date(bubble.createdAt).toISOString() : createdAt(bubble.createdAt);
175
+ return { role, text: bubble.text.slice(0, 4000), timestamp };
176
+ }
177
+ export async function readCursorTranscripts(path) {
178
+ const global = await snapshot(path, db => {
179
+ requireTable(db, "cursorDiskKV");
180
+ // Older layouts only need ItemTable in the workspace databases.
181
+ const hasHeadersTable = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ItemTable'").get();
182
+ if (hasHeadersTable)
183
+ requireTable(db, "ItemTable");
184
+ const headers = hasHeadersTable ? item(db, "composer.composerHeaders") : undefined;
185
+ // Materialize only the relevant rows, one value at a time. Large tool and
186
+ // code fields are discarded immediately rather than retained in memory.
187
+ const metadata = new Map();
188
+ const bubbles = new Map();
189
+ for (const row of db.prepare("SELECT key, value FROM cursorDiskKV WHERE key GLOB 'composerData:*' OR key GLOB 'bubbleId:*' ORDER BY key").iterate()) {
190
+ if (row.key.startsWith("composerData:")) {
191
+ const data = object(json(row.value, row.key));
192
+ if (!data)
193
+ unsupported("cursor", `invalid ${row.key}`);
194
+ metadata.set(row.key.slice("composerData:".length), data);
195
+ }
196
+ else {
197
+ const parts = row.key.split(":");
198
+ if (parts.length !== 3 || !parts[1] || !parts[2])
199
+ unsupported("cursor", `invalid bubble key ${row.key}`);
200
+ const entries = bubbles.get(parts[1]) ?? [];
201
+ entries.push({ id: parts[2], message: parseBubble(row.value, parts[2]) });
202
+ bubbles.set(parts[1], entries);
203
+ }
204
+ }
205
+ return { headers, metadata, bubbles };
206
+ });
207
+ const index = new Map();
208
+ const add = (composer, cwd) => {
209
+ const id = composer.composerId;
210
+ if (!cwd)
211
+ unsupported("cursor", `missing workspace path for composer ${id}`);
212
+ if (index.has(id) && resolve(index.get(id)) !== resolve(cwd))
213
+ unsupported("cursor", `conflicting workspaces for composer ${id}`);
214
+ index.set(id, cwd);
215
+ };
216
+ let foundIndex = global.headers !== undefined;
217
+ if (foundIndex) {
218
+ for (const composer of composers(global.headers, "composer.composerHeaders")) {
219
+ add(composer, workspacePath(object(composer.workspaceIdentifier)?.uri));
220
+ }
221
+ }
222
+ const workspaces = join(dirname(dirname(path)), "workspaceStorage");
223
+ let directories;
224
+ try {
225
+ directories = await readdir(workspaces, { withFileTypes: true });
226
+ }
227
+ catch (error) {
228
+ if (error.code !== "ENOENT")
229
+ throw error;
230
+ directories = [];
231
+ }
232
+ for (const entry of directories.sort((a, b) => a.name.localeCompare(b.name))) {
233
+ if (!entry.isDirectory())
234
+ continue;
235
+ const workspace = join(workspaces, entry.name);
236
+ const database = join(workspace, "state.vscdb");
237
+ if (!await fileStamp(database))
238
+ continue;
239
+ const legacy = await snapshot(database, db => {
240
+ requireTable(db, "ItemTable");
241
+ return item(db, "composer.composerData");
242
+ });
243
+ if (legacy === undefined)
244
+ continue;
245
+ foundIndex = true;
246
+ const entries = composers(legacy, "composer.composerData");
247
+ if (!entries.length)
248
+ continue;
249
+ let info;
250
+ try {
251
+ info = object(json(await readFile(join(workspace, "workspace.json"), "utf8"), "workspace.json"));
252
+ }
253
+ catch (error) {
254
+ if (error.code === "ENOENT")
255
+ unsupported("cursor", "missing workspace.json for composer index");
256
+ throw error;
257
+ }
258
+ for (const composer of entries) {
259
+ // Current central headers take precedence over stale workspace indexes.
260
+ if (!index.has(composer.composerId))
261
+ add(composer, workspacePath(info?.folder ?? info?.workspace));
262
+ }
263
+ }
264
+ if (!foundIndex)
265
+ unsupported("cursor", "missing composer.composerHeaders and workspace composer.composerData indexes");
266
+ const transcripts = [];
267
+ for (const [sessionId, cwd] of index) {
268
+ const metadata = global.metadata.get(sessionId);
269
+ if (!metadata)
270
+ unsupported("cursor", `missing composerData:${sessionId}`);
271
+ const headers = metadata.fullConversationHeadersOnly ?? metadata.conversation;
272
+ if (headers !== undefined && !Array.isArray(headers))
273
+ unsupported("cursor", `invalid conversation headers for ${sessionId}`);
274
+ const order = new Map();
275
+ const entries = global.bubbles.get(sessionId) ?? [];
276
+ const bubbleIds = new Set(entries.map(bubble => bubble.id));
277
+ for (const [position, raw] of (Array.isArray(headers) ? headers : []).entries()) {
278
+ const header = object(raw);
279
+ if (typeof header?.bubbleId !== "string")
280
+ unsupported("cursor", `invalid conversation header for ${sessionId}`);
281
+ order.set(header.bubbleId, position);
282
+ if (!bubbleIds.has(header.bubbleId)) {
283
+ unsupported("cursor", `missing bubbleId:${sessionId}:${header.bubbleId}`);
284
+ }
285
+ }
286
+ entries.sort((a, b) => (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity)
287
+ || (a.message?.timestamp ?? "").localeCompare(b.message?.timestamp ?? "") || a.id.localeCompare(b.id));
288
+ transcripts.push({ host: "cursor", cwd, sessionId, malformed: false,
289
+ events: entries.flatMap(({ id, message }) => message ? [{
290
+ id: `bubble-${id}`, eventType: `message:${message.role}`, payloadJson: JSON.stringify({ text: message.text }), createdAt: message.timestamp,
291
+ }] : []),
292
+ });
293
+ }
294
+ return transcripts;
295
+ }
@@ -0,0 +1,118 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { expandTilde } from "../context.js";
7
+ import { isClaudeRecord, parseClaudeTranscript } from "./claudeCode.js";
8
+ import { parseCodexTranscript } from "./codex.js";
9
+ import { cursorDatabasePath, cursorUserDirectory, readCursorTranscripts } from "./cursor.js";
10
+ import { object } from "./types.js";
11
+ export const TRANSCRIPT_HOSTS = ["claude-code", "codex", "cursor"];
12
+ const parsers = { "claude-code": parseClaudeTranscript, codex: parseCodexTranscript };
13
+ export function transcriptDirectory(host) {
14
+ if (host === "cursor")
15
+ return cursorUserDirectory();
16
+ return join(homedir(), host === "codex" ? ".codex" : ".claude", host === "codex" ? "sessions" : "projects");
17
+ }
18
+ export function transcriptSources(options) {
19
+ const host = options.host;
20
+ if (host && !TRANSCRIPT_HOSTS.includes(host)) {
21
+ throw new Error(`Unsupported transcript host: ${host}. Choose ${TRANSCRIPT_HOSTS.join(" or ")}`);
22
+ }
23
+ const selected = host;
24
+ if (options.transcriptsDir)
25
+ return [{ directory: resolve(expandTilde(options.transcriptsDir)), host: selected }];
26
+ return (selected ? [selected] : TRANSCRIPT_HOSTS).map(host => ({ directory: transcriptDirectory(host), host }));
27
+ }
28
+ export async function* transcriptFiles(directory) {
29
+ let entries;
30
+ try {
31
+ entries = await readdir(directory, { withFileTypes: true });
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ENOENT")
35
+ return;
36
+ throw error;
37
+ }
38
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
39
+ const path = join(directory, entry.name);
40
+ if (entry.isDirectory())
41
+ yield* transcriptFiles(path);
42
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
43
+ yield path;
44
+ }
45
+ }
46
+ /** Recognize Cursor overrides without changing JSONL directory overrides. */
47
+ export async function isCursorSource(directory) {
48
+ try {
49
+ if ((await stat(directory)).isFile())
50
+ return directory.endsWith(".vscdb");
51
+ return !!await cursorDatabasePath(directory);
52
+ }
53
+ catch (error) {
54
+ if (error.code === "ENOENT")
55
+ return false;
56
+ throw error;
57
+ }
58
+ }
59
+ export async function readTranscript(path, claimedHost) {
60
+ if (claimedHost === "cursor")
61
+ throw new Error("unsupported format for cursor: expected a User directory or state.vscdb database");
62
+ const records = [];
63
+ let malformed = false;
64
+ const input = createReadStream(path);
65
+ const lines = createInterface({ input, crlfDelay: Infinity });
66
+ let lineIndex = 0;
67
+ try {
68
+ for await (const line of lines) {
69
+ const index = lineIndex++;
70
+ if (!line.trim())
71
+ continue;
72
+ let value;
73
+ try {
74
+ value = object(JSON.parse(line.replace(/^\uFEFF/, "")));
75
+ }
76
+ catch {
77
+ malformed = true;
78
+ continue;
79
+ }
80
+ if (!value)
81
+ throw new Error(`unsupported format${claimedHost ? ` for ${claimedHost}` : ""}: record on line ${index + 1} is not an object`);
82
+ records.push({ value, line: index });
83
+ }
84
+ }
85
+ finally {
86
+ lines.close();
87
+ input.destroy();
88
+ }
89
+ const host = claimedHost ?? (records.some(({ value }) => value.type === "session_meta") ? "codex"
90
+ : records.some(({ value }) => isClaudeRecord(value)) ? "claude-code" : undefined);
91
+ if (!host && records.length)
92
+ throw new Error("unsupported format: expected Claude Code transcripts or Codex CLI rollouts");
93
+ const result = parsers[host ?? "claude-code"](path, records);
94
+ return { ...result, malformed };
95
+ }
96
+ /** Read-only doctor probe: enumerate history and validate one sample per host. */
97
+ export async function inspectTranscriptDiscovery(host) {
98
+ const directory = transcriptDirectory(host);
99
+ if (host === "cursor") {
100
+ const path = await cursorDatabasePath(directory);
101
+ if (!path)
102
+ return `No transcripts found (${directory})`;
103
+ const transcripts = await readCursorTranscripts(path);
104
+ return `${transcripts.length} composer(s) found (${path}); parsed as cursor`;
105
+ }
106
+ let count = 0;
107
+ let sample;
108
+ for await (const path of transcriptFiles(directory)) {
109
+ count++;
110
+ sample ??= path;
111
+ }
112
+ if (!sample)
113
+ return `No transcripts found (${directory})`;
114
+ const transcript = await readTranscript(sample, host);
115
+ if (transcript.malformed)
116
+ throw new Error(`malformed or incomplete JSONL (${sample})`);
117
+ return `${count} transcript file(s) found (${directory}); sample parsed as ${host}`;
118
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from "node:crypto";
2
+ export function object(value) {
3
+ return value && typeof value === "object" && !Array.isArray(value)
4
+ ? value : undefined;
5
+ }
6
+ export function stableId(...parts) {
7
+ return `transcript-${createHash("sha256").update(JSON.stringify(parts)).digest("hex")}`;
8
+ }
9
+ export function createdAt(value) {
10
+ const timestamp = typeof value === "string" ? Date.parse(value) : NaN;
11
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
12
+ }
13
+ export function unsupported(host, detail) {
14
+ throw new Error(`unsupported format for ${host}: ${detail}`);
15
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ export const watchCheckpointPath = (dbPath) => join(dirname(resolve(dbPath)), `${basename(dbPath)}.watch-checkpoint.json`);
4
+ export function saveWatchCheckpoint(context, summary, intervalSeconds) {
5
+ if (context.dbPath === ":memory:")
6
+ return;
7
+ const path = watchCheckpointPath(context.dbPath);
8
+ const checkpoint = {
9
+ dbPath: resolve(context.dbPath), completedAt: new Date().toISOString(), intervalSeconds,
10
+ checkpointCount: context.db.prepare("SELECT count(*) AS n FROM transcript_checkpoints").get().n,
11
+ summary,
12
+ };
13
+ const temp = `${path}.${process.pid}.tmp`;
14
+ writeFileSync(temp, JSON.stringify(checkpoint), { mode: 0o600 });
15
+ renameSync(temp, path);
16
+ }
17
+ export function inspectWatchCheckpoint(dbPath, now = Date.now()) {
18
+ const checkpoint = JSON.parse(readFileSync(watchCheckpointPath(dbPath), "utf8"));
19
+ const age = now - Date.parse(checkpoint.completedAt);
20
+ if (checkpoint.dbPath !== resolve(dbPath))
21
+ throw new Error("Watcher checkpoint belongs to a different database");
22
+ if (!Number.isFinite(checkpoint.intervalSeconds) || checkpoint.intervalSeconds <= 0 || checkpoint.intervalSeconds > 86400)
23
+ throw new Error("Invalid watcher checkpoint interval");
24
+ if (!Number.isFinite(age) || age < -60000 || age > Math.max(120000, checkpoint.intervalSeconds * 3000))
25
+ throw new Error(`Watcher checkpoint is stale or invalid (${checkpoint.completedAt})`);
26
+ if (!checkpoint.summary || Object.values(checkpoint.summary).length !== 5 || Object.values(checkpoint.summary).some(value => !Number.isInteger(value) || value < 0))
27
+ throw new Error("Invalid watcher sweep counters");
28
+ if (checkpoint.summary.errors !== 0)
29
+ throw new Error(`Last watcher sweep had ${checkpoint.summary.errors} capture error(s)`);
30
+ if (!Number.isInteger(checkpoint.checkpointCount) || checkpoint.checkpointCount < 0)
31
+ throw new Error("Invalid transcript checkpoint count");
32
+ if (checkpoint.summary.filesFound > 0 && checkpoint.checkpointCount === 0)
33
+ throw new Error("Transcripts found but none checkpointed yet");
34
+ return `Sweep completed ${Math.max(0, Math.round(age / 1000))}s ago; ${checkpoint.checkpointCount} transcript checkpoint(s); ${checkpoint.summary.filesFound} source file(s)`;
35
+ }
@@ -0,0 +1,23 @@
1
+ import { mkdirSync, openSync, closeSync, writeSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ /** Redirect both streams before opening storage so headless startup failures land
4
+ * in the same log as per-file capture failures. Never reads stdin or uses a TTY. */
5
+ export function openWatchLog(path) {
6
+ if (!path)
7
+ return () => { };
8
+ const absolute = resolve(path);
9
+ mkdirSync(dirname(absolute), { recursive: true });
10
+ const fd = openSync(absolute, "a", 0o600);
11
+ const stdout = process.stdout.write;
12
+ const stderr = process.stderr.write;
13
+ const write = ((chunk, encodingOrCallback, callback) => {
14
+ const value = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
15
+ writeSync(fd, `${new Date().toISOString()} ${value}`);
16
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
17
+ done?.();
18
+ return true;
19
+ });
20
+ process.stdout.write = write;
21
+ process.stderr.write = write;
22
+ return () => { process.stdout.write = stdout; process.stderr.write = stderr; closeSync(fd); };
23
+ }
@@ -90,6 +90,9 @@ export const ingestSessionEventSchema = z.object({
90
90
  export const ingestSessionEventsRequestSchema = z.object({
91
91
  projectId: z.string().min(1),
92
92
  sessionId: z.string().min(1).max(200),
93
+ // Mutable transcript hosts can refresh an existing event without changing
94
+ // its identity, order or original timestamp. Other callers remain append-only.
95
+ replaceExisting: z.boolean().optional(),
93
96
  events: z.array(ingestSessionEventSchema).min(1).max(MAX_INGEST_EVENTS),
94
97
  });
95
98
  export const summarizeSessionToMemoryRequestSchema = z.object({