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.
@@ -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
- " Tip: call ingestSessionEvents during sessions to track token usage.",
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
- // teammate snapshot back. syncCommand declares a trailing `ctx?` test seam, so
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);
@@ -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 `process.cwd()`.
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
- return projectIdFromCwd(process.cwd());
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
- eventIndex: z.number().int().nonnegative(),
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: event.eventIndex,
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
- const ingested = ingest();
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 ranked = retrieveMemories({
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 shouldDecay = Number.isFinite(ageDays) && ageDays > thresholdDays;
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: shouldDecay
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;