sessionmem 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +148 -79
  3. package/dist/cli/autostart/index.js +246 -0
  4. package/dist/cli/commands/autostart.js +24 -0
  5. package/dist/cli/commands/config.js +1 -0
  6. package/dist/cli/commands/doctor.js +28 -0
  7. package/dist/cli/commands/install.js +10 -0
  8. package/dist/cli/commands/uninstall.js +4 -0
  9. package/dist/cli/commands/watch.js +14 -2
  10. package/dist/cli/index.js +16 -7
  11. package/dist/cli/transcriptSweep.js +103 -143
  12. package/dist/cli/transcripts/claudeCode.js +62 -0
  13. package/dist/cli/transcripts/codex.js +111 -0
  14. package/dist/cli/transcripts/cursor.js +295 -0
  15. package/dist/cli/transcripts/index.js +118 -0
  16. package/dist/cli/transcripts/types.js +15 -0
  17. package/dist/cli/watchCheckpoint.js +35 -0
  18. package/dist/cli/watchLog.js +23 -0
  19. package/dist/core/api/contracts.js +3 -0
  20. package/dist/core/api/memoryCoreService.js +37 -1
  21. package/dist/core/capture/toolEvent.js +14 -2
  22. package/dist/core/config/policyConfig.js +3 -0
  23. package/dist/core/embed/localSemanticEmbedder.js +62 -0
  24. package/dist/core/retrieve/retrieveMemories.js +18 -2
  25. package/dist/core/schema/migrations/016_semantic_embeddings.sql +12 -0
  26. package/dist/core/storage/memorySearchRepo.js +11 -0
  27. package/dist/core/storage/semanticSearchRepo.js +56 -0
  28. package/dist/core/storage/sessionEventsRepo.js +12 -3
  29. package/docs/architecture.md +13 -1
  30. package/docs/benchmark.md +9 -0
  31. package/docs/local-semantic.md +56 -0
  32. package/docs/research/automatic-capture.md +62 -0
  33. package/docs/troubleshooting.md +5 -0
  34. package/package.json +21 -2
@@ -0,0 +1,24 @@
1
+ import { createAutostart, invalidLaunchPaths } from "../autostart/index.js";
2
+ export function autostartCommand(action, service) {
3
+ if (!["enable", "disable", "status"].includes(action))
4
+ throw new Error("Use sessionmem autostart enable, disable, or status");
5
+ const autostart = service ?? createAutostart();
6
+ if (action === "status") {
7
+ const status = autostart.status();
8
+ console.log(`Auto-start ${status.registered ? "enabled" : "disabled"}: ${status.detail}`);
9
+ if (status.launch) {
10
+ console.log(`Binary: ${status.launch.command}\nEntry point: ${status.launch.args[0]}`);
11
+ const invalid = invalidLaunchPaths(status.launch);
12
+ if (invalid.length) {
13
+ console.error(`Invalid binary path: ${invalid.join(", ")}. Run sessionmem autostart enable.`);
14
+ process.exitCode = 1;
15
+ }
16
+ }
17
+ return;
18
+ }
19
+ if (action === "enable")
20
+ autostart.enable();
21
+ else
22
+ autostart.disable();
23
+ console.log(`Auto-start ${action === "enable" ? "enabled and started" : "disabled and stopped"}`);
24
+ }
@@ -42,6 +42,7 @@ const CONFIG_KEYS = {
42
42
  retentionDays: { field: "retentionDays", coerce: coerceRetentionDays },
43
43
  redactionEnabled: { field: "redactionEnabled", coerce: coerceBool },
44
44
  injectionCap: { field: "injectionCap", coerce: coerceInjectionCap },
45
+ semanticSearch: { field: "semanticSearch", coerce: coerceBool },
45
46
  };
46
47
  function resolvePath(options) {
47
48
  return options?.configPath ?? configFilePath();
@@ -1,3 +1,5 @@
1
+ import { createAutostart, invalidLaunchPaths } from "../autostart/index.js";
2
+ import { inspectWatchCheckpoint } from "../watchCheckpoint.js";
1
3
  import BetterSqlite3 from "better-sqlite3";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { createRequire } from "node:module";
@@ -7,6 +9,7 @@ import { ADAPTER_NAMES, AdapterFactory } from "../../adapters/factory.js";
7
9
  import { createMemoryCoreService } from "../../core/api/memoryCoreService.js";
8
10
  import { inspectMigrations } from "../../core/schema/runMigrations.js";
9
11
  import { resolveDbPath } from "../context.js";
12
+ import { inspectTranscriptDiscovery, TRANSCRIPT_HOSTS } from "../transcripts/index.js";
10
13
  const VERSION = createRequire(import.meta.url)("../../../package.json").version;
11
14
  /** Exercise real storage, embedding, ranking and injection without retaining data. */
12
15
  export async function probeRecall(db) {
@@ -53,6 +56,31 @@ export async function doctorCommand(options, overrides = {}) {
53
56
  report("Agent hosts", false, message(err), "Install a supported host or select one with sessionmem doctor --adapter <name>");
54
57
  }
55
58
  const dbPath = resolveDbPath(overrides.dbPath);
59
+ const captureFix = "Run sessionmem autostart enable; inspect logs/watch.log beside the database and OS service logs, then run sessionmem doctor";
60
+ try {
61
+ const registration = createAutostart({ dbPath }).status();
62
+ report("Auto-start registration", registration.registered, registration.detail, registration.registered ? undefined : captureFix);
63
+ const invalid = registration.launch ? invalidLaunchPaths(registration.launch) : ["No registered binary"];
64
+ report("Auto-start binary", invalid.length === 0, invalid.length ? invalid.join(", ") : "Registered Node binary and sessionmem entry point are readable", invalid.length ? "Reinstall sessionmem if needed, then run sessionmem autostart enable" : undefined);
65
+ }
66
+ catch (err) {
67
+ report("Auto-start registration", false, message(err), captureFix);
68
+ report("Auto-start binary", false, "Cannot inspect the registration", captureFix);
69
+ }
70
+ try {
71
+ report("Watcher capture", true, inspectWatchCheckpoint(dbPath));
72
+ }
73
+ catch (err) {
74
+ report("Watcher capture", false, message(err), captureFix);
75
+ }
76
+ for (const host of TRANSCRIPT_HOSTS) {
77
+ try {
78
+ report(`${host} transcripts`, true, await inspectTranscriptDiscovery(host));
79
+ }
80
+ catch (err) {
81
+ report(`${host} transcripts`, false, message(err), `Check transcript directory permissions and format; run sessionmem scan --host ${host}`);
82
+ }
83
+ }
56
84
  let db;
57
85
  let schemaOk = false;
58
86
  try {
@@ -1,3 +1,4 @@
1
+ import { createAutostart } from "../autostart/index.js";
1
2
  import { existsSync } from "fs";
2
3
  import { join } from "path";
3
4
  import { homedir } from "os";
@@ -87,6 +88,15 @@ export async function installCommand(options, contextOverrides) {
87
88
  console.log("✓ Claude Code: SessionStart, SessionEnd, PostToolUse hooks configured (~/.claude/settings.json)");
88
89
  }
89
90
  }
91
+ try {
92
+ createAutostart({ dbPath: dbPath }).enable();
93
+ console.log("Auto-start enabled and started; capture logs are in logs/watch.log beside the database");
94
+ }
95
+ catch (err) {
96
+ console.error(`Auto-start setup failed: ${err instanceof Error ? err.message : String(err)}`);
97
+ console.error("Fix the OS login service error, then run sessionmem autostart enable. Run sessionmem doctor to check capture.");
98
+ failed = true;
99
+ }
90
100
  if (failed)
91
101
  process.exit(1);
92
102
  console.log("✓ sessionmem ready — restart your editor/agent so it picks up the MCP server");
@@ -1,3 +1,4 @@
1
+ import { createAutostart } from "../autostart/index.js";
1
2
  import { existsSync, rmSync } from "fs";
2
3
  import { join } from "path";
3
4
  import { homedir } from "os";
@@ -28,6 +29,9 @@ export async function uninstallCommand(options = {}) {
28
29
  console.error(err instanceof Error ? err.message : String(err));
29
30
  process.exit(1);
30
31
  }
32
+ // Stop capture before removing integrations or purging an open database.
33
+ createAutostart({ dbPath: options.dbPath }).disable();
34
+ console.log("Auto-start removed and watcher stopped");
31
35
  if (!adapter.uninstall) {
32
36
  console.error(`${adapter.name} does not support automated uninstall. Remove sessionmem from your MCP config manually.`);
33
37
  process.exit(1);
@@ -1,3 +1,5 @@
1
+ import { openWatchLog } from "../watchLog.js";
2
+ import { saveWatchCheckpoint } from "../watchCheckpoint.js";
1
3
  import { setTimeout } from "node:timers/promises";
2
4
  import { createCliContext } from "../context.js";
3
5
  import { printSweepSummary, sweepTranscripts } from "../transcriptSweep.js";
@@ -6,16 +8,20 @@ export async function watchCommand(options = {}, ctx) {
6
8
  if (!Number.isFinite(interval) || interval <= 0 || interval > 86400) {
7
9
  throw new Error("--interval must be a number greater than 0 and at most 86400 seconds");
8
10
  }
9
- const context = ctx ?? createCliContext();
11
+ const closeLog = openWatchLog(options.logFile);
12
+ let context;
10
13
  const controller = new AbortController();
11
14
  const stop = () => controller.abort();
12
15
  process.once("SIGINT", stop);
13
16
  process.once("SIGTERM", stop);
14
17
  let firstSweep = true;
15
18
  try {
19
+ console.log(`sessionmem watch starting (pid ${process.pid}, interval ${interval}s)`);
20
+ context = ctx ?? createCliContext({ dbPath: options.dbPath });
16
21
  do {
17
22
  try {
18
23
  const summary = await sweepTranscripts(context, options);
24
+ saveWatchCheckpoint(context, summary, interval);
19
25
  if (firstSweep || options.once || summary.sessionsImported || summary.errors) {
20
26
  printSweepSummary("watch", summary);
21
27
  }
@@ -39,10 +45,16 @@ export async function watchCommand(options = {}, ctx) {
39
45
  }
40
46
  } while (!controller.signal.aborted);
41
47
  }
48
+ catch (error) {
49
+ console.error(`[sessionmem] watch failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
50
+ throw error;
51
+ }
42
52
  finally {
43
53
  process.removeListener("SIGINT", stop);
44
54
  process.removeListener("SIGTERM", stop);
45
55
  if (!ctx)
46
- context.db.close();
56
+ context?.db.close();
57
+ console.log("sessionmem watch stopped");
58
+ closeLog();
47
59
  }
48
60
  }
package/dist/cli/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { autostartCommand } from "./commands/autostart.js";
2
3
  import { Command } from "commander";
3
4
  import { createRequire } from "node:module";
4
5
  import { runMcpServer } from "./commands/run.js";
@@ -60,29 +61,37 @@ program
60
61
  .action(() => ingestHookCommand());
61
62
  program
62
63
  .command("scan")
63
- .description("Import existing Claude Code transcripts using local capture and summarization")
64
- .option("--transcripts-dir <path>", "Transcript directory (default: ~/.claude/projects)")
64
+ .description("Import Claude Code, Codex, and Cursor transcripts using local capture and summarization")
65
+ .option("--transcripts-dir <path>", "Override transcript roots (Cursor: User directory or state.vscdb)")
66
+ .option("--host <host>", "Select a transcript host: claude-code, codex, or cursor")
65
67
  .action(async (options) => { await scanCommand(options); });
66
68
  program
67
69
  .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)")
70
+ .description("Poll Claude Code, Codex, and Cursor transcripts and capture sessions offline")
71
+ .option("--transcripts-dir <path>", "Override transcript roots (Cursor: User directory or state.vscdb)")
72
+ .option("--host <host>", "Select a transcript host: claude-code, codex, or cursor")
73
+ .option("--db-path <path>", "Database file for headless capture")
74
+ .option("--log-file <path>", "Append timestamped stdout, stderr and startup failures to a file")
70
75
  .option("--once", "Run one sweep and exit")
71
76
  .option("--interval <seconds>", "Seconds between sweeps", "5")
72
77
  .action((options) => watchCommand(options));
73
78
  program
74
79
  .command("install")
75
- .description("Install sessionmem into every detected agent host")
80
+ .description("Install integrations and enable login transcript capture")
76
81
  .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
77
82
  .action((options) => installCommand(options));
83
+ program
84
+ .command("autostart <action>")
85
+ .description("Control login capture: enable, disable, or status")
86
+ .action((action) => autostartCommand(action));
78
87
  program
79
88
  .command("doctor")
80
- .description("Check the local store, schema, hooks, adapter wiring, and recall offline")
89
+ .description("Check login capture, storage, transcripts, hooks, MCP wiring, and recall offline")
81
90
  .option("--adapter <name>", "Check a specific host instead of installed-host detection")
82
91
  .action(async (options) => { await doctorCommand(options); });
83
92
  program
84
93
  .command("uninstall")
85
- .description("Remove sessionmem from the current MCP host")
94
+ .description("Stop login capture and remove sessionmem from the current MCP host")
86
95
  .option("--purge", "Also delete the local memories database")
87
96
  .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
88
97
  .action((options) => uninstallCommand(options));
@@ -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
+ }