@context-vault/core 2.14.0 → 2.17.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/package.json +1 -1
- package/src/capture/index.js +11 -0
- package/src/consolidation/index.js +112 -0
- package/src/constants.js +7 -2
- package/src/core/categories.js +10 -0
- package/src/core/config.js +46 -2
- package/src/core/status.js +28 -2
- package/src/index/db.js +102 -9
- package/src/index/index.js +48 -21
- package/src/index.js +4 -0
- package/src/retrieve/index.js +268 -64
- package/src/server/tools/context-status.js +7 -0
- package/src/server/tools/create-snapshot.js +12 -3
- package/src/server/tools/get-context.js +311 -11
- package/src/server/tools/ingest-project.js +244 -0
- package/src/server/tools/list-buckets.js +116 -0
- package/src/server/tools/save-context.js +190 -19
- package/src/server/tools/session-start.js +285 -0
- package/src/server/tools.js +6 -0
package/src/retrieve/index.js
CHANGED
|
@@ -7,10 +7,27 @@
|
|
|
7
7
|
* Agent Constraint: Read-only access to DB. Never writes.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const FTS_WEIGHT = 0.4;
|
|
11
|
-
const VEC_WEIGHT = 0.6;
|
|
12
10
|
const NEAR_DUP_THRESHOLD = 0.92;
|
|
13
11
|
|
|
12
|
+
const RRF_K = 60;
|
|
13
|
+
|
|
14
|
+
const MMR_LAMBDA = 0.7;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Exponential recency decay score based on updated_at timestamp.
|
|
18
|
+
* Returns e^(-decayRate * ageDays) for valid dates, or 0.5 as a neutral
|
|
19
|
+
* score when updatedAt is null/undefined.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null|undefined} updatedAt - ISO timestamp
|
|
22
|
+
* @param {number} decayRate - Decay rate per day (default 0.05)
|
|
23
|
+
* @returns {number} Score in [0, 1]
|
|
24
|
+
*/
|
|
25
|
+
export function recencyDecayScore(updatedAt, decayRate = 0.05) {
|
|
26
|
+
if (updatedAt == null) return 0.5;
|
|
27
|
+
const ageDays = (Date.now() - new Date(updatedAt).getTime()) / 86400000;
|
|
28
|
+
return Math.exp(-decayRate * ageDays);
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
/**
|
|
15
32
|
* Dot product of two Float32Array vectors (cosine similarity for unit vectors).
|
|
16
33
|
*/
|
|
@@ -57,6 +74,7 @@ export function recencyBoost(createdAt, category, decayDays = 30) {
|
|
|
57
74
|
*/
|
|
58
75
|
export function buildFilterClauses({
|
|
59
76
|
categoryFilter,
|
|
77
|
+
excludeEvents = false,
|
|
60
78
|
since,
|
|
61
79
|
until,
|
|
62
80
|
userIdFilter,
|
|
@@ -77,6 +95,9 @@ export function buildFilterClauses({
|
|
|
77
95
|
clauses.push("e.category = ?");
|
|
78
96
|
params.push(categoryFilter);
|
|
79
97
|
}
|
|
98
|
+
if (excludeEvents && !categoryFilter) {
|
|
99
|
+
clauses.push("e.category != 'event'");
|
|
100
|
+
}
|
|
80
101
|
if (since) {
|
|
81
102
|
clauses.push("e.created_at >= ?");
|
|
82
103
|
params.push(since);
|
|
@@ -93,7 +114,126 @@ export function buildFilterClauses({
|
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
/**
|
|
96
|
-
*
|
|
117
|
+
* Reciprocal Rank Fusion: merge multiple ranked lists into a single score.
|
|
118
|
+
* Each document receives 1/(k + rank) from each list it appears in.
|
|
119
|
+
*
|
|
120
|
+
* @param {Array<string[]>} rankedLists - Arrays of document IDs in rank order (best first).
|
|
121
|
+
* @param {number} k - Smoothing constant (default RRF_K = 60).
|
|
122
|
+
* @returns {Map<string, number>} Map of id -> RRF score.
|
|
123
|
+
*/
|
|
124
|
+
export function reciprocalRankFusion(rankedLists, k = RRF_K) {
|
|
125
|
+
const scores = new Map();
|
|
126
|
+
for (const list of rankedLists) {
|
|
127
|
+
for (let rank = 0; rank < list.length; rank++) {
|
|
128
|
+
const id = list[rank];
|
|
129
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return scores;
|
|
133
|
+
}
|
|
134
|
+
|
|
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
|
+
/**
|
|
227
|
+
* Hybrid search combining FTS5 text matching and vector similarity,
|
|
228
|
+
* with RRF merging and MMR reranking for diversity.
|
|
229
|
+
*
|
|
230
|
+
* Pipeline:
|
|
231
|
+
* 1. FTS5 ranked list
|
|
232
|
+
* 2. Vector (semantic) ranked list
|
|
233
|
+
* 3. RRF: merge the two ranked lists into a single score
|
|
234
|
+
* 4. Apply recency decay to RRF scores
|
|
235
|
+
* 5. MMR: rerank top candidates for diversity (uses embeddings or Jaccard fallback)
|
|
236
|
+
* 6. Near-duplicate suppression on the final selection
|
|
97
237
|
*
|
|
98
238
|
* @param {import('../server/types.js').BaseCtx} ctx
|
|
99
239
|
* @param {string} query
|
|
@@ -106,6 +246,7 @@ export async function hybridSearch(
|
|
|
106
246
|
{
|
|
107
247
|
kindFilter = null,
|
|
108
248
|
categoryFilter = null,
|
|
249
|
+
excludeEvents = false,
|
|
109
250
|
since = null,
|
|
110
251
|
until = null,
|
|
111
252
|
limit = 20,
|
|
@@ -116,11 +257,13 @@ export async function hybridSearch(
|
|
|
116
257
|
includeSuperseeded = false,
|
|
117
258
|
} = {},
|
|
118
259
|
) {
|
|
119
|
-
const
|
|
260
|
+
const rowMap = new Map();
|
|
120
261
|
const idToRowid = new Map();
|
|
121
262
|
let queryVec = null;
|
|
263
|
+
|
|
122
264
|
const extraFilters = buildFilterClauses({
|
|
123
265
|
categoryFilter,
|
|
266
|
+
excludeEvents,
|
|
124
267
|
since,
|
|
125
268
|
until,
|
|
126
269
|
userIdFilter,
|
|
@@ -128,7 +271,9 @@ export async function hybridSearch(
|
|
|
128
271
|
includeSuperseeded,
|
|
129
272
|
});
|
|
130
273
|
|
|
131
|
-
|
|
274
|
+
const ftsRankedIds = [];
|
|
275
|
+
|
|
276
|
+
// Stage 1a: FTS5 — collect ranked list of IDs
|
|
132
277
|
const ftsQuery = buildFtsQuery(query);
|
|
133
278
|
if (ftsQuery) {
|
|
134
279
|
try {
|
|
@@ -145,25 +290,21 @@ export async function hybridSearch(
|
|
|
145
290
|
const ftsSQL = `SELECT e.*, rank FROM vault_fts f JOIN vault e ON f.rowid = e.rowid WHERE ${whereParts.join(" AND ")} ORDER BY rank LIMIT 15`;
|
|
146
291
|
const rows = ctx.db.prepare(ftsSQL).all(...ftsParams);
|
|
147
292
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (let i = 0; i < rows.length; i++) {
|
|
153
|
-
const { rank: _rank, ...row } = rows[i];
|
|
154
|
-
const normalized = ftsScores[i] / maxFts;
|
|
155
|
-
results.set(row.id, { ...row, score: normalized * FTS_WEIGHT });
|
|
293
|
+
for (const { rank: _rank, ...row } of rows) {
|
|
294
|
+
ftsRankedIds.push(row.id);
|
|
295
|
+
if (!rowMap.has(row.id)) rowMap.set(row.id, row);
|
|
156
296
|
}
|
|
157
297
|
} catch (err) {
|
|
158
|
-
if (err.message?.includes("fts5: syntax error")) {
|
|
159
|
-
// Expected: malformed query, fall through to vector search
|
|
160
|
-
} else {
|
|
298
|
+
if (!err.message?.includes("fts5: syntax error")) {
|
|
161
299
|
console.error(`[retrieve] FTS search error: ${err.message}`);
|
|
162
300
|
}
|
|
163
301
|
}
|
|
164
302
|
}
|
|
165
303
|
|
|
166
|
-
|
|
304
|
+
const vecRankedIds = [];
|
|
305
|
+
const vecSimMap = new Map();
|
|
306
|
+
|
|
307
|
+
// Stage 1b: Vector similarity — collect ranked list of IDs and raw similarity scores
|
|
167
308
|
try {
|
|
168
309
|
const vecCount = ctx.db
|
|
169
310
|
.prepare("SELECT COUNT(*) as c FROM vault_vec")
|
|
@@ -171,7 +312,6 @@ export async function hybridSearch(
|
|
|
171
312
|
if (vecCount > 0) {
|
|
172
313
|
queryVec = await ctx.embed(query);
|
|
173
314
|
if (queryVec) {
|
|
174
|
-
// Increase limits in hosted mode to compensate for post-filtering
|
|
175
315
|
const hasPostFilter = userIdFilter !== undefined || teamIdFilter;
|
|
176
316
|
const vecLimit = hasPostFilter
|
|
177
317
|
? kindFilter
|
|
@@ -187,7 +327,6 @@ export async function hybridSearch(
|
|
|
187
327
|
.all(queryVec, vecLimit);
|
|
188
328
|
|
|
189
329
|
if (vecRows.length) {
|
|
190
|
-
// Batch hydration: single query instead of N+1
|
|
191
330
|
const rowids = vecRows.map((vr) => vr.rowid);
|
|
192
331
|
const placeholders = rowids.map(() => "?").join(",");
|
|
193
332
|
const hydrated = ctx.db
|
|
@@ -207,6 +346,7 @@ export async function hybridSearch(
|
|
|
207
346
|
if (teamIdFilter && row.team_id !== teamIdFilter) continue;
|
|
208
347
|
if (kindFilter && row.kind !== kindFilter) continue;
|
|
209
348
|
if (categoryFilter && row.category !== categoryFilter) continue;
|
|
349
|
+
if (excludeEvents && row.category === "event") continue;
|
|
210
350
|
if (since && row.created_at < since) continue;
|
|
211
351
|
if (until && row.created_at > until) continue;
|
|
212
352
|
if (row.expires_at && new Date(row.expires_at) <= new Date())
|
|
@@ -214,70 +354,111 @@ export async function hybridSearch(
|
|
|
214
354
|
|
|
215
355
|
const { rowid: _rowid, ...cleanRow } = row;
|
|
216
356
|
idToRowid.set(cleanRow.id, Number(row.rowid));
|
|
357
|
+
|
|
217
358
|
// sqlite-vec returns L2 distance [0, 2] for normalized vectors.
|
|
218
|
-
// Convert to similarity [
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
results.set(cleanRow.id, { ...cleanRow, score: vecScore });
|
|
225
|
-
}
|
|
359
|
+
// Convert to similarity [0, 1]: 1 - distance/2
|
|
360
|
+
const vecSim = Math.max(0, 1 - vr.distance / 2);
|
|
361
|
+
vecSimMap.set(cleanRow.id, vecSim);
|
|
362
|
+
vecRankedIds.push(cleanRow.id);
|
|
363
|
+
|
|
364
|
+
if (!rowMap.has(cleanRow.id)) rowMap.set(cleanRow.id, cleanRow);
|
|
226
365
|
}
|
|
227
366
|
}
|
|
228
367
|
}
|
|
229
368
|
}
|
|
230
369
|
} catch (err) {
|
|
231
|
-
if (err.message?.includes("no such table")) {
|
|
232
|
-
// Expected on fresh vaults with no vec table yet
|
|
233
|
-
} else {
|
|
370
|
+
if (!err.message?.includes("no such table")) {
|
|
234
371
|
console.error(`[retrieve] Vector search error: ${err.message}`);
|
|
235
372
|
}
|
|
236
373
|
}
|
|
237
374
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
375
|
+
if (rowMap.size === 0) return [];
|
|
376
|
+
|
|
377
|
+
// Stage 2: RRF — merge FTS and vector ranked lists into a single score
|
|
378
|
+
const rrfScores = reciprocalRankFusion([ftsRankedIds, vecRankedIds]);
|
|
379
|
+
|
|
380
|
+
// Stage 3: Apply category-aware recency boost to RRF scores
|
|
381
|
+
for (const [id, entry] of rowMap) {
|
|
382
|
+
const boost = recencyBoost(entry.created_at, entry.category, decayDays);
|
|
383
|
+
rrfScores.set(id, (rrfScores.get(id) ?? 0) * boost);
|
|
241
384
|
}
|
|
242
385
|
|
|
243
|
-
|
|
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
|
+
}
|
|
244
399
|
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
400
|
+
// Attach final score to each entry and sort by RRF score descending
|
|
401
|
+
const candidates = [...rowMap.values()].map((entry) => ({
|
|
402
|
+
...entry,
|
|
403
|
+
score: rrfScores.get(entry.id) ?? 0,
|
|
404
|
+
}));
|
|
405
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
251
406
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
407
|
+
// Stage 4: Fetch embeddings for all candidates that have a rowid
|
|
408
|
+
const embeddingMap = new Map();
|
|
409
|
+
if (queryVec && idToRowid.size > 0) {
|
|
410
|
+
const rowidToId = new Map();
|
|
411
|
+
for (const [id, rowid] of idToRowid) rowidToId.set(rowid, id);
|
|
412
|
+
|
|
413
|
+
const rowidsToFetch = [...idToRowid.values()];
|
|
414
|
+
try {
|
|
415
|
+
const placeholders = rowidsToFetch.map(() => "?").join(",");
|
|
416
|
+
const vecData = ctx.db
|
|
417
|
+
.prepare(
|
|
418
|
+
`SELECT rowid, embedding FROM vault_vec WHERE rowid IN (${placeholders})`,
|
|
419
|
+
)
|
|
420
|
+
.all(...rowidsToFetch);
|
|
421
|
+
for (const row of vecData) {
|
|
422
|
+
const id = rowidToId.get(Number(row.rowid));
|
|
423
|
+
const buf = row.embedding;
|
|
424
|
+
if (id && buf) {
|
|
425
|
+
embeddingMap.set(
|
|
426
|
+
id,
|
|
427
|
+
new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4),
|
|
428
|
+
);
|
|
269
429
|
}
|
|
270
|
-
} catch (_) {
|
|
271
|
-
return sorted.slice(offset, offset + limit);
|
|
272
430
|
}
|
|
431
|
+
} catch (_) {
|
|
432
|
+
// Embeddings unavailable — MMR will fall back to Jaccard similarity
|
|
273
433
|
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Use vecSim as the query-relevance signal for MMR; fall back to RRF score
|
|
437
|
+
const querySimMap = new Map();
|
|
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
|
+
}
|
|
274
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) {
|
|
275
457
|
const selected = [];
|
|
276
458
|
const selectedVecs = [];
|
|
277
|
-
for (const candidate of
|
|
459
|
+
for (const candidate of mmrSelected) {
|
|
278
460
|
if (selected.length >= offset + limit) break;
|
|
279
|
-
const
|
|
280
|
-
const vec = rowid !== undefined ? embeddingMap.get(rowid) : null;
|
|
461
|
+
const vec = embeddingMap.get(candidate.id);
|
|
281
462
|
if (vec && selectedVecs.length > 0) {
|
|
282
463
|
let maxSim = 0;
|
|
283
464
|
for (const sv of selectedVecs) {
|
|
@@ -289,8 +470,31 @@ export async function hybridSearch(
|
|
|
289
470
|
selected.push(candidate);
|
|
290
471
|
if (vec) selectedVecs.push(vec);
|
|
291
472
|
}
|
|
292
|
-
|
|
473
|
+
const dedupedPage = selected.slice(offset, offset + limit);
|
|
474
|
+
trackAccess(ctx.db, dedupedPage);
|
|
475
|
+
return dedupedPage;
|
|
293
476
|
}
|
|
294
477
|
|
|
295
|
-
|
|
478
|
+
const finalPage = mmrSelected.slice(offset, offset + limit);
|
|
479
|
+
trackAccess(ctx.db, finalPage);
|
|
480
|
+
return finalPage;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Increment hit_count and set last_accessed_at for a batch of retrieved entries.
|
|
485
|
+
* Single batched UPDATE for efficiency.
|
|
486
|
+
*
|
|
487
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
488
|
+
* @param {Array<{id: string}>} entries
|
|
489
|
+
*/
|
|
490
|
+
function trackAccess(db, entries) {
|
|
491
|
+
if (!entries.length) return;
|
|
492
|
+
try {
|
|
493
|
+
const placeholders = entries.map(() => "?").join(",");
|
|
494
|
+
db.prepare(
|
|
495
|
+
`UPDATE vault SET hit_count = hit_count + 1, last_accessed_at = datetime('now') WHERE id IN (${placeholders})`,
|
|
496
|
+
).run(...entries.map((e) => e.id));
|
|
497
|
+
} catch (_) {
|
|
498
|
+
// Non-fatal: frequency tracking is best-effort
|
|
499
|
+
}
|
|
296
500
|
}
|
|
@@ -146,6 +146,13 @@ export function handler(_args, ctx) {
|
|
|
146
146
|
for (const w of growth.warnings) {
|
|
147
147
|
lines.push(` ${w.message}`);
|
|
148
148
|
}
|
|
149
|
+
if (growth.kindBreakdown.length) {
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push(" Breakdown by kind:");
|
|
152
|
+
for (const { kind, count, pct } of growth.kindBreakdown) {
|
|
153
|
+
lines.push(` ${kind}: ${count.toLocaleString()} (${pct}%)`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
149
156
|
if (growth.actions.length) {
|
|
150
157
|
lines.push("", "Suggested growth actions:");
|
|
151
158
|
for (const a of growth.actions) {
|
|
@@ -20,6 +20,12 @@ export const inputSchema = {
|
|
|
20
20
|
.array(z.string())
|
|
21
21
|
.optional()
|
|
22
22
|
.describe("Optional tag filters — entries must match at least one"),
|
|
23
|
+
buckets: z
|
|
24
|
+
.array(z.string())
|
|
25
|
+
.optional()
|
|
26
|
+
.describe(
|
|
27
|
+
"Filter by project-scoped buckets. Each name expands to a 'bucket:<name>' tag. Composes with 'tags' via OR (entries matching any tag or any bucket are included).",
|
|
28
|
+
),
|
|
23
29
|
kinds: z
|
|
24
30
|
.array(z.string())
|
|
25
31
|
.optional()
|
|
@@ -99,7 +105,7 @@ function slugifyTopic(topic) {
|
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
export async function handler(
|
|
102
|
-
{ topic, tags, kinds, identity_key },
|
|
108
|
+
{ topic, tags, buckets, kinds, identity_key },
|
|
103
109
|
ctx,
|
|
104
110
|
{ ensureIndexed },
|
|
105
111
|
) {
|
|
@@ -116,6 +122,9 @@ export async function handler(
|
|
|
116
122
|
await ensureIndexed();
|
|
117
123
|
|
|
118
124
|
const normalizedKinds = kinds?.map(normalizeKind) ?? [];
|
|
125
|
+
// Expand buckets to bucket: prefixed tags and merge with explicit tags
|
|
126
|
+
const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
|
|
127
|
+
const effectiveTags = [...(tags ?? []), ...bucketTags];
|
|
119
128
|
|
|
120
129
|
let candidates = [];
|
|
121
130
|
|
|
@@ -143,10 +152,10 @@ export async function handler(
|
|
|
143
152
|
});
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
if (
|
|
155
|
+
if (effectiveTags.length) {
|
|
147
156
|
candidates = candidates.filter((r) => {
|
|
148
157
|
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
149
|
-
return
|
|
158
|
+
return effectiveTags.some((t) => entryTags.includes(t));
|
|
150
159
|
});
|
|
151
160
|
}
|
|
152
161
|
|