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 +129 -15
- package/mcp-server/index.js +6 -1
- package/mcp-server/test/benchmark.js +365 -0
- package/mcp-server/tools/read.js +12 -67
- package/mcp-server/tools/search.js +5 -108
- package/mcp-server/tools/update-map.js +47 -11
- package/mcp-server/tools/write.js +7 -1
- package/mcp-server/vault-index.js +127 -0
- package/package.json +1 -1
- package/setup-server/public/index.html +234 -4
- package/setup-server/server.js +11 -0
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
|
-
-
|
|
163
|
-
|
|
164
|
-
-
|
|
165
|
-
|
|
166
|
-
-
|
|
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
|
-
-
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
|
|
225
|
-
-
|
|
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
|
-
|
|
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:
|
|
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;
|
package/mcp-server/index.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|