limbo-ai 1.20.3 → 1.21.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/cli.js CHANGED
@@ -159,12 +159,11 @@ function composeContent() {
159
159
  - ${VAULT_DIR}:/data/vault
160
160
  - limbo-zeroclaw-state:/home/limbo/.zeroclaw
161
161
  secrets:
162
- - source: llm_api_key
163
- mode: 0444
164
- - source: telegram_bot_token
165
- mode: 0444
166
- - source: gateway_token
167
- mode: 0444
162
+ - llm_api_key
163
+ - telegram_bot_token
164
+ - gateway_token
165
+ - groq_api_key
166
+ - brave_api_key
168
167
  env_file:
169
168
  - ${LIMBO_DIR}/.env
170
169
  environment:
@@ -186,6 +185,10 @@ secrets:
186
185
  file: ${SECRETS_DIR}/telegram_bot_token
187
186
  gateway_token:
188
187
  file: ${SECRETS_DIR}/gateway_token
188
+ groq_api_key:
189
+ file: ${SECRETS_DIR}/groq_api_key
190
+ brave_api_key:
191
+ file: ${SECRETS_DIR}/brave_api_key
189
192
 
190
193
  volumes:
191
194
  limbo-data:
@@ -218,12 +221,11 @@ function composeContentHardened() {
218
221
  - ${VAULT_DIR}:/data/vault
219
222
  - limbo-zeroclaw-state:/home/limbo/.zeroclaw
220
223
  secrets:
221
- - source: llm_api_key
222
- mode: 0444
223
- - source: telegram_bot_token
224
- mode: 0444
225
- - source: gateway_token
226
- mode: 0444
224
+ - llm_api_key
225
+ - telegram_bot_token
226
+ - gateway_token
227
+ - groq_api_key
228
+ - brave_api_key
227
229
  env_file:
228
230
  - ${LIMBO_DIR}/.env
229
231
  environment:
@@ -276,6 +278,10 @@ secrets:
276
278
  file: ${SECRETS_DIR}/telegram_bot_token
277
279
  gateway_token:
278
280
  file: ${SECRETS_DIR}/gateway_token
281
+ groq_api_key:
282
+ file: ${SECRETS_DIR}/groq_api_key
283
+ brave_api_key:
284
+ file: ${SECRETS_DIR}/brave_api_key
279
285
 
280
286
  volumes:
281
287
  limbo-data:
@@ -705,6 +711,8 @@ function normalizeConfig(cfg, existingEnv = {}) {
705
711
  TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
706
712
  TELEGRAM_AUTO_PAIR_FIRST_DM: cfg.telegramAutoPair || existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'false',
707
713
  GATEWAY_TOKEN: gatewayToken,
714
+ VOICE_ENABLED: cfg.voiceEnabled || existingEnv.VOICE_ENABLED || 'false',
715
+ WEB_SEARCH_ENABLED: cfg.webSearchEnabled || existingEnv.WEB_SEARCH_ENABLED || 'false',
708
716
  };
709
717
 
710
718
  return base;
@@ -713,7 +721,10 @@ function normalizeConfig(cfg, existingEnv = {}) {
713
721
  function writeSecretFile(name, value) {
714
722
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
715
723
  const filePath = path.join(SECRETS_DIR, name);
716
- fs.writeFileSync(filePath, value || '', { mode: 0o600 });
724
+ // Use 0644 so any container user can read the mounted file.
725
+ // Docker Compose file-based secrets ignore uid/gid/mode settings,
726
+ // so the host file permissions are what the container sees.
727
+ fs.writeFileSync(filePath, value || '', { mode: 0o644 });
717
728
  }
718
729
 
719
730
  function writeSecrets(cfg, existingEnv = {}) {
@@ -721,6 +732,8 @@ function writeSecrets(cfg, existingEnv = {}) {
721
732
  writeSecretFile('llm_api_key', normalized.LLM_API_KEY);
722
733
  writeSecretFile('telegram_bot_token', normalized.TELEGRAM_BOT_TOKEN);
723
734
  writeSecretFile('gateway_token', normalized.GATEWAY_TOKEN);
735
+ writeSecretFile('groq_api_key', cfg.groqApiKey || readSecretFile('groq_api_key'));
736
+ writeSecretFile('brave_api_key', cfg.braveApiKey || readSecretFile('brave_api_key'));
724
737
  }
725
738
 
726
739
  const SECRET_KEYS = new Set([
@@ -925,9 +938,9 @@ function ensureComposeFile(hardened = false) {
925
938
  fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
926
939
  fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
927
940
  // Ensure secret files exist (Docker Compose secrets require the files to be present)
928
- for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token']) {
941
+ for (const name of ['llm_api_key', 'telegram_bot_token', 'gateway_token', 'groq_api_key', 'brave_api_key']) {
929
942
  const fp = path.join(SECRETS_DIR, name);
930
- if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o600 });
943
+ if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o644 });
931
944
  }
932
945
  if (hardened) {
933
946
  // Copy squid config files for egress filtering
@@ -1686,6 +1699,98 @@ function cmdStatus() {
1686
1699
  run('docker compose ps');
1687
1700
  }
1688
1701
 
1702
+ function cmdConfig() {
1703
+ const args = process.argv.slice(3);
1704
+ const feature = args[0];
1705
+
1706
+ if (!feature || !['voice', 'web-search'].includes(feature)) {
1707
+ console.log(`
1708
+ ${c.bold}Usage:${c.reset}
1709
+ limbo config voice --enable --api-key <key>
1710
+ limbo config voice --disable
1711
+ limbo config voice --status
1712
+ limbo config web-search --enable --api-key <key>
1713
+ limbo config web-search --disable
1714
+ limbo config web-search --status
1715
+ `);
1716
+ return;
1717
+ }
1718
+
1719
+ if (!fs.existsSync(ENV_FILE)) {
1720
+ die('Limbo is not configured. Run "limbo start" first.');
1721
+ }
1722
+
1723
+ const existingEnv = {};
1724
+ const envContent = fs.readFileSync(ENV_FILE, 'utf8');
1725
+ for (const line of envContent.split('\n')) {
1726
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
1727
+ if (match) existingEnv[match[1]] = match[2].replace(/^"|"$/g, '');
1728
+ }
1729
+
1730
+ const isVoice = feature === 'voice';
1731
+ const envKey = isVoice ? 'VOICE_ENABLED' : 'WEB_SEARCH_ENABLED';
1732
+ const secretName = isVoice ? 'groq_api_key' : 'brave_api_key';
1733
+ const featureLabel = isVoice ? 'Voice transcription' : 'Web search';
1734
+
1735
+ const hasEnable = args.includes('--enable');
1736
+ const hasDisable = args.includes('--disable');
1737
+ const hasStatus = args.includes('--status');
1738
+ const apiKeyIdx = args.indexOf('--api-key');
1739
+ const apiKey = apiKeyIdx !== -1 ? args[apiKeyIdx + 1] : null;
1740
+
1741
+ if (hasStatus) {
1742
+ const enabled = existingEnv[envKey] === 'true';
1743
+ const key = readSecretFile(secretName);
1744
+ console.log(`${featureLabel}: ${enabled ? `${c.green}enabled${c.reset}` : `${c.dim}disabled${c.reset}`}`);
1745
+ if (key) {
1746
+ const masked = key.length > 8 ? key.substring(0, 4) + '...' + key.slice(-4) : '***';
1747
+ console.log(`API Key: ${masked}`);
1748
+ }
1749
+ return;
1750
+ }
1751
+
1752
+ if (!hasEnable && !hasDisable) {
1753
+ die('Specify --enable, --disable, or --status');
1754
+ }
1755
+
1756
+ if (hasEnable) {
1757
+ if (apiKey) {
1758
+ if (isVoice && !apiKey.startsWith('gsk_')) {
1759
+ warn('Groq API keys typically start with gsk_');
1760
+ }
1761
+ if (!isVoice && !apiKey.startsWith('BSA')) {
1762
+ warn('Brave API keys typically start with BSA');
1763
+ }
1764
+ writeSecretFile(secretName, apiKey);
1765
+ ok(`${featureLabel} API key saved.`);
1766
+ } else {
1767
+ const existing = readSecretFile(secretName);
1768
+ if (!existing) {
1769
+ die(`No API key found. Use --api-key <key> to set one.`);
1770
+ }
1771
+ }
1772
+ existingEnv[envKey] = 'true';
1773
+ } else {
1774
+ existingEnv[envKey] = 'false';
1775
+ }
1776
+
1777
+ const newContent = Object.entries(existingEnv)
1778
+ .map(([key, value]) => `${key}=${value}`)
1779
+ .join('\n') + '\n';
1780
+ fs.writeFileSync(ENV_FILE, newContent, { mode: 0o600 });
1781
+ ok(`${featureLabel} ${hasEnable ? 'enabled' : 'disabled'}.`);
1782
+
1783
+ if (fs.existsSync(COMPOSE_FILE)) {
1784
+ log('Restarting container...');
1785
+ try {
1786
+ execSync(`docker compose -f "${COMPOSE_FILE}" restart limbo`, { stdio: 'inherit' });
1787
+ ok('Container restarted.');
1788
+ } catch {
1789
+ warn('Could not restart container. Restart manually with: limbo stop && limbo start');
1790
+ }
1791
+ }
1792
+ }
1793
+
1689
1794
  function cmdHelp() {
1690
1795
  console.log(`
1691
1796
  ${c.bold}limbo${c.reset} - personal AI memory agent
@@ -1699,6 +1804,7 @@ ${c.bold}Commands:${c.reset}
1699
1804
  logs Tail container logs
1700
1805
  update Pull latest image and restart
1701
1806
  status Show container status
1807
+ config Configure optional features (voice, web-search)
1702
1808
  help Show this help
1703
1809
 
1704
1810
  ${c.bold}Flags:${c.reset}
@@ -1711,6 +1817,13 @@ ${c.bold}Flags:${c.reset}
1711
1817
  --language <code> Language: en, es (default: en)
1712
1818
  --tunnel-domain <d> Admin: use branded subdomain for setup tunnel (e.g. limbo.tomasward.com)
1713
1819
 
1820
+ ${c.bold}Config:${c.reset}
1821
+ limbo config voice --enable --api-key gsk_xxx Enable voice transcription
1822
+ limbo config voice --disable Disable voice transcription
1823
+ limbo config web-search --enable --api-key BSAxxx Enable web search
1824
+ limbo config web-search --disable Disable web search
1825
+ limbo config voice --status Show feature status
1826
+
1714
1827
  ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
1715
1828
  `);
1716
1829
  }
@@ -1727,6 +1840,7 @@ const [,, cmd = 'start'] = process.argv;
1727
1840
  case 'logs': cmdLogs(); break;
1728
1841
  case 'update': cmdUpdate(); break;
1729
1842
  case 'status': cmdStatus(); break;
1843
+ case 'config': cmdConfig(); break;
1730
1844
  case 'help':
1731
1845
  case '--help':
1732
1846
  case '-h': cmdHelp(); break;
@@ -5,6 +5,7 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
 
8
+ import { buildIndex } from "./vault-index.js";
8
9
  import { vaultSearch } from "./tools/search.js";
9
10
  import { vaultRead } from "./tools/read.js";
10
11
  import { vaultWriteNote } from "./tools/write.js";
@@ -13,7 +14,7 @@ import { vaultUpdateMap } from "./tools/update-map.js";
13
14
  const server = new Server(
14
15
  {
15
16
  name: "limbo-vault",
16
- version: "1.1.0",
17
+ version: "1.2.0",
17
18
  },
18
19
  {
19
20
  capabilities: {
@@ -172,5 +173,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
172
173
 
173
174
  // ── Start ───────────────────────────────────────────────────────────────────
174
175
 
176
+ // Build in-memory index before accepting connections
177
+ const noteCount = await buildIndex();
178
+ process.stderr.write(`[limbo-vault] Index built: ${noteCount} notes indexed\n`);
179
+
175
180
  const transport = new StdioServerTransport();
176
181
  await server.connect(transport);
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Benchmark: old (filesystem scan) vs new (in-memory index)
3
+ *
4
+ * Creates a temporary vault with N notes, then compares:
5
+ * - vault_search latency
6
+ * - vault_read latency
7
+ * - vault_write_note + subsequent search latency
8
+ *
9
+ * Run: node test/benchmark.js [noteCount]
10
+ * default noteCount = 200
11
+ */
12
+
13
+ import { mkdir, writeFile, rm } from "fs/promises";
14
+ import { join, basename, relative } from "path";
15
+ import { tmpdir } from "os";
16
+ import { randomUUID } from "crypto";
17
+
18
+ // ── Scaffold a temp vault ──────────────────────────────────────────────────
19
+
20
+ const NOTE_COUNT = parseInt(process.argv[2] || "200", 10);
21
+ const VAULT_DIR = join(tmpdir(), `limbo-bench-${randomUUID().slice(0, 8)}`);
22
+ const NOTES_DIR = join(VAULT_DIR, "notes");
23
+ const MAPS_DIR = join(VAULT_DIR, "maps");
24
+
25
+ const DOMAINS = ["personal", "research", "projects", "aios", "limbo"];
26
+ const TYPES = ["fact", "preference", "idea", "insight", "decision"];
27
+
28
+ function generateNote(i) {
29
+ const domain = DOMAINS[i % DOMAINS.length];
30
+ const type = TYPES[i % TYPES.length];
31
+ const id = `bench-note-${String(i).padStart(4, "0")}`;
32
+ const title = `Benchmark note number ${i} about ${domain}`;
33
+ const description = `This is test note ${i} in the ${domain} domain for performance benchmarking`;
34
+ const body = [
35
+ `This note contains searchable content for domain ${domain}.`,
36
+ `Keywords: optimization, performance, latency, throughput, indexing.`,
37
+ `Note index: ${i}. UUID: ${randomUUID()}.`,
38
+ `The quick brown fox jumps over the lazy dog.`,
39
+ i % 7 === 0 ? "Special keyword: UNIQUE_NEEDLE" : "",
40
+ i % 3 === 0 ? `Reference to [[bench-note-${String(i - 1).padStart(4, "0")}]]` : "",
41
+ ].join("\n\n");
42
+
43
+ const frontmatter = [
44
+ "---",
45
+ `id: ${id}`,
46
+ `title: "${title}"`,
47
+ `description: "${description}"`,
48
+ `type: ${type}`,
49
+ `schema_version: 1`,
50
+ `domain: ${domain}`,
51
+ `created: "2026-03-19"`,
52
+ `source: benchmark`,
53
+ "topics:",
54
+ ` - "[[${domain}-map]]"`,
55
+ "---",
56
+ ].join("\n");
57
+
58
+ return { id, domain, content: `${frontmatter}\n\n${body}\n` };
59
+ }
60
+
61
+ async function scaffoldVault() {
62
+ await mkdir(NOTES_DIR, { recursive: true });
63
+ await mkdir(MAPS_DIR, { recursive: true });
64
+
65
+ const writes = [];
66
+ for (let i = 0; i < NOTE_COUNT; i++) {
67
+ const { id, domain, content } = generateNote(i);
68
+ const dir = join(NOTES_DIR, domain);
69
+ writes.push(
70
+ mkdir(dir, { recursive: true }).then(() =>
71
+ writeFile(join(dir, `${id}.md`), content, "utf8")
72
+ )
73
+ );
74
+ }
75
+ await Promise.all(writes);
76
+ }
77
+
78
+ async function cleanup() {
79
+ await rm(VAULT_DIR, { recursive: true, force: true });
80
+ }
81
+
82
+ // ── Old implementation (filesystem scan) ───────────────────────────────────
83
+
84
+ import { readdir, readFile, stat } from "fs/promises";
85
+
86
+ async function oldWalkNotes(dir, base = dir) {
87
+ const entries = [];
88
+ let items;
89
+ try {
90
+ items = await readdir(dir);
91
+ } catch {
92
+ return entries;
93
+ }
94
+ for (const item of items) {
95
+ if (item.startsWith(".") || item === "_meta") continue;
96
+ const full = join(dir, item);
97
+ let s;
98
+ try {
99
+ s = await stat(full);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (s.isDirectory()) {
104
+ const sub = await oldWalkNotes(full, base);
105
+ entries.push(...sub);
106
+ } else if (item.endsWith(".md")) {
107
+ const rel = relative(base, dir);
108
+ entries.push({ filePath: full, domain: rel || null });
109
+ }
110
+ }
111
+ return entries;
112
+ }
113
+
114
+ function oldExtractTitle(content) {
115
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
116
+ if (fmMatch) {
117
+ const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
118
+ if (titleMatch) return titleMatch[1];
119
+ }
120
+ return null;
121
+ }
122
+
123
+ async function oldSearch(query) {
124
+ const files = await oldWalkNotes(NOTES_DIR);
125
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ const regex = new RegExp(escaped, "gi");
127
+ const results = [];
128
+ for (const { filePath, domain } of files) {
129
+ let content;
130
+ try {
131
+ content = await readFile(filePath, "utf8");
132
+ } catch {
133
+ continue;
134
+ }
135
+ const matches = content.match(regex);
136
+ if (!matches) continue;
137
+ const noteId = basename(filePath, ".md");
138
+ const title = oldExtractTitle(content) || noteId;
139
+ results.push({ noteId, title, score: matches.length, domain });
140
+ }
141
+ results.sort((a, b) => b.score - a.score);
142
+ return results;
143
+ }
144
+
145
+ async function oldRead(noteId) {
146
+ // Fast path
147
+ const flatPath = join(NOTES_DIR, `${noteId}.md`);
148
+ try {
149
+ await stat(flatPath);
150
+ return await readFile(flatPath, "utf8");
151
+ } catch {}
152
+
153
+ // Recursive search
154
+ async function searchDir(dir) {
155
+ let items;
156
+ try {
157
+ items = await readdir(dir);
158
+ } catch {
159
+ return null;
160
+ }
161
+ for (const item of items) {
162
+ if (item.startsWith(".") || item === "_meta") continue;
163
+ const full = join(dir, item);
164
+ let s;
165
+ try {
166
+ s = await stat(full);
167
+ } catch {
168
+ continue;
169
+ }
170
+ if (s.isDirectory()) {
171
+ const candidate = join(full, `${noteId}.md`);
172
+ try {
173
+ await stat(candidate);
174
+ return await readFile(candidate, "utf8");
175
+ } catch {
176
+ const found = await searchDir(full);
177
+ if (found) return found;
178
+ }
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ return searchDir(NOTES_DIR);
185
+ }
186
+
187
+ // ── New implementation (in-memory index) ───────────────────────────────────
188
+
189
+ // We inline the index here to avoid env var coupling with vault-index.js
190
+
191
+ const index = new Map();
192
+
193
+ function newExtractTitle(content) {
194
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
195
+ if (fmMatch) {
196
+ const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
197
+ if (titleMatch) return titleMatch[1];
198
+ const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
199
+ if (descMatch) return descMatch[1];
200
+ }
201
+ return null;
202
+ }
203
+
204
+ async function newWalkAndIndex(dir, base = dir) {
205
+ let items;
206
+ try {
207
+ items = await readdir(dir);
208
+ } catch {
209
+ return;
210
+ }
211
+ const promises = [];
212
+ for (const item of items) {
213
+ if (item.startsWith(".") || item === "_meta") continue;
214
+ const full = join(dir, item);
215
+ promises.push(
216
+ stat(full)
217
+ .then((s) => {
218
+ if (s.isDirectory()) return newWalkAndIndex(full, base);
219
+ if (item.endsWith(".md")) {
220
+ return readFile(full, "utf8")
221
+ .then((content) => {
222
+ const noteId = basename(full, ".md");
223
+ const domain = relative(base, dir) || null;
224
+ const title = newExtractTitle(content) || noteId;
225
+ index.set(noteId, { path: full, title, content, domain });
226
+ })
227
+ .catch(() => {});
228
+ }
229
+ })
230
+ .catch(() => {})
231
+ );
232
+ }
233
+ await Promise.all(promises);
234
+ }
235
+
236
+ async function newBuildIndex() {
237
+ index.clear();
238
+ await newWalkAndIndex(NOTES_DIR);
239
+ return index.size;
240
+ }
241
+
242
+ function newSearch(query) {
243
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
244
+ const regex = new RegExp(escaped, "gi");
245
+ const results = [];
246
+ for (const [noteId, entry] of index) {
247
+ const matches = entry.content.match(regex);
248
+ if (!matches) continue;
249
+ results.push({ noteId, title: entry.title, score: matches.length, domain: entry.domain });
250
+ }
251
+ results.sort((a, b) => b.score - a.score);
252
+ return results;
253
+ }
254
+
255
+ function newRead(noteId) {
256
+ const entry = index.get(noteId);
257
+ return entry ? entry.content : null;
258
+ }
259
+
260
+ // ── Benchmark harness ──────────────────────────────────────────────────────
261
+
262
+ async function timeMs(fn, iterations = 1) {
263
+ const times = [];
264
+ for (let i = 0; i < iterations; i++) {
265
+ const start = performance.now();
266
+ await fn();
267
+ times.push(performance.now() - start);
268
+ }
269
+ times.sort((a, b) => a - b);
270
+ return {
271
+ min: times[0],
272
+ median: times[Math.floor(times.length / 2)],
273
+ max: times[times.length - 1],
274
+ avg: times.reduce((a, b) => a + b, 0) / times.length,
275
+ };
276
+ }
277
+
278
+ function fmt(ms) {
279
+ if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
280
+ return `${ms.toFixed(2)}ms`;
281
+ }
282
+
283
+ function printResult(label, old, now) {
284
+ const speedup = old.median / now.median;
285
+ console.log(` ${label}`);
286
+ console.log(` OLD: median=${fmt(old.median)} avg=${fmt(old.avg)} min=${fmt(old.min)} max=${fmt(old.max)}`);
287
+ console.log(` NEW: median=${fmt(now.median)} avg=${fmt(now.avg)} min=${fmt(now.min)} max=${fmt(now.max)}`);
288
+ console.log(` Speedup: ${speedup.toFixed(1)}x faster`);
289
+ console.log();
290
+ }
291
+
292
+ // ── Main ───────────────────────────────────────────────────────────────────
293
+
294
+ console.log(`\n=== Limbo MCP Benchmark ===`);
295
+ console.log(`Notes: ${NOTE_COUNT}`);
296
+ console.log(`Vault: ${VAULT_DIR}\n`);
297
+
298
+ try {
299
+ // Setup
300
+ console.log("Scaffolding vault...");
301
+ await scaffoldVault();
302
+
303
+ // Build index (one-time cost)
304
+ console.log("Building index...");
305
+ const buildTime = await timeMs(() => newBuildIndex(), 3);
306
+ console.log(` Index build: median=${fmt(buildTime.median)} (${index.size} notes)\n`);
307
+
308
+ const ITERS = 20;
309
+
310
+ // ── Search: broad query (many matches) ─────────────────────────────────
311
+ console.log(`--- Search: broad query ("optimization") × ${ITERS} ---`);
312
+ const oldSearchBroad = await timeMs(() => oldSearch("optimization"), ITERS);
313
+ const newSearchBroad = await timeMs(() => newSearch("optimization"), ITERS);
314
+ printResult("Broad search", oldSearchBroad, newSearchBroad);
315
+
316
+ // ── Search: narrow query (few matches) ─────────────────────────────────
317
+ console.log(`--- Search: narrow query ("UNIQUE_NEEDLE") × ${ITERS} ---`);
318
+ const oldSearchNarrow = await timeMs(() => oldSearch("UNIQUE_NEEDLE"), ITERS);
319
+ const newSearchNarrow = await timeMs(() => newSearch("UNIQUE_NEEDLE"), ITERS);
320
+ printResult("Narrow search", oldSearchNarrow, newSearchNarrow);
321
+
322
+ // ── Search: no matches ─────────────────────────────────────────────────
323
+ console.log(`--- Search: miss ("xyzzy_nonexistent") × ${ITERS} ---`);
324
+ const oldSearchMiss = await timeMs(() => oldSearch("xyzzy_nonexistent"), ITERS);
325
+ const newSearchMiss = await timeMs(() => newSearch("xyzzy_nonexistent"), ITERS);
326
+ printResult("Miss search", oldSearchMiss, newSearchMiss);
327
+
328
+ // ── Read: note in subdirectory (worst case for old) ────────────────────
329
+ console.log(`--- Read: note in subdirectory × ${ITERS} ---`);
330
+ const readTarget = `bench-note-${String(Math.floor(NOTE_COUNT / 2)).padStart(4, "0")}`;
331
+ const oldReadSub = await timeMs(() => oldRead(readTarget), ITERS);
332
+ const newReadSub = await timeMs(() => newRead(readTarget), ITERS);
333
+ printResult(`Read "${readTarget}"`, oldReadSub, newReadSub);
334
+
335
+ // ── Read: nonexistent note ─────────────────────────────────────────────
336
+ console.log(`--- Read: nonexistent note × ${ITERS} ---`);
337
+ const oldReadMiss = await timeMs(() => oldRead("does-not-exist-999"), ITERS);
338
+ const newReadMiss = await timeMs(() => newRead("does-not-exist-999"), ITERS);
339
+ printResult("Read miss", oldReadMiss, newReadMiss);
340
+
341
+ // ── Correctness check ──────────────────────────────────────────────────
342
+ console.log("--- Correctness ---");
343
+ const oldResults = await oldSearch("optimization");
344
+ const newResults = newSearch("optimization");
345
+ const oldCount = oldResults.length;
346
+ const newCount = newResults.length;
347
+ const match = oldCount === newCount;
348
+ console.log(` Old result count: ${oldCount}`);
349
+ console.log(` New result count: ${newCount}`);
350
+ console.log(` Match: ${match ? "PASS ✓" : "FAIL ✗"}`);
351
+ if (!match) {
352
+ console.log(" WARNING: result counts differ!");
353
+ }
354
+
355
+ // Verify read returns identical content
356
+ const oldContent = await oldRead(readTarget);
357
+ const newContent = newRead(readTarget);
358
+ const readMatch = oldContent === newContent;
359
+ console.log(` Read content match: ${readMatch ? "PASS ✓" : "FAIL ✗"}`);
360
+ console.log();
361
+
362
+ } finally {
363
+ await cleanup();
364
+ console.log("Cleaned up.\n");
365
+ }