sessionmem 1.3.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/.github/sessionmem-logo.svg +57 -0
- package/CHANGELOG.md +128 -0
- package/CONTRIBUTING.md +130 -0
- package/README.md +292 -108
- package/SECURITY.md +33 -0
- 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/dedupe.js +1 -1
- 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/import.js +15 -44
- package/dist/cli/commands/ingestHook.js +4 -78
- package/dist/cli/commands/install.js +37 -59
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/commands/run.js +10 -12
- package/dist/cli/commands/scan.js +16 -0
- package/dist/cli/commands/search.js +9 -4
- package/dist/cli/commands/team.js +7 -3
- package/dist/cli/commands/ui.js +67 -0
- package/dist/cli/commands/uninstall.js +2 -1
- package/dist/cli/commands/watch.js +48 -0
- package/dist/cli/context.js +8 -6
- package/dist/cli/index.js +48 -7
- 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/config/policyConfig.js +5 -1
- package/dist/core/extract/factClaims.js +97 -0
- package/dist/core/extract/knowledge.js +127 -0
- package/dist/core/injection/tokenBudget.js +10 -2
- 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 +55 -5
- package/dist/core/storage/db.js +31 -9
- 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/cloudSummarizer.js +3 -8
- 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 +77 -0
- package/docs/benchmark.md +122 -0
- package/docs/cloud-summarization.md +57 -0
- package/docs/migration.md +44 -0
- package/docs/privacy-and-retention.md +139 -0
- package/docs/team-mode.md +117 -0
- package/docs/troubleshooting.md +126 -0
- package/docs/typescript-upgrade.md +48 -0
- package/package.json +25 -13
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,17 +25,27 @@ 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
|
|
30
36
|
// "../../package.json" specifier resolves to the package root.
|
|
31
37
|
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
32
38
|
const program = new Command();
|
|
33
|
-
program.name("sessionmem").version(pkg.version);
|
|
39
|
+
program.name("sessionmem").description("Local-first memory for MCP coding agents").version(pkg.version);
|
|
34
40
|
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")
|
|
@@ -74,15 +102,28 @@ program
|
|
|
74
102
|
program
|
|
75
103
|
.command("search <query>")
|
|
76
104
|
.description("Search memories by semantic query")
|
|
77
|
-
.option("--limit <n>", "Maximum number of results", "10")
|
|
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
|
-
.description("List
|
|
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
|
-
.description("Show
|
|
126
|
+
.description("Show memory details by ID (content capped at 2000 characters)")
|
|
86
127
|
.action((id) => showCommand(id));
|
|
87
128
|
program
|
|
88
129
|
.command("forget <id>")
|
|
@@ -96,7 +137,7 @@ program
|
|
|
96
137
|
program
|
|
97
138
|
.command("import <path>")
|
|
98
139
|
.description("Import memories from a JSON file")
|
|
99
|
-
.option("--merge", "
|
|
140
|
+
.option("--merge", "Merge new records; existing IDs are preserved")
|
|
100
141
|
.action((path, options) => importCommand(path, options));
|
|
101
142
|
program
|
|
102
143
|
.command("stats")
|
|
@@ -171,7 +212,7 @@ program
|
|
|
171
212
|
.command("dedupe")
|
|
172
213
|
.description("Find and optionally remove near-duplicate memories")
|
|
173
214
|
.option("--apply", "Delete the lower-importance duplicate in each pair (default: dry-run)")
|
|
174
|
-
.option("--threshold <n>", "Cosine similarity threshold 0
|
|
215
|
+
.option("--threshold <n>", "Cosine similarity threshold >0 to 1 (default: 0.85)")
|
|
175
216
|
.action((options) => dedupeCommand({ dry: !options.apply, threshold: options.threshold }));
|
|
176
217
|
// browse — cross-project memory viewer.
|
|
177
218
|
program
|
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
|
+
}
|
|
@@ -88,7 +88,11 @@ export function readPolicyConfig(filePath) {
|
|
|
88
88
|
let obj;
|
|
89
89
|
try {
|
|
90
90
|
raw = readFileSync(filePath, "utf8");
|
|
91
|
-
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
93
|
+
return { ...DEFAULT_POLICY_CONFIG };
|
|
94
|
+
}
|
|
95
|
+
obj = parsed;
|
|
92
96
|
}
|
|
93
97
|
catch {
|
|
94
98
|
return { ...DEFAULT_POLICY_CONFIG };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { canonicalEntityName, extractKnowledge } from "./knowledge.js";
|
|
2
|
+
import { applyRedaction } from "../summarize/redaction.js";
|
|
3
|
+
const CATEGORIES = [
|
|
4
|
+
["editor", ["vs code", "vscode", "visual studio code", "neovim", "vim", "emacs", "sublime text", "intellij"]],
|
|
5
|
+
["language", ["python", "rust", "typescript", "javascript", "java", "go", "ruby"]],
|
|
6
|
+
["database", ["sqlite", "postgresql", "postgres", "mysql", "mongodb"]],
|
|
7
|
+
["package manager", ["npm", "pnpm", "yarn", "bun"]],
|
|
8
|
+
["test runner", ["vitest", "jest", "mocha", "pytest"]],
|
|
9
|
+
];
|
|
10
|
+
const aliases = { vscode: "vs code", "visual studio code": "vs code", postgres: "postgresql" };
|
|
11
|
+
const value = (claim) => aliases[claim.target.name] ?? claim.target.name;
|
|
12
|
+
export function claimKey(claim) {
|
|
13
|
+
return JSON.stringify([claim.source.kind, claim.source.name, claim.type,
|
|
14
|
+
claim.target.kind, claim.target.name, claim.context]);
|
|
15
|
+
}
|
|
16
|
+
export function claimSlot(claim) {
|
|
17
|
+
return JSON.stringify([claim.source, claim.type, claim.context, claim.attribute || claim.target]);
|
|
18
|
+
}
|
|
19
|
+
/** Small affirmative/negative grammar layered on migration 014's entity rules. */
|
|
20
|
+
export function extractFactClaims(content) {
|
|
21
|
+
const text = applyRedaction(content, { redactionEnabled: true }).text.normalize("NFKC");
|
|
22
|
+
const claims = new Map();
|
|
23
|
+
let speaker = "";
|
|
24
|
+
for (let line of text.split(/\n|(?<=[.!?])\s+/)) {
|
|
25
|
+
line = line.replace(/^\s*(?:-\s*)?(?:\[[^\]]{1,60}\]\s*)?/, "").trim();
|
|
26
|
+
const prefix = /^([\p{Lu}][\p{L}'’-]*(?:\s+[\p{Lu}][\p{L}'’-]*){0,2}):\s*/u.exec(line);
|
|
27
|
+
if (prefix && !/^(actions|decisions|outcomes|facts|summary|user|assistant)$/i.test(prefix[1])) {
|
|
28
|
+
speaker = prefix[1];
|
|
29
|
+
line = line.slice(prefix[0].length);
|
|
30
|
+
}
|
|
31
|
+
// No questions, hypothetical statements, reported quotations or code.
|
|
32
|
+
if (/\?|^[`"“]|```/.test(line) || /\b(?:if|might|may|could|should|would|can|said|says|asked|used to)\b/i.test(line))
|
|
33
|
+
continue;
|
|
34
|
+
const negative = /\b(?:not|never|no longer|stopped)\b|n['’]t\b/i.test(line);
|
|
35
|
+
const explicitChange = /\b(?:now|switched|replaced|instead|no longer|stopped)\b/i.test(line);
|
|
36
|
+
const replaced = /\bswitched\s+from\s+(.+?)\s+to\b|\breplaced\s+(.+?)\s+with\b|\binstead\s+of\s+(.+?)[.!]?$/i.exec(line);
|
|
37
|
+
const replaces = canonicalEntityName(replaced?.slice(1).find(Boolean) ?? "").replace(/^(?:tool|service|project)\s+/, "");
|
|
38
|
+
let affirmative = line
|
|
39
|
+
.replace(/\b(?:do|does|did)\s+not\s+|\b(?:don['’]t|doesn['’]t|didn['’]t)\s+|\b(?:never|no longer)\s+/gi, "")
|
|
40
|
+
.replace(/\bstopped\s+using\b/gi, "uses")
|
|
41
|
+
.replace(/\bnot\s+(?=(?:uses?|using|prefers?|works?\s+on|working\s+on|blocked\s+by)\b)/gi, "")
|
|
42
|
+
.replace(/\bswitched\s+(?:from\s+.+?\s+)?to\s+/gi, "uses ")
|
|
43
|
+
.replace(/\breplaced\s+.+?\s+with\s+/gi, "uses ")
|
|
44
|
+
.replace(/\b(?:now|currently)\s+/gi, "")
|
|
45
|
+
.replace(/\s+(?:now|currently|again)[.!]?$/i, ".");
|
|
46
|
+
// Other negation forms are ambiguous; never turn them into positive claims.
|
|
47
|
+
if (/\bnot\b|n['’]t\b/i.test(affirmative))
|
|
48
|
+
continue;
|
|
49
|
+
const context = canonicalEntityName(/\b(?:for|as)\s+(.+?)(?:[.!;]|\s+because\b|$)/i.exec(affirmative)?.[1] ?? "")
|
|
50
|
+
.replace(/\b(?:my|our|the|a|an)\s+/g, "");
|
|
51
|
+
affirmative = affirmative.replace(/\s+as\s+.+$/i, "");
|
|
52
|
+
const graph = extractKnowledge(speaker ? `${speaker}: ${affirmative}` : affirmative);
|
|
53
|
+
for (const edge of graph.relations) {
|
|
54
|
+
const category = edge.type === "uses" || edge.type === "prefers"
|
|
55
|
+
? CATEGORIES.find(([, names]) => names.includes(edge.target.name))?.[0] ?? "" : "";
|
|
56
|
+
const claim = { ...edge, context, attribute: category, negative, explicitChange, replaces };
|
|
57
|
+
// Last assertion about the same slot in a session wins, including retraction.
|
|
58
|
+
const slot = claimSlot(claim);
|
|
59
|
+
claims.delete(slot);
|
|
60
|
+
claims.set(slot, claim);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...claims.values()];
|
|
64
|
+
}
|
|
65
|
+
function contextOverlap(left, right) {
|
|
66
|
+
if (left === right)
|
|
67
|
+
return true;
|
|
68
|
+
if (!left || !right)
|
|
69
|
+
return false;
|
|
70
|
+
const words = (text) => new Set(text.match(/[\p{L}\p{N}]+/gu) ?? []);
|
|
71
|
+
const a = words(left), b = words(right);
|
|
72
|
+
const shared = [...a].filter(word => b.has(word)).length;
|
|
73
|
+
return shared >= 2 && shared / new Set([...a, ...b]).size >= 0.6;
|
|
74
|
+
}
|
|
75
|
+
export function supersessionReason(older, newer) {
|
|
76
|
+
if (older.source.kind !== newer.source.kind || older.source.name !== newer.source.name || older.type !== newer.type)
|
|
77
|
+
return;
|
|
78
|
+
if (value(older) === value(newer) && older.target.kind === newer.target.kind) {
|
|
79
|
+
if (older.negative !== newer.negative && (!older.context || !newer.context || contextOverlap(older.context, newer.context)))
|
|
80
|
+
return "explicit_negation";
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (older.negative || newer.negative || older.target.kind !== newer.target.kind)
|
|
84
|
+
return;
|
|
85
|
+
if (value(older) === (aliases[newer.replaces] ?? newer.replaces))
|
|
86
|
+
return "different_value";
|
|
87
|
+
// Shared lexical purpose is a stronger attribute cue than a tool category.
|
|
88
|
+
if (older.context || newer.context) {
|
|
89
|
+
if (contextOverlap(older.context, newer.context))
|
|
90
|
+
return "different_value";
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (older.attribute && older.attribute === newer.attribute)
|
|
94
|
+
return "different_value";
|
|
95
|
+
if (newer.explicitChange && older.type === "works_on")
|
|
96
|
+
return "different_value";
|
|
97
|
+
}
|