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
|
@@ -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({
|
|
@@ -160,6 +163,7 @@ export const retrieveMemoriesRequestSchema = z.object({
|
|
|
160
163
|
limit: z.number().int().min(1).max(100).default(20),
|
|
161
164
|
mode: z.enum(["auto", "on-demand"]).default("auto"),
|
|
162
165
|
depth: z.enum(["default", "deep"]).default("default"),
|
|
166
|
+
includeHistory: z.boolean().optional(),
|
|
163
167
|
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
164
168
|
});
|
|
165
169
|
export const listMemoriesRequestSchema = z.object({
|
|
@@ -208,7 +208,7 @@ export function createMemoryCoreService(deps) {
|
|
|
208
208
|
event_type: event.eventType,
|
|
209
209
|
payload_json: redactedPayload,
|
|
210
210
|
created_at: event.createdAt,
|
|
211
|
-
});
|
|
211
|
+
}, parsed.replaceExisting);
|
|
212
212
|
}
|
|
213
213
|
return written;
|
|
214
214
|
});
|
|
@@ -265,6 +265,7 @@ export function createMemoryCoreService(deps) {
|
|
|
265
265
|
redactionEnabled: resolveRedactionEnabled(parsed.redactionEnabled),
|
|
266
266
|
});
|
|
267
267
|
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
268
|
+
const capturedAt = deps.now?.().toISOString();
|
|
268
269
|
// Per-session write soft limit: warn the caller when the session has
|
|
269
270
|
// already accumulated SESSION_WRITE_SOFT_LIMIT memories so the agent
|
|
270
271
|
// gets feedback to stop storing excessively. The write still proceeds.
|
|
@@ -291,6 +292,8 @@ export function createMemoryCoreService(deps) {
|
|
|
291
292
|
origin_project_id: null,
|
|
292
293
|
tags: JSON.stringify(parsed.tags ?? null),
|
|
293
294
|
expires_at: parsed.expiresAt ?? null,
|
|
295
|
+
created_at: capturedAt,
|
|
296
|
+
updated_at: capturedAt,
|
|
294
297
|
});
|
|
295
298
|
const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
296
299
|
if (!inserted) {
|
|
@@ -315,7 +318,9 @@ export function createMemoryCoreService(deps) {
|
|
|
315
318
|
db,
|
|
316
319
|
projectId: parsed.projectId,
|
|
317
320
|
queryText: parsed.query,
|
|
321
|
+
includeHistory: parsed.includeHistory,
|
|
318
322
|
limit,
|
|
323
|
+
now: deps.now?.(),
|
|
319
324
|
});
|
|
320
325
|
// Optional tag filter: keep a memory only when its stored tags contain ALL
|
|
321
326
|
// requested tags. The semantic candidate projection omits tags, so re-read
|
|
@@ -694,6 +699,7 @@ export function createMemoryCoreService(deps) {
|
|
|
694
699
|
redactionEnabled: resolveRedactionEnabled(item.redactionEnabled),
|
|
695
700
|
});
|
|
696
701
|
const embedding = deterministicEmbed(redaction.text, dimension);
|
|
702
|
+
const capturedAt = deps.now?.().toISOString();
|
|
697
703
|
insertMemory(db, {
|
|
698
704
|
id: item.memoryId,
|
|
699
705
|
project_id: parsed.projectId,
|
|
@@ -710,6 +716,8 @@ export function createMemoryCoreService(deps) {
|
|
|
710
716
|
origin_project_id: null,
|
|
711
717
|
tags: JSON.stringify(item.tags ?? null),
|
|
712
718
|
expires_at: item.expiresAt ?? null,
|
|
719
|
+
created_at: capturedAt,
|
|
720
|
+
updated_at: capturedAt,
|
|
713
721
|
});
|
|
714
722
|
const inserted = getMemoryRecordById(db, parsed.projectId, item.memoryId);
|
|
715
723
|
if (!inserted) {
|
|
@@ -6,6 +6,8 @@ import { insertSummarizationFailure } from "../storage/summarizationFailuresRepo
|
|
|
6
6
|
import { summarizeWithCloud } from "../summarize/cloudSummarizer.js";
|
|
7
7
|
import { summarizeLocalSessionEvents, } from "../summarize/localSummarizer.js";
|
|
8
8
|
import { resolveSummarizerMode } from "../summarize/strategySelector.js";
|
|
9
|
+
import { extractEventText } from "../summarize/summaryShape.js";
|
|
10
|
+
import { extractKnowledge } from "../extract/knowledge.js";
|
|
9
11
|
const DEFAULT_EMBEDDING_DIMENSION = 32;
|
|
10
12
|
const CLOUD_RETRY_CONFIG = {
|
|
11
13
|
retries: 2,
|
|
@@ -58,7 +60,7 @@ function storeSummaryMemory(db, embeddingDimension, input) {
|
|
|
58
60
|
// Session summaries are locally authored.
|
|
59
61
|
author: input.author,
|
|
60
62
|
origin_project_id: null,
|
|
61
|
-
});
|
|
63
|
+
}, input.knowledge, input.factSource);
|
|
62
64
|
}
|
|
63
65
|
function buildMemoryId(request) {
|
|
64
66
|
return request.memoryId ?? `${request.sessionId}-summary`;
|
|
@@ -160,6 +162,10 @@ export function createSessionLifecycleService(deps) {
|
|
|
160
162
|
}
|
|
161
163
|
const mode = resolveSummarizerMode(request.config);
|
|
162
164
|
const memoryId = buildMemoryId(request);
|
|
165
|
+
// Extract from the same event text as the summarizer, before its line/token
|
|
166
|
+
// caps. This remains deterministic even when cloud summaries are opted in.
|
|
167
|
+
const factSource = events.map(event => ({ text: extractEventText(event), observedAt: event.created_at }));
|
|
168
|
+
const knowledge = extractKnowledge(factSource.map(source => source.text).join("\n"));
|
|
163
169
|
const baseInput = {
|
|
164
170
|
events,
|
|
165
171
|
summaryTokenCap: request.config.summaryTokenCap,
|
|
@@ -179,6 +185,8 @@ export function createSessionLifecycleService(deps) {
|
|
|
179
185
|
sourceAdapter: request.sourceAdapter,
|
|
180
186
|
summary: cloudResult.summary,
|
|
181
187
|
author,
|
|
188
|
+
knowledge,
|
|
189
|
+
factSource,
|
|
182
190
|
});
|
|
183
191
|
runLightPrune(request.projectId);
|
|
184
192
|
return {
|
|
@@ -215,6 +223,8 @@ export function createSessionLifecycleService(deps) {
|
|
|
215
223
|
sourceAdapter: request.sourceAdapter,
|
|
216
224
|
summary: fallbackResult.summary,
|
|
217
225
|
author,
|
|
226
|
+
knowledge,
|
|
227
|
+
factSource,
|
|
218
228
|
});
|
|
219
229
|
runLightPrune(request.projectId);
|
|
220
230
|
return {
|
|
@@ -269,6 +279,8 @@ export function createSessionLifecycleService(deps) {
|
|
|
269
279
|
sourceAdapter: request.sourceAdapter,
|
|
270
280
|
summary: localResult.summary,
|
|
271
281
|
author,
|
|
282
|
+
knowledge,
|
|
283
|
+
factSource,
|
|
272
284
|
});
|
|
273
285
|
runLightPrune(request.projectId);
|
|
274
286
|
return {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// High-signal tool types that we capture as session events.
|
|
2
|
+
const CAPTURED_TOOL_TYPES = new Set(["Bash", "Edit", "Write", "MultiEdit"]);
|
|
3
|
+
// Max payload size stored per hook event (chars). The full hook payload can be
|
|
4
|
+
// large (file diffs); cap it so a single tool use can't bloat session_events.
|
|
5
|
+
const MAX_HOOK_PAYLOAD_CHARS = 4000;
|
|
6
|
+
// Per-field cap applied BEFORE serialization. Truncating the serialized JSON
|
|
7
|
+
// instead would produce a string that is no longer valid JSON, which the
|
|
8
|
+
// `payloadJson` contract rejects (it parses the value) — the event would then be
|
|
9
|
+
// dropped, silently, by the catch-all below. Trim the inputs, not the output.
|
|
10
|
+
const MAX_FIELD_CHARS = 1200;
|
|
11
|
+
// One-line human summary shown in session summaries (summaryShape reads `text`).
|
|
12
|
+
const MAX_TEXT_CHARS = 300;
|
|
13
|
+
function truncate(value, max) {
|
|
14
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Shorten every string in a tool input to MAX_FIELD_CHARS, recursively. Keeps
|
|
18
|
+
* the object's shape (so it still serializes to valid JSON) while bounding the
|
|
19
|
+
* size contributed by large fields such as a Write tool's `content` or an Edit
|
|
20
|
+
* tool's replacement text.
|
|
21
|
+
*/
|
|
22
|
+
function shrinkValue(value, depth = 0) {
|
|
23
|
+
if (typeof value === "string")
|
|
24
|
+
return truncate(value, MAX_FIELD_CHARS);
|
|
25
|
+
if (depth >= 4)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.slice(0, 20).map((item) => shrinkValue(item, depth + 1));
|
|
29
|
+
}
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
33
|
+
out[key] = shrinkValue(inner, depth + 1);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
// Numbers/booleans/null pass through; functions/undefined/symbols drop out.
|
|
38
|
+
return value === undefined || typeof value === "function" || typeof value === "symbol"
|
|
39
|
+
? undefined
|
|
40
|
+
: value;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A short human-readable line describing the tool use. Stored alongside the
|
|
44
|
+
* structured input as `text`, which the local summarizer prefers over the raw
|
|
45
|
+
* payload — so session summaries read as prose instead of dumped JSON.
|
|
46
|
+
*/
|
|
47
|
+
function describeToolUse(toolName, input) {
|
|
48
|
+
const str = (key) => typeof input?.[key] === "string" ? input[key] : "";
|
|
49
|
+
if (toolName === "Bash") {
|
|
50
|
+
const command = str("command").replace(/\s+/g, " ").trim();
|
|
51
|
+
return truncate(command ? `Ran: ${command}` : "Ran a shell command", MAX_TEXT_CHARS);
|
|
52
|
+
}
|
|
53
|
+
const filePath = str("file_path") || str("path") || str("notebook_path");
|
|
54
|
+
const verb = toolName === "Write" ? "Wrote" : "Edited";
|
|
55
|
+
return truncate(filePath ? `${verb} ${filePath}` : `${verb} a file`, MAX_TEXT_CHARS);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Serialize the event payload, guaranteeing valid JSON within
|
|
59
|
+
* MAX_HOOK_PAYLOAD_CHARS. Per-field shrinking handles the common case; if the
|
|
60
|
+
* result is still over budget (many fields, or one huge key set), drop the
|
|
61
|
+
* structured input and keep only the human-readable summary rather than
|
|
62
|
+
* emitting truncated — and therefore unparseable — JSON.
|
|
63
|
+
*/
|
|
64
|
+
function buildPayloadJson(toolName, text, input, cwd, callId) {
|
|
65
|
+
const full = JSON.stringify({
|
|
66
|
+
tool: toolName,
|
|
67
|
+
text,
|
|
68
|
+
input: shrinkValue(input),
|
|
69
|
+
cwd,
|
|
70
|
+
...(callId ? { call_id: callId } : {}),
|
|
71
|
+
});
|
|
72
|
+
if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
|
|
73
|
+
return full;
|
|
74
|
+
return JSON.stringify({ tool: toolName, text, cwd, ...(callId ? { call_id: callId } : {}), truncated: true });
|
|
75
|
+
}
|
|
76
|
+
export function buildToolEvent(toolName, input, cwd) {
|
|
77
|
+
if (!CAPTURED_TOOL_TYPES.has(toolName))
|
|
78
|
+
return undefined;
|
|
79
|
+
return {
|
|
80
|
+
eventType: `tool_use:${toolName.toLowerCase()}`,
|
|
81
|
+
payloadJson: buildPayloadJson(toolName, describeToolUse(toolName, input), input, cwd),
|
|
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
|
+
}
|