limbo-ai 1.6.1 → 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 CHANGED
@@ -114,6 +114,17 @@ const TEXT = {
114
114
  invalidOpenAIKey: 'OpenAI API keys usually start with "sk-".',
115
115
  invalidAnthropicKey: 'Anthropic API keys usually start with "sk-ant-".',
116
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.',
117
128
  telegramTokenPrompt: ' Telegram bot token: ',
118
129
  yes: 'Yes',
119
130
  no: 'No',
@@ -192,6 +203,17 @@ const TEXT = {
192
203
  invalidOpenAIKey: 'Las API keys de OpenAI normalmente empiezan con "sk-".',
193
204
  invalidAnthropicKey: 'Las API keys de Anthropic normalmente empiezan con "sk-ant-".',
194
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.',
195
217
  telegramTokenPrompt: ' Telegram bot token: ',
196
218
  yes: 'Si',
197
219
  no: 'No',
@@ -521,6 +543,10 @@ async function collectConfig(existingEnv = {}) {
521
543
 
522
544
  let telegramToken = '';
523
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('');
524
550
  telegramToken = await promptValidated(
525
551
  t(language, 'telegramTokenPrompt'),
526
552
  (value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
@@ -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.0.0",
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 regex query. Returns matching notes with titles, snippets, and relevance scores.",
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: "Regex or keyword query to search across all vault notes",
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. Required fields: id, title, type, description, content. Optional: map.",
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, e.g. claim, source, concept, question" },
69
- description: { type: "string", description: "One-sentence description of the note's claim or content" },
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
- map: { type: "string", description: "Optional: name of the MOC this note belongs to" },
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 (alphanumeric, dashes, underscores)",
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
 
@@ -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: only allow alphanumeric, dashes, underscores
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 = join(NOTES_DIR, `${safe}.md`);
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
- * Extracts the title from YAML frontmatter or first H1 heading.
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): regex search across all .md files in /data/vault/notes/.
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
- let files;
47
- try {
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 mdFiles = files.filter((f) => f.endsWith(".md"));
54
- let regex;
55
- try {
56
- regex = new RegExp(query, "gi");
57
- } catch {
58
- // Fallback to literal search if invalid regex
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 file of mdFiles) {
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(file, ".md");
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 it doesn't exist.
46
- * Entries are markdown link strings, e.g. ["[[note-id|Note Title]]"]
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 a title
68
- existing = `# ${map}\n`;
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.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
21
+ lines.push(`title: "${escapeYaml(note.title)}"`);
22
+ lines.push(`description: "${escapeYaml(note.description)}"`);
16
23
  lines.push(`type: ${note.type}`);
17
- lines.push(`description: "${note.description.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
18
- if (note.map) {
19
- lines.push(`map: ${note.map}`);
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, map?}
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
- await mkdir(NOTES_DIR, { recursive: true });
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(NOTES_DIR, `${safe}.md`);
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.6.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": {