limbo-ai 1.6.0 → 1.7.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 +78 -82
- package/mcp-server/index.js +21 -13
- package/mcp-server/tools/read.js +59 -3
- package/mcp-server/tools/search.js +55 -22
- package/mcp-server/tools/update-map.js +18 -4
- package/mcp-server/tools/write.js +44 -10
- package/package.json +4 -1
package/cli.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// cli.js — Limbo CLI
|
|
3
3
|
// Orchestrates the Docker-based Limbo runtime.
|
|
4
|
-
// Zero npm dependencies — pure Node.js stdlib.
|
|
5
4
|
'use strict';
|
|
6
5
|
|
|
7
6
|
const { execSync, spawnSync } = require('child_process');
|
|
@@ -14,6 +13,7 @@ const readline = require('readline');
|
|
|
14
13
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
15
14
|
|
|
16
15
|
const LIMBO_DIR = path.join(os.homedir(), '.limbo');
|
|
16
|
+
const VAULT_DIR = path.join(LIMBO_DIR, 'vault');
|
|
17
17
|
const ENV_FILE = path.join(LIMBO_DIR, '.env');
|
|
18
18
|
const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
|
|
19
19
|
const GHCR_IMAGE = 'ghcr.io/tomasward1/limbo';
|
|
@@ -68,6 +68,7 @@ const COMPOSE_CONTENT = `services:
|
|
|
68
68
|
- "127.0.0.1:${PORT}:${PORT}"
|
|
69
69
|
volumes:
|
|
70
70
|
- limbo-data:/data
|
|
71
|
+
- ./vault:/data/vault
|
|
71
72
|
- limbo-openclaw-state:/home/limbo/.openclaw
|
|
72
73
|
env_file:
|
|
73
74
|
- .env
|
|
@@ -113,6 +114,17 @@ const TEXT = {
|
|
|
113
114
|
invalidOpenAIKey: 'OpenAI API keys usually start with "sk-".',
|
|
114
115
|
invalidAnthropicKey: 'Anthropic API keys usually start with "sk-ant-".',
|
|
115
116
|
telegramQuestion: 'Want to speak to Limbo through Telegram?',
|
|
117
|
+
telegramBotFatherSteps: [
|
|
118
|
+
'To create a Telegram bot:',
|
|
119
|
+
' 1. Open Telegram and search for @BotFather',
|
|
120
|
+
' 2. Send the command: /newbot',
|
|
121
|
+
' 3. Choose a display name for your bot (e.g. "My Limbo")',
|
|
122
|
+
' 4. Choose a username ending in "bot" (e.g. "my_limbo_bot")',
|
|
123
|
+
' 5. BotFather will reply with a token like:',
|
|
124
|
+
' 123456789:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
125
|
+
' 6. Copy that token and paste it below.',
|
|
126
|
+
],
|
|
127
|
+
telegramTokenSafe: 'Your token is stored locally in ~/.limbo/.env and never sent anywhere.',
|
|
116
128
|
telegramTokenPrompt: ' Telegram bot token: ',
|
|
117
129
|
yes: 'Yes',
|
|
118
130
|
no: 'No',
|
|
@@ -191,6 +203,17 @@ const TEXT = {
|
|
|
191
203
|
invalidOpenAIKey: 'Las API keys de OpenAI normalmente empiezan con "sk-".',
|
|
192
204
|
invalidAnthropicKey: 'Las API keys de Anthropic normalmente empiezan con "sk-ant-".',
|
|
193
205
|
telegramQuestion: 'Quieres hablar con Limbo por Telegram?',
|
|
206
|
+
telegramBotFatherSteps: [
|
|
207
|
+
'Para crear un bot de Telegram:',
|
|
208
|
+
' 1. Abri Telegram y busca @BotFather',
|
|
209
|
+
' 2. Manda el comando: /newbot',
|
|
210
|
+
' 3. Elegí un nombre para tu bot (ej: "Mi Limbo")',
|
|
211
|
+
' 4. Elegí un username que termine en "bot" (ej: "mi_limbo_bot")',
|
|
212
|
+
' 5. BotFather te va a responder con un token como este:',
|
|
213
|
+
' 123456789:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
214
|
+
' 6. Copiá ese token y pegalo abajo.',
|
|
215
|
+
],
|
|
216
|
+
telegramTokenSafe: 'Tu token se guarda localmente en ~/.limbo/.env y nunca se envia a ningun servidor externo.',
|
|
194
217
|
telegramTokenPrompt: ' Telegram bot token: ',
|
|
195
218
|
yes: 'Si',
|
|
196
219
|
no: 'No',
|
|
@@ -278,6 +301,22 @@ function sleep(ms) {
|
|
|
278
301
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
279
302
|
}
|
|
280
303
|
|
|
304
|
+
let clackPromise;
|
|
305
|
+
|
|
306
|
+
async function getClack() {
|
|
307
|
+
if (!clackPromise) clackPromise = import('@clack/prompts');
|
|
308
|
+
return clackPromise;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function maybeHandleClackCancel(value) {
|
|
312
|
+
const { cancel, isCancel } = await getClack();
|
|
313
|
+
if (isCancel(value)) {
|
|
314
|
+
cancel('Setup cancelled.');
|
|
315
|
+
process.exit(130);
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
|
|
281
320
|
function hasDocker() {
|
|
282
321
|
const result = spawnSync('docker', ['compose', 'version'], { stdio: 'pipe' });
|
|
283
322
|
return result.status === 0;
|
|
@@ -311,25 +350,34 @@ function createPromptInterface() {
|
|
|
311
350
|
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
312
351
|
}
|
|
313
352
|
|
|
314
|
-
async function promptValidated(
|
|
353
|
+
async function promptValidated(question, validate, errorMessage) {
|
|
354
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
355
|
+
const { text } = await getClack();
|
|
356
|
+
while (true) {
|
|
357
|
+
const value = await maybeHandleClackCancel(await text({
|
|
358
|
+
message: question.trim(),
|
|
359
|
+
validate: (input) => {
|
|
360
|
+
const validation = validate(String(input ?? ''));
|
|
361
|
+
return validation.ok ? undefined : (validation.message || errorMessage);
|
|
362
|
+
},
|
|
363
|
+
}));
|
|
364
|
+
const validation = validate(String(value));
|
|
365
|
+
if (validation.ok) return validation.value;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const rl = createPromptInterface();
|
|
315
370
|
while (true) {
|
|
316
371
|
const value = (await prompt(rl, question)).trim();
|
|
317
372
|
const validation = validate(value);
|
|
318
|
-
if (validation.ok)
|
|
373
|
+
if (validation.ok) {
|
|
374
|
+
rl.close();
|
|
375
|
+
return validation.value;
|
|
376
|
+
}
|
|
319
377
|
warn(validation.message || errorMessage);
|
|
320
378
|
}
|
|
321
379
|
}
|
|
322
380
|
|
|
323
|
-
function renderMenu(question, options, selectedIndex, lang) {
|
|
324
|
-
const lines = [`${c.bold}${question}${c.reset}`, `${c.dim}${t(lang, 'menuHelp')}${c.reset}`, ''];
|
|
325
|
-
options.forEach((option, index) => {
|
|
326
|
-
const prefix = index === selectedIndex ? `${c.green}>${c.reset}` : ' ';
|
|
327
|
-
lines.push(`${prefix} ${option.label}`);
|
|
328
|
-
if (option.description) lines.push(` ${c.dim}${option.description}${c.reset}`);
|
|
329
|
-
});
|
|
330
|
-
return lines.join('\n');
|
|
331
|
-
}
|
|
332
|
-
|
|
333
381
|
async function selectMenu(question, options, lang) {
|
|
334
382
|
if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
335
383
|
const rl = createPromptInterface();
|
|
@@ -345,68 +393,16 @@ async function selectMenu(question, options, lang) {
|
|
|
345
393
|
warn('Pick one of the listed options.');
|
|
346
394
|
}
|
|
347
395
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
process.stdin.off('keypress', onKeypress);
|
|
359
|
-
process.stdin.setRawMode(Boolean(previousRawMode));
|
|
360
|
-
rl.close();
|
|
361
|
-
process.stdout.write('\n');
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function draw() {
|
|
365
|
-
const output = renderMenu(question, options, selectedIndex, lang);
|
|
366
|
-
if (lastRenderLineCount > 0) {
|
|
367
|
-
readline.moveCursor(process.stdout, 0, -lastRenderLineCount);
|
|
368
|
-
}
|
|
369
|
-
for (let i = 0; i < lastRenderLineCount; i++) {
|
|
370
|
-
readline.clearLine(process.stdout, 0);
|
|
371
|
-
if (i < lastRenderLineCount - 1) readline.moveCursor(process.stdout, 0, 1);
|
|
372
|
-
}
|
|
373
|
-
if (lastRenderLineCount > 0) {
|
|
374
|
-
readline.moveCursor(process.stdout, 0, -Math.max(lastRenderLineCount - 1, 0));
|
|
375
|
-
}
|
|
376
|
-
readline.cursorTo(process.stdout, 0);
|
|
377
|
-
process.stdout.write(output);
|
|
378
|
-
lastRenderLineCount = output.split('\n').length;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function onKeypress(_, key = {}) {
|
|
382
|
-
if (key.name === 'up' || key.name === 'k') {
|
|
383
|
-
selectedIndex = selectedIndex === 0 ? options.length - 1 : selectedIndex - 1;
|
|
384
|
-
draw();
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (key.name === 'down' || key.name === 'j') {
|
|
389
|
-
selectedIndex = selectedIndex === options.length - 1 ? 0 : selectedIndex + 1;
|
|
390
|
-
draw();
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (key.name === 'return') {
|
|
395
|
-
const value = options[selectedIndex];
|
|
396
|
-
cleanup();
|
|
397
|
-
resolve(value);
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (key.ctrl && key.name === 'c') {
|
|
402
|
-
cleanup();
|
|
403
|
-
process.exit(130);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
process.stdin.on('keypress', onKeypress);
|
|
408
|
-
draw();
|
|
409
|
-
});
|
|
396
|
+
const { select } = await getClack();
|
|
397
|
+
const selectedValue = await maybeHandleClackCancel(await select({
|
|
398
|
+
message: question,
|
|
399
|
+
options: options.map((option) => ({
|
|
400
|
+
value: option.value,
|
|
401
|
+
label: option.label,
|
|
402
|
+
hint: option.description,
|
|
403
|
+
})),
|
|
404
|
+
}));
|
|
405
|
+
return options.find((option) => option.value === selectedValue) || options[0];
|
|
410
406
|
}
|
|
411
407
|
|
|
412
408
|
function parseEnvFile() {
|
|
@@ -516,14 +512,11 @@ async function collectConfig(existingEnv = {}) {
|
|
|
516
512
|
|
|
517
513
|
const modelName = await chooseModel(language, providerFamily, accessMethod);
|
|
518
514
|
const provider = getModelCatalog(providerFamily, accessMethod).provider;
|
|
519
|
-
|
|
520
|
-
const rl = createPromptInterface();
|
|
521
515
|
let apiKey = '';
|
|
522
516
|
|
|
523
517
|
if (accessMethod === 'api-key') {
|
|
524
518
|
if (providerFamily === 'openai') {
|
|
525
519
|
apiKey = await promptValidated(
|
|
526
|
-
rl,
|
|
527
520
|
t(language, 'openAiApiKeyPrompt'),
|
|
528
521
|
(value) => {
|
|
529
522
|
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
@@ -533,7 +526,6 @@ async function collectConfig(existingEnv = {}) {
|
|
|
533
526
|
);
|
|
534
527
|
} else {
|
|
535
528
|
apiKey = await promptValidated(
|
|
536
|
-
rl,
|
|
537
529
|
t(language, 'anthropicApiKeyPrompt'),
|
|
538
530
|
(value) => {
|
|
539
531
|
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
@@ -551,15 +543,16 @@ async function collectConfig(existingEnv = {}) {
|
|
|
551
543
|
|
|
552
544
|
let telegramToken = '';
|
|
553
545
|
if (telegramChoice.value === 'true') {
|
|
546
|
+
console.log('');
|
|
547
|
+
TEXT[language].telegramBotFatherSteps.forEach((line) => console.log(` ${c.dim}${line}${c.reset}`));
|
|
548
|
+
console.log(` ${c.yellow}${TEXT[language].telegramTokenSafe}${c.reset}`);
|
|
549
|
+
console.log('');
|
|
554
550
|
telegramToken = await promptValidated(
|
|
555
|
-
rl,
|
|
556
551
|
t(language, 'telegramTokenPrompt'),
|
|
557
552
|
(value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
|
|
558
553
|
);
|
|
559
554
|
}
|
|
560
555
|
|
|
561
|
-
rl.close();
|
|
562
|
-
|
|
563
556
|
return {
|
|
564
557
|
language,
|
|
565
558
|
authMode: accessMethod,
|
|
@@ -575,6 +568,8 @@ async function collectConfig(existingEnv = {}) {
|
|
|
575
568
|
|
|
576
569
|
function ensureComposeFile() {
|
|
577
570
|
fs.mkdirSync(LIMBO_DIR, { recursive: true });
|
|
571
|
+
fs.mkdirSync(path.join(VAULT_DIR, 'notes'), { recursive: true });
|
|
572
|
+
fs.mkdirSync(path.join(VAULT_DIR, 'maps'), { recursive: true });
|
|
578
573
|
fs.writeFileSync(COMPOSE_FILE, COMPOSE_CONTENT);
|
|
579
574
|
}
|
|
580
575
|
|
|
@@ -686,6 +681,7 @@ ${c.green}${c.bold}╚═══════════════════
|
|
|
686
681
|
${c.bold}${t(cfg.language, 'gateway')}:${c.reset} ws://127.0.0.1:${PORT}
|
|
687
682
|
${c.bold}${t(cfg.language, 'gatewayToken')}:${c.reset} ${gatewayToken}
|
|
688
683
|
${c.bold}${t(cfg.language, 'data')}:${c.reset} ${LIMBO_DIR}
|
|
684
|
+
${c.bold}Vault:${c.reset} ${VAULT_DIR}
|
|
689
685
|
${c.bold}${t(cfg.language, 'logs')}:${c.reset} limbo logs
|
|
690
686
|
${c.bold}${t(cfg.language, 'stop')}:${c.reset} limbo stop
|
|
691
687
|
${c.bold}${t(cfg.language, 'update')}:${c.reset} limbo update
|
package/mcp-server/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { vaultUpdateMap } from "./tools/update-map.js";
|
|
|
13
13
|
const server = new Server(
|
|
14
14
|
{
|
|
15
15
|
name: "limbo-vault",
|
|
16
|
-
version: "1.
|
|
16
|
+
version: "1.1.0",
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
capabilities: {
|
|
@@ -29,13 +29,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
29
29
|
{
|
|
30
30
|
name: "vault_search",
|
|
31
31
|
description:
|
|
32
|
-
"Search notes in the vault by
|
|
32
|
+
"Search notes in the vault by keyword query. Recursively searches all subdirectories. Returns matching notes with titles, snippets, relevance scores, and domain (subdirectory).",
|
|
33
33
|
inputSchema: {
|
|
34
34
|
type: "object",
|
|
35
35
|
properties: {
|
|
36
36
|
query: {
|
|
37
37
|
type: "string",
|
|
38
|
-
description: "
|
|
38
|
+
description: "Keyword query to search across all vault notes",
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
41
|
required: ["query"],
|
|
@@ -44,13 +44,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
44
44
|
{
|
|
45
45
|
name: "vault_read",
|
|
46
46
|
description:
|
|
47
|
-
"Read the full content of a vault note by ID. Returns raw markdown including YAML frontmatter.",
|
|
47
|
+
"Read the full content of a vault note by ID. Searches recursively through subdirectories. Returns raw markdown including YAML frontmatter.",
|
|
48
48
|
inputSchema: {
|
|
49
49
|
type: "object",
|
|
50
50
|
properties: {
|
|
51
51
|
noteId: {
|
|
52
52
|
type: "string",
|
|
53
|
-
description: "The note ID (filename without .md extension)",
|
|
53
|
+
description: "The note ID (filename without .md extension). Searched recursively across all subdirectories.",
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
56
|
required: ["noteId"],
|
|
@@ -59,16 +59,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
59
59
|
{
|
|
60
60
|
name: "vault_write_note",
|
|
61
61
|
description:
|
|
62
|
-
"Create or overwrite a vault note with YAML frontmatter.
|
|
62
|
+
"Create or overwrite a vault note with YAML frontmatter. Supports subdirectory organization — creates the subdirectory if it doesn't exist.",
|
|
63
63
|
inputSchema: {
|
|
64
64
|
type: "object",
|
|
65
65
|
properties: {
|
|
66
66
|
id: { type: "string", description: "Unique note identifier (alphanumeric, dashes, underscores)" },
|
|
67
67
|
title: { type: "string", description: "Human-readable note title" },
|
|
68
|
-
type: { type: "string", description: "Note type,
|
|
69
|
-
description: { type: "string", description: "One-sentence description of the note's claim
|
|
68
|
+
type: { type: "string", description: "Note type: gotcha, decision, config-fact, pattern, tool-knowledge, research-finding, personal-fact" },
|
|
69
|
+
description: { type: "string", description: "One-sentence falsifiable description of the note's claim" },
|
|
70
70
|
content: { type: "string", description: "Full markdown body of the note" },
|
|
71
|
-
|
|
71
|
+
subdirectory: { type: "string", description: "Optional subdirectory under notes/ (e.g. 'openclaw', 'research', 'aios/infrastructure'). Created if it doesn't exist." },
|
|
72
|
+
status: { type: "string", description: "Optional: current, outdated, superseded. Defaults to none." },
|
|
73
|
+
domain: { type: "string", description: "Optional: knowledge domain (e.g. openclaw, aios, research, personal)" },
|
|
74
|
+
source: { type: "string", description: "Optional: provenance (e.g. limbo, claude-code, web)" },
|
|
75
|
+
topics: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string" },
|
|
78
|
+
description: "Optional: map references as wikilinks, e.g. [\"[[openclaw-map]]\"]",
|
|
79
|
+
},
|
|
72
80
|
},
|
|
73
81
|
required: ["id", "title", "type", "description", "content"],
|
|
74
82
|
},
|
|
@@ -76,13 +84,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
76
84
|
{
|
|
77
85
|
name: "vault_update_map",
|
|
78
86
|
description:
|
|
79
|
-
"Append entries to a section in a Map of Content (MOC). Creates the map file and/or section if they don't exist.",
|
|
87
|
+
"Append entries to a section in a Map of Content (MOC). Creates the map file (with frontmatter) and/or section if they don't exist. Maps live in vault/maps/.",
|
|
80
88
|
inputSchema: {
|
|
81
89
|
type: "object",
|
|
82
90
|
properties: {
|
|
83
91
|
map: {
|
|
84
92
|
type: "string",
|
|
85
|
-
description: "Map filename without extension (
|
|
93
|
+
description: "Map filename without extension (e.g. 'openclaw-map', 'ai-research-map')",
|
|
86
94
|
},
|
|
87
95
|
section: {
|
|
88
96
|
type: "string",
|
|
@@ -91,7 +99,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
91
99
|
entries: {
|
|
92
100
|
type: "array",
|
|
93
101
|
items: { type: "string" },
|
|
94
|
-
description: "Markdown link strings to append, e.g. [\"[[note-id|Note Title]]\"]",
|
|
102
|
+
description: "Markdown link strings to append, e.g. [\"- [[note-id|Note Title]]\"]",
|
|
95
103
|
},
|
|
96
104
|
},
|
|
97
105
|
required: ["map", "section", "entries"],
|
|
@@ -128,7 +136,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
128
136
|
case "vault_write_note": {
|
|
129
137
|
const result = await vaultWriteNote(args);
|
|
130
138
|
return {
|
|
131
|
-
content: [{ type: "text", text: `Note written: ${result.id}` }],
|
|
139
|
+
content: [{ type: "text", text: `Note written: ${result.id} → ${result.path}` }],
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
package/mcp-server/tools/read.js
CHANGED
|
@@ -1,11 +1,65 @@
|
|
|
1
|
-
import { readFile } from "fs/promises";
|
|
1
|
+
import { readFile, readdir, stat } from "fs/promises";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
5
|
const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
6
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
|
+
}
|
|
59
|
+
|
|
7
60
|
/**
|
|
8
61
|
* vault_read(noteId): reads full content of a note by ID.
|
|
62
|
+
* Searches recursively through subdirectories.
|
|
9
63
|
* Returns the raw markdown content including YAML frontmatter.
|
|
10
64
|
* Returns null if the note doesn't exist.
|
|
11
65
|
*/
|
|
@@ -14,13 +68,15 @@ export async function vaultRead(noteId) {
|
|
|
14
68
|
throw new Error("noteId must be a non-empty string");
|
|
15
69
|
}
|
|
16
70
|
|
|
17
|
-
// Sanitize:
|
|
71
|
+
// Sanitize: allow alphanumeric, dashes, underscores
|
|
18
72
|
const safe = noteId.replace(/[^a-zA-Z0-9_\-]/g, "");
|
|
19
73
|
if (safe !== noteId) {
|
|
20
74
|
throw new Error("noteId contains invalid characters");
|
|
21
75
|
}
|
|
22
76
|
|
|
23
|
-
const filePath =
|
|
77
|
+
const filePath = await findNote(safe);
|
|
78
|
+
if (!filePath) return null;
|
|
79
|
+
|
|
24
80
|
try {
|
|
25
81
|
return await readFile(filePath, "utf8");
|
|
26
82
|
} catch (err) {
|
|
@@ -1,17 +1,56 @@
|
|
|
1
|
-
import { readdir, readFile } from "fs/promises";
|
|
2
|
-
import { join, basename } from "path";
|
|
1
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
2
|
+
import { join, basename, relative } from "path";
|
|
3
3
|
|
|
4
4
|
const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
|
|
5
5
|
const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
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.
|
|
9
45
|
*/
|
|
10
46
|
function extractTitle(content) {
|
|
11
47
|
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
12
48
|
if (fmMatch) {
|
|
13
49
|
const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
14
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];
|
|
15
54
|
}
|
|
16
55
|
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
17
56
|
if (h1Match) return h1Match[1];
|
|
@@ -35,33 +74,27 @@ function extractSnippet(content, regex, maxLen = 150) {
|
|
|
35
74
|
}
|
|
36
75
|
|
|
37
76
|
/**
|
|
38
|
-
* vault_search(query):
|
|
39
|
-
* Returns [{noteId, title, snippet, score}] sorted by score desc.
|
|
77
|
+
* vault_search(query): recursive search across all .md files in vault/notes/.
|
|
78
|
+
* Returns [{noteId, title, snippet, score, domain}] sorted by score desc.
|
|
40
79
|
*
|
|
41
80
|
* NOTE: Current implementation is a linear scan (O(n) per query). This is fine
|
|
42
81
|
* for small vaults (hundreds of notes), but will need optimization at scale —
|
|
43
82
|
* consider an inverted index (e.g. SQLite FTS5) when the vault grows large.
|
|
44
83
|
*/
|
|
45
84
|
export async function vaultSearch(query) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
files = await readdir(NOTES_DIR);
|
|
49
|
-
} catch {
|
|
50
|
-
return [];
|
|
85
|
+
if (query.length > 200) {
|
|
86
|
+
throw new Error("Search query too long (max 200 characters)");
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
60
|
-
}
|
|
89
|
+
const files = await walkNotes(NOTES_DIR);
|
|
90
|
+
if (files.length === 0) return [];
|
|
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");
|
|
61
95
|
|
|
62
96
|
const results = [];
|
|
63
|
-
for (const
|
|
64
|
-
const filePath = join(NOTES_DIR, file);
|
|
97
|
+
for (const { filePath, domain } of files) {
|
|
65
98
|
let content;
|
|
66
99
|
try {
|
|
67
100
|
content = await readFile(filePath, "utf8");
|
|
@@ -72,12 +105,12 @@ export async function vaultSearch(query) {
|
|
|
72
105
|
const matches = content.match(regex);
|
|
73
106
|
if (!matches) continue;
|
|
74
107
|
|
|
75
|
-
const noteId = basename(
|
|
108
|
+
const noteId = basename(filePath, ".md");
|
|
76
109
|
const title = extractTitle(content) || noteId;
|
|
77
110
|
const score = matches.length;
|
|
78
111
|
const snippet = extractSnippet(content, regex);
|
|
79
112
|
|
|
80
|
-
results.push({ noteId, title, snippet, score });
|
|
113
|
+
results.push({ noteId, title, snippet, score, domain });
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
results.sort((a, b) => b.score - a.score);
|
|
@@ -13,6 +13,19 @@ function sanitizeName(name) {
|
|
|
13
13
|
return safe;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Builds frontmatter for a new map file.
|
|
18
|
+
*/
|
|
19
|
+
function buildMapFrontmatter(name) {
|
|
20
|
+
const lines = [
|
|
21
|
+
"---",
|
|
22
|
+
`description: "${name.replace(/-/g, " ")}"`,
|
|
23
|
+
"type: moc",
|
|
24
|
+
"---",
|
|
25
|
+
];
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* Finds or creates a section in markdown content.
|
|
18
31
|
* Returns the updated content string.
|
|
@@ -42,8 +55,9 @@ function upsertSection(content, section, entries) {
|
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* vault_update_map(map, section, entries): appends entries to a MOC section.
|
|
45
|
-
* Creates the section if
|
|
46
|
-
*
|
|
58
|
+
* Creates the map file and/or section if they don't exist.
|
|
59
|
+
* New maps are created with proper YAML frontmatter.
|
|
60
|
+
* Entries are markdown link strings, e.g. ["- [[note-id|Note Title]]"]
|
|
47
61
|
*
|
|
48
62
|
* @param {string} map - map filename without extension
|
|
49
63
|
* @param {string} section - section heading text
|
|
@@ -64,8 +78,8 @@ export async function vaultUpdateMap(map, section, entries) {
|
|
|
64
78
|
existing = await readFile(filePath, "utf8");
|
|
65
79
|
} catch (err) {
|
|
66
80
|
if (err.code !== "ENOENT") throw err;
|
|
67
|
-
// New map — start with
|
|
68
|
-
existing =
|
|
81
|
+
// New map — start with frontmatter and title
|
|
82
|
+
existing = `${buildMapFrontmatter(map)}\n\n# ${map}\n`;
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
const updated = upsertSection(existing, section, entries);
|
|
@@ -6,27 +6,46 @@ const NOTES_DIR = join(VAULT_PATH, "notes");
|
|
|
6
6
|
|
|
7
7
|
const REQUIRED_FIELDS = ["id", "title", "type", "description", "content"];
|
|
8
8
|
|
|
9
|
+
function escapeYaml(str) {
|
|
10
|
+
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
14
|
* Builds YAML frontmatter string from note metadata.
|
|
15
|
+
* Supports the merged schema: id, title, description, type, status, domain,
|
|
16
|
+
* created, source, topics.
|
|
11
17
|
*/
|
|
12
18
|
function buildFrontmatter(note) {
|
|
13
19
|
const lines = ["---"];
|
|
14
20
|
lines.push(`id: ${note.id}`);
|
|
15
|
-
lines.push(`title: "${note.title
|
|
21
|
+
lines.push(`title: "${escapeYaml(note.title)}"`);
|
|
22
|
+
lines.push(`description: "${escapeYaml(note.description)}"`);
|
|
16
23
|
lines.push(`type: ${note.type}`);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
if (note.status) {
|
|
25
|
+
lines.push(`status: ${note.status}`);
|
|
26
|
+
}
|
|
27
|
+
if (note.domain) {
|
|
28
|
+
lines.push(`domain: ${note.domain}`);
|
|
29
|
+
}
|
|
30
|
+
lines.push(`created: "${note.created || new Date().toISOString().split("T")[0]}"`);
|
|
31
|
+
if (note.source) {
|
|
32
|
+
lines.push(`source: ${note.source}`);
|
|
33
|
+
}
|
|
34
|
+
if (note.topics && note.topics.length > 0) {
|
|
35
|
+
lines.push("topics:");
|
|
36
|
+
for (const topic of note.topics) {
|
|
37
|
+
lines.push(` - "${escapeYaml(topic)}"`);
|
|
38
|
+
}
|
|
20
39
|
}
|
|
21
|
-
lines.push(`created: ${new Date().toISOString().split("T")[0]}`);
|
|
22
40
|
lines.push("---");
|
|
23
41
|
return lines.join("\n");
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
/**
|
|
27
45
|
* vault_write_note(note): creates a markdown file with YAML frontmatter.
|
|
28
|
-
* Input: {id, title, type, description, content,
|
|
29
|
-
* Writes to /data/vault/notes/{id}.md
|
|
46
|
+
* Input: {id, title, type, description, content, subdirectory?, status?, domain?, source?, topics?}
|
|
47
|
+
* Writes to /data/vault/notes/{subdirectory?}/{id}.md
|
|
48
|
+
* Creates the subdirectory if it doesn't exist.
|
|
30
49
|
*/
|
|
31
50
|
export async function vaultWriteNote(note) {
|
|
32
51
|
for (const field of REQUIRED_FIELDS) {
|
|
@@ -41,11 +60,26 @@ export async function vaultWriteNote(note) {
|
|
|
41
60
|
throw new Error("note.id contains invalid characters");
|
|
42
61
|
}
|
|
43
62
|
|
|
44
|
-
|
|
63
|
+
// Determine target directory
|
|
64
|
+
let targetDir = NOTES_DIR;
|
|
65
|
+
if (note.subdirectory) {
|
|
66
|
+
// Sanitize subdirectory: allow alphanumeric, dashes, underscores, forward slashes
|
|
67
|
+
const safeSub = note.subdirectory.replace(/[^a-zA-Z0-9_\-/]/g, "");
|
|
68
|
+
if (safeSub !== note.subdirectory) {
|
|
69
|
+
throw new Error("subdirectory contains invalid characters");
|
|
70
|
+
}
|
|
71
|
+
// Prevent path traversal
|
|
72
|
+
if (safeSub.includes("..")) {
|
|
73
|
+
throw new Error("subdirectory cannot contain '..'");
|
|
74
|
+
}
|
|
75
|
+
targetDir = join(NOTES_DIR, safeSub);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await mkdir(targetDir, { recursive: true });
|
|
45
79
|
|
|
46
|
-
const frontmatter = buildFrontmatter(note);
|
|
80
|
+
const frontmatter = buildFrontmatter({ ...note, id: safe });
|
|
47
81
|
const fileContent = `${frontmatter}\n\n${note.content}\n`;
|
|
48
|
-
const filePath = join(
|
|
82
|
+
const filePath = join(targetDir, `${safe}.md`);
|
|
49
83
|
|
|
50
84
|
await writeFile(filePath, fileContent, "utf8");
|
|
51
85
|
return { id: safe, path: filePath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limbo-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Your personal AI memory agent — install and manage Limbo via npx",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=18"
|
|
11
11
|
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@clack/prompts": "^1.1.0"
|
|
14
|
+
},
|
|
12
15
|
"scripts": {
|
|
13
16
|
"start": "node cli.js start"
|
|
14
17
|
},
|