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.
- package/CHANGELOG.md +43 -0
- package/README.md +148 -79
- package/dist/cli/autostart/index.js +246 -0
- package/dist/cli/commands/autostart.js +24 -0
- package/dist/cli/commands/config.js +1 -0
- package/dist/cli/commands/doctor.js +28 -0
- package/dist/cli/commands/install.js +10 -0
- package/dist/cli/commands/uninstall.js +4 -0
- package/dist/cli/commands/watch.js +14 -2
- package/dist/cli/index.js +16 -7
- 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/cli/watchCheckpoint.js +35 -0
- package/dist/cli/watchLog.js +23 -0
- package/dist/core/api/contracts.js +3 -0
- package/dist/core/api/memoryCoreService.js +37 -1
- package/dist/core/capture/toolEvent.js +14 -2
- package/dist/core/config/policyConfig.js +3 -0
- package/dist/core/embed/localSemanticEmbedder.js +62 -0
- package/dist/core/retrieve/retrieveMemories.js +18 -2
- package/dist/core/schema/migrations/016_semantic_embeddings.sql +12 -0
- package/dist/core/storage/memorySearchRepo.js +11 -0
- package/dist/core/storage/semanticSearchRepo.js +56 -0
- package/dist/core/storage/sessionEventsRepo.js +12 -3
- package/docs/architecture.md +13 -1
- package/docs/benchmark.md +9 -0
- package/docs/local-semantic.md +56 -0
- package/docs/research/automatic-capture.md +62 -0
- package/docs/troubleshooting.md +5 -0
- 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
|
|
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
|
|
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
|
|
64
|
-
.option("--transcripts-dir <path>", "
|
|
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
|
|
69
|
-
.option("--transcripts-dir <path>", "
|
|
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
|
|
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
|
|
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("
|
|
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 {
|
|
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
|
+
}
|