context-vault 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,338 @@
1
+ import { z } from 'zod';
2
+ import { captureAndIndex } from '@context-vault/core/capture';
3
+ import { execSync } from 'node:child_process';
4
+ import { ok, err, ensureVaultExists } from '../helpers.js';
5
+ import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
6
+
7
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
8
+ const MAX_ENTRIES_PER_SESSION = 5;
9
+
10
+ const PROMPT_TEMPLATE = `## Session End: Knowledge Capture
11
+
12
+ Before this session ends, take a moment to capture what was learned.
13
+
14
+ **Answer these questions (skip any that don't apply):**
15
+
16
+ 1. **What was accomplished?**
17
+ List the key outcomes of this session.
18
+
19
+ 2. **What was learned?** (most important)
20
+ Non-obvious findings, gotchas, or discoveries that would save time in a future session.
21
+ Skip anything derivable from reading the code or git history.
22
+
23
+ 3. **What decisions were made and why?**
24
+ Architectural choices, trade-offs, or scope decisions with their rationale.
25
+
26
+ 4. **What would save time for the next session?**
27
+ Blockers, context that took a while to build, or shortcuts discovered.
28
+
29
+ **Then call \`session_end\` again with your summary and any discrete discoveries:**
30
+
31
+ \`\`\`
32
+ session_end({
33
+ summary: "your summary text",
34
+ discoveries: [
35
+ { title: "Short descriptive title", body: "What was learned and why it matters", kind: "insight" },
36
+ { title: "Decision: chose X over Y", body: "Rationale and trade-offs", kind: "decision" }
37
+ ]
38
+ })
39
+ \`\`\``;
40
+
41
+ function detectProject(): string | null {
42
+ try {
43
+ const remote = execSync('git remote get-url origin 2>/dev/null', {
44
+ encoding: 'utf-8',
45
+ timeout: 3000,
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ }).trim();
48
+ if (remote) {
49
+ const match = remote.match(/\/([^/]+?)(?:\.git)?$/);
50
+ if (match) return match[1];
51
+ }
52
+ } catch {}
53
+
54
+ try {
55
+ const cwd = process.cwd();
56
+ const parts = cwd.split(/[/\\]/);
57
+ return parts[parts.length - 1];
58
+ } catch {}
59
+
60
+ return null;
61
+ }
62
+
63
+ function classifyDiscovery(body: string): string {
64
+ const lower = body.toLowerCase();
65
+ if (/chose|decided|picked|went with|trade.?off|alternative/i.test(lower)) return 'architectural';
66
+ if (/bug|fix|error|crash|broke|regression/i.test(lower)) return 'bugfix';
67
+ if (/pattern|convention|approach|technique/i.test(lower)) return 'pattern';
68
+ if (/api|endpoint|library|framework|dependency/i.test(lower)) return 'integration';
69
+ return 'general';
70
+ }
71
+
72
+ function extractInsightsFromSummary(summary: string): Array<{ title: string; body: string; kind: string }> {
73
+ const insights: Array<{ title: string; body: string; kind: string }> = [];
74
+
75
+ const sections = summary.split(/\n(?=#+\s|(?:\d+\.|\*|-)\s+\*\*)/);
76
+ for (const section of sections) {
77
+ const lower = section.toLowerCase();
78
+
79
+ if (/\blearned\b|\bdiscover|\bgotcha|\bnon.?obvious|\bsurpris|\bunexpect|\bworkaround/.test(lower)) {
80
+ const lines = section.split('\n').filter(l => l.trim());
81
+ const bullets = lines.filter(l => /^\s*[-*]\s/.test(l));
82
+
83
+ if (bullets.length > 0) {
84
+ for (const bullet of bullets) {
85
+ const text = bullet.replace(/^\s*[-*]\s+/, '').trim();
86
+ if (text.length > 20) {
87
+ insights.push({
88
+ title: text.length > 120 ? text.slice(0, 117) + '...' : text,
89
+ body: text,
90
+ kind: 'insight',
91
+ });
92
+ }
93
+ }
94
+ } else if (section.trim().length > 30) {
95
+ const firstLine = lines[0]?.replace(/^#+\s*/, '').trim() || 'Session insight';
96
+ insights.push({
97
+ title: firstLine.length > 120 ? firstLine.slice(0, 117) + '...' : firstLine,
98
+ body: section.trim(),
99
+ kind: 'insight',
100
+ });
101
+ }
102
+ }
103
+
104
+ if (/\bdecision|\bdecided|\bchose|\btrade.?off/.test(lower)) {
105
+ const lines = section.split('\n').filter(l => l.trim());
106
+ const bullets = lines.filter(l => /^\s*[-*]\s/.test(l));
107
+
108
+ if (bullets.length > 0) {
109
+ for (const bullet of bullets) {
110
+ const text = bullet.replace(/^\s*[-*]\s+/, '').trim();
111
+ if (text.length > 20) {
112
+ insights.push({
113
+ title: text.length > 120 ? text.slice(0, 117) + '...' : text,
114
+ body: text,
115
+ kind: 'decision',
116
+ });
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ return insights.slice(0, MAX_ENTRIES_PER_SESSION);
124
+ }
125
+
126
+ async function deduplicateAndSave(
127
+ entries: Array<{ title: string; body: string; kind: string; tags: string[]; tier: string; meta?: Record<string, unknown> }>,
128
+ ctx: LocalCtx
129
+ ): Promise<{ saved: Array<{ id: string; title: string; kind: string }>; skipped: Array<{ title: string; reason: string }> }> {
130
+ const saved: Array<{ id: string; title: string; kind: string }> = [];
131
+ const skipped: Array<{ title: string; reason: string }> = [];
132
+
133
+ for (const entry of entries) {
134
+ const embeddingText = [entry.title, entry.body].filter(Boolean).join(' ');
135
+ let isDuplicate = false;
136
+
137
+ try {
138
+ const embedding = await ctx.embed(embeddingText);
139
+ if (embedding) {
140
+ const vecCount = (ctx.db.prepare('SELECT COUNT(*) as c FROM vault_vec').get() as any)?.c ?? 0;
141
+ if (vecCount > 0) {
142
+ const vecRows: any[] = ctx.db
143
+ .prepare(
144
+ 'SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT 3'
145
+ )
146
+ .all(embedding, 3) as any[];
147
+
148
+ for (const vr of vecRows) {
149
+ const similarity = Math.max(0, 1 - (vr.distance as number) / 2);
150
+ if (similarity >= DEFAULT_SIMILARITY_THRESHOLD) {
151
+ const row = ctx.db
152
+ .prepare('SELECT id, title FROM vault WHERE rowid = ?')
153
+ .get(vr.rowid) as any;
154
+ if (row) {
155
+ isDuplicate = true;
156
+ skipped.push({
157
+ title: entry.title,
158
+ reason: `similar to "${row.title || row.id}" (${(similarity * 100).toFixed(0)}%)`,
159
+ });
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ } catch {}
167
+
168
+ if (isDuplicate) continue;
169
+
170
+ try {
171
+ const result = await captureAndIndex(ctx, {
172
+ kind: entry.kind,
173
+ title: entry.title,
174
+ body: entry.body,
175
+ tags: entry.tags,
176
+ tier: entry.tier,
177
+ meta: entry.meta,
178
+ source: 'session-end',
179
+ });
180
+ saved.push({ id: result.id, title: entry.title, kind: entry.kind });
181
+ } catch (e) {
182
+ skipped.push({
183
+ title: entry.title,
184
+ reason: `save failed: ${(e as Error).message}`,
185
+ });
186
+ }
187
+ }
188
+
189
+ return { saved, skipped };
190
+ }
191
+
192
+ export const name = 'session_end';
193
+
194
+ export const description =
195
+ 'End-of-session knowledge capture. Call when a session ends to extract and save insights, decisions, and learnings. If called without a summary, returns a prompt template to guide the agent through knowledge capture.';
196
+
197
+ export const inputSchema = {
198
+ summary: z
199
+ .string()
200
+ .optional()
201
+ .describe(
202
+ 'Session summary text covering what was accomplished, learned, decided, and what would help next time.'
203
+ ),
204
+ discoveries: z
205
+ .array(
206
+ z.object({
207
+ title: z.string().describe('Short descriptive title for this discovery'),
208
+ body: z.string().describe('What was learned and why it matters'),
209
+ kind: z
210
+ .enum(['insight', 'decision', 'pattern', 'reference'])
211
+ .optional()
212
+ .describe('Entry kind (default: insight)'),
213
+ })
214
+ )
215
+ .optional()
216
+ .describe(
217
+ 'Explicit discoveries to save. Each becomes a vault entry with deduplication.'
218
+ ),
219
+ project: z
220
+ .string()
221
+ .optional()
222
+ .describe('Project name for bucket tagging. Auto-detected from git remote if not provided.'),
223
+ };
224
+
225
+ export async function handler(
226
+ { summary, discoveries, project }: Record<string, any>,
227
+ ctx: LocalCtx,
228
+ { ensureIndexed }: SharedCtx
229
+ ): Promise<ToolResult> {
230
+ const { config } = ctx;
231
+
232
+ const vaultErr = ensureVaultExists(config);
233
+ if (vaultErr) return vaultErr;
234
+
235
+ if (!summary && (!discoveries || discoveries.length === 0)) {
236
+ return ok(PROMPT_TEMPLATE);
237
+ }
238
+
239
+ await ensureIndexed({ blocking: false });
240
+
241
+ const effectiveProject = project?.trim() || detectProject();
242
+ const bucketTag = effectiveProject ? `bucket:${effectiveProject}` : null;
243
+ const baseTags = ['auto-session', ...(bucketTag ? [bucketTag] : [])];
244
+
245
+ const entriesToSave: Array<{
246
+ title: string;
247
+ body: string;
248
+ kind: string;
249
+ tags: string[];
250
+ tier: string;
251
+ meta?: Record<string, unknown>;
252
+ }> = [];
253
+
254
+ if (discoveries?.length) {
255
+ for (const d of discoveries.slice(0, MAX_ENTRIES_PER_SESSION)) {
256
+ const kind = d.kind || 'insight';
257
+ entriesToSave.push({
258
+ title: d.title,
259
+ body: d.body,
260
+ kind,
261
+ tags: [...baseTags],
262
+ tier: kind === 'decision' ? 'durable' : 'working',
263
+ meta: {
264
+ discovery_type: classifyDiscovery(d.body),
265
+ source: 'session-end-explicit',
266
+ },
267
+ });
268
+ }
269
+ }
270
+
271
+ if (summary) {
272
+ const extracted = extractInsightsFromSummary(summary);
273
+ const remainingSlots = MAX_ENTRIES_PER_SESSION - entriesToSave.length;
274
+ for (const e of extracted.slice(0, remainingSlots)) {
275
+ entriesToSave.push({
276
+ title: e.title,
277
+ body: e.body,
278
+ kind: e.kind,
279
+ tags: [...baseTags],
280
+ tier: e.kind === 'decision' ? 'durable' : 'working',
281
+ meta: {
282
+ discovery_type: classifyDiscovery(e.body),
283
+ source: 'session-end-extracted',
284
+ },
285
+ });
286
+ }
287
+
288
+ entriesToSave.push({
289
+ title: `Session: ${effectiveProject || 'unknown'} ${new Date().toISOString().slice(0, 10)}`,
290
+ body: summary,
291
+ kind: 'session',
292
+ tags: [...baseTags],
293
+ tier: 'ephemeral',
294
+ meta: {
295
+ source: 'session-end',
296
+ discoveries_explicit: discoveries?.length ?? 0,
297
+ discoveries_extracted: extractInsightsFromSummary(summary).length,
298
+ },
299
+ });
300
+ }
301
+
302
+ if (entriesToSave.length === 0) {
303
+ return ok('No save-worthy content found in the provided summary. Session recorded without vault entries.');
304
+ }
305
+
306
+ const { saved, skipped } = await deduplicateAndSave(entriesToSave, ctx);
307
+
308
+ const lines = ['## Session End Summary\n'];
309
+
310
+ if (saved.length > 0) {
311
+ lines.push(`### Saved (${saved.length})\n`);
312
+ for (const s of saved) {
313
+ lines.push(`- **${s.title}** (\`${s.kind}\`) \`${s.id}\``);
314
+ }
315
+ lines.push('');
316
+ }
317
+
318
+ if (skipped.length > 0) {
319
+ lines.push(`### Skipped (${skipped.length})\n`);
320
+ for (const s of skipped) {
321
+ lines.push(`- **${s.title}**: ${s.reason}`);
322
+ }
323
+ lines.push('');
324
+ }
325
+
326
+ if (saved.length === 0 && skipped.length > 0) {
327
+ lines.push('_All discoveries matched existing entries. No new entries created._');
328
+ }
329
+
330
+ const result: ToolResult = ok(lines.join('\n'));
331
+ result._meta = {
332
+ project: effectiveProject,
333
+ saved_count: saved.length,
334
+ skipped_count: skipped.length,
335
+ saved_ids: saved.map(s => s.id),
336
+ };
337
+ return result;
338
+ }