@strvmarv/total-recall 0.6.8-beta.4 → 0.6.8-beta.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strvmarv/total-recall",
3
- "version": "0.6.8-beta.4",
3
+ "version": "0.6.8-beta.7",
4
4
  "description": "Multi-tiered memory and knowledge base plugin for TUI coding assistants",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,8 @@
17
17
  "prepare": "git config core.hooksPath git-hooks || true",
18
18
  "postinstall": "node scripts/postinstall.js",
19
19
  "prepublishOnly": "npm run build && npm run test:dist",
20
- "benchmark": "bun dist/eval/ci-smoke.js"
20
+ "benchmark": "bun dist/eval/ci-smoke.js",
21
+ "smoke": "node scripts/mcp-smoke-test.mjs"
21
22
  },
22
23
  "files": [
23
24
  "dist/",
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * End-to-end MCP smoke test.
4
+ *
5
+ * Why this exists: `vitest.config.ts` aliases `bun:sqlite` to a
6
+ * `better-sqlite3`-backed shim (`tests/helpers/bun-sqlite-shim.ts`), so the
7
+ * entire vitest suite runs against better-sqlite3 — which ships its own
8
+ * SQLite with extension loading enabled — NOT the real `bun:sqlite` that
9
+ * production uses. That's how the macOS darwin extension-loading bug
10
+ * (0.6.8-beta.6) slipped past green CI on all three matrix legs.
11
+ *
12
+ * This script plugs that hole. It launches the built MCP server via
13
+ * `bin/start.cjs` (which re-execs `dist/index.js` under bundled bun,
14
+ * exercising the real `src/db/sqlite-bootstrap.ts` → `Database.setCustomSQLite()`
15
+ * → `sqliteVec.load()` → vector query path) and drives it over stdio with
16
+ * the real MCP client SDK. Any platform-specific runtime regression in
17
+ * the SQLite stack will fail this test — darwin, linux, windows, all
18
+ * the same.
19
+ *
20
+ * Passes:
21
+ * 1. Default DB path (TOTAL_RECALL_HOME=<tmp>, TOTAL_RECALL_DB_PATH unset).
22
+ * 2. Custom DB path (TOTAL_RECALL_DB_PATH=<deeply-nested>/custom.db).
23
+ * 3. (Task 6) Invalid env var → startup fail-fast.
24
+ *
25
+ * Run locally: `npm run smoke`
26
+ * Run in CI: after `bun run build`, on every matrix leg.
27
+ */
28
+
29
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
31
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join, dirname } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { spawn } from "node:child_process";
36
+
37
+ const SCRIPT_DIR = fileURLToPath(new URL(".", import.meta.url));
38
+ const REPO_ROOT = join(SCRIPT_DIR, "..");
39
+ const START_CJS = join(REPO_ROOT, "bin", "start.cjs");
40
+
41
+ let exitCode = 0;
42
+
43
+ function fail(msg, err) {
44
+ exitCode = 1;
45
+ process.stderr.write(`[smoke] FAIL: ${msg}\n`);
46
+ if (err) process.stderr.write(`${err.stack ?? err}\n`);
47
+ }
48
+
49
+ function ok(msg) {
50
+ process.stdout.write(`[smoke] ok: ${msg}\n`);
51
+ }
52
+
53
+ async function parseToolResult(result) {
54
+ // Tool results come back as { content: [{ type: "text", text: "<json>" }] }
55
+ const text = result?.content?.[0]?.text;
56
+ if (typeof text !== "string") {
57
+ throw new Error(`expected text content in tool result, got: ${JSON.stringify(result)}`);
58
+ }
59
+ return JSON.parse(text);
60
+ }
61
+
62
+ /**
63
+ * Connect an MCP client to a freshly-spawned total-recall server.
64
+ * Returns {client, cleanup}. cleanup() closes the client (which kills
65
+ * the child). Tempdir removal is the caller's responsibility.
66
+ */
67
+ async function spawnMcpClient({ env, label }) {
68
+ process.stdout.write(`[smoke] launching (${label}): node ${START_CJS}\n`);
69
+ const transport = new StdioClientTransport({
70
+ command: process.execPath,
71
+ args: [START_CJS],
72
+ cwd: REPO_ROOT,
73
+ env: { ...process.env, ...env },
74
+ });
75
+ const client = new Client(
76
+ { name: "mcp-smoke-test", version: "1.0.0" },
77
+ { capabilities: {} },
78
+ );
79
+ await client.connect(transport);
80
+ return {
81
+ client,
82
+ cleanup: async () => {
83
+ try { await client.close(); } catch (e) { fail(`client.close threw (${label})`, e); }
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Pass 1: default DB path (TOTAL_RECALL_HOME points at tmpdir,
90
+ * TOTAL_RECALL_DB_PATH unset). Exercises the baseline MCP server
91
+ * against a throwaway data dir, validating tools/list, status,
92
+ * memory_store, memory_search (critical vector path), memory_delete.
93
+ */
94
+ async function runPass1Default() {
95
+ let tempHome = "";
96
+ let cleanup = async () => {};
97
+ try {
98
+ tempHome = mkdtempSync(join(tmpdir(), "total-recall-smoke-pass1-"));
99
+ process.stdout.write(`[smoke] pass 1: TOTAL_RECALL_HOME=${tempHome}\n`);
100
+ const spawned = await spawnMcpClient({
101
+ env: { TOTAL_RECALL_HOME: tempHome, TOTAL_RECALL_DB_PATH: "" },
102
+ label: "pass 1 default",
103
+ });
104
+ const { client } = spawned;
105
+ cleanup = spawned.cleanup;
106
+ ok("pass 1: connected to MCP server");
107
+
108
+ const { tools } = await client.listTools();
109
+ if (!Array.isArray(tools) || tools.length === 0) {
110
+ throw new Error(`expected non-empty tools list, got: ${JSON.stringify(tools)}`);
111
+ }
112
+ const expectedTools = ["status", "memory_store", "memory_search", "memory_delete"];
113
+ const toolNames = new Set(tools.map((t) => t.name));
114
+ for (const name of expectedTools) {
115
+ if (!toolNames.has(name)) throw new Error(`expected tool '${name}' in tools/list`);
116
+ }
117
+ ok(`pass 1: tools/list returned ${tools.length} tools (including ${expectedTools.join(", ")})`);
118
+
119
+ const statusResult = await parseToolResult(
120
+ await client.callTool({ name: "status", arguments: {} }),
121
+ );
122
+ if (!statusResult?.db?.path) throw new Error(`status missing db.path: ${JSON.stringify(statusResult)}`);
123
+ if (!statusResult.db.path.startsWith(tempHome)) {
124
+ throw new Error(`pass 1: status db.path not in temp home — got: ${statusResult.db.path}`);
125
+ }
126
+ ok(`pass 1: status: db at ${statusResult.db.path} (${statusResult.db.sizeBytes} bytes)`);
127
+
128
+ const storeResult = await parseToolResult(
129
+ await client.callTool({
130
+ name: "memory_store",
131
+ arguments: {
132
+ content: "MCP smoke pass 1: default DB path via TOTAL_RECALL_HOME.",
133
+ entryType: "decision",
134
+ tags: ["smoke-test", "ci", "pass1"],
135
+ source: "mcp-smoke-test",
136
+ project: "total-recall",
137
+ },
138
+ }),
139
+ );
140
+ if (!storeResult?.id) throw new Error(`memory_store returned no id: ${JSON.stringify(storeResult)}`);
141
+ const storedId = storeResult.id;
142
+ ok(`pass 1: memory_store: id=${storedId}`);
143
+
144
+ const searchResult = await parseToolResult(
145
+ await client.callTool({
146
+ name: "memory_search",
147
+ arguments: { query: "smoke test default path", topK: 3 },
148
+ }),
149
+ );
150
+ if (!Array.isArray(searchResult) || searchResult.length === 0) {
151
+ throw new Error(`memory_search returned no results: ${JSON.stringify(searchResult)}`);
152
+ }
153
+ const topMatch = searchResult[0];
154
+ if (topMatch?.entry?.id !== storedId) {
155
+ throw new Error(`pass 1: memory_search top result wrong — expected ${storedId}, got ${topMatch?.entry?.id}`);
156
+ }
157
+ ok(`pass 1: memory_search: top result id=${topMatch.entry.id} score=${topMatch.score.toFixed(3)}`);
158
+
159
+ const deleteResult = await parseToolResult(
160
+ await client.callTool({ name: "memory_delete", arguments: { id: storedId } }),
161
+ );
162
+ if (deleteResult?.deleted !== true) {
163
+ throw new Error(`memory_delete did not confirm deletion: ${JSON.stringify(deleteResult)}`);
164
+ }
165
+ ok(`pass 1: memory_delete: deleted ${storedId}`);
166
+ } finally {
167
+ await cleanup();
168
+ if (tempHome) {
169
+ try { rmSync(tempHome, { recursive: true, force: true }); } catch {}
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Pass 2: custom DB path via TOTAL_RECALL_DB_PATH. Verifies that:
176
+ * - The server honors the env var end-to-end (status reports the override).
177
+ * - The parent directory of the custom path is auto-created (it does not
178
+ * exist before the server spawns).
179
+ * - The SQLite file is created at exactly the configured location.
180
+ * - Vector search still works against the relocated DB (the critical
181
+ * sqlite-vec + embeddings pipeline is unaffected by the path change).
182
+ */
183
+ async function runPass2CustomDbPath() {
184
+ let tempHome = "";
185
+ let tempDbDir = "";
186
+ let cleanup = async () => {};
187
+ try {
188
+ tempHome = mkdtempSync(join(tmpdir(), "total-recall-smoke-pass2-home-"));
189
+ tempDbDir = mkdtempSync(join(tmpdir(), "total-recall-smoke-pass2-db-"));
190
+ // Deliberately deeper than the temp root: mkdirSync(dirname(dbPath)) in
191
+ // connection.ts should create the "nested/sub" segments on first run.
192
+ const customDbPath = join(tempDbDir, "nested", "sub", "custom.db");
193
+ process.stdout.write(`[smoke] pass 2: TOTAL_RECALL_HOME=${tempHome}\n`);
194
+ process.stdout.write(`[smoke] pass 2: TOTAL_RECALL_DB_PATH=${customDbPath}\n`);
195
+
196
+ // Sanity: parent dir must NOT exist before the server spawns.
197
+ if (existsSync(dirname(customDbPath))) {
198
+ throw new Error(`pass 2: precondition failed — parent dir already exists: ${dirname(customDbPath)}`);
199
+ }
200
+
201
+ const spawned = await spawnMcpClient({
202
+ env: {
203
+ TOTAL_RECALL_HOME: tempHome,
204
+ TOTAL_RECALL_DB_PATH: customDbPath,
205
+ },
206
+ label: "pass 2 custom db path",
207
+ });
208
+ const { client } = spawned;
209
+ cleanup = spawned.cleanup;
210
+ ok("pass 2: connected to MCP server");
211
+
212
+ const statusResult = await parseToolResult(
213
+ await client.callTool({ name: "status", arguments: {} }),
214
+ );
215
+ // NOTE: This is strict equality against the raw env-var value. If
216
+ // getDbPath() or connection.ts ever starts resolving symlinks (e.g.
217
+ // realpathSync), this assertion will break on macOS where tmpdir()
218
+ // returns /var/folders/... which is a symlink to /private/var/.... The
219
+ // spec mandates passthrough of the literal value, so preserving this
220
+ // strict check also guards that contract.
221
+ if (statusResult?.db?.path !== customDbPath) {
222
+ throw new Error(
223
+ `pass 2: status db.path mismatch — expected ${customDbPath}, got ${statusResult?.db?.path}`,
224
+ );
225
+ }
226
+ ok(`pass 2: status reports custom path: ${statusResult.db.path}`);
227
+
228
+ // Assert the file actually exists on disk at the configured location.
229
+ if (!existsSync(customDbPath)) {
230
+ throw new Error(`pass 2: DB file not found at ${customDbPath} after handshake`);
231
+ }
232
+ ok(`pass 2: DB file exists at ${customDbPath}`);
233
+
234
+ // Assert the parent dir was auto-created (was absent pre-spawn).
235
+ if (!existsSync(dirname(customDbPath))) {
236
+ throw new Error(`pass 2: parent dir not created at ${dirname(customDbPath)}`);
237
+ }
238
+ ok(`pass 2: parent directory was auto-created: ${dirname(customDbPath)}`);
239
+
240
+ // Vector search against the relocated DB.
241
+ const storeResult = await parseToolResult(
242
+ await client.callTool({
243
+ name: "memory_store",
244
+ arguments: {
245
+ content: "MCP smoke pass 2: custom DB path via TOTAL_RECALL_DB_PATH.",
246
+ entryType: "decision",
247
+ tags: ["smoke-test", "ci", "pass2"],
248
+ source: "mcp-smoke-test",
249
+ project: "total-recall",
250
+ },
251
+ }),
252
+ );
253
+ if (!storeResult?.id) throw new Error(`pass 2: memory_store returned no id`);
254
+ const storedId = storeResult.id;
255
+ ok(`pass 2: memory_store against relocated DB: id=${storedId}`);
256
+
257
+ const searchResult = await parseToolResult(
258
+ await client.callTool({
259
+ name: "memory_search",
260
+ arguments: { query: "smoke test custom path relocated", topK: 3 },
261
+ }),
262
+ );
263
+ if (!Array.isArray(searchResult) || searchResult.length === 0) {
264
+ throw new Error(`pass 2: memory_search returned no results`);
265
+ }
266
+ const topMatch = searchResult[0];
267
+ if (topMatch?.entry?.id !== storedId) {
268
+ throw new Error(
269
+ `pass 2: memory_search top result wrong — expected ${storedId}, got ${topMatch?.entry?.id}`,
270
+ );
271
+ }
272
+ ok(`pass 2: vector search hit the relocated DB: score=${topMatch.score.toFixed(3)}`);
273
+
274
+ const deleteResult = await parseToolResult(
275
+ await client.callTool({ name: "memory_delete", arguments: { id: storedId } }),
276
+ );
277
+ if (deleteResult?.deleted !== true) {
278
+ throw new Error(`pass 2: memory_delete did not confirm deletion: ${JSON.stringify(deleteResult)}`);
279
+ }
280
+ ok(`pass 2: memory_delete: deleted ${storedId}`);
281
+ } finally {
282
+ await cleanup();
283
+ if (tempHome) {
284
+ try { rmSync(tempHome, { recursive: true, force: true }); } catch {}
285
+ }
286
+ if (tempDbDir) {
287
+ try { rmSync(tempDbDir, { recursive: true, force: true }); } catch {}
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Pass 3: invalid TOTAL_RECALL_DB_PATH must fail fast at startup.
294
+ *
295
+ * This pass does NOT use the MCP client SDK — it spawns the server
296
+ * process directly and inspects exit code + stderr. The SDK would
297
+ * blow up during transport init when the child exits, masking the
298
+ * real assertion.
299
+ *
300
+ * Contract from the design spec:
301
+ * - Server must exit with a non-zero code.
302
+ * - Exit must happen BEFORE the 5-second watchdog.
303
+ * - stderr must contain the SqliteDbPathError message.
304
+ * - stderr must echo the raw bad value.
305
+ * - No partial DB should be created anywhere.
306
+ */
307
+ async function runPass3InvalidDbPath() {
308
+ process.stdout.write(`[smoke] pass 3: invalid TOTAL_RECALL_DB_PATH="./relative.db" (expecting fail-fast)\n`);
309
+
310
+ const child = spawn(process.execPath, [START_CJS], {
311
+ cwd: REPO_ROOT,
312
+ env: {
313
+ ...process.env,
314
+ TOTAL_RECALL_DB_PATH: "./relative.db",
315
+ },
316
+ stdio: ["ignore", "pipe", "pipe"],
317
+ });
318
+
319
+ let stdoutBuf = "";
320
+ let stderrBuf = "";
321
+ child.stdout.on("data", (c) => { stdoutBuf += c.toString(); });
322
+ child.stderr.on("data", (c) => { stderrBuf += c.toString(); });
323
+
324
+ const WATCHDOG_MS = 5000;
325
+ let timedOut = false;
326
+ const watchdog = setTimeout(() => {
327
+ timedOut = true;
328
+ try { child.kill("SIGKILL"); } catch {}
329
+ }, WATCHDOG_MS);
330
+
331
+ // Await `close` not `exit`: Node documents that exit can fire while stdio
332
+ // streams are still being drained. On a fast-failing child, we'd race
333
+ // the stderr `data` event and get false negatives on the substring
334
+ // assertions below. `close` is explicitly emitted after all stdio streams
335
+ // have closed.
336
+ const exitInfo = await new Promise((resolve, reject) => {
337
+ child.on("error", (err) => {
338
+ clearTimeout(watchdog);
339
+ reject(new Error(`pass 3: failed to spawn child: ${err.message}`));
340
+ });
341
+ child.on("close", (code, signal) => {
342
+ clearTimeout(watchdog);
343
+ resolve({ code, signal });
344
+ });
345
+ });
346
+
347
+ if (timedOut) {
348
+ throw new Error(
349
+ `pass 3: server did not exit within ${WATCHDOG_MS}ms — fail-fast contract violated. stderr so far: ${stderrBuf}`,
350
+ );
351
+ }
352
+ if (exitInfo.code === 0) {
353
+ throw new Error(`pass 3: server exited with code 0 — expected non-zero. stderr: ${stderrBuf}`);
354
+ }
355
+ // Substring must match the SqliteDbPathError message produced by
356
+ // src/config.ts:getDbPath() for a non-absolute value. If the wording in
357
+ // config.ts changes, update this string in lockstep.
358
+ if (!stderrBuf.includes("TOTAL_RECALL_DB_PATH must be absolute or start with ~/")) {
359
+ throw new Error(
360
+ `pass 3: stderr missing expected error message. Got stderr: ${stderrBuf}`,
361
+ );
362
+ }
363
+ if (!stderrBuf.includes('"./relative.db"')) {
364
+ throw new Error(
365
+ `pass 3: stderr missing raw value echo. Got stderr: ${stderrBuf}`,
366
+ );
367
+ }
368
+ ok(`pass 3: server exited with code ${exitInfo.code} (signal ${exitInfo.signal ?? "none"}) before watchdog`);
369
+ ok(`pass 3: stderr contained the expected SqliteDbPathError message`);
370
+ // stdoutBuf is captured for potential future assertions (e.g. "did not
371
+ // write MCP handshake") but we don't assert on it right now.
372
+ void stdoutBuf;
373
+ }
374
+
375
+ async function runAllPasses() {
376
+ await runPass1Default();
377
+ await runPass2CustomDbPath();
378
+ await runPass3InvalidDbPath();
379
+ }
380
+
381
+ try {
382
+ await runAllPasses();
383
+ process.stdout.write("[smoke] ALL CHECKS PASSED\n");
384
+ } catch (e) {
385
+ fail("smoke test aborted", e);
386
+ } finally {
387
+ process.exit(exitCode);
388
+ }
@@ -9,6 +9,27 @@ import os from "node:os";
9
9
 
10
10
  const BUN_VERSION = "1.2.10";
11
11
 
12
+ // Keep in sync with src/db/sqlite-bootstrap.ts DARWIN_SQLITE_CANDIDATES.
13
+ const DARWIN_SQLITE_CANDIDATES = [
14
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
15
+ "/usr/local/opt/sqlite/lib/libsqlite3.dylib",
16
+ ];
17
+
18
+ function checkDarwinSqlite() {
19
+ if (process.platform !== "darwin") return;
20
+ if (DARWIN_SQLITE_CANDIDATES.some((p) => fs.existsSync(p))) return;
21
+ warn("");
22
+ warn("macOS: no extension-capable libsqlite3 found.");
23
+ warn("total-recall uses sqlite-vec, which needs a libsqlite3 built with");
24
+ warn("SQLITE_ENABLE_LOAD_EXTENSION. The /usr/lib/libsqlite3.dylib shipped");
25
+ warn("with macOS does NOT have it. Install Homebrew sqlite to fix:");
26
+ warn("");
27
+ warn(" brew install sqlite");
28
+ warn("");
29
+ warn("The server will fail at first use with a clear error if this is");
30
+ warn("not resolved before starting total-recall.");
31
+ }
32
+
12
33
  const PLATFORM_MAP = {
13
34
  "linux-x64": "bun-linux-x64",
14
35
  "linux-arm64": "bun-linux-aarch64",
@@ -90,6 +111,10 @@ async function extractZip(zipPath, destDir) {
90
111
  }
91
112
 
92
113
  async function main() {
114
+ // Always run the darwin sqlite check — independent of bun download state —
115
+ // so the warning fires on cached installs too.
116
+ checkDarwinSqlite();
117
+
93
118
  const platformKey = getPlatformKey();
94
119
  if (!platformKey || !PLATFORM_MAP[platformKey]) {
95
120
  warn(`unsupported platform ${process.platform}-${process.arch}. Supported: ${Object.keys(PLATFORM_MAP).join(", ")}`);