@bcmyguest/openmem 0.6.3 → 0.9.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.
Files changed (104) hide show
  1. package/{skills → .agents/skills}/openmem/SKILL.md +5 -0
  2. package/README.md +81 -76
  3. package/cli/coding-agent/capabilities.mjs +14 -3
  4. package/cli/coding-agent/context-store.mjs +81 -0
  5. package/cli/coding-agent/context-trim.mjs +899 -0
  6. package/cli/coding-agent/file-io.mjs +72 -0
  7. package/cli/coding-agent/hook-harnesses.mjs +2 -1
  8. package/cli/coding-agent/hook-runner.mjs +28 -4
  9. package/cli/coding-agent/install-access.mjs +43 -17
  10. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  11. package/cli/coding-agent/install-rtk.mjs +60 -0
  12. package/cli/coding-agent/install-shim.mjs +119 -15
  13. package/cli/coding-agent/opencode-config.mjs +21 -5
  14. package/cli/coding-agent/opencode-plugin.mjs +39 -3
  15. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  16. package/cli/coding-agent/recall-frame.mjs +29 -0
  17. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  18. package/cli/coding-agent/runtime.mjs +2 -1
  19. package/cli/coding-agent/session-db.mjs +626 -0
  20. package/cli/coding-agent/session-facade.mjs +30 -6
  21. package/cli/coding-agent/toml-edit.mjs +161 -0
  22. package/cli/context-management.mjs +254 -0
  23. package/cli/feed-sync.mjs +18 -2
  24. package/cli/install-coding-agent.mjs +173 -40
  25. package/cli/mcp/context-sql-runner.mjs +58 -0
  26. package/cli/mcp/context-store.mjs +670 -0
  27. package/cli/mcp/knowledge.mjs +333 -0
  28. package/cli/mcp/memory-facade.mjs +99 -33
  29. package/cli/openmem-mcp.mjs +3 -0
  30. package/cli/openmem.mjs +18 -1
  31. package/cli/schedule.mjs +176 -0
  32. package/cli/stack.mjs +2 -0
  33. package/cli/switch-memory-provider.mjs +1 -1
  34. package/compose.yaml +67 -4
  35. package/config/hindsight.env.example +2 -0
  36. package/config/openmem.toml.example +64 -2
  37. package/docs/architecture.md +35 -9
  38. package/docs/coding-agent-hooks.md +26 -2
  39. package/docs/coding-agents.md +27 -7
  40. package/docs/compatibility.md +80 -0
  41. package/docs/concepts.md +30 -7
  42. package/docs/context-management.md +230 -0
  43. package/docs/context-proxy.md +115 -0
  44. package/docs/context-research.md +226 -0
  45. package/docs/context-store.md +277 -0
  46. package/docs/evidence-plan.md +216 -0
  47. package/docs/hindsight.md +5 -2
  48. package/docs/mcp-setup.md +5 -2
  49. package/docs/mem0.md +39 -1
  50. package/docs/memory-providers.md +2 -2
  51. package/docs/meridian-litellm.md +198 -0
  52. package/docs/model-configuration.md +22 -0
  53. package/docs/npx-install.md +6 -4
  54. package/docs/openmem-config.md +2 -2
  55. package/docs/quickstart.md +12 -6
  56. package/docs/setup-wizard.md +5 -4
  57. package/docs/sources-feeds.md +9 -22
  58. package/package.json +5 -4
  59. package/providers/cognee/OPERATOR.md +2 -2
  60. package/providers/hindsight/lib/clack.mjs +122 -20
  61. package/providers/hindsight/lib/coding-agent.mjs +1 -1
  62. package/providers/hindsight/lib/config.mjs +6 -0
  63. package/providers/hindsight/lib/env-setup.mjs +2 -1
  64. package/providers/hindsight/lib/litellm-routes.mjs +21 -12
  65. package/providers/hindsight/lib/meridian.mjs +307 -0
  66. package/providers/hindsight/lib/model-backend.mjs +38 -9
  67. package/providers/hindsight/lib/probe.mjs +21 -0
  68. package/providers/hindsight/lib/stack-env.mjs +5 -12
  69. package/providers/hindsight/lib/state.mjs +6 -1
  70. package/providers/hindsight/lib/summary.mjs +16 -2
  71. package/providers/hindsight/lib/validate.mjs +6 -0
  72. package/providers/hindsight/wizard.mjs +3 -2
  73. package/providers/mem0/OPERATOR.md +21 -2
  74. package/providers/mem0/compose.yaml +24 -2
  75. package/providers/mem0/lib/coding-agent.mjs +1 -1
  76. package/providers/mem0/lib/configure.mjs +66 -0
  77. package/providers/mem0/lib/litellm.mjs +457 -0
  78. package/providers/mem0/lib/stack-env.mjs +40 -1
  79. package/providers/mem0/lib/summary.mjs +30 -2
  80. package/providers/mem0/manifest.json +1 -1
  81. package/providers/mem0/wizard.mjs +15 -2
  82. package/proxy/config.yaml +33 -0
  83. package/proxy/openmem_context/__init__.py +6 -0
  84. package/proxy/openmem_context/adapters.py +504 -0
  85. package/proxy/openmem_context/compress.py +53 -0
  86. package/proxy/openmem_context/core.py +608 -0
  87. package/proxy/openmem_context/embed.py +212 -0
  88. package/proxy/openmem_context/recall.py +49 -0
  89. package/proxy/openmem_context/session.py +70 -0
  90. package/proxy/openmem_context/sessiondb.py +276 -0
  91. package/proxy/openmem_context/settings.py +134 -0
  92. package/proxy/openmem_context/store.py +129 -0
  93. package/proxy/openmem_context/tokens.py +123 -0
  94. package/proxy/openmem_context/ttl.py +78 -0
  95. package/proxy/openmem_context_hook.py +34 -0
  96. package/scripts/check-pack.mjs +2 -1
  97. package/scripts/run-setup.sh +14 -5
  98. package/scripts/setup.mjs +14 -1
  99. package/server.json +2 -2
  100. package/wizard/lib/context-step.mjs +140 -0
  101. package/wizard/lib/litellm-env.mjs +55 -0
  102. package/wizard/lib/pm-config.mjs +212 -4
  103. package/wizard/lib/prompts.mjs +28 -0
  104. package/wizard/lib/run-wizard.mjs +26 -31
@@ -0,0 +1,670 @@
1
+ /**
2
+ * Provider-independent smart_compact_* tools over the session databases (`sessions/<sid>.db`) and blobs, and
3
+ * context_status over their events tables.
4
+ * Schema and file layout follow docs/context-store.md. Reads only: search and get open each database
5
+ * read-only; smart_compact_sql runs in a child process on a read-only connection.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import vm from "node:vm";
14
+ import { contextRoot as hookContextRoot, sanitizeSessionId as hookSanitizeSessionId } from "../coding-agent/context-store.mjs";
15
+ import {
16
+ DB_SUFFIX, EVENT_WINDOW_HOURS, SCHEMA_VERSION, fetchEmbeddings, maybeSweepContextStore, nearest, proxyCodeVersion, sqlite,
17
+ storeSettings, summarizeEvents, vectorCounts,
18
+ } from "../coding-agent/session-db.mjs";
19
+
20
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
21
+ const SQL_RUNNER = path.join(path.dirname(fileURLToPath(import.meta.url)), "context-sql-runner.mjs");
22
+ const DEFAULT_LIMIT = 8;
23
+ const MAX_SESSIONS = 20;
24
+ const RESULT_MAX_CHARS = 8000;
25
+ const EXCERPT_CHARS = 400;
26
+ const BLOB_MAX_CHARS = 100_000;
27
+ const BLOB_ID_RE = /^[0-9a-f]{16}$/;
28
+ const PATTERN_MAX_CHARS = 200;
29
+ const PATTERN_ROW_CHARS = 20_000;
30
+ const PATTERN_BUDGET_MS = 1000;
31
+ const CONTEXT_MAX = 5;
32
+ const ROLES = ["user", "assistant", "tool"];
33
+ const DEFAULT_ROLES = ["user", "tool"];
34
+ /** Reciprocal rank fusion constant (Cormack et al. 2009) and vector candidates per search. */
35
+ const RRF_K = 60;
36
+ const VECTOR_CANDIDATES = 50;
37
+ const SQL_TIMEOUT_MS = 2000;
38
+ const SQL_ROW_CAP = 200;
39
+ const SQL_CELL_MAX_CHARS = 2000;
40
+ const SQL_RESULT_MAX_CHARS = 30_000;
41
+ const SQL_MAX_CHARS = 10_000;
42
+ const STATUS_MAX_HOURS = 24 * 30;
43
+ /** Words that may not appear outside string literals and quoted identifiers in smart_compact_sql. */
44
+ const SQL_FORBIDDEN = new Set([
45
+ "ATTACH", "DETACH", "PRAGMA", "INSERT", "UPDATE", "DELETE", "UPSERT", "CREATE", "DROP", "ALTER",
46
+ "VACUUM", "REINDEX", "ANALYZE", "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "TRANSACTION",
47
+ ]);
48
+
49
+ const SCHEMA_TEXT = "messages(seq INTEGER PRIMARY KEY, fingerprint, thread, role 'user'|'assistant'|'tool', "
50
+ + "kind 'text'|'tool_use'|'tool_result'|'thinking', name (tool name or NULL), text, blob_id, in_context 0|1, "
51
+ + "trimmed_at_seq, trimmed_at, created_at); lines(id, message_seq, line_no (1-based), role, text) with one row per "
52
+ + "line of each message; messages_fts(text) and lines_fts(text): FTS5 indexes whose rowid is messages.seq / lines.id; "
53
+ + "vectors(message_seq, model, dim, blob); events(id, at, kind 'trim'|'fail_open'|'usage', version, location, error, "
54
+ + "tokens_before, tokens_after, dropped, model, thread_root, trimmed 0|1|2, input_tokens, cache_read_tokens, "
55
+ + "cache_creation_tokens, output_tokens, breakpoints, breakpoint_at), one row per trim, fail-open or model call; "
56
+ + "trim token figures estimated, usage counts provider-reported (NULL when not reported).";
57
+
58
+ export const CONTEXT_STORE_MCP_TOOLS = [
59
+ {
60
+ name: "smart_compact_search",
61
+ title: "Search this session's stored context",
62
+ description:
63
+ "Search every stored message of this session, including messages trimmed from your context, and return matching "
64
+ + "rows with an excerpt. Use it when an [openmem: continuing session …] pointer says earlier messages were trimmed; "
65
+ + "pass that session_id (without one it searches recent sessions in this git root). Pass exactly one of query "
66
+ + "(full-text words) or pattern (regex). Roles default to user and tool. total_matches counts rows; line_matches "
67
+ + "is an exact count of matching lines across the whole session; use it for 'how many' questions, or smart_compact_sql "
68
+ + "for grouped counts. Rows citing a compressed result return blob_ids for smart_compact_get.",
69
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
70
+ inputSchema: {
71
+ type: "object",
72
+ properties: {
73
+ query: { type: "string", description: "Words to find (case-insensitive whole words; rows with more words rank first). Omit when passing pattern" },
74
+ pattern: {
75
+ type: "string",
76
+ description: `JavaScript regex source, at most ${PATTERN_MAX_CHARS} characters; tested against each row's first ${PATTERN_ROW_CHARS} characters and against every line. Omit when passing query`,
77
+ },
78
+ ignore_case: { type: "boolean", description: "Match pattern case-insensitively (default false)" },
79
+ context: { type: "number", description: `Neighbouring rows of the same conversation to return before and after each hit, 0-${CONTEXT_MAX} (default 0)` },
80
+ session_id: { type: "string", description: "Session id from the [openmem: continuing session …] pointer" },
81
+ limit: { type: "number", description: `Max rows (default ${DEFAULT_LIMIT})` },
82
+ role: {
83
+ description: 'Roles to search: "user", "assistant", "tool", a list of them, or "all" (default ["user", "tool"])',
84
+ anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
85
+ },
86
+ in_context: { type: "boolean", description: "true: only rows still in your context; false: only trimmed rows (default both)" },
87
+ mode: {
88
+ type: "string",
89
+ enum: ["hybrid", "lexical"],
90
+ description: "hybrid merges full-text and embedding rankings (default when the session has vectors); lexical is full-text only",
91
+ },
92
+ },
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ {
97
+ name: "smart_compact_get",
98
+ title: "Read a compressed tool result",
99
+ description:
100
+ "Return the full text of a tool result the context proxy compressed, by the 16-hex id from "
101
+ + '[openmem: tool result compressed … smart_compact_get(id="<id>")] or a smart_compact_search blob_ids entry. '
102
+ + `Output is capped at ${BLOB_MAX_CHARS} characters; pass offset (next_offset) to page.`,
103
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ id: { type: "string", description: "Blob id: exactly 16 lowercase hex characters" },
108
+ offset: { type: "number", description: "Character offset to start from (default 0)" },
109
+ },
110
+ required: ["id"],
111
+ additionalProperties: false,
112
+ },
113
+ },
114
+ {
115
+ name: "smart_compact_sql",
116
+ title: "Query a session database with SQL",
117
+ description:
118
+ "Run one read-only SQLite SELECT (or WITH … SELECT) against a session's database, for counts, aggregates and "
119
+ + "joins that smart_compact_search cannot express. The database holds every message of the session, in or out of "
120
+ + `your context. Schema: ${SCHEMA_TEXT} `
121
+ + "Full-text: `lines_fts MATCH 'error AND payments'` (FTS5 syntax; join lines ON lines.id = lines_fts.rowid); "
122
+ + "exact substrings: `text LIKE '%level=error%'` (case-insensitive for ASCII). Examples: "
123
+ + "(1) lines naming a level and a service: SELECT count(*) AS n FROM lines WHERE text LIKE '%level=error%' AND text LIKE '%service=payments%'; "
124
+ + "(2) tool calls per tool: SELECT name, count(*) AS calls FROM messages WHERE kind = 'tool_use' GROUP BY name ORDER BY calls DESC; "
125
+ + "(3) error lines per tool result: SELECT m.seq, m.name, count(*) AS errors FROM lines_fts JOIN lines l ON l.id = lines_fts.rowid "
126
+ + "JOIN messages m ON m.seq = l.message_seq WHERE lines_fts MATCH 'error' AND m.kind = 'tool_result' GROUP BY m.seq ORDER BY errors DESC LIMIT 10. "
127
+ + `Writes, ATTACH and PRAGMA are rejected; the query stops after ${SQL_TIMEOUT_MS / 1000} s; at most ${SQL_ROW_CAP} rows return `
128
+ + `(truncated: true when there were more; long text cells are cut at ${SQL_CELL_MAX_CHARS} characters).`,
129
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ session_id: { type: "string", description: "Session id from the [openmem: continuing session …] pointer" },
134
+ sql: { type: "string", description: `One SELECT or WITH statement, at most ${SQL_MAX_CHARS} characters` },
135
+ },
136
+ required: ["session_id", "sql"],
137
+ additionalProperties: false,
138
+ },
139
+ },
140
+ {
141
+ name: "context_status",
142
+ title: "Context management health",
143
+ description:
144
+ "Summarize context management over recent sessions from the session store: trims, estimated tokens saved, "
145
+ + "fail-opens with their error type and location, the proxy code version (stale when it differs from the "
146
+ + "checkout), and provider-reported prompt-cache usage (hit ratio, cache-creation tokens on requests that made a "
147
+ + "new cut vs others, cache_control breakpoint counts). A fail-open means the request still succeeded but was sent untrimmed. Use this instead of docker "
148
+ + "logs; per-session detail is in smart_compact_sql's events table.",
149
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: {
153
+ hours: { type: "number", description: `Window in hours, 1-${STATUS_MAX_HOURS} (default ${EVENT_WINDOW_HOURS})` },
154
+ },
155
+ additionalProperties: false,
156
+ },
157
+ },
158
+ ];
159
+
160
+ export const CONTEXT_STORE_TOOL_NAMES = new Set(CONTEXT_STORE_MCP_TOOLS.map((t) => t.name));
161
+
162
+ /** `$OPENMEM_STATE_DIR/context` (trimmed), read at call time; same resolver as the hooks. */
163
+ export function contextRoot(env = process.env) {
164
+ return hookContextRoot(env.HOME || os.homedir(), env);
165
+ }
166
+
167
+ /** Hook-side sanitization (docs/context-store.md). An explicit search id containing a path separator, or
168
+ * equal to `.`/`..`, is rejected: pointers hand the agent an already-sanitized id, which never has one. */
169
+ export function sanitizeSessionId(raw) {
170
+ const text = String(raw ?? "");
171
+ if (/[/\\]/.test(text) || /^\s*\.{1,2}\s*$/.test(text)) throw new Error(`invalid session_id: ${JSON.stringify(text)}`);
172
+ let sid = "";
173
+ try {
174
+ sid = hookSanitizeSessionId(text);
175
+ } catch {
176
+ // empty after trimming
177
+ }
178
+ if (!sid) throw new Error(`invalid session_id: ${JSON.stringify(text)}`);
179
+ return sid;
180
+ }
181
+
182
+ /**
183
+ * Store settings (storeSettings) with config.toml `[context_store]` under the environment, which wins.
184
+ * config.toml is read lazily; an unreadable one leaves the defaults.
185
+ */
186
+ export async function contextStoreSettings(env = process.env, { home = env.HOME || os.homedir(), stackRoot = PKG_ROOT } = {}) {
187
+ let fromConfig = {};
188
+ try {
189
+ const { contextTrimEnvFromPmConfig, loadPmConfig } = await import("../../wizard/lib/pm-config.mjs");
190
+ fromConfig = contextTrimEnvFromPmConfig(loadPmConfig(home, stackRoot).merged);
191
+ } catch {
192
+ // defaults
193
+ }
194
+ return storeSettings({ ...fromConfig, ...env });
195
+ }
196
+
197
+ /** MCP server start: TTL cleanup of old session files, at most once a day. Never throws; returns deleted sids. */
198
+ export async function startContextStore(env = process.env, opts = {}) {
199
+ try {
200
+ const settings = await contextStoreSettings(env, opts);
201
+ return maybeSweepContextStore(contextRoot(env), settings.ttlDays, opts.now);
202
+ } catch (err) {
203
+ process.stderr.write(`openmem-mcp: context store cleanup failed: ${err instanceof Error ? err.message : err}\n`);
204
+ return [];
205
+ }
206
+ }
207
+
208
+ function gitRoot(dir) {
209
+ let candidate = path.resolve(dir);
210
+ while (true) {
211
+ if (fs.existsSync(path.join(candidate, ".git"))) return candidate;
212
+ const parent = path.dirname(candidate);
213
+ if (parent === candidate) return null;
214
+ candidate = parent;
215
+ }
216
+ }
217
+
218
+ /** The repository's shared git dir: linked worktrees of one repo resolve to the same path. */
219
+ function repoIdentity(root) {
220
+ if (!root) return null;
221
+ const entry = path.join(root, ".git");
222
+ try {
223
+ if (fs.statSync(entry).isDirectory()) return fs.realpathSync(entry);
224
+ const match = /^gitdir:\s*(.+)\s*$/m.exec(fs.readFileSync(entry, "utf8"));
225
+ if (!match) return null;
226
+ const gitDir = path.resolve(root, match[1].trim());
227
+ const common = path.join(gitDir, "commondir");
228
+ const shared = fs.existsSync(common) ? path.resolve(gitDir, fs.readFileSync(common, "utf8").trim()) : gitDir;
229
+ return fs.realpathSync(shared);
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function readJsonFile(file) {
236
+ try {
237
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
238
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function within(child, parent) {
245
+ return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`);
246
+ }
247
+
248
+ /** Registered sessions whose cwd lies in the workspace git root, equals cwd, or belongs to a linked
249
+ * worktree of the same repository, newest first. */
250
+ function workspaceSessions(sessionsDir, cwd) {
251
+ let names;
252
+ try {
253
+ names = fs.readdirSync(sessionsDir);
254
+ } catch {
255
+ return [];
256
+ }
257
+ const here = path.resolve(cwd);
258
+ const root = gitRoot(here);
259
+ const repo = repoIdentity(root);
260
+ const found = [];
261
+ for (const file of names) {
262
+ if (!file.endsWith(".json") || file.endsWith(".trim.json")) continue;
263
+ const reg = readJsonFile(path.join(sessionsDir, file));
264
+ if (!reg || typeof reg.cwd !== "string" || !path.isAbsolute(reg.cwd)) continue;
265
+ const regCwd = path.resolve(reg.cwd);
266
+ const sameRepo = repo && repoIdentity(gitRoot(regCwd)) === repo;
267
+ if (!(regCwd === here || (root && within(regCwd, root)) || sameRepo)) continue;
268
+ const at = Date.parse(reg.registered_at);
269
+ found.push({ sid: file.slice(0, -".json".length), at: Number.isFinite(at) ? at : 0 });
270
+ }
271
+ return found.sort((a, b) => b.at - a.at).slice(0, MAX_SESSIONS).map((s) => s.sid);
272
+ }
273
+
274
+ /** Read-only connection to a session database, or null when it is missing or of a newer schema version (a v1
275
+ * database, not yet migrated by a writer, reads the same except that it has no events table). */
276
+ function openReadOnly(file) {
277
+ if (!fs.existsSync(file)) return null;
278
+ const { DatabaseSync } = sqlite();
279
+ const db = new DatabaseSync(file, { readOnly: true });
280
+ try {
281
+ db.exec("PRAGMA busy_timeout = 1000");
282
+ const version = db.prepare("PRAGMA user_version").get().user_version;
283
+ if (version < 1 || version > SCHEMA_VERSION) {
284
+ db.close();
285
+ return null;
286
+ }
287
+ } catch (err) {
288
+ db.close();
289
+ throw err;
290
+ }
291
+ return db;
292
+ }
293
+
294
+ function excerpt(text, at) {
295
+ if (text.length <= EXCERPT_CHARS) return text;
296
+ const start = Math.max(0, Math.min(at - Math.floor(EXCERPT_CHARS / 4), text.length - EXCERPT_CHARS));
297
+ const end = start + EXCERPT_CHARS;
298
+ return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
299
+ }
300
+
301
+ function given(value) {
302
+ return value !== undefined && value !== null && String(value).trim() !== "";
303
+ }
304
+
305
+ function compilePattern(args) {
306
+ const source = String(args.pattern);
307
+ if (source.length > PATTERN_MAX_CHARS) throw new Error(`pattern must be at most ${PATTERN_MAX_CHARS} characters`);
308
+ try {
309
+ return new RegExp(source, args.ignore_case === true ? "i" : "");
310
+ } catch (err) {
311
+ throw new Error(`invalid pattern: ${err instanceof Error ? err.message : String(err)}`);
312
+ }
313
+ }
314
+
315
+ function contextCount(raw) {
316
+ if (raw === undefined || raw === null) return 0;
317
+ const n = Number(raw);
318
+ if (!Number.isInteger(n) || n < 0 || n > CONTEXT_MAX) throw new Error(`context must be an integer from 0 to ${CONTEXT_MAX}`);
319
+ return n;
320
+ }
321
+
322
+ function roleFilter(raw) {
323
+ if (raw === undefined || raw === null) return DEFAULT_ROLES;
324
+ const list = (Array.isArray(raw) ? raw : [raw]).map((r) => String(r).trim().toLowerCase());
325
+ if (list.includes("all")) return ROLES;
326
+ const bad = list.filter((r) => !ROLES.includes(r));
327
+ if (bad.length || !list.length) throw new Error(`role must be "user", "assistant", "tool", a list of them, or "all"`);
328
+ return [...new Set(list)];
329
+ }
330
+
331
+ function inContextFilter(raw) {
332
+ if (raw === undefined || raw === null) return null;
333
+ if (typeof raw !== "boolean") throw new Error("in_context must be true or false");
334
+ return raw ? 1 : 0;
335
+ }
336
+
337
+ /** WHERE clause over messages `m` (and optionally lines `l` for the role) for the role and in_context filters. */
338
+ function filterSql(roles, inContext, roleColumn = "m.role") {
339
+ const parts = [`${roleColumn} IN (${roles.map(() => "?").join(", ")})`];
340
+ const params = [...roles];
341
+ if (inContext !== null) {
342
+ parts.push("m.in_context = ?");
343
+ params.push(inContext);
344
+ }
345
+ return { where: parts.join(" AND "), params };
346
+ }
347
+
348
+ /** Query words as FTS5 strings; a word without letters or digits matches nothing and is dropped. */
349
+ function ftsTerms(query) {
350
+ const words = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
351
+ return words.filter((w) => /[\p{L}\p{N}]/u.test(w)).map((w) => ({ word: w, fts: `"${w.replaceAll('"', '""')}"` }));
352
+ }
353
+
354
+ /**
355
+ * Regex hits as [rowIndex, offset] pairs over the first PATTERN_ROW_CHARS of each text, and matching lines
356
+ * (lines[i]: the lines-table rows of texts[i]) added to counter.lines. Runs in a vm context so V8 interrupts catastrophic backtracking at the deadline;
357
+ * hits and lines counted before the deadline are kept. Returns whether the deadline cut the scan short.
358
+ */
359
+ function patternScan(re, texts, lines, deadline, out, counter) {
360
+ const timeout = Math.floor(deadline - Date.now());
361
+ if (timeout < 1) return true;
362
+ const cut = (t) => t.slice(0, PATTERN_ROW_CHARS);
363
+ const sandbox = { re, texts: texts.map(cut), lines: lines.map((group) => group.map(cut)), out, counter };
364
+ try {
365
+ vm.runInNewContext(
366
+ // Row by row, its lines with it, so a deadline keeps the counts of every row scanned before it.
367
+ "for (let i = 0; i < texts.length; i++) { const m = re.exec(texts[i]); if (m) out.push([i, m.index]); "
368
+ + "for (const line of lines[i]) if (re.test(line)) counter.lines++; }",
369
+ sandbox,
370
+ { timeout },
371
+ );
372
+ return false;
373
+ } catch (err) {
374
+ if (err?.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") return true;
375
+ throw err;
376
+ }
377
+ }
378
+
379
+ function rowSummary(row, text) {
380
+ return { seq: row.seq ?? null, role: row.role ?? null, kind: row.kind ?? null, name: row.name ?? null, in_context: row.in_context === 1, text };
381
+ }
382
+
383
+ const ROW_COLUMNS = "m.seq AS seq, m.thread AS thread, m.role AS role, m.kind AS kind, m.name AS name, m.text AS text, "
384
+ + "m.blob_id AS blob_id, m.in_context AS in_context";
385
+
386
+ /** Lexical hits of one session: [{seq, matched, score, first}] and the exact line count. */
387
+ function lexicalSession(db, terms, filter) {
388
+ const anyExpr = terms.map((t) => t.fts).join(" OR ");
389
+ const allExpr = terms.map((t) => t.fts).join(" AND ");
390
+ const hits = db.prepare(`SELECT m.seq AS seq, bm25(messages_fts) AS score FROM messages_fts JOIN messages m ON m.seq = messages_fts.rowid `
391
+ + `WHERE messages_fts MATCH ? AND ${filter.where}`).all(anyExpr, ...filter.params);
392
+ const matched = new Map(hits.map((h) => [h.seq, 0]));
393
+ if (terms.length > 1) {
394
+ const one = db.prepare("SELECT rowid AS seq FROM messages_fts WHERE messages_fts MATCH ?");
395
+ for (const t of terms) for (const r of one.all(t.fts)) if (matched.has(r.seq)) matched.set(r.seq, matched.get(r.seq) + 1);
396
+ } else for (const h of hits) matched.set(h.seq, 1);
397
+ const lines = db.prepare(`SELECT count(*) AS n FROM lines_fts JOIN lines l ON l.id = lines_fts.rowid JOIN messages m ON m.seq = l.message_seq `
398
+ + `WHERE lines_fts MATCH ? AND ${filterSql(filter.roles, filter.inContext, "l.role").where}`)
399
+ .get(allExpr, ...filterSql(filter.roles, filter.inContext, "l.role").params).n;
400
+ return { hits: hits.map((h) => ({ seq: h.seq, score: h.score, matched: matched.get(h.seq) })), lines };
401
+ }
402
+
403
+ function vectorModel(db, preferred) {
404
+ const models = db.prepare("SELECT model, count(*) AS n FROM vectors WHERE dim > 0 GROUP BY model ORDER BY n DESC, model").all();
405
+ if (!models.length) return null;
406
+ return models.find((m) => m.model === preferred)?.model ?? models[0].model;
407
+ }
408
+
409
+ function firstTermAt(text, terms) {
410
+ const lower = text.toLowerCase();
411
+ let first = Infinity;
412
+ for (const t of terms) {
413
+ const i = lower.indexOf(t.word);
414
+ if (i >= 0) first = Math.min(first, i);
415
+ }
416
+ return Number.isFinite(first) ? first : 0;
417
+ }
418
+
419
+ async function search(args, env, cwd, opts) {
420
+ const hasQuery = given(args.query);
421
+ const hasPattern = args.pattern !== undefined && args.pattern !== null && String(args.pattern) !== "";
422
+ if (hasQuery === hasPattern) throw new Error("pass exactly one of query or pattern");
423
+ const re = hasPattern ? compilePattern(args) : null;
424
+ const query = hasQuery ? String(args.query).trim() : null;
425
+ const terms = hasQuery ? ftsTerms(query) : [];
426
+ if (hasQuery && !terms.length) throw new Error("query has no searchable words (letters or digits); use pattern for punctuation");
427
+ const context = contextCount(args.context);
428
+ const limit = Number(args.limit) > 0 ? Math.floor(Number(args.limit)) : DEFAULT_LIMIT;
429
+ const roles = roleFilter(args.role);
430
+ const inContext = inContextFilter(args.in_context);
431
+ if (args.mode !== undefined && args.mode !== "hybrid" && args.mode !== "lexical") throw new Error('mode must be "hybrid" or "lexical"');
432
+ const filter = { ...filterSql(roles, inContext), roles, inContext };
433
+ const sessionsDir = path.join(contextRoot(env), "sessions");
434
+ const sessions = given(args.session_id) ? [sanitizeSessionId(args.session_id)] : workspaceSessions(sessionsDir, cwd);
435
+
436
+ const deadline = Date.now() + PATTERN_BUDGET_MS;
437
+ let timedOut = false;
438
+ let lineMatches = 0;
439
+ const databases = {};
440
+ const open = new Map();
441
+ const lexical = [];
442
+ const vectorDbs = [];
443
+ try {
444
+ for (const [sessionRank, sid] of sessions.entries()) {
445
+ const file = path.join(sessionsDir, `${sid}${DB_SUFFIX}`);
446
+ const db = openReadOnly(file);
447
+ if (!db) continue;
448
+ open.set(sid, db);
449
+ databases[sid] = file;
450
+ if (re) {
451
+ if (timedOut) continue;
452
+ const rows = db.prepare(`SELECT ${ROW_COLUMNS} FROM messages m WHERE ${filter.where} ORDER BY m.seq`).all(...filter.params);
453
+ const lineFilter = filterSql(roles, inContext, "l.role");
454
+ const bySeq = new Map(rows.map((r) => [r.seq, []]));
455
+ for (const l of db.prepare(`SELECT l.message_seq AS seq, l.text AS text FROM lines l JOIN messages m ON m.seq = l.message_seq `
456
+ + `WHERE ${lineFilter.where} ORDER BY l.message_seq, l.line_no`).all(...lineFilter.params)) bySeq.get(l.seq)?.push(l.text);
457
+ const lines = rows.map((r) => bySeq.get(r.seq));
458
+ const found = [];
459
+ const counter = { lines: 0 };
460
+ timedOut = patternScan(re, rows.map((r) => r.text), lines, deadline, found, counter);
461
+ lineMatches += counter.lines;
462
+ for (const [index, at] of found) lexical.push({ sid, sessionRank, seq: rows[index].seq, matched: 1, score: 0, first: at });
463
+ continue;
464
+ }
465
+ const result = lexicalSession(db, terms, filter);
466
+ lineMatches += result.lines;
467
+ for (const h of result.hits) lexical.push({ sid, sessionRank, ...h });
468
+ vectorDbs.push({ sid, sessionRank, db });
469
+ }
470
+ // Lexical rank: more query words first, then BM25 (lower is better), newer session, later row.
471
+ lexical.sort((a, b) => b.matched - a.matched || a.score - b.score || a.sessionRank - b.sessionRank || b.seq - a.seq);
472
+
473
+ const settings = re ? null : opts.settings || await contextStoreSettings(env, opts);
474
+ const withVectors = re ? [] : vectorDbs.map((v) => ({ ...v, model: vectorModel(v.db, settings.embeddingModel) })).filter((v) => v.model);
475
+ // Background embedding progress of the searched sessions: rows with a vector, still waiting, skipped.
476
+ let vectorStatus;
477
+ for (const v of re ? [] : vectorDbs) {
478
+ const model = vectorModel(v.db, settings.embeddingModel) ?? settings.embeddingModel;
479
+ if (!model) continue;
480
+ const counts = vectorCounts(v.db, model);
481
+ vectorStatus ??= { filled: 0, pending: 0, skipped: 0 };
482
+ for (const k of Object.keys(vectorStatus)) vectorStatus[k] += counts[k];
483
+ }
484
+ let mode = "lexical";
485
+ let embeddingError;
486
+ let ranked = lexical.map((h) => ({ ...h, key: `${h.sid}\u0000${h.seq}` }));
487
+ if (!re && args.mode !== "lexical" && withVectors.length) {
488
+ try {
489
+ const vectorHits = [];
490
+ const byModel = new Map();
491
+ for (const v of withVectors) {
492
+ if (!byModel.has(v.model)) {
493
+ const config = { model: v.model, url: settings.embeddingUrl, apiKey: settings.embeddingApiKey };
494
+ byModel.set(v.model, (await fetchEmbeddings(config, [query], opts.fetch))[0]);
495
+ }
496
+ for (const [seq, similarity] of nearest(v.db, byModel.get(v.model), v.model, VECTOR_CANDIDATES, filter.where, filter.params)) {
497
+ vectorHits.push({ sid: v.sid, sessionRank: v.sessionRank, seq, similarity });
498
+ }
499
+ }
500
+ vectorHits.sort((a, b) => b.similarity - a.similarity || a.sessionRank - b.sessionRank || b.seq - a.seq);
501
+ const fused = new Map();
502
+ const add = (h, rank, extra) => {
503
+ const key = `${h.sid}\u0000${h.seq}`;
504
+ const entry = fused.get(key) || { sid: h.sid, sessionRank: h.sessionRank, seq: h.seq, matched: 0, rrf: 0, key };
505
+ entry.rrf += 1 / (RRF_K + rank + 1);
506
+ Object.assign(entry, extra);
507
+ fused.set(key, entry);
508
+ };
509
+ lexical.forEach((h, rank) => add(h, rank, { matched: h.matched, first: h.first }));
510
+ vectorHits.slice(0, VECTOR_CANDIDATES).forEach((h, rank) => add(h, rank, { similarity: Math.round(h.similarity * 1e4) / 1e4 }));
511
+ ranked = [...fused.values()].sort((a, b) => b.rrf - a.rrf || a.sessionRank - b.sessionRank || b.seq - a.seq);
512
+ mode = "hybrid";
513
+ } catch (err) {
514
+ embeddingError = err instanceof Error ? err.message : String(err);
515
+ }
516
+ }
517
+
518
+ const envelope = {
519
+ ...(re ? { pattern: String(args.pattern) } : { query }),
520
+ mode,
521
+ roles,
522
+ ...(inContext === null ? {} : { in_context: inContext === 1 }),
523
+ sessions_searched: sessions,
524
+ databases,
525
+ total_matches: lexical.length,
526
+ line_matches: lineMatches,
527
+ ...(vectorStatus ? { vectors: vectorStatus } : {}),
528
+ ...(embeddingError ? { embedding_error: embeddingError } : {}),
529
+ };
530
+ const overhead = JSON.stringify(envelope, null, 2).length;
531
+ const results = [];
532
+ let truncated = timedOut || ranked.length > limit;
533
+ for (const h of ranked.slice(0, limit)) {
534
+ const db = open.get(h.sid);
535
+ const row = db.prepare(`SELECT ${ROW_COLUMNS} FROM messages m WHERE m.seq = ?`).get(h.seq);
536
+ const at = h.first ?? (re ? 0 : firstTermAt(row.text, terms));
537
+ const neighbour = (r) => rowSummary(r, excerpt(r.text, 0));
538
+ const item = {
539
+ session_id: h.sid,
540
+ ...rowSummary(row, excerpt(row.text, at)),
541
+ ...(re ? {} : { matched_terms: h.matched ?? 0 }),
542
+ ...(h.similarity !== undefined ? { similarity: h.similarity } : {}),
543
+ ...(row.blob_id ? { blob_ids: [row.blob_id] } : {}),
544
+ ...(context
545
+ ? {
546
+ before: db.prepare(`SELECT ${ROW_COLUMNS} FROM messages m WHERE m.thread = ? AND m.seq < ? ORDER BY m.seq DESC LIMIT ?`)
547
+ .all(row.thread, row.seq, context).reverse().map(neighbour),
548
+ after: db.prepare(`SELECT ${ROW_COLUMNS} FROM messages m WHERE m.thread = ? AND m.seq > ? ORDER BY m.seq LIMIT ?`)
549
+ .all(row.thread, row.seq, context).map(neighbour),
550
+ }
551
+ : {}),
552
+ };
553
+ if (overhead + JSON.stringify([...results, item], null, 2).length > RESULT_MAX_CHARS) {
554
+ truncated = true;
555
+ break;
556
+ }
557
+ results.push(item);
558
+ }
559
+ return { ...envelope, results, truncated, ...(timedOut ? { timed_out: true } : {}) };
560
+ } finally {
561
+ for (const db of open.values()) db.close();
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Reject anything but one SELECT / WITH statement: comments, string literals and quoted identifiers are
567
+ * blanked, then the first word must be SELECT or WITH, no `;` may precede trailing whitespace, and no
568
+ * write, ATTACH, PRAGMA or transaction keyword may remain. The read-only connection is the second line of defence.
569
+ */
570
+ export function checkReadOnlySql(sql) {
571
+ const text = String(sql ?? "");
572
+ if (!text.trim()) throw new Error("sql is required");
573
+ if (text.length > SQL_MAX_CHARS) throw new Error(`sql must be at most ${SQL_MAX_CHARS} characters`);
574
+ const blanked = text
575
+ .replace(/--[^\n]*|\/\*[\s\S]*?(\*\/|$)/g, " ")
576
+ .replace(/'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`(?:[^`]|``)*`?|\[[^\]]*\]?/g, " '' ");
577
+ const statement = blanked.replace(/;\s*$/, "");
578
+ if (statement.includes(";")) throw new Error("smart_compact_sql runs one statement; remove the extra ';'");
579
+ const words = statement.toUpperCase().match(/[A-Z_][A-Z0-9_$]*/g) || [];
580
+ if (words[0] !== "SELECT" && words[0] !== "WITH") throw new Error("smart_compact_sql accepts only SELECT or WITH … SELECT");
581
+ // replace() is a function; REPLACE INTO is a write.
582
+ const bad = words.find((w) => SQL_FORBIDDEN.has(w)) || (/\bREPLACE\s+INTO\b/i.test(statement) ? "REPLACE INTO" : null);
583
+ if (bad) throw new Error(`smart_compact_sql is read-only: ${bad} is not allowed`);
584
+ return text.trim().replace(/;\s*$/, "");
585
+ }
586
+
587
+ function runSql(args, env) {
588
+ if (!given(args.session_id)) throw new Error("session_id is required");
589
+ const sid = sanitizeSessionId(args.session_id);
590
+ const sql = checkReadOnlySql(args.sql);
591
+ const file = path.join(contextRoot(env), "sessions", `${sid}${DB_SUFFIX}`);
592
+ if (!fs.existsSync(file)) throw new Error(`no session database for ${sid}`);
593
+ const child = spawnSync(process.execPath, ["--no-warnings", SQL_RUNNER], {
594
+ input: JSON.stringify({ file, sql, cap: SQL_ROW_CAP, cellMax: SQL_CELL_MAX_CHARS }),
595
+ encoding: "utf8",
596
+ timeout: SQL_TIMEOUT_MS,
597
+ killSignal: "SIGKILL",
598
+ maxBuffer: 64 * 1024 * 1024,
599
+ });
600
+ if (child.error?.code === "ETIMEDOUT" || child.signal === "SIGKILL") {
601
+ throw new Error(`query stopped after ${SQL_TIMEOUT_MS / 1000} s; narrow it (LIMIT, FTS MATCH) and retry`);
602
+ }
603
+ if (child.error) throw child.error;
604
+ let out;
605
+ try {
606
+ out = JSON.parse(child.stdout);
607
+ } catch {
608
+ throw new Error(`query failed: ${String(child.stderr || "no output").trim().split("\n").at(-1)}`);
609
+ }
610
+ if (out.error) throw new Error(`query failed: ${out.error}`);
611
+ const result = { session_id: sid, columns: out.columns, rows: out.rows, row_count: out.rows.length, truncated: out.truncated };
612
+ while (result.rows.length && JSON.stringify(result).length > SQL_RESULT_MAX_CHARS) {
613
+ result.rows = result.rows.slice(0, Math.floor(result.rows.length / 2));
614
+ result.row_count = result.rows.length;
615
+ result.truncated = true;
616
+ }
617
+ return result;
618
+ }
619
+
620
+ function getBlob(args, env) {
621
+ const id = String(args.id ?? "").trim();
622
+ if (!BLOB_ID_RE.test(id)) throw new Error("id must be exactly 16 lowercase hex characters");
623
+ const offset = Number(args.offset) > 0 ? Math.floor(Number(args.offset)) : 0;
624
+ let text;
625
+ try {
626
+ text = fs.readFileSync(path.join(contextRoot(env), "blobs", `${id}.txt`), "utf8");
627
+ } catch {
628
+ return { id, found: false, text: "" };
629
+ }
630
+ const chunk = text.slice(offset, offset + BLOB_MAX_CHARS);
631
+ const end = offset + chunk.length;
632
+ return {
633
+ id,
634
+ found: true,
635
+ total_chars: text.length,
636
+ offset,
637
+ text: chunk,
638
+ truncated: end < text.length,
639
+ ...(end < text.length ? { next_offset: end } : {}),
640
+ };
641
+ }
642
+
643
+ function contextStatusTool(args, env) {
644
+ const hours = given(args.hours) ? Number(args.hours) : EVENT_WINDOW_HOURS;
645
+ if (!Number.isFinite(hours) || hours < 1 || hours > STATUS_MAX_HOURS) throw new Error(`hours must be 1-${STATUS_MAX_HOURS}`);
646
+ let checkout = null;
647
+ try { checkout = proxyCodeVersion(path.join(PKG_ROOT, "proxy", "openmem_context")); } catch { /* installed runtime has no proxy/ */ }
648
+ const events = summarizeEvents(path.join(contextRoot(env), "sessions"), { hours });
649
+ const running = events.latest.proxy || null;
650
+ return {
651
+ ...events,
652
+ checkout_proxy_version: checkout,
653
+ proxy_stale: running && checkout ? running !== checkout : null,
654
+ not_recorded: "fail-opens before the session id is known or from the session database itself (proxy stderr only)",
655
+ };
656
+ }
657
+
658
+ /**
659
+ * @param {string} name
660
+ * @param {Record<string, unknown>} args
661
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, settings?: object, fetch?: typeof fetch, home?: string, stackRoot?: string }} [opts]
662
+ */
663
+ export async function callContextStoreTool(name, args = {}, opts = {}) {
664
+ const env = opts.env || process.env;
665
+ if (name === "smart_compact_search") return search(args, env, opts.cwd || process.cwd(), opts);
666
+ if (name === "smart_compact_get") return getBlob(args, env);
667
+ if (name === "smart_compact_sql") return runSql(args, env);
668
+ if (name === "context_status") return contextStatusTool(args, env);
669
+ throw new Error(`unknown tool: ${name}`);
670
+ }