@context-vault/core 2.14.0 → 2.15.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/core/categories.js +10 -0
- package/src/core/config.js +37 -0
- package/src/index/db.js +102 -9
- package/src/index/index.js +24 -1
- package/src/index.js +4 -0
- package/src/retrieve/index.js +261 -64
- package/src/server/tools/create-snapshot.js +12 -3
- package/src/server/tools/get-context.js +297 -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
|
*/
|
|
@@ -93,7 +110,126 @@ export function buildFilterClauses({
|
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
/**
|
|
96
|
-
*
|
|
113
|
+
* Reciprocal Rank Fusion: merge multiple ranked lists into a single score.
|
|
114
|
+
* Each document receives 1/(k + rank) from each list it appears in.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<string[]>} rankedLists - Arrays of document IDs in rank order (best first).
|
|
117
|
+
* @param {number} k - Smoothing constant (default RRF_K = 60).
|
|
118
|
+
* @returns {Map<string, number>} Map of id -> RRF score.
|
|
119
|
+
*/
|
|
120
|
+
export function reciprocalRankFusion(rankedLists, k = RRF_K) {
|
|
121
|
+
const scores = new Map();
|
|
122
|
+
for (const list of rankedLists) {
|
|
123
|
+
for (let rank = 0; rank < list.length; rank++) {
|
|
124
|
+
const id = list[rank];
|
|
125
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return scores;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Jaccard similarity between two strings based on word sets.
|
|
133
|
+
* Used as a fallback for MMR when embedding vectors are unavailable.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} a
|
|
136
|
+
* @param {string} b
|
|
137
|
+
* @returns {number} Similarity in [0, 1].
|
|
138
|
+
*/
|
|
139
|
+
export function jaccardSimilarity(a, b) {
|
|
140
|
+
const wordsA = new Set((a ?? "").toLowerCase().split(/\W+/).filter(Boolean));
|
|
141
|
+
const wordsB = new Set((b ?? "").toLowerCase().split(/\W+/).filter(Boolean));
|
|
142
|
+
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
|
143
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
144
|
+
let intersection = 0;
|
|
145
|
+
for (const w of wordsA) if (wordsB.has(w)) intersection++;
|
|
146
|
+
return intersection / (wordsA.size + wordsB.size - intersection);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Maximal Marginal Relevance reranking.
|
|
151
|
+
*
|
|
152
|
+
* Selects up to n candidates that balance relevance to the query and
|
|
153
|
+
* diversity from already-selected results.
|
|
154
|
+
*
|
|
155
|
+
* MMR_score = lambda * querySim(doc) - (1 - lambda) * max(sim(doc, selected))
|
|
156
|
+
*
|
|
157
|
+
* @param {Array<object>} candidates - Entries with at least {id, title, body}.
|
|
158
|
+
* @param {Map<string, number>} querySimMap - Map of id -> relevance score.
|
|
159
|
+
* @param {Map<string, Float32Array|null>} embeddingMap - Map of id -> embedding (null if unavailable).
|
|
160
|
+
* @param {number} n - Number of results to select.
|
|
161
|
+
* @param {number} lambda - Trade-off weight (default MMR_LAMBDA = 0.7).
|
|
162
|
+
* @returns {Array<object>} Reranked subset of candidates (length <= n).
|
|
163
|
+
*/
|
|
164
|
+
export function maximalMarginalRelevance(
|
|
165
|
+
candidates,
|
|
166
|
+
querySimMap,
|
|
167
|
+
embeddingMap,
|
|
168
|
+
n,
|
|
169
|
+
lambda = MMR_LAMBDA,
|
|
170
|
+
) {
|
|
171
|
+
if (candidates.length === 0) return [];
|
|
172
|
+
|
|
173
|
+
const remaining = [...candidates];
|
|
174
|
+
const selected = [];
|
|
175
|
+
const selectedVecs = [];
|
|
176
|
+
const selectedEntries = [];
|
|
177
|
+
|
|
178
|
+
while (selected.length < n && remaining.length > 0) {
|
|
179
|
+
let bestIdx = -1;
|
|
180
|
+
let bestScore = -Infinity;
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
183
|
+
const candidate = remaining[i];
|
|
184
|
+
const relevance = querySimMap.get(candidate.id) ?? 0;
|
|
185
|
+
|
|
186
|
+
let maxRedundancy = 0;
|
|
187
|
+
if (selectedVecs.length > 0) {
|
|
188
|
+
const vec = embeddingMap.get(candidate.id);
|
|
189
|
+
for (let j = 0; j < selectedVecs.length; j++) {
|
|
190
|
+
let sim;
|
|
191
|
+
if (vec && selectedVecs[j]) {
|
|
192
|
+
sim = dotProduct(vec, selectedVecs[j]);
|
|
193
|
+
} else {
|
|
194
|
+
const selEntry = selectedEntries[j];
|
|
195
|
+
sim = jaccardSimilarity(
|
|
196
|
+
`${candidate.title} ${candidate.body}`,
|
|
197
|
+
`${selEntry.title} ${selEntry.body}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (sim > maxRedundancy) maxRedundancy = sim;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const score = lambda * relevance - (1 - lambda) * maxRedundancy;
|
|
205
|
+
if (score > bestScore) {
|
|
206
|
+
bestScore = score;
|
|
207
|
+
bestIdx = i;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (bestIdx === -1) break;
|
|
212
|
+
|
|
213
|
+
const chosen = remaining.splice(bestIdx, 1)[0];
|
|
214
|
+
selected.push(chosen);
|
|
215
|
+
selectedVecs.push(embeddingMap.get(chosen.id) ?? null);
|
|
216
|
+
selectedEntries.push(chosen);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return selected;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Hybrid search combining FTS5 text matching and vector similarity,
|
|
224
|
+
* with RRF merging and MMR reranking for diversity.
|
|
225
|
+
*
|
|
226
|
+
* Pipeline:
|
|
227
|
+
* 1. FTS5 ranked list
|
|
228
|
+
* 2. Vector (semantic) ranked list
|
|
229
|
+
* 3. RRF: merge the two ranked lists into a single score
|
|
230
|
+
* 4. Apply recency decay to RRF scores
|
|
231
|
+
* 5. MMR: rerank top candidates for diversity (uses embeddings or Jaccard fallback)
|
|
232
|
+
* 6. Near-duplicate suppression on the final selection
|
|
97
233
|
*
|
|
98
234
|
* @param {import('../server/types.js').BaseCtx} ctx
|
|
99
235
|
* @param {string} query
|
|
@@ -116,9 +252,10 @@ export async function hybridSearch(
|
|
|
116
252
|
includeSuperseeded = false,
|
|
117
253
|
} = {},
|
|
118
254
|
) {
|
|
119
|
-
const
|
|
255
|
+
const rowMap = new Map();
|
|
120
256
|
const idToRowid = new Map();
|
|
121
257
|
let queryVec = null;
|
|
258
|
+
|
|
122
259
|
const extraFilters = buildFilterClauses({
|
|
123
260
|
categoryFilter,
|
|
124
261
|
since,
|
|
@@ -128,7 +265,9 @@ export async function hybridSearch(
|
|
|
128
265
|
includeSuperseeded,
|
|
129
266
|
});
|
|
130
267
|
|
|
131
|
-
|
|
268
|
+
const ftsRankedIds = [];
|
|
269
|
+
|
|
270
|
+
// Stage 1a: FTS5 — collect ranked list of IDs
|
|
132
271
|
const ftsQuery = buildFtsQuery(query);
|
|
133
272
|
if (ftsQuery) {
|
|
134
273
|
try {
|
|
@@ -145,25 +284,21 @@ export async function hybridSearch(
|
|
|
145
284
|
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
285
|
const rows = ctx.db.prepare(ftsSQL).all(...ftsParams);
|
|
147
286
|
|
|
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 });
|
|
287
|
+
for (const { rank: _rank, ...row } of rows) {
|
|
288
|
+
ftsRankedIds.push(row.id);
|
|
289
|
+
if (!rowMap.has(row.id)) rowMap.set(row.id, row);
|
|
156
290
|
}
|
|
157
291
|
} catch (err) {
|
|
158
|
-
if (err.message?.includes("fts5: syntax error")) {
|
|
159
|
-
// Expected: malformed query, fall through to vector search
|
|
160
|
-
} else {
|
|
292
|
+
if (!err.message?.includes("fts5: syntax error")) {
|
|
161
293
|
console.error(`[retrieve] FTS search error: ${err.message}`);
|
|
162
294
|
}
|
|
163
295
|
}
|
|
164
296
|
}
|
|
165
297
|
|
|
166
|
-
|
|
298
|
+
const vecRankedIds = [];
|
|
299
|
+
const vecSimMap = new Map();
|
|
300
|
+
|
|
301
|
+
// Stage 1b: Vector similarity — collect ranked list of IDs and raw similarity scores
|
|
167
302
|
try {
|
|
168
303
|
const vecCount = ctx.db
|
|
169
304
|
.prepare("SELECT COUNT(*) as c FROM vault_vec")
|
|
@@ -171,7 +306,6 @@ export async function hybridSearch(
|
|
|
171
306
|
if (vecCount > 0) {
|
|
172
307
|
queryVec = await ctx.embed(query);
|
|
173
308
|
if (queryVec) {
|
|
174
|
-
// Increase limits in hosted mode to compensate for post-filtering
|
|
175
309
|
const hasPostFilter = userIdFilter !== undefined || teamIdFilter;
|
|
176
310
|
const vecLimit = hasPostFilter
|
|
177
311
|
? kindFilter
|
|
@@ -187,7 +321,6 @@ export async function hybridSearch(
|
|
|
187
321
|
.all(queryVec, vecLimit);
|
|
188
322
|
|
|
189
323
|
if (vecRows.length) {
|
|
190
|
-
// Batch hydration: single query instead of N+1
|
|
191
324
|
const rowids = vecRows.map((vr) => vr.rowid);
|
|
192
325
|
const placeholders = rowids.map(() => "?").join(",");
|
|
193
326
|
const hydrated = ctx.db
|
|
@@ -214,70 +347,111 @@ export async function hybridSearch(
|
|
|
214
347
|
|
|
215
348
|
const { rowid: _rowid, ...cleanRow } = row;
|
|
216
349
|
idToRowid.set(cleanRow.id, Number(row.rowid));
|
|
350
|
+
|
|
217
351
|
// 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
|
-
}
|
|
352
|
+
// Convert to similarity [0, 1]: 1 - distance/2
|
|
353
|
+
const vecSim = Math.max(0, 1 - vr.distance / 2);
|
|
354
|
+
vecSimMap.set(cleanRow.id, vecSim);
|
|
355
|
+
vecRankedIds.push(cleanRow.id);
|
|
356
|
+
|
|
357
|
+
if (!rowMap.has(cleanRow.id)) rowMap.set(cleanRow.id, cleanRow);
|
|
226
358
|
}
|
|
227
359
|
}
|
|
228
360
|
}
|
|
229
361
|
}
|
|
230
362
|
} catch (err) {
|
|
231
|
-
if (err.message?.includes("no such table")) {
|
|
232
|
-
// Expected on fresh vaults with no vec table yet
|
|
233
|
-
} else {
|
|
363
|
+
if (!err.message?.includes("no such table")) {
|
|
234
364
|
console.error(`[retrieve] Vector search error: ${err.message}`);
|
|
235
365
|
}
|
|
236
366
|
}
|
|
237
367
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
368
|
+
if (rowMap.size === 0) return [];
|
|
369
|
+
|
|
370
|
+
// Stage 2: RRF — merge FTS and vector ranked lists into a single score
|
|
371
|
+
const rrfScores = reciprocalRankFusion([ftsRankedIds, vecRankedIds]);
|
|
372
|
+
|
|
373
|
+
// Stage 3: Apply category-aware recency boost to RRF scores
|
|
374
|
+
for (const [id, entry] of rowMap) {
|
|
375
|
+
const boost = recencyBoost(entry.created_at, entry.category, decayDays);
|
|
376
|
+
rrfScores.set(id, (rrfScores.get(id) ?? 0) * boost);
|
|
241
377
|
}
|
|
242
378
|
|
|
243
|
-
|
|
379
|
+
// Stage 3b: Frequency signal — log(1 + hit_count) / log(1 + max_hit_count)
|
|
380
|
+
const allRows = [...rowMap.values()];
|
|
381
|
+
const maxHitCount = Math.max(...allRows.map((e) => e.hit_count || 0), 0);
|
|
382
|
+
if (maxHitCount > 0) {
|
|
383
|
+
const logMax = Math.log(1 + maxHitCount);
|
|
384
|
+
for (const entry of allRows) {
|
|
385
|
+
const freqScore = Math.log(1 + (entry.hit_count || 0)) / logMax;
|
|
386
|
+
rrfScores.set(
|
|
387
|
+
entry.id,
|
|
388
|
+
(rrfScores.get(entry.id) ?? 0) + freqScore * 0.13,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
244
392
|
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
393
|
+
// Attach final score to each entry and sort by RRF score descending
|
|
394
|
+
const candidates = [...rowMap.values()].map((entry) => ({
|
|
395
|
+
...entry,
|
|
396
|
+
score: rrfScores.get(entry.id) ?? 0,
|
|
397
|
+
}));
|
|
398
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
251
399
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
400
|
+
// Stage 4: Fetch embeddings for all candidates that have a rowid
|
|
401
|
+
const embeddingMap = new Map();
|
|
402
|
+
if (queryVec && idToRowid.size > 0) {
|
|
403
|
+
const rowidToId = new Map();
|
|
404
|
+
for (const [id, rowid] of idToRowid) rowidToId.set(rowid, id);
|
|
405
|
+
|
|
406
|
+
const rowidsToFetch = [...idToRowid.values()];
|
|
407
|
+
try {
|
|
408
|
+
const placeholders = rowidsToFetch.map(() => "?").join(",");
|
|
409
|
+
const vecData = ctx.db
|
|
410
|
+
.prepare(
|
|
411
|
+
`SELECT rowid, embedding FROM vault_vec WHERE rowid IN (${placeholders})`,
|
|
412
|
+
)
|
|
413
|
+
.all(...rowidsToFetch);
|
|
414
|
+
for (const row of vecData) {
|
|
415
|
+
const id = rowidToId.get(Number(row.rowid));
|
|
416
|
+
const buf = row.embedding;
|
|
417
|
+
if (id && buf) {
|
|
418
|
+
embeddingMap.set(
|
|
419
|
+
id,
|
|
420
|
+
new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4),
|
|
421
|
+
);
|
|
269
422
|
}
|
|
270
|
-
} catch (_) {
|
|
271
|
-
return sorted.slice(offset, offset + limit);
|
|
272
423
|
}
|
|
424
|
+
} catch (_) {
|
|
425
|
+
// Embeddings unavailable — MMR will fall back to Jaccard similarity
|
|
273
426
|
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Use vecSim as the query-relevance signal for MMR; fall back to RRF score
|
|
430
|
+
const querySimMap = new Map();
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
querySimMap.set(
|
|
433
|
+
candidate.id,
|
|
434
|
+
vecSimMap.has(candidate.id)
|
|
435
|
+
? vecSimMap.get(candidate.id)
|
|
436
|
+
: candidate.score,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
274
439
|
|
|
440
|
+
// Stage 5: MMR — rerank for diversity using embeddings or Jaccard fallback
|
|
441
|
+
const mmrSelected = maximalMarginalRelevance(
|
|
442
|
+
candidates,
|
|
443
|
+
querySimMap,
|
|
444
|
+
embeddingMap,
|
|
445
|
+
offset + limit,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// Stage 6: Near-duplicate suppression (hard filter, not reorder)
|
|
449
|
+
if (queryVec && embeddingMap.size > 0 && mmrSelected.length > limit) {
|
|
275
450
|
const selected = [];
|
|
276
451
|
const selectedVecs = [];
|
|
277
|
-
for (const candidate of
|
|
452
|
+
for (const candidate of mmrSelected) {
|
|
278
453
|
if (selected.length >= offset + limit) break;
|
|
279
|
-
const
|
|
280
|
-
const vec = rowid !== undefined ? embeddingMap.get(rowid) : null;
|
|
454
|
+
const vec = embeddingMap.get(candidate.id);
|
|
281
455
|
if (vec && selectedVecs.length > 0) {
|
|
282
456
|
let maxSim = 0;
|
|
283
457
|
for (const sv of selectedVecs) {
|
|
@@ -289,8 +463,31 @@ export async function hybridSearch(
|
|
|
289
463
|
selected.push(candidate);
|
|
290
464
|
if (vec) selectedVecs.push(vec);
|
|
291
465
|
}
|
|
292
|
-
|
|
466
|
+
const dedupedPage = selected.slice(offset, offset + limit);
|
|
467
|
+
trackAccess(ctx.db, dedupedPage);
|
|
468
|
+
return dedupedPage;
|
|
293
469
|
}
|
|
294
470
|
|
|
295
|
-
|
|
471
|
+
const finalPage = mmrSelected.slice(offset, offset + limit);
|
|
472
|
+
trackAccess(ctx.db, finalPage);
|
|
473
|
+
return finalPage;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Increment hit_count and set last_accessed_at for a batch of retrieved entries.
|
|
478
|
+
* Single batched UPDATE for efficiency.
|
|
479
|
+
*
|
|
480
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
481
|
+
* @param {Array<{id: string}>} entries
|
|
482
|
+
*/
|
|
483
|
+
function trackAccess(db, entries) {
|
|
484
|
+
if (!entries.length) return;
|
|
485
|
+
try {
|
|
486
|
+
const placeholders = entries.map(() => "?").join(",");
|
|
487
|
+
db.prepare(
|
|
488
|
+
`UPDATE vault SET hit_count = hit_count + 1, last_accessed_at = datetime('now') WHERE id IN (${placeholders})`,
|
|
489
|
+
).run(...entries.map((e) => e.id));
|
|
490
|
+
} catch (_) {
|
|
491
|
+
// Non-fatal: frequency tracking is best-effort
|
|
492
|
+
}
|
|
296
493
|
}
|
|
@@ -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
|
|