sessionmem 1.5.0 → 1.6.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - Multi-host transcript imports: `sessionmem scan` and `sessionmem watch` now
13
+ sweep Claude Code, Codex CLI, and Cursor histories by default, with a
14
+ separate parser per host and an explicit unsupported-format error when a
15
+ claimed host's records cannot be parsed. `--host` selects one host.
16
+ ([#52], [#53])
17
+ - Codex CLI support: dated rollout discovery, project and session identity from
18
+ session metadata, resume-file grouping with cross-file deduplication,
19
+ sub-agent session separation, native tool-call events, and filtering of
20
+ encrypted agent payloads and injected machine blocks. ([#52])
21
+ - Cursor support: chat history read from Cursor's SQLite storage through
22
+ private snapshot copies opened read-only, covering both composer index
23
+ layouts, with per-composer checkpoints and live-update handling. ([#53])
24
+ - `sessionmem doctor` now checks transcript discovery for each supported
25
+ host. ([#52], [#53])
26
+
8
27
  ## [1.5.0] - 2026-10-18
9
28
 
10
29
  ### Added
@@ -102,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102
121
  errors and findings fail the job; added scanner dependency updates and
103
122
  Dependabot cooldowns. ([#32])
104
123
 
124
+ [1.6.0]: https://github.com/catfish-1234/sessionmem/compare/v1.5.0...v1.6.0
105
125
  [1.5.0]: https://github.com/catfish-1234/sessionmem/compare/679b8954f0b49f197985c40da47f181c00060cd9...v1.5.0
106
126
  [#43]: https://github.com/catfish-1234/sessionmem/pull/43
107
127
  [#44]: https://github.com/catfish-1234/sessionmem/pull/44
@@ -112,6 +132,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112
132
  [#49]: https://github.com/catfish-1234/sessionmem/pull/49
113
133
  [#50]: https://github.com/catfish-1234/sessionmem/pull/50
114
134
  [#51]: https://github.com/catfish-1234/sessionmem/pull/51
135
+ [#52]: https://github.com/catfish-1234/sessionmem/pull/52
136
+ [#53]: https://github.com/catfish-1234/sessionmem/pull/53
115
137
  [1.4.0]: https://github.com/catfish-1234/sessionmem/compare/a73f503123b1d33c35407ce66403f281ba3451ac...679b8954f0b49f197985c40da47f181c00060cd9
116
138
  [#22]: https://github.com/catfish-1234/sessionmem/pull/22
117
139
  [#23]: https://github.com/catfish-1234/sessionmem/pull/23
package/README.md CHANGED
@@ -210,7 +210,7 @@ See [`docs/architecture.md`](docs/architecture.md) for the technical overview.
210
210
  | Command | What it does |
211
211
  |---------|--------------|
212
212
  | `sessionmem install [--adapter <name>]` | Detect installed hosts, register sessionmem with each, and write default config; the flag selects one host. |
213
- | `sessionmem doctor [--adapter <name>]` | Check the store, schema, hooks, MCP wiring, and canary recall offline; print PASS/FAIL and repair hints. |
213
+ | `sessionmem doctor [--adapter <name>]` | Check the store, schema, transcript discovery, hooks, MCP wiring, and canary recall offline; print PASS/FAIL and repair hints. |
214
214
  | `sessionmem uninstall [--adapter <name>] [--purge]` | Remove sessionmem from the host. `--purge` also deletes the local database. |
215
215
  | `sessionmem run` | Start the MCP server. |
216
216
  | `sessionmem ui [--port N]` | Open the local read-only web viewer (default port 3777). |
@@ -223,8 +223,8 @@ See [`docs/architecture.md`](docs/architecture.md) for the technical overview.
223
223
  | `sessionmem forget <id> [--force]` | Delete a memory by ID. |
224
224
  | `sessionmem export [path]` | Export all current-project memories to JSON (default `~/.sessionmem/export-YYYY-MM-DD.json`). |
225
225
  | `sessionmem import <path> [--merge]` | Import up to 1000 valid records from UTF-8 JSON into the current project. Existing IDs are preserved, including with `--merge`; invalid records are skipped with warnings. |
226
- | `sessionmem scan [--transcripts-dir <path>]` | Import existing Claude Code JSONL transcripts into local session memories. Reports files found, sessions imported, memories created, skipped files, and errors. |
227
- | `sessionmem watch [--once] [--interval <seconds>] [--transcripts-dir <path>]` | Capture new or updated Claude Code transcripts. Polls every 5 seconds by default; `--once` runs a single sweep. |
226
+ | `sessionmem scan [--host <host>] [--transcripts-dir <path>]` | Import existing Claude Code, Codex CLI, and Cursor transcripts into local session memories. Reports files found, sessions imported, memories created, skips, and errors. |
227
+ | `sessionmem watch [--once] [--interval <seconds>] [--host <host>] [--transcripts-dir <path>]` | Capture new or updated Claude Code, Codex CLI, and Cursor transcripts. Polls every 5 seconds by default; `--once` runs a single sweep. |
228
228
  | `sessionmem stats` | Show memory statistics for the current project. |
229
229
  | `sessionmem savings [--json]` | Show token savings from compression and injection, with percentage. |
230
230
  | `sessionmem redact-scan [--apply]` | Scan stored memories for secrets; `--apply` redacts in place. |
@@ -297,32 +297,78 @@ When a fact changes, sessionmem can link its replacement locally. For example,
297
297
 
298
298
  Detection uses deterministic entity/relation rules, shared purpose words, a small tool-category vocabulary, and explicit negation or replacement phrases. It needs no API or model. Unfamiliar wording can be missed, and ambiguous changes can be misclassified. A memory with independent claims remains searchable while any claim is current; history labels these partial supersessions. See [supersession details](docs/architecture.md#contradiction-and-supersession).
299
299
 
300
- Run a scan to capture Claude Code sessions from before sessionmem was installed:
300
+ Run a scan to capture Claude Code, Codex CLI, and Cursor sessions from before sessionmem was installed:
301
301
 
302
302
  ```bash
303
303
  sessionmem scan
304
304
  sessionmem watch --once
305
305
  sessionmem watch --interval 10
306
+ sessionmem scan --host cursor
306
307
  ```
307
308
 
308
- Both commands recursively read `~/.claude/projects/**/*.jsonl`. Use
309
- `--transcripts-dir "/path/to/projects"` to select another transcript tree. They
310
- work fully offline without API keys and always use the local summarizer, even
311
- if cloud summarization is enabled elsewhere.
312
-
313
- Transcript user/assistant text and supported tool uses (Bash, Edit, Write,
314
- MultiEdit) pass through the existing event-ingestion, redaction, and session-summary
315
- pipeline. Each session gets one summary memory, including short sessions with
316
- only one event. Updated sessions refresh that summary. Project buckets come from
317
- each transcript's `cwd`, using its repository root when available; the directory
318
- where you run scan/watch does not select the project. `SESSIONMEM_PROJECT_ID`
319
- can explicitly pin all imports to one bucket. Transcripts without a working
320
- directory or capturable events are skipped; nested subagent files get separate
321
- session summaries.
309
+ Both commands sweep all detected transcript histories by default:
310
+
311
+ - Claude Code: `~/.claude/projects/**/*.jsonl`.
312
+ - Codex CLI: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
313
+ - Cursor: `User/globalStorage/state.vscdb` under
314
+ `~/Library/Application Support/Cursor` on macOS, `~/.config/Cursor` on Linux,
315
+ or `%APPDATA%/Cursor` on Windows.
316
+
317
+ Missing history directories are allowed. Use `--transcripts-dir "/path/to/history"`
318
+ to select another transcript tree; each JSONL file's host is detected from its records.
319
+ For Cursor, the override accepts its `User` directory or a `state.vscdb` path.
320
+ Use `--host cursor`, `--host codex`, or `--host claude-code` to select one host,
321
+ including when using an override. A Cursor User directory or `.vscdb` file is
322
+ also detected without `--host`. Files that do not match a supported format, or
323
+ the selected host, produce an explicit `unsupported format` error.
324
+
325
+ Both commands work offline without API keys and always use the local summarizer,
326
+ even if cloud summarization is enabled elsewhere. `sessionmem doctor` checks
327
+ transcript discovery for all three hosts, parses JSONL samples, and validates
328
+ Cursor's composer indexes and message rows when history is present.
329
+
330
+ Transcript user/assistant text, supported Claude tool uses (Bash, Edit, Write,
331
+ MultiEdit), and Codex function calls pass through the existing event-ingestion,
332
+ redaction, and session-summary pipeline. Codex tool events retain the native tool
333
+ name and `call_id`. Encrypted `agent_message` payloads and obvious injected machine
334
+ blocks, including plugin catalogues and environment context, are excluded. Each
335
+ session gets one summary memory, including short sessions with only one event.
336
+ Updated sessions refresh that summary. Project buckets come from
337
+ each Claude transcript's `cwd`, Codex `session_meta.payload.cwd`, or Cursor's
338
+ workspace path, using the repository root when available; the directory where you run scan/watch does not
339
+ select the project. `SESSIONMEM_PROJECT_ID` can explicitly pin all imports to one
340
+ bucket. Transcripts without capturable events are skipped. Claude transcripts
341
+ without a working directory are also skipped; Codex session metadata must contain
342
+ an ID and working directory. Nested subagent files get separate session summaries.
343
+ Summary memories record `sourceAdapter` as `claude-code`, `codex`, or `cursor`
344
+ according to the transcript host. Cursor session IDs come from `composerId`.
345
+
346
+ Cursor stores messages in `cursorDiskKV` bubble rows in its global SQLite
347
+ database. The importer supports workspace `composer.composerData` indexes
348
+ (Cursor 2.6 and earlier) and the central `composer.composerHeaders` index
349
+ (Cursor 3.0 and later). It captures up to 4,000 characters of user/assistant text
350
+ per bubble and excludes tool-only bubbles and internal tool/code fields. Missing
351
+ tables, index keys, or required conversation data produce an explicit
352
+ `unsupported format for cursor` error.
353
+
354
+ Cursor databases are never written. Each sweep copies the database and its WAL
355
+ to a private temporary directory, checking for concurrent changes during the
356
+ copy. SQLite opens the copy read-only, using immutable mode when URI support is
357
+ available and there is no WAL to replay. Cursor's live database is never opened by SQLite. Busy
358
+ copies are reported and retried. Each sweep reads a fresh snapshot; per-composer
359
+ checkpoints skip unchanged messages, and streamed bubble updates refresh the
360
+ existing event without duplicating it.
361
+
362
+ Codex resumes create new rollout files. Files with the same session ID are grouped,
363
+ and replayed events are deduplicated across files, sweeps, and restarts. A Codex
364
+ sub-agent keeps its own `session_meta.payload.id`, even when `parent_thread_id`
365
+ points to a parent session.
322
366
 
323
367
  Re-running either command does not duplicate memories or transcript events.
324
- File checkpoints live in the selected memory database, and unchanged files are
325
- skipped across restarts. Stable event IDs also make interrupted imports safe to
368
+ File and composer checkpoints live in the selected memory database, and unchanged
369
+ files or composers are skipped across restarts. For Cursor, `files found` counts
370
+ global databases, and `skipped` counts unchanged or empty composers.
371
+ Stable event IDs also make interrupted imports safe to
326
372
  retry. Partial or malformed JSONL lines are reported and retried on later sweeps;
327
373
  valid events in the same file can still be captured. A single sweep exits with
328
374
  status 1 if it encounters errors. The daemon keeps running and retries errors.
@@ -366,7 +412,7 @@ and adjust PATH for your Node install:
366
412
 
367
413
  ```ini
368
414
  [Unit]
369
- Description=Capture Claude Code transcripts with sessionmem
415
+ Description=Capture Claude Code, Codex, and Cursor transcripts with sessionmem
370
416
 
371
417
  [Service]
372
418
  ExecStart=/usr/local/bin/sessionmem watch
@@ -511,8 +557,9 @@ sessionmem doctor
511
557
  sessionmem doctor --adapter claude-code # explicit host override
512
558
  ```
513
559
 
514
- `doctor` reports a separate PASS or FAIL for the memory store, schema, hook
515
- definitions shipped with the current version, configured MCP command, and a
560
+ `doctor` reports a separate PASS or FAIL for the memory store, schema, transcript
561
+ discovery for Claude Code and Codex, hook definitions shipped with the current
562
+ version, configured MCP command, and a
516
563
  canary write/recall. It exits nonzero if any check fails and prints fix hints,
517
564
  including exact commands for safe repairs. It does not create a missing store or
518
565
  apply migrations. Its temporary canary is isolated from your projects and rolled
@@ -7,6 +7,7 @@ import { ADAPTER_NAMES, AdapterFactory } from "../../adapters/factory.js";
7
7
  import { createMemoryCoreService } from "../../core/api/memoryCoreService.js";
8
8
  import { inspectMigrations } from "../../core/schema/runMigrations.js";
9
9
  import { resolveDbPath } from "../context.js";
10
+ import { inspectTranscriptDiscovery, TRANSCRIPT_HOSTS } from "../transcripts/index.js";
10
11
  const VERSION = createRequire(import.meta.url)("../../../package.json").version;
11
12
  /** Exercise real storage, embedding, ranking and injection without retaining data. */
12
13
  export async function probeRecall(db) {
@@ -53,6 +54,14 @@ export async function doctorCommand(options, overrides = {}) {
53
54
  report("Agent hosts", false, message(err), "Install a supported host or select one with sessionmem doctor --adapter <name>");
54
55
  }
55
56
  const dbPath = resolveDbPath(overrides.dbPath);
57
+ for (const host of TRANSCRIPT_HOSTS) {
58
+ try {
59
+ report(`${host} transcripts`, true, await inspectTranscriptDiscovery(host));
60
+ }
61
+ catch (err) {
62
+ report(`${host} transcripts`, false, message(err), `Check transcript directory permissions and format; run sessionmem scan --host ${host}`);
63
+ }
64
+ }
56
65
  let db;
57
66
  let schemaOk = false;
58
67
  try {
package/dist/cli/index.js CHANGED
@@ -60,13 +60,15 @@ program
60
60
  .action(() => ingestHookCommand());
61
61
  program
62
62
  .command("scan")
63
- .description("Import existing Claude Code transcripts using local capture and summarization")
64
- .option("--transcripts-dir <path>", "Transcript directory (default: ~/.claude/projects)")
63
+ .description("Import Claude Code, Codex, and Cursor transcripts using local capture and summarization")
64
+ .option("--transcripts-dir <path>", "Override transcript roots (Cursor: User directory or state.vscdb)")
65
+ .option("--host <host>", "Select a transcript host: claude-code, codex, or cursor")
65
66
  .action(async (options) => { await scanCommand(options); });
66
67
  program
67
68
  .command("watch")
68
- .description("Poll Claude Code transcripts and capture new or updated sessions offline")
69
- .option("--transcripts-dir <path>", "Transcript directory (default: ~/.claude/projects)")
69
+ .description("Poll Claude Code, Codex, and Cursor transcripts and capture sessions offline")
70
+ .option("--transcripts-dir <path>", "Override transcript roots (Cursor: User directory or state.vscdb)")
71
+ .option("--host <host>", "Select a transcript host: claude-code, codex, or cursor")
70
72
  .option("--once", "Run one sweep and exit")
71
73
  .option("--interval <seconds>", "Seconds between sweeps", "5")
72
74
  .action((options) => watchCommand(options));
@@ -77,7 +79,7 @@ program
77
79
  .action((options) => installCommand(options));
78
80
  program
79
81
  .command("doctor")
80
- .description("Check the local store, schema, hooks, adapter wiring, and recall offline")
82
+ .description("Check storage, transcript discovery, hooks, MCP wiring, and recall offline")
81
83
  .option("--adapter <name>", "Check a specific host instead of installed-host detection")
82
84
  .action(async (options) => { await doctorCommand(options); });
83
85
  program
@@ -1,145 +1,107 @@
1
- import { createHash } from "node:crypto";
2
- import { createReadStream } from "node:fs";
3
- import { readdir, stat } from "node:fs/promises";
4
- import { basename, join, resolve } from "node:path";
5
- import { createInterface } from "node:readline";
6
- import { homedir } from "node:os";
7
- import { buildToolEvent } from "../core/capture/toolEvent.js";
1
+ import { stat } from "node:fs/promises";
8
2
  import { handleSessionEndConfigSchema, MAX_INGEST_EVENTS } from "../core/api/contracts.js";
9
- import { expandTilde } from "./context.js";
10
3
  import { deriveProjectId } from "./projectId.js";
11
- function object(value) {
12
- return value && typeof value === "object" && !Array.isArray(value)
13
- ? value : undefined;
14
- }
15
- function stableId(...parts) {
16
- return `transcript-${createHash("sha256").update(JSON.stringify(parts)).digest("hex")}`;
17
- }
18
- /** Translate the transcript envelope; tool normalization stays shared with ingest-hook. */
19
- async function readTranscript(path) {
20
- const result = {
21
- cwd: undefined,
22
- sessionId: basename(path, ".jsonl"),
23
- events: [],
24
- malformed: false,
25
- };
26
- const input = createReadStream(path);
27
- const lines = createInterface({ input, crlfDelay: Infinity });
28
- let lineIndex = 0;
29
- try {
30
- for await (const line of lines) {
31
- const index = lineIndex++;
32
- if (!line.trim())
33
- continue;
34
- let record;
35
- try {
36
- record = object(JSON.parse(line.replace(/^\uFEFF/, "")));
37
- }
38
- catch {
39
- result.malformed = true;
40
- continue;
41
- }
42
- if (!record)
43
- continue;
44
- if (!result.cwd && typeof record.cwd === "string" && record.cwd.trim()) {
45
- result.cwd = record.cwd;
46
- }
47
- // Subagent files share their parent's sessionId; retain their own filename.
48
- if (!basename(path).startsWith("agent-") && typeof record.sessionId === "string") {
49
- result.sessionId = record.sessionId;
50
- }
51
- if (record.type !== "user" && record.type !== "assistant")
52
- continue;
53
- const message = object(record.message);
54
- const content = message?.content;
55
- const blocks = typeof content === "string" ? [{ type: "text", text: content }]
56
- : Array.isArray(content) ? content : [];
57
- for (const [blockIndex, rawBlock] of blocks.entries()) {
58
- const block = object(rawBlock);
59
- if (!block)
60
- continue;
61
- const key = typeof record.uuid === "string" ? record.uuid : `line-${index}`;
62
- const timestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
63
- const createdAt = Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
64
- if (block.type === "tool_use" && typeof block.name === "string") {
65
- const event = buildToolEvent(block.name, object(block.input), result.cwd);
66
- if (event)
67
- result.events.push({
68
- id: typeof block.id === "string" ? `tool-${block.id}` : `${key}-${blockIndex}`,
69
- ...event,
70
- createdAt,
71
- });
72
- }
73
- else if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
74
- result.events.push({
75
- id: `${key}-${blockIndex}`,
76
- eventType: `message:${record.type}`,
77
- createdAt,
78
- payloadJson: JSON.stringify({ text: block.text.slice(0, 4000) }),
79
- });
80
- }
81
- }
82
- }
83
- }
84
- finally {
85
- lines.close();
86
- input.destroy();
87
- }
88
- return result;
89
- }
90
- async function* transcriptFiles(directory) {
91
- let entries;
92
- try {
93
- entries = await readdir(directory, { withFileTypes: true });
94
- }
95
- catch (error) {
96
- if (error.code === "ENOENT")
97
- return;
98
- throw error;
99
- }
100
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
101
- const path = join(directory, entry.name);
102
- if (entry.isDirectory())
103
- yield* transcriptFiles(path);
104
- else if (entry.isFile() && entry.name.endsWith(".jsonl"))
105
- yield path;
106
- }
107
- }
108
- async function fingerprint(path) {
4
+ import { isCursorSource, readTranscript, transcriptFiles, transcriptSources } from "./transcripts/index.js";
5
+ import { cursorDatabasePath, readCursorTranscripts } from "./transcripts/cursor.js";
6
+ import { stableId } from "./transcripts/types.js";
7
+ async function fingerprint(path, host) {
109
8
  const info = await stat(path, { bigint: true });
110
- return `${process.env.SESSIONMEM_PROJECT_ID ?? ""}:${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
9
+ // Version the checkpoint so files scanned by the old Claude-only reader are
10
+ // retried, and a changed host claim cannot reuse a previous parser's result.
11
+ return `hosts-v1:${host ?? "auto"}:${process.env.SESSIONMEM_PROJECT_ID ?? ""}:${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
111
12
  }
112
13
  export async function sweepTranscripts(context, options = {}) {
113
- const directory = resolve(expandTilde(options.transcriptsDir ?? join(homedir(), ".claude", "projects")));
14
+ const sources = transcriptSources(options);
114
15
  const summary = { filesFound: 0, sessionsImported: 0, memoriesCreated: 0, skipped: 0, errors: 0 };
115
- const importedSessions = new Set();
16
+ const groups = new Map();
116
17
  const checkpoint = context.db.prepare("SELECT fingerprint FROM transcript_checkpoints WHERE path = ?");
117
18
  const saveCheckpoint = context.db.prepare("INSERT OR REPLACE INTO transcript_checkpoints (path, fingerprint) VALUES (?, ?)");
118
- for await (const path of transcriptFiles(directory)) {
119
- summary.filesFound++;
19
+ const report = (path, error) => {
20
+ summary.errors++;
21
+ process.stderr.write(`[sessionmem] ${path}: ${error instanceof Error ? error.message : String(error)}\n`);
22
+ };
23
+ for (const source of sources) {
120
24
  try {
121
- const before = await fingerprint(path);
122
- if (checkpoint.get(path)?.fingerprint === before) {
123
- summary.skipped++;
25
+ if (source.host === "cursor" || (!source.host && await isCursorSource(source.directory))) {
26
+ const path = await cursorDatabasePath(source.directory);
27
+ if (!path)
28
+ continue;
29
+ summary.filesFound++;
30
+ for (const transcript of await readCursorTranscripts(path)) {
31
+ const projectId = deriveProjectId(transcript.cwd);
32
+ const checkpointPath = `cursor:${JSON.stringify([path, transcript.sessionId])}`;
33
+ // A database/WAL fingerprint is insufficient: unrelated settings and
34
+ // other composers change it. Hash only this composer's bounded events.
35
+ const before = stableId("cursor-v1", projectId, JSON.stringify(transcript.events));
36
+ if (checkpoint.get(checkpointPath)?.fingerprint === before) {
37
+ summary.skipped++;
38
+ continue;
39
+ }
40
+ if (!transcript.events.length) {
41
+ summary.skipped++;
42
+ saveCheckpoint.run(checkpointPath, before);
43
+ continue;
44
+ }
45
+ groups.set(JSON.stringify(["cursor", projectId, transcript.sessionId]), {
46
+ host: "cursor", projectId, sessionId: transcript.sessionId,
47
+ events: new Map(transcript.events.map(event => [event.id, event])),
48
+ files: [{ path: checkpointPath, before, host: "cursor", malformed: false, snapshot: true }],
49
+ });
50
+ }
124
51
  continue;
125
52
  }
126
- const transcript = await readTranscript(path);
127
- if (!transcript.cwd || transcript.events.length === 0) {
128
- summary.skipped++;
129
- if (!transcript.malformed)
130
- saveCheckpoint.run(path, before);
131
- else
132
- throw new Error("malformed or incomplete JSONL; will retry");
133
- continue;
53
+ for await (const path of transcriptFiles(source.directory)) {
54
+ summary.filesFound++;
55
+ try {
56
+ const before = await fingerprint(path, source.host);
57
+ if (checkpoint.get(path)?.fingerprint === before) {
58
+ summary.skipped++;
59
+ continue;
60
+ }
61
+ const transcript = await readTranscript(path, source.host);
62
+ if (transcript.malformed)
63
+ report(path, "malformed or incomplete JSONL; will retry");
64
+ if (!transcript.cwd || transcript.events.length === 0) {
65
+ summary.skipped++;
66
+ if (!transcript.malformed && await fingerprint(path, source.host) === before)
67
+ saveCheckpoint.run(path, before);
68
+ continue;
69
+ }
70
+ const projectId = deriveProjectId(transcript.cwd);
71
+ const key = JSON.stringify([transcript.host, projectId, transcript.sessionId]);
72
+ let group = groups.get(key);
73
+ if (!group) {
74
+ group = { host: transcript.host, projectId, sessionId: transcript.sessionId, events: new Map(), files: [] };
75
+ groups.set(key, group);
76
+ }
77
+ for (const event of transcript.events) {
78
+ if (!group.events.has(event.id))
79
+ group.events.set(event.id, event);
80
+ }
81
+ group.files.push({ path, before, host: source.host, malformed: transcript.malformed });
82
+ }
83
+ catch (error) {
84
+ report(path, error);
85
+ }
134
86
  }
135
- const projectId = deriveProjectId(transcript.cwd);
136
- const sessionId = transcript.sessionId;
137
- for (let offset = 0; offset < transcript.events.length; offset += MAX_INGEST_EVENTS) {
87
+ }
88
+ catch (error) {
89
+ report(source.directory, error);
90
+ }
91
+ }
92
+ for (const group of groups.values()) {
93
+ const { projectId, sessionId, host } = group;
94
+ try {
95
+ const events = [...group.events.values()];
96
+ for (let offset = 0; offset < events.length; offset += MAX_INGEST_EVENTS) {
138
97
  const result = await context.service.call("ingestSessionEvents", {
139
- projectId,
140
- sessionId,
141
- events: transcript.events.slice(offset, offset + MAX_INGEST_EVENTS).map(event => ({
142
- ...event, id: stableId(projectId, sessionId, event.id),
98
+ projectId, sessionId,
99
+ // Cursor edits the same bubble while streaming a response.
100
+ replaceExisting: host === "cursor",
101
+ events: events.slice(offset, offset + MAX_INGEST_EVENTS).map(event => ({
102
+ // Preserve existing Claude IDs for compatibility with earlier sweeps.
103
+ ...event, id: host === "claude-code" ? stableId(projectId, sessionId, event.id)
104
+ : stableId(host, projectId, sessionId, event.id),
143
105
  })),
144
106
  });
145
107
  if (!result.ok)
@@ -147,32 +109,30 @@ export async function sweepTranscripts(context, options = {}) {
147
109
  }
148
110
  const existing = context.db.prepare("SELECT id FROM memories WHERE project_id = ? AND session_id = ? AND kind = 'summary' LIMIT 1")
149
111
  .get(projectId, sessionId);
150
- // The pipeline upserts by project/session; keep existing hook summary IDs.
151
112
  const memoryId = existing?.id ?? stableId(projectId, sessionId, "summary");
152
113
  const result = await context.service.call("handleSessionEnd", {
153
- projectId,
154
- sessionId,
155
- memoryId,
156
- sourceAdapter: "claude-code",
114
+ projectId, sessionId, memoryId, sourceAdapter: host,
157
115
  config: handleSessionEndConfigSchema.parse({ minimumEventThreshold: 1, allowCloudSummarization: false }),
158
116
  });
159
117
  if (!result.ok)
160
118
  throw new Error(result.error.message);
161
119
  if (result.status !== "stored")
162
120
  throw new Error(`summary ${result.status}; will retry`);
163
- importedSessions.add(JSON.stringify([projectId, sessionId]));
164
- summary.sessionsImported = importedSessions.size;
121
+ summary.sessionsImported++;
165
122
  if (!existing)
166
123
  summary.memoriesCreated++;
167
- if (transcript.malformed)
168
- throw new Error("malformed or incomplete JSONL; will retry");
169
- // Never mark concurrent appends as processed. The next sweep replays safely.
170
- if (await fingerprint(path) === before)
171
- saveCheckpoint.run(path, before);
124
+ for (const file of group.files) {
125
+ // Never mark concurrent appends or incomplete files as processed.
126
+ // Cursor checkpoints describe the copied snapshot, so a newer live
127
+ // commit will be discovered by the next fresh read without a stat gate.
128
+ if (!file.malformed && (file.snapshot || await fingerprint(file.path, file.host) === file.before)) {
129
+ saveCheckpoint.run(file.path, file.before);
130
+ }
131
+ }
172
132
  }
173
133
  catch (error) {
174
- summary.errors++;
175
- process.stderr.write(`[sessionmem] ${path}: ${error instanceof Error ? error.message : String(error)}\n`);
134
+ for (const file of group.files)
135
+ report(file.path, error);
176
136
  }
177
137
  }
178
138
  return summary;
@@ -0,0 +1,62 @@
1
+ import { basename } from "node:path";
2
+ import { buildToolEvent } from "../../core/capture/toolEvent.js";
3
+ import { createdAt, object, unsupported } from "./types.js";
4
+ const METADATA_TYPES = new Set([
5
+ "file-history-snapshot", "queue-operation", "summary", "system", "progress",
6
+ "last-prompt", "pr-link", "custom-title", "agent-name", "agent-color",
7
+ ]);
8
+ export function isClaudeRecord(record) {
9
+ return ((record.type === "user" || record.type === "assistant") && !!object(record.message))
10
+ || METADATA_TYPES.has(String(record.type));
11
+ }
12
+ export function parseClaudeTranscript(path, records) {
13
+ const result = { host: "claude-code", sessionId: basename(path, ".jsonl"), events: [], malformed: false };
14
+ for (const { value: record, line } of records) {
15
+ if (!isClaudeRecord(record))
16
+ unsupported(result.host, `unrecognized record on line ${line + 1}`);
17
+ if (!result.cwd && typeof record.cwd === "string" && record.cwd.trim())
18
+ result.cwd = record.cwd;
19
+ // Claude subagent files reuse the parent's session ID.
20
+ if (!basename(path).startsWith("agent-") && typeof record.sessionId === "string") {
21
+ result.sessionId = record.sessionId;
22
+ }
23
+ if (record.type !== "user" && record.type !== "assistant")
24
+ continue;
25
+ const content = object(record.message)?.content;
26
+ if (typeof content !== "string" && !Array.isArray(content)) {
27
+ unsupported(result.host, `invalid message content on line ${line + 1}`);
28
+ }
29
+ const blocks = typeof content === "string" ? [{ type: "text", text: content }] : content;
30
+ for (const [blockIndex, rawBlock] of blocks.entries()) {
31
+ const block = object(rawBlock);
32
+ if (!block)
33
+ unsupported(result.host, `invalid message block on line ${line + 1}`);
34
+ const key = typeof record.uuid === "string" ? record.uuid : `line-${line}`;
35
+ const timestamp = createdAt(record.timestamp);
36
+ if (block.type === "tool_use") {
37
+ if (typeof block.name !== "string")
38
+ unsupported(result.host, `invalid tool use on line ${line + 1}`);
39
+ const event = buildToolEvent(block.name, object(block.input), result.cwd);
40
+ if (event)
41
+ result.events.push({
42
+ id: typeof block.id === "string" ? `tool-${block.id}` : `${key}-${blockIndex}`,
43
+ ...event, createdAt: timestamp,
44
+ });
45
+ }
46
+ else if (block.type === "text") {
47
+ if (typeof block.text !== "string")
48
+ unsupported(result.host, `invalid text on line ${line + 1}`);
49
+ if (block.text.trim())
50
+ result.events.push({
51
+ id: `${key}-${blockIndex}`, eventType: `message:${record.type}`, createdAt: timestamp,
52
+ payloadJson: JSON.stringify({ text: block.text.slice(0, 4000) }),
53
+ });
54
+ }
55
+ else if (!["tool_result", "image", "document", "thinking", "redacted_thinking",
56
+ "server_tool_use", "web_search_tool_result", "web_fetch_tool_result"].includes(String(block.type))) {
57
+ unsupported(result.host, `unrecognized message block on line ${line + 1}`);
58
+ }
59
+ }
60
+ }
61
+ return result;
62
+ }
@@ -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
+ }
@@ -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({
@@ -208,7 +208,7 @@ export function createMemoryCoreService(deps) {
208
208
  event_type: event.eventType,
209
209
  payload_json: redactedPayload,
210
210
  created_at: event.createdAt,
211
- });
211
+ }, parsed.replaceExisting);
212
212
  }
213
213
  return written;
214
214
  });
@@ -61,16 +61,17 @@ function describeToolUse(toolName, input) {
61
61
  * structured input and keep only the human-readable summary rather than
62
62
  * emitting truncated — and therefore unparseable — JSON.
63
63
  */
64
- function buildPayloadJson(toolName, text, input, cwd) {
64
+ function buildPayloadJson(toolName, text, input, cwd, callId) {
65
65
  const full = JSON.stringify({
66
66
  tool: toolName,
67
67
  text,
68
68
  input: shrinkValue(input),
69
69
  cwd,
70
+ ...(callId ? { call_id: callId } : {}),
70
71
  });
71
72
  if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
72
73
  return full;
73
- return JSON.stringify({ tool: toolName, text, cwd, truncated: true });
74
+ return JSON.stringify({ tool: toolName, text, cwd, ...(callId ? { call_id: callId } : {}), truncated: true });
74
75
  }
75
76
  export function buildToolEvent(toolName, input, cwd) {
76
77
  if (!CAPTURED_TOOL_TYPES.has(toolName))
@@ -80,3 +81,14 @@ export function buildToolEvent(toolName, input, cwd) {
80
81
  payloadJson: buildPayloadJson(toolName, describeToolUse(toolName, input), input, cwd),
81
82
  };
82
83
  }
84
+ /** Codex function calls retain their native name and call ID. */
85
+ export function buildFunctionCallEvent(name, input, callId, cwd) {
86
+ const command = input.command ?? input.cmd;
87
+ const text = ["exec_command", "shell_command", "shell"].includes(name)
88
+ ? describeToolUse("Bash", { command: Array.isArray(command) ? command.join(" ") : command })
89
+ : CAPTURED_TOOL_TYPES.has(name) ? describeToolUse(name, input) : truncate(`Called ${name}`, MAX_TEXT_CHARS);
90
+ return {
91
+ eventType: `tool_use:${name.toLowerCase()}`,
92
+ payloadJson: buildPayloadJson(name, text, input, cwd, callId),
93
+ };
94
+ }
@@ -4,6 +4,11 @@ function getSessionEventsStatements(db) {
4
4
  if (stmts)
5
5
  return stmts;
6
6
  stmts = {
7
+ updateEvent: db.prepare(`
8
+ UPDATE session_events SET event_type = @event_type, payload_json = @payload_json
9
+ WHERE id = @id AND project_id = @project_id AND session_id = @session_id
10
+ AND (event_type != @event_type OR payload_json != @payload_json)
11
+ `),
7
12
  // INSERT OR IGNORE so re-ingesting an event with the same logical key
8
13
  // (project_id, session_id, event_index) — now a UNIQUE index, migration 009
9
14
  // — is a no-op rather than a duplicate row or a PK error.
@@ -34,10 +39,14 @@ function getSessionEventsStatements(db) {
34
39
  /**
35
40
  * Insert a session event. Returns the number of rows written (1, or 0 when the
36
41
  * (project_id, session_id, event_index) key already exists and the insert was
37
- * ignored).
42
+ * ignored). Mutable transcript imports can opt in to refreshing the content of
43
+ * an existing ID in the same project/session, preserving its index and date.
44
+ * Returns 1 for either an insertion or a content update; unchanged replays are 0.
38
45
  */
39
- export function insertSessionEvent(db, input) {
40
- return getSessionEventsStatements(db).insertEvent.run(input).changes;
46
+ export function insertSessionEvent(db, input, replaceExisting = false) {
47
+ const statements = getSessionEventsStatements(db);
48
+ const updated = replaceExisting ? statements.updateEvent.run(input).changes : 0;
49
+ return updated + statements.insertEvent.run(input).changes;
41
50
  }
42
51
  /**
43
52
  * Next free `event_index` for a session (max + 1, or 0 when the session has no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessionmem",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local-first MCP memory layer for coding agents across Claude Code, Codex, Cursor, Cline, Windsurf, and other MCP-compatible hosts.",