limbo-ai 1.0.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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "limbo-vault-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Vault MCP server for Limbo — exposes vault_search, vault_read, vault_write_note, vault_update_map",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "start": "node index.js"
9
+ },
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.0.0"
12
+ },
13
+ "engines": {
14
+ "node": ">=22"
15
+ }
16
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
+ const NOTES_DIR = join(VAULT_PATH, "notes");
6
+
7
+ /**
8
+ * vault_read(noteId): reads full content of a note by ID.
9
+ * Returns the raw markdown content including YAML frontmatter.
10
+ * Returns null if the note doesn't exist.
11
+ */
12
+ export async function vaultRead(noteId) {
13
+ if (!noteId || typeof noteId !== "string") {
14
+ throw new Error("noteId must be a non-empty string");
15
+ }
16
+
17
+ // Sanitize: only allow alphanumeric, dashes, underscores
18
+ const safe = noteId.replace(/[^a-zA-Z0-9_\-]/g, "");
19
+ if (safe !== noteId) {
20
+ throw new Error("noteId contains invalid characters");
21
+ }
22
+
23
+ const filePath = join(NOTES_DIR, `${safe}.md`);
24
+ try {
25
+ return await readFile(filePath, "utf8");
26
+ } catch (err) {
27
+ if (err.code === "ENOENT") return null;
28
+ throw err;
29
+ }
30
+ }
@@ -0,0 +1,85 @@
1
+ import { readdir, readFile } from "fs/promises";
2
+ import { join, basename } from "path";
3
+
4
+ const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
+ const NOTES_DIR = join(VAULT_PATH, "notes");
6
+
7
+ /**
8
+ * Extracts the title from YAML frontmatter or first H1 heading.
9
+ */
10
+ function extractTitle(content) {
11
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
12
+ if (fmMatch) {
13
+ const titleMatch = fmMatch[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
14
+ if (titleMatch) return titleMatch[1];
15
+ }
16
+ const h1Match = content.match(/^#\s+(.+)$/m);
17
+ if (h1Match) return h1Match[1];
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * Finds a short snippet around the first match.
23
+ */
24
+ function extractSnippet(content, regex, maxLen = 150) {
25
+ regex.lastIndex = 0;
26
+ const match = regex.exec(content);
27
+ regex.lastIndex = 0;
28
+ if (!match) return "";
29
+ const start = Math.max(0, match.index - 60);
30
+ const end = Math.min(content.length, match.index + maxLen);
31
+ let snippet = content.slice(start, end).replace(/\n/g, " ").trim();
32
+ if (start > 0) snippet = "..." + snippet;
33
+ if (end < content.length) snippet += "...";
34
+ return snippet;
35
+ }
36
+
37
+ /**
38
+ * vault_search(query): regex search across all .md files in /data/vault/notes/.
39
+ * Returns [{noteId, title, snippet, score}] sorted by score desc.
40
+ *
41
+ * NOTE: Current implementation is a linear scan (O(n) per query). This is fine
42
+ * for small vaults (hundreds of notes), but will need optimization at scale —
43
+ * consider an inverted index (e.g. SQLite FTS5) when the vault grows large.
44
+ */
45
+ export async function vaultSearch(query) {
46
+ let files;
47
+ try {
48
+ files = await readdir(NOTES_DIR);
49
+ } catch {
50
+ return [];
51
+ }
52
+
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
+ }
61
+
62
+ const results = [];
63
+ for (const file of mdFiles) {
64
+ const filePath = join(NOTES_DIR, file);
65
+ let content;
66
+ try {
67
+ content = await readFile(filePath, "utf8");
68
+ } catch {
69
+ continue;
70
+ }
71
+
72
+ const matches = content.match(regex);
73
+ if (!matches) continue;
74
+
75
+ const noteId = basename(file, ".md");
76
+ const title = extractTitle(content) || noteId;
77
+ const score = matches.length;
78
+ const snippet = extractSnippet(content, regex);
79
+
80
+ results.push({ noteId, title, snippet, score });
81
+ }
82
+
83
+ results.sort((a, b) => b.score - a.score);
84
+ return results;
85
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile, writeFile, mkdir } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
+ const MAPS_DIR = join(VAULT_PATH, "maps");
6
+
7
+ /**
8
+ * Sanitizes a map name (filename without extension).
9
+ */
10
+ function sanitizeName(name) {
11
+ const safe = name.replace(/[^a-zA-Z0-9_\-]/g, "");
12
+ if (safe !== name) throw new Error(`Invalid characters in name: ${name}`);
13
+ return safe;
14
+ }
15
+
16
+ /**
17
+ * Finds or creates a section in markdown content.
18
+ * Returns the updated content string.
19
+ */
20
+ function upsertSection(content, section, entries) {
21
+ const sectionHeader = `## ${section}`;
22
+ const lines = content.split("\n");
23
+
24
+ const sectionIdx = lines.findIndex((l) => l.trim() === sectionHeader);
25
+
26
+ if (sectionIdx === -1) {
27
+ // Section doesn't exist — append it
28
+ const toAdd = ["", sectionHeader, "", ...entries, ""];
29
+ return lines.concat(toAdd).join("\n");
30
+ }
31
+
32
+ // Find where the section ends (next ## or EOF)
33
+ let insertIdx = sectionIdx + 1;
34
+ while (insertIdx < lines.length && !lines[insertIdx].startsWith("## ")) {
35
+ insertIdx++;
36
+ }
37
+
38
+ // Insert entries before the next section (or EOF)
39
+ lines.splice(insertIdx, 0, ...entries);
40
+ return lines.join("\n");
41
+ }
42
+
43
+ /**
44
+ * 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]]"]
47
+ *
48
+ * @param {string} map - map filename without extension
49
+ * @param {string} section - section heading text
50
+ * @param {string[]} entries - array of markdown link strings to append
51
+ */
52
+ export async function vaultUpdateMap(map, section, entries) {
53
+ if (!map || typeof map !== "string") throw new Error("map must be a non-empty string");
54
+ if (!section || typeof section !== "string") throw new Error("section must be a non-empty string");
55
+ if (!Array.isArray(entries) || entries.length === 0) throw new Error("entries must be a non-empty array");
56
+
57
+ const safeMap = sanitizeName(map);
58
+ await mkdir(MAPS_DIR, { recursive: true });
59
+
60
+ const filePath = join(MAPS_DIR, `${safeMap}.md`);
61
+
62
+ let existing = "";
63
+ try {
64
+ existing = await readFile(filePath, "utf8");
65
+ } catch (err) {
66
+ if (err.code !== "ENOENT") throw err;
67
+ // New map — start with a title
68
+ existing = `# ${map}\n`;
69
+ }
70
+
71
+ const updated = upsertSection(existing, section, entries);
72
+ await writeFile(filePath, updated, "utf8");
73
+ return { map: safeMap, section, added: entries.length };
74
+ }
@@ -0,0 +1,52 @@
1
+ import { writeFile, mkdir } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ const VAULT_PATH = process.env.VAULT_PATH || "/data/vault";
5
+ const NOTES_DIR = join(VAULT_PATH, "notes");
6
+
7
+ const REQUIRED_FIELDS = ["id", "title", "type", "description", "content"];
8
+
9
+ /**
10
+ * Builds YAML frontmatter string from note metadata.
11
+ */
12
+ function buildFrontmatter(note) {
13
+ const lines = ["---"];
14
+ lines.push(`id: ${note.id}`);
15
+ lines.push(`title: "${note.title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
16
+ 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}`);
20
+ }
21
+ lines.push(`created: ${new Date().toISOString().split("T")[0]}`);
22
+ lines.push("---");
23
+ return lines.join("\n");
24
+ }
25
+
26
+ /**
27
+ * 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
30
+ */
31
+ export async function vaultWriteNote(note) {
32
+ for (const field of REQUIRED_FIELDS) {
33
+ if (!note[field] || typeof note[field] !== "string") {
34
+ throw new Error(`Missing or invalid required field: ${field}`);
35
+ }
36
+ }
37
+
38
+ // Sanitize id
39
+ const safe = note.id.replace(/[^a-zA-Z0-9_\-]/g, "");
40
+ if (safe !== note.id) {
41
+ throw new Error("note.id contains invalid characters");
42
+ }
43
+
44
+ await mkdir(NOTES_DIR, { recursive: true });
45
+
46
+ const frontmatter = buildFrontmatter(note);
47
+ const fileContent = `${frontmatter}\n\n${note.content}\n`;
48
+ const filePath = join(NOTES_DIR, `${safe}.md`);
49
+
50
+ await writeFile(filePath, fileContent, "utf8");
51
+ return { id: safe, path: filePath };
52
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "limbo-ai",
3
+ "version": "1.0.0",
4
+ "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "limbo": "./cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "start": "node cli.js start"
14
+ },
15
+ "keywords": [
16
+ "limbo",
17
+ "ai",
18
+ "memory",
19
+ "agent",
20
+ "openclaw",
21
+ "personal"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/TomasWard1/limbo.git"
27
+ }
28
+ }