context-vault 3.5.1 → 3.7.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 (41) hide show
  1. package/assets/vault-error-hook.mjs +106 -0
  2. package/assets/vault-recall-hook.mjs +67 -0
  3. package/bin/cli.js +607 -2
  4. package/dist/register-tools.d.ts.map +1 -1
  5. package/dist/register-tools.js +4 -0
  6. package/dist/register-tools.js.map +1 -1
  7. package/dist/tools/clear-context.d.ts +7 -3
  8. package/dist/tools/clear-context.d.ts.map +1 -1
  9. package/dist/tools/clear-context.js +157 -8
  10. package/dist/tools/clear-context.js.map +1 -1
  11. package/dist/tools/context-status.d.ts.map +1 -1
  12. package/dist/tools/context-status.js +42 -0
  13. package/dist/tools/context-status.js.map +1 -1
  14. package/dist/tools/get-context.d.ts.map +1 -1
  15. package/dist/tools/get-context.js +66 -1
  16. package/dist/tools/get-context.js.map +1 -1
  17. package/dist/tools/recall.d.ts +25 -0
  18. package/dist/tools/recall.d.ts.map +1 -0
  19. package/dist/tools/recall.js +257 -0
  20. package/dist/tools/recall.js.map +1 -0
  21. package/dist/tools/save-context.d.ts.map +1 -1
  22. package/dist/tools/save-context.js +20 -0
  23. package/dist/tools/save-context.js.map +1 -1
  24. package/dist/tools/session-end.d.ts +20 -0
  25. package/dist/tools/session-end.d.ts.map +1 -0
  26. package/dist/tools/session-end.js +288 -0
  27. package/dist/tools/session-end.js.map +1 -0
  28. package/dist/tools/session-start.d.ts +2 -1
  29. package/dist/tools/session-start.d.ts.map +1 -1
  30. package/dist/tools/session-start.js +16 -5
  31. package/dist/tools/session-start.js.map +1 -1
  32. package/node_modules/@context-vault/core/package.json +1 -1
  33. package/package.json +2 -2
  34. package/src/register-tools.ts +4 -0
  35. package/src/tools/clear-context.ts +195 -10
  36. package/src/tools/context-status.ts +65 -0
  37. package/src/tools/get-context.ts +86 -1
  38. package/src/tools/recall.ts +307 -0
  39. package/src/tools/save-context.ts +25 -0
  40. package/src/tools/session-end.ts +338 -0
  41. package/src/tools/session-start.ts +18 -5
@@ -1,6 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { ok } from '../helpers.js';
3
- import type { ToolResult } from '../types.js';
2
+ import { ok, ensureVaultExists, kindIcon, fmtDate } from '../helpers.js';
3
+ import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
4
+
5
+ const DEFAULT_PRELOAD_TOKENS = 2000;
6
+ const MAX_BODY_PER_ENTRY = 300;
7
+ const RECENT_DAYS = 7;
8
+ const PRIORITY_KINDS = ['decision', 'insight', 'pattern'];
4
9
 
5
10
  export const name = 'clear_context';
6
11
 
@@ -14,28 +19,208 @@ export const inputSchema = {
14
19
  .describe(
15
20
  'Optional tag or project name to focus on going forward. When provided, treat subsequent get_context calls as if filtered to this tag.'
16
21
  ),
22
+ preload_bucket: z
23
+ .string()
24
+ .optional()
25
+ .describe(
26
+ 'Bucket name to preload context from after clearing. Loads recent decisions, insights, and patterns scoped to this bucket into the response so the agent has immediate context for the new project.'
27
+ ),
28
+ max_tokens: z
29
+ .number()
30
+ .optional()
31
+ .describe(
32
+ 'Token budget for preloaded context (rough estimate: 1 token ~ 4 chars). Default: 2000. Only applies when preload_bucket is set.'
33
+ ),
17
34
  };
18
35
 
19
- export function handler({ scope }: { scope?: string } = {}): ToolResult {
36
+ function estimateTokens(text: string | null | undefined): number {
37
+ return Math.ceil((text || '').length / 4);
38
+ }
39
+
40
+ function truncateBody(body: string | null | undefined, maxLen = MAX_BODY_PER_ENTRY): string {
41
+ if (!body) return '(no body)';
42
+ if (body.length <= maxLen) return body;
43
+ return body.slice(0, maxLen) + '...';
44
+ }
45
+
46
+ function formatEntry(entry: any): string {
47
+ const tags = entry.tags ? JSON.parse(entry.tags) : [];
48
+ const tagStr = tags.length ? tags.join(', ') : '';
49
+ const date = fmtDate(entry.updated_at || entry.created_at);
50
+ const icon = kindIcon(entry.kind);
51
+ const meta = [`\`${entry.kind}\``, tagStr, date].filter(Boolean).join(' · ');
52
+ return [
53
+ `- ${icon} **${entry.title || '(untitled)'}**`,
54
+ ` ${meta} · \`${entry.id}\``,
55
+ ` ${truncateBody(entry.body).replace(/\n+/g, ' ').trim()}`,
56
+ ].join('\n');
57
+ }
58
+
59
+ function preloadBucketContext(
60
+ ctx: LocalCtx,
61
+ bucket: string,
62
+ tokenBudget: number
63
+ ): { sections: string[]; tokensUsed: number; entryCount: number } {
64
+ const sections: string[] = [];
65
+ let tokensUsed = 0;
66
+ let entryCount = 0;
67
+
68
+ const bucketTag = `bucket:${bucket}`;
69
+ const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
70
+
71
+ // Query priority kinds (decisions, insights, patterns)
72
+ const kindPlaceholders = PRIORITY_KINDS.map(() => '?').join(',');
73
+ const priorityRows = ctx.db
74
+ .prepare(
75
+ `SELECT * FROM vault
76
+ WHERE kind IN (${kindPlaceholders})
77
+ AND created_at >= ?
78
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
79
+ AND superseded_by IS NULL
80
+ ORDER BY created_at DESC
81
+ LIMIT 30`
82
+ )
83
+ .all(...PRIORITY_KINDS, sinceDate) as any[];
84
+
85
+ // Filter to bucket-tagged entries
86
+ const taggedPriority = priorityRows.filter((r: any) => {
87
+ const tags = r.tags ? JSON.parse(r.tags) : [];
88
+ return tags.includes(bucketTag);
89
+ });
90
+
91
+ if (taggedPriority.length > 0) {
92
+ const header = '### Active Decisions, Insights & Patterns\n';
93
+ const headerTokens = estimateTokens(header);
94
+ if (tokensUsed + headerTokens <= tokenBudget) {
95
+ const entryLines: string[] = [];
96
+ tokensUsed += headerTokens;
97
+ for (const entry of taggedPriority) {
98
+ const line = formatEntry(entry);
99
+ const lineTokens = estimateTokens(line);
100
+ if (tokensUsed + lineTokens > tokenBudget) break;
101
+ entryLines.push(line);
102
+ tokensUsed += lineTokens;
103
+ entryCount++;
104
+ }
105
+ if (entryLines.length > 0) {
106
+ sections.push(header + entryLines.join('\n'));
107
+ }
108
+ }
109
+ }
110
+
111
+ // Query recent entries (any kind)
112
+ const recentRows = ctx.db
113
+ .prepare(
114
+ `SELECT * FROM vault
115
+ WHERE created_at >= ?
116
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
117
+ AND superseded_by IS NULL
118
+ ORDER BY created_at DESC
119
+ LIMIT 30`
120
+ )
121
+ .all(sinceDate) as any[];
122
+
123
+ const seenIds = new Set(taggedPriority.map((r: any) => r.id));
124
+ const taggedRecent = recentRows.filter((r: any) => {
125
+ if (seenIds.has(r.id)) return false;
126
+ const tags = r.tags ? JSON.parse(r.tags) : [];
127
+ return tags.includes(bucketTag);
128
+ });
129
+
130
+ if (taggedRecent.length > 0) {
131
+ const header = `\n### Recent Entries (last ${RECENT_DAYS} days)\n`;
132
+ const headerTokens = estimateTokens(header);
133
+ if (tokensUsed + headerTokens <= tokenBudget) {
134
+ const entryLines: string[] = [];
135
+ tokensUsed += headerTokens;
136
+ for (const entry of taggedRecent) {
137
+ const line = formatEntry(entry);
138
+ const lineTokens = estimateTokens(line);
139
+ if (tokensUsed + lineTokens > tokenBudget) break;
140
+ entryLines.push(line);
141
+ tokensUsed += lineTokens;
142
+ entryCount++;
143
+ }
144
+ if (entryLines.length > 0) {
145
+ sections.push(header + entryLines.join('\n'));
146
+ }
147
+ }
148
+ }
149
+
150
+ return { sections, tokensUsed, entryCount };
151
+ }
152
+
153
+ export async function handler(
154
+ { scope, preload_bucket, max_tokens }: { scope?: string; preload_bucket?: string; max_tokens?: number } = {},
155
+ ctx?: LocalCtx,
156
+ shared?: SharedCtx
157
+ ): Promise<ToolResult> {
20
158
  const lines = [
21
159
  '## Context Reset',
22
160
  '',
23
161
  'Active session context has been cleared. All previous context from this session should be disregarded.',
24
162
  '',
25
- 'Vault entries are unchanged no data was deleted.',
163
+ 'Vault entries are unchanged -- no data was deleted.',
26
164
  ];
27
165
 
28
- if (scope?.trim()) {
29
- const trimmed = scope.trim();
166
+ const trimmedScope = scope?.trim() || '';
167
+ // If preload_bucket is set but scope is not, infer scope from the bucket
168
+ const effectiveScope = trimmedScope || preload_bucket?.trim() || '';
169
+
170
+ if (effectiveScope) {
30
171
  lines.push(
31
172
  '',
32
- `### Active Scope: \`${trimmed}\``,
173
+ `### Active Scope: \`${effectiveScope}\``,
33
174
  '',
34
- `Going forward, treat \`get_context\` calls as scoped to the tag or project **"${trimmed}"** unless the user explicitly requests a different scope or passes their own tag filters.`
175
+ `Going forward, treat \`get_context\` calls as scoped to the tag or project **"${effectiveScope}"** unless the user explicitly requests a different scope or passes their own tag filters.`
35
176
  );
36
177
  } else {
37
- lines.push('', 'No scope set. Use `get_context` normally all vault entries are accessible.');
178
+ lines.push('', 'No scope set. Use `get_context` normally -- all vault entries are accessible.');
179
+ }
180
+
181
+ // Preload bucket context if requested and ctx is available
182
+ const bucket = preload_bucket?.trim();
183
+ let preloadMeta: Record<string, unknown> = {};
184
+ if (bucket && ctx) {
185
+ const vaultErr = ensureVaultExists(ctx.config);
186
+ if (vaultErr) {
187
+ lines.push('', `> **Warning:** Could not preload bucket context: vault not found.`);
188
+ } else {
189
+ if (shared?.ensureIndexed) {
190
+ await shared.ensureIndexed({ blocking: false });
191
+ }
192
+
193
+ const tokenBudget = max_tokens || DEFAULT_PRELOAD_TOKENS;
194
+ const { sections, tokensUsed, entryCount } = preloadBucketContext(ctx, bucket, tokenBudget);
195
+
196
+ if (sections.length > 0) {
197
+ lines.push(
198
+ '',
199
+ `## Preloaded Context: \`${bucket}\``,
200
+ `_${entryCount} entries | ${tokensUsed} / ${tokenBudget} tokens used_`,
201
+ '',
202
+ ...sections
203
+ );
204
+ } else {
205
+ lines.push(
206
+ '',
207
+ `_No recent entries found in bucket \`${bucket}\`. Use \`get_context\` to search across all entries._`
208
+ );
209
+ }
210
+
211
+ preloadMeta = {
212
+ preload_bucket: bucket,
213
+ preload_entries: entryCount,
214
+ preload_tokens_used: tokensUsed,
215
+ preload_tokens_budget: tokenBudget,
216
+ };
217
+ }
38
218
  }
39
219
 
40
- return ok(lines.join('\n'));
220
+ const result: ToolResult = ok(lines.join('\n'));
221
+ result._meta = {
222
+ scope: effectiveScope || null,
223
+ ...preloadMeta,
224
+ };
225
+ return result;
41
226
  }
@@ -14,6 +14,47 @@ function relativeTime(ts: number): string {
14
14
  return `${hrs} hour${hrs === 1 ? '' : 's'} ago`;
15
15
  }
16
16
 
17
+ interface LearningRate {
18
+ saved7d: number;
19
+ saved30d: number;
20
+ sessions30d: number;
21
+ savesPerSession: string;
22
+ recalls30d: number;
23
+ recallToSaveRatio: string;
24
+ }
25
+
26
+ function computeLearningRate(ctx: LocalCtx): LearningRate | null {
27
+ try {
28
+ const saved7d = (ctx.db.prepare(
29
+ `SELECT COUNT(*) as c FROM vault WHERE created_at >= datetime('now', '-7 days') AND kind NOT IN ('bucket', 'session', 'brief')`
30
+ ).get() as any)?.c ?? 0;
31
+
32
+ const saved30d = (ctx.db.prepare(
33
+ `SELECT COUNT(*) as c FROM vault WHERE created_at >= datetime('now', '-30 days') AND kind NOT IN ('bucket', 'session', 'brief')`
34
+ ).get() as any)?.c ?? 0;
35
+
36
+ const sessions30d = (ctx.db.prepare(
37
+ `SELECT COUNT(*) as c FROM vault WHERE created_at >= datetime('now', '-30 days') AND kind = 'session'`
38
+ ).get() as any)?.c ?? 0;
39
+
40
+ const savesPerSession = sessions30d > 0
41
+ ? (saved30d / sessions30d).toFixed(1)
42
+ : '0.0';
43
+
44
+ const recalls30d = (ctx.db.prepare(
45
+ `SELECT SUM(recall_count) as c FROM vault WHERE last_recalled_at >= datetime('now', '-30 days')`
46
+ ).get() as any)?.c ?? 0;
47
+
48
+ const recallToSaveRatio = saved30d > 0
49
+ ? `${(recalls30d / saved30d).toFixed(1)}:1`
50
+ : recalls30d > 0 ? `${recalls30d}:0 (all recall, no save)` : 'n/a';
51
+
52
+ return { saved7d, saved30d, sessions30d, savesPerSession, recalls30d, recallToSaveRatio };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
17
58
  export const name = 'context_status';
18
59
 
19
60
  export const description =
@@ -114,6 +155,30 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
114
155
  }
115
156
  }
116
157
 
158
+ const learningRate = computeLearningRate(ctx);
159
+ if (learningRate) {
160
+ lines.push(``, `### Learning Rate`);
161
+ lines.push(`| Metric | Value |`);
162
+ lines.push(`|---|---|`);
163
+ lines.push(`| **Saved (7 days)** | ${learningRate.saved7d} |`);
164
+ lines.push(`| **Saved (30 days)** | ${learningRate.saved30d} |`);
165
+ lines.push(`| **Sessions (30 days)** | ${learningRate.sessions30d} |`);
166
+ if (learningRate.sessions30d > 0) {
167
+ lines.push(`| **Saves per session** | ${learningRate.savesPerSession} |`);
168
+ }
169
+ if (learningRate.recalls30d > 0 || learningRate.saved30d > 0) {
170
+ lines.push(`| **Recalls (30 days)** | ${learningRate.recalls30d} |`);
171
+ lines.push(`| **Recall:save ratio** | ${learningRate.recallToSaveRatio} |`);
172
+ }
173
+ if (learningRate.saved30d === 0) {
174
+ lines.push('');
175
+ lines.push('_No entries saved in 30 days. Knowledge may be getting lost between sessions._');
176
+ } else if (learningRate.sessions30d > 0 && learningRate.savesPerSession === '0.0') {
177
+ lines.push('');
178
+ lines.push('_Very low save rate. Consider using `session_end` to capture learnings._');
179
+ }
180
+ }
181
+
117
182
  if (status.stalePaths) {
118
183
  lines.push(``);
119
184
  lines.push(`### ⚠ Stale Paths`);
@@ -243,6 +243,22 @@ function checkStaleness(entry: any): { stale: boolean; stale_reason: string } |
243
243
  return null;
244
244
  }
245
245
 
246
+ const ERROR_TERMS = /\b(error|bug|fix|debug|failing|broken|crash|exception|issue|wrong|unexpected|stacktrace|traceback)\b/i;
247
+
248
+ function generateSaveHint(query: string | undefined, resultCount: number): string | null {
249
+ if (!query) return null;
250
+
251
+ if (ERROR_TERMS.test(query)) {
252
+ return 'If you solve this, save the root cause and fix as an insight.';
253
+ }
254
+
255
+ if (resultCount === 0) {
256
+ return 'No existing entries for this topic. If you learn something useful, save it for future sessions.';
257
+ }
258
+
259
+ return null;
260
+ }
261
+
246
262
  export const name = 'get_context';
247
263
 
248
264
  export const description =
@@ -679,7 +695,7 @@ export async function handler(
679
695
  }
680
696
  }
681
697
 
682
- // Graph traversal: follow related_to links bidirectionally
698
+ // Graph traversal: follow related_to links bidirectionally + co-retrieval edges
683
699
  if (follow_links) {
684
700
  const { forward, backward } = collectLinkedEntries(ctx.db, filtered as any);
685
701
  const allLinked: any[] = [...(forward as any[]), ...(backward as any[])];
@@ -706,6 +722,69 @@ export async function handler(
706
722
  } else {
707
723
  lines.push(`## Linked Entries\n\nNo related entries found.\n`);
708
724
  }
725
+
726
+ // Co-retrieval graph traversal: edges with count > 3
727
+ const primaryIds = filtered.map((e: any) => e.id);
728
+ const primaryIdSet = new Set([...primaryIds, ...Array.from(seen)]);
729
+ const coRetrieved: any[] = [];
730
+
731
+ try {
732
+ const CO_RETRIEVAL_THRESHOLD = 3;
733
+ const placeholders = primaryIds.map(() => '?').join(',');
734
+ const coRows = ctx.db
735
+ .prepare(
736
+ `SELECT entry_a, entry_b, count FROM co_retrievals
737
+ WHERE count > ? AND (entry_a IN (${placeholders}) OR entry_b IN (${placeholders}))`
738
+ )
739
+ .all(CO_RETRIEVAL_THRESHOLD, ...primaryIds, ...primaryIds) as {
740
+ entry_a: string;
741
+ entry_b: string;
742
+ count: number;
743
+ }[];
744
+
745
+ const linkedIds = new Map<string, number>();
746
+ for (const row of coRows) {
747
+ for (const candidate of [row.entry_a, row.entry_b]) {
748
+ if (!primaryIdSet.has(candidate)) {
749
+ const existing = linkedIds.get(candidate) ?? 0;
750
+ if (row.count > existing) linkedIds.set(candidate, row.count);
751
+ }
752
+ }
753
+ }
754
+
755
+ if (linkedIds.size > 0) {
756
+ const coIds = Array.from(linkedIds.keys());
757
+ const coPlaceholders = coIds.map(() => '?').join(',');
758
+ const coEntries = ctx.db
759
+ .prepare(
760
+ `SELECT * FROM vault WHERE id IN (${coPlaceholders}) AND (expires_at IS NULL OR expires_at > datetime('now')) AND superseded_by IS NULL`
761
+ )
762
+ .all(...coIds) as any[];
763
+
764
+ for (const entry of coEntries) {
765
+ coRetrieved.push({ ...entry, co_retrieval_weight: linkedIds.get(entry.id) ?? 0 });
766
+ }
767
+ }
768
+ } catch {
769
+ // Non-fatal: co-retrieval traversal is best-effort
770
+ }
771
+
772
+ if (coRetrieved.length > 0) {
773
+ coRetrieved.sort((a: any, b: any) => b.co_retrieval_weight - a.co_retrieval_weight);
774
+ lines.push(
775
+ `## Co-Retrieved Entries (${coRetrieved.length} via usage patterns)\n`
776
+ );
777
+ for (const r of coRetrieved) {
778
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
779
+ const tagStr = entryTags.length ? entryTags.join(', ') : '';
780
+ const icon = kindIcon(r.kind);
781
+ lines.push(`### ${icon} ${r.title || '(untitled)'} (weight: ${r.co_retrieval_weight})`);
782
+ const meta = [`\`${r.kind}\``, tagStr].filter(Boolean).join(' · ');
783
+ lines.push(`${meta} \nid: \`${r.id}\``);
784
+ lines.push(truncateBody(r.body, body_limit ?? 300));
785
+ lines.push('');
786
+ }
787
+ }
709
788
  }
710
789
 
711
790
  // Consolidation suggestion detection — lazy, opportunistic, vault-wide
@@ -743,6 +822,12 @@ export async function handler(
743
822
  if (consolidationSuggestions.length > 0) {
744
823
  meta.consolidation_suggestions = consolidationSuggestions;
745
824
  }
825
+
826
+ const saveHint = generateSaveHint(query, filtered.length);
827
+ if (saveHint) {
828
+ meta.save_hint = saveHint;
829
+ }
830
+
746
831
  result._meta = meta;
747
832
  return result;
748
833
  }