context-vault 3.13.0 → 3.16.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 (89) hide show
  1. package/bin/cli.js +275 -0
  2. package/dist/error-log.d.ts +2 -0
  3. package/dist/error-log.d.ts.map +1 -1
  4. package/dist/error-log.js +31 -1
  5. package/dist/error-log.js.map +1 -1
  6. package/dist/server.js +32 -2
  7. package/dist/server.js.map +1 -1
  8. package/dist/status.d.ts.map +1 -1
  9. package/dist/status.js +17 -0
  10. package/dist/status.js.map +1 -1
  11. package/dist/tools/context-status.d.ts.map +1 -1
  12. package/dist/tools/context-status.js +26 -1
  13. package/dist/tools/context-status.js.map +1 -1
  14. package/dist/tools/delete-context.d.ts +1 -1
  15. package/dist/tools/delete-context.d.ts.map +1 -1
  16. package/dist/tools/delete-context.js +15 -2
  17. package/dist/tools/delete-context.js.map +1 -1
  18. package/dist/tools/get-context.d.ts.map +1 -1
  19. package/dist/tools/get-context.js +3 -2
  20. package/dist/tools/get-context.js.map +1 -1
  21. package/dist/tools/list-context.d.ts +7 -15
  22. package/dist/tools/list-context.d.ts.map +1 -1
  23. package/dist/tools/list-context.js +570 -111
  24. package/dist/tools/list-context.js.map +1 -1
  25. package/dist/tools/publish-to-team.js +1 -1
  26. package/dist/tools/publish-to-team.js.map +1 -1
  27. package/dist/tools/save-context.js +2 -2
  28. package/dist/tools/save-context.js.map +1 -1
  29. package/dist/tools/session-start.d.ts +20 -7
  30. package/dist/tools/session-start.d.ts.map +1 -1
  31. package/dist/tools/session-start.js +406 -439
  32. package/dist/tools/session-start.js.map +1 -1
  33. package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
  34. package/node_modules/@context-vault/core/dist/capture.js +4 -0
  35. package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
  36. package/node_modules/@context-vault/core/dist/categories.d.ts.map +1 -1
  37. package/node_modules/@context-vault/core/dist/categories.js +8 -0
  38. package/node_modules/@context-vault/core/dist/categories.js.map +1 -1
  39. package/node_modules/@context-vault/core/dist/compact.d.ts +38 -0
  40. package/node_modules/@context-vault/core/dist/compact.d.ts.map +1 -0
  41. package/node_modules/@context-vault/core/dist/compact.js +127 -0
  42. package/node_modules/@context-vault/core/dist/compact.js.map +1 -0
  43. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  44. package/node_modules/@context-vault/core/dist/config.js +12 -0
  45. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  46. package/node_modules/@context-vault/core/dist/db.d.ts +1 -1
  47. package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
  48. package/node_modules/@context-vault/core/dist/db.js +40 -4
  49. package/node_modules/@context-vault/core/dist/db.js.map +1 -1
  50. package/node_modules/@context-vault/core/dist/main.d.ts +6 -2
  51. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  52. package/node_modules/@context-vault/core/dist/main.js +5 -1
  53. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  54. package/node_modules/@context-vault/core/dist/search.d.ts +13 -1
  55. package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
  56. package/node_modules/@context-vault/core/dist/search.js +50 -5
  57. package/node_modules/@context-vault/core/dist/search.js.map +1 -1
  58. package/node_modules/@context-vault/core/dist/tier-analysis.d.ts +36 -0
  59. package/node_modules/@context-vault/core/dist/tier-analysis.d.ts.map +1 -0
  60. package/node_modules/@context-vault/core/dist/tier-analysis.js +227 -0
  61. package/node_modules/@context-vault/core/dist/tier-analysis.js.map +1 -0
  62. package/node_modules/@context-vault/core/dist/types.d.ts +12 -0
  63. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  64. package/node_modules/@context-vault/core/dist/watch.d.ts +21 -0
  65. package/node_modules/@context-vault/core/dist/watch.d.ts.map +1 -0
  66. package/node_modules/@context-vault/core/dist/watch.js +230 -0
  67. package/node_modules/@context-vault/core/dist/watch.js.map +1 -0
  68. package/node_modules/@context-vault/core/package.json +13 -1
  69. package/node_modules/@context-vault/core/src/capture.ts +4 -0
  70. package/node_modules/@context-vault/core/src/categories.ts +8 -0
  71. package/node_modules/@context-vault/core/src/compact.ts +183 -0
  72. package/node_modules/@context-vault/core/src/config.ts +8 -0
  73. package/node_modules/@context-vault/core/src/db.ts +40 -4
  74. package/node_modules/@context-vault/core/src/main.ts +10 -0
  75. package/node_modules/@context-vault/core/src/search.ts +55 -4
  76. package/node_modules/@context-vault/core/src/tier-analysis.ts +299 -0
  77. package/node_modules/@context-vault/core/src/types.ts +10 -0
  78. package/node_modules/@context-vault/core/src/watch.ts +269 -0
  79. package/package.json +2 -2
  80. package/src/error-log.ts +30 -0
  81. package/src/server.ts +31 -2
  82. package/src/status.ts +17 -0
  83. package/src/tools/context-status.ts +30 -1
  84. package/src/tools/delete-context.ts +10 -5
  85. package/src/tools/get-context.ts +3 -2
  86. package/src/tools/list-context.ts +620 -119
  87. package/src/tools/publish-to-team.ts +1 -1
  88. package/src/tools/save-context.ts +2 -2
  89. package/src/tools/session-start.ts +444 -484
@@ -1,160 +1,661 @@
1
1
  import { z } from 'zod';
2
- import { normalizeKind } from '@context-vault/core/files';
3
- import { categoryFor } from '@context-vault/core/categories';
4
- import { ok, err, errWithHint, kindIcon, fmtDate } from '../helpers.js';
5
- import { resolveTemporalParams } from '../temporal.js';
2
+ import { execSync } from 'node:child_process';
3
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { ok, err, ensureVaultExists, kindIcon, fmtDate } from '../helpers.js';
7
+ import { getAutoMemory } from '../auto-memory.js';
8
+ import { getRemoteClient, getTeamId, getPublicVaults } from '../remote.js';
9
+ import type { AutoMemoryEntry, AutoMemoryResult } from '../auto-memory.js';
6
10
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
7
11
 
8
- export const name = 'list_context';
12
+ const DEFAULT_MAX_TOKENS = 4000;
13
+ const RECENT_DAYS = 7;
14
+ const MAX_BODY_PER_ENTRY = 400;
15
+ const PRIORITY_KINDS = ['decision', 'insight', 'pattern'];
16
+ const SESSION_SUMMARY_KIND = 'session';
17
+
18
+ /**
19
+ * Build a search context string from auto-memory entries.
20
+ * Used to boost vault retrieval relevance.
21
+ */
22
+ function buildAutoMemoryContext(entries: AutoMemoryEntry[]): string {
23
+ if (entries.length === 0) return '';
24
+ const parts = entries
25
+ .map((e) => {
26
+ const desc = e.description ? `: ${e.description}` : '';
27
+ // Include a snippet of the body for richer context
28
+ const bodySnippet = e.body ? ` -- ${e.body.slice(0, 200)}` : '';
29
+ return `${e.name}${desc}${bodySnippet}`;
30
+ });
31
+ return parts.join('. ');
32
+ }
33
+
34
+ export const name = 'session_start';
9
35
 
10
36
  export const description =
11
- 'Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at, updated_at. Use get_context with a query for semantic search. Use this to browse by tags or find recent entries.';
37
+ 'Auto-assemble a context brief for the current project on session start. Pulls recent entries, last session summary, and active decisions/blockers into a token-budgeted capsule formatted for agent consumption.';
12
38
 
13
39
  export const inputSchema = {
14
- kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
15
- category: z.enum(['knowledge', 'entity', 'event']).optional().describe('Filter by category'),
16
- tags: z.array(z.string()).optional().describe('Filter by tags (entries must match at least one)'),
17
- since: z.string().optional().describe('ISO date, return entries created after this'),
18
- until: z.string().optional().describe('ISO date, return entries created before this'),
19
- limit: z.number().optional().describe('Max results to return (default 20, max 100)'),
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.'),
40
+ project: z
41
+ .string()
42
+ .optional()
43
+ .describe(
44
+ 'Project name or tag to scope the brief. Auto-detected from cwd/git remote if not provided.'
45
+ ),
46
+ max_tokens: z
47
+ .number()
48
+ .optional()
49
+ .describe('Token budget for the capsule (rough estimate: 1 token ~ 4 chars). Default: 4000.'),
50
+ buckets: z
51
+ .array(z.string())
52
+ .optional()
53
+ .describe(
54
+ "Bucket names to scope the session brief. Each name expands to a 'bucket:<name>' tag filter. When provided, the brief only includes entries from these buckets."
55
+ ),
56
+ auto_memory_path: z
57
+ .string()
58
+ .optional()
59
+ .describe(
60
+ "Explicit path to the Claude Code auto-memory directory. Overrides auto-detection. If not provided, session_start attempts to detect ~/.claude/projects/-<project-key>/memory/ from cwd."
61
+ ),
22
62
  };
23
63
 
64
+ function detectProject() {
65
+ try {
66
+ const remote = execSync('git remote get-url origin 2>/dev/null', {
67
+ encoding: 'utf-8',
68
+ timeout: 3000,
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ }).trim();
71
+ if (remote) {
72
+ const match = remote.match(/\/([^/]+?)(?:\.git)?$/);
73
+ if (match) return match[1];
74
+ }
75
+ } catch {}
76
+
77
+ try {
78
+ const cwd = process.cwd();
79
+ const parts = cwd.split(/[/\\]/);
80
+ return parts[parts.length - 1];
81
+ } catch {}
82
+
83
+ return null;
84
+ }
85
+
86
+ function truncateBody(body: string | null | undefined, maxLen = MAX_BODY_PER_ENTRY): string {
87
+ if (!body) return '(no body)';
88
+ if (body.length <= maxLen) return body;
89
+ return body.slice(0, maxLen) + '...';
90
+ }
91
+
92
+ function estimateTokens(text: string | null | undefined): number {
93
+ return Math.ceil((text || '').length / 4);
94
+ }
95
+
96
+ function formatEntry(entry: any): string {
97
+ const tags = entry.tags ? JSON.parse(entry.tags) : [];
98
+ const tagStr = tags.length ? tags.join(', ') : '';
99
+ const date = fmtDate(entry.updated_at || entry.created_at);
100
+ const icon = kindIcon(entry.kind);
101
+ const meta = [`\`${entry.kind}\``, tagStr, date].filter(Boolean).join(' · ');
102
+ return [
103
+ `- ${icon} **${entry.title || '(untitled)'}**`,
104
+ ` ${meta} · \`${entry.id}\``,
105
+ ` ${truncateBody(entry.body).replace(/\n+/g, ' ').trim()}`,
106
+ ].join('\n');
107
+ }
108
+
24
109
  export async function handler(
25
- { kind, category, tags, since, until, limit, offset, include_unindexed }: Record<string, any>,
110
+ { project, max_tokens, buckets, auto_memory_path }: Record<string, any>,
26
111
  ctx: LocalCtx,
27
- { ensureIndexed, reindexFailed }: SharedCtx
112
+ { ensureIndexed }: SharedCtx
28
113
  ): Promise<ToolResult> {
29
114
  const { config } = ctx;
30
115
 
116
+ const vaultErr = ensureVaultExists(config);
117
+ if (vaultErr) return vaultErr;
118
+
31
119
  await ensureIndexed();
32
120
 
33
- const resolved = resolveTemporalParams({ since, until });
34
- since = resolved.since;
35
- until = resolved.until;
121
+ // Sanity check: compare DB entries vs disk files
122
+ let indexWarning = '';
123
+ try {
124
+ const dbCount = (ctx.db.prepare('SELECT COUNT(*) as cnt FROM vault').get() as any)?.cnt ?? 0;
125
+ let diskCount = 0;
126
+ const walk = (dir: string, depth = 0) => {
127
+ if (depth > 3 || diskCount >= 100) return;
128
+ try {
129
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
130
+ if (diskCount >= 100) return;
131
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== '_archive') {
132
+ walk(`${dir}/${entry.name}`, depth + 1);
133
+ } else if (entry.name.endsWith('.md')) {
134
+ diskCount++;
135
+ }
136
+ }
137
+ } catch {}
138
+ };
139
+ walk(config.vaultDir);
140
+ if (diskCount >= 100 && dbCount < diskCount / 10) {
141
+ indexWarning = `\n> **WARNING:** Vault has significantly more files on disk (~${diskCount}+) than indexed entries (${dbCount}). The search index may be out of sync. Run \`context-vault reconnect\` to fix.\n`;
142
+ }
143
+ } catch {}
144
+
145
+ const effectiveProject = project?.trim() || detectProject();
146
+ const tokenBudget = max_tokens || DEFAULT_MAX_TOKENS;
147
+
148
+ const bucketTags = buckets?.length ? buckets.map((b: string) => `bucket:${b}`) : [];
149
+ const effectiveTags = bucketTags.length ? bucketTags : effectiveProject ? [effectiveProject] : [];
150
+
151
+ const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
152
+
153
+ // Auto-detect Claude Code auto-memory (explicit path overrides auto-detection)
154
+ const autoMemory: AutoMemoryResult = getAutoMemory(auto_memory_path);
155
+ const autoMemoryContext = buildAutoMemoryContext(autoMemory.entries);
156
+ const topicsExtracted = autoMemory.entries.length > 0
157
+ ? extractKeywords(autoMemoryContext).slice(0, 20)
158
+ : [];
159
+
160
+ const sections = [];
161
+ let tokensUsed = 0;
162
+
163
+ sections.push(`# Session Brief${effectiveProject ? ` -- ${effectiveProject}` : ''}`);
164
+ const bucketsLabel = buckets?.length ? ` | buckets: ${buckets.join(', ')}` : '';
165
+ const autoMemoryLabel = autoMemory.detected
166
+ ? ` | auto-memory: ${autoMemory.entries.length} entries detected, used as search context`
167
+ : '';
168
+ sections.push(
169
+ `_Generated ${new Date().toISOString().slice(0, 10)} | budget: ${tokenBudget} tokens${bucketsLabel}${autoMemoryLabel}_\n`
170
+ );
171
+ tokensUsed += estimateTokens(sections.join('\n'));
36
172
 
37
- const kindFilter = kind ? normalizeKind(kind) : null;
38
- const effectiveCategory = category || (kindFilter ? categoryFor(kindFilter) : null);
39
- let effectiveSince = since || null;
40
- let autoWindowed = false;
41
- if (effectiveCategory === 'event' && !since && !until) {
42
- const decayMs = (config.eventDecayDays || 30) * 86400000;
43
- effectiveSince = new Date(Date.now() - decayMs).toISOString();
44
- autoWindowed = true;
173
+ const lastSession = queryLastSession(ctx, effectiveTags);
174
+ if (lastSession) {
175
+ const sessionBlock = ['## Last Session Summary', truncateBody(lastSession.body, 600), ''].join(
176
+ '\n'
177
+ );
178
+ const sessionTokens = estimateTokens(sessionBlock);
179
+ if (tokensUsed + sessionTokens <= tokenBudget) {
180
+ sections.push(sessionBlock);
181
+ tokensUsed += sessionTokens;
182
+ }
45
183
  }
46
184
 
47
- const clauses = [];
48
- const params = [];
185
+ // Hot entries: always loaded regardless of project/bucket scope
186
+ const hotEntries = queryHotEntries(ctx);
187
+ if (hotEntries.length > 0) {
188
+ const header = '## Hot Entries (frequently accessed)\n';
189
+ const headerTokens = estimateTokens(header);
190
+ if (tokensUsed + headerTokens <= tokenBudget) {
191
+ const entryLines: string[] = [];
192
+ tokensUsed += headerTokens;
193
+ for (const entry of hotEntries) {
194
+ const line = formatEntry(entry);
195
+ const lineTokens = estimateTokens(line);
196
+ if (tokensUsed + lineTokens > tokenBudget) break;
197
+ entryLines.push(line);
198
+ tokensUsed += lineTokens;
199
+ }
200
+ if (entryLines.length > 0) {
201
+ sections.push(header + entryLines.join('\n') + '\n');
202
+ }
203
+ }
204
+ }
49
205
 
50
- if (kindFilter) {
51
- clauses.push('kind = ?');
52
- params.push(kindFilter);
206
+ // When auto-memory context is available, boost decisions query with FTS
207
+ const decisions = queryByKinds(
208
+ ctx,
209
+ PRIORITY_KINDS,
210
+ sinceDate,
211
+ effectiveTags,
212
+ autoMemoryContext
213
+ );
214
+ if (decisions.length > 0) {
215
+ const header = '## Active Decisions, Insights & Patterns\n';
216
+ const headerTokens = estimateTokens(header);
217
+ if (tokensUsed + headerTokens <= tokenBudget) {
218
+ const entryLines = [];
219
+ tokensUsed += headerTokens;
220
+ for (const entry of decisions) {
221
+ const line = formatEntry(entry);
222
+ const lineTokens = estimateTokens(line);
223
+ if (tokensUsed + lineTokens > tokenBudget) break;
224
+ entryLines.push(line);
225
+ tokensUsed += lineTokens;
226
+ }
227
+ if (entryLines.length > 0) {
228
+ sections.push(header + entryLines.join('\n') + '\n');
229
+ }
230
+ }
53
231
  }
54
- if (category) {
55
- clauses.push('category = ?');
56
- params.push(category);
232
+
233
+ const seenIdsForDurable = new Set([
234
+ ...decisions.map((d: any) => d.id),
235
+ ...(lastSession ? [lastSession.id] : []),
236
+ ]);
237
+ const durables = queryDurable(ctx, effectiveTags, seenIdsForDurable);
238
+ if (durables.length > 0) {
239
+ const header = '## Foundational Decisions\n';
240
+ const headerTokens = estimateTokens(header);
241
+ if (tokensUsed + headerTokens <= tokenBudget) {
242
+ const entryLines = [];
243
+ tokensUsed += headerTokens;
244
+ for (const entry of durables) {
245
+ const line = formatEntry(entry);
246
+ const lineTokens = estimateTokens(line);
247
+ if (tokensUsed + lineTokens > tokenBudget) break;
248
+ entryLines.push(line);
249
+ tokensUsed += lineTokens;
250
+ }
251
+ if (entryLines.length > 0) {
252
+ sections.push(header + entryLines.join('\n') + '\n');
253
+ }
254
+ }
57
255
  }
58
- if (effectiveSince) {
59
- clauses.push('created_at >= ?');
60
- params.push(effectiveSince);
256
+
257
+ const recent = queryRecent(ctx, sinceDate, effectiveTags);
258
+ const seenIds = new Set(decisions.map((d: any) => d.id));
259
+ if (lastSession) seenIds.add(lastSession.id);
260
+ durables.forEach((d: any) => seenIds.add(d.id));
261
+ const deduped = recent.filter((r: any) => !seenIds.has(r.id));
262
+
263
+ if (deduped.length > 0) {
264
+ const header = `## Recent Entries (last ${RECENT_DAYS} days)\n`;
265
+ const headerTokens = estimateTokens(header);
266
+ if (tokensUsed + headerTokens <= tokenBudget) {
267
+ const entryLines = [];
268
+ tokensUsed += headerTokens;
269
+ for (const entry of deduped) {
270
+ const line = formatEntry(entry);
271
+ const lineTokens = estimateTokens(line);
272
+ if (tokensUsed + lineTokens > tokenBudget) break;
273
+ entryLines.push(line);
274
+ tokensUsed += lineTokens;
275
+ }
276
+ if (entryLines.length > 0) {
277
+ sections.push(header + entryLines.join('\n') + '\n');
278
+ }
279
+ }
61
280
  }
62
- if (until) {
63
- clauses.push('created_at <= ?');
64
- params.push(until);
281
+
282
+ // Remote entries: pull recent from hosted API if configured
283
+ const remoteClient = getRemoteClient(ctx.config);
284
+ let remoteCount = 0;
285
+ if (remoteClient && tokensUsed < tokenBudget) {
286
+ try {
287
+ const seenIds = new Set([
288
+ ...decisions.map((d: any) => d.id),
289
+ ...deduped.map((d: any) => d.id),
290
+ ...(lastSession ? [lastSession.id] : []),
291
+ ]);
292
+ const remoteTags = effectiveTags.length ? effectiveTags : undefined;
293
+ const remoteResults = await remoteClient.search({
294
+ tags: remoteTags,
295
+ limit: 10,
296
+ since: sinceDate,
297
+ });
298
+ const uniqueRemote = remoteResults.filter((r: any) => !seenIds.has(r.id));
299
+ if (uniqueRemote.length > 0) {
300
+ const header = '## Remote Entries\n';
301
+ const headerTokens = estimateTokens(header);
302
+ if (tokensUsed + headerTokens <= tokenBudget) {
303
+ const entryLines: string[] = [];
304
+ tokensUsed += headerTokens;
305
+ for (const entry of uniqueRemote) {
306
+ const line = formatEntry(entry);
307
+ const lineTokens = estimateTokens(line);
308
+ if (tokensUsed + lineTokens > tokenBudget) break;
309
+ entryLines.push(line);
310
+ tokensUsed += lineTokens;
311
+ remoteCount++;
312
+ }
313
+ if (entryLines.length > 0) {
314
+ sections.push(header + entryLines.join('\n') + '\n');
315
+ }
316
+ }
317
+ }
318
+ } catch (e) {
319
+ console.warn(`[context-vault] Remote session_start failed: ${(e as Error).message}`);
320
+ }
65
321
  }
66
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
67
- if (!include_unindexed) {
68
- clauses.push('indexed = 1');
322
+
323
+ // Team vault entries: include team knowledge in brief if teamId is configured
324
+ let teamCount = 0;
325
+ const teamId = getTeamId(ctx.config);
326
+ if (remoteClient && teamId && tokensUsed < tokenBudget) {
327
+ try {
328
+ const allSeenIds = new Set([
329
+ ...decisions.map((d: any) => d.id),
330
+ ...deduped.map((d: any) => d.id),
331
+ ...(lastSession ? [lastSession.id] : []),
332
+ ]);
333
+ const teamResults = await remoteClient.teamSearch(teamId, {
334
+ tags: effectiveTags.length ? effectiveTags : undefined,
335
+ limit: 10,
336
+ since: sinceDate,
337
+ });
338
+ const uniqueTeam = teamResults.filter((r: any) => !allSeenIds.has(r.id));
339
+ if (uniqueTeam.length > 0) {
340
+ const header = '## Team Knowledge\n';
341
+ const headerTokens = estimateTokens(header);
342
+ if (tokensUsed + headerTokens <= tokenBudget) {
343
+ const entryLines: string[] = [];
344
+ tokensUsed += headerTokens;
345
+ for (const entry of uniqueTeam) {
346
+ const line = formatEntry(entry) + ' `[team]`';
347
+ const lineTokens = estimateTokens(line);
348
+ if (tokensUsed + lineTokens > tokenBudget) break;
349
+ entryLines.push(line);
350
+ tokensUsed += lineTokens;
351
+ teamCount++;
352
+ }
353
+ if (entryLines.length > 0) {
354
+ sections.push(header + entryLines.join('\n') + '\n');
355
+ }
356
+ }
357
+ }
358
+ } catch (e) {
359
+ console.warn(`[context-vault] Team session_start failed: ${(e as Error).message}`);
360
+ }
69
361
  }
70
362
 
71
- const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
72
- const effectiveLimit = Math.min(limit || 20, 100);
73
- const effectiveOffset = offset || 0;
74
- // When tag-filtering, over-fetch to compensate for post-filter reduction
75
- const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
363
+ // Public vault entries: include public knowledge if publicVaults are configured
364
+ let publicCount = 0;
365
+ const publicVaultSlugs = getPublicVaults(ctx.config);
366
+ if (remoteClient && publicVaultSlugs.length > 0 && tokensUsed < tokenBudget) {
367
+ try {
368
+ const allPublicSeenIds = new Set([
369
+ ...decisions.map((d: any) => d.id),
370
+ ...deduped.map((d: any) => d.id),
371
+ ...(lastSession ? [lastSession.id] : []),
372
+ ]);
373
+ const publicSearches = publicVaultSlugs.map(slug =>
374
+ remoteClient.publicSearch(slug, {
375
+ tags: effectiveTags.length ? effectiveTags : undefined,
376
+ limit: 5,
377
+ since: sinceDate,
378
+ }).catch(() => [])
379
+ );
380
+ const allPublicResults = await Promise.all(publicSearches);
381
+ const flatPublic = allPublicResults.flat().filter((r: any) => !allPublicSeenIds.has(r.id));
382
+ if (flatPublic.length > 0) {
383
+ const header = '## Public Knowledge\n';
384
+ const headerTokens = estimateTokens(header);
385
+ if (tokensUsed + headerTokens <= tokenBudget) {
386
+ const entryLines: string[] = [];
387
+ tokensUsed += headerTokens;
388
+ for (const entry of flatPublic) {
389
+ const slug = (entry as any).vault_slug || 'public';
390
+ const line = formatEntry(entry) + ` \`[public:${slug}]\``;
391
+ const lineTokens = estimateTokens(line);
392
+ if (tokensUsed + lineTokens > tokenBudget) break;
393
+ entryLines.push(line);
394
+ tokensUsed += lineTokens;
395
+ publicCount++;
396
+ }
397
+ if (entryLines.length > 0) {
398
+ sections.push(header + entryLines.join('\n') + '\n');
399
+ }
400
+ }
401
+ }
402
+ } catch (e) {
403
+ console.warn(`[context-vault] Public vault session_start failed: ${(e as Error).message}`);
404
+ }
405
+ }
76
406
 
77
- const countParams = [...params];
78
- let total;
79
- let rows: any[];
80
- try {
81
- total =
82
- (ctx.db.prepare(`SELECT COUNT(*) as c FROM vault ${where}`).get(...countParams) as any)?.c ??
83
- 0;
407
+ const totalEntries =
408
+ (lastSession ? 1 : 0) +
409
+ decisions.length +
410
+ durables.length +
411
+ deduped.filter((_d: any) => {
412
+ return true;
413
+ }).length +
414
+ remoteCount +
415
+ teamCount +
416
+ publicCount;
84
417
 
85
- params.push(fetchLimit, effectiveOffset);
86
- rows = ctx.db
418
+ if (indexWarning) {
419
+ sections.push(indexWarning);
420
+ }
421
+
422
+ sections.push('---');
423
+ sections.push(
424
+ `_${tokensUsed} / ${tokenBudget} tokens used | project: ${effectiveProject || 'unscoped'}_`
425
+ );
426
+
427
+ const result: ToolResult = ok(sections.join('\n'));
428
+ result._meta = {
429
+ project: effectiveProject || null,
430
+ buckets: buckets || null,
431
+ tokens_used: tokensUsed,
432
+ tokens_budget: tokenBudget,
433
+ sections: {
434
+ last_session: lastSession ? 1 : 0,
435
+ hot_entries: hotEntries.length,
436
+ decisions: decisions.length,
437
+ foundational: durables.length,
438
+ recent: deduped.length,
439
+ },
440
+ auto_memory: {
441
+ detected: autoMemory.detected,
442
+ path: autoMemory.path,
443
+ entries: autoMemory.entries.length,
444
+ lines_used: autoMemory.linesUsed,
445
+ topics_extracted: topicsExtracted,
446
+ },
447
+ };
448
+ return result;
449
+ }
450
+
451
+ function queryHotEntries(ctx: LocalCtx): any[] {
452
+ try {
453
+ return ctx.db
87
454
  .prepare(
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 ?`
455
+ `SELECT * FROM vault
456
+ WHERE heat_tier = 'hot'
457
+ AND superseded_by IS NULL
458
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
459
+ AND indexed = 1
460
+ ORDER BY last_accessed_at DESC
461
+ LIMIT 10`
89
462
  )
90
- .all(...params) as any[];
91
- } catch (e) {
92
- return errWithHint(
93
- e instanceof Error ? e.message : String(e),
94
- 'DB_ERROR',
95
- 'context-vault list_context DB_ERROR. Check `cat ~/.context-mcp/error.log | tail -5` and help me debug.'
96
- );
463
+ .all();
464
+ } catch {
465
+ return [];
97
466
  }
467
+ }
98
468
 
99
- // Post-filter by tags if provided, then apply requested limit
100
- const filtered = tags?.length
101
- ? rows
102
- .filter((r: any) => {
103
- const entryTags = r.tags ? JSON.parse(r.tags) : [];
104
- return tags.some((t: string) => entryTags.includes(t));
105
- })
106
- .slice(0, effectiveLimit)
107
- : rows;
108
-
109
- if (!filtered.length) {
110
- if (autoWindowed) {
111
- const days = config.eventDecayDays || 30;
112
- return ok(
113
- `No entries found matching the given filters in events (last ${days} days).\nTry with \`since: "YYYY-MM-DD"\` to search older events.`
114
- );
115
- }
116
- return ok('No entries found matching the given filters.');
469
+ function queryLastSession(ctx: LocalCtx, effectiveTags: string[]): any {
470
+ const clauses = [`kind = '${SESSION_SUMMARY_KIND}'`];
471
+ const params: any[] = [];
472
+
473
+ if (false) {
117
474
  }
475
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
476
+ clauses.push('superseded_by IS NULL');
118
477
 
119
- const lines = [];
120
- if (reindexFailed)
121
- lines.push(
122
- `> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`
123
- );
124
- lines.push(`## Vault Entries (${filtered.length} shown, ${total} total)\n`);
125
- if (autoWindowed) {
126
- const days = config.eventDecayDays || 30;
127
- lines.push(
128
- `> ℹ Event search limited to last ${days} days. Use \`since\` parameter for older results.\n`
129
- );
478
+ const where = `WHERE ${clauses.join(' AND ')}`;
479
+ const rows = ctx.db
480
+ .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 5`)
481
+ .all(...params);
482
+
483
+ if (effectiveTags.length) {
484
+ const match = rows.find((r: any) => {
485
+ const tags = r.tags ? JSON.parse(r.tags) : [];
486
+ return effectiveTags.some((t: string) => tags.includes(t));
487
+ });
488
+ if (match) return match;
130
489
  }
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
- }
138
- for (const r of filtered) {
139
- const entryTags = r.tags ? JSON.parse(r.tags) : [];
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}\` |`);
490
+ return rows[0] || null;
491
+ }
492
+
493
+ function queryByKinds(
494
+ ctx: LocalCtx,
495
+ kinds: string[],
496
+ since: string,
497
+ effectiveTags: string[],
498
+ autoMemoryContext = ''
499
+ ): any[] {
500
+ const kindPlaceholders = kinds.map(() => '?').join(',');
501
+ const clauses = [`kind IN (${kindPlaceholders})`];
502
+ const params = [...kinds];
503
+
504
+ clauses.push('created_at >= ?');
505
+ params.push(since);
506
+
507
+ if (false) {
508
+ }
509
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
510
+ clauses.push('superseded_by IS NULL');
511
+
512
+ const where = `WHERE ${clauses.join(' AND ')}`;
513
+ let rows = ctx.db
514
+ .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
515
+ .all(...params) as any[];
516
+
517
+ if (effectiveTags.length) {
518
+ const tagged = rows.filter((r: any) => {
519
+ const tags = r.tags ? JSON.parse(r.tags) : [];
520
+ return effectiveTags.some((t: string) => tags.includes(t));
521
+ });
522
+ if (tagged.length > 0) rows = tagged;
523
+ }
524
+
525
+ // When auto-memory context is available, boost results by FTS relevance
526
+ // to surface vault entries that match what the user's auto-memory says they care about
527
+ if (autoMemoryContext && rows.length > 1) {
528
+ rows = boostByAutoMemory(ctx, rows, autoMemoryContext);
529
+ }
530
+
531
+ return rows;
532
+ }
533
+
534
+ /**
535
+ * Re-rank vault entries by FTS relevance to auto-memory context.
536
+ * Entries matching auto-memory topics float to the top while preserving
537
+ * all original entries (non-matching ones keep their original order at the end).
538
+ */
539
+ function boostByAutoMemory(ctx: LocalCtx, rows: any[], context: string): any[] {
540
+ // Extract meaningful keywords from auto-memory context for FTS
541
+ const keywords = extractKeywords(context);
542
+ if (keywords.length === 0) return rows;
543
+
544
+ // Build FTS query from auto-memory keywords
545
+ const ftsTerms = keywords.slice(0, 10).map((k) => `"${k}"`).join(' OR ');
546
+ const rowIds = new Set(rows.map((r: any) => r.id));
547
+
548
+ try {
549
+ const ftsRows = ctx.db
550
+ .prepare(
551
+ `SELECT e.id, rank FROM vault_fts f JOIN vault e ON f.rowid = e.rowid WHERE vault_fts MATCH ? ORDER BY rank LIMIT 50`
552
+ )
553
+ .all(ftsTerms) as { id: string; rank: number }[];
554
+
555
+ // Build a boost map: entries matching auto-memory context get priority
556
+ const boostMap = new Map<string, number>();
557
+ for (const fr of ftsRows) {
558
+ if (rowIds.has(fr.id)) {
559
+ boostMap.set(fr.id, fr.rank);
560
+ }
150
561
  }
562
+
563
+ if (boostMap.size === 0) return rows;
564
+
565
+ // Sort: boosted entries first (by FTS rank), then unboosted in original order
566
+ const boosted = rows.filter((r: any) => boostMap.has(r.id));
567
+ const unboosted = rows.filter((r: any) => !boostMap.has(r.id));
568
+ boosted.sort((a: any, b: any) => (boostMap.get(a.id) ?? 0) - (boostMap.get(b.id) ?? 0));
569
+ return [...boosted, ...unboosted];
570
+ } catch {
571
+ // FTS errors are non-fatal; return original order
572
+ return rows;
151
573
  }
574
+ }
152
575
 
153
- if (effectiveOffset + effectiveLimit < total) {
154
- lines.push(
155
- `\n_Page ${Math.floor(effectiveOffset / effectiveLimit) + 1}. Use offset: ${effectiveOffset + effectiveLimit} for next page._`
156
- );
576
+ /**
577
+ * Extract meaningful keywords from auto-memory context text.
578
+ * Filters out common stop words and short tokens.
579
+ */
580
+ function extractKeywords(text: string): string[] {
581
+ const stopWords = new Set([
582
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
583
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
584
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
585
+ 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'for', 'with',
586
+ 'from', 'into', 'during', 'before', 'after', 'above', 'below',
587
+ 'to', 'of', 'in', 'on', 'at', 'by', 'about', 'between', 'through',
588
+ 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them',
589
+ 'their', 'we', 'our', 'you', 'your', 'he', 'she', 'him', 'her',
590
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',
591
+ 'some', 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very',
592
+ 'just', 'also', 'now', 'then', 'here', 'there', 'when', 'where',
593
+ 'how', 'what', 'which', 'who', 'whom', 'why', 'if', 'because',
594
+ 'as', 'until', 'while', 'use', 'using', 'used',
595
+ ]);
596
+
597
+ const words = text
598
+ .toLowerCase()
599
+ .replace(/[^a-z0-9\s-]/g, ' ')
600
+ .split(/\s+/)
601
+ .filter((w) => w.length >= 3 && !stopWords.has(w));
602
+
603
+ // Deduplicate while preserving order
604
+ const seen = new Set<string>();
605
+ return words.filter((w) => {
606
+ if (seen.has(w)) return false;
607
+ seen.add(w);
608
+ return true;
609
+ });
610
+ }
611
+
612
+ function queryDurable(
613
+ ctx: LocalCtx,
614
+ effectiveTags: string[],
615
+ excludeIds: Set<string>
616
+ ): any[] {
617
+ const clauses = [
618
+ "tier = 'durable'",
619
+ "kind IN ('decision', 'pattern', 'architecture', 'reference')",
620
+ "(expires_at IS NULL OR expires_at > datetime('now'))",
621
+ "superseded_by IS NULL",
622
+ ];
623
+ const rows = ctx.db
624
+ .prepare(`SELECT * FROM vault WHERE ${clauses.join(' AND ')} ORDER BY created_at DESC LIMIT 20`)
625
+ .all() as any[];
626
+
627
+ let filtered = rows.filter((r: any) => !excludeIds.has(r.id));
628
+
629
+ if (effectiveTags.length) {
630
+ filtered = filtered.filter((r: any) => {
631
+ const tags = r.tags ? JSON.parse(r.tags) : [];
632
+ return effectiveTags.some((t: string) => tags.includes(t));
633
+ });
634
+ }
635
+
636
+ return filtered;
637
+ }
638
+
639
+ function queryRecent(ctx: LocalCtx, since: string, effectiveTags: string[]): any[] {
640
+ const clauses = ['created_at >= ?'];
641
+ const params = [since];
642
+
643
+ if (false) {
157
644
  }
645
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
646
+ clauses.push('superseded_by IS NULL');
647
+
648
+ const where = `WHERE ${clauses.join(' AND ')}`;
649
+ const rows = ctx.db
650
+ .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
651
+ .all(...params);
158
652
 
159
- return ok(lines.join('\n'));
653
+ if (effectiveTags.length) {
654
+ const tagged = rows.filter((r: any) => {
655
+ const tags = r.tags ? JSON.parse(r.tags) : [];
656
+ return effectiveTags.some((t: string) => tags.includes(t));
657
+ });
658
+ if (tagged.length > 0) return tagged;
659
+ }
660
+ return rows;
160
661
  }