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.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +233 -41
  3. package/dist/adapters/diagnostics.js +109 -0
  4. package/dist/adapters/generic.js +6 -2
  5. package/dist/adapters/global/antigravity.js +4 -1
  6. package/dist/adapters/global/claudeCode.js +19 -4
  7. package/dist/adapters/global/codex.js +7 -6
  8. package/dist/adapters/global/qcoder.js +4 -1
  9. package/dist/adapters/ide/cline.js +3 -0
  10. package/dist/adapters/ide/cursor.js +3 -0
  11. package/dist/adapters/ide/installer.js +17 -5
  12. package/dist/adapters/ide/windsurf.js +3 -0
  13. package/dist/adapters/installedHosts.js +51 -0
  14. package/dist/cli/commands/doctor.js +155 -0
  15. package/dist/cli/commands/entities.js +27 -0
  16. package/dist/cli/commands/history.js +23 -0
  17. package/dist/cli/commands/ingestHook.js +4 -78
  18. package/dist/cli/commands/install.js +34 -56
  19. package/dist/cli/commands/scan.js +16 -0
  20. package/dist/cli/commands/search.js +1 -0
  21. package/dist/cli/commands/team.js +7 -3
  22. package/dist/cli/commands/ui.js +67 -0
  23. package/dist/cli/commands/watch.js +48 -0
  24. package/dist/cli/index.js +44 -1
  25. package/dist/cli/projectId.js +1 -2
  26. package/dist/cli/transcriptSweep.js +142 -0
  27. package/dist/cli/transcripts/claudeCode.js +62 -0
  28. package/dist/cli/transcripts/codex.js +111 -0
  29. package/dist/cli/transcripts/cursor.js +295 -0
  30. package/dist/cli/transcripts/index.js +118 -0
  31. package/dist/cli/transcripts/types.js +15 -0
  32. package/dist/core/api/contracts.js +4 -0
  33. package/dist/core/api/memoryCoreService.js +9 -1
  34. package/dist/core/api/sessionLifecycleService.js +13 -1
  35. package/dist/core/capture/toolEvent.js +94 -0
  36. package/dist/core/extract/factClaims.js +97 -0
  37. package/dist/core/extract/knowledge.js +127 -0
  38. package/dist/core/retrieve/retrieveMemories.js +20 -4
  39. package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
  40. package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
  41. package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
  42. package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
  43. package/dist/core/schema/runMigrations.js +46 -0
  44. package/dist/core/storage/knowledgeRepo.js +67 -0
  45. package/dist/core/storage/memoryRepo.js +45 -12
  46. package/dist/core/storage/memorySearchRepo.js +30 -20
  47. package/dist/core/storage/sessionEventsRepo.js +12 -3
  48. package/dist/core/storage/supersessionRepo.js +101 -0
  49. package/dist/core/summarize/summaryShape.js +1 -1
  50. package/dist/ui/assets.js +181 -0
  51. package/dist/ui/server.js +147 -0
  52. package/docs/REGISTRY_LISTING.md +88 -0
  53. package/docs/architecture.md +27 -3
  54. package/docs/benchmark.md +105 -33
  55. package/docs/migration.md +2 -0
  56. package/docs/troubleshooting.md +52 -4
  57. 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
+ }