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 +22 -0
- package/README.md +70 -23
- package/dist/cli/commands/doctor.js +9 -0
- package/dist/cli/index.js +7 -5
- package/dist/cli/transcriptSweep.js +103 -143
- package/dist/cli/transcripts/claudeCode.js +62 -0
- package/dist/cli/transcripts/codex.js +111 -0
- package/dist/cli/transcripts/cursor.js +295 -0
- package/dist/cli/transcripts/index.js +118 -0
- package/dist/cli/transcripts/types.js +15 -0
- package/dist/core/api/contracts.js +3 -0
- package/dist/core/api/memoryCoreService.js +1 -1
- package/dist/core/capture/toolEvent.js +14 -2
- package/dist/core/storage/sessionEventsRepo.js +12 -3
- package/package.json +1 -1
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
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
325
|
-
skipped across restarts.
|
|
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,
|
|
515
|
-
|
|
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
|
|
64
|
-
.option("--transcripts-dir <path>", "
|
|
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
|
|
69
|
-
.option("--transcripts-dir <path>", "
|
|
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
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
14
|
+
const sources = transcriptSources(options);
|
|
114
15
|
const summary = { filesFound: 0, sessionsImported: 0, memoriesCreated: 0, skipped: 0, errors: 0 };
|
|
115
|
-
const
|
|
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
|
-
|
|
119
|
-
summary.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
164
|
-
summary.sessionsImported = importedSessions.size;
|
|
121
|
+
summary.sessionsImported++;
|
|
165
122
|
if (!existing)
|
|
166
123
|
summary.memoriesCreated++;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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({
|
|
@@ -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
|
-
|
|
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