limbo-ai 1.20.4 → 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 +118 -1
- 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
|
@@ -162,6 +162,8 @@ function composeContent() {
|
|
|
162
162
|
- llm_api_key
|
|
163
163
|
- telegram_bot_token
|
|
164
164
|
- gateway_token
|
|
165
|
+
- groq_api_key
|
|
166
|
+
- brave_api_key
|
|
165
167
|
env_file:
|
|
166
168
|
- ${LIMBO_DIR}/.env
|
|
167
169
|
environment:
|
|
@@ -183,6 +185,10 @@ secrets:
|
|
|
183
185
|
file: ${SECRETS_DIR}/telegram_bot_token
|
|
184
186
|
gateway_token:
|
|
185
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
|
|
186
192
|
|
|
187
193
|
volumes:
|
|
188
194
|
limbo-data:
|
|
@@ -218,6 +224,8 @@ function composeContentHardened() {
|
|
|
218
224
|
- llm_api_key
|
|
219
225
|
- telegram_bot_token
|
|
220
226
|
- gateway_token
|
|
227
|
+
- groq_api_key
|
|
228
|
+
- brave_api_key
|
|
221
229
|
env_file:
|
|
222
230
|
- ${LIMBO_DIR}/.env
|
|
223
231
|
environment:
|
|
@@ -270,6 +278,10 @@ secrets:
|
|
|
270
278
|
file: ${SECRETS_DIR}/telegram_bot_token
|
|
271
279
|
gateway_token:
|
|
272
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
|
|
273
285
|
|
|
274
286
|
volumes:
|
|
275
287
|
limbo-data:
|
|
@@ -699,6 +711,8 @@ function normalizeConfig(cfg, existingEnv = {}) {
|
|
|
699
711
|
TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
|
|
700
712
|
TELEGRAM_AUTO_PAIR_FIRST_DM: cfg.telegramAutoPair || existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'false',
|
|
701
713
|
GATEWAY_TOKEN: gatewayToken,
|
|
714
|
+
VOICE_ENABLED: cfg.voiceEnabled || existingEnv.VOICE_ENABLED || 'false',
|
|
715
|
+
WEB_SEARCH_ENABLED: cfg.webSearchEnabled || existingEnv.WEB_SEARCH_ENABLED || 'false',
|
|
702
716
|
};
|
|
703
717
|
|
|
704
718
|
return base;
|
|
@@ -718,6 +732,8 @@ function writeSecrets(cfg, existingEnv = {}) {
|
|
|
718
732
|
writeSecretFile('llm_api_key', normalized.LLM_API_KEY);
|
|
719
733
|
writeSecretFile('telegram_bot_token', normalized.TELEGRAM_BOT_TOKEN);
|
|
720
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'));
|
|
721
737
|
}
|
|
722
738
|
|
|
723
739
|
const SECRET_KEYS = new Set([
|
|
@@ -922,7 +938,7 @@ function ensureComposeFile(hardened = false) {
|
|
|
922
938
|
fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
|
|
923
939
|
fs.mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
|
|
924
940
|
// Ensure secret files exist (Docker Compose secrets require the files to be present)
|
|
925
|
-
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']) {
|
|
926
942
|
const fp = path.join(SECRETS_DIR, name);
|
|
927
943
|
if (!fs.existsSync(fp)) fs.writeFileSync(fp, '', { mode: 0o644 });
|
|
928
944
|
}
|
|
@@ -1683,6 +1699,98 @@ function cmdStatus() {
|
|
|
1683
1699
|
run('docker compose ps');
|
|
1684
1700
|
}
|
|
1685
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
|
+
|
|
1686
1794
|
function cmdHelp() {
|
|
1687
1795
|
console.log(`
|
|
1688
1796
|
${c.bold}limbo${c.reset} - personal AI memory agent
|
|
@@ -1696,6 +1804,7 @@ ${c.bold}Commands:${c.reset}
|
|
|
1696
1804
|
logs Tail container logs
|
|
1697
1805
|
update Pull latest image and restart
|
|
1698
1806
|
status Show container status
|
|
1807
|
+
config Configure optional features (voice, web-search)
|
|
1699
1808
|
help Show this help
|
|
1700
1809
|
|
|
1701
1810
|
${c.bold}Flags:${c.reset}
|
|
@@ -1708,6 +1817,13 @@ ${c.bold}Flags:${c.reset}
|
|
|
1708
1817
|
--language <code> Language: en, es (default: en)
|
|
1709
1818
|
--tunnel-domain <d> Admin: use branded subdomain for setup tunnel (e.g. limbo.tomasward.com)
|
|
1710
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
|
+
|
|
1711
1827
|
${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
1712
1828
|
`);
|
|
1713
1829
|
}
|
|
@@ -1724,6 +1840,7 @@ const [,, cmd = 'start'] = process.argv;
|
|
|
1724
1840
|
case 'logs': cmdLogs(); break;
|
|
1725
1841
|
case 'update': cmdUpdate(); break;
|
|
1726
1842
|
case 'status': cmdStatus(); break;
|
|
1843
|
+
case 'config': cmdConfig(); break;
|
|
1727
1844
|
case 'help':
|
|
1728
1845
|
case '--help':
|
|
1729
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
|
+
}
|
package/mcp-server/tools/read.js
CHANGED
|
@@ -1,65 +1,13 @@
|
|
|
1
|
-
import { readFile
|
|
2
|
-
import {
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { ensureIndex, getNote } from "../vault-index.js";
|
|
3
4
|
|
|
4
5
|
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
|
-
const NOTES_DIR =
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Recursively find a note file by ID. Checks flat first, then subdirectories.
|
|
9
|
-
* Returns the file path or null.
|
|
10
|
-
*/
|
|
11
|
-
async function findNote(noteId) {
|
|
12
|
-
// Fast path: check flat location first
|
|
13
|
-
const flatPath = join(NOTES_DIR, `${noteId}.md`);
|
|
14
|
-
try {
|
|
15
|
-
await stat(flatPath);
|
|
16
|
-
return flatPath;
|
|
17
|
-
} catch {
|
|
18
|
-
// Not in root — search subdirectories
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return searchDir(NOTES_DIR, noteId);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function searchDir(dir, noteId) {
|
|
25
|
-
let items;
|
|
26
|
-
try {
|
|
27
|
-
items = await readdir(dir);
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
for (const item of items) {
|
|
33
|
-
if (item.startsWith(".") || item === "_meta") continue;
|
|
34
|
-
|
|
35
|
-
const full = join(dir, item);
|
|
36
|
-
let s;
|
|
37
|
-
try {
|
|
38
|
-
s = await stat(full);
|
|
39
|
-
} catch {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (s.isDirectory()) {
|
|
44
|
-
// Check if the note exists directly in this subdirectory
|
|
45
|
-
const candidate = join(full, `${noteId}.md`);
|
|
46
|
-
try {
|
|
47
|
-
await stat(candidate);
|
|
48
|
-
return candidate;
|
|
49
|
-
} catch {
|
|
50
|
-
// Recurse deeper
|
|
51
|
-
const found = await searchDir(full, noteId);
|
|
52
|
-
if (found) return found;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
6
|
+
const NOTES_DIR = resolve(VAULT_PATH, "notes");
|
|
59
7
|
|
|
60
8
|
/**
|
|
61
9
|
* vault_read(noteId): reads full content of a note by ID.
|
|
62
|
-
*
|
|
10
|
+
* Uses in-memory index for O(1) path lookup — no recursive filesystem search.
|
|
63
11
|
* Returns the raw markdown content including YAML frontmatter.
|
|
64
12
|
* Returns null if the note doesn't exist.
|
|
65
13
|
*/
|
|
@@ -74,19 +22,16 @@ export async function vaultRead(noteId) {
|
|
|
74
22
|
throw new Error("noteId contains invalid characters");
|
|
75
23
|
}
|
|
76
24
|
|
|
77
|
-
|
|
78
|
-
|
|
25
|
+
await ensureIndex();
|
|
26
|
+
const entry = getNote(safe);
|
|
27
|
+
if (!entry) return null;
|
|
79
28
|
|
|
80
29
|
// Defense-in-depth: ensure resolved path stays within vault
|
|
81
|
-
const resolved = resolve(
|
|
82
|
-
if (!resolved.startsWith(
|
|
30
|
+
const resolved = resolve(entry.path);
|
|
31
|
+
if (!resolved.startsWith(NOTES_DIR + "/")) {
|
|
83
32
|
throw new Error("Path traversal detected");
|
|
84
33
|
}
|
|
85
34
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} catch (err) {
|
|
89
|
-
if (err.code === "ENOENT") return null;
|
|
90
|
-
throw err;
|
|
91
|
-
}
|
|
35
|
+
// Return content from index (already in memory)
|
|
36
|
+
return entry.content;
|
|
92
37
|
}
|
|
@@ -1,118 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, basename, relative } from "path";
|
|
3
|
-
|
|
4
|
-
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
|
-
const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Recursively collects all .md files under a directory.
|
|
9
|
-
* Returns array of { filePath, domain } where domain is the relative subdirectory.
|
|
10
|
-
*/
|
|
11
|
-
async function walkNotes(dir, base = dir) {
|
|
12
|
-
const entries = [];
|
|
13
|
-
let items;
|
|
14
|
-
try {
|
|
15
|
-
items = await readdir(dir);
|
|
16
|
-
} catch {
|
|
17
|
-
return entries;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
for (const item of items) {
|
|
21
|
-
// Skip hidden directories and _meta
|
|
22
|
-
if (item.startsWith(".") || item === "_meta") continue;
|
|
23
|
-
|
|
24
|
-
const full = join(dir, item);
|
|
25
|
-
let s;
|
|
26
|
-
try {
|
|
27
|
-
s = await stat(full);
|
|
28
|
-
} catch {
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (s.isDirectory()) {
|
|
33
|
-
const sub = await walkNotes(full, base);
|
|
34
|
-
entries.push(...sub);
|
|
35
|
-
} else if (item.endsWith(".md")) {
|
|
36
|
-
const rel = relative(base, dir);
|
|
37
|
-
entries.push({ filePath: full, domain: rel || null });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return entries;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Extracts the title from YAML frontmatter, falling back to description or first H1.
|
|
45
|
-
*/
|
|
46
|
-
function extractTitle(content) {
|
|
47
|
-
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
48
|
-
if (fmMatch) {
|
|
49
|
-
const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
50
|
-
if (titleMatch) return titleMatch[1];
|
|
51
|
-
// Fallback: use description if no title field
|
|
52
|
-
const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
|
53
|
-
if (descMatch) return descMatch[1];
|
|
54
|
-
}
|
|
55
|
-
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
56
|
-
if (h1Match) return h1Match[1];
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
1
|
+
import { ensureIndex, search } from "../vault-index.js";
|
|
59
2
|
|
|
60
3
|
/**
|
|
61
|
-
*
|
|
62
|
-
*/
|
|
63
|
-
function extractSnippet(content, regex, maxLen = 150) {
|
|
64
|
-
regex.lastIndex = 0;
|
|
65
|
-
const match = regex.exec(content);
|
|
66
|
-
regex.lastIndex = 0;
|
|
67
|
-
if (!match) return "";
|
|
68
|
-
const start = Math.max(0, match.index - 60);
|
|
69
|
-
const end = Math.min(content.length, match.index + maxLen);
|
|
70
|
-
let snippet = content.slice(start, end).replace(/\n/g, " ").trim();
|
|
71
|
-
if (start > 0) snippet = "..." + snippet;
|
|
72
|
-
if (end < content.length) snippet += "...";
|
|
73
|
-
return snippet;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* vault_search(query): recursive search across all .md files in vault/notes/.
|
|
4
|
+
* vault_search(query): searches all notes via in-memory index.
|
|
78
5
|
* Returns [{noteId, title, snippet, score, domain}] sorted by score desc.
|
|
79
|
-
*
|
|
80
|
-
* NOTE: Current implementation is a linear scan (O(n) per query). This is fine
|
|
81
|
-
* for small vaults (hundreds of notes), but will need optimization at scale —
|
|
82
|
-
* consider an inverted index (e.g. SQLite FTS5) when the vault grows large.
|
|
6
|
+
* No disk I/O after initial index build.
|
|
83
7
|
*/
|
|
84
8
|
export async function vaultSearch(query) {
|
|
85
9
|
if (query.length > 200) {
|
|
86
10
|
throw new Error("Search query too long (max 200 characters)");
|
|
87
11
|
}
|
|
88
12
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Always escape user input to prevent ReDoS from pathological patterns
|
|
93
|
-
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
-
const regex = new RegExp(escaped, "gi");
|
|
95
|
-
|
|
96
|
-
const results = [];
|
|
97
|
-
for (const { filePath, domain } of files) {
|
|
98
|
-
let content;
|
|
99
|
-
try {
|
|
100
|
-
content = await readFile(filePath, "utf8");
|
|
101
|
-
} catch {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const matches = content.match(regex);
|
|
106
|
-
if (!matches) continue;
|
|
107
|
-
|
|
108
|
-
const noteId = basename(filePath, ".md");
|
|
109
|
-
const title = extractTitle(content) || noteId;
|
|
110
|
-
const score = matches.length;
|
|
111
|
-
const snippet = extractSnippet(content, regex);
|
|
112
|
-
|
|
113
|
-
results.push({ noteId, title, snippet, score, domain });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
results.sort((a, b) => b.score - a.score);
|
|
117
|
-
return results;
|
|
13
|
+
await ensureIndex();
|
|
14
|
+
return search(query);
|
|
118
15
|
}
|
|
@@ -26,8 +26,23 @@ function buildMapFrontmatter(name) {
|
|
|
26
26
|
return lines.join("\n");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Extracts wikilink noteIds from a block of text.
|
|
31
|
+
* Matches [[noteId]] and [[noteId|Display Title]].
|
|
32
|
+
*/
|
|
33
|
+
function extractWikilinks(text) {
|
|
34
|
+
const regex = /\[\[([^\]|]+)/g;
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = regex.exec(text)) !== null) {
|
|
38
|
+
ids.add(match[1].trim());
|
|
39
|
+
}
|
|
40
|
+
return ids;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/**
|
|
30
44
|
* Finds or creates a section in markdown content.
|
|
45
|
+
* Deduplicates entries — skips any whose wikilink noteId already exists in the section.
|
|
31
46
|
* Returns the updated content string.
|
|
32
47
|
*/
|
|
33
48
|
function upsertSection(content, section, entries) {
|
|
@@ -37,20 +52,37 @@ function upsertSection(content, section, entries) {
|
|
|
37
52
|
const sectionIdx = lines.findIndex((l) => l.trim() === sectionHeader);
|
|
38
53
|
|
|
39
54
|
if (sectionIdx === -1) {
|
|
40
|
-
// Section doesn't exist — append it
|
|
55
|
+
// Section doesn't exist — append it (all entries are new)
|
|
41
56
|
const toAdd = ["", sectionHeader, "", ...entries, ""];
|
|
42
|
-
return lines.concat(toAdd).join("\n");
|
|
57
|
+
return { content: lines.concat(toAdd).join("\n"), added: entries.length };
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
// Find where the section ends (next ## or EOF)
|
|
46
|
-
let
|
|
47
|
-
while (
|
|
48
|
-
|
|
61
|
+
let endIdx = sectionIdx + 1;
|
|
62
|
+
while (endIdx < lines.length && !lines[endIdx].startsWith("## ")) {
|
|
63
|
+
endIdx++;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
//
|
|
52
|
-
lines.
|
|
53
|
-
|
|
66
|
+
// Collect existing wikilinks in this section
|
|
67
|
+
const sectionText = lines.slice(sectionIdx, endIdx).join("\n");
|
|
68
|
+
const existing = extractWikilinks(sectionText);
|
|
69
|
+
|
|
70
|
+
// Filter out entries whose noteId is already present
|
|
71
|
+
const newEntries = entries.filter((entry) => {
|
|
72
|
+
const entryIds = extractWikilinks(entry);
|
|
73
|
+
for (const id of entryIds) {
|
|
74
|
+
if (existing.has(id)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (newEntries.length === 0) {
|
|
80
|
+
return { content: content, added: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Insert new entries before the next section (or EOF)
|
|
84
|
+
lines.splice(endIdx, 0, ...newEntries);
|
|
85
|
+
return { content: lines.join("\n"), added: newEntries.length };
|
|
54
86
|
}
|
|
55
87
|
|
|
56
88
|
/**
|
|
@@ -85,7 +117,11 @@ export async function vaultUpdateMap(map, section, entries) {
|
|
|
85
117
|
existing = `${buildMapFrontmatter(map)}\n\n# ${map}\n`;
|
|
86
118
|
}
|
|
87
119
|
|
|
88
|
-
const updated = upsertSection(existing, section, entries);
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
const { content: updated, added } = upsertSection(existing, section, entries);
|
|
121
|
+
|
|
122
|
+
if (added > 0) {
|
|
123
|
+
await writeFile(filePath, updated, "utf8");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { map: safeMap, section, added };
|
|
91
127
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeFile, mkdir } from "fs/promises";
|
|
2
|
-
import { join, resolve } from "path";
|
|
2
|
+
import { join, resolve, relative } from "path";
|
|
3
|
+
import { updateEntry } from "../vault-index.js";
|
|
3
4
|
|
|
4
5
|
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
6
|
const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
@@ -91,5 +92,10 @@ export async function vaultWriteNote(note) {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
await writeFile(filePath, fileContent, "utf8");
|
|
95
|
+
|
|
96
|
+
// Update in-memory index immediately — no re-scan needed
|
|
97
|
+
const domain = relative(NOTES_DIR, resolve(targetDir)) || null;
|
|
98
|
+
updateEntry(safe, filePath, fileContent, domain);
|
|
99
|
+
|
|
94
100
|
return { id: safe, path: filePath };
|
|
95
101
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
2
|
+
import { join, basename, relative } from "path";
|
|
3
|
+
|
|
4
|
+
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
|
+
const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
6
|
+
|
|
7
|
+
// In-memory index: noteId → { path, title, content, domain }
|
|
8
|
+
const index = new Map();
|
|
9
|
+
let built = false;
|
|
10
|
+
|
|
11
|
+
function extractTitle(content) {
|
|
12
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
13
|
+
if (fmMatch) {
|
|
14
|
+
const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
15
|
+
if (titleMatch) return titleMatch[1];
|
|
16
|
+
const descMatch = fmMatch[1].match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
|
17
|
+
if (descMatch) return descMatch[1];
|
|
18
|
+
}
|
|
19
|
+
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
20
|
+
if (h1Match) return h1Match[1];
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Recursively walks notes/ and indexes all .md files in parallel.
|
|
26
|
+
* Uses Promise.all for concurrent I/O instead of sequential await.
|
|
27
|
+
*/
|
|
28
|
+
async function walkAndIndex(dir, base = dir) {
|
|
29
|
+
let items;
|
|
30
|
+
try {
|
|
31
|
+
items = await readdir(dir);
|
|
32
|
+
} catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const promises = [];
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
if (item.startsWith(".") || item === "_meta") continue;
|
|
39
|
+
const full = join(dir, item);
|
|
40
|
+
promises.push(
|
|
41
|
+
stat(full)
|
|
42
|
+
.then((s) => {
|
|
43
|
+
if (s.isDirectory()) {
|
|
44
|
+
return walkAndIndex(full, base);
|
|
45
|
+
}
|
|
46
|
+
if (item.endsWith(".md")) {
|
|
47
|
+
return readFile(full, "utf8")
|
|
48
|
+
.then((content) => {
|
|
49
|
+
const noteId = basename(full, ".md");
|
|
50
|
+
const domain = relative(base, dir) || null;
|
|
51
|
+
const title = extractTitle(content) || noteId;
|
|
52
|
+
index.set(noteId, { path: full, title, content, domain });
|
|
53
|
+
})
|
|
54
|
+
.catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
await Promise.all(promises);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build (or rebuild) the full in-memory index from disk.
|
|
65
|
+
*/
|
|
66
|
+
export async function buildIndex() {
|
|
67
|
+
index.clear();
|
|
68
|
+
await walkAndIndex(NOTES_DIR);
|
|
69
|
+
built = true;
|
|
70
|
+
return index.size;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ensure the index is built before use.
|
|
75
|
+
*/
|
|
76
|
+
export async function ensureIndex() {
|
|
77
|
+
if (!built) await buildIndex();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* O(1) lookup by noteId. Returns { path, title, content, domain } or null.
|
|
82
|
+
*/
|
|
83
|
+
export function getNote(noteId) {
|
|
84
|
+
return index.get(noteId) || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Update a single entry in the index (called after vault_write_note).
|
|
89
|
+
*/
|
|
90
|
+
export function updateEntry(noteId, path, content, domain) {
|
|
91
|
+
const title = extractTitle(content) || noteId;
|
|
92
|
+
index.set(noteId, { path, title, content, domain });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Search all indexed notes by keyword. O(n) over in-memory strings — no disk I/O.
|
|
97
|
+
*/
|
|
98
|
+
export function search(query) {
|
|
99
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
const regex = new RegExp(escaped, "gi");
|
|
101
|
+
|
|
102
|
+
const results = [];
|
|
103
|
+
for (const [noteId, entry] of index) {
|
|
104
|
+
const matches = entry.content.match(regex);
|
|
105
|
+
if (!matches) continue;
|
|
106
|
+
|
|
107
|
+
const score = matches.length;
|
|
108
|
+
|
|
109
|
+
// Extract snippet around first match
|
|
110
|
+
regex.lastIndex = 0;
|
|
111
|
+
const match = regex.exec(entry.content);
|
|
112
|
+
regex.lastIndex = 0;
|
|
113
|
+
let snippet = "";
|
|
114
|
+
if (match) {
|
|
115
|
+
const start = Math.max(0, match.index - 60);
|
|
116
|
+
const end = Math.min(entry.content.length, match.index + 150);
|
|
117
|
+
snippet = entry.content.slice(start, end).replace(/\n/g, " ").trim();
|
|
118
|
+
if (start > 0) snippet = "..." + snippet;
|
|
119
|
+
if (end < entry.content.length) snippet += "...";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
results.push({ noteId, title: entry.title, snippet, score, domain: entry.domain });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
results.sort((a, b) => b.score - a.score);
|
|
126
|
+
return results;
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -686,6 +686,7 @@
|
|
|
686
686
|
<div class="progress-dot"></div>
|
|
687
687
|
<div class="progress-dot"></div>
|
|
688
688
|
<div class="progress-dot"></div>
|
|
689
|
+
<div class="progress-dot"></div>
|
|
689
690
|
</div>
|
|
690
691
|
|
|
691
692
|
<!-- Nav Header -->
|
|
@@ -1160,8 +1161,107 @@
|
|
|
1160
1161
|
</div>
|
|
1161
1162
|
</div>
|
|
1162
1163
|
|
|
1163
|
-
<!-- Step 7:
|
|
1164
|
+
<!-- Step 7: Optional Features -->
|
|
1164
1165
|
<div class="step" data-step="7">
|
|
1166
|
+
<h2 class="step-title">
|
|
1167
|
+
<span data-lang="en">Optional Features</span>
|
|
1168
|
+
<span data-lang="es">Funcionalidades Opcionales</span>
|
|
1169
|
+
</h2>
|
|
1170
|
+
<p class="step-desc">
|
|
1171
|
+
<span data-lang="en">Enable additional capabilities for Limbo. You can always change these later.</span>
|
|
1172
|
+
<span data-lang="es">Habilitá capacidades adicionales para Limbo. Podés cambiar esto después.</span>
|
|
1173
|
+
</p>
|
|
1174
|
+
|
|
1175
|
+
<!-- Voice Messages (Groq) -->
|
|
1176
|
+
<div class="toggle-row" style="margin-bottom:12px">
|
|
1177
|
+
<div>
|
|
1178
|
+
<div class="toggle-label">
|
|
1179
|
+
<span data-lang="en">Voice Messages</span>
|
|
1180
|
+
<span data-lang="es">Mensajes de Voz</span>
|
|
1181
|
+
</div>
|
|
1182
|
+
<div class="toggle-sub">
|
|
1183
|
+
<span data-lang="en">Transcribe voice notes with Groq Whisper</span>
|
|
1184
|
+
<span data-lang="es">Transcribir notas de voz con Groq Whisper</span>
|
|
1185
|
+
</div>
|
|
1186
|
+
</div>
|
|
1187
|
+
<label class="toggle">
|
|
1188
|
+
<input type="checkbox" id="voiceToggle" onchange="toggleFeatureVoice()">
|
|
1189
|
+
<span class="toggle-track"></span>
|
|
1190
|
+
<span class="toggle-thumb"></span>
|
|
1191
|
+
</label>
|
|
1192
|
+
</div>
|
|
1193
|
+
|
|
1194
|
+
<div id="voiceFields" class="tg-fields">
|
|
1195
|
+
<div class="input-group">
|
|
1196
|
+
<label class="input-label" for="groqKeyInput">Groq API Key</label>
|
|
1197
|
+
<input
|
|
1198
|
+
type="password"
|
|
1199
|
+
class="input-field"
|
|
1200
|
+
id="groqKeyInput"
|
|
1201
|
+
placeholder="gsk_..."
|
|
1202
|
+
autocomplete="off"
|
|
1203
|
+
spellcheck="false"
|
|
1204
|
+
>
|
|
1205
|
+
<div class="validation-msg" id="groqKeyValidation"></div>
|
|
1206
|
+
<p class="input-hint">
|
|
1207
|
+
<span data-lang="en">Get your key at <a href="https://console.groq.com/keys" target="_blank">console.groq.com/keys</a></span>
|
|
1208
|
+
<span data-lang="es">Obtené tu key en <a href="https://console.groq.com/keys" target="_blank">console.groq.com/keys</a></span>
|
|
1209
|
+
</p>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
|
|
1213
|
+
<!-- Web Search (Brave) -->
|
|
1214
|
+
<div class="toggle-row" style="margin-bottom:12px">
|
|
1215
|
+
<div>
|
|
1216
|
+
<div class="toggle-label">
|
|
1217
|
+
<span data-lang="en">Web Search</span>
|
|
1218
|
+
<span data-lang="es">Búsqueda Web</span>
|
|
1219
|
+
</div>
|
|
1220
|
+
<div class="toggle-sub">
|
|
1221
|
+
<span data-lang="en">Search the web via Brave Search API</span>
|
|
1222
|
+
<span data-lang="es">Buscar en la web con Brave Search API</span>
|
|
1223
|
+
</div>
|
|
1224
|
+
</div>
|
|
1225
|
+
<label class="toggle">
|
|
1226
|
+
<input type="checkbox" id="webSearchToggle" onchange="toggleFeatureWebSearch()">
|
|
1227
|
+
<span class="toggle-track"></span>
|
|
1228
|
+
<span class="toggle-thumb"></span>
|
|
1229
|
+
</label>
|
|
1230
|
+
</div>
|
|
1231
|
+
|
|
1232
|
+
<div id="webSearchFields" class="tg-fields">
|
|
1233
|
+
<div class="input-group">
|
|
1234
|
+
<label class="input-label" for="braveKeyInput">Brave API Key</label>
|
|
1235
|
+
<input
|
|
1236
|
+
type="password"
|
|
1237
|
+
class="input-field"
|
|
1238
|
+
id="braveKeyInput"
|
|
1239
|
+
placeholder="BSA..."
|
|
1240
|
+
autocomplete="off"
|
|
1241
|
+
spellcheck="false"
|
|
1242
|
+
>
|
|
1243
|
+
<div class="validation-msg" id="braveKeyValidation"></div>
|
|
1244
|
+
<p class="input-hint">
|
|
1245
|
+
<span data-lang="en">Get your key at <a href="https://brave.com/search/api/" target="_blank">brave.com/search/api</a></span>
|
|
1246
|
+
<span data-lang="es">Obtené tu key en <a href="https://brave.com/search/api/" target="_blank">brave.com/search/api</a></span>
|
|
1247
|
+
</p>
|
|
1248
|
+
</div>
|
|
1249
|
+
</div>
|
|
1250
|
+
|
|
1251
|
+
<div class="btn-row">
|
|
1252
|
+
<button class="btn-secondary" onclick="skipFeatures()">
|
|
1253
|
+
<span data-lang="en">Skip</span>
|
|
1254
|
+
<span data-lang="es">Omitir</span>
|
|
1255
|
+
</button>
|
|
1256
|
+
<button class="btn-primary" onclick="submitFeatures()">
|
|
1257
|
+
<span data-lang="en">Continue</span>
|
|
1258
|
+
<span data-lang="es">Continuar</span>
|
|
1259
|
+
</button>
|
|
1260
|
+
</div>
|
|
1261
|
+
</div>
|
|
1262
|
+
|
|
1263
|
+
<!-- Step 8: Confirm -->
|
|
1264
|
+
<div class="step" data-step="8">
|
|
1165
1265
|
<h2 class="step-title">
|
|
1166
1266
|
<span data-lang="en">Ready to launch</span>
|
|
1167
1267
|
<span data-lang="es">Listo para arrancar</span>
|
|
@@ -1233,11 +1333,15 @@
|
|
|
1233
1333
|
apiKey: '',
|
|
1234
1334
|
model: null,
|
|
1235
1335
|
telegram: { enabled: true, botToken: '' },
|
|
1336
|
+
features: {
|
|
1337
|
+
voice: { enabled: false, apiKey: '' },
|
|
1338
|
+
webSearch: { enabled: false, apiKey: '' },
|
|
1339
|
+
},
|
|
1236
1340
|
subscriptionDone: false,
|
|
1237
1341
|
subscriptionEmail: '',
|
|
1238
1342
|
};
|
|
1239
1343
|
|
|
1240
|
-
const totalSteps =
|
|
1344
|
+
const totalSteps = 8;
|
|
1241
1345
|
|
|
1242
1346
|
// --- Prefix validation rules ---
|
|
1243
1347
|
const keyPrefixes = {
|
|
@@ -1292,7 +1396,8 @@
|
|
|
1292
1396
|
if (n === 4) setupApiKeyStep();
|
|
1293
1397
|
if (n === 5) fetchModels();
|
|
1294
1398
|
if (n === 6) resetTelegramUI();
|
|
1295
|
-
if (n === 7)
|
|
1399
|
+
if (n === 7) resetFeaturesUI();
|
|
1400
|
+
if (n === 8) buildSummary();
|
|
1296
1401
|
}
|
|
1297
1402
|
|
|
1298
1403
|
document.getElementById('btnBack').addEventListener('click', () => {
|
|
@@ -1843,7 +1948,105 @@
|
|
|
1843
1948
|
}
|
|
1844
1949
|
}
|
|
1845
1950
|
|
|
1846
|
-
// --- Step 7:
|
|
1951
|
+
// --- Step 7: Features ---
|
|
1952
|
+
function toggleFeatureVoice() {
|
|
1953
|
+
const enabled = document.getElementById('voiceToggle').checked;
|
|
1954
|
+
state.features.voice.enabled = enabled;
|
|
1955
|
+
document.getElementById('voiceFields').classList.toggle('visible', enabled);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function toggleFeatureWebSearch() {
|
|
1959
|
+
const enabled = document.getElementById('webSearchToggle').checked;
|
|
1960
|
+
state.features.webSearch.enabled = enabled;
|
|
1961
|
+
document.getElementById('webSearchFields').classList.toggle('visible', enabled);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
function resetFeaturesUI() {
|
|
1965
|
+
document.getElementById('voiceFields').classList.toggle('visible', state.features.voice.enabled);
|
|
1966
|
+
document.getElementById('webSearchFields').classList.toggle('visible', state.features.webSearch.enabled);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
document.getElementById('groqKeyInput').addEventListener('input', function () {
|
|
1970
|
+
const val = this.value.trim();
|
|
1971
|
+
const msgEl = document.getElementById('groqKeyValidation');
|
|
1972
|
+
state.features.voice.apiKey = val;
|
|
1973
|
+
if (!val) {
|
|
1974
|
+
this.className = 'input-field';
|
|
1975
|
+
msgEl.innerHTML = '';
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (val.startsWith('gsk_') && val.length > 10) {
|
|
1979
|
+
this.className = 'input-field valid';
|
|
1980
|
+
const msg = state.language === 'es' ? 'Formato válido' : 'Valid format';
|
|
1981
|
+
msgEl.innerHTML = `<span class="validation-msg success">${msg}</span>`;
|
|
1982
|
+
} else if (!val.startsWith('gsk_')) {
|
|
1983
|
+
this.className = 'input-field invalid';
|
|
1984
|
+
const msg = state.language === 'es'
|
|
1985
|
+
? 'La key debe empezar con <code>gsk_</code>'
|
|
1986
|
+
: 'Key should start with <code>gsk_</code>';
|
|
1987
|
+
msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
|
|
1988
|
+
} else {
|
|
1989
|
+
this.className = 'input-field';
|
|
1990
|
+
msgEl.innerHTML = '';
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
document.getElementById('braveKeyInput').addEventListener('input', function () {
|
|
1995
|
+
const val = this.value.trim();
|
|
1996
|
+
const msgEl = document.getElementById('braveKeyValidation');
|
|
1997
|
+
state.features.webSearch.apiKey = val;
|
|
1998
|
+
if (!val) {
|
|
1999
|
+
this.className = 'input-field';
|
|
2000
|
+
msgEl.innerHTML = '';
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (val.startsWith('BSA') && val.length > 10) {
|
|
2004
|
+
this.className = 'input-field valid';
|
|
2005
|
+
const msg = state.language === 'es' ? 'Formato válido' : 'Valid format';
|
|
2006
|
+
msgEl.innerHTML = `<span class="validation-msg success">${msg}</span>`;
|
|
2007
|
+
} else if (!val.startsWith('BSA')) {
|
|
2008
|
+
this.className = 'input-field invalid';
|
|
2009
|
+
const msg = state.language === 'es'
|
|
2010
|
+
? 'La key debe empezar con <code>BSA</code>'
|
|
2011
|
+
: 'Key should start with <code>BSA</code>';
|
|
2012
|
+
msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
|
|
2013
|
+
} else {
|
|
2014
|
+
this.className = 'input-field';
|
|
2015
|
+
msgEl.innerHTML = '';
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
function skipFeatures() {
|
|
2020
|
+
state.features.voice = { enabled: false, apiKey: '' };
|
|
2021
|
+
state.features.webSearch = { enabled: false, apiKey: '' };
|
|
2022
|
+
goToStep(8);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function submitFeatures() {
|
|
2026
|
+
if (state.features.voice.enabled) {
|
|
2027
|
+
const key = document.getElementById('groqKeyInput').value.trim();
|
|
2028
|
+
if (!key) {
|
|
2029
|
+
const msgEl = document.getElementById('groqKeyValidation');
|
|
2030
|
+
const msg = state.language === 'es' ? 'Ingresá tu API key de Groq' : 'Enter your Groq API key';
|
|
2031
|
+
msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
state.features.voice.apiKey = key;
|
|
2035
|
+
}
|
|
2036
|
+
if (state.features.webSearch.enabled) {
|
|
2037
|
+
const key = document.getElementById('braveKeyInput').value.trim();
|
|
2038
|
+
if (!key) {
|
|
2039
|
+
const msgEl = document.getElementById('braveKeyValidation');
|
|
2040
|
+
const msg = state.language === 'es' ? 'Ingresá tu API key de Brave' : 'Enter your Brave API key';
|
|
2041
|
+
msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
state.features.webSearch.apiKey = key;
|
|
2045
|
+
}
|
|
2046
|
+
goToStep(8);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// --- Step 8: Summary ---
|
|
1847
2050
|
function buildSummary() {
|
|
1848
2051
|
const card = document.getElementById('summaryCard');
|
|
1849
2052
|
const l = state.language;
|
|
@@ -1886,6 +2089,20 @@
|
|
|
1886
2089
|
: (l === 'es' ? 'Desactivado' : 'Disabled'),
|
|
1887
2090
|
step: 6,
|
|
1888
2091
|
},
|
|
2092
|
+
{
|
|
2093
|
+
label: l === 'es' ? 'Voz' : 'Voice',
|
|
2094
|
+
value: state.features.voice.enabled
|
|
2095
|
+
? (l === 'es' ? 'Activado' : 'Enabled')
|
|
2096
|
+
: (l === 'es' ? 'Desactivado' : 'Disabled'),
|
|
2097
|
+
step: 7,
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
label: l === 'es' ? 'Búsqueda Web' : 'Web Search',
|
|
2101
|
+
value: state.features.webSearch.enabled
|
|
2102
|
+
? (l === 'es' ? 'Activado' : 'Enabled')
|
|
2103
|
+
: (l === 'es' ? 'Desactivado' : 'Disabled'),
|
|
2104
|
+
step: 7,
|
|
2105
|
+
},
|
|
1889
2106
|
];
|
|
1890
2107
|
|
|
1891
2108
|
card.innerHTML = items
|
|
@@ -1917,8 +2134,11 @@
|
|
|
1917
2134
|
state.subscriptionDone = false;
|
|
1918
2135
|
state.subscriptionEmail = '';
|
|
1919
2136
|
state.telegram = { enabled: true, botToken: '' };
|
|
2137
|
+
state.features = { voice: { enabled: false, apiKey: '' }, webSearch: { enabled: false, apiKey: '' } };
|
|
1920
2138
|
pairingActive = false;
|
|
1921
2139
|
document.getElementById('tgToggle').checked = true;
|
|
2140
|
+
document.getElementById('voiceToggle').checked = false;
|
|
2141
|
+
document.getElementById('webSearchToggle').checked = false;
|
|
1922
2142
|
resetTelegramUI();
|
|
1923
2143
|
goToStep(1);
|
|
1924
2144
|
}
|
|
@@ -1943,6 +2163,16 @@
|
|
|
1943
2163
|
enabled: state.telegram.enabled,
|
|
1944
2164
|
botToken: state.telegram.enabled ? state.telegram.botToken : '',
|
|
1945
2165
|
},
|
|
2166
|
+
features: {
|
|
2167
|
+
voice: {
|
|
2168
|
+
enabled: state.features.voice.enabled,
|
|
2169
|
+
apiKey: state.features.voice.enabled ? state.features.voice.apiKey : '',
|
|
2170
|
+
},
|
|
2171
|
+
webSearch: {
|
|
2172
|
+
enabled: state.features.webSearch.enabled,
|
|
2173
|
+
apiKey: state.features.webSearch.enabled ? state.features.webSearch.apiKey : '',
|
|
2174
|
+
},
|
|
2175
|
+
},
|
|
1946
2176
|
};
|
|
1947
2177
|
if (state.authMode === 'api-key') {
|
|
1948
2178
|
payload.apiKey = state.apiKey;
|
package/setup-server/server.js
CHANGED
|
@@ -734,6 +734,15 @@ async function handleConfigure(req, res) {
|
|
|
734
734
|
// chat_id is already captured by /api/telegram/pair during wizard Step 6
|
|
735
735
|
}
|
|
736
736
|
|
|
737
|
+
// Handle optional features (voice transcription, web search)
|
|
738
|
+
const features = data.features || {};
|
|
739
|
+
if (features.voice && features.voice.enabled && features.voice.apiKey) {
|
|
740
|
+
writeSecretFile('groq_api_key', features.voice.apiKey);
|
|
741
|
+
}
|
|
742
|
+
if (features.webSearch && features.webSearch.enabled && features.webSearch.apiKey) {
|
|
743
|
+
writeSecretFile('brave_api_key', features.webSearch.apiKey);
|
|
744
|
+
}
|
|
745
|
+
|
|
737
746
|
const gatewayToken = ensureGatewayToken();
|
|
738
747
|
|
|
739
748
|
// Build env vars (excluding secrets)
|
|
@@ -745,6 +754,8 @@ async function handleConfigure(req, res) {
|
|
|
745
754
|
MODEL_NAME: modelName,
|
|
746
755
|
LIMBO_PORT: String(PORT),
|
|
747
756
|
TELEGRAM_ENABLED: telegram.enabled ? 'true' : 'false',
|
|
757
|
+
VOICE_ENABLED: (features.voice && features.voice.enabled) ? 'true' : 'false',
|
|
758
|
+
WEB_SEARCH_ENABLED: (features.webSearch && features.webSearch.enabled) ? 'true' : 'false',
|
|
748
759
|
};
|
|
749
760
|
|
|
750
761
|
// Write .env file (quote values to handle special chars)
|