context-vault 2.17.0 → 2.17.1
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/bin/cli.js +143 -47
- package/node_modules/@context-vault/core/package.json +2 -2
- package/node_modules/@context-vault/core/src/capture/file-ops.js +2 -0
- package/node_modules/@context-vault/core/src/capture/index.js +14 -0
- package/node_modules/@context-vault/core/src/core/categories.js +1 -0
- package/node_modules/@context-vault/core/src/core/files.js +6 -29
- package/node_modules/@context-vault/core/src/core/frontmatter.js +1 -0
- package/node_modules/@context-vault/core/src/core/linking.js +161 -0
- package/node_modules/@context-vault/core/src/core/migrate-dirs.js +196 -0
- package/node_modules/@context-vault/core/src/core/temporal.js +146 -0
- package/node_modules/@context-vault/core/src/index/db.js +178 -8
- package/node_modules/@context-vault/core/src/index/index.js +89 -28
- package/node_modules/@context-vault/core/src/index.js +5 -0
- package/node_modules/@context-vault/core/src/retrieve/index.js +9 -136
- package/node_modules/@context-vault/core/src/server/tools/create-snapshot.js +37 -68
- package/node_modules/@context-vault/core/src/server/tools/get-context.js +108 -21
- package/node_modules/@context-vault/core/src/server/tools/save-context.js +29 -6
- package/node_modules/@context-vault/core/src/server/tools.js +0 -2
- package/package.json +3 -3
- package/src/server/index.js +3 -2
- package/node_modules/@context-vault/core/src/server/tools/submit-feedback.js +0 -55
|
@@ -66,37 +66,53 @@ export async function indexEntry(
|
|
|
66
66
|
const cat = category || categoryFor(kind);
|
|
67
67
|
const effectiveTier = tier || defaultTierFor(kind);
|
|
68
68
|
const userIdVal = userId || null;
|
|
69
|
+
const isLocal = ctx.stmts._mode === "local";
|
|
69
70
|
|
|
70
71
|
let wasUpdate = false;
|
|
71
72
|
|
|
72
|
-
// Entity upsert: check by (kind, identity_key, user_id) first
|
|
73
|
+
// Entity upsert: check by (kind, identity_key[, user_id]) first.
|
|
74
|
+
// Local mode omits user_id — all entries are user-agnostic.
|
|
73
75
|
if (cat === "entity" && identity_key) {
|
|
74
|
-
const existing =
|
|
75
|
-
kind,
|
|
76
|
-
identity_key,
|
|
77
|
-
userIdVal,
|
|
78
|
-
);
|
|
76
|
+
const existing = isLocal
|
|
77
|
+
? ctx.stmts.getByIdentityKey.get(kind, identity_key)
|
|
78
|
+
: ctx.stmts.getByIdentityKey.get(kind, identity_key, userIdVal);
|
|
79
79
|
if (existing) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
80
|
+
if (isLocal) {
|
|
81
|
+
ctx.stmts.upsertByIdentityKey.run(
|
|
82
|
+
title || null,
|
|
83
|
+
body,
|
|
84
|
+
metaJson,
|
|
85
|
+
tagsJson,
|
|
86
|
+
source || "claude-code",
|
|
87
|
+
cat,
|
|
88
|
+
filePath,
|
|
89
|
+
expires_at || null,
|
|
90
|
+
sourceFilesJson,
|
|
91
|
+
kind,
|
|
92
|
+
identity_key,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
ctx.stmts.upsertByIdentityKey.run(
|
|
96
|
+
title || null,
|
|
97
|
+
body,
|
|
98
|
+
metaJson,
|
|
99
|
+
tagsJson,
|
|
100
|
+
source || "claude-code",
|
|
101
|
+
cat,
|
|
102
|
+
filePath,
|
|
103
|
+
expires_at || null,
|
|
104
|
+
sourceFilesJson,
|
|
105
|
+
kind,
|
|
106
|
+
identity_key,
|
|
107
|
+
userIdVal,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
94
110
|
wasUpdate = true;
|
|
95
111
|
}
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
if (!wasUpdate) {
|
|
99
|
-
// Prepare encryption if ctx.encrypt is available
|
|
115
|
+
// Prepare encryption if ctx.encrypt is available (hosted mode only)
|
|
100
116
|
let encrypted = null;
|
|
101
117
|
if (ctx.encrypt) {
|
|
102
118
|
encrypted = await ctx.encrypt({ title, body, meta });
|
|
@@ -104,7 +120,8 @@ export async function indexEntry(
|
|
|
104
120
|
|
|
105
121
|
try {
|
|
106
122
|
if (encrypted) {
|
|
107
|
-
//
|
|
123
|
+
// Hosted-mode encrypted insert: store preview in body for FTS,
|
|
124
|
+
// full content in encrypted columns.
|
|
108
125
|
const bodyPreview = body.slice(0, 200);
|
|
109
126
|
ctx.stmts.insertEntryEncrypted.run(
|
|
110
127
|
id,
|
|
@@ -128,7 +145,27 @@ export async function indexEntry(
|
|
|
128
145
|
sourceFilesJson,
|
|
129
146
|
effectiveTier,
|
|
130
147
|
);
|
|
148
|
+
} else if (isLocal) {
|
|
149
|
+
// Local mode: no user_id column — 15 params.
|
|
150
|
+
ctx.stmts.insertEntry.run(
|
|
151
|
+
id,
|
|
152
|
+
kind,
|
|
153
|
+
cat,
|
|
154
|
+
title || null,
|
|
155
|
+
body,
|
|
156
|
+
metaJson,
|
|
157
|
+
tagsJson,
|
|
158
|
+
source || "claude-code",
|
|
159
|
+
filePath,
|
|
160
|
+
identity_key || null,
|
|
161
|
+
expires_at || null,
|
|
162
|
+
createdAt,
|
|
163
|
+
createdAt,
|
|
164
|
+
sourceFilesJson,
|
|
165
|
+
effectiveTier,
|
|
166
|
+
);
|
|
131
167
|
} else {
|
|
168
|
+
// Hosted mode without encryption: 16 params (includes user_id).
|
|
132
169
|
ctx.stmts.insertEntry.run(
|
|
133
170
|
id,
|
|
134
171
|
userIdVal,
|
|
@@ -262,10 +299,14 @@ export async function reindex(ctx, opts = {}) {
|
|
|
262
299
|
|
|
263
300
|
if (!existsSync(ctx.config.vaultDir)) return stats;
|
|
264
301
|
|
|
265
|
-
// Use INSERT OR IGNORE for reindex — handles files with duplicate frontmatter IDs
|
|
266
|
-
//
|
|
302
|
+
// Use INSERT OR IGNORE for reindex — handles files with duplicate frontmatter IDs.
|
|
303
|
+
// Local mode: no user_id column (15 params).
|
|
304
|
+
// Hosted mode: user_id is NULL for file-sourced entries (14 params, NULL literal).
|
|
305
|
+
const isLocalReindex = ctx.stmts._mode === "local";
|
|
267
306
|
const upsertEntry = ctx.db.prepare(
|
|
268
|
-
|
|
307
|
+
isLocalReindex
|
|
308
|
+
? `INSERT OR IGNORE INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
309
|
+
: `INSERT OR IGNORE INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
269
310
|
);
|
|
270
311
|
|
|
271
312
|
// Auto-discover kind directories, supporting both:
|
|
@@ -317,7 +358,7 @@ export async function reindex(ctx, opts = {}) {
|
|
|
317
358
|
// P3: Fetch all mutable fields for change detection
|
|
318
359
|
const dbRows = ctx.db
|
|
319
360
|
.prepare(
|
|
320
|
-
"SELECT id, file_path, body, title, tags, meta FROM vault WHERE kind = ?",
|
|
361
|
+
"SELECT id, file_path, body, title, tags, meta, related_to FROM vault WHERE kind = ?",
|
|
321
362
|
)
|
|
322
363
|
.all(kind);
|
|
323
364
|
const dbByPath = new Map(dbRows.map((r) => [r.file_path, r]));
|
|
@@ -343,6 +384,12 @@ export async function reindex(ctx, opts = {}) {
|
|
|
343
384
|
// Extract identity_key and expires_at from frontmatter
|
|
344
385
|
const identity_key = fmMeta.identity_key || null;
|
|
345
386
|
const expires_at = fmMeta.expires_at || null;
|
|
387
|
+
const related_to = Array.isArray(fmMeta.related_to)
|
|
388
|
+
? fmMeta.related_to
|
|
389
|
+
: null;
|
|
390
|
+
const relatedToJson = related_to?.length
|
|
391
|
+
? JSON.stringify(related_to)
|
|
392
|
+
: null;
|
|
346
393
|
|
|
347
394
|
// Derive folder from disk location (source of truth)
|
|
348
395
|
const meta = { ...(parsed.meta || {}) };
|
|
@@ -372,6 +419,9 @@ export async function reindex(ctx, opts = {}) {
|
|
|
372
419
|
fmMeta.updated || created,
|
|
373
420
|
);
|
|
374
421
|
if (result.changes > 0) {
|
|
422
|
+
if (relatedToJson && ctx.stmts.updateRelatedTo) {
|
|
423
|
+
ctx.stmts.updateRelatedTo.run(relatedToJson, id);
|
|
424
|
+
}
|
|
375
425
|
if (category !== "event") {
|
|
376
426
|
const rowidResult = ctx.stmts.getRowid.get(id);
|
|
377
427
|
if (rowidResult?.rowid) {
|
|
@@ -396,8 +446,16 @@ export async function reindex(ctx, opts = {}) {
|
|
|
396
446
|
const bodyChanged = existing.body !== parsed.body;
|
|
397
447
|
const tagsChanged = tagsJson !== (existing.tags || null);
|
|
398
448
|
const metaChanged = metaJson !== (existing.meta || null);
|
|
399
|
-
|
|
400
|
-
|
|
449
|
+
const relatedToChanged =
|
|
450
|
+
relatedToJson !== (existing.related_to || null);
|
|
451
|
+
|
|
452
|
+
if (
|
|
453
|
+
bodyChanged ||
|
|
454
|
+
titleChanged ||
|
|
455
|
+
tagsChanged ||
|
|
456
|
+
metaChanged ||
|
|
457
|
+
relatedToChanged
|
|
458
|
+
) {
|
|
401
459
|
ctx.stmts.updateEntry.run(
|
|
402
460
|
parsed.title || null,
|
|
403
461
|
parsed.body,
|
|
@@ -409,6 +467,9 @@ export async function reindex(ctx, opts = {}) {
|
|
|
409
467
|
expires_at,
|
|
410
468
|
filePath,
|
|
411
469
|
);
|
|
470
|
+
if (relatedToChanged && ctx.stmts.updateRelatedTo) {
|
|
471
|
+
ctx.stmts.updateRelatedTo.run(relatedToJson, existing.id);
|
|
472
|
+
}
|
|
412
473
|
|
|
413
474
|
// Queue re-embed if title or body changed (vector ops deferred to Phase 2)
|
|
414
475
|
if ((bodyChanged || titleChanged) && category !== "event") {
|
|
@@ -29,6 +29,11 @@ export {
|
|
|
29
29
|
parseEntryFromMarkdown,
|
|
30
30
|
} from "./core/frontmatter.js";
|
|
31
31
|
export { gatherVaultStatus } from "./core/status.js";
|
|
32
|
+
export {
|
|
33
|
+
PLURAL_TO_SINGULAR,
|
|
34
|
+
planMigration,
|
|
35
|
+
executeMigration,
|
|
36
|
+
} from "./core/migrate-dirs.js";
|
|
32
37
|
|
|
33
38
|
// Capture layer
|
|
34
39
|
export {
|
|
@@ -11,8 +11,6 @@ const NEAR_DUP_THRESHOLD = 0.92;
|
|
|
11
11
|
|
|
12
12
|
const RRF_K = 60;
|
|
13
13
|
|
|
14
|
-
const MMR_LAMBDA = 0.7;
|
|
15
|
-
|
|
16
14
|
/**
|
|
17
15
|
* Exponential recency decay score based on updated_at timestamp.
|
|
18
16
|
* Returns e^(-decayRate * ageDays) for valid dates, or 0.5 as a neutral
|
|
@@ -132,108 +130,16 @@ export function reciprocalRankFusion(rankedLists, k = RRF_K) {
|
|
|
132
130
|
return scores;
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
/**
|
|
136
|
-
* Jaccard similarity between two strings based on word sets.
|
|
137
|
-
* Used as a fallback for MMR when embedding vectors are unavailable.
|
|
138
|
-
*
|
|
139
|
-
* @param {string} a
|
|
140
|
-
* @param {string} b
|
|
141
|
-
* @returns {number} Similarity in [0, 1].
|
|
142
|
-
*/
|
|
143
|
-
export function jaccardSimilarity(a, b) {
|
|
144
|
-
const wordsA = new Set((a ?? "").toLowerCase().split(/\W+/).filter(Boolean));
|
|
145
|
-
const wordsB = new Set((b ?? "").toLowerCase().split(/\W+/).filter(Boolean));
|
|
146
|
-
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
|
147
|
-
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
148
|
-
let intersection = 0;
|
|
149
|
-
for (const w of wordsA) if (wordsB.has(w)) intersection++;
|
|
150
|
-
return intersection / (wordsA.size + wordsB.size - intersection);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Maximal Marginal Relevance reranking.
|
|
155
|
-
*
|
|
156
|
-
* Selects up to n candidates that balance relevance to the query and
|
|
157
|
-
* diversity from already-selected results.
|
|
158
|
-
*
|
|
159
|
-
* MMR_score = lambda * querySim(doc) - (1 - lambda) * max(sim(doc, selected))
|
|
160
|
-
*
|
|
161
|
-
* @param {Array<object>} candidates - Entries with at least {id, title, body}.
|
|
162
|
-
* @param {Map<string, number>} querySimMap - Map of id -> relevance score.
|
|
163
|
-
* @param {Map<string, Float32Array|null>} embeddingMap - Map of id -> embedding (null if unavailable).
|
|
164
|
-
* @param {number} n - Number of results to select.
|
|
165
|
-
* @param {number} lambda - Trade-off weight (default MMR_LAMBDA = 0.7).
|
|
166
|
-
* @returns {Array<object>} Reranked subset of candidates (length <= n).
|
|
167
|
-
*/
|
|
168
|
-
export function maximalMarginalRelevance(
|
|
169
|
-
candidates,
|
|
170
|
-
querySimMap,
|
|
171
|
-
embeddingMap,
|
|
172
|
-
n,
|
|
173
|
-
lambda = MMR_LAMBDA,
|
|
174
|
-
) {
|
|
175
|
-
if (candidates.length === 0) return [];
|
|
176
|
-
|
|
177
|
-
const remaining = [...candidates];
|
|
178
|
-
const selected = [];
|
|
179
|
-
const selectedVecs = [];
|
|
180
|
-
const selectedEntries = [];
|
|
181
|
-
|
|
182
|
-
while (selected.length < n && remaining.length > 0) {
|
|
183
|
-
let bestIdx = -1;
|
|
184
|
-
let bestScore = -Infinity;
|
|
185
|
-
|
|
186
|
-
for (let i = 0; i < remaining.length; i++) {
|
|
187
|
-
const candidate = remaining[i];
|
|
188
|
-
const relevance = querySimMap.get(candidate.id) ?? 0;
|
|
189
|
-
|
|
190
|
-
let maxRedundancy = 0;
|
|
191
|
-
if (selectedVecs.length > 0) {
|
|
192
|
-
const vec = embeddingMap.get(candidate.id);
|
|
193
|
-
for (let j = 0; j < selectedVecs.length; j++) {
|
|
194
|
-
let sim;
|
|
195
|
-
if (vec && selectedVecs[j]) {
|
|
196
|
-
sim = dotProduct(vec, selectedVecs[j]);
|
|
197
|
-
} else {
|
|
198
|
-
const selEntry = selectedEntries[j];
|
|
199
|
-
sim = jaccardSimilarity(
|
|
200
|
-
`${candidate.title} ${candidate.body}`,
|
|
201
|
-
`${selEntry.title} ${selEntry.body}`,
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
if (sim > maxRedundancy) maxRedundancy = sim;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const score = lambda * relevance - (1 - lambda) * maxRedundancy;
|
|
209
|
-
if (score > bestScore) {
|
|
210
|
-
bestScore = score;
|
|
211
|
-
bestIdx = i;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (bestIdx === -1) break;
|
|
216
|
-
|
|
217
|
-
const chosen = remaining.splice(bestIdx, 1)[0];
|
|
218
|
-
selected.push(chosen);
|
|
219
|
-
selectedVecs.push(embeddingMap.get(chosen.id) ?? null);
|
|
220
|
-
selectedEntries.push(chosen);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return selected;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
133
|
/**
|
|
227
134
|
* Hybrid search combining FTS5 text matching and vector similarity,
|
|
228
|
-
* with RRF merging
|
|
135
|
+
* with RRF merging, recency decay, and near-duplicate suppression.
|
|
229
136
|
*
|
|
230
137
|
* Pipeline:
|
|
231
138
|
* 1. FTS5 ranked list
|
|
232
139
|
* 2. Vector (semantic) ranked list
|
|
233
140
|
* 3. RRF: merge the two ranked lists into a single score
|
|
234
|
-
* 4.
|
|
235
|
-
* 5.
|
|
236
|
-
* 6. Near-duplicate suppression on the final selection
|
|
141
|
+
* 4. Recency decay: penalise old events (knowledge/entity entries unaffected)
|
|
142
|
+
* 5. Near-duplicate suppression (cosine similarity > 0.92 threshold)
|
|
237
143
|
*
|
|
238
144
|
* @param {import('../server/types.js').BaseCtx} ctx
|
|
239
145
|
* @param {string} query
|
|
@@ -383,20 +289,6 @@ export async function hybridSearch(
|
|
|
383
289
|
rrfScores.set(id, (rrfScores.get(id) ?? 0) * boost);
|
|
384
290
|
}
|
|
385
291
|
|
|
386
|
-
// Stage 3b: Frequency signal — log(1 + hit_count) / log(1 + max_hit_count)
|
|
387
|
-
const allRows = [...rowMap.values()];
|
|
388
|
-
const maxHitCount = Math.max(...allRows.map((e) => e.hit_count || 0), 0);
|
|
389
|
-
if (maxHitCount > 0) {
|
|
390
|
-
const logMax = Math.log(1 + maxHitCount);
|
|
391
|
-
for (const entry of allRows) {
|
|
392
|
-
const freqScore = Math.log(1 + (entry.hit_count || 0)) / logMax;
|
|
393
|
-
rrfScores.set(
|
|
394
|
-
entry.id,
|
|
395
|
-
(rrfScores.get(entry.id) ?? 0) + freqScore * 0.13,
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
292
|
// Attach final score to each entry and sort by RRF score descending
|
|
401
293
|
const candidates = [...rowMap.values()].map((entry) => ({
|
|
402
294
|
...entry,
|
|
@@ -404,7 +296,7 @@ export async function hybridSearch(
|
|
|
404
296
|
}));
|
|
405
297
|
candidates.sort((a, b) => b.score - a.score);
|
|
406
298
|
|
|
407
|
-
// Stage 4: Fetch embeddings for
|
|
299
|
+
// Stage 4: Fetch embeddings for near-duplicate suppression
|
|
408
300
|
const embeddingMap = new Map();
|
|
409
301
|
if (queryVec && idToRowid.size > 0) {
|
|
410
302
|
const rowidToId = new Map();
|
|
@@ -429,34 +321,15 @@ export async function hybridSearch(
|
|
|
429
321
|
}
|
|
430
322
|
}
|
|
431
323
|
} catch (_) {
|
|
432
|
-
// Embeddings unavailable —
|
|
324
|
+
// Embeddings unavailable — near-dup suppression skipped
|
|
433
325
|
}
|
|
434
326
|
}
|
|
435
327
|
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
for (const candidate of candidates) {
|
|
439
|
-
querySimMap.set(
|
|
440
|
-
candidate.id,
|
|
441
|
-
vecSimMap.has(candidate.id)
|
|
442
|
-
? vecSimMap.get(candidate.id)
|
|
443
|
-
: candidate.score,
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Stage 5: MMR — rerank for diversity using embeddings or Jaccard fallback
|
|
448
|
-
const mmrSelected = maximalMarginalRelevance(
|
|
449
|
-
candidates,
|
|
450
|
-
querySimMap,
|
|
451
|
-
embeddingMap,
|
|
452
|
-
offset + limit,
|
|
453
|
-
);
|
|
454
|
-
|
|
455
|
-
// Stage 6: Near-duplicate suppression (hard filter, not reorder)
|
|
456
|
-
if (queryVec && embeddingMap.size > 0 && mmrSelected.length > limit) {
|
|
328
|
+
// Stage 5: Near-duplicate suppression (cosine similarity > 0.92 threshold)
|
|
329
|
+
if (queryVec && embeddingMap.size > 0) {
|
|
457
330
|
const selected = [];
|
|
458
331
|
const selectedVecs = [];
|
|
459
|
-
for (const candidate of
|
|
332
|
+
for (const candidate of candidates) {
|
|
460
333
|
if (selected.length >= offset + limit) break;
|
|
461
334
|
const vec = embeddingMap.get(candidate.id);
|
|
462
335
|
if (vec && selectedVecs.length > 0) {
|
|
@@ -475,7 +348,7 @@ export async function hybridSearch(
|
|
|
475
348
|
return dedupedPage;
|
|
476
349
|
}
|
|
477
350
|
|
|
478
|
-
const finalPage =
|
|
351
|
+
const finalPage = candidates.slice(offset, offset + limit);
|
|
479
352
|
trackAccess(ctx.db, finalPage);
|
|
480
353
|
return finalPage;
|
|
481
354
|
}
|
|
@@ -5,14 +5,13 @@ import { normalizeKind } from "../../core/files.js";
|
|
|
5
5
|
import { ok, err, ensureVaultExists } from "../helpers.js";
|
|
6
6
|
|
|
7
7
|
const NOISE_KINDS = new Set(["prompt-history", "task-notification"]);
|
|
8
|
-
const
|
|
9
|
-
const MAX_ENTRIES_FOR_SYNTHESIS = 40;
|
|
8
|
+
const MAX_ENTRIES_FOR_GATHER = 40;
|
|
10
9
|
const MAX_BODY_PER_ENTRY = 600;
|
|
11
10
|
|
|
12
11
|
export const name = "create_snapshot";
|
|
13
12
|
|
|
14
13
|
export const description =
|
|
15
|
-
"Pull all relevant vault entries matching a topic,
|
|
14
|
+
"Pull all relevant vault entries matching a topic, deduplicate, and save them as a structured context brief (kind: 'brief'). Entries are formatted as markdown — no external API or LLM call required. The calling agent can synthesize the gathered content directly. Retrieve with: get_context(kind: 'brief', identity_key: '<key>').";
|
|
16
15
|
|
|
17
16
|
export const inputSchema = {
|
|
18
17
|
topic: z.string().describe("The topic or project name to snapshot"),
|
|
@@ -38,62 +37,42 @@ export const inputSchema = {
|
|
|
38
37
|
),
|
|
39
38
|
};
|
|
40
39
|
|
|
41
|
-
function
|
|
42
|
-
const
|
|
40
|
+
function formatGatheredEntries(topic, entries) {
|
|
41
|
+
const header = [
|
|
42
|
+
`# ${topic} — Context Brief`,
|
|
43
|
+
"",
|
|
44
|
+
`*Gathered from ${entries.length} vault ${entries.length === 1 ? "entry" : "entries"}. Synthesize the content below to extract key decisions, patterns, and constraints.*`,
|
|
45
|
+
"",
|
|
46
|
+
"---",
|
|
47
|
+
"",
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
50
|
+
const body = entries
|
|
43
51
|
.map((e, i) => {
|
|
44
52
|
const tags = e.tags ? JSON.parse(e.tags) : [];
|
|
45
53
|
const tagStr = tags.length ? tags.join(", ") : "none";
|
|
46
|
-
const
|
|
54
|
+
const updated = e.updated_at || e.created_at || "unknown";
|
|
55
|
+
const bodyText = e.body
|
|
47
56
|
? e.body.slice(0, MAX_BODY_PER_ENTRY) +
|
|
48
57
|
(e.body.length > MAX_BODY_PER_ENTRY ? "…" : "")
|
|
49
58
|
: "(no body)";
|
|
59
|
+
const title = e.title || `Entry ${i + 1}`;
|
|
50
60
|
return [
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
`## ${i + 1}. [${e.kind}] ${title}`,
|
|
62
|
+
"",
|
|
63
|
+
`**Tags:** ${tagStr}`,
|
|
64
|
+
`**Updated:** ${updated}`,
|
|
65
|
+
`**ID:** \`${e.id}\``,
|
|
66
|
+
"",
|
|
67
|
+
bodyText,
|
|
68
|
+
"",
|
|
69
|
+
"---",
|
|
70
|
+
"",
|
|
55
71
|
].join("\n");
|
|
56
72
|
})
|
|
57
|
-
.join("
|
|
58
|
-
|
|
59
|
-
return `You are a knowledge synthesis assistant. Given the following vault entries about "${topic}", produce a structured context brief.
|
|
60
|
-
|
|
61
|
-
Deduplicate overlapping information, resolve any contradictions (note them in Audit Notes), and organise the content into the sections below. Keep each section concise and actionable. Omit sections that have no relevant content.
|
|
62
|
-
|
|
63
|
-
Output ONLY the markdown document — no preamble, no explanation.
|
|
64
|
-
|
|
65
|
-
Required format:
|
|
66
|
-
# ${topic} — Context Brief
|
|
67
|
-
## Status
|
|
68
|
-
(current state of the topic)
|
|
69
|
-
## Key Decisions
|
|
70
|
-
(architectural or strategic decisions made)
|
|
71
|
-
## Patterns & Conventions
|
|
72
|
-
(recurring patterns, coding conventions, standards)
|
|
73
|
-
## Active Constraints
|
|
74
|
-
(known limitations, hard requirements, deadlines)
|
|
75
|
-
## Open Questions
|
|
76
|
-
(unresolved questions or areas needing investigation)
|
|
77
|
-
## Audit Notes
|
|
78
|
-
(contradictions detected, stale entries flagged with their ids)
|
|
73
|
+
.join("");
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
VAULT ENTRIES:
|
|
82
|
-
|
|
83
|
-
${entriesBlock}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function callLlm(prompt) {
|
|
87
|
-
const { Anthropic } = await import("@anthropic-ai/sdk");
|
|
88
|
-
const client = new Anthropic();
|
|
89
|
-
const message = await client.messages.create({
|
|
90
|
-
model: SYNTHESIS_MODEL,
|
|
91
|
-
max_tokens: 2048,
|
|
92
|
-
messages: [{ role: "user", content: prompt }],
|
|
93
|
-
});
|
|
94
|
-
const block = message.content.find((b) => b.type === "text");
|
|
95
|
-
if (!block) throw new Error("LLM returned no text content");
|
|
96
|
-
return block.text;
|
|
75
|
+
return header + body;
|
|
97
76
|
}
|
|
98
77
|
|
|
99
78
|
function slugifyTopic(topic) {
|
|
@@ -122,7 +101,6 @@ export async function handler(
|
|
|
122
101
|
await ensureIndexed();
|
|
123
102
|
|
|
124
103
|
const normalizedKinds = kinds?.map(normalizeKind) ?? [];
|
|
125
|
-
// Expand buckets to bucket: prefixed tags and merge with explicit tags
|
|
126
104
|
const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
|
|
127
105
|
const effectiveTags = [...(tags ?? []), ...bucketTags];
|
|
128
106
|
|
|
@@ -132,7 +110,7 @@ export async function handler(
|
|
|
132
110
|
for (const kindFilter of normalizedKinds) {
|
|
133
111
|
const rows = await hybridSearch(ctx, topic, {
|
|
134
112
|
kindFilter,
|
|
135
|
-
limit: Math.ceil(
|
|
113
|
+
limit: Math.ceil(MAX_ENTRIES_FOR_GATHER / normalizedKinds.length),
|
|
136
114
|
userIdFilter: userId,
|
|
137
115
|
includeSuperseeded: false,
|
|
138
116
|
});
|
|
@@ -146,7 +124,7 @@ export async function handler(
|
|
|
146
124
|
});
|
|
147
125
|
} else {
|
|
148
126
|
candidates = await hybridSearch(ctx, topic, {
|
|
149
|
-
limit:
|
|
127
|
+
limit: MAX_ENTRIES_FOR_GATHER,
|
|
150
128
|
userIdFilter: userId,
|
|
151
129
|
includeSuperseeded: false,
|
|
152
130
|
});
|
|
@@ -163,25 +141,16 @@ export async function handler(
|
|
|
163
141
|
.filter((r) => NOISE_KINDS.has(r.kind))
|
|
164
142
|
.map((r) => r.id);
|
|
165
143
|
|
|
166
|
-
const
|
|
144
|
+
const gatherEntries = candidates.filter((r) => !NOISE_KINDS.has(r.kind));
|
|
167
145
|
|
|
168
|
-
if (
|
|
146
|
+
if (gatherEntries.length === 0) {
|
|
169
147
|
return err(
|
|
170
|
-
`No entries found for topic "${topic}"
|
|
148
|
+
`No entries found for topic "${topic}". Try a broader topic or different tags.`,
|
|
171
149
|
"NO_ENTRIES",
|
|
172
150
|
);
|
|
173
151
|
}
|
|
174
152
|
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
const prompt = buildSynthesisPrompt(topic, synthesisEntries);
|
|
178
|
-
briefBody = await callLlm(prompt);
|
|
179
|
-
} catch (e) {
|
|
180
|
-
return err(
|
|
181
|
-
`LLM synthesis failed: ${e.message}. Ensure ANTHROPIC_API_KEY is set.`,
|
|
182
|
-
"LLM_ERROR",
|
|
183
|
-
);
|
|
184
|
-
}
|
|
153
|
+
const briefBody = formatGatheredEntries(topic, gatherEntries);
|
|
185
154
|
|
|
186
155
|
const effectiveIdentityKey =
|
|
187
156
|
identity_key ?? `snapshot-${slugifyTopic(topic)}`;
|
|
@@ -205,9 +174,9 @@ export async function handler(
|
|
|
205
174
|
userId,
|
|
206
175
|
meta: {
|
|
207
176
|
topic,
|
|
208
|
-
entry_count:
|
|
177
|
+
entry_count: gatherEntries.length,
|
|
209
178
|
noise_superseded: noiseIds.length,
|
|
210
|
-
synthesized_from:
|
|
179
|
+
synthesized_from: gatherEntries.map((e) => e.id),
|
|
211
180
|
},
|
|
212
181
|
});
|
|
213
182
|
|
|
@@ -215,7 +184,7 @@ export async function handler(
|
|
|
215
184
|
`✓ Snapshot created → id: ${entry.id}`,
|
|
216
185
|
` title: ${entry.title}`,
|
|
217
186
|
` identity_key: ${effectiveIdentityKey}`,
|
|
218
|
-
` synthesized from: ${
|
|
187
|
+
` synthesized from: ${gatherEntries.length} entries`,
|
|
219
188
|
noiseIds.length > 0
|
|
220
189
|
? ` noise superseded: ${noiseIds.length} entries`
|
|
221
190
|
: null,
|