sessionmem 1.4.0 → 1.5.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 (51) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +186 -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 +146 -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 +42 -1
  25. package/dist/cli/projectId.js +1 -2
  26. package/dist/cli/transcriptSweep.js +182 -0
  27. package/dist/core/api/contracts.js +1 -0
  28. package/dist/core/api/memoryCoreService.js +8 -0
  29. package/dist/core/api/sessionLifecycleService.js +13 -1
  30. package/dist/core/capture/toolEvent.js +82 -0
  31. package/dist/core/extract/factClaims.js +97 -0
  32. package/dist/core/extract/knowledge.js +127 -0
  33. package/dist/core/retrieve/retrieveMemories.js +20 -4
  34. package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
  35. package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
  36. package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
  37. package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
  38. package/dist/core/schema/runMigrations.js +46 -0
  39. package/dist/core/storage/knowledgeRepo.js +67 -0
  40. package/dist/core/storage/memoryRepo.js +45 -12
  41. package/dist/core/storage/memorySearchRepo.js +30 -20
  42. package/dist/core/storage/supersessionRepo.js +101 -0
  43. package/dist/core/summarize/summaryShape.js +1 -1
  44. package/dist/ui/assets.js +181 -0
  45. package/dist/ui/server.js +147 -0
  46. package/docs/REGISTRY_LISTING.md +88 -0
  47. package/docs/architecture.md +27 -3
  48. package/docs/benchmark.md +105 -33
  49. package/docs/migration.md +2 -0
  50. package/docs/troubleshooting.md +52 -4
  51. package/package.json +1 -1
@@ -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,28 @@ 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 existing Claude Code transcripts using local capture and summarization")
64
+ .option("--transcripts-dir <path>", "Transcript directory (default: ~/.claude/projects)")
65
+ .action(async (options) => { await scanCommand(options); });
66
+ program
67
+ .command("watch")
68
+ .description("Poll Claude Code transcripts and capture new or updated sessions offline")
69
+ .option("--transcripts-dir <path>", "Transcript directory (default: ~/.claude/projects)")
70
+ .option("--once", "Run one sweep and exit")
71
+ .option("--interval <seconds>", "Seconds between sweeps", "5")
72
+ .action((options) => watchCommand(options));
50
73
  program
51
74
  .command("install")
52
- .description("Install sessionmem into the current MCP host")
75
+ .description("Install sessionmem into every detected agent host")
53
76
  .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
54
77
  .action((options) => installCommand(options));
78
+ program
79
+ .command("doctor")
80
+ .description("Check the local store, schema, hooks, adapter wiring, and recall offline")
81
+ .option("--adapter <name>", "Check a specific host instead of installed-host detection")
82
+ .action(async (options) => { await doctorCommand(options); });
55
83
  program
56
84
  .command("uninstall")
57
85
  .description("Remove sessionmem from the current MCP host")
@@ -75,11 +103,24 @@ program
75
103
  .command("search <query>")
76
104
  .description("Search memories by semantic query")
77
105
  .option("--limit <n>", "Maximum number of results (1-100)", "10")
106
+ .option("--include-history", "Include superseded facts")
78
107
  .action((query, options) => searchCommand(query, options));
79
108
  program
80
109
  .command("list")
81
110
  .description("List up to 200 recent memories for the current project")
82
111
  .action(() => listCommand());
112
+ program
113
+ .command("entities")
114
+ .description("List extracted entities, typed relations, and source memory IDs for a project")
115
+ .option("--project <id>", "Project ID (default: current project)")
116
+ .option("--json", "Output entities and relations as JSON")
117
+ .action((options) => entitiesCommand(options));
118
+ program
119
+ .command("history")
120
+ .description("List supersession chains, source memory IDs, and timestamps")
121
+ .option("--project <id>", "Project ID (default: current project)")
122
+ .option("--json", "Output supersession links as JSON")
123
+ .action((options) => historyCommand(options));
83
124
  program
84
125
  .command("show <id>")
85
126
  .description("Show memory details by ID (content capped at 2000 characters)")
@@ -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,182 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { readdir, stat } from "node:fs/promises";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { homedir } from "node:os";
7
+ import { buildToolEvent } from "../core/capture/toolEvent.js";
8
+ import { handleSessionEndConfigSchema, MAX_INGEST_EVENTS } from "../core/api/contracts.js";
9
+ import { expandTilde } from "./context.js";
10
+ import { deriveProjectId } from "./projectId.js";
11
+ function object(value) {
12
+ return value && typeof value === "object" && !Array.isArray(value)
13
+ ? value : undefined;
14
+ }
15
+ function stableId(...parts) {
16
+ return `transcript-${createHash("sha256").update(JSON.stringify(parts)).digest("hex")}`;
17
+ }
18
+ /** Translate the transcript envelope; tool normalization stays shared with ingest-hook. */
19
+ async function readTranscript(path) {
20
+ const result = {
21
+ cwd: undefined,
22
+ sessionId: basename(path, ".jsonl"),
23
+ events: [],
24
+ malformed: false,
25
+ };
26
+ const input = createReadStream(path);
27
+ const lines = createInterface({ input, crlfDelay: Infinity });
28
+ let lineIndex = 0;
29
+ try {
30
+ for await (const line of lines) {
31
+ const index = lineIndex++;
32
+ if (!line.trim())
33
+ continue;
34
+ let record;
35
+ try {
36
+ record = object(JSON.parse(line.replace(/^\uFEFF/, "")));
37
+ }
38
+ catch {
39
+ result.malformed = true;
40
+ continue;
41
+ }
42
+ if (!record)
43
+ continue;
44
+ if (!result.cwd && typeof record.cwd === "string" && record.cwd.trim()) {
45
+ result.cwd = record.cwd;
46
+ }
47
+ // Subagent files share their parent's sessionId; retain their own filename.
48
+ if (!basename(path).startsWith("agent-") && typeof record.sessionId === "string") {
49
+ result.sessionId = record.sessionId;
50
+ }
51
+ if (record.type !== "user" && record.type !== "assistant")
52
+ continue;
53
+ const message = object(record.message);
54
+ const content = message?.content;
55
+ const blocks = typeof content === "string" ? [{ type: "text", text: content }]
56
+ : Array.isArray(content) ? content : [];
57
+ for (const [blockIndex, rawBlock] of blocks.entries()) {
58
+ const block = object(rawBlock);
59
+ if (!block)
60
+ continue;
61
+ const key = typeof record.uuid === "string" ? record.uuid : `line-${index}`;
62
+ const timestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
63
+ const createdAt = Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
64
+ if (block.type === "tool_use" && typeof block.name === "string") {
65
+ const event = buildToolEvent(block.name, object(block.input), result.cwd);
66
+ if (event)
67
+ result.events.push({
68
+ id: typeof block.id === "string" ? `tool-${block.id}` : `${key}-${blockIndex}`,
69
+ ...event,
70
+ createdAt,
71
+ });
72
+ }
73
+ else if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
74
+ result.events.push({
75
+ id: `${key}-${blockIndex}`,
76
+ eventType: `message:${record.type}`,
77
+ createdAt,
78
+ payloadJson: JSON.stringify({ text: block.text.slice(0, 4000) }),
79
+ });
80
+ }
81
+ }
82
+ }
83
+ }
84
+ finally {
85
+ lines.close();
86
+ input.destroy();
87
+ }
88
+ return result;
89
+ }
90
+ async function* transcriptFiles(directory) {
91
+ let entries;
92
+ try {
93
+ entries = await readdir(directory, { withFileTypes: true });
94
+ }
95
+ catch (error) {
96
+ if (error.code === "ENOENT")
97
+ return;
98
+ throw error;
99
+ }
100
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
101
+ const path = join(directory, entry.name);
102
+ if (entry.isDirectory())
103
+ yield* transcriptFiles(path);
104
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
105
+ yield path;
106
+ }
107
+ }
108
+ async function fingerprint(path) {
109
+ const info = await stat(path, { bigint: true });
110
+ return `${process.env.SESSIONMEM_PROJECT_ID ?? ""}:${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
111
+ }
112
+ export async function sweepTranscripts(context, options = {}) {
113
+ const directory = resolve(expandTilde(options.transcriptsDir ?? join(homedir(), ".claude", "projects")));
114
+ const summary = { filesFound: 0, sessionsImported: 0, memoriesCreated: 0, skipped: 0, errors: 0 };
115
+ const importedSessions = new Set();
116
+ const checkpoint = context.db.prepare("SELECT fingerprint FROM transcript_checkpoints WHERE path = ?");
117
+ const saveCheckpoint = context.db.prepare("INSERT OR REPLACE INTO transcript_checkpoints (path, fingerprint) VALUES (?, ?)");
118
+ for await (const path of transcriptFiles(directory)) {
119
+ summary.filesFound++;
120
+ try {
121
+ const before = await fingerprint(path);
122
+ if (checkpoint.get(path)?.fingerprint === before) {
123
+ summary.skipped++;
124
+ continue;
125
+ }
126
+ const transcript = await readTranscript(path);
127
+ if (!transcript.cwd || transcript.events.length === 0) {
128
+ summary.skipped++;
129
+ if (!transcript.malformed)
130
+ saveCheckpoint.run(path, before);
131
+ else
132
+ throw new Error("malformed or incomplete JSONL; will retry");
133
+ continue;
134
+ }
135
+ const projectId = deriveProjectId(transcript.cwd);
136
+ const sessionId = transcript.sessionId;
137
+ for (let offset = 0; offset < transcript.events.length; offset += MAX_INGEST_EVENTS) {
138
+ const result = await context.service.call("ingestSessionEvents", {
139
+ projectId,
140
+ sessionId,
141
+ events: transcript.events.slice(offset, offset + MAX_INGEST_EVENTS).map(event => ({
142
+ ...event, id: stableId(projectId, sessionId, event.id),
143
+ })),
144
+ });
145
+ if (!result.ok)
146
+ throw new Error(result.error.message);
147
+ }
148
+ const existing = context.db.prepare("SELECT id FROM memories WHERE project_id = ? AND session_id = ? AND kind = 'summary' LIMIT 1")
149
+ .get(projectId, sessionId);
150
+ // The pipeline upserts by project/session; keep existing hook summary IDs.
151
+ const memoryId = existing?.id ?? stableId(projectId, sessionId, "summary");
152
+ const result = await context.service.call("handleSessionEnd", {
153
+ projectId,
154
+ sessionId,
155
+ memoryId,
156
+ sourceAdapter: "claude-code",
157
+ config: handleSessionEndConfigSchema.parse({ minimumEventThreshold: 1, allowCloudSummarization: false }),
158
+ });
159
+ if (!result.ok)
160
+ throw new Error(result.error.message);
161
+ if (result.status !== "stored")
162
+ throw new Error(`summary ${result.status}; will retry`);
163
+ importedSessions.add(JSON.stringify([projectId, sessionId]));
164
+ summary.sessionsImported = importedSessions.size;
165
+ if (!existing)
166
+ summary.memoriesCreated++;
167
+ if (transcript.malformed)
168
+ throw new Error("malformed or incomplete JSONL; will retry");
169
+ // Never mark concurrent appends as processed. The next sweep replays safely.
170
+ if (await fingerprint(path) === before)
171
+ saveCheckpoint.run(path, before);
172
+ }
173
+ catch (error) {
174
+ summary.errors++;
175
+ process.stderr.write(`[sessionmem] ${path}: ${error instanceof Error ? error.message : String(error)}\n`);
176
+ }
177
+ }
178
+ return summary;
179
+ }
180
+ export function printSweepSummary(command, summary) {
181
+ console.log(`sessionmem ${command}: files found: ${summary.filesFound}; sessions imported: ${summary.sessionsImported}; memories created: ${summary.memoriesCreated}; skipped: ${summary.skipped}; errors: ${summary.errors}`);
182
+ }
@@ -160,6 +160,7 @@ export const retrieveMemoriesRequestSchema = z.object({
160
160
  limit: z.number().int().min(1).max(100).default(20),
161
161
  mode: z.enum(["auto", "on-demand"]).default("auto"),
162
162
  depth: z.enum(["default", "deep"]).default("default"),
163
+ includeHistory: z.boolean().optional(),
163
164
  tags: z.array(z.string().min(1).max(50)).max(10).optional(),
164
165
  });
165
166
  export const listMemoriesRequestSchema = z.object({
@@ -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,82 @@
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) {
65
+ const full = JSON.stringify({
66
+ tool: toolName,
67
+ text,
68
+ input: shrinkValue(input),
69
+ cwd,
70
+ });
71
+ if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
72
+ return full;
73
+ return JSON.stringify({ tool: toolName, text, cwd, truncated: true });
74
+ }
75
+ export function buildToolEvent(toolName, input, cwd) {
76
+ if (!CAPTURED_TOOL_TYPES.has(toolName))
77
+ return undefined;
78
+ return {
79
+ eventType: `tool_use:${toolName.toLowerCase()}`,
80
+ payloadJson: buildPayloadJson(toolName, describeToolUse(toolName, input), input, cwd),
81
+ };
82
+ }