sessionmem 1.1.2 → 1.3.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/README.md +74 -42
- package/dist/adapters/claudeMdInjector.js +17 -1
- package/dist/adapters/generic.js +3 -0
- package/dist/adapters/global/claudeCode.js +8 -3
- package/dist/adapters/ide/installer.js +35 -8
- package/dist/cli/commands/browse.js +33 -0
- package/dist/cli/commands/dedupe.js +139 -0
- package/dist/cli/commands/ingestHook.js +193 -0
- package/dist/cli/commands/savings.js +3 -1
- package/dist/cli/commands/stats.js +7 -0
- package/dist/cli/index.js +23 -11
- package/dist/cli/projectId.js +48 -2
- package/dist/core/api/contracts.js +13 -1
- package/dist/core/api/memoryCoreService.js +40 -4
- package/dist/core/retrieve/decay.js +11 -4
- package/dist/core/retrieve/retrieveMemories.js +1 -1
- package/dist/core/schema/migrations/010_memory_tags.sql +9 -0
- package/dist/core/schema/migrations/011_memory_expiry.sql +9 -0
- package/dist/core/storage/memoryRepo.js +18 -1
- package/dist/core/storage/memorySearchRepo.js +36 -12
- package/dist/core/storage/sessionEventsRepo.js +21 -0
- package/dist/core/summarize/redaction.js +70 -10
- package/dist/core/summarize/summaryShape.js +50 -20
- package/package.json +4 -3
- package/dist/core/session.js +0 -4
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
// High-signal tool types that we capture as session events.
|
|
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;
|
|
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
|
+
}
|
|
24
|
+
async function readStdinJson() {
|
|
25
|
+
if (process.stdin.isTTY)
|
|
26
|
+
return {};
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let data = "";
|
|
29
|
+
let settled = false;
|
|
30
|
+
const finish = () => {
|
|
31
|
+
if (settled)
|
|
32
|
+
return;
|
|
33
|
+
settled = true;
|
|
34
|
+
try {
|
|
35
|
+
resolve(JSON.parse(data));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
resolve({});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
process.stdin.removeAllListeners();
|
|
43
|
+
process.stdin.pause();
|
|
44
|
+
finish();
|
|
45
|
+
}, 500);
|
|
46
|
+
timer.unref?.();
|
|
47
|
+
process.stdin.setEncoding("utf8");
|
|
48
|
+
process.stdin.on("data", (c) => { data += c; });
|
|
49
|
+
process.stdin.on("end", () => { clearTimeout(timer); finish(); });
|
|
50
|
+
process.stdin.on("error", () => { clearTimeout(timer); finish(); });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function resolveSessionId(payload) {
|
|
54
|
+
if (typeof payload.session_id === "string" && payload.session_id.trim()) {
|
|
55
|
+
return payload.session_id.trim();
|
|
56
|
+
}
|
|
57
|
+
return process.env.CLAUDE_CODE_SESSION_ID ?? process.env.SESSION_ID ?? `session-${Date.now()}`;
|
|
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
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `sessionmem ingest-hook` — PostToolUse hook handler.
|
|
120
|
+
* Reads Claude Code's PostToolUse JSON payload from stdin and stores it as a
|
|
121
|
+
* session event so the SessionEnd auto-summarizer has material to work with.
|
|
122
|
+
* Also auto-stores git commits as decision memories (git commit detection).
|
|
123
|
+
*/
|
|
124
|
+
export async function ingestHookCommand(ctx) {
|
|
125
|
+
let context;
|
|
126
|
+
try {
|
|
127
|
+
const payload = await readStdinJson();
|
|
128
|
+
const toolName = payload.tool_name ?? "";
|
|
129
|
+
if (!CAPTURED_TOOL_TYPES.has(toolName)) {
|
|
130
|
+
debug(`skipped: tool "${toolName}" is not captured`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
context = ctx ?? createCliContext();
|
|
134
|
+
const sessionId = resolveSessionId(payload);
|
|
135
|
+
const projectId = context.projectId;
|
|
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", {
|
|
141
|
+
projectId,
|
|
142
|
+
sessionId,
|
|
143
|
+
events: [{
|
|
144
|
+
id: randomUUID(),
|
|
145
|
+
eventType: `tool_use:${toolName.toLowerCase()}`,
|
|
146
|
+
payloadJson: buildPayloadJson(toolName, text, payload.tool_input, payload.cwd),
|
|
147
|
+
}],
|
|
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
|
+
}
|
|
155
|
+
// Git commit detection: auto-store as a decision memory.
|
|
156
|
+
if (toolName === "Bash") {
|
|
157
|
+
const cmd = typeof payload.tool_input?.command === "string"
|
|
158
|
+
? payload.tool_input.command
|
|
159
|
+
: "";
|
|
160
|
+
const commitMatch = cmd.match(/git\s+commit[^|&;]*(?:-m\s+['"]([^'"]+)['"]|--message=?['"]([^'"]+)['"])/);
|
|
161
|
+
if (commitMatch) {
|
|
162
|
+
const message = (commitMatch[1] ?? commitMatch[2] ?? "").trim();
|
|
163
|
+
if (message) {
|
|
164
|
+
await context.service.call("storeMemory", {
|
|
165
|
+
projectId,
|
|
166
|
+
sessionId,
|
|
167
|
+
memoryId: `gitcommit-${Date.now()}`,
|
|
168
|
+
sourceAdapter: "sessionmem-hook",
|
|
169
|
+
kind: "decision",
|
|
170
|
+
content: `Git commit: ${message}`,
|
|
171
|
+
importance: 5,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
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
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -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:",
|
|
@@ -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` +
|
package/dist/cli/index.js
CHANGED
|
@@ -21,6 +21,9 @@ import { configGetCommand, configSetCommand } from "./commands/config.js";
|
|
|
21
21
|
import { teamEnableCommand, teamDisableCommand, teamStatusCommand, } from "./commands/team.js";
|
|
22
22
|
import { syncCommand } from "./commands/sync.js";
|
|
23
23
|
import { reEmbedCommand } from "./commands/reEmbed.js";
|
|
24
|
+
import { ingestHookCommand } from "./commands/ingestHook.js";
|
|
25
|
+
import { dedupeCommand } from "./commands/dedupe.js";
|
|
26
|
+
import { browseCommand } from "./commands/browse.js";
|
|
24
27
|
// Source the version from package.json (single source of truth) so `--version`
|
|
25
28
|
// never drifts from the published manifest. createRequire + resolveJsonModule
|
|
26
29
|
// reads the manifest relative to this module; from dist/cli/index.js the
|
|
@@ -40,6 +43,10 @@ program
|
|
|
40
43
|
.command("session-end")
|
|
41
44
|
.description("Run the session-end pipeline (auto-summarize ingested session events + light retention prune) for the current project (invoked automatically by the SessionEnd hook installed during `sessionmem install`)")
|
|
42
45
|
.action(() => sessionEndCommand());
|
|
46
|
+
program
|
|
47
|
+
.command("ingest-hook")
|
|
48
|
+
.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
|
+
.action(() => ingestHookCommand());
|
|
43
50
|
program
|
|
44
51
|
.command("install")
|
|
45
52
|
.description("Install sessionmem into the current MCP host")
|
|
@@ -119,8 +126,6 @@ retention
|
|
|
119
126
|
.option("--days <n>", "Override the retention window in days")
|
|
120
127
|
.action((options) => retentionPruneCommand(options));
|
|
121
128
|
// config command group — generic get/set over ~/.sessionmem/config.json.
|
|
122
|
-
// config get/set are synchronous and take no CliContext, so the arrow-wrap here
|
|
123
|
-
// only drops commander's trailing Command argument.
|
|
124
129
|
const config = program
|
|
125
130
|
.command("config")
|
|
126
131
|
.description("Read and write sessionmem policy config");
|
|
@@ -133,10 +138,6 @@ config
|
|
|
133
138
|
.description("Persist a config key to ~/.sessionmem/config.json")
|
|
134
139
|
.action((key, value) => configSetCommand(key, value));
|
|
135
140
|
// team command group — turn shared-memory mode on/off and inspect it.
|
|
136
|
-
// enable/status take no CliContext (config-only), so their arrow-wraps just drop
|
|
137
|
-
// commander's trailing Command argument. disable accepts an optional CliContext
|
|
138
|
-
// seam for the --remove-team-memories DB delete; the arrow-wrap forwards only
|
|
139
|
-
// options so production falls through to createCliContext() (NOTE above).
|
|
140
141
|
const team = program
|
|
141
142
|
.command("team")
|
|
142
143
|
.description("Manage shared-path team memory mode");
|
|
@@ -154,19 +155,30 @@ team
|
|
|
154
155
|
.description("Show team mode state and shared-path availability")
|
|
155
156
|
.action(() => teamStatusCommand());
|
|
156
157
|
// re-embed — bulk-update stale embeddings to the current version.
|
|
157
|
-
// reEmbedCommand declares a trailing `ctx?` test seam, so arrow-wrap to drop
|
|
158
|
-
// commander's trailing Command argument (NOTE above).
|
|
159
158
|
program
|
|
160
159
|
.command("re-embed")
|
|
161
160
|
.description("Re-embed all memories with stale embedding versions")
|
|
162
161
|
.action(() => reEmbedCommand());
|
|
163
|
-
// sync — push a local snapshot to the shared path and pull every
|
|
164
|
-
//
|
|
165
|
-
// arrow-wrap to drop commander's trailing Command argument (NOTE above).
|
|
162
|
+
// sync — push a local snapshot to the shared path and pull every teammate
|
|
163
|
+
// snapshot back.
|
|
166
164
|
program
|
|
167
165
|
.command("sync")
|
|
168
166
|
.description("Push local memories and pull teammate memories via the shared path")
|
|
169
167
|
.action(() => syncCommand());
|
|
168
|
+
// dedupe — find near-duplicate memories by embedding cosine similarity.
|
|
169
|
+
// Dry-run by default; --apply deletes the lower-importance duplicate per pair.
|
|
170
|
+
program
|
|
171
|
+
.command("dedupe")
|
|
172
|
+
.description("Find and optionally remove near-duplicate memories")
|
|
173
|
+
.option("--apply", "Delete the lower-importance duplicate in each pair (default: dry-run)")
|
|
174
|
+
.option("--threshold <n>", "Cosine similarity threshold 0-1 (default: 0.85)")
|
|
175
|
+
.action((options) => dedupeCommand({ dry: !options.apply, threshold: options.threshold }));
|
|
176
|
+
// browse — cross-project memory viewer.
|
|
177
|
+
program
|
|
178
|
+
.command("browse")
|
|
179
|
+
.description("List all projects with memories, or browse a specific project")
|
|
180
|
+
.option("--project <id>", "Show memories for a specific project ID")
|
|
181
|
+
.action((options) => browseCommand({ project: options.project }));
|
|
170
182
|
program.parseAsync(process.argv).catch((err) => {
|
|
171
183
|
console.error(err instanceof Error ? err.message : String(err));
|
|
172
184
|
process.exit(1);
|
package/dist/cli/projectId.js
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
/** Safety bound on the upward walk in {@link findProjectRoot}. */
|
|
5
|
+
const MAX_PARENT_WALK = 64;
|
|
6
|
+
/**
|
|
7
|
+
* Walk up from `startDir` to the nearest directory containing `.git`, and
|
|
8
|
+
* return it. Returns null when there is no repository above `startDir`.
|
|
9
|
+
*
|
|
10
|
+
* Memory is keyed by project, and a project is the repository — not whichever
|
|
11
|
+
* directory a command happened to run in. Hashing the raw cwd meant every
|
|
12
|
+
* subdirectory got its OWN bucket: the Claude Code hooks fire at the repo root
|
|
13
|
+
* and write there, then `sessionmem stats` run from `src/` reported an empty
|
|
14
|
+
* store, because it was reading a different project entirely.
|
|
15
|
+
*
|
|
16
|
+
* `.git` is matched as a path entry rather than a directory so linked worktrees
|
|
17
|
+
* and submodules (where `.git` is a FILE pointing at the real gitdir) anchor
|
|
18
|
+
* correctly too.
|
|
19
|
+
*/
|
|
20
|
+
export function findProjectRoot(startDir) {
|
|
21
|
+
let current = resolve(startDir);
|
|
22
|
+
for (let depth = 0; depth < MAX_PARENT_WALK; depth += 1) {
|
|
23
|
+
try {
|
|
24
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
25
|
+
if (existsSync(join(current, ".git"))) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Unreadable directory (permissions, race): stop rather than throw — the
|
|
31
|
+
// caller falls back to the cwd-derived id.
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const parent = dirname(current);
|
|
35
|
+
if (parent === current)
|
|
36
|
+
break; // filesystem root
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
2
41
|
/**
|
|
3
42
|
* Sanitize a raw token to the filename-safe character set used across
|
|
4
43
|
* sessionmem (mirrors localUsername). Any character outside [A-Za-z0-9._-]
|
|
@@ -45,7 +84,13 @@ export function projectIdFromCwd(cwd) {
|
|
|
45
84
|
/**
|
|
46
85
|
* Resolve the effective project id: the `SESSIONMEM_PROJECT_ID` env override
|
|
47
86
|
* (operator-controlled test/migration seam; also lets a user pin a legacy
|
|
48
|
-
* basename-only id) wins, otherwise derive from
|
|
87
|
+
* basename-only id) wins, otherwise derive from the enclosing repository root,
|
|
88
|
+
* falling back to `process.cwd()` when the cwd is not inside a repository.
|
|
89
|
+
*
|
|
90
|
+
* Anchoring to the repository keeps every command — hooks firing at the root,
|
|
91
|
+
* a `sessionmem stats` typed three directories down — on the same memory
|
|
92
|
+
* bucket. The id is unchanged for anyone already running at the repo root,
|
|
93
|
+
* since the root IS the cwd in that case.
|
|
49
94
|
*/
|
|
50
95
|
export function deriveProjectId() {
|
|
51
96
|
const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
|
|
@@ -65,5 +110,6 @@ export function deriveProjectId() {
|
|
|
65
110
|
return sanitized;
|
|
66
111
|
}
|
|
67
112
|
}
|
|
68
|
-
|
|
113
|
+
const cwd = process.cwd();
|
|
114
|
+
return projectIdFromCwd(findProjectRoot(cwd) ?? cwd);
|
|
69
115
|
}
|
|
@@ -58,12 +58,19 @@ export const memorySchema = z.object({
|
|
|
58
58
|
accessCount: z.number().int().nonnegative(),
|
|
59
59
|
lastAccessed: z.string().nullable(),
|
|
60
60
|
effectiveImportance: z.number().int().min(1).max(10),
|
|
61
|
+
tags: z.array(z.string()).nullable().optional(),
|
|
62
|
+
expiresAt: z.string().nullable().optional(),
|
|
61
63
|
createdAt: z.string().min(1),
|
|
62
64
|
updatedAt: z.string().min(1),
|
|
63
65
|
});
|
|
64
66
|
export const ingestSessionEventSchema = z.object({
|
|
65
67
|
id: z.string().min(1).max(200),
|
|
66
|
-
|
|
68
|
+
// Optional: callers that already track an ordering key (batch ingestion of a
|
|
69
|
+
// transcript) supply it; callers that fire one event at a time from separate
|
|
70
|
+
// processes (the PostToolUse hook) omit it and the service assigns the next
|
|
71
|
+
// free index per session. See nextSessionEventIndex for why inventing a
|
|
72
|
+
// wall-clock index silently loses events.
|
|
73
|
+
eventIndex: z.number().int().nonnegative().optional(),
|
|
67
74
|
eventType: z.string().min(1).max(100),
|
|
68
75
|
payloadJson: z
|
|
69
76
|
.string()
|
|
@@ -144,6 +151,8 @@ export const storeMemoryRequestSchema = z.object({
|
|
|
144
151
|
// the service layer can fall back to the policy-config redactionEnabled
|
|
145
152
|
// setting (override > config.json > default precedence).
|
|
146
153
|
redactionEnabled: z.boolean().optional(),
|
|
154
|
+
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
155
|
+
expiresAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, "must be UTC ISO timestamp").optional(),
|
|
147
156
|
});
|
|
148
157
|
export const retrieveMemoriesRequestSchema = z.object({
|
|
149
158
|
projectId: z.string().min(1),
|
|
@@ -151,6 +160,7 @@ export const retrieveMemoriesRequestSchema = z.object({
|
|
|
151
160
|
limit: z.number().int().min(1).max(100).default(20),
|
|
152
161
|
mode: z.enum(["auto", "on-demand"]).default("auto"),
|
|
153
162
|
depth: z.enum(["default", "deep"]).default("default"),
|
|
163
|
+
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
154
164
|
});
|
|
155
165
|
export const listMemoriesRequestSchema = z.object({
|
|
156
166
|
projectId: z.string().min(1),
|
|
@@ -380,6 +390,8 @@ export const batchStoreMemoryItemSchema = z.object({
|
|
|
380
390
|
content: z.string().min(1).max(MAX_CONTENT_LENGTH),
|
|
381
391
|
importance: z.number().int().min(1).max(10),
|
|
382
392
|
redactionEnabled: z.boolean().optional(),
|
|
393
|
+
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
394
|
+
expiresAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, "must be UTC ISO timestamp").optional(),
|
|
383
395
|
});
|
|
384
396
|
export const batchStoreMemoryRequestSchema = z.object({
|
|
385
397
|
projectId: z.string().min(1),
|
|
@@ -8,7 +8,7 @@ import { applyRedaction } from "../summarize/redaction.js";
|
|
|
8
8
|
import { countAllMemoriesByProject, countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, deleteMemoryById, getMemoryOwnerProjectId, getMemoryRecordById, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertImportedMemory, upsertPulledMemory, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
9
9
|
import { SESSION_WRITE_SOFT_LIMIT, configFilePath, DEEP_MODE_RETRIEVAL_CAP, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
10
10
|
import { insertMemoryFeedbackEvent } from "../storage/memoryFeedbackRepo.js";
|
|
11
|
-
import { countAllSessionEvents, insertSessionEvent } from "../storage/sessionEventsRepo.js";
|
|
11
|
+
import { countAllSessionEvents, insertSessionEvent, nextSessionEventIndex, } from "../storage/sessionEventsRepo.js";
|
|
12
12
|
import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, LIST_MEMORIES_DEFAULT_LIMIT, listMemoriesRequestSchema, pruneMemoriesRequestSchema, redactExistingRequestSchema, resetAccessCountsRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
|
|
13
13
|
import { DomainError, toErrorEnvelope } from "./errors.js";
|
|
14
14
|
import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
|
|
@@ -75,6 +75,8 @@ function toMemoryDto(record) {
|
|
|
75
75
|
accessCount: record.access_count,
|
|
76
76
|
lastAccessed: record.last_accessed,
|
|
77
77
|
effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
|
|
78
|
+
tags: record.tags ? JSON.parse(record.tags) : null,
|
|
79
|
+
expiresAt: record.expires_at ?? null,
|
|
78
80
|
createdAt: record.created_at,
|
|
79
81
|
updatedAt: record.updated_at,
|
|
80
82
|
};
|
|
@@ -114,6 +116,10 @@ function toRetrievedMemoryDto(record) {
|
|
|
114
116
|
accessCount: record.access_count,
|
|
115
117
|
lastAccessed: null,
|
|
116
118
|
effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
|
|
119
|
+
// The semantic-search candidate projection does not carry tags/expires_at;
|
|
120
|
+
// expired rows are already filtered out by the retrieval SQL.
|
|
121
|
+
tags: null,
|
|
122
|
+
expiresAt: null,
|
|
117
123
|
createdAt: record.created_at,
|
|
118
124
|
updatedAt: record.updated_at,
|
|
119
125
|
semantic: record.semantic,
|
|
@@ -180,15 +186,25 @@ export function createMemoryCoreService(deps) {
|
|
|
180
186
|
const redactionEnabled = resolveRedactionEnabled(undefined);
|
|
181
187
|
const ingest = db.transaction(() => {
|
|
182
188
|
let written = 0;
|
|
189
|
+
// Events that omit eventIndex are appended after the session's current
|
|
190
|
+
// maximum. Read it once inside the transaction and increment locally so
|
|
191
|
+
// a multi-event batch stays contiguous.
|
|
192
|
+
let nextIndex;
|
|
183
193
|
for (const event of parsed.events) {
|
|
184
194
|
const redactedPayload = applyRedaction(event.payloadJson, {
|
|
185
195
|
redactionEnabled,
|
|
186
196
|
}).text;
|
|
197
|
+
let eventIndex = event.eventIndex;
|
|
198
|
+
if (eventIndex === undefined) {
|
|
199
|
+
nextIndex ??= nextSessionEventIndex(db, parsed.projectId, parsed.sessionId);
|
|
200
|
+
eventIndex = nextIndex;
|
|
201
|
+
nextIndex += 1;
|
|
202
|
+
}
|
|
187
203
|
written += insertSessionEvent(db, {
|
|
188
204
|
id: event.id,
|
|
189
205
|
project_id: parsed.projectId,
|
|
190
206
|
session_id: parsed.sessionId,
|
|
191
|
-
event_index:
|
|
207
|
+
event_index: eventIndex,
|
|
192
208
|
event_type: event.eventType,
|
|
193
209
|
payload_json: redactedPayload,
|
|
194
210
|
created_at: event.createdAt,
|
|
@@ -196,7 +212,11 @@ export function createMemoryCoreService(deps) {
|
|
|
196
212
|
}
|
|
197
213
|
return written;
|
|
198
214
|
});
|
|
199
|
-
|
|
215
|
+
// `immediate` takes the write lock up front so two concurrent hook
|
|
216
|
+
// processes cannot both read the same next index and have one insert
|
|
217
|
+
// silently ignored. busy_timeout (db.ts) makes the loser wait rather
|
|
218
|
+
// than fail.
|
|
219
|
+
const ingested = ingest.immediate();
|
|
200
220
|
return {
|
|
201
221
|
ok: true,
|
|
202
222
|
ingested,
|
|
@@ -269,6 +289,8 @@ export function createMemoryCoreService(deps) {
|
|
|
269
289
|
// because this row did not come from another project's store.
|
|
270
290
|
author: localAuthor,
|
|
271
291
|
origin_project_id: null,
|
|
292
|
+
tags: JSON.stringify(parsed.tags ?? null),
|
|
293
|
+
expires_at: parsed.expiresAt ?? null,
|
|
272
294
|
});
|
|
273
295
|
const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
274
296
|
if (!inserted) {
|
|
@@ -289,12 +311,24 @@ export function createMemoryCoreService(deps) {
|
|
|
289
311
|
async retrieveMemories(request) {
|
|
290
312
|
const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
|
|
291
313
|
const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, DEEP_MODE_RETRIEVAL_CAP) : parsed.limit;
|
|
292
|
-
const
|
|
314
|
+
const rankedAll = retrieveMemories({
|
|
293
315
|
db,
|
|
294
316
|
projectId: parsed.projectId,
|
|
295
317
|
queryText: parsed.query,
|
|
296
318
|
limit,
|
|
297
319
|
});
|
|
320
|
+
// Optional tag filter: keep a memory only when its stored tags contain ALL
|
|
321
|
+
// requested tags. The semantic candidate projection omits tags, so re-read
|
|
322
|
+
// each row's tags by id (this is an explicit, infrequent filter path).
|
|
323
|
+
const ranked = parsed.tags && parsed.tags.length > 0
|
|
324
|
+
? rankedAll.filter((m) => {
|
|
325
|
+
const rec = getMemoryRecordById(db, parsed.projectId, m.id);
|
|
326
|
+
const recTags = rec?.tags
|
|
327
|
+
? JSON.parse(rec.tags)
|
|
328
|
+
: [];
|
|
329
|
+
return parsed.tags.every((t) => recTags.includes(t));
|
|
330
|
+
})
|
|
331
|
+
: rankedAll;
|
|
298
332
|
if (ranked.length > 0 && parsed.mode !== "on-demand") {
|
|
299
333
|
// Only boost access counts for startup injection (mode='auto'), not for
|
|
300
334
|
// explicit on-demand retrieval, so a mid-session lookup does not inflate
|
|
@@ -674,6 +708,8 @@ export function createMemoryCoreService(deps) {
|
|
|
674
708
|
embedding_version: embedding.embeddingVersion,
|
|
675
709
|
author: localAuthor,
|
|
676
710
|
origin_project_id: null,
|
|
711
|
+
tags: JSON.stringify(item.tags ?? null),
|
|
712
|
+
expires_at: item.expiresAt ?? null,
|
|
677
713
|
});
|
|
678
714
|
const inserted = getMemoryRecordById(db, parsed.projectId, item.memoryId);
|
|
679
715
|
if (!inserted) {
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
const DEFAULT_DECAY_THRESHOLD_DAYS = 7;
|
|
2
2
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Apply time-based importance decay. Memories decay by 1 point per week
|
|
5
|
+
* after the first week of inactivity, capping decay at -3 so even old
|
|
6
|
+
* memories remain discoverable (minimum effective importance = stored - 3).
|
|
7
|
+
* This produces a smoother ranking fade-out vs. the previous single -1 step.
|
|
8
|
+
*/
|
|
3
9
|
export function decayOldBoosts(memories, now = new Date(), thresholdDays = DEFAULT_DECAY_THRESHOLD_DAYS) {
|
|
4
10
|
return memories.map((memory) => {
|
|
5
11
|
const updatedAt = new Date(memory.updated_at);
|
|
6
12
|
const ageDays = Math.max(0, (now.getTime() - updatedAt.getTime()) / DAY_IN_MS);
|
|
7
|
-
const
|
|
13
|
+
const weeksOverThreshold = Number.isFinite(ageDays) && ageDays > thresholdDays
|
|
14
|
+
? Math.floor((ageDays - thresholdDays) / 7)
|
|
15
|
+
: 0;
|
|
16
|
+
const decayAmount = Math.min(weeksOverThreshold, 3);
|
|
8
17
|
return {
|
|
9
18
|
...memory,
|
|
10
|
-
decayedImportance:
|
|
11
|
-
? Math.max(1, memory.importance - 1)
|
|
12
|
-
: memory.importance,
|
|
19
|
+
decayedImportance: Math.max(1, memory.importance - decayAmount),
|
|
13
20
|
};
|
|
14
21
|
});
|
|
15
22
|
}
|
|
@@ -43,7 +43,7 @@ export function retrieveMemories(input) {
|
|
|
43
43
|
const now = input.now ?? new Date();
|
|
44
44
|
// Use FTS5 pre-filtering to limit cosine similarity computation to ~50 candidates.
|
|
45
45
|
// queryText is guaranteed non-empty by the Zod schema (z.string().min(1)) upstream.
|
|
46
|
-
const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText);
|
|
46
|
+
const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText, now);
|
|
47
47
|
const decayedCandidates = decayOldBoosts(candidates, now);
|
|
48
48
|
const dimension = resolveEmbeddingDimension(candidates);
|
|
49
49
|
const queryVector = deterministicEmbed(queryText, dimension).vector;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Memory tags: optional JSON-encoded string array for categorizing memories so
|
|
2
|
+
-- they can be filtered at retrieval time. Added to the existing `memories` table
|
|
3
|
+
-- without rewriting it so pre-existing rows survive (they get NULL tags).
|
|
4
|
+
--
|
|
5
|
+
-- Idempotency: SQLite has no `ADD COLUMN IF NOT EXISTS`. Re-running this
|
|
6
|
+
-- migration (only possible if the _migrations record was lost) throws
|
|
7
|
+
-- "duplicate column name", which runMigrations catches and treats as
|
|
8
|
+
-- already-applied. See src/core/schema/runMigrations.ts.
|
|
9
|
+
ALTER TABLE memories ADD COLUMN tags TEXT;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Memory expiry: optional UTC ISO timestamp after which a memory is considered
|
|
2
|
+
-- expired and excluded from retrieval/listing. Added to the existing `memories`
|
|
3
|
+
-- table without rewriting it so pre-existing rows survive (NULL = never expires).
|
|
4
|
+
--
|
|
5
|
+
-- Idempotency: SQLite has no `ADD COLUMN IF NOT EXISTS`. Re-running this
|
|
6
|
+
-- migration (only possible if the _migrations record was lost) throws
|
|
7
|
+
-- "duplicate column name", which runMigrations catches and treats as
|
|
8
|
+
-- already-applied. See src/core/schema/runMigrations.ts.
|
|
9
|
+
ALTER TABLE memories ADD COLUMN expires_at TEXT;
|