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 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 three tools. Your AI agent calls them automatically — you don't invoke them directly.
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 any kind of knowledge to the vault |
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-mcp")} v${VERSION} — Persistent memory for AI agents
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(" context-mcp") + dim(` v${VERSION}`));
184
- console.log(dim(" Persistent memory for AI agents — saves and searches knowledge across sessions"));
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(" Detecting installed tools...\n"));
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(" Downloading embedding model...")}`);
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(" Configuring tools...\n")}`);
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
- const ok = results.filter((r) => r.ok);
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: ok.length > 0 },
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("+") : red("x")} ${c.label}`);
372
+ console.log(` ${c.pass ? green("") : red("")} ${c.label}`);
373
373
  }
374
374
 
375
- // First-use guidance
376
- const toolName = ok.length ? ok[0].tool.name : "your AI tool";
377
- console.log(bold("\n What to do next:\n"));
378
- console.log(` 1. Open ${toolName}`);
379
- console.log(` 2. Try: ${cyan('"Search my vault for getting started"')}`);
380
- console.log(` 3. Try: ${cyan('"Save an insight: JavaScript Date objects are mutable"')}`);
381
- console.log(`\n Vault: ${resolvedVaultDir}`);
382
- console.log(` Dashboard: ${cyan("context-mcp ui")}`);
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(` Added: ${stats.added}`);
542
- console.log(` Updated: ${stats.updated}`);
543
- console.log(` Removed: ${stats.removed}`);
544
- console.log(` Unchanged: ${stats.unchanged}`);
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(" Vault Status"));
570
+ console.log(` ${bold(" context-vault")} ${dim(`v${VERSION}`)}`);
563
571
  console.log();
564
- console.log(` Vault: ${config.vaultDir} (exists: ${config.vaultDirExists}, ${status.fileCount} files)`);
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} (exists: ${existsSync(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
- console.log(` ${c} ${kind}s`);
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.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
- "description": "Personal context vault connects any AI agent to your accumulated knowledge",
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",
@@ -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 (?, ?)`),
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * tools.js — MCP tool registrations
3
3
  *
4
- * Three tools: save_context (write), get_context (read), context_status (diag).
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 (read) ────────────────────────────────────────────────────
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 (const r of filtered) {
102
- const meta = r.meta ? JSON.parse(r.meta) : {};
103
- lines.push(`### ${r.title || "(untitled)"} [${r.kind}/${r.category}]`);
104
- lines.push(`Score: ${r.score.toFixed(3)} | Tags: ${r.tags || "none"} | File: ${r.file_path || "n/a"}`);
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
- kind: z.string().describe("Entry kind determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind)"),
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 (non-empty string)", "INVALID_INPUT");
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
- return ok(`Saved ${kind} ${entry.id}\nFile: ${entry.filePath}${title ? "\nTitle: " + title : ""}`);
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} (exists: ${config.vaultDirExists}, ${status.fileCount} files)`,
164
- `Database: ${config.dbPath} (exists: ${existsSync(config.dbPath)}, ${status.dbSize})`,
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 Detected`);
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
  );