sessionmem 1.2.0 → 1.4.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 +84 -0
- package/CONTRIBUTING.md +130 -0
- package/README.md +153 -82
- package/SECURITY.md +33 -0
- package/dist/cli/commands/dedupe.js +89 -22
- package/dist/cli/commands/import.js +15 -44
- package/dist/cli/commands/ingestHook.js +107 -18
- package/dist/cli/commands/install.js +3 -3
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/commands/run.js +10 -12
- package/dist/cli/commands/savings.js +3 -1
- package/dist/cli/commands/search.js +8 -4
- package/dist/cli/commands/stats.js +7 -0
- package/dist/cli/commands/uninstall.js +2 -1
- package/dist/cli/context.js +8 -6
- package/dist/cli/index.js +8 -17
- package/dist/cli/projectId.js +48 -2
- package/dist/core/api/contracts.js +6 -1
- package/dist/core/api/memoryCoreService.js +17 -3
- package/dist/core/config/policyConfig.js +5 -1
- package/dist/core/injection/tokenBudget.js +10 -2
- package/dist/core/retrieve/retrieveMemories.js +1 -1
- package/dist/core/schema/runMigrations.js +9 -5
- package/dist/core/storage/db.js +31 -9
- package/dist/core/storage/memoryRepo.js +1 -1
- package/dist/core/storage/memorySearchRepo.js +36 -14
- package/dist/core/storage/sessionEventsRepo.js +21 -0
- package/dist/core/summarize/cloudSummarizer.js +3 -8
- package/dist/core/summarize/redaction.js +70 -10
- package/dist/core/summarize/summaryShape.js +50 -20
- package/docs/architecture.md +53 -0
- package/docs/benchmark.md +50 -0
- package/docs/cloud-summarization.md +57 -0
- package/docs/migration.md +42 -0
- package/docs/privacy-and-retention.md +139 -0
- package/docs/team-mode.md +117 -0
- package/docs/troubleshooting.md +78 -0
- package/docs/typescript-upgrade.md +48 -0
- package/package.json +26 -13
- package/dist/core/session.js +0 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCliContext } from "../context.js";
|
|
2
2
|
import { listMemoriesByProject } from "../../core/storage/memoryRepo.js";
|
|
3
3
|
const SIMILARITY_THRESHOLD = 0.85;
|
|
4
|
-
const
|
|
4
|
+
const MAX_PREVIEW_LEN = 200;
|
|
5
5
|
function cosineSimilarity(a, b) {
|
|
6
6
|
if (a.length !== b.length || a.length === 0)
|
|
7
7
|
return 0;
|
|
@@ -14,31 +14,82 @@ function cosineSimilarity(a, b) {
|
|
|
14
14
|
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
15
15
|
return denom === 0 ? 0 : dot / denom;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse and validate the `--threshold` flag. An unparseable or out-of-range
|
|
19
|
+
* value used to flow through as NaN, and every `sim >= NaN` comparison is
|
|
20
|
+
* false — so `--threshold banana` reported "no duplicates found" rather than
|
|
21
|
+
* telling the user their input was rejected.
|
|
22
|
+
*/
|
|
23
|
+
function resolveThreshold(raw) {
|
|
24
|
+
if (raw === undefined)
|
|
25
|
+
return SIMILARITY_THRESHOLD;
|
|
26
|
+
const parsed = Number(raw.trim());
|
|
27
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) {
|
|
28
|
+
console.error(`Invalid --threshold "${raw}": expected a number greater than 0 and at most 1.`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
/** Parse a stored embedding, returning null when it is absent or malformed. */
|
|
34
|
+
function parseVector(embedding) {
|
|
35
|
+
if (!embedding)
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(embedding);
|
|
39
|
+
if (!Array.isArray(parsed) || !parsed.every((n) => Number.isFinite(n))) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
17
48
|
export async function dedupeCommand(options = {}, ctx) {
|
|
18
49
|
const context = ctx ?? createCliContext();
|
|
19
|
-
const threshold =
|
|
50
|
+
const threshold = resolveThreshold(options.threshold);
|
|
20
51
|
const dryRun = options.dry !== false;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
// Parse each embedding ONCE. The inner loop used to re-parse the same
|
|
53
|
+
// candidate's JSON for every outer element — O(n²) JSON.parse calls over a
|
|
54
|
+
// list that only has n distinct vectors.
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const memory of listMemoriesByProject(context.db, context.projectId)) {
|
|
57
|
+
const vector = parseVector(memory.embedding);
|
|
58
|
+
if (!vector || !memory.embedding_dim)
|
|
59
|
+
continue;
|
|
60
|
+
candidates.push({
|
|
61
|
+
id: memory.id,
|
|
62
|
+
content: memory.content,
|
|
63
|
+
importance: memory.importance,
|
|
64
|
+
vector,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (candidates.length < 2) {
|
|
24
68
|
process.stdout.write("Not enough memories with embeddings to compare.\n");
|
|
25
69
|
return;
|
|
26
70
|
}
|
|
71
|
+
// Highest importance first, id as the tie-break. Pairs are emitted in this
|
|
72
|
+
// order and the first element of a pair is the one kept, so the survivor of
|
|
73
|
+
// any duplicate cluster is deterministic.
|
|
74
|
+
//
|
|
75
|
+
// Without this the order came from `ORDER BY updated_at DESC`, which is not a
|
|
76
|
+
// total order — memories written in the same clock tick tie, and the winner
|
|
77
|
+
// varied by platform. The same three memories could collapse to a different
|
|
78
|
+
// survivor on Windows than on Linux, and `--apply` is a hard delete.
|
|
79
|
+
candidates.sort((left, right) => right.importance - left.importance || left.id.localeCompare(right.id));
|
|
27
80
|
const pairs = [];
|
|
28
|
-
for (let i = 0; i <
|
|
29
|
-
const a =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const vecB = JSON.parse(b.embedding);
|
|
34
|
-
const sim = cosineSimilarity(vecA, vecB);
|
|
81
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
82
|
+
const a = candidates[i];
|
|
83
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
84
|
+
const b = candidates[j];
|
|
85
|
+
const sim = cosineSimilarity(a.vector, b.vector);
|
|
35
86
|
if (sim >= threshold) {
|
|
36
87
|
pairs.push({
|
|
37
88
|
idA: a.id,
|
|
38
89
|
idB: b.id,
|
|
39
90
|
similarity: sim,
|
|
40
|
-
contentA: a.content.slice(0,
|
|
41
|
-
contentB: b.content.slice(0,
|
|
91
|
+
contentA: a.content.slice(0, MAX_PREVIEW_LEN),
|
|
92
|
+
contentB: b.content.slice(0, MAX_PREVIEW_LEN),
|
|
42
93
|
});
|
|
43
94
|
}
|
|
44
95
|
}
|
|
@@ -47,26 +98,42 @@ export async function dedupeCommand(options = {}, ctx) {
|
|
|
47
98
|
process.stdout.write(`No near-duplicate memories found (threshold: ${threshold}).\n`);
|
|
48
99
|
return;
|
|
49
100
|
}
|
|
101
|
+
const byId = new Map(candidates.map((c) => [c.id, c]));
|
|
102
|
+
const deleted = new Set();
|
|
50
103
|
process.stdout.write(`Found ${pairs.length} near-duplicate pair(s) (threshold: ${threshold}):\n\n`);
|
|
51
104
|
for (const pair of pairs) {
|
|
105
|
+
// Skip a pair whose partner is already gone. Similarity is not transitive:
|
|
106
|
+
// in a chain A~B, B~C with A≁C, deleting B for the (A,B) pair left the
|
|
107
|
+
// (B,C) pair still to process, and that pass could delete C — a memory
|
|
108
|
+
// whose only duplicate no longer existed. Nothing kept C from being
|
|
109
|
+
// dropped on the strength of an already-deleted row.
|
|
110
|
+
if (deleted.has(pair.idA) || deleted.has(pair.idB))
|
|
111
|
+
continue;
|
|
52
112
|
process.stdout.write(`Similarity: ${(pair.similarity * 100).toFixed(1)}%\n`);
|
|
53
|
-
process.stdout.write(` A [${pair.idA}]: ${pair.contentA}${pair.contentA.length >=
|
|
54
|
-
process.stdout.write(` B [${pair.idB}]: ${pair.contentB}${pair.contentB.length >=
|
|
113
|
+
process.stdout.write(` A [${pair.idA}]: ${pair.contentA}${pair.contentA.length >= MAX_PREVIEW_LEN ? "…" : ""}\n`);
|
|
114
|
+
process.stdout.write(` B [${pair.idB}]: ${pair.contentB}${pair.contentB.length >= MAX_PREVIEW_LEN ? "…" : ""}\n`);
|
|
55
115
|
if (!dryRun) {
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
116
|
+
const memA = byId.get(pair.idA);
|
|
117
|
+
const memB = byId.get(pair.idB);
|
|
118
|
+
if (!memA || !memB)
|
|
119
|
+
continue;
|
|
59
120
|
const keepId = memA.importance >= memB.importance ? pair.idA : pair.idB;
|
|
60
121
|
const dropId = keepId === pair.idA ? pair.idB : pair.idA;
|
|
61
|
-
await context.service.call("forgetMemory", {
|
|
122
|
+
const result = await context.service.call("forgetMemory", {
|
|
62
123
|
projectId: context.projectId,
|
|
63
124
|
memoryId: dropId,
|
|
64
125
|
});
|
|
65
|
-
|
|
126
|
+
if (result.ok) {
|
|
127
|
+
deleted.add(dropId);
|
|
128
|
+
process.stdout.write(` → Deleted ${dropId} (kept ${keepId})\n`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
process.stdout.write(` → Failed to delete ${dropId}: ${result.error.message}\n`);
|
|
132
|
+
}
|
|
66
133
|
}
|
|
67
134
|
process.stdout.write("\n");
|
|
68
135
|
}
|
|
69
|
-
if (dryRun
|
|
136
|
+
if (dryRun) {
|
|
70
137
|
process.stdout.write(`Run with --apply to delete the lower-importance duplicate in each pair.\n`);
|
|
71
138
|
}
|
|
72
139
|
}
|
|
@@ -14,7 +14,7 @@ export async function importCommand(pathArg, options, ctx) {
|
|
|
14
14
|
// Read and parse the JSON file
|
|
15
15
|
let parsed;
|
|
16
16
|
try {
|
|
17
|
-
const raw =
|
|
17
|
+
const raw = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(inPath));
|
|
18
18
|
parsed = JSON.parse(raw);
|
|
19
19
|
}
|
|
20
20
|
catch (err) {
|
|
@@ -27,61 +27,32 @@ export async function importCommand(pathArg, options, ctx) {
|
|
|
27
27
|
console.error("Import file must be a JSON array of memory records.");
|
|
28
28
|
process.exit(1);
|
|
29
29
|
}
|
|
30
|
-
const
|
|
31
|
-
let toImport = records;
|
|
32
|
-
let skippedCount = 0;
|
|
33
|
-
if (!options.merge) {
|
|
34
|
-
// By default, skip existing IDs (pre-filter). `id` is a
|
|
35
|
-
// globally-unique PRIMARY KEY (not scoped by project), so the duplicate
|
|
36
|
-
// check must consider every project's ids -- otherwise a record whose id
|
|
37
|
-
// collides with another project's memory would not be pre-filtered here
|
|
38
|
-
// and could fall through to the service's ON CONFLICT(id) upsert.
|
|
39
|
-
const existingIds = listAllMemoryIds(context.db);
|
|
40
|
-
const filtered = records.filter((r) => {
|
|
41
|
-
const id = typeof r.id === "string" ? r.id : undefined;
|
|
42
|
-
return id === undefined || !existingIds.has(id);
|
|
43
|
-
});
|
|
44
|
-
skippedCount = records.length - filtered.length;
|
|
45
|
-
toImport = filtered;
|
|
46
|
-
}
|
|
47
|
-
if (toImport.length === 0 && skippedCount > 0) {
|
|
48
|
-
console.log(`Imported 0, skipped ${skippedCount} duplicates.`);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
// Map to the expected shape, then validate each record before sending to the service
|
|
52
|
-
const mapped = toImport.map((r) => ({
|
|
53
|
-
id: r.id,
|
|
54
|
-
projectId: r.projectId ?? context.projectId,
|
|
55
|
-
sessionId: r.sessionId,
|
|
56
|
-
sourceAdapter: r.sourceAdapter,
|
|
57
|
-
kind: r.kind,
|
|
58
|
-
content: r.content,
|
|
59
|
-
importance: r.importance,
|
|
60
|
-
createdAt: r.createdAt,
|
|
61
|
-
updatedAt: r.updatedAt,
|
|
62
|
-
}));
|
|
63
|
-
// Validate each record but skip-and-warn on individual invalid
|
|
64
|
-
// records (consistent with the duplicate-skip UX) instead of aborting the
|
|
65
|
-
// entire import on the first invalid record, which would discard earlier
|
|
66
|
-
// valid records with no partial import.
|
|
30
|
+
const existingIds = options.merge ? undefined : listAllMemoryIds(context.db);
|
|
67
31
|
const validMemories = [];
|
|
32
|
+
let skippedCount = 0;
|
|
68
33
|
let invalidCount = 0;
|
|
69
|
-
for (
|
|
70
|
-
const
|
|
34
|
+
for (const [index, record] of parsed.entries()) {
|
|
35
|
+
const isRecord = record !== null && typeof record === "object" && !Array.isArray(record);
|
|
36
|
+
if (isRecord && typeof record.id === "string" && existingIds?.has(record.id)) {
|
|
37
|
+
skippedCount += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Validate unknown values before accessing fields. Keep the source index in
|
|
41
|
+
// diagnostics even when earlier records were duplicates, and preserve the
|
|
42
|
+
// optional provenance fields carried by exports.
|
|
43
|
+
const check = importMemoryRecordSchema.safeParse(isRecord ? { ...record, projectId: record.projectId ?? context.projectId } : record);
|
|
71
44
|
if (!check.success) {
|
|
72
|
-
console.error(`Record at index ${
|
|
45
|
+
console.error(`Record at index ${index} is invalid, skipping: ${check.error.message}`);
|
|
73
46
|
invalidCount += 1;
|
|
74
47
|
continue;
|
|
75
48
|
}
|
|
76
|
-
// Use the parsed/transformed record so `kind` is narrowed to the
|
|
77
|
-
// canonical enum (memoryKindSchema maps legacy values like `architecture`).
|
|
78
49
|
validMemories.push(check.data);
|
|
79
50
|
}
|
|
80
51
|
if (validMemories.length === 0) {
|
|
81
52
|
if (invalidCount > 0) {
|
|
82
53
|
console.log(`Imported 0, skipped ${invalidCount} invalid record(s).`);
|
|
83
54
|
}
|
|
84
|
-
else
|
|
55
|
+
else {
|
|
85
56
|
console.log(`Imported 0, skipped ${skippedCount} duplicates.`);
|
|
86
57
|
}
|
|
87
58
|
return;
|
|
@@ -5,6 +5,22 @@ const CAPTURED_TOOL_TYPES = new Set(["Bash", "Edit", "Write", "MultiEdit"]);
|
|
|
5
5
|
// Max payload size stored per hook event (chars). The full hook payload can be
|
|
6
6
|
// large (file diffs); cap it so a single tool use can't bloat session_events.
|
|
7
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;
|
|
15
|
+
/** Emit a hook diagnostic to stderr when SESSIONMEM_DEBUG=1. */
|
|
16
|
+
function debug(message) {
|
|
17
|
+
if (process.env.SESSIONMEM_DEBUG === "1") {
|
|
18
|
+
process.stderr.write(`[sessionmem] ingest-hook: ${message}\n`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function truncate(value, max) {
|
|
22
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
23
|
+
}
|
|
8
24
|
async function readStdinJson() {
|
|
9
25
|
if (process.stdin.isTTY)
|
|
10
26
|
return {};
|
|
@@ -40,6 +56,65 @@ function resolveSessionId(payload) {
|
|
|
40
56
|
}
|
|
41
57
|
return process.env.CLAUDE_CODE_SESSION_ID ?? process.env.SESSION_ID ?? `session-${Date.now()}`;
|
|
42
58
|
}
|
|
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
|
+
}
|
|
43
118
|
/**
|
|
44
119
|
* `sessionmem ingest-hook` — PostToolUse hook handler.
|
|
45
120
|
* Reads Claude Code's PostToolUse JSON payload from stdin and stores it as a
|
|
@@ -47,34 +122,36 @@ function resolveSessionId(payload) {
|
|
|
47
122
|
* Also auto-stores git commits as decision memories (git commit detection).
|
|
48
123
|
*/
|
|
49
124
|
export async function ingestHookCommand(ctx) {
|
|
125
|
+
let context;
|
|
50
126
|
try {
|
|
51
127
|
const payload = await readStdinJson();
|
|
52
128
|
const toolName = payload.tool_name ?? "";
|
|
53
|
-
if (!CAPTURED_TOOL_TYPES.has(toolName))
|
|
129
|
+
if (!CAPTURED_TOOL_TYPES.has(toolName)) {
|
|
130
|
+
debug(`skipped: tool "${toolName}" is not captured`);
|
|
54
131
|
return;
|
|
55
|
-
|
|
132
|
+
}
|
|
133
|
+
context = ctx ?? createCliContext();
|
|
56
134
|
const sessionId = resolveSessionId(payload);
|
|
57
135
|
const projectId = context.projectId;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
});
|
|
64
|
-
const trimmedPayload = fullPayload.length > MAX_HOOK_PAYLOAD_CHARS
|
|
65
|
-
? fullPayload.slice(0, MAX_HOOK_PAYLOAD_CHARS) + "…"
|
|
66
|
-
: fullPayload;
|
|
67
|
-
// Store as a session event (for SessionEnd auto-summarize).
|
|
68
|
-
await context.service.call("ingestSessionEvents", {
|
|
136
|
+
const text = describeToolUse(toolName, payload.tool_input);
|
|
137
|
+
// eventIndex is intentionally omitted: the service assigns the next free
|
|
138
|
+
// index per session inside an immediate transaction, so parallel tool calls
|
|
139
|
+
// cannot collide (see nextSessionEventIndex).
|
|
140
|
+
const result = await context.service.call("ingestSessionEvents", {
|
|
69
141
|
projectId,
|
|
70
142
|
sessionId,
|
|
71
143
|
events: [{
|
|
72
144
|
id: randomUUID(),
|
|
73
|
-
eventIndex: Date.now(), // monotonic within the session
|
|
74
145
|
eventType: `tool_use:${toolName.toLowerCase()}`,
|
|
75
|
-
payloadJson:
|
|
146
|
+
payloadJson: buildPayloadJson(toolName, text, payload.tool_input, payload.cwd),
|
|
76
147
|
}],
|
|
77
148
|
});
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
debug(`ingest failed: ${result.error.message}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
debug(`ingested ${result.ingested} event(s) for session ${sessionId} (project ${projectId})`);
|
|
154
|
+
}
|
|
78
155
|
// Git commit detection: auto-store as a decision memory.
|
|
79
156
|
if (toolName === "Bash") {
|
|
80
157
|
const cmd = typeof payload.tool_input?.command === "string"
|
|
@@ -96,9 +173,21 @@ export async function ingestHookCommand(ctx) {
|
|
|
96
173
|
}
|
|
97
174
|
}
|
|
98
175
|
}
|
|
99
|
-
context.db.close();
|
|
100
176
|
}
|
|
101
|
-
catch {
|
|
102
|
-
// Never block tool use —
|
|
177
|
+
catch (err) {
|
|
178
|
+
// Never block tool use — a hook failure must not surface as a tool error.
|
|
179
|
+
debug(err instanceof Error ? err.message : String(err));
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
// Close only a context this command opened; an injected ctx belongs to the
|
|
183
|
+
// caller (tests reuse it across assertions). Previously the close was
|
|
184
|
+
// inside the try block, so it was skipped on every error path and leaked
|
|
185
|
+
// the sqlite handle.
|
|
186
|
+
if (!ctx && context) {
|
|
187
|
+
try {
|
|
188
|
+
context.db.close();
|
|
189
|
+
}
|
|
190
|
+
catch { /* already closed */ }
|
|
191
|
+
}
|
|
103
192
|
}
|
|
104
193
|
}
|
|
@@ -3,7 +3,7 @@ import { join } from "path";
|
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { AdapterFactory } from "../../adapters/factory.js";
|
|
5
5
|
import { injectGuidanceBlock } from "../../adapters/claudeMdInjector.js";
|
|
6
|
-
import { createCliContext } from "../context.js";
|
|
6
|
+
import { createCliContext, resolveDbPath } from "../context.js";
|
|
7
7
|
/**
|
|
8
8
|
* Resolve the adapter to install into: an explicit `--adapter` flag wins, then
|
|
9
9
|
* the SESSIONMEM_ADAPTER env override, else auto-detection from the host env.
|
|
@@ -53,8 +53,8 @@ export async function installCommand(options, contextOverrides) {
|
|
|
53
53
|
dbPath = ctx.dbPath;
|
|
54
54
|
}
|
|
55
55
|
catch (err) {
|
|
56
|
-
console.error(`✗ DB init failed (
|
|
57
|
-
console.error("Hint: ensure
|
|
56
|
+
console.error(`✗ DB init failed (${resolveDbPath(contextOverrides?.dbPath)}): ${err instanceof Error ? err.message : String(err)}`);
|
|
57
|
+
console.error("Hint: ensure the database parent directory is writable and run `sessionmem install` again.");
|
|
58
58
|
process.exit(1);
|
|
59
59
|
}
|
|
60
60
|
console.log(`✓ DB initialized (${dbPath})`);
|
|
@@ -10,4 +10,7 @@ export async function listCommand(ctx) {
|
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
console.log(formatTable(result.memories));
|
|
13
|
+
if (result.total > result.memories.length) {
|
|
14
|
+
console.error(`Showing ${result.memories.length} of ${result.total} memories. Use \`sessionmem export\` for the full project.`);
|
|
15
|
+
}
|
|
13
16
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { AdapterFactory } from "../../adapters/factory.js";
|
|
2
2
|
import { mkdirSync, writeFileSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
5
4
|
import { deriveProjectId } from "../projectId.js";
|
|
6
|
-
import {
|
|
5
|
+
import { resolveDbPath } from "../context.js";
|
|
7
6
|
export async function runMcpServer() {
|
|
8
7
|
const adapter = AdapterFactory.detectAdapter();
|
|
9
|
-
//
|
|
10
|
-
|
|
8
|
+
// Keep diagnostics beside the selected database so an override never
|
|
9
|
+
// creates the default home directory just to write a log.
|
|
10
|
+
const dbPath = resolveDbPath();
|
|
11
|
+
const logDir = join(dirname(dbPath), "logs");
|
|
11
12
|
const logPath = join(logDir, "mcp.log");
|
|
12
|
-
// Derive db path for diagnostics (mirrors context.ts defaultDbPath)
|
|
13
|
-
const envDbPath = process.env.SESSIONMEM_DB_PATH;
|
|
14
|
-
const dbPath = envDbPath && envDbPath.trim() !== ""
|
|
15
|
-
? expandTilde(envDbPath)
|
|
16
|
-
: join(homedir(), ".sessionmem", "memories.db");
|
|
17
13
|
// Derive project ID for diagnostics. Shares the exact derivation the server
|
|
18
14
|
// uses (createCliContext → deriveProjectId) so the logged id never diverges
|
|
19
15
|
// from the bucket the server actually serves.
|
|
@@ -21,8 +17,10 @@ export async function runMcpServer() {
|
|
|
21
17
|
const adapterName = adapter.name;
|
|
22
18
|
const logMessage = `[${new Date().toISOString()}] Started sessionmem | adapter=${adapterName} db=${dbPath} project=${projectId}\n`;
|
|
23
19
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
if (dbPath !== ":memory:") {
|
|
21
|
+
mkdirSync(logDir, { recursive: true });
|
|
22
|
+
writeFileSync(logPath, logMessage, { flag: "a" });
|
|
23
|
+
}
|
|
26
24
|
}
|
|
27
25
|
catch {
|
|
28
26
|
// best-effort logging; ignore failures
|
|
@@ -63,7 +63,9 @@ export function savingsCommand(ctx, options) {
|
|
|
63
63
|
? [
|
|
64
64
|
"Storage compression:",
|
|
65
65
|
" No session data ingested yet.",
|
|
66
|
-
"
|
|
66
|
+
" Session events are captured automatically by the PostToolUse hook.",
|
|
67
|
+
" If this stays empty, run `sessionmem install` to (re)register the hook,",
|
|
68
|
+
" then check `sessionmem stats` for the session_events counter.",
|
|
67
69
|
]
|
|
68
70
|
: [
|
|
69
71
|
"Storage compression:",
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { createCliContext } from "../context.js";
|
|
2
2
|
import { formatTable } from "../output.js";
|
|
3
|
-
const DEFAULT_LIMIT =
|
|
3
|
+
const DEFAULT_LIMIT = 10;
|
|
4
4
|
function coerceLimit(value) {
|
|
5
5
|
if (value === undefined)
|
|
6
6
|
return DEFAULT_LIMIT;
|
|
7
|
-
const parsed = Number
|
|
8
|
-
|
|
7
|
+
const parsed = Number(value);
|
|
8
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
|
9
|
+
throw new Error(`Invalid --limit "${value}": expected an integer from 1 to 100.`);
|
|
10
|
+
}
|
|
11
|
+
return parsed;
|
|
9
12
|
}
|
|
10
13
|
export async function searchCommand(query, options = {}, ctx) {
|
|
14
|
+
const limit = coerceLimit(options.limit);
|
|
11
15
|
const context = ctx ?? createCliContext();
|
|
12
16
|
const result = await context.service.call("retrieveMemories", {
|
|
13
17
|
projectId: context.projectId,
|
|
14
18
|
query,
|
|
15
|
-
limit
|
|
19
|
+
limit,
|
|
16
20
|
mode: "auto",
|
|
17
21
|
depth: "default",
|
|
18
22
|
});
|
|
@@ -2,6 +2,7 @@ import { statSync } from "fs";
|
|
|
2
2
|
import { createCliContext } from "../context.js";
|
|
3
3
|
import { countTokens } from "../../core/injection/tokenBudget.js";
|
|
4
4
|
import { listMemoriesByProject } from "../../core/storage/memoryRepo.js";
|
|
5
|
+
import { countDistinctSessions } from "../../core/storage/tokenSavingsRepo.js";
|
|
5
6
|
import { configFilePath, readPolicyConfig, } from "../../core/config/policyConfig.js";
|
|
6
7
|
export async function statsCommand(ctx, options) {
|
|
7
8
|
const context = ctx ?? createCliContext();
|
|
@@ -38,7 +39,13 @@ export async function statsCommand(ctx, options) {
|
|
|
38
39
|
retentionLine = `Retention: ${retentionDays} days (${eligible} memories eligible for pruning)`;
|
|
39
40
|
}
|
|
40
41
|
const redactionLine = `Redaction: ${redactionEnabled ? "enabled" : "disabled"}`;
|
|
42
|
+
// Surface the session-event counters. The auto-ingest pipeline is silent by
|
|
43
|
+
// design (a hook must never fail a tool call), so without these lines a
|
|
44
|
+
// pipeline that captures nothing looks identical to one that works.
|
|
45
|
+
const sessions = countDistinctSessions(context.db, context.projectId);
|
|
41
46
|
process.stdout.write(`memories: ${result.totalMemories}\n` +
|
|
47
|
+
`sessions: ${sessions}\n` +
|
|
48
|
+
`session_events: ${result.totalSessionEvents}\n` +
|
|
42
49
|
`db_size_bytes: ${sizeBytes}\n` +
|
|
43
50
|
`total_content_tokens: ${totalTokens}\n` +
|
|
44
51
|
`${retentionLine}\n` +
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
+
import { resolveDbPath } from "../context.js";
|
|
4
5
|
import { AdapterFactory } from "../../adapters/factory.js";
|
|
5
6
|
import { removeClaudeMdBlock } from "../../adapters/claudeMdInjector.js";
|
|
6
7
|
/**
|
|
@@ -51,7 +52,7 @@ export async function uninstallCommand(options = {}) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
// Resolve dbPath: use injected override (for tests) or the default location
|
|
54
|
-
const dbPath = options.dbPath
|
|
55
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
55
56
|
if (options.purge) {
|
|
56
57
|
// --purge: delete only memories.db, not logs or the ~/.sessionmem directory
|
|
57
58
|
if (existsSync(dbPath)) {
|
package/dist/cli/context.js
CHANGED
|
@@ -42,19 +42,21 @@ export function expandTilde(p) {
|
|
|
42
42
|
}
|
|
43
43
|
return p;
|
|
44
44
|
}
|
|
45
|
-
function
|
|
45
|
+
export function resolveDbPath(override) {
|
|
46
|
+
if (override !== undefined)
|
|
47
|
+
return expandTilde(override);
|
|
46
48
|
// Env override seam (see deriveProjectId). Defaults to ~/.sessionmem/memories.db.
|
|
47
49
|
const envDbPath = process.env.SESSIONMEM_DB_PATH;
|
|
48
50
|
if (envDbPath && envDbPath.trim() !== "")
|
|
49
51
|
return expandTilde(envDbPath);
|
|
50
|
-
// `dir` is the fixed ~/.sessionmem dir computed above, not user input.
|
|
51
52
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
52
|
-
return join(
|
|
53
|
+
return join(homedir(), ".sessionmem", "memories.db");
|
|
53
54
|
}
|
|
54
55
|
export function createCliContext(overrides = {}) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const dbPath = resolveDbPath(overrides.dbPath);
|
|
57
|
+
if (!overrides.db && dbPath !== ":memory:") {
|
|
58
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
59
|
+
}
|
|
58
60
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
59
61
|
const migrationsDir = overrides.migrationsDir ?? join(here, "..", "core", "schema", "migrations");
|
|
60
62
|
const username = overrides.username ?? localUsername();
|