sessionmem 1.4.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 +66 -0
- package/README.md +233 -41
- package/dist/adapters/diagnostics.js +109 -0
- package/dist/adapters/generic.js +6 -2
- package/dist/adapters/global/antigravity.js +4 -1
- package/dist/adapters/global/claudeCode.js +19 -4
- package/dist/adapters/global/codex.js +7 -6
- package/dist/adapters/global/qcoder.js +4 -1
- package/dist/adapters/ide/cline.js +3 -0
- package/dist/adapters/ide/cursor.js +3 -0
- package/dist/adapters/ide/installer.js +17 -5
- package/dist/adapters/ide/windsurf.js +3 -0
- package/dist/adapters/installedHosts.js +51 -0
- package/dist/cli/commands/doctor.js +155 -0
- package/dist/cli/commands/entities.js +27 -0
- package/dist/cli/commands/history.js +23 -0
- package/dist/cli/commands/ingestHook.js +4 -78
- package/dist/cli/commands/install.js +34 -56
- package/dist/cli/commands/scan.js +16 -0
- package/dist/cli/commands/search.js +1 -0
- package/dist/cli/commands/team.js +7 -3
- package/dist/cli/commands/ui.js +67 -0
- package/dist/cli/commands/watch.js +48 -0
- package/dist/cli/index.js +44 -1
- package/dist/cli/projectId.js +1 -2
- package/dist/cli/transcriptSweep.js +142 -0
- 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 +4 -0
- package/dist/core/api/memoryCoreService.js +9 -1
- package/dist/core/api/sessionLifecycleService.js +13 -1
- package/dist/core/capture/toolEvent.js +94 -0
- package/dist/core/extract/factClaims.js +97 -0
- package/dist/core/extract/knowledge.js +127 -0
- package/dist/core/retrieve/retrieveMemories.js +20 -4
- package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
- package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
- package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
- package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
- package/dist/core/schema/runMigrations.js +46 -0
- package/dist/core/storage/knowledgeRepo.js +67 -0
- package/dist/core/storage/memoryRepo.js +45 -12
- package/dist/core/storage/memorySearchRepo.js +30 -20
- package/dist/core/storage/sessionEventsRepo.js +12 -3
- package/dist/core/storage/supersessionRepo.js +101 -0
- package/dist/core/summarize/summaryShape.js +1 -1
- package/dist/ui/assets.js +181 -0
- package/dist/ui/server.js +147 -0
- package/docs/REGISTRY_LISTING.md +88 -0
- package/docs/architecture.md +27 -3
- package/docs/benchmark.md +105 -33
- package/docs/migration.md +2 -0
- package/docs/troubleshooting.md +52 -4
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { join } from "path";
|
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
4
|
import { configFilePath, readPolicyConfig, writePolicyConfig, } from "../../core/config/policyConfig.js";
|
|
5
5
|
import { createCliContext } from "../context.js";
|
|
6
|
+
import { reconcileProjectSupersessions } from "../../core/storage/supersessionRepo.js";
|
|
6
7
|
function resolvePath(options) {
|
|
7
8
|
return options?.configPath ?? configFilePath();
|
|
8
9
|
}
|
|
@@ -89,8 +90,11 @@ export function teamDisableCommand(options, ctx) {
|
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
92
|
const context = ctx ?? createCliContext();
|
|
92
|
-
const result = context.db
|
|
93
|
-
.prepare("DELETE FROM memories WHERE project_id = ? AND author != ? AND author != ''")
|
|
94
|
-
|
|
93
|
+
const result = context.db.transaction(() => {
|
|
94
|
+
const removed = context.db.prepare("DELETE FROM memories WHERE project_id = ? AND author != ? AND author != ''").run(context.projectId, context.username);
|
|
95
|
+
if (removed.changes)
|
|
96
|
+
reconcileProjectSupersessions(context.db, context.projectId);
|
|
97
|
+
return removed;
|
|
98
|
+
})();
|
|
95
99
|
console.log(`Team mode disabled. Removed ${result.changes} teammate-authored ${result.changes === 1 ? "memory" : "memories"}.`);
|
|
96
100
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { createMemoryCoreService } from "../../core/api/memoryCoreService.js";
|
|
5
|
+
import { deriveProjectId } from "../projectId.js";
|
|
6
|
+
import { resolveDbPath } from "../context.js";
|
|
7
|
+
import { inspectMigrations } from "../../core/schema/runMigrations.js";
|
|
8
|
+
import { parseUiPort, startUiServer } from "../../ui/server.js";
|
|
9
|
+
/** Open the OS browser without a shell or an additional runtime dependency. */
|
|
10
|
+
export function openBrowser(url) {
|
|
11
|
+
const command = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
12
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
13
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
14
|
+
const failed = () => console.error("Could not open a browser. Open the printed URL manually.");
|
|
15
|
+
child.once("error", failed);
|
|
16
|
+
child.once("exit", code => { if (code)
|
|
17
|
+
failed(); });
|
|
18
|
+
child.unref();
|
|
19
|
+
}
|
|
20
|
+
export async function uiCommand(options = {}) {
|
|
21
|
+
const port = parseUiPort(options.port);
|
|
22
|
+
// Unlike the normal CLI context, a viewer must not create, migrate or write the store.
|
|
23
|
+
let db;
|
|
24
|
+
try {
|
|
25
|
+
db = new BetterSqlite3(resolveDbPath(), { readonly: true, fileMustExist: true });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error("Cannot open the memory store. Run sessionmem ping first, or check SESSIONMEM_DB_PATH.");
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
db.pragma("query_only = ON");
|
|
32
|
+
db.pragma("busy_timeout = 5000");
|
|
33
|
+
const migrations = inspectMigrations(db);
|
|
34
|
+
if (migrations.unknown.length) {
|
|
35
|
+
throw new Error("The memory store uses an unrecognized schema. Use the sessionmem version that created it.");
|
|
36
|
+
}
|
|
37
|
+
if (migrations.pending.length) {
|
|
38
|
+
throw new Error("The memory store needs an upgrade. Run sessionmem ping first to apply pending migrations.");
|
|
39
|
+
}
|
|
40
|
+
const viewer = await startUiServer({ db, service: createMemoryCoreService({ db }), projectId: deriveProjectId() }, port);
|
|
41
|
+
let stopping = false;
|
|
42
|
+
const stop = () => {
|
|
43
|
+
if (stopping)
|
|
44
|
+
return;
|
|
45
|
+
stopping = true;
|
|
46
|
+
void viewer.close().catch(error => {
|
|
47
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
process.once("SIGINT", stop);
|
|
52
|
+
process.once("SIGTERM", stop);
|
|
53
|
+
try {
|
|
54
|
+
const closed = once(viewer.server, "close");
|
|
55
|
+
console.log(`sessionmem viewer: ${viewer.url}\nRead-only. Press Ctrl-C to stop.`);
|
|
56
|
+
openBrowser(viewer.url);
|
|
57
|
+
await closed;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
process.removeListener("SIGINT", stop);
|
|
61
|
+
process.removeListener("SIGTERM", stop);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
2
|
+
import { createCliContext } from "../context.js";
|
|
3
|
+
import { printSweepSummary, sweepTranscripts } from "../transcriptSweep.js";
|
|
4
|
+
export async function watchCommand(options = {}, ctx) {
|
|
5
|
+
const interval = Number(options.interval ?? 5);
|
|
6
|
+
if (!Number.isFinite(interval) || interval <= 0 || interval > 86400) {
|
|
7
|
+
throw new Error("--interval must be a number greater than 0 and at most 86400 seconds");
|
|
8
|
+
}
|
|
9
|
+
const context = ctx ?? createCliContext();
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const stop = () => controller.abort();
|
|
12
|
+
process.once("SIGINT", stop);
|
|
13
|
+
process.once("SIGTERM", stop);
|
|
14
|
+
let firstSweep = true;
|
|
15
|
+
try {
|
|
16
|
+
do {
|
|
17
|
+
try {
|
|
18
|
+
const summary = await sweepTranscripts(context, options);
|
|
19
|
+
if (firstSweep || options.once || summary.sessionsImported || summary.errors) {
|
|
20
|
+
printSweepSummary("watch", summary);
|
|
21
|
+
}
|
|
22
|
+
firstSweep = false;
|
|
23
|
+
if (options.once && summary.errors)
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (options.once)
|
|
28
|
+
throw error;
|
|
29
|
+
console.error(`[sessionmem] watch: ${error instanceof Error ? error.message : String(error)}`);
|
|
30
|
+
}
|
|
31
|
+
if (options.once || controller.signal.aborted)
|
|
32
|
+
break;
|
|
33
|
+
try {
|
|
34
|
+
await setTimeout(interval * 1000, undefined, { signal: controller.signal });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!controller.signal.aborted)
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
} while (!controller.signal.aborted);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
process.removeListener("SIGINT", stop);
|
|
44
|
+
process.removeListener("SIGTERM", stop);
|
|
45
|
+
if (!ctx)
|
|
46
|
+
context.db.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { runMcpServer } from "./commands/run.js";
|
|
|
5
5
|
import { sessionStartCommand } from "./commands/sessionStart.js";
|
|
6
6
|
import { sessionEndCommand } from "./commands/sessionEnd.js";
|
|
7
7
|
import { installCommand } from "./commands/install.js";
|
|
8
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
8
9
|
import { uninstallCommand } from "./commands/uninstall.js";
|
|
9
10
|
import { pingCommand } from "./commands/ping.js";
|
|
10
11
|
import { searchCommand } from "./commands/search.js";
|
|
@@ -24,6 +25,11 @@ import { reEmbedCommand } from "./commands/reEmbed.js";
|
|
|
24
25
|
import { ingestHookCommand } from "./commands/ingestHook.js";
|
|
25
26
|
import { dedupeCommand } from "./commands/dedupe.js";
|
|
26
27
|
import { browseCommand } from "./commands/browse.js";
|
|
28
|
+
import { scanCommand } from "./commands/scan.js";
|
|
29
|
+
import { watchCommand } from "./commands/watch.js";
|
|
30
|
+
import { entitiesCommand } from "./commands/entities.js";
|
|
31
|
+
import { historyCommand } from "./commands/history.js";
|
|
32
|
+
import { uiCommand } from "./commands/ui.js";
|
|
27
33
|
// Source the version from package.json (single source of truth) so `--version`
|
|
28
34
|
// never drifts from the published manifest. createRequire + resolveJsonModule
|
|
29
35
|
// reads the manifest relative to this module; from dist/cli/index.js the
|
|
@@ -35,6 +41,11 @@ program
|
|
|
35
41
|
.command("run")
|
|
36
42
|
.description("Start the sessionmem MCP server")
|
|
37
43
|
.action(runMcpServer);
|
|
44
|
+
program
|
|
45
|
+
.command("ui")
|
|
46
|
+
.description("Open a local read-only web viewer for the memory store")
|
|
47
|
+
.option("--port <N>", "Local port (default: 3777; 0 selects a free port)")
|
|
48
|
+
.action((options) => uiCommand(options));
|
|
38
49
|
program
|
|
39
50
|
.command("session-start")
|
|
40
51
|
.description("Print prior memory context for the current project as Claude Code SessionStart hook output (invoked automatically by the hook installed during `sessionmem install`)")
|
|
@@ -47,11 +58,30 @@ program
|
|
|
47
58
|
.command("ingest-hook")
|
|
48
59
|
.description("Process a Claude Code PostToolUse hook payload from stdin and store it as a session event (invoked automatically by the hook installed during `sessionmem install`)")
|
|
49
60
|
.action(() => ingestHookCommand());
|
|
61
|
+
program
|
|
62
|
+
.command("scan")
|
|
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")
|
|
66
|
+
.action(async (options) => { await scanCommand(options); });
|
|
67
|
+
program
|
|
68
|
+
.command("watch")
|
|
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")
|
|
72
|
+
.option("--once", "Run one sweep and exit")
|
|
73
|
+
.option("--interval <seconds>", "Seconds between sweeps", "5")
|
|
74
|
+
.action((options) => watchCommand(options));
|
|
50
75
|
program
|
|
51
76
|
.command("install")
|
|
52
|
-
.description("Install sessionmem into
|
|
77
|
+
.description("Install sessionmem into every detected agent host")
|
|
53
78
|
.option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
|
|
54
79
|
.action((options) => installCommand(options));
|
|
80
|
+
program
|
|
81
|
+
.command("doctor")
|
|
82
|
+
.description("Check storage, transcript discovery, hooks, MCP wiring, and recall offline")
|
|
83
|
+
.option("--adapter <name>", "Check a specific host instead of installed-host detection")
|
|
84
|
+
.action(async (options) => { await doctorCommand(options); });
|
|
55
85
|
program
|
|
56
86
|
.command("uninstall")
|
|
57
87
|
.description("Remove sessionmem from the current MCP host")
|
|
@@ -75,11 +105,24 @@ program
|
|
|
75
105
|
.command("search <query>")
|
|
76
106
|
.description("Search memories by semantic query")
|
|
77
107
|
.option("--limit <n>", "Maximum number of results (1-100)", "10")
|
|
108
|
+
.option("--include-history", "Include superseded facts")
|
|
78
109
|
.action((query, options) => searchCommand(query, options));
|
|
79
110
|
program
|
|
80
111
|
.command("list")
|
|
81
112
|
.description("List up to 200 recent memories for the current project")
|
|
82
113
|
.action(() => listCommand());
|
|
114
|
+
program
|
|
115
|
+
.command("entities")
|
|
116
|
+
.description("List extracted entities, typed relations, and source memory IDs for a project")
|
|
117
|
+
.option("--project <id>", "Project ID (default: current project)")
|
|
118
|
+
.option("--json", "Output entities and relations as JSON")
|
|
119
|
+
.action((options) => entitiesCommand(options));
|
|
120
|
+
program
|
|
121
|
+
.command("history")
|
|
122
|
+
.description("List supersession chains, source memory IDs, and timestamps")
|
|
123
|
+
.option("--project <id>", "Project ID (default: current project)")
|
|
124
|
+
.option("--json", "Output supersession links as JSON")
|
|
125
|
+
.action((options) => historyCommand(options));
|
|
83
126
|
program
|
|
84
127
|
.command("show <id>")
|
|
85
128
|
.description("Show memory details by ID (content capped at 2000 characters)")
|
package/dist/cli/projectId.js
CHANGED
|
@@ -92,7 +92,7 @@ export function projectIdFromCwd(cwd) {
|
|
|
92
92
|
* bucket. The id is unchanged for anyone already running at the repo root,
|
|
93
93
|
* since the root IS the cwd in that case.
|
|
94
94
|
*/
|
|
95
|
-
export function deriveProjectId() {
|
|
95
|
+
export function deriveProjectId(cwd = process.cwd()) {
|
|
96
96
|
const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
|
|
97
97
|
if (envProjectId && envProjectId.trim() !== "") {
|
|
98
98
|
// Sanitize the operator-supplied id to prevent path traversal: the id is
|
|
@@ -110,6 +110,5 @@ export function deriveProjectId() {
|
|
|
110
110
|
return sanitized;
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
-
const cwd = process.cwd();
|
|
114
113
|
return projectIdFromCwd(findProjectRoot(cwd) ?? cwd);
|
|
115
114
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { handleSessionEndConfigSchema, MAX_INGEST_EVENTS } from "../core/api/contracts.js";
|
|
3
|
+
import { deriveProjectId } from "./projectId.js";
|
|
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) {
|
|
8
|
+
const info = await stat(path, { bigint: true });
|
|
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}`;
|
|
12
|
+
}
|
|
13
|
+
export async function sweepTranscripts(context, options = {}) {
|
|
14
|
+
const sources = transcriptSources(options);
|
|
15
|
+
const summary = { filesFound: 0, sessionsImported: 0, memoriesCreated: 0, skipped: 0, errors: 0 };
|
|
16
|
+
const groups = new Map();
|
|
17
|
+
const checkpoint = context.db.prepare("SELECT fingerprint FROM transcript_checkpoints WHERE path = ?");
|
|
18
|
+
const saveCheckpoint = context.db.prepare("INSERT OR REPLACE INTO transcript_checkpoints (path, fingerprint) VALUES (?, ?)");
|
|
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) {
|
|
24
|
+
try {
|
|
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
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
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
|
+
}
|
|
86
|
+
}
|
|
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) {
|
|
97
|
+
const result = await context.service.call("ingestSessionEvents", {
|
|
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),
|
|
105
|
+
})),
|
|
106
|
+
});
|
|
107
|
+
if (!result.ok)
|
|
108
|
+
throw new Error(result.error.message);
|
|
109
|
+
}
|
|
110
|
+
const existing = context.db.prepare("SELECT id FROM memories WHERE project_id = ? AND session_id = ? AND kind = 'summary' LIMIT 1")
|
|
111
|
+
.get(projectId, sessionId);
|
|
112
|
+
const memoryId = existing?.id ?? stableId(projectId, sessionId, "summary");
|
|
113
|
+
const result = await context.service.call("handleSessionEnd", {
|
|
114
|
+
projectId, sessionId, memoryId, sourceAdapter: host,
|
|
115
|
+
config: handleSessionEndConfigSchema.parse({ minimumEventThreshold: 1, allowCloudSummarization: false }),
|
|
116
|
+
});
|
|
117
|
+
if (!result.ok)
|
|
118
|
+
throw new Error(result.error.message);
|
|
119
|
+
if (result.status !== "stored")
|
|
120
|
+
throw new Error(`summary ${result.status}; will retry`);
|
|
121
|
+
summary.sessionsImported++;
|
|
122
|
+
if (!existing)
|
|
123
|
+
summary.memoriesCreated++;
|
|
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
for (const file of group.files)
|
|
135
|
+
report(file.path, error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return summary;
|
|
139
|
+
}
|
|
140
|
+
export function printSweepSummary(command, summary) {
|
|
141
|
+
console.log(`sessionmem ${command}: files found: ${summary.filesFound}; sessions imported: ${summary.sessionsImported}; memories created: ${summary.memoriesCreated}; skipped: ${summary.skipped}; errors: ${summary.errors}`);
|
|
142
|
+
}
|
|
@@ -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
|
+
}
|