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
|
@@ -21,6 +21,9 @@ export class CursorAdapter extends GenericMCPAdapter {
|
|
|
21
21
|
get configPath() {
|
|
22
22
|
return join(homedir(), ".cursor", "mcp.json");
|
|
23
23
|
}
|
|
24
|
+
installation() {
|
|
25
|
+
return { mcp: { path: this.configPath, command: "sessionmem", args: ["run"] } };
|
|
26
|
+
}
|
|
24
27
|
async install() {
|
|
25
28
|
return IDEInstaller.injectMcpConfig(this.configPath, "sessionmem", "sessionmem", ["run"]);
|
|
26
29
|
}
|
|
@@ -35,6 +35,15 @@ export const SESSIONMEM_POST_TOOL_HOOK_COMMAND = _isWindows
|
|
|
35
35
|
export const SESSIONMEM_POST_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit";
|
|
36
36
|
/** Default Claude Code hook event for the legacy 2-arg hook helpers. */
|
|
37
37
|
const DEFAULT_HOOK_EVENT = "SessionStart";
|
|
38
|
+
/** Recognize our bare/Windows hook forms, including stale command arguments. */
|
|
39
|
+
export function isManagedClaudeHook(value, expected) {
|
|
40
|
+
if (typeof value !== "string")
|
|
41
|
+
return false;
|
|
42
|
+
const normalize = (command) => command.replace(/^cmd \/c /i, "");
|
|
43
|
+
const base = normalize(expected);
|
|
44
|
+
const candidate = normalize(value);
|
|
45
|
+
return candidate === base || candidate.startsWith(`${base} `);
|
|
46
|
+
}
|
|
38
47
|
export class IDEInstaller {
|
|
39
48
|
static parseJsonc(content) {
|
|
40
49
|
// Strip single-line comments (//) that appear outside of string literals.
|
|
@@ -201,11 +210,14 @@ export class IDEInstaller {
|
|
|
201
210
|
const eventEntries = Array.isArray(hooks[eventName])
|
|
202
211
|
? hooks[eventName]
|
|
203
212
|
: [];
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
// Replace our commands, including stale forms, while preserving user hooks
|
|
214
|
+
// that share an entry. The fresh entry restores the current matcher/type.
|
|
215
|
+
const filtered = eventEntries.flatMap((entry) => {
|
|
216
|
+
if (!entry || !Array.isArray(entry.hooks))
|
|
217
|
+
return [entry];
|
|
218
|
+
const remaining = entry.hooks.filter((hook) => !isManagedClaudeHook(hook?.command, command));
|
|
219
|
+
return remaining.length ? [{ ...entry, hooks: remaining }] : [];
|
|
220
|
+
});
|
|
209
221
|
filtered.push(matcher !== undefined
|
|
210
222
|
? { matcher, hooks: [{ type: "command", command }] }
|
|
211
223
|
: { hooks: [{ type: "command", command }] });
|
|
@@ -20,6 +20,9 @@ export class WindsurfAdapter extends GenericMCPAdapter {
|
|
|
20
20
|
get configPath() {
|
|
21
21
|
return join(homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
22
22
|
}
|
|
23
|
+
installation() {
|
|
24
|
+
return { mcp: { path: this.configPath, command: "sessionmem", args: ["run"] } };
|
|
25
|
+
}
|
|
23
26
|
async install() {
|
|
24
27
|
return IDEInstaller.injectMcpConfig(this.configPath, "sessionmem", "sessionmem", ["run"]);
|
|
25
28
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
import { AdapterFactory } from "./factory.js";
|
|
5
|
+
// Installed-host detection is deliberately separate from runtime session-env
|
|
6
|
+
// detection. Add a row here when another host has a verified install probe.
|
|
7
|
+
export const INSTALLED_HOSTS = [
|
|
8
|
+
{
|
|
9
|
+
adapter: "claude-code",
|
|
10
|
+
executables: ["claude"],
|
|
11
|
+
homeExecutables: [".local/bin/claude", ".claude/local/claude"],
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
export function isExecutable(file) {
|
|
15
|
+
try {
|
|
16
|
+
if (!statSync(file).isFile())
|
|
17
|
+
return false;
|
|
18
|
+
accessSync(file, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function executableCandidates(file) {
|
|
26
|
+
if (process.platform !== "win32")
|
|
27
|
+
return [file];
|
|
28
|
+
const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";");
|
|
29
|
+
return [file, ...extensions.filter(Boolean).map((ext) => file + ext.toLowerCase())];
|
|
30
|
+
}
|
|
31
|
+
export function detectInstalledAdapters(table = INSTALLED_HOSTS) {
|
|
32
|
+
const pathDirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
33
|
+
return table.filter((host) => {
|
|
34
|
+
const paths = [
|
|
35
|
+
...host.executables.flatMap((name) => pathDirs.map((dir) => join(dir.replace(/^"|"$/g, ""), name))),
|
|
36
|
+
...(host.homeExecutables ?? []).map((file) => join(homedir(), file)),
|
|
37
|
+
];
|
|
38
|
+
return paths.some((file) => executableCandidates(file).some(isExecutable));
|
|
39
|
+
}).map((host) => AdapterFactory.forName(host.adapter));
|
|
40
|
+
}
|
|
41
|
+
/** Explicit flags take precedence over the existing environment override. */
|
|
42
|
+
export function resolveInstallAdapters(options) {
|
|
43
|
+
const explicit = options?.adapter?.trim() || process.env.SESSIONMEM_ADAPTER?.trim();
|
|
44
|
+
if (explicit)
|
|
45
|
+
return { adapters: [AdapterFactory.forName(explicit)], forced: true };
|
|
46
|
+
const adapters = detectInstalledAdapters();
|
|
47
|
+
if (adapters.length === 0) {
|
|
48
|
+
throw new Error("No supported agent host found. Install Claude Code or use sessionmem install --adapter <name>.");
|
|
49
|
+
}
|
|
50
|
+
return { adapters, forced: false };
|
|
51
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { resolveInstallAdapters } from "../../adapters/installedHosts.js";
|
|
5
|
+
import { checkHooks, readMcpLaunch, probeMcpWiring } from "../../adapters/diagnostics.js";
|
|
6
|
+
import { ADAPTER_NAMES, AdapterFactory } from "../../adapters/factory.js";
|
|
7
|
+
import { createMemoryCoreService } from "../../core/api/memoryCoreService.js";
|
|
8
|
+
import { inspectMigrations } from "../../core/schema/runMigrations.js";
|
|
9
|
+
import { resolveDbPath } from "../context.js";
|
|
10
|
+
const VERSION = createRequire(import.meta.url)("../../../package.json").version;
|
|
11
|
+
/** Exercise real storage, embedding, ranking and injection without retaining data. */
|
|
12
|
+
export async function probeRecall(db) {
|
|
13
|
+
const id = `sessionmem-doctor-${randomUUID()}`;
|
|
14
|
+
const content = "Sessionmem doctor canary checks local memory recall.";
|
|
15
|
+
const service = createMemoryCoreService({ db });
|
|
16
|
+
// A separate project prevents both ranking collisions and access-count changes
|
|
17
|
+
// to user memories. Rollback also removes FTS entries and feedback on failure.
|
|
18
|
+
db.exec("BEGIN IMMEDIATE");
|
|
19
|
+
try {
|
|
20
|
+
const stored = await service.call("storeMemory", {
|
|
21
|
+
projectId: id, sessionId: id, memoryId: id, sourceAdapter: "doctor",
|
|
22
|
+
kind: "fact", content, importance: 5,
|
|
23
|
+
});
|
|
24
|
+
if (!stored.ok)
|
|
25
|
+
throw new Error(`Canary write failed: ${stored.error.message}`);
|
|
26
|
+
const recalled = await service.call("retrieveMemories", {
|
|
27
|
+
projectId: id, query: content, limit: 5, mode: "auto", depth: "default",
|
|
28
|
+
});
|
|
29
|
+
if (!recalled.ok)
|
|
30
|
+
throw new Error(`Canary recall failed: ${recalled.error.message}`);
|
|
31
|
+
if (!recalled.memories.some((memory) => memory.id === id && memory.content === content)) {
|
|
32
|
+
throw new Error("Recall did not return the canary memory");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
db.exec("ROLLBACK");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function doctorCommand(options, overrides = {}) {
|
|
40
|
+
const results = [];
|
|
41
|
+
const report = (check, ok, detail, fix) => {
|
|
42
|
+
results.push({ check, ok, detail, ...(fix ? { fix } : {}) });
|
|
43
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${check}: ${detail}`);
|
|
44
|
+
if (fix)
|
|
45
|
+
console.log(` Fix: ${fix}`);
|
|
46
|
+
};
|
|
47
|
+
const message = (err) => err instanceof Error ? err.message : String(err);
|
|
48
|
+
let adapters = [];
|
|
49
|
+
try {
|
|
50
|
+
adapters = resolveInstallAdapters(options).adapters;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
report("Agent hosts", false, message(err), "Install a supported host or select one with sessionmem doctor --adapter <name>");
|
|
54
|
+
}
|
|
55
|
+
const dbPath = resolveDbPath(overrides.dbPath);
|
|
56
|
+
let db;
|
|
57
|
+
let schemaOk = false;
|
|
58
|
+
try {
|
|
59
|
+
try {
|
|
60
|
+
if (dbPath === ":memory:")
|
|
61
|
+
throw new Error("No persistent memory store selected (:memory:)");
|
|
62
|
+
// fileMustExist prevents doctor from hiding a missing store; migrations
|
|
63
|
+
// are inspected separately and are never applied during diagnosis.
|
|
64
|
+
db = new BetterSqlite3(dbPath, { fileMustExist: true, timeout: 5000 });
|
|
65
|
+
const status = db.pragma("quick_check", { simple: true });
|
|
66
|
+
if (status !== "ok")
|
|
67
|
+
throw new Error(`SQLite quick_check: ${String(status)}`);
|
|
68
|
+
db.pragma("foreign_keys = ON");
|
|
69
|
+
report("Memory store", true, `Exists and opens (${dbPath})`);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
db?.close();
|
|
73
|
+
db = undefined;
|
|
74
|
+
report("Memory store", false, message(err), "Check the selected database path and directory permissions; initialize a missing store with sessionmem ping");
|
|
75
|
+
}
|
|
76
|
+
if (db) {
|
|
77
|
+
try {
|
|
78
|
+
const schema = inspectMigrations(db, overrides.migrationsDir);
|
|
79
|
+
if (schema.unknown.length) {
|
|
80
|
+
report("Schema", false, `Unrecognized migrations: ${schema.unknown.join(", ")}`, "Use the sessionmem version that created this database; do not downgrade its schema");
|
|
81
|
+
}
|
|
82
|
+
else if (schema.pending.length) {
|
|
83
|
+
report("Schema", false, `Pending migrations: ${schema.pending.join(", ")}`, "sessionmem ping");
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
schemaOk = true;
|
|
87
|
+
report("Schema", true, "All bundled migrations are applied");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
report("Schema", false, message(err), "Check the database and bundled schema migrations; sessionmem ping applies pending migrations");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
report("Schema", false, "Cannot inspect an unavailable store", "sessionmem ping");
|
|
96
|
+
}
|
|
97
|
+
for (const adapter of adapters) {
|
|
98
|
+
const name = ADAPTER_NAMES.find((name) => AdapterFactory.forName(name).name === adapter.name);
|
|
99
|
+
const installFix = name ? `sessionmem install --adapter ${name}` : "sessionmem install";
|
|
100
|
+
const installation = adapter.installation?.();
|
|
101
|
+
if (!installation) {
|
|
102
|
+
report(`${adapter.name} hooks`, false, "Adapter has no installation diagnostics", installFix);
|
|
103
|
+
report(`${adapter.name} adapter wiring`, false, "Adapter has no installation diagnostics", installFix);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
report(`${adapter.name} hooks`, true, `${checkHooks(installation)}; sessionmem ${VERSION}`);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
report(`${adapter.name} hooks`, false, message(err), `Enable hooks in the host settings if disabled, then run ${installFix}`);
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const launch = readMcpLaunch(installation);
|
|
114
|
+
if (!schemaOk)
|
|
115
|
+
throw new Error("MCP launch probe blocked until the store and schema checks pass");
|
|
116
|
+
await probeMcpWiring(launch, dbPath, VERSION);
|
|
117
|
+
report(`${adapter.name} adapter wiring`, true, "Configured MCP command starts, matches this version, and answers stats");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
report(`${adapter.name} adapter wiring`, false, message(err), `Ensure sessionmem is on PATH, then run ${installFix}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!adapters.length) {
|
|
124
|
+
report("Hook configuration", false, "No host selected", "sessionmem doctor --adapter <name>");
|
|
125
|
+
report("Adapter wiring", false, "No host selected", "sessionmem doctor --adapter <name>");
|
|
126
|
+
}
|
|
127
|
+
if (db && schemaOk) {
|
|
128
|
+
try {
|
|
129
|
+
await probeRecall(db);
|
|
130
|
+
report("Recall canary", true, "Write and recall succeeded; canary rolled back");
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
report("Recall canary", false, message(err), "Check database write permissions and errors; run sessionmem doctor again after repair");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
report("Recall canary", false, "Blocked until the store and schema checks pass", "sessionmem ping");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
db?.close();
|
|
142
|
+
}
|
|
143
|
+
if (results.some((result) => !result.ok))
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { listProjectKnowledge } from "../../core/storage/knowledgeRepo.js";
|
|
2
|
+
import { createCliContext } from "../context.js";
|
|
3
|
+
export function entitiesCommand(options = {}, ctx) {
|
|
4
|
+
const context = ctx ?? createCliContext();
|
|
5
|
+
try {
|
|
6
|
+
const projectId = options.project ?? context.projectId;
|
|
7
|
+
const knowledge = listProjectKnowledge(context.db, projectId);
|
|
8
|
+
if (options.json) {
|
|
9
|
+
console.log(JSON.stringify({ projectId, ...knowledge }, null, 2));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log(`Entities for ${projectId}:`);
|
|
13
|
+
for (const entity of knowledge.entities) {
|
|
14
|
+
console.log(` [${entity.kind}] ${entity.name} (memories: ${entity.memoryIds.join(", ")})`);
|
|
15
|
+
}
|
|
16
|
+
console.log("Relations:");
|
|
17
|
+
for (const edge of knowledge.relations) {
|
|
18
|
+
console.log(` [${edge.sourceKind}] ${edge.sourceName} --${edge.relation}--> [${edge.targetKind}] ${edge.targetName} (memories: ${edge.memoryIds.join(", ")})`);
|
|
19
|
+
}
|
|
20
|
+
if (!knowledge.entities.length)
|
|
21
|
+
console.log(" No entities captured.");
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (!ctx)
|
|
25
|
+
context.db.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { listSupersessions } from "../../core/storage/supersessionRepo.js";
|
|
2
|
+
import { createCliContext } from "../context.js";
|
|
3
|
+
export function historyCommand(options = {}, ctx) {
|
|
4
|
+
const context = ctx ?? createCliContext();
|
|
5
|
+
try {
|
|
6
|
+
const projectId = options.project ?? context.projectId;
|
|
7
|
+
const links = listSupersessions(context.db, projectId);
|
|
8
|
+
if (options.json) {
|
|
9
|
+
console.log(JSON.stringify({ projectId, links }, null, 2));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log(`Supersession links for ${projectId}:`);
|
|
13
|
+
for (const link of links) {
|
|
14
|
+
console.log(` ${link.supersedes} -> ${link.supersededBy} at ${link.supersededAt} (${link.entity} ${link.relation}: ${link.oldValue} -> ${link.newValue}; ${link.reason}; ${link.fullySuperseded ? "superseded" : "partially superseded"})`);
|
|
15
|
+
}
|
|
16
|
+
if (!links.length)
|
|
17
|
+
console.log(" No supersessions captured.");
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
if (!ctx)
|
|
21
|
+
context.db.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
import { createCliContext } from "../context.js";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
const CAPTURED_TOOL_TYPES = new Set(["Bash", "Edit", "Write", "MultiEdit"]);
|
|
5
|
-
// Max payload size stored per hook event (chars). The full hook payload can be
|
|
6
|
-
// large (file diffs); cap it so a single tool use can't bloat session_events.
|
|
7
|
-
const MAX_HOOK_PAYLOAD_CHARS = 4000;
|
|
8
|
-
// Per-field cap applied BEFORE serialization. Truncating the serialized JSON
|
|
9
|
-
// instead would produce a string that is no longer valid JSON, which the
|
|
10
|
-
// `payloadJson` contract rejects (it parses the value) — the event would then be
|
|
11
|
-
// dropped, silently, by the catch-all below. Trim the inputs, not the output.
|
|
12
|
-
const MAX_FIELD_CHARS = 1200;
|
|
13
|
-
// One-line human summary shown in session summaries (summaryShape reads `text`).
|
|
14
|
-
const MAX_TEXT_CHARS = 300;
|
|
3
|
+
import { buildToolEvent } from "../../core/capture/toolEvent.js";
|
|
15
4
|
/** Emit a hook diagnostic to stderr when SESSIONMEM_DEBUG=1. */
|
|
16
5
|
function debug(message) {
|
|
17
6
|
if (process.env.SESSIONMEM_DEBUG === "1") {
|
|
18
7
|
process.stderr.write(`[sessionmem] ingest-hook: ${message}\n`);
|
|
19
8
|
}
|
|
20
9
|
}
|
|
21
|
-
function truncate(value, max) {
|
|
22
|
-
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
23
|
-
}
|
|
24
10
|
async function readStdinJson() {
|
|
25
11
|
if (process.stdin.isTTY)
|
|
26
12
|
return {};
|
|
@@ -56,65 +42,6 @@ function resolveSessionId(payload) {
|
|
|
56
42
|
}
|
|
57
43
|
return process.env.CLAUDE_CODE_SESSION_ID ?? process.env.SESSION_ID ?? `session-${Date.now()}`;
|
|
58
44
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Shorten every string in a tool input to MAX_FIELD_CHARS, recursively. Keeps
|
|
61
|
-
* the object's shape (so it still serializes to valid JSON) while bounding the
|
|
62
|
-
* size contributed by large fields such as a Write tool's `content` or an Edit
|
|
63
|
-
* tool's replacement text.
|
|
64
|
-
*/
|
|
65
|
-
function shrinkValue(value, depth = 0) {
|
|
66
|
-
if (typeof value === "string")
|
|
67
|
-
return truncate(value, MAX_FIELD_CHARS);
|
|
68
|
-
if (depth >= 4)
|
|
69
|
-
return undefined;
|
|
70
|
-
if (Array.isArray(value)) {
|
|
71
|
-
return value.slice(0, 20).map((item) => shrinkValue(item, depth + 1));
|
|
72
|
-
}
|
|
73
|
-
if (value && typeof value === "object") {
|
|
74
|
-
const out = {};
|
|
75
|
-
for (const [key, inner] of Object.entries(value)) {
|
|
76
|
-
out[key] = shrinkValue(inner, depth + 1);
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
// Numbers/booleans/null pass through; functions/undefined/symbols drop out.
|
|
81
|
-
return value === undefined || typeof value === "function" || typeof value === "symbol"
|
|
82
|
-
? undefined
|
|
83
|
-
: value;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* A short human-readable line describing the tool use. Stored alongside the
|
|
87
|
-
* structured input as `text`, which the local summarizer prefers over the raw
|
|
88
|
-
* payload — so session summaries read as prose instead of dumped JSON.
|
|
89
|
-
*/
|
|
90
|
-
function describeToolUse(toolName, input) {
|
|
91
|
-
const str = (key) => typeof input?.[key] === "string" ? input[key] : "";
|
|
92
|
-
if (toolName === "Bash") {
|
|
93
|
-
const command = str("command").replace(/\s+/g, " ").trim();
|
|
94
|
-
return truncate(command ? `Ran: ${command}` : "Ran a shell command", MAX_TEXT_CHARS);
|
|
95
|
-
}
|
|
96
|
-
const filePath = str("file_path") || str("path") || str("notebook_path");
|
|
97
|
-
const verb = toolName === "Write" ? "Wrote" : "Edited";
|
|
98
|
-
return truncate(filePath ? `${verb} ${filePath}` : `${verb} a file`, MAX_TEXT_CHARS);
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Serialize the event payload, guaranteeing valid JSON within
|
|
102
|
-
* MAX_HOOK_PAYLOAD_CHARS. Per-field shrinking handles the common case; if the
|
|
103
|
-
* result is still over budget (many fields, or one huge key set), drop the
|
|
104
|
-
* structured input and keep only the human-readable summary rather than
|
|
105
|
-
* emitting truncated — and therefore unparseable — JSON.
|
|
106
|
-
*/
|
|
107
|
-
function buildPayloadJson(toolName, text, input, cwd) {
|
|
108
|
-
const full = JSON.stringify({
|
|
109
|
-
tool: toolName,
|
|
110
|
-
text,
|
|
111
|
-
input: shrinkValue(input),
|
|
112
|
-
cwd,
|
|
113
|
-
});
|
|
114
|
-
if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
|
|
115
|
-
return full;
|
|
116
|
-
return JSON.stringify({ tool: toolName, text, cwd, truncated: true });
|
|
117
|
-
}
|
|
118
45
|
/**
|
|
119
46
|
* `sessionmem ingest-hook` — PostToolUse hook handler.
|
|
120
47
|
* Reads Claude Code's PostToolUse JSON payload from stdin and stores it as a
|
|
@@ -126,14 +53,14 @@ export async function ingestHookCommand(ctx) {
|
|
|
126
53
|
try {
|
|
127
54
|
const payload = await readStdinJson();
|
|
128
55
|
const toolName = payload.tool_name ?? "";
|
|
129
|
-
|
|
56
|
+
const event = buildToolEvent(toolName, payload.tool_input, payload.cwd);
|
|
57
|
+
if (!event) {
|
|
130
58
|
debug(`skipped: tool "${toolName}" is not captured`);
|
|
131
59
|
return;
|
|
132
60
|
}
|
|
133
61
|
context = ctx ?? createCliContext();
|
|
134
62
|
const sessionId = resolveSessionId(payload);
|
|
135
63
|
const projectId = context.projectId;
|
|
136
|
-
const text = describeToolUse(toolName, payload.tool_input);
|
|
137
64
|
// eventIndex is intentionally omitted: the service assigns the next free
|
|
138
65
|
// index per session inside an immediate transaction, so parallel tool calls
|
|
139
66
|
// cannot collide (see nextSessionEventIndex).
|
|
@@ -142,8 +69,7 @@ export async function ingestHookCommand(ctx) {
|
|
|
142
69
|
sessionId,
|
|
143
70
|
events: [{
|
|
144
71
|
id: randomUUID(),
|
|
145
|
-
|
|
146
|
-
payloadJson: buildPayloadJson(toolName, text, payload.tool_input, payload.cwd),
|
|
72
|
+
...event,
|
|
147
73
|
}],
|
|
148
74
|
});
|
|
149
75
|
if (!result.ok) {
|
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveInstallAdapters } from "../../adapters/installedHosts.js";
|
|
5
5
|
import { injectGuidanceBlock } from "../../adapters/claudeMdInjector.js";
|
|
6
6
|
import { createCliContext, resolveDbPath } from "../context.js";
|
|
7
|
-
/**
|
|
8
|
-
* Resolve the adapter to install into: an explicit `--adapter` flag wins, then
|
|
9
|
-
* the SESSIONMEM_ADAPTER env override, else auto-detection from the host env.
|
|
10
|
-
* Throws (via AdapterFactory.forName) on an unknown explicit name so the CLI
|
|
11
|
-
* surfaces a clear error rather than silently installing the generic adapter.
|
|
12
|
-
*/
|
|
13
|
-
function resolveAdapter(options) {
|
|
14
|
-
const explicit = options?.adapter && options.adapter.trim() !== ""
|
|
15
|
-
? options.adapter.trim()
|
|
16
|
-
: process.env.SESSIONMEM_ADAPTER && process.env.SESSIONMEM_ADAPTER.trim() !== ""
|
|
17
|
-
? process.env.SESSIONMEM_ADAPTER.trim()
|
|
18
|
-
: undefined;
|
|
19
|
-
if (explicit) {
|
|
20
|
-
return { adapter: AdapterFactory.forName(explicit), forced: true };
|
|
21
|
-
}
|
|
22
|
-
return { adapter: AdapterFactory.detectAdapter(), forced: false };
|
|
23
|
-
}
|
|
24
7
|
import { configFilePath, writePolicyConfig, DEFAULT_POLICY_CONFIG, } from "../../core/config/policyConfig.js";
|
|
25
8
|
export const MANUAL_CONFIG_BLOCK = JSON.stringify({
|
|
26
9
|
mcpServers: {
|
|
@@ -37,10 +20,10 @@ export function printManualFallback(adapterName) {
|
|
|
37
20
|
export async function installCommand(options, contextOverrides) {
|
|
38
21
|
// Resolve the target adapter up front so an invalid `--adapter` fails before
|
|
39
22
|
// any filesystem work (DB init, config write).
|
|
40
|
-
let
|
|
23
|
+
let adapters;
|
|
41
24
|
let forced;
|
|
42
25
|
try {
|
|
43
|
-
({
|
|
26
|
+
({ adapters, forced } = resolveInstallAdapters(options));
|
|
44
27
|
}
|
|
45
28
|
catch (err) {
|
|
46
29
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -51,6 +34,8 @@ export async function installCommand(options, contextOverrides) {
|
|
|
51
34
|
try {
|
|
52
35
|
const ctx = createCliContext(contextOverrides);
|
|
53
36
|
dbPath = ctx.dbPath;
|
|
37
|
+
if (!contextOverrides?.db)
|
|
38
|
+
ctx.db.close();
|
|
54
39
|
}
|
|
55
40
|
catch (err) {
|
|
56
41
|
console.error(`✗ DB init failed (${resolveDbPath(contextOverrides?.dbPath)}): ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -69,47 +54,40 @@ export async function installCommand(options, contextOverrides) {
|
|
|
69
54
|
writePolicyConfig(configPath, { ...DEFAULT_POLICY_CONFIG });
|
|
70
55
|
console.log(`✓ config.json initialized (${configPath})`);
|
|
71
56
|
}
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!adapter.install) {
|
|
79
|
-
console.error(`✗ ${adapter.name} config update failed`);
|
|
80
|
-
printManualFallback(adapter.name);
|
|
81
|
-
process.exit(1);
|
|
82
|
-
}
|
|
83
|
-
const success = await adapter.install();
|
|
84
|
-
if (!success) {
|
|
85
|
-
console.error(`✗ ${adapter.name} config update failed`);
|
|
86
|
-
printManualFallback(adapter.name);
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
console.log(`✓ ${adapter.name} config updated`);
|
|
90
|
-
// Step 3: Guidance injection — non-fatal. Inject the sessionmem instruction
|
|
91
|
-
// block into the file(s) the detected host actually reads at session start so
|
|
92
|
-
// the agent automatically knows the MCP exists and how to use it. Adapters
|
|
93
|
-
// declare their own target(s); when none are declared (e.g. a minimal mock or
|
|
94
|
-
// an unknown host) fall back to the global Claude Code memory file.
|
|
95
|
-
const guidanceTargets = adapter.guidanceTargets?.() ?? [join(homedir(), ".claude", "CLAUDE.md")];
|
|
96
|
-
for (const target of guidanceTargets) {
|
|
57
|
+
// Try every detected host even when one host's config cannot be updated.
|
|
58
|
+
let failed = false;
|
|
59
|
+
for (const adapter of adapters) {
|
|
60
|
+
console.log(forced
|
|
61
|
+
? `→ Installing for adapter: ${adapter.name} (forced)`
|
|
62
|
+
: `→ Detected host: ${adapter.name} (override with --adapter <name>)`);
|
|
97
63
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
console.log(`✓ Agent guidance injected (${target})`);
|
|
64
|
+
if (!adapter.install || !(await adapter.install())) {
|
|
65
|
+
throw new Error("config update failed");
|
|
101
66
|
}
|
|
102
|
-
|
|
103
|
-
|
|
67
|
+
console.log(`✓ ${adapter.name} config updated`);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(`✗ ${adapter.name} config update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
printManualFallback(adapter.name);
|
|
72
|
+
failed = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const guidanceTargets = adapter.guidanceTargets?.() ?? [join(homedir(), ".claude", "CLAUDE.md")];
|
|
76
|
+
for (const target of guidanceTargets) {
|
|
77
|
+
try {
|
|
78
|
+
if (!injectGuidanceBlock(target))
|
|
79
|
+
throw new Error("injection failed");
|
|
80
|
+
console.log(`✓ ${adapter.name}: Agent guidance injected (${target})`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
console.error(`✗ ${adapter.name}: Agent guidance injection failed (non-fatal): ${target}`);
|
|
104
84
|
}
|
|
105
85
|
}
|
|
106
|
-
|
|
107
|
-
console.
|
|
86
|
+
if (adapter.name === "Claude Code") {
|
|
87
|
+
console.log("✓ Claude Code: SessionStart, SessionEnd, PostToolUse hooks configured (~/.claude/settings.json)");
|
|
108
88
|
}
|
|
109
89
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
console.log("✓ Auto-injection hook installed (~/.claude/settings.json) — prior memories load automatically at the start of every Claude Code session");
|
|
113
|
-
}
|
|
90
|
+
if (failed)
|
|
91
|
+
process.exit(1);
|
|
114
92
|
console.log("✓ sessionmem ready — restart your editor/agent so it picks up the MCP server");
|
|
115
93
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { printSweepSummary, sweepTranscripts } from "../transcriptSweep.js";
|
|
3
|
+
export async function scanCommand(options = {}, ctx) {
|
|
4
|
+
const context = ctx ?? createCliContext();
|
|
5
|
+
try {
|
|
6
|
+
const summary = await sweepTranscripts(context, options);
|
|
7
|
+
printSweepSummary("scan", summary);
|
|
8
|
+
if (summary.errors)
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
return summary;
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
if (!ctx)
|
|
14
|
+
context.db.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join } from "path";
|
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
4
|
import { configFilePath, readPolicyConfig, writePolicyConfig, } from "../../core/config/policyConfig.js";
|
|
5
5
|
import { createCliContext } from "../context.js";
|
|
6
|
+
import { reconcileProjectSupersessions } from "../../core/storage/supersessionRepo.js";
|
|
6
7
|
function resolvePath(options) {
|
|
7
8
|
return options?.configPath ?? configFilePath();
|
|
8
9
|
}
|
|
@@ -89,8 +90,11 @@ export function teamDisableCommand(options, ctx) {
|
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
92
|
const context = ctx ?? createCliContext();
|
|
92
|
-
const result = context.db
|
|
93
|
-
.prepare("DELETE FROM memories WHERE project_id = ? AND author != ? AND author != ''")
|
|
94
|
-
|
|
93
|
+
const result = context.db.transaction(() => {
|
|
94
|
+
const removed = context.db.prepare("DELETE FROM memories WHERE project_id = ? AND author != ? AND author != ''").run(context.projectId, context.username);
|
|
95
|
+
if (removed.changes)
|
|
96
|
+
reconcileProjectSupersessions(context.db, context.projectId);
|
|
97
|
+
return removed;
|
|
98
|
+
})();
|
|
95
99
|
console.log(`Team mode disabled. Removed ${result.changes} teammate-authored ${result.changes === 1 ? "memory" : "memories"}.`);
|
|
96
100
|
}
|