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.
- package/assets/vault-error-hook.mjs +106 -0
- package/assets/vault-recall-hook.mjs +67 -0
- package/bin/cli.js +607 -2
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +4 -0
- package/dist/register-tools.js.map +1 -1
- package/dist/tools/clear-context.d.ts +7 -3
- package/dist/tools/clear-context.d.ts.map +1 -1
- package/dist/tools/clear-context.js +157 -8
- package/dist/tools/clear-context.js.map +1 -1
- package/dist/tools/context-status.d.ts.map +1 -1
- package/dist/tools/context-status.js +42 -0
- package/dist/tools/context-status.js.map +1 -1
- package/dist/tools/get-context.d.ts.map +1 -1
- package/dist/tools/get-context.js +66 -1
- package/dist/tools/get-context.js.map +1 -1
- package/dist/tools/recall.d.ts +25 -0
- package/dist/tools/recall.d.ts.map +1 -0
- package/dist/tools/recall.js +257 -0
- package/dist/tools/recall.js.map +1 -0
- package/dist/tools/save-context.d.ts.map +1 -1
- package/dist/tools/save-context.js +20 -0
- package/dist/tools/save-context.js.map +1 -1
- package/dist/tools/session-end.d.ts +20 -0
- package/dist/tools/session-end.d.ts.map +1 -0
- package/dist/tools/session-end.js +288 -0
- package/dist/tools/session-end.js.map +1 -0
- package/dist/tools/session-start.d.ts +2 -1
- package/dist/tools/session-start.d.ts.map +1 -1
- package/dist/tools/session-start.js +16 -5
- package/dist/tools/session-start.js.map +1 -1
- package/node_modules/@context-vault/core/package.json +1 -1
- package/package.json +2 -2
- package/src/register-tools.ts +4 -0
- package/src/tools/clear-context.ts +195 -10
- package/src/tools/context-status.ts +65 -0
- package/src/tools/get-context.ts +86 -1
- package/src/tools/recall.ts +307 -0
- package/src/tools/save-context.ts +25 -0
- package/src/tools/session-end.ts +338 -0
- 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
|
-
|
|
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
|
|
163
|
+
'Vault entries are unchanged -- no data was deleted.',
|
|
26
164
|
];
|
|
27
165
|
|
|
28
|
-
|
|
29
|
-
|
|
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: \`${
|
|
173
|
+
`### Active Scope: \`${effectiveScope}\``,
|
|
33
174
|
'',
|
|
34
|
-
`Going forward, treat \`get_context\` calls as scoped to the tag or project **"${
|
|
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
|
|
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
|
-
|
|
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`);
|
package/src/tools/get-context.ts
CHANGED
|
@@ -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
|
}
|