context-vault 2.1.0 → 2.2.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/README.md +39 -3
- package/bin/cli.js +55 -30
- package/package.json +9 -2
- package/src/capture/index.js +66 -1
- package/src/index/db.js +1 -0
- package/src/server/tools.js +171 -30
package/README.md
CHANGED
|
@@ -30,12 +30,14 @@ Setup auto-detects your tools (Claude Code, Claude Desktop, Cursor, Windsurf, Cl
|
|
|
30
30
|
|
|
31
31
|
## Tools
|
|
32
32
|
|
|
33
|
-
The server exposes
|
|
33
|
+
The server exposes five tools. Your AI agent calls them automatically — you don't invoke them directly.
|
|
34
34
|
|
|
35
35
|
| Tool | Type | Description |
|
|
36
36
|
|------|------|-------------|
|
|
37
37
|
| `get_context` | Read | Hybrid FTS5 + vector search across all knowledge |
|
|
38
|
-
| `save_context` | Write | Save
|
|
38
|
+
| `save_context` | Write | Save new knowledge or update existing entries by ID |
|
|
39
|
+
| `list_context` | Browse | List vault entries with filtering and pagination |
|
|
40
|
+
| `delete_context` | Delete | Remove an entry by ID (file + index) |
|
|
39
41
|
| `context_status` | Diag | Show resolved config, health, and per-kind file counts |
|
|
40
42
|
|
|
41
43
|
### `get_context` — Search your vault
|
|
@@ -51,9 +53,10 @@ get_context({
|
|
|
51
53
|
|
|
52
54
|
Returns entries ranked by combined full-text and semantic similarity, with recency weighting.
|
|
53
55
|
|
|
54
|
-
### `save_context` — Save knowledge
|
|
56
|
+
### `save_context` — Save or update knowledge
|
|
55
57
|
|
|
56
58
|
```js
|
|
59
|
+
// Create new entry
|
|
57
60
|
save_context({
|
|
58
61
|
kind: "insight", // Determines folder: insights/
|
|
59
62
|
body: "React Query staleTime defaults to 0",
|
|
@@ -64,10 +67,43 @@ save_context({
|
|
|
64
67
|
source: "debugging-session" // Optional: provenance
|
|
65
68
|
})
|
|
66
69
|
// → ~/vault/knowledge/insights/react/hooks/staletime-gotcha.md
|
|
70
|
+
|
|
71
|
+
// Update existing entry by ID
|
|
72
|
+
save_context({
|
|
73
|
+
id: "01HXYZ...", // ULID from a previous save
|
|
74
|
+
body: "Updated content here", // Only provide fields you want to change
|
|
75
|
+
tags: ["react", "updated"] // Omitted fields are preserved
|
|
76
|
+
})
|
|
67
77
|
```
|
|
68
78
|
|
|
69
79
|
The `kind` field accepts any string — `"insight"`, `"decision"`, `"pattern"`, `"reference"`, or any custom kind. The folder is auto-created from the pluralized kind name.
|
|
70
80
|
|
|
81
|
+
When updating (`id` provided), omitted fields are preserved from the original. You cannot change `kind` or `identity_key` — delete and re-create instead.
|
|
82
|
+
|
|
83
|
+
### `list_context` — Browse entries
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
list_context({
|
|
87
|
+
kind: "insight", // Optional: filter by kind
|
|
88
|
+
category: "knowledge", // Optional: knowledge, entity, or event
|
|
89
|
+
tags: ["react"], // Optional: filter by tags
|
|
90
|
+
limit: 10, // Optional: max results (default 20, max 100)
|
|
91
|
+
offset: 0 // Optional: pagination offset
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Returns entry metadata (id, title, kind, category, tags, created_at) without body content. Use `get_context` with a search query to retrieve full entries.
|
|
96
|
+
|
|
97
|
+
### `delete_context` — Remove an entry
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
delete_context({
|
|
101
|
+
id: "01HXYZ..." // ULID of the entry to delete
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Removes the markdown file from disk and cleans up the database and vector index.
|
|
106
|
+
|
|
71
107
|
### `context_status` — Diagnostics
|
|
72
108
|
|
|
73
109
|
Shows vault path, database size, file counts per kind, embedding coverage, and any issues.
|
package/bin/cli.js
CHANGED
|
@@ -156,7 +156,8 @@ const TOOLS = [
|
|
|
156
156
|
|
|
157
157
|
function showHelp() {
|
|
158
158
|
console.log(`
|
|
159
|
-
${bold("context-
|
|
159
|
+
${bold("◇ context-vault")} ${dim(`v${VERSION}`)}
|
|
160
|
+
${dim("Persistent memory for AI agents")}
|
|
160
161
|
|
|
161
162
|
${bold("Usage:")}
|
|
162
163
|
context-mcp <command> [options]
|
|
@@ -180,14 +181,12 @@ ${bold("Options:")}
|
|
|
180
181
|
async function runSetup() {
|
|
181
182
|
// Banner
|
|
182
183
|
console.log();
|
|
183
|
-
console.log(bold("
|
|
184
|
-
console.log(dim(" Persistent memory for AI agents
|
|
185
|
-
console.log();
|
|
186
|
-
console.log(dim(" Setup will: detect tools, configure MCP, download embeddings, and verify."));
|
|
184
|
+
console.log(` ${bold("◇ context-vault")} ${dim(`v${VERSION}`)}`);
|
|
185
|
+
console.log(dim(" Persistent memory for AI agents"));
|
|
187
186
|
console.log();
|
|
188
187
|
|
|
189
188
|
// Detect tools
|
|
190
|
-
console.log(bold("
|
|
189
|
+
console.log(dim(` [1/5]`) + bold(" Detecting tools...\n"));
|
|
191
190
|
const detected = [];
|
|
192
191
|
for (const tool of TOOLS) {
|
|
193
192
|
const found = tool.detect();
|
|
@@ -254,6 +253,7 @@ async function runSetup() {
|
|
|
254
253
|
}
|
|
255
254
|
|
|
256
255
|
// Vault directory (content files)
|
|
256
|
+
console.log(dim(` [2/5]`) + bold(" Configuring vault...\n"));
|
|
257
257
|
const defaultVaultDir = join(HOME, "vault");
|
|
258
258
|
const vaultDir = isNonInteractive
|
|
259
259
|
? defaultVaultDir
|
|
@@ -299,7 +299,7 @@ async function runSetup() {
|
|
|
299
299
|
console.log(`\n ${green("+")} Wrote ${configPath}`);
|
|
300
300
|
|
|
301
301
|
// Pre-download embedding model
|
|
302
|
-
console.log(`\n${bold("
|
|
302
|
+
console.log(`\n ${dim("[3/5]")}${bold(" Downloading embedding model...")}`);
|
|
303
303
|
console.log(dim(" all-MiniLM-L6-v2 (~22MB, one-time download)\n"));
|
|
304
304
|
try {
|
|
305
305
|
const { embed } = await import("../src/index/embed.js");
|
|
@@ -319,7 +319,7 @@ async function runSetup() {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
// Configure each tool — pass vault dir as arg if non-default
|
|
322
|
-
console.log(`\n${bold("
|
|
322
|
+
console.log(`\n ${dim("[4/5]")}${bold(" Configuring tools...\n")}`);
|
|
323
323
|
const results = [];
|
|
324
324
|
const defaultVDir = join(HOME, "vault");
|
|
325
325
|
const customVaultDir = resolvedVaultDir !== resolve(defaultVDir) ? resolvedVaultDir : null;
|
|
@@ -360,26 +360,34 @@ async function runSetup() {
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
// Health check
|
|
363
|
-
|
|
363
|
+
console.log(`\n ${dim("[5/5]")}${bold(" Health check...")}\n`);
|
|
364
|
+
const okResults = results.filter((r) => r.ok);
|
|
364
365
|
const checks = [
|
|
365
366
|
{ label: "Vault directory exists", pass: existsSync(resolvedVaultDir) },
|
|
366
367
|
{ label: "Config file written", pass: existsSync(configPath) },
|
|
367
|
-
{ label: "At least one tool configured", pass:
|
|
368
|
+
{ label: "At least one tool configured", pass: okResults.length > 0 },
|
|
368
369
|
];
|
|
369
370
|
const passed = checks.filter((c) => c.pass).length;
|
|
370
|
-
console.log(bold(`\n Health check: ${passed}/${checks.length} passed\n`));
|
|
371
371
|
for (const c of checks) {
|
|
372
|
-
console.log(` ${c.pass ? green("
|
|
372
|
+
console.log(` ${c.pass ? green("✓") : red("✗")} ${c.label}`);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
//
|
|
376
|
-
const toolName =
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
375
|
+
// Completion box
|
|
376
|
+
const toolName = okResults.length ? okResults[0].tool.name : "your AI tool";
|
|
377
|
+
const boxLines = [
|
|
378
|
+
` ✓ Setup complete — ${passed}/${checks.length} checks passed`,
|
|
379
|
+
``,
|
|
380
|
+
` Open ${toolName} and try:`,
|
|
381
|
+
` "Search my vault for getting started"`,
|
|
382
|
+
];
|
|
383
|
+
const innerWidth = Math.max(...boxLines.map((l) => l.length)) + 2;
|
|
384
|
+
const pad = (s) => s + " ".repeat(Math.max(0, innerWidth - s.length));
|
|
385
|
+
console.log();
|
|
386
|
+
console.log(` ${dim("┌" + "─".repeat(innerWidth) + "┐")}`);
|
|
387
|
+
for (const line of boxLines) {
|
|
388
|
+
console.log(` ${dim("│")}${pad(line)}${dim("│")}`);
|
|
389
|
+
}
|
|
390
|
+
console.log(` ${dim("└" + "─".repeat(innerWidth) + "┘")}`);
|
|
383
391
|
console.log();
|
|
384
392
|
}
|
|
385
393
|
|
|
@@ -537,11 +545,11 @@ async function runReindex() {
|
|
|
537
545
|
const stats = await reindex(ctx, { fullSync: true });
|
|
538
546
|
|
|
539
547
|
db.close();
|
|
540
|
-
console.log(green("Reindex complete
|
|
541
|
-
console.log(`
|
|
542
|
-
console.log(`
|
|
543
|
-
console.log(`
|
|
544
|
-
console.log(`
|
|
548
|
+
console.log(green("✓ Reindex complete"));
|
|
549
|
+
console.log(` ${green("+")} ${stats.added} added`);
|
|
550
|
+
console.log(` ${yellow("~")} ${stats.updated} updated`);
|
|
551
|
+
console.log(` ${red("-")} ${stats.removed} removed`);
|
|
552
|
+
console.log(` ${dim("·")} ${stats.unchanged} unchanged`);
|
|
545
553
|
}
|
|
546
554
|
|
|
547
555
|
// ─── Status Command ──────────────────────────────────────────────────────────
|
|
@@ -559,26 +567,43 @@ async function runStatus() {
|
|
|
559
567
|
db.close();
|
|
560
568
|
|
|
561
569
|
console.log();
|
|
562
|
-
console.log(bold("
|
|
570
|
+
console.log(` ${bold("◇ context-vault")} ${dim(`v${VERSION}`)}`);
|
|
563
571
|
console.log();
|
|
564
|
-
console.log(` Vault: ${config.vaultDir} (
|
|
565
|
-
console.log(` Database: ${config.dbPath} (${status.dbSize})`);
|
|
572
|
+
console.log(` Vault: ${config.vaultDir} ${dim(`(${config.vaultDirExists ? status.fileCount + " files" : "missing"})`)}`);
|
|
573
|
+
console.log(` Database: ${config.dbPath} ${dim(`(${status.dbSize})`)}`);
|
|
566
574
|
console.log(` Dev dir: ${config.devDir}`);
|
|
567
575
|
console.log(` Data dir: ${config.dataDir}`);
|
|
568
|
-
console.log(` Config: ${config.configPath} (
|
|
576
|
+
console.log(` Config: ${config.configPath} ${dim(`(${existsSync(config.configPath) ? "exists" : "missing"})`)}`);
|
|
569
577
|
console.log(` Resolved: ${status.resolvedFrom}`);
|
|
570
578
|
console.log(` Schema: v5 (categories)`);
|
|
571
579
|
|
|
572
580
|
if (status.kindCounts.length) {
|
|
581
|
+
const BAR_WIDTH = 20;
|
|
582
|
+
const maxCount = Math.max(...status.kindCounts.map((k) => k.c));
|
|
573
583
|
console.log();
|
|
574
584
|
console.log(bold(" Indexed"));
|
|
575
585
|
for (const { kind, c } of status.kindCounts) {
|
|
576
|
-
|
|
586
|
+
const filled = maxCount > 0 ? Math.round((c / maxCount) * BAR_WIDTH) : 0;
|
|
587
|
+
const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
|
|
588
|
+
const countStr = String(c).padStart(4);
|
|
589
|
+
console.log(` ${countStr} ${kind}s ${dim(bar)}`);
|
|
577
590
|
}
|
|
578
591
|
} else {
|
|
579
592
|
console.log(`\n ${dim("(empty — no entries indexed)")}`);
|
|
580
593
|
}
|
|
581
594
|
|
|
595
|
+
if (status.embeddingStatus) {
|
|
596
|
+
const { indexed, total, missing } = status.embeddingStatus;
|
|
597
|
+
if (missing > 0) {
|
|
598
|
+
const BAR_WIDTH = 20;
|
|
599
|
+
const filled = total > 0 ? Math.round((indexed / total) * BAR_WIDTH) : 0;
|
|
600
|
+
const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
|
|
601
|
+
const pct = total > 0 ? Math.round((indexed / total) * 100) : 0;
|
|
602
|
+
console.log();
|
|
603
|
+
console.log(` Embeddings ${dim(bar)} ${indexed}/${total} (${pct}%)`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
582
607
|
if (status.subdirs.length) {
|
|
583
608
|
console.log();
|
|
584
609
|
console.log(bold(" Disk Directories"));
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-vault",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
|
|
6
6
|
"bin": {
|
|
7
7
|
"context-mcp": "bin/cli.js"
|
|
8
8
|
},
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
"repository": { "type": "git", "url": "https://github.com/fellanH/context-mcp.git" },
|
|
22
22
|
"homepage": "https://github.com/fellanH/context-mcp",
|
|
23
23
|
"keywords": ["mcp", "model-context-protocol", "ai", "knowledge-base", "knowledge-management", "vault", "rag", "sqlite", "embeddings", "claude", "cursor", "cline", "windsurf"],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"vitest": "^3.0.0"
|
|
30
|
+
},
|
|
24
31
|
"dependencies": {
|
|
25
32
|
"@huggingface/transformers": "^3.0.0",
|
|
26
33
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
package/src/capture/index.js
CHANGED
|
@@ -11,7 +11,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { ulid, slugify, kindToPath } from "../core/files.js";
|
|
13
13
|
import { categoryFor } from "../core/categories.js";
|
|
14
|
-
import { parseFrontmatter } from "../core/frontmatter.js";
|
|
14
|
+
import { parseFrontmatter, formatFrontmatter } from "../core/frontmatter.js";
|
|
15
|
+
import { formatBody } from "./formatters.js";
|
|
15
16
|
import { writeEntryFile } from "./file-ops.js";
|
|
16
17
|
|
|
17
18
|
export function writeEntry(ctx, { kind, title, body, meta, tags, source, folder, identity_key, expires_at }) {
|
|
@@ -61,6 +62,70 @@ export function writeEntry(ctx, { kind, title, body, meta, tags, source, folder,
|
|
|
61
62
|
return { id, filePath, kind, category, title, body, meta, tags, source, createdAt, identity_key, expires_at };
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Update an existing entry's file on disk (merge provided fields with existing).
|
|
67
|
+
* Does NOT re-index — caller must call indexEntry after.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ config, stmts }} ctx
|
|
70
|
+
* @param {object} existing — Row from vault table (from getEntryById)
|
|
71
|
+
* @param {{ title?, body?, tags?, meta?, source?, expires_at? }} updates
|
|
72
|
+
* @returns {object} Entry object suitable for indexEntry
|
|
73
|
+
*/
|
|
74
|
+
export function updateEntryFile(ctx, existing, updates) {
|
|
75
|
+
const raw = readFileSync(existing.file_path, "utf-8");
|
|
76
|
+
const { meta: fmMeta } = parseFrontmatter(raw);
|
|
77
|
+
|
|
78
|
+
const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
|
|
79
|
+
const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
|
|
80
|
+
|
|
81
|
+
const title = updates.title !== undefined ? updates.title : existing.title;
|
|
82
|
+
const body = updates.body !== undefined ? updates.body : existing.body;
|
|
83
|
+
const tags = updates.tags !== undefined ? updates.tags : existingTags;
|
|
84
|
+
const source = updates.source !== undefined ? updates.source : existing.source;
|
|
85
|
+
const expires_at = updates.expires_at !== undefined ? updates.expires_at : existing.expires_at;
|
|
86
|
+
|
|
87
|
+
let mergedMeta;
|
|
88
|
+
if (updates.meta !== undefined) {
|
|
89
|
+
mergedMeta = { ...existingMeta, ...(updates.meta || {}) };
|
|
90
|
+
} else {
|
|
91
|
+
mergedMeta = { ...existingMeta };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build frontmatter
|
|
95
|
+
const fmFields = { id: existing.id };
|
|
96
|
+
for (const [k, v] of Object.entries(mergedMeta)) {
|
|
97
|
+
if (k === "folder") continue;
|
|
98
|
+
if (v !== null && v !== undefined) fmFields[k] = v;
|
|
99
|
+
}
|
|
100
|
+
if (existing.identity_key) fmFields.identity_key = existing.identity_key;
|
|
101
|
+
if (expires_at) fmFields.expires_at = expires_at;
|
|
102
|
+
fmFields.tags = tags;
|
|
103
|
+
fmFields.source = source || "claude-code";
|
|
104
|
+
fmFields.created = fmMeta.created || existing.created_at;
|
|
105
|
+
|
|
106
|
+
const mdBody = formatBody(existing.kind, { title, body, meta: mergedMeta });
|
|
107
|
+
const md = formatFrontmatter(fmFields) + mdBody;
|
|
108
|
+
|
|
109
|
+
writeFileSync(existing.file_path, md);
|
|
110
|
+
|
|
111
|
+
const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: existing.id,
|
|
115
|
+
filePath: existing.file_path,
|
|
116
|
+
kind: existing.kind,
|
|
117
|
+
category: existing.category,
|
|
118
|
+
title,
|
|
119
|
+
body,
|
|
120
|
+
meta: finalMeta,
|
|
121
|
+
tags,
|
|
122
|
+
source,
|
|
123
|
+
createdAt: fmMeta.created || existing.created_at,
|
|
124
|
+
identity_key: existing.identity_key,
|
|
125
|
+
expires_at,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
64
129
|
export async function captureAndIndex(ctx, data, indexFn) {
|
|
65
130
|
// For entity upserts, preserve previous file content for safe rollback
|
|
66
131
|
let previousContent = null;
|
package/src/index/db.js
CHANGED
|
@@ -114,6 +114,7 @@ export function prepareStatements(db) {
|
|
|
114
114
|
deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
|
|
115
115
|
getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
|
|
116
116
|
getRowidByPath: db.prepare(`SELECT rowid FROM vault WHERE file_path = ?`),
|
|
117
|
+
getEntryById: db.prepare(`SELECT * FROM vault WHERE id = ?`),
|
|
117
118
|
getByIdentityKey: db.prepare(`SELECT * FROM vault WHERE kind = ? AND identity_key = ?`),
|
|
118
119
|
upsertByIdentityKey: db.prepare(`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ? WHERE kind = ? AND identity_key = ?`),
|
|
119
120
|
insertVecStmt: db.prepare(`INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`),
|
package/src/server/tools.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools.js — MCP tool registrations
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five tools: save_context (write/update), get_context (search), list_context (browse),
|
|
5
|
+
* delete_context (remove), context_status (diag).
|
|
5
6
|
* Auto-reindex runs transparently on first tool call per session.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { z } from "zod";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
10
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
10
11
|
|
|
11
|
-
import { captureAndIndex } from "../capture/index.js";
|
|
12
|
+
import { captureAndIndex, updateEntryFile } from "../capture/index.js";
|
|
12
13
|
import { hybridSearch } from "../retrieve/index.js";
|
|
13
14
|
import { reindex, indexEntry } from "../index/index.js";
|
|
14
15
|
import { gatherVaultStatus } from "../core/status.js";
|
|
@@ -58,7 +59,7 @@ export function registerTools(server, ctx) {
|
|
|
58
59
|
return reindexPromise;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
// ─── get_context (
|
|
62
|
+
// ─── get_context (search) ──────────────────────────────────────────────────
|
|
62
63
|
|
|
63
64
|
server.tool(
|
|
64
65
|
"get_context",
|
|
@@ -98,10 +99,13 @@ export function registerTools(server, ctx) {
|
|
|
98
99
|
const lines = [];
|
|
99
100
|
if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-mcp reindex\` to fix.\n`);
|
|
100
101
|
lines.push(`## Results for "${query}" (${filtered.length} matches)\n`);
|
|
101
|
-
for (
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
103
|
+
const r = filtered[i];
|
|
104
|
+
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
105
|
+
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
106
|
+
const relPath = r.file_path && config.vaultDir ? r.file_path.replace(config.vaultDir + "/", "") : r.file_path || "n/a";
|
|
107
|
+
lines.push(`### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]`);
|
|
108
|
+
lines.push(`${r.score.toFixed(3)} · ${tagStr} · ${relPath}`);
|
|
105
109
|
lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
|
|
106
110
|
lines.push("");
|
|
107
111
|
}
|
|
@@ -109,15 +113,16 @@ export function registerTools(server, ctx) {
|
|
|
109
113
|
}
|
|
110
114
|
);
|
|
111
115
|
|
|
112
|
-
// ─── save_context (write)
|
|
116
|
+
// ─── save_context (write / update) ────────────────────────────────────────
|
|
113
117
|
|
|
114
118
|
server.tool(
|
|
115
119
|
"save_context",
|
|
116
120
|
"Save knowledge to your vault. Creates a .md file and indexes it for search. Use for any kind of context: insights, decisions, patterns, references, or any custom kind.",
|
|
117
121
|
{
|
|
118
|
-
|
|
122
|
+
id: z.string().optional().describe("Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved."),
|
|
123
|
+
kind: z.string().optional().describe("Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries."),
|
|
119
124
|
title: z.string().optional().describe("Entry title (optional for insights)"),
|
|
120
|
-
body: z.string().describe("Main content"),
|
|
125
|
+
body: z.string().optional().describe("Main content. Required for new entries."),
|
|
121
126
|
tags: z.array(z.string()).optional().describe("Tags for categorization and search"),
|
|
122
127
|
meta: z.any().optional().describe("Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })"),
|
|
123
128
|
folder: z.string().optional().describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
|
|
@@ -125,14 +130,40 @@ export function registerTools(server, ctx) {
|
|
|
125
130
|
identity_key: z.string().optional().describe("Required for entity kinds (contact, project, tool, source). The unique identifier for this entity."),
|
|
126
131
|
expires_at: z.string().optional().describe("ISO date for TTL expiry"),
|
|
127
132
|
},
|
|
128
|
-
async ({ kind, title, body, tags, meta, folder, source, identity_key, expires_at }) => {
|
|
133
|
+
async ({ id, kind, title, body, tags, meta, folder, source, identity_key, expires_at }) => {
|
|
129
134
|
const vaultErr = ensureVaultExists(config);
|
|
130
135
|
if (vaultErr) return vaultErr;
|
|
136
|
+
|
|
137
|
+
// ── Update mode ──
|
|
138
|
+
if (id) {
|
|
139
|
+
await ensureIndexed();
|
|
140
|
+
|
|
141
|
+
const existing = ctx.stmts.getEntryById.get(id);
|
|
142
|
+
if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
143
|
+
|
|
144
|
+
if (kind && normalizeKind(kind) !== existing.kind) {
|
|
145
|
+
return err(`Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`, "INVALID_UPDATE");
|
|
146
|
+
}
|
|
147
|
+
if (identity_key && identity_key !== existing.identity_key) {
|
|
148
|
+
return err(`Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`, "INVALID_UPDATE");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const entry = updateEntryFile(ctx, existing, { title, body, tags, meta, source, expires_at });
|
|
152
|
+
await indexEntry(ctx, entry);
|
|
153
|
+
const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
|
|
154
|
+
const parts = [`✓ Updated ${entry.kind} → ${relPath}`, ` id: ${entry.id}`];
|
|
155
|
+
if (entry.title) parts.push(` title: ${entry.title}`);
|
|
156
|
+
const entryTags = entry.tags || [];
|
|
157
|
+
if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
|
|
158
|
+
return ok(parts.join("\n"));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Create mode ──
|
|
162
|
+
if (!kind) return err("Required: kind (for new entries)", "INVALID_INPUT");
|
|
131
163
|
const kindErr = ensureValidKind(kind);
|
|
132
164
|
if (kindErr) return kindErr;
|
|
133
|
-
if (!body?.trim()) return err("Required: body (
|
|
165
|
+
if (!body?.trim()) return err("Required: body (for new entries)", "INVALID_INPUT");
|
|
134
166
|
|
|
135
|
-
// Validate: entity kinds require identity_key
|
|
136
167
|
if (categoryFor(kind) === "entity" && !identity_key) {
|
|
137
168
|
return err(`Entity kind "${kind}" requires identity_key`, "MISSING_IDENTITY_KEY");
|
|
138
169
|
}
|
|
@@ -144,7 +175,117 @@ export function registerTools(server, ctx) {
|
|
|
144
175
|
const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
|
|
145
176
|
|
|
146
177
|
const entry = await captureAndIndex(ctx, { kind, title, body, meta: finalMeta, tags, source, folder, identity_key, expires_at }, indexEntry);
|
|
147
|
-
|
|
178
|
+
const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
|
|
179
|
+
const parts = [`✓ Saved ${kind} → ${relPath}`, ` id: ${entry.id}`];
|
|
180
|
+
if (title) parts.push(` title: ${title}`);
|
|
181
|
+
if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
|
|
182
|
+
return ok(parts.join("\n"));
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// ─── list_context (browse) ────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
server.tool(
|
|
189
|
+
"list_context",
|
|
190
|
+
"Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at. Use get_context with a query for semantic search.",
|
|
191
|
+
{
|
|
192
|
+
kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
|
|
193
|
+
category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
|
|
194
|
+
tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
|
|
195
|
+
since: z.string().optional().describe("ISO date, return entries created after this"),
|
|
196
|
+
until: z.string().optional().describe("ISO date, return entries created before this"),
|
|
197
|
+
limit: z.number().optional().describe("Max results to return (default 20, max 100)"),
|
|
198
|
+
offset: z.number().optional().describe("Skip first N results for pagination"),
|
|
199
|
+
},
|
|
200
|
+
async ({ kind, category, tags, since, until, limit, offset }) => {
|
|
201
|
+
await ensureIndexed();
|
|
202
|
+
|
|
203
|
+
const clauses = [];
|
|
204
|
+
const params = [];
|
|
205
|
+
|
|
206
|
+
if (kind) {
|
|
207
|
+
clauses.push("kind = ?");
|
|
208
|
+
params.push(normalizeKind(kind));
|
|
209
|
+
}
|
|
210
|
+
if (category) {
|
|
211
|
+
clauses.push("category = ?");
|
|
212
|
+
params.push(category);
|
|
213
|
+
}
|
|
214
|
+
if (since) {
|
|
215
|
+
clauses.push("created_at >= ?");
|
|
216
|
+
params.push(since);
|
|
217
|
+
}
|
|
218
|
+
if (until) {
|
|
219
|
+
clauses.push("created_at <= ?");
|
|
220
|
+
params.push(until);
|
|
221
|
+
}
|
|
222
|
+
clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
|
|
223
|
+
|
|
224
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
225
|
+
const effectiveLimit = Math.min(limit || 20, 100);
|
|
226
|
+
const effectiveOffset = offset || 0;
|
|
227
|
+
|
|
228
|
+
const countParams = [...params];
|
|
229
|
+
const total = ctx.db.prepare(`SELECT COUNT(*) as c FROM vault ${where}`).get(...countParams).c;
|
|
230
|
+
|
|
231
|
+
params.push(effectiveLimit, effectiveOffset);
|
|
232
|
+
const rows = ctx.db.prepare(`SELECT id, title, kind, category, tags, created_at FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
|
|
233
|
+
|
|
234
|
+
// Post-filter by tags if provided
|
|
235
|
+
const filtered = tags?.length
|
|
236
|
+
? rows.filter((r) => {
|
|
237
|
+
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
238
|
+
return tags.some((t) => entryTags.includes(t));
|
|
239
|
+
})
|
|
240
|
+
: rows;
|
|
241
|
+
|
|
242
|
+
if (!filtered.length) return ok("No entries found matching the given filters.");
|
|
243
|
+
|
|
244
|
+
const lines = [`## Vault Entries (${filtered.length} shown, ${total} total)\n`];
|
|
245
|
+
for (const r of filtered) {
|
|
246
|
+
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
247
|
+
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
248
|
+
lines.push(`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${r.created_at} — \`${r.id}\``);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (effectiveOffset + effectiveLimit < total) {
|
|
252
|
+
lines.push(`\n_Page ${Math.floor(effectiveOffset / effectiveLimit) + 1}. Use offset: ${effectiveOffset + effectiveLimit} for next page._`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return ok(lines.join("\n"));
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ─── delete_context (remove) ──────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
server.tool(
|
|
262
|
+
"delete_context",
|
|
263
|
+
"Delete an entry from your vault by its ULID id. Removes the file from disk and cleans up the search index.",
|
|
264
|
+
{
|
|
265
|
+
id: z.string().describe("The entry ULID to delete"),
|
|
266
|
+
},
|
|
267
|
+
async ({ id }) => {
|
|
268
|
+
if (!id?.trim()) return err("Required: id (non-empty string)", "INVALID_INPUT");
|
|
269
|
+
await ensureIndexed();
|
|
270
|
+
|
|
271
|
+
const entry = ctx.stmts.getEntryById.get(id);
|
|
272
|
+
if (!entry) return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
273
|
+
|
|
274
|
+
// Delete file from disk first (source of truth)
|
|
275
|
+
if (entry.file_path) {
|
|
276
|
+
try { unlinkSync(entry.file_path); } catch {}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Delete vector embedding
|
|
280
|
+
const rowidResult = ctx.stmts.getRowid.get(id);
|
|
281
|
+
if (rowidResult?.rowid) {
|
|
282
|
+
try { ctx.deleteVec(Number(rowidResult.rowid)); } catch {}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Delete DB row (FTS trigger handles FTS cleanup)
|
|
286
|
+
ctx.stmts.deleteEntry.run(id);
|
|
287
|
+
|
|
288
|
+
return ok(`Deleted ${entry.kind}: ${entry.title || "(untitled)"} [${id}]`);
|
|
148
289
|
}
|
|
149
290
|
);
|
|
150
291
|
|
|
@@ -157,20 +298,29 @@ export function registerTools(server, ctx) {
|
|
|
157
298
|
() => {
|
|
158
299
|
const status = gatherVaultStatus(ctx);
|
|
159
300
|
|
|
301
|
+
const hasIssues = status.stalePaths || (status.embeddingStatus?.missing > 0);
|
|
302
|
+
const healthIcon = hasIssues ? "⚠" : "✓";
|
|
303
|
+
|
|
160
304
|
const lines = [
|
|
161
|
-
`## Vault Status`,
|
|
305
|
+
`## ${healthIcon} Vault Status`,
|
|
162
306
|
``,
|
|
163
|
-
`Vault: ${config.vaultDir} (
|
|
164
|
-
`Database: ${config.dbPath} (
|
|
307
|
+
`Vault: ${config.vaultDir} (${config.vaultDirExists ? status.fileCount + " files" : "missing"})`,
|
|
308
|
+
`Database: ${config.dbPath} (${status.dbSize})`,
|
|
165
309
|
`Dev dir: ${config.devDir}`,
|
|
166
310
|
`Data dir: ${config.dataDir}`,
|
|
167
311
|
`Config: ${config.configPath}`,
|
|
168
312
|
`Resolved via: ${status.resolvedFrom}`,
|
|
169
313
|
`Schema: v5 (categories)`,
|
|
170
|
-
``,
|
|
171
|
-
`### Indexed`,
|
|
172
314
|
];
|
|
173
315
|
|
|
316
|
+
if (status.embeddingStatus) {
|
|
317
|
+
const { indexed, total, missing } = status.embeddingStatus;
|
|
318
|
+
const pct = total > 0 ? Math.round((indexed / total) * 100) : 100;
|
|
319
|
+
lines.push(`Embeddings: ${indexed}/${total} (${pct}%)`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
lines.push(``, `### Indexed`);
|
|
323
|
+
|
|
174
324
|
if (status.kindCounts.length) {
|
|
175
325
|
for (const { kind, c } of status.kindCounts) lines.push(`- ${c} ${kind}s`);
|
|
176
326
|
} else {
|
|
@@ -191,20 +341,11 @@ export function registerTools(server, ctx) {
|
|
|
191
341
|
|
|
192
342
|
if (status.stalePaths) {
|
|
193
343
|
lines.push(``);
|
|
194
|
-
lines.push(`### Stale Paths
|
|
344
|
+
lines.push(`### ⚠ Stale Paths`);
|
|
195
345
|
lines.push(`DB contains ${status.staleCount} paths not matching current vault dir.`);
|
|
196
346
|
lines.push(`Auto-reindex will fix this on next search or save.`);
|
|
197
347
|
}
|
|
198
348
|
|
|
199
|
-
if (status.embeddingStatus) {
|
|
200
|
-
const { indexed, total, missing } = status.embeddingStatus;
|
|
201
|
-
if (missing > 0) {
|
|
202
|
-
lines.push(``);
|
|
203
|
-
lines.push(`### Embeddings`);
|
|
204
|
-
lines.push(`${indexed}/${total} entries have embeddings (${missing} missing)`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
349
|
return ok(lines.join("\n"));
|
|
209
350
|
}
|
|
210
351
|
);
|