context-vault 3.4.4 → 3.5.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.
Files changed (86) hide show
  1. package/assets/agent-rules.md +50 -0
  2. package/assets/setup-prompt.md +58 -0
  3. package/assets/skills/vault-setup/skill.md +81 -0
  4. package/bin/cli.js +753 -126
  5. package/dist/helpers.d.ts +2 -0
  6. package/dist/helpers.d.ts.map +1 -1
  7. package/dist/helpers.js +23 -0
  8. package/dist/helpers.js.map +1 -1
  9. package/dist/server.js +26 -2
  10. package/dist/server.js.map +1 -1
  11. package/dist/status.d.ts.map +1 -1
  12. package/dist/status.js +29 -0
  13. package/dist/status.js.map +1 -1
  14. package/dist/tools/context-status.d.ts.map +1 -1
  15. package/dist/tools/context-status.js +64 -29
  16. package/dist/tools/context-status.js.map +1 -1
  17. package/dist/tools/get-context.js +23 -18
  18. package/dist/tools/get-context.js.map +1 -1
  19. package/dist/tools/list-context.d.ts +2 -1
  20. package/dist/tools/list-context.d.ts.map +1 -1
  21. package/dist/tools/list-context.js +27 -10
  22. package/dist/tools/list-context.js.map +1 -1
  23. package/dist/tools/save-context.d.ts +2 -1
  24. package/dist/tools/save-context.d.ts.map +1 -1
  25. package/dist/tools/save-context.js +95 -26
  26. package/dist/tools/save-context.js.map +1 -1
  27. package/dist/tools/session-start.d.ts.map +1 -1
  28. package/dist/tools/session-start.js +230 -11
  29. package/dist/tools/session-start.js.map +1 -1
  30. package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
  31. package/node_modules/@context-vault/core/dist/capture.js +13 -0
  32. package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
  33. package/node_modules/@context-vault/core/dist/config.d.ts +8 -0
  34. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  35. package/node_modules/@context-vault/core/dist/config.js +47 -2
  36. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  37. package/node_modules/@context-vault/core/dist/constants.d.ts +13 -0
  38. package/node_modules/@context-vault/core/dist/constants.d.ts.map +1 -1
  39. package/node_modules/@context-vault/core/dist/constants.js +13 -0
  40. package/node_modules/@context-vault/core/dist/constants.js.map +1 -1
  41. package/node_modules/@context-vault/core/dist/db.d.ts +1 -1
  42. package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
  43. package/node_modules/@context-vault/core/dist/db.js +73 -9
  44. package/node_modules/@context-vault/core/dist/db.js.map +1 -1
  45. package/node_modules/@context-vault/core/dist/frontmatter.d.ts.map +1 -1
  46. package/node_modules/@context-vault/core/dist/frontmatter.js +2 -0
  47. package/node_modules/@context-vault/core/dist/frontmatter.js.map +1 -1
  48. package/node_modules/@context-vault/core/dist/index.d.ts +4 -1
  49. package/node_modules/@context-vault/core/dist/index.d.ts.map +1 -1
  50. package/node_modules/@context-vault/core/dist/index.js +58 -10
  51. package/node_modules/@context-vault/core/dist/index.js.map +1 -1
  52. package/node_modules/@context-vault/core/dist/indexing.d.ts +8 -0
  53. package/node_modules/@context-vault/core/dist/indexing.d.ts.map +1 -0
  54. package/node_modules/@context-vault/core/dist/indexing.js +22 -0
  55. package/node_modules/@context-vault/core/dist/indexing.js.map +1 -0
  56. package/node_modules/@context-vault/core/dist/main.d.ts +3 -2
  57. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  58. package/node_modules/@context-vault/core/dist/main.js +3 -1
  59. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  60. package/node_modules/@context-vault/core/dist/search.d.ts +2 -0
  61. package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
  62. package/node_modules/@context-vault/core/dist/search.js +82 -6
  63. package/node_modules/@context-vault/core/dist/search.js.map +1 -1
  64. package/node_modules/@context-vault/core/dist/types.d.ts +24 -0
  65. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  66. package/node_modules/@context-vault/core/package.json +5 -1
  67. package/node_modules/@context-vault/core/src/capture.ts +11 -0
  68. package/node_modules/@context-vault/core/src/config.ts +40 -2
  69. package/node_modules/@context-vault/core/src/constants.ts +15 -0
  70. package/node_modules/@context-vault/core/src/db.ts +73 -9
  71. package/node_modules/@context-vault/core/src/frontmatter.ts +2 -0
  72. package/node_modules/@context-vault/core/src/index.ts +65 -11
  73. package/node_modules/@context-vault/core/src/indexing.ts +35 -0
  74. package/node_modules/@context-vault/core/src/main.ts +5 -0
  75. package/node_modules/@context-vault/core/src/search.ts +96 -6
  76. package/node_modules/@context-vault/core/src/types.ts +26 -0
  77. package/package.json +2 -2
  78. package/scripts/prepack.js +17 -0
  79. package/src/helpers.ts +25 -0
  80. package/src/server.ts +28 -2
  81. package/src/status.ts +35 -0
  82. package/src/tools/context-status.ts +65 -30
  83. package/src/tools/get-context.ts +24 -24
  84. package/src/tools/list-context.ts +25 -13
  85. package/src/tools/save-context.ts +106 -29
  86. package/src/tools/session-start.ts +257 -13
package/src/helpers.ts CHANGED
@@ -58,4 +58,29 @@ export function ensureValidKind(kind: string): ToolResult | null {
58
58
  return null;
59
59
  }
60
60
 
61
+ const KIND_ICONS: Record<string, string> = {
62
+ insight: '◆',
63
+ decision: '◇',
64
+ pattern: '◈',
65
+ reference: '▸',
66
+ event: '○',
67
+ session: '◎',
68
+ brief: '▪',
69
+ bucket: '▫',
70
+ contact: '●',
71
+ project: '■',
72
+ tool: '▹',
73
+ source: '►',
74
+ feedback: '◉',
75
+ };
76
+
77
+ export function kindIcon(kind: string): string {
78
+ return KIND_ICONS[kind] || '·';
79
+ }
80
+
81
+ export function fmtDate(date: string | null | undefined): string {
82
+ if (!date) return '';
83
+ return date.split('T')[0];
84
+ }
85
+
61
86
  export { pkg };
package/src/server.ts CHANGED
@@ -7,9 +7,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
7
7
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
9
9
  import type { IncomingMessage, ServerResponse } from 'node:http';
10
- import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
10
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync } from 'node:fs';
11
11
  import { join, dirname } from 'node:path';
12
- import { homedir } from 'node:os';
12
+ import { homedir, tmpdir } from 'node:os';
13
13
  import { fileURLToPath } from 'node:url';
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -31,6 +31,7 @@ import {
31
31
  } from '@context-vault/core/db';
32
32
  import { registerTools } from './register-tools.js';
33
33
  import { pruneExpired } from '@context-vault/core/index';
34
+ import { setSessionId } from '@context-vault/core/search';
34
35
 
35
36
  const DAEMON_PORT = 3377;
36
37
  const PID_PATH = join(homedir(), '.context-mcp', 'daemon.pid');
@@ -312,6 +313,29 @@ async function main(): Promise<void> {
312
313
 
313
314
  config.vaultDirExists = existsSync(config.vaultDir);
314
315
 
316
+ // Validate vaultDir sanity
317
+ const osTmp = tmpdir();
318
+ const isTempPath = config.vaultDir.startsWith(osTmp) ||
319
+ config.vaultDir.startsWith('/tmp/') ||
320
+ config.vaultDir.startsWith('/var/folders/');
321
+ if (isTempPath) {
322
+ console.error(`[context-vault] WARNING: vaultDir points to a temp directory: ${config.vaultDir}`);
323
+ console.error(`[context-vault] This is likely from a test run that overwrote ~/.context-mcp/config.json`);
324
+ console.error(`[context-vault] Fix: run 'context-vault reconnect' or 'context-vault setup'`);
325
+ }
326
+
327
+ if (config.vaultDirExists) {
328
+ try {
329
+ const entries = readdirSync(config.vaultDir);
330
+ const hasMdFiles = entries.some(f => f.endsWith('.md'));
331
+ const hasMarker = entries.includes('.context-mcp');
332
+ if (!hasMdFiles && hasMarker) {
333
+ console.error(`[context-vault] WARNING: vaultDir has no markdown files but has a marker file`);
334
+ console.error(`[context-vault] The vault may be misconfigured. Run 'context-vault reconnect'`);
335
+ }
336
+ } catch {}
337
+ }
338
+
315
339
  console.error(`[context-vault] Vault: ${config.vaultDir}`);
316
340
  console.error(`[context-vault] Database: ${config.dbPath}`);
317
341
  console.error(`[context-vault] Dev dir: ${config.devDir}`);
@@ -336,6 +360,8 @@ async function main(): Promise<void> {
336
360
  toolStats: { ok: 0, errors: 0, lastError: null },
337
361
  };
338
362
 
363
+ setSessionId(randomUUID());
364
+
339
365
  try {
340
366
  const pruned = await pruneExpired(ctx);
341
367
  if (pruned > 0) {
package/src/status.ts CHANGED
@@ -148,6 +148,20 @@ export function gatherVaultStatus(ctx: LocalCtx, opts: Record<string, unknown> =
148
148
  errors.push(`Archived count failed: ${(e as Error).message}`);
149
149
  }
150
150
 
151
+ let indexingStats: { indexed: number; unindexed: number; total: number; byKind: Array<{ kind: string; indexed: number; total: number }> } | null = null;
152
+ try {
153
+ const totalRow = db.prepare('SELECT COUNT(*) as c FROM vault').get() as { c: number } | undefined;
154
+ const indexedRow = db.prepare('SELECT COUNT(*) as c FROM vault WHERE indexed = 1').get() as { c: number } | undefined;
155
+ const total = totalRow?.c ?? 0;
156
+ const indexed = indexedRow?.c ?? 0;
157
+ const byKind = db.prepare(
158
+ 'SELECT kind, COUNT(*) as total, SUM(CASE WHEN indexed = 1 THEN 1 ELSE 0 END) as indexed FROM vault GROUP BY kind'
159
+ ).all() as Array<{ kind: string; total: number; indexed: number }>;
160
+ indexingStats = { indexed, unindexed: total - indexed, total, byKind };
161
+ } catch (e) {
162
+ errors.push(`Indexing stats failed: ${(e as Error).message}`);
163
+ }
164
+
151
165
  let staleKnowledge: unknown[] = [];
152
166
  try {
153
167
  const stalenessKinds = Object.entries(KIND_STALENESS_DAYS);
@@ -168,6 +182,25 @@ export function gatherVaultStatus(ctx: LocalCtx, opts: Record<string, unknown> =
168
182
  errors.push(`Stale knowledge check failed: ${(e as Error).message}`);
169
183
  }
170
184
 
185
+ let recallStats: { totalRecalls: number; entriesRecalled: number; avgRecallCount: number; maxRecallCount: number; topRecalled: Array<{ title: string; kind: string; recall_count: number; recall_sessions: number }> } | null = null;
186
+ try {
187
+ const totals = db.prepare(
188
+ `SELECT SUM(recall_count) as total_recalls, COUNT(CASE WHEN recall_count > 0 THEN 1 END) as entries_recalled, AVG(CASE WHEN recall_count > 0 THEN recall_count END) as avg_recall, MAX(recall_count) as max_recall FROM vault`
189
+ ).get() as any;
190
+ const topRecalled = db.prepare(
191
+ `SELECT title, kind, recall_count, recall_sessions FROM vault WHERE recall_count > 0 ORDER BY recall_count DESC LIMIT 5`
192
+ ).all() as any[];
193
+ recallStats = {
194
+ totalRecalls: totals?.total_recalls ?? 0,
195
+ entriesRecalled: totals?.entries_recalled ?? 0,
196
+ avgRecallCount: Math.round((totals?.avg_recall ?? 0) * 10) / 10,
197
+ maxRecallCount: totals?.max_recall ?? 0,
198
+ topRecalled,
199
+ };
200
+ } catch (e) {
201
+ errors.push(`Recall stats failed: ${(e as Error).message}`);
202
+ }
203
+
171
204
  return {
172
205
  fileCount,
173
206
  subdirs,
@@ -185,6 +218,8 @@ export function gatherVaultStatus(ctx: LocalCtx, opts: Record<string, unknown> =
185
218
  autoCapturedFeedbackCount,
186
219
  archivedCount,
187
220
  staleKnowledge,
221
+ indexingStats,
222
+ recallStats,
188
223
  resolvedFrom: config.resolvedFrom,
189
224
  errors,
190
225
  };
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { gatherVaultStatus, computeGrowthWarnings } from '../status.js';
4
4
  import { errorLogPath, errorLogCount } from '../error-log.js';
5
- import { ok, err } from '../helpers.js';
5
+ import { ok, err, kindIcon } from '../helpers.js';
6
6
  import type { LocalCtx, ToolResult } from '../types.js';
7
7
 
8
8
  function relativeTime(ts: number): string {
@@ -30,53 +30,88 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
30
30
  const hasIssues = status.stalePaths || (status.embeddingStatus?.missing ?? 0) > 0;
31
31
  const healthIcon = hasIssues ? '⚠' : '✓';
32
32
 
33
+ const schemaVersion = (ctx.db.prepare('PRAGMA user_version').get() as any)?.user_version ?? 'unknown';
34
+ const embedPct = status.embeddingStatus
35
+ ? (status.embeddingStatus.total > 0 ? Math.round((status.embeddingStatus.indexed / status.embeddingStatus.total) * 100) : 100)
36
+ : null;
37
+ const embedStr = status.embeddingStatus
38
+ ? `${status.embeddingStatus.indexed}/${status.embeddingStatus.total} (${embedPct}%)`
39
+ : 'n/a';
40
+ const modelStr = status.embedModelAvailable === false ? '⚠ unavailable' : status.embedModelAvailable === true ? '✓ loaded' : 'unknown';
41
+
33
42
  const lines = [
34
- `## ${healthIcon} Vault Status (connected)`,
43
+ `## ${healthIcon} Vault Dashboard`,
35
44
  ``,
36
- `Vault: ${config.vaultDir} (${config.vaultDirExists ? status.fileCount + ' files' : 'missing'})`,
37
- `Database: ${config.dbPath} (${status.dbSize})`,
38
- `Dev dir: ${config.devDir}`,
39
- `Data dir: ${config.dataDir}`,
40
- `Config: ${config.configPath}`,
41
- `Resolved via: ${status.resolvedFrom}`,
42
- `Schema: v9 (updated_at, superseded_by)`,
45
+ `| | |`,
46
+ `|---|---|`,
47
+ `| **Vault** | ${config.vaultDir} (${config.vaultDirExists ? status.fileCount + ' files' : '⚠ missing'}) |`,
48
+ `| **Database** | ${config.dbPath} (${status.dbSize}) |`,
49
+ `| **Schema** | v${schemaVersion} |`,
50
+ `| **Embeddings** | ${embedStr} |`,
51
+ `| **Model** | ${modelStr} |`,
52
+ `| **Event decay** | ${config.eventDecayDays} days |`,
43
53
  ];
44
-
45
- if (status.embeddingStatus) {
46
- const { indexed, total, missing } = status.embeddingStatus;
47
- const pct = total > 0 ? Math.round((indexed / total) * 100) : 100;
48
- lines.push(`Embeddings: ${indexed}/${total} (${pct}%)`);
49
- }
50
- if (status.embedModelAvailable === false) {
51
- lines.push(`Embed model: unavailable (semantic search disabled, FTS still works)`);
52
- } else if (status.embedModelAvailable === true) {
53
- lines.push(`Embed model: loaded`);
54
- }
55
- lines.push(`Decay: ${config.eventDecayDays} days (event recency window)`);
56
54
  if (status.expiredCount > 0) {
57
- lines.push(
58
- `Expired: ${status.expiredCount} entries pending prune (run \`context-vault prune\` to remove now)`
59
- );
55
+ lines.push(`| **Expired** | ${status.expiredCount} pending prune |`);
60
56
  }
61
57
 
62
- lines.push(``, `### Indexed`);
58
+ // Selective indexing stats
59
+ if (status.indexingStats) {
60
+ const ix = status.indexingStats;
61
+ const pct = ix.total > 0 ? Math.round((ix.indexed / ix.total) * 100) : 100;
62
+ lines.push(`| **Indexed entries** | ${ix.indexed}/${ix.total} (${pct}%) |`);
63
+ }
63
64
 
65
+ // Indexed kinds as compact table
66
+ lines.push(``, `### Entries by Kind`);
64
67
  if (status.kindCounts.length) {
65
- for (const { kind, c } of status.kindCounts) lines.push(`- ${c} ${kind}s`);
68
+ if (status.indexingStats?.byKind?.length) {
69
+ lines.push('| Kind | Total | Indexed |');
70
+ lines.push('|---|---|---|');
71
+ const byKindMap = new Map(status.indexingStats.byKind.map((k: any) => [k.kind, k]));
72
+ for (const { kind, c } of status.kindCounts) {
73
+ const kStats = byKindMap.get(kind) as { indexed?: number } | undefined;
74
+ const idxCount = kStats?.indexed ?? c;
75
+ lines.push(`| ${kindIcon(kind)} \`${kind}\` | ${c} | ${idxCount} |`);
76
+ }
77
+ } else {
78
+ lines.push('| Kind | Count |');
79
+ lines.push('|---|---|');
80
+ for (const { kind, c } of status.kindCounts) {
81
+ lines.push(`| ${kindIcon(kind)} \`${kind}\` | ${c} |`);
82
+ }
83
+ }
66
84
  } else {
67
- lines.push(`- (empty)`);
85
+ lines.push(`_(empty vault)_`);
68
86
  }
69
87
 
70
88
  if (status.categoryCounts.length) {
71
89
  lines.push(``);
72
90
  lines.push(`### Categories`);
73
- for (const { category, c } of status.categoryCounts) lines.push(`- ${category}: ${c}`);
91
+ for (const { category, c } of status.categoryCounts) lines.push(`- **${category}**: ${c}`);
74
92
  }
75
93
 
76
94
  if (status.subdirs.length) {
77
95
  lines.push(``);
78
- lines.push(`### Disk Directories`);
79
- for (const { name, count } of status.subdirs) lines.push(`- ${name}/: ${count} files`);
96
+ lines.push(`### Disk`);
97
+ for (const { name, count } of status.subdirs) lines.push(`- \`${name}/\` ${count} files`);
98
+ }
99
+
100
+ if (status.recallStats && status.recallStats.totalRecalls > 0) {
101
+ const rs = status.recallStats;
102
+ lines.push(``, `### Recall Frequency`);
103
+ lines.push(`| Metric | Value |`);
104
+ lines.push(`|---|---|`);
105
+ lines.push(`| **Total recalls** | ${rs.totalRecalls} |`);
106
+ lines.push(`| **Entries recalled** | ${rs.entriesRecalled} |`);
107
+ lines.push(`| **Avg recalls/entry** | ${rs.avgRecallCount} |`);
108
+ lines.push(`| **Max recalls** | ${rs.maxRecallCount} |`);
109
+ if (rs.topRecalled?.length) {
110
+ lines.push(``, `**Top recalled:**`);
111
+ for (const t of rs.topRecalled) {
112
+ lines.push(`- "${t.title || '(untitled)'}" (\`${t.kind}\`): ${t.recall_count} recalls, ${t.recall_sessions} sessions`);
113
+ }
114
+ }
80
115
  }
81
116
 
82
117
  if (status.stalePaths) {
@@ -8,7 +8,7 @@ import { normalizeKind } from '@context-vault/core/files';
8
8
  import { parseContextParam } from '@context-vault/core/context';
9
9
  import { resolveTemporalParams } from '../temporal.js';
10
10
  import { collectLinkedEntries } from '../linking.js';
11
- import { ok, err, errWithHint } from '../helpers.js';
11
+ import { ok, err, errWithHint, kindIcon, fmtDate } from '../helpers.js';
12
12
  import { isEmbedAvailable } from '@context-vault/core/embed';
13
13
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
14
14
 
@@ -526,6 +526,7 @@ export async function handler(
526
526
  if (!include_superseded) {
527
527
  clauses.push('superseded_by IS NULL');
528
528
  }
529
+ clauses.push('indexed = 1');
529
530
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
530
531
  params.push(fetchLimit);
531
532
  let rows;
@@ -633,23 +634,24 @@ export async function handler(
633
634
  const r = filtered[i];
634
635
  const isSkeleton = i >= effectivePivot;
635
636
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
636
- const tagStr = entryTags.length ? entryTags.join(', ') : 'none';
637
- const relPath =
638
- r.file_path && config.vaultDir
639
- ? r.file_path.replace(config.vaultDir + '/', '')
640
- : r.file_path || 'n/a';
641
- const skeletonLabel = isSkeleton ? ' skeleton' : '';
637
+ const tagStr = entryTags.length ? entryTags.join(', ') : '';
638
+ const icon = kindIcon(r.kind);
639
+ const skeletonLabel = isSkeleton ? ' `skeleton`' : '';
640
+ const tierLabel = r.tier ? `**${r.tier}**` : '';
641
+ const dateStr = r.updated_at && r.updated_at !== r.created_at
642
+ ? `${fmtDate(r.created_at)} (upd ${fmtDate(r.updated_at)})`
643
+ : fmtDate(r.created_at);
642
644
  lines.push(
643
- `### [${i + 1}/${filtered.length}] ${r.title || '(untitled)'} [${r.kind}/${r.category}]${skeletonLabel}`
644
- );
645
- const dateStr =
646
- r.updated_at && r.updated_at !== r.created_at
647
- ? `${r.created_at} (updated ${r.updated_at})`
648
- : r.created_at || '';
649
- const tierStr = r.tier ? ` · tier: ${r.tier}` : '';
650
- lines.push(
651
- `${r.score.toFixed(3)} · ${tagStr} · ${relPath} · ${dateStr} · skeleton: ${isSkeleton}${tierStr} · id: \`${r.id}\``
645
+ `### ${icon} ${r.title || '(untitled)'}${skeletonLabel}`
652
646
  );
647
+ const meta = [
648
+ `\`${r.score.toFixed(2)}\``,
649
+ `\`${r.kind}\``,
650
+ tierLabel,
651
+ tagStr,
652
+ dateStr,
653
+ ].filter(Boolean).join(' · ');
654
+ lines.push(`${meta} \nid: \`${r.id}\``);
653
655
  const stalenessResult = checkStaleness(r);
654
656
  if (stalenessResult) {
655
657
  r.stale = true;
@@ -691,15 +693,13 @@ export async function handler(
691
693
  if (uniqueLinked.length > 0) {
692
694
  lines.push(`## Linked Entries (${uniqueLinked.length} via related_to)\n`);
693
695
  for (const r of uniqueLinked) {
694
- const direction = forward.some((f: any) => f.id === r.id) ? '→ forward' : '← backlink';
696
+ const direction = forward.some((f: any) => f.id === r.id) ? '→' : '←';
695
697
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
696
- const tagStr = entryTags.length ? entryTags.join(', ') : 'none';
697
- const relPath =
698
- r.file_path && config.vaultDir
699
- ? r.file_path.replace(config.vaultDir + '/', '')
700
- : r.file_path || 'n/a';
701
- lines.push(`### ${r.title || '(untitled)'} [${r.kind}/${r.category}] ${direction}`);
702
- lines.push(`${tagStr} · ${relPath} · id: \`${r.id}\``);
698
+ const tagStr = entryTags.length ? entryTags.join(', ') : '';
699
+ const icon = kindIcon(r.kind);
700
+ lines.push(`### ${icon} ${r.title || '(untitled)'} ${direction}`);
701
+ const meta = [`\`${r.kind}\``, tagStr].filter(Boolean).join(' · ');
702
+ lines.push(`${meta} \nid: \`${r.id}\``);
703
703
  lines.push(truncateBody(r.body, body_limit ?? 300));
704
704
  lines.push('');
705
705
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { normalizeKind } from '@context-vault/core/files';
3
3
  import { categoryFor } from '@context-vault/core/categories';
4
- import { ok, err, errWithHint } from '../helpers.js';
4
+ import { ok, err, errWithHint, kindIcon, fmtDate } from '../helpers.js';
5
5
  import { resolveTemporalParams } from '../temporal.js';
6
6
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
7
7
 
@@ -18,10 +18,11 @@ export const inputSchema = {
18
18
  until: z.string().optional().describe('ISO date, return entries created before this'),
19
19
  limit: z.number().optional().describe('Max results to return (default 20, max 100)'),
20
20
  offset: z.number().optional().describe('Skip first N results for pagination'),
21
+ include_unindexed: z.boolean().optional().describe('If true, include entries that are stored but not indexed for search. Default: false.'),
21
22
  };
22
23
 
23
24
  export async function handler(
24
- { kind, category, tags, since, until, limit, offset }: Record<string, any>,
25
+ { kind, category, tags, since, until, limit, offset, include_unindexed }: Record<string, any>,
25
26
  ctx: LocalCtx,
26
27
  { ensureIndexed, reindexFailed }: SharedCtx
27
28
  ): Promise<ToolResult> {
@@ -63,6 +64,9 @@ export async function handler(
63
64
  params.push(until);
64
65
  }
65
66
  clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
67
+ if (!include_unindexed) {
68
+ clauses.push('indexed = 1');
69
+ }
66
70
 
67
71
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
68
72
  const effectiveLimit = Math.min(limit || 20, 100);
@@ -81,7 +85,7 @@ export async function handler(
81
85
  params.push(fetchLimit, effectiveOffset);
82
86
  rows = ctx.db
83
87
  .prepare(
84
- `SELECT id, title, kind, category, tags, created_at, updated_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
88
+ `SELECT id, title, kind, category, tags, created_at, updated_at, indexed, recall_count, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
85
89
  )
86
90
  .all(...params) as any[];
87
91
  } catch (e) {
@@ -124,18 +128,26 @@ export async function handler(
124
128
  `> ℹ Event search limited to last ${days} days. Use \`since\` parameter for older results.\n`
125
129
  );
126
130
  }
131
+ if (include_unindexed) {
132
+ lines.push('| | Title | Kind | Tags | Recalls | Idx | Date | ID |');
133
+ lines.push('|---|---|---|---|---|---|---|---|');
134
+ } else {
135
+ lines.push('| | Title | Kind | Tags | Recalls | Date | ID |');
136
+ lines.push('|---|---|---|---|---|---|---|');
137
+ }
127
138
  for (const r of filtered) {
128
139
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
129
- const tagStr = entryTags.length ? entryTags.join(', ') : 'none';
130
- const dateStr =
131
- r.updated_at && r.updated_at !== r.created_at
132
- ? `${r.created_at} (updated ${r.updated_at})`
133
- : r.created_at;
134
- lines.push(
135
- `- **${r.title || '(untitled)'}** [${r.kind}/${r.category}] — ${tagStr} — ${dateStr} — \`${r.id}\``
136
- );
137
- if (r.preview)
138
- lines.push(` ${r.preview.replace(/\n+/g, ' ').trim()}${r.preview.length >= 120 ? '…' : ''}`);
140
+ const tagStr = entryTags.length ? entryTags.join(', ') : '';
141
+ const date = fmtDate(r.updated_at && r.updated_at !== r.created_at ? r.updated_at : r.created_at);
142
+ const icon = kindIcon(r.kind);
143
+ const title = (r.title || '(untitled)').replace(/\|/g, '\\|');
144
+ const recalls = r.recall_count ?? 0;
145
+ if (include_unindexed) {
146
+ const idxStr = r.indexed ? 'Y' : 'N';
147
+ lines.push(`| ${icon} | **${title}** | \`${r.kind}\` | ${tagStr} | ${recalls} | ${idxStr} | ${date} | \`${r.id}\` |`);
148
+ } else {
149
+ lines.push(`| ${icon} | **${title}** | \`${r.kind}\` | ${tagStr} | ${recalls} | ${date} | \`${r.id}\` |`);
150
+ }
139
151
  }
140
152
 
141
153
  if (effectiveOffset + effectiveLimit < total) {
@@ -1,10 +1,14 @@
1
1
  import { z } from 'zod';
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { resolve, basename, join } from 'node:path';
4
+ import { homedir } from 'node:os';
2
5
  import { captureAndIndex, updateEntryFile } from '@context-vault/core/capture';
3
6
  import { indexEntry } from '@context-vault/core/index';
4
7
  import { categoryFor, defaultTierFor } from '@context-vault/core/categories';
5
- import { normalizeKind } from '@context-vault/core/files';
8
+ import { normalizeKind, kindToPath } from '@context-vault/core/files';
6
9
  import { parseContextParam } from '@context-vault/core/context';
7
- import { ok, err, errWithHint, ensureVaultExists, ensureValidKind } from '../helpers.js';
10
+ import { shouldIndex } from '@context-vault/core/indexing';
11
+ import { ok, err, errWithHint, ensureVaultExists, ensureValidKind, kindIcon } from '../helpers.js';
8
12
  import { maybeShowFeedbackPrompt } from '../telemetry.js';
9
13
  import { validateRelatedTo } from '../linking.js';
10
14
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
@@ -23,6 +27,34 @@ const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
23
27
  const SKIP_THRESHOLD = 0.95;
24
28
  const UPDATE_THRESHOLD = 0.85;
25
29
 
30
+ function isDualWriteEnabled(config: { dataDir: string }): boolean {
31
+ try {
32
+ const configPath = join(config.dataDir, 'config.json');
33
+ if (!existsSync(configPath)) return true;
34
+ const fc = JSON.parse(readFileSync(configPath, 'utf-8'));
35
+ if (fc.dualWrite && fc.dualWrite.enabled === false) return false;
36
+ return true;
37
+ } catch {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ function dualWriteLocal(entryFilePath: string, kind: string): void {
43
+ const cwd = process.cwd();
44
+ const home = homedir();
45
+ if (!cwd || cwd === '/' || cwd === home) return;
46
+
47
+ try {
48
+ const content = readFileSync(entryFilePath, 'utf-8');
49
+ const localDir = resolve(cwd, '.context', kindToPath(kind));
50
+ mkdirSync(localDir, { recursive: true });
51
+ const filename = basename(entryFilePath);
52
+ writeFileSync(join(localDir, filename), content);
53
+ } catch (e) {
54
+ console.warn(`[context-vault] Dual-write to .context/ failed: ${(e as Error).message}`);
55
+ }
56
+ }
57
+
26
58
  async function findSimilar(
27
59
  ctx: LocalCtx,
28
60
  embedding: any,
@@ -82,13 +114,13 @@ async function findSimilar(
82
114
  }
83
115
 
84
116
  function formatSimilarWarning(similar: any[]): string {
85
- const lines = ['', '⚠ Similar entries already exist:'];
117
+ const lines = ['', '### ⚠ Similar entries'];
86
118
  for (const e of similar) {
87
- const score = e.score.toFixed(2);
88
- const title = e.title ? `"${e.title}"` : '(no title)';
89
- lines.push(` - ${title} (${score}) id: ${e.id}`);
119
+ const score = (e.score * 100).toFixed(0);
120
+ const title = e.title ? `**${e.title}**` : '(no title)';
121
+ lines.push(`- ${title} \`${score}%\` · \`${e.id}\``);
90
122
  }
91
- lines.push(' Consider using `id: <existing>` in save_context to update instead.');
123
+ lines.push('_Use `id` param to update instead of creating a duplicate._');
92
124
  return lines.join('\n');
93
125
  }
94
126
 
@@ -142,13 +174,12 @@ export function buildConflictCandidates(similarEntries: any[]): any[] {
142
174
  }
143
175
 
144
176
  function formatConflictSuggestions(candidates: any[]): string {
145
- const lines = ['', '── Conflict Resolution Suggestions ──'];
177
+ const lines = ['', '### Conflict Resolution'];
146
178
  for (const c of candidates) {
147
- const titleDisplay = c.title ? `"${c.title}"` : '(no title)';
148
- lines.push(
149
- ` [${c.suggested_action}] ${titleDisplay} (${(c.score * 100).toFixed(0)}%) id: ${c.id}`
150
- );
151
- lines.push(` ${c.reasoning_context}`);
179
+ const titleDisplay = c.title ? `**${c.title}**` : '(no title)';
180
+ const actionIcon = c.suggested_action === 'SKIP' ? '⊘' : c.suggested_action === 'UPDATE' ? '↻' : '+';
181
+ lines.push(`${actionIcon} **${c.suggested_action}** ${titleDisplay} \`${(c.score * 100).toFixed(0)}%\` · \`${c.id}\``);
182
+ lines.push(` ${c.reasoning_context}`);
152
183
  }
153
184
  return lines.join('\n');
154
185
  }
@@ -317,6 +348,12 @@ export const inputSchema = {
317
348
  .describe(
318
349
  'Encoding context for contextual reinstatement. Captures the situation when this entry was created, enabling context-aware retrieval boosting. Pass a structured object (e.g. { project: "myapp", arc: "auth-rewrite", task: "implementing JWT" }) or a free-text string describing the current context.'
319
350
  ),
351
+ indexed: z
352
+ .boolean()
353
+ .optional()
354
+ .describe(
355
+ 'Whether to index this entry for search (generate embeddings + FTS). Default: auto-determined by indexing config. Set to false to store file + metadata only, skipping embedding generation. Set to true to force indexing regardless of config rules.'
356
+ ),
320
357
  };
321
358
 
322
359
  export async function handler(
@@ -339,6 +376,7 @@ export async function handler(
339
376
  tier,
340
377
  conflict_resolution,
341
378
  encoding_context,
379
+ indexed,
342
380
  }: Record<string, any>,
343
381
  ctx: LocalCtx,
344
382
  { ensureIndexed }: SharedCtx
@@ -448,6 +486,10 @@ export async function handler(
448
486
  } else if (entry.related_to === null && ctx.stmts.updateRelatedTo) {
449
487
  ctx.stmts.updateRelatedTo.run(null, entry.id);
450
488
  }
489
+ if (isDualWriteEnabled(config) && entry.filePath) {
490
+ dualWriteLocal(entry.filePath, entry.kind);
491
+ }
492
+
451
493
  const relPath = entry.filePath
452
494
  ? entry.filePath.replace(config.vaultDir + '/', '')
453
495
  : entry.filePath;
@@ -549,7 +591,17 @@ export async function handler(
549
591
 
550
592
  const effectiveTier = tier ?? defaultTierFor(normalizedKind);
551
593
 
552
- const embeddingToReuse = category === 'knowledge' ? queryEmbedding : null;
594
+ const effectiveIndexed = shouldIndex(
595
+ {
596
+ kind: normalizedKind,
597
+ category,
598
+ bodyLength: body?.length ?? 0,
599
+ explicitIndexed: indexed,
600
+ },
601
+ config.indexing
602
+ );
603
+
604
+ const embeddingToReuse = category === 'knowledge' && effectiveIndexed ? queryEmbedding : null;
553
605
 
554
606
  let entry;
555
607
  try {
@@ -568,8 +620,8 @@ export async function handler(
568
620
  supersedes,
569
621
  related_to,
570
622
  source_files,
571
-
572
623
  tier: effectiveTier,
624
+ indexed: effectiveIndexed,
573
625
  },
574
626
  embeddingToReuse
575
627
  );
@@ -582,7 +634,7 @@ export async function handler(
582
634
  }
583
635
 
584
636
  // Store context embedding in vault_ctx_vec for contextual reinstatement
585
- if (parsedCtx?.text && entry) {
637
+ if (parsedCtx?.text && entry && effectiveIndexed) {
586
638
  try {
587
639
  const ctxEmbedding = await ctx.embed(parsedCtx.text);
588
640
  if (ctxEmbedding) {
@@ -599,6 +651,10 @@ export async function handler(
599
651
  }
600
652
  }
601
653
 
654
+ if (isDualWriteEnabled(config) && entry.filePath) {
655
+ dualWriteLocal(entry.filePath, normalizedKind);
656
+ }
657
+
602
658
  if (ctx.config?.dataDir) {
603
659
  maybeShowFeedbackPrompt(ctx.config.dataDir);
604
660
  }
@@ -606,11 +662,25 @@ export async function handler(
606
662
  const relPath = entry.filePath
607
663
  ? entry.filePath.replace(config.vaultDir + '/', '')
608
664
  : entry.filePath;
609
- const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
610
- if (title) parts.push(` title: ${title}`);
611
- if (tags?.length) parts.push(` tags: ${tags.join(', ')}`);
612
- parts.push(` tier: ${effectiveTier}`);
613
- parts.push('', '_Use this id to update or delete later._');
665
+ const icon = kindIcon(normalizedKind);
666
+ const parts = [
667
+ `## Saved`,
668
+ `${icon} **${title || '(untitled)'}**`,
669
+ `\`${normalizedKind}\` · **${effectiveTier}**${tags?.length ? ` · ${tags.join(', ')}` : ''}`,
670
+ `\`${entry.id}\` → ${relPath}`,
671
+ ];
672
+ if (!effectiveIndexed) {
673
+ parts.push(
674
+ '',
675
+ '_Note: this entry is stored but not indexed (no embeddings/FTS). It will not appear in search results. Use `include_unindexed: true` in list_context to browse unindexed entries._'
676
+ );
677
+ }
678
+ if (effectiveTier === 'ephemeral') {
679
+ parts.push(
680
+ '',
681
+ '_Note: ephemeral entries are excluded from default search. Use `include_ephemeral: true` in get_context to find them._'
682
+ );
683
+ }
614
684
  const hasBucketTag = (tags || []).some(
615
685
  (t: any) => typeof t === 'string' && t.startsWith('bucket:')
616
686
  );
@@ -624,18 +694,25 @@ export async function handler(
624
694
  (t: any) => typeof t === 'string' && t.startsWith('bucket:')
625
695
  );
626
696
  for (const bt of bucketTags) {
627
- const bucketUserClause = '';
628
- const bucketParams = false ? [bt] : [bt];
629
697
  const exists = ctx.db
630
698
  .prepare(
631
- `SELECT 1 FROM vault WHERE kind = 'bucket' AND identity_key = ? ${bucketUserClause} LIMIT 1`
699
+ `SELECT 1 FROM vault WHERE kind = 'bucket' AND identity_key = ? LIMIT 1`
632
700
  )
633
- .get(...bucketParams);
701
+ .get(bt);
634
702
  if (!exists) {
635
- parts.push(
636
- ``,
637
- `_Note: bucket '${bt}' is not registered. Use save_context(kind: "bucket", identity_key: "${bt}") to register it._`
638
- );
703
+ // Auto-register the bucket silently
704
+ const bucketName = bt.replace(/^bucket:/, '');
705
+ try {
706
+ await captureAndIndex(ctx, {
707
+ kind: 'bucket',
708
+ title: bucketName,
709
+ body: `Bucket for project: ${bucketName}`,
710
+ tags: [bt],
711
+ identity_key: bt,
712
+ });
713
+ } catch {
714
+ // Non-fatal: bucket registration failure should not block the save
715
+ }
639
716
  }
640
717
  }
641
718
  if (similarEntries.length) {