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.
@@ -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 = ctx.stmts.getByIdentityKey.get(
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
- ctx.stmts.upsertByIdentityKey.run(
81
- title || null,
82
- body,
83
- metaJson,
84
- tagsJson,
85
- source || "claude-code",
86
- cat,
87
- filePath,
88
- expires_at || null,
89
- sourceFilesJson,
90
- kind,
91
- identity_key,
92
- userIdVal,
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
- // Encrypted insert: store preview in body column for FTS, full content in encrypted columns
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
- // user_id is NULL for reindex (always local mode)
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
- `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, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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
- if (bodyChanged || titleChanged || tagsChanged || metaChanged) {
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 and MMR reranking for diversity.
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. 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
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 all candidates that have a rowid
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 — MMR will fall back to Jaccard similarity
324
+ // Embeddings unavailable — near-dup suppression skipped
433
325
  }
434
326
  }
435
327
 
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
- }
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 mmrSelected) {
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 = mmrSelected.slice(offset, offset + limit);
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 SYNTHESIS_MODEL = "claude-haiku-4-5-20251001";
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, run an LLM synthesis pass to deduplicate and structure them into a context brief, then save and return the brief's ULID. The brief is saved as kind: 'brief' with a deterministic identity_key for retrieval.";
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 buildSynthesisPrompt(topic, entries) {
42
- const entriesBlock = entries
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 body = e.body
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
- `### Entry ${i + 1} [${e.kind}] id: ${e.id}`,
52
- `tags: ${tagStr}`,
53
- `updated: ${e.updated_at || e.created_at || "unknown"}`,
54
- body,
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("\n\n");
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(MAX_ENTRIES_FOR_SYNTHESIS / normalizedKinds.length),
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: MAX_ENTRIES_FOR_SYNTHESIS,
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 synthesisEntries = candidates.filter((r) => !NOISE_KINDS.has(r.kind));
144
+ const gatherEntries = candidates.filter((r) => !NOISE_KINDS.has(r.kind));
167
145
 
168
- if (synthesisEntries.length === 0) {
146
+ if (gatherEntries.length === 0) {
169
147
  return err(
170
- `No entries found for topic "${topic}" to synthesize. Try a broader topic or different tags.`,
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
- let briefBody;
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: synthesisEntries.length,
177
+ entry_count: gatherEntries.length,
209
178
  noise_superseded: noiseIds.length,
210
- synthesized_from: synthesisEntries.map((e) => e.id),
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: ${synthesisEntries.length} entries`,
187
+ ` synthesized from: ${gatherEntries.length} entries`,
219
188
  noiseIds.length > 0
220
189
  ? ` noise superseded: ${noiseIds.length} entries`
221
190
  : null,