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.
- package/CHANGELOG.md +44 -0
- package/README.md +186 -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 +146 -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 +42 -1
- package/dist/cli/projectId.js +1 -2
- package/dist/cli/transcriptSweep.js +182 -0
- package/dist/core/api/contracts.js +1 -0
- package/dist/core/api/memoryCoreService.js +8 -0
- package/dist/core/api/sessionLifecycleService.js +13 -1
- package/dist/core/capture/toolEvent.js +82 -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/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,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
|
|
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)")
|
package/dist/cli/projectId.js
CHANGED
|
@@ -92,7 +92,7 @@ export function projectIdFromCwd(cwd) {
|
|
|
92
92
|
* bucket. The id is unchanged for anyone already running at the repo root,
|
|
93
93
|
* since the root IS the cwd in that case.
|
|
94
94
|
*/
|
|
95
|
-
export function deriveProjectId() {
|
|
95
|
+
export function deriveProjectId(cwd = process.cwd()) {
|
|
96
96
|
const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
|
|
97
97
|
if (envProjectId && envProjectId.trim() !== "") {
|
|
98
98
|
// Sanitize the operator-supplied id to prevent path traversal: the id is
|
|
@@ -110,6 +110,5 @@ export function deriveProjectId() {
|
|
|
110
110
|
return sanitized;
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
-
const cwd = process.cwd();
|
|
114
113
|
return projectIdFromCwd(findProjectRoot(cwd) ?? cwd);
|
|
115
114
|
}
|
|
@@ -0,0 +1,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
|
+
}
|