@context-vault/core 2.17.1 → 3.0.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.
Files changed (101) hide show
  1. package/dist/capture.d.ts +21 -0
  2. package/dist/capture.d.ts.map +1 -0
  3. package/dist/capture.js +269 -0
  4. package/dist/capture.js.map +1 -0
  5. package/dist/categories.d.ts +6 -0
  6. package/dist/categories.d.ts.map +1 -0
  7. package/dist/categories.js +50 -0
  8. package/dist/categories.js.map +1 -0
  9. package/dist/config.d.ts +4 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +190 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/constants.d.ts +33 -0
  14. package/dist/constants.d.ts.map +1 -0
  15. package/dist/constants.js +23 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/db.d.ts +13 -0
  18. package/dist/db.d.ts.map +1 -0
  19. package/dist/db.js +191 -0
  20. package/dist/db.js.map +1 -0
  21. package/dist/embed.d.ts +5 -0
  22. package/dist/embed.d.ts.map +1 -0
  23. package/dist/embed.js +78 -0
  24. package/dist/embed.js.map +1 -0
  25. package/dist/files.d.ts +13 -0
  26. package/dist/files.d.ts.map +1 -0
  27. package/dist/files.js +66 -0
  28. package/dist/files.js.map +1 -0
  29. package/dist/formatters.d.ts +8 -0
  30. package/dist/formatters.d.ts.map +1 -0
  31. package/dist/formatters.js +18 -0
  32. package/dist/formatters.js.map +1 -0
  33. package/dist/frontmatter.d.ts +12 -0
  34. package/dist/frontmatter.d.ts.map +1 -0
  35. package/dist/frontmatter.js +101 -0
  36. package/dist/frontmatter.js.map +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +297 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/ingest-url.d.ts +20 -0
  42. package/dist/ingest-url.d.ts.map +1 -0
  43. package/dist/ingest-url.js +113 -0
  44. package/dist/ingest-url.js.map +1 -0
  45. package/dist/main.d.ts +14 -0
  46. package/dist/main.d.ts.map +1 -0
  47. package/dist/main.js +25 -0
  48. package/dist/main.js.map +1 -0
  49. package/dist/search.d.ts +18 -0
  50. package/dist/search.d.ts.map +1 -0
  51. package/dist/search.js +238 -0
  52. package/dist/search.js.map +1 -0
  53. package/dist/types.d.ts +176 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +66 -16
  58. package/src/capture.ts +308 -0
  59. package/src/categories.ts +54 -0
  60. package/src/{core/config.js → config.ts} +34 -33
  61. package/src/{constants.js → constants.ts} +6 -3
  62. package/src/db.ts +229 -0
  63. package/src/{index/embed.js → embed.ts} +10 -35
  64. package/src/{core/files.js → files.ts} +15 -20
  65. package/src/{capture/formatters.js → formatters.ts} +13 -11
  66. package/src/{core/frontmatter.js → frontmatter.ts} +26 -33
  67. package/src/index.ts +351 -0
  68. package/src/ingest-url.ts +99 -0
  69. package/src/main.ts +111 -0
  70. package/src/{retrieve/index.js → search.ts} +62 -150
  71. package/src/types.ts +166 -0
  72. package/src/capture/file-ops.js +0 -99
  73. package/src/capture/import-pipeline.js +0 -46
  74. package/src/capture/importers.js +0 -387
  75. package/src/capture/index.js +0 -250
  76. package/src/capture/ingest-url.js +0 -252
  77. package/src/consolidation/index.js +0 -112
  78. package/src/core/categories.js +0 -73
  79. package/src/core/error-log.js +0 -54
  80. package/src/core/linking.js +0 -161
  81. package/src/core/migrate-dirs.js +0 -196
  82. package/src/core/status.js +0 -350
  83. package/src/core/telemetry.js +0 -90
  84. package/src/core/temporal.js +0 -146
  85. package/src/index/db.js +0 -586
  86. package/src/index/index.js +0 -583
  87. package/src/index.js +0 -71
  88. package/src/server/helpers.js +0 -44
  89. package/src/server/tools/clear-context.js +0 -47
  90. package/src/server/tools/context-status.js +0 -182
  91. package/src/server/tools/create-snapshot.js +0 -200
  92. package/src/server/tools/delete-context.js +0 -60
  93. package/src/server/tools/get-context.js +0 -765
  94. package/src/server/tools/ingest-project.js +0 -244
  95. package/src/server/tools/ingest-url.js +0 -88
  96. package/src/server/tools/list-buckets.js +0 -116
  97. package/src/server/tools/list-context.js +0 -163
  98. package/src/server/tools/save-context.js +0 -632
  99. package/src/server/tools/session-start.js +0 -285
  100. package/src/server/tools.js +0 -172
  101. package/src/sync/sync.js +0 -235
@@ -1,632 +0,0 @@
1
- import { z } from "zod";
2
- import { captureAndIndex, updateEntryFile } from "../../capture/index.js";
3
- import { indexEntry } from "../../index/index.js";
4
- import { categoryFor, defaultTierFor } from "../../core/categories.js";
5
- import { normalizeKind } from "../../core/files.js";
6
- import { ok, err, ensureVaultExists, ensureValidKind } from "../helpers.js";
7
- import { maybeShowFeedbackPrompt } from "../../core/telemetry.js";
8
- import { validateRelatedTo } from "../../core/linking.js";
9
- import {
10
- MAX_BODY_LENGTH,
11
- MAX_TITLE_LENGTH,
12
- MAX_KIND_LENGTH,
13
- MAX_TAG_LENGTH,
14
- MAX_TAGS_COUNT,
15
- MAX_META_LENGTH,
16
- MAX_SOURCE_LENGTH,
17
- MAX_IDENTITY_KEY_LENGTH,
18
- } from "../../constants.js";
19
-
20
- const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
21
- const SKIP_THRESHOLD = 0.95;
22
- const UPDATE_THRESHOLD = 0.85;
23
-
24
- async function findSimilar(
25
- ctx,
26
- embedding,
27
- threshold,
28
- userId,
29
- { hydrate = false } = {},
30
- ) {
31
- try {
32
- const vecCount = ctx.db
33
- .prepare("SELECT COUNT(*) as c FROM vault_vec")
34
- .get().c;
35
- if (vecCount === 0) return [];
36
-
37
- const vecRows = ctx.db
38
- .prepare(
39
- `SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ?`,
40
- )
41
- .all(embedding, 10);
42
-
43
- if (!vecRows.length) return [];
44
-
45
- const rowids = vecRows.map((vr) => vr.rowid);
46
- const placeholders = rowids.map(() => "?").join(",");
47
- // Local mode has no user_id column — omit it from the SELECT list.
48
- const isLocal = ctx.stmts._mode === "local";
49
- const columns = isLocal
50
- ? hydrate
51
- ? "rowid, id, title, body, kind, tags, category, updated_at"
52
- : "rowid, id, title, category"
53
- : hydrate
54
- ? "rowid, id, title, body, kind, tags, category, user_id, updated_at"
55
- : "rowid, id, title, category, user_id";
56
- const hydratedRows = ctx.db
57
- .prepare(`SELECT ${columns} FROM vault WHERE rowid IN (${placeholders})`)
58
- .all(...rowids);
59
-
60
- const byRowid = new Map();
61
- for (const row of hydratedRows) byRowid.set(row.rowid, row);
62
-
63
- const results = [];
64
- for (const vr of vecRows) {
65
- const similarity = Math.max(0, 1 - vr.distance / 2);
66
- if (similarity < threshold) continue;
67
- const row = byRowid.get(vr.rowid);
68
- if (!row) continue;
69
- if (!isLocal && userId !== undefined && row.user_id !== userId) continue;
70
- if (row.category === "entity") continue;
71
- const entry = { id: row.id, title: row.title, score: similarity };
72
- if (hydrate) {
73
- entry.body = row.body;
74
- entry.kind = row.kind;
75
- entry.tags = row.tags;
76
- entry.updated_at = row.updated_at;
77
- }
78
- results.push(entry);
79
- }
80
- return results;
81
- } catch {
82
- return [];
83
- }
84
- }
85
-
86
- function formatSimilarWarning(similar) {
87
- const lines = ["", "⚠ Similar entries already exist:"];
88
- for (const e of similar) {
89
- const score = e.score.toFixed(2);
90
- const title = e.title ? `"${e.title}"` : "(no title)";
91
- lines.push(` - ${title} (${score}) — id: ${e.id}`);
92
- }
93
- lines.push(
94
- " Consider using `id: <existing>` in save_context to update instead.",
95
- );
96
- return lines.join("\n");
97
- }
98
-
99
- export function buildConflictCandidates(similarEntries) {
100
- return similarEntries.map((entry) => {
101
- let suggested_action;
102
- let reasoning_context;
103
-
104
- if (entry.score >= SKIP_THRESHOLD) {
105
- suggested_action = "SKIP";
106
- reasoning_context =
107
- `Near-duplicate detected (${(entry.score * 100).toFixed(0)}% similarity)` +
108
- `${entry.title ? ` with "${entry.title}"` : ""}. ` +
109
- `Content is nearly identical — saving would create a redundant entry. ` +
110
- `Use save_context with id: "${entry.id}" to update instead, or skip saving entirely.`;
111
- } else if (entry.score >= UPDATE_THRESHOLD) {
112
- suggested_action = "UPDATE";
113
- reasoning_context =
114
- `High content similarity (${(entry.score * 100).toFixed(0)}%)` +
115
- `${entry.title ? ` with "${entry.title}"` : ""}. ` +
116
- `Likely the same knowledge — consider updating this entry via save_context with id: "${entry.id}".`;
117
- } else {
118
- suggested_action = "ADD";
119
- reasoning_context =
120
- `Moderate similarity (${(entry.score * 100).toFixed(0)}%)` +
121
- `${entry.title ? ` with "${entry.title}"` : ""}. ` +
122
- `Content is related but distinct enough to coexist.`;
123
- }
124
-
125
- let parsedTags = [];
126
- if (entry.tags) {
127
- try {
128
- parsedTags =
129
- typeof entry.tags === "string" ? JSON.parse(entry.tags) : entry.tags;
130
- } catch {
131
- parsedTags = [];
132
- }
133
- }
134
-
135
- return {
136
- id: entry.id,
137
- title: entry.title || null,
138
- body: entry.body || null,
139
- kind: entry.kind || null,
140
- tags: parsedTags,
141
- score: entry.score,
142
- updated_at: entry.updated_at || null,
143
- suggested_action,
144
- reasoning_context,
145
- };
146
- });
147
- }
148
-
149
- function formatConflictSuggestions(candidates) {
150
- const lines = ["", "── Conflict Resolution Suggestions ──"];
151
- for (const c of candidates) {
152
- const titleDisplay = c.title ? `"${c.title}"` : "(no title)";
153
- lines.push(
154
- ` [${c.suggested_action}] ${titleDisplay} (${(c.score * 100).toFixed(0)}%) — id: ${c.id}`,
155
- );
156
- lines.push(` ${c.reasoning_context}`);
157
- }
158
- return lines.join("\n");
159
- }
160
-
161
- /**
162
- * Validate input fields for save_context. Returns an error response or null.
163
- */
164
- function validateSaveInput({
165
- kind,
166
- title,
167
- body,
168
- tags,
169
- meta,
170
- source,
171
- identity_key,
172
- expires_at,
173
- }) {
174
- if (kind !== undefined && kind !== null) {
175
- if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
176
- return err(
177
- `kind must be a string, max ${MAX_KIND_LENGTH} chars`,
178
- "INVALID_INPUT",
179
- );
180
- }
181
- }
182
- if (body !== undefined && body !== null) {
183
- if (typeof body !== "string" || body.length > MAX_BODY_LENGTH) {
184
- return err(
185
- `body must be a string, max ${MAX_BODY_LENGTH / 1024}KB`,
186
- "INVALID_INPUT",
187
- );
188
- }
189
- }
190
- if (title !== undefined && title !== null) {
191
- if (typeof title !== "string" || title.length > MAX_TITLE_LENGTH) {
192
- return err(
193
- `title must be a string, max ${MAX_TITLE_LENGTH} chars`,
194
- "INVALID_INPUT",
195
- );
196
- }
197
- }
198
- if (tags !== undefined && tags !== null) {
199
- if (!Array.isArray(tags))
200
- return err("tags must be an array of strings", "INVALID_INPUT");
201
- if (tags.length > MAX_TAGS_COUNT)
202
- return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
203
- for (const tag of tags) {
204
- if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
205
- return err(
206
- `each tag must be a string, max ${MAX_TAG_LENGTH} chars`,
207
- "INVALID_INPUT",
208
- );
209
- }
210
- }
211
- }
212
- if (meta !== undefined && meta !== null) {
213
- const metaStr = JSON.stringify(meta);
214
- if (metaStr.length > MAX_META_LENGTH) {
215
- return err(
216
- `meta must be under ${MAX_META_LENGTH / 1024}KB when serialized`,
217
- "INVALID_INPUT",
218
- );
219
- }
220
- }
221
- if (source !== undefined && source !== null) {
222
- if (typeof source !== "string" || source.length > MAX_SOURCE_LENGTH) {
223
- return err(
224
- `source must be a string, max ${MAX_SOURCE_LENGTH} chars`,
225
- "INVALID_INPUT",
226
- );
227
- }
228
- }
229
- if (identity_key !== undefined && identity_key !== null) {
230
- if (
231
- typeof identity_key !== "string" ||
232
- identity_key.length > MAX_IDENTITY_KEY_LENGTH
233
- ) {
234
- return err(
235
- `identity_key must be a string, max ${MAX_IDENTITY_KEY_LENGTH} chars`,
236
- "INVALID_INPUT",
237
- );
238
- }
239
- }
240
- if (expires_at !== undefined && expires_at !== null) {
241
- if (
242
- typeof expires_at !== "string" ||
243
- isNaN(new Date(expires_at).getTime())
244
- ) {
245
- return err("expires_at must be a valid ISO date string", "INVALID_INPUT");
246
- }
247
- }
248
- return null;
249
- }
250
-
251
- export const name = "save_context";
252
-
253
- export const description =
254
- "Save knowledge to your vault. Creates a .md file and indexes it for search. Use for any kind of context: insights, decisions, patterns, references, or any custom kind. To update an existing entry, pass its `id` — omitted fields are preserved.";
255
-
256
- export const inputSchema = {
257
- id: z
258
- .string()
259
- .optional()
260
- .describe(
261
- "Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved.",
262
- ),
263
- kind: z
264
- .string()
265
- .optional()
266
- .describe(
267
- "Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries.",
268
- ),
269
- title: z.string().optional().describe("Entry title (optional for insights)"),
270
- body: z
271
- .string()
272
- .optional()
273
- .describe("Main content. Required for new entries."),
274
- tags: z
275
- .array(z.string())
276
- .optional()
277
- .describe(
278
- "Tags for categorization and search. Use 'bucket:' prefix for project/domain scoping (e.g., 'bucket:autohub') to enable project-scoped retrieval.",
279
- ),
280
- meta: z
281
- .any()
282
- .optional()
283
- .describe(
284
- "Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })",
285
- ),
286
- folder: z
287
- .string()
288
- .optional()
289
- .describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
290
- source: z.string().optional().describe("Where this knowledge came from"),
291
- identity_key: z
292
- .string()
293
- .optional()
294
- .describe(
295
- "Required for entity kinds (contact, project, tool, source). The unique identifier for this entity.",
296
- ),
297
- expires_at: z.string().optional().describe("ISO date for TTL expiry"),
298
- supersedes: z
299
- .array(z.string())
300
- .optional()
301
- .describe(
302
- "Array of entry IDs that this entry supersedes/replaces. Those entries will be marked with superseded_by pointing to this new entry and excluded from future search results by default.",
303
- ),
304
- related_to: z
305
- .array(z.string())
306
- .optional()
307
- .describe(
308
- "Array of entry IDs this entry is related to. Enables bidirectional graph traversal — use get_context with follow_links:true to retrieve linked entries.",
309
- ),
310
- source_files: z
311
- .array(
312
- z.object({
313
- path: z.string().describe("File path (absolute or relative to cwd)"),
314
- hash: z
315
- .string()
316
- .describe("SHA-256 hash of the file contents at observation time"),
317
- }),
318
- )
319
- .optional()
320
- .describe(
321
- "Source code files this entry is derived from. When these files change (hash mismatch), the entry will be flagged as stale in get_context results.",
322
- ),
323
- tier: z
324
- .enum(["ephemeral", "working", "durable"])
325
- .optional()
326
- .describe(
327
- "Memory tier for lifecycle management. 'ephemeral': short-lived session data. 'working': active context (default). 'durable': long-term reference material. Defaults based on kind when not specified.",
328
- ),
329
- dry_run: z
330
- .boolean()
331
- .optional()
332
- .describe(
333
- "If true, check for similar entries without saving. Returns similarity results without creating a new entry. Only applies to knowledge and event categories.",
334
- ),
335
- similarity_threshold: z
336
- .number()
337
- .min(0)
338
- .max(1)
339
- .optional()
340
- .describe(
341
- "Cosine similarity threshold for duplicate detection (0–1, default 0.85). Entries above this score are flagged as similar. Only applies to knowledge and event categories.",
342
- ),
343
- tier: z
344
- .enum(["ephemeral", "working", "durable"])
345
- .optional()
346
- .describe(
347
- "Memory tier for lifecycle management. 'ephemeral': short-lived session data. 'working': active context (default). 'durable': long-term reference material. Defaults based on kind when not specified.",
348
- ),
349
- conflict_resolution: z
350
- .enum(["suggest", "off"])
351
- .optional()
352
- .describe(
353
- 'Conflict resolution mode. "suggest" (default): when similar entries are found, return structured conflict_candidates with suggested_action (ADD/UPDATE/SKIP) and reasoning_context for the calling agent to decide. Thresholds: score > 0.95 → SKIP (near-duplicate), score > 0.85 → UPDATE (very similar), score < 0.85 → ADD (distinct enough). "off": flag similar entries only (legacy behavior).',
354
- ),
355
- };
356
-
357
- /**
358
- * @param {object} args
359
- * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
360
- * @param {import('../types.js').ToolShared} shared
361
- */
362
- export async function handler(
363
- {
364
- id,
365
- kind,
366
- title,
367
- body,
368
- tags,
369
- meta,
370
- folder,
371
- source,
372
- identity_key,
373
- expires_at,
374
- supersedes,
375
- related_to,
376
- source_files,
377
- dry_run,
378
- similarity_threshold,
379
- tier,
380
- conflict_resolution,
381
- },
382
- ctx,
383
- { ensureIndexed },
384
- ) {
385
- const { config } = ctx;
386
- const userId = ctx.userId !== undefined ? ctx.userId : undefined;
387
- const suggestMode = conflict_resolution !== "off";
388
-
389
- const vaultErr = ensureVaultExists(config);
390
- if (vaultErr) return vaultErr;
391
-
392
- const relatedToErr = validateRelatedTo(related_to);
393
- if (relatedToErr) return err(relatedToErr, "INVALID_INPUT");
394
-
395
- const inputErr = validateSaveInput({
396
- kind,
397
- title,
398
- body,
399
- tags,
400
- meta,
401
- source,
402
- identity_key,
403
- expires_at,
404
- });
405
- if (inputErr) return inputErr;
406
-
407
- // ── Update mode ──
408
- if (id) {
409
- await ensureIndexed();
410
-
411
- const existing = ctx.stmts.getEntryById.get(id);
412
- if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
413
-
414
- // Ownership check: don't leak existence across users
415
- if (userId !== undefined && existing.user_id !== userId) {
416
- return err(`Entry not found: ${id}`, "NOT_FOUND");
417
- }
418
-
419
- if (kind && normalizeKind(kind) !== existing.kind) {
420
- return err(
421
- `Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`,
422
- "INVALID_UPDATE",
423
- );
424
- }
425
- if (identity_key && identity_key !== existing.identity_key) {
426
- return err(
427
- `Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`,
428
- "INVALID_UPDATE",
429
- );
430
- }
431
-
432
- // Decrypt existing entry before merge if encrypted
433
- if (ctx.decrypt && existing.body_encrypted) {
434
- const decrypted = await ctx.decrypt(existing);
435
- existing.body = decrypted.body;
436
- if (decrypted.title) existing.title = decrypted.title;
437
- if (decrypted.meta) existing.meta = JSON.stringify(decrypted.meta);
438
- }
439
-
440
- const entry = updateEntryFile(ctx, existing, {
441
- title,
442
- body,
443
- tags,
444
- meta,
445
- source,
446
- expires_at,
447
- supersedes,
448
- related_to,
449
- source_files,
450
- });
451
- await indexEntry(ctx, entry);
452
- if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
453
- ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
454
- } else if (entry.related_to === null && ctx.stmts.updateRelatedTo) {
455
- ctx.stmts.updateRelatedTo.run(null, entry.id);
456
- }
457
- const relPath = entry.filePath
458
- ? entry.filePath.replace(config.vaultDir + "/", "")
459
- : entry.filePath;
460
- const parts = [`✓ Updated ${entry.kind} → ${relPath}`, ` id: ${entry.id}`];
461
- if (entry.title) parts.push(` title: ${entry.title}`);
462
- const entryTags = entry.tags || [];
463
- if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
464
- parts.push("", "_Search with get_context to verify changes._");
465
- return ok(parts.join("\n"));
466
- }
467
-
468
- // ── Create mode ──
469
- if (!kind) return err("Required: kind (for new entries)", "INVALID_INPUT");
470
- const kindErr = ensureValidKind(kind);
471
- if (kindErr) return kindErr;
472
- if (!body?.trim())
473
- return err("Required: body (for new entries)", "INVALID_INPUT");
474
-
475
- // Normalize kind to canonical singular form (e.g. "insights" → "insight")
476
- const normalizedKind = normalizeKind(kind);
477
-
478
- if (categoryFor(normalizedKind) === "entity" && !identity_key) {
479
- return err(
480
- `Entity kind "${normalizedKind}" requires identity_key`,
481
- "MISSING_IDENTITY_KEY",
482
- );
483
- }
484
-
485
- await ensureIndexed();
486
-
487
- // ── Similarity check (knowledge + event only) ────────────────────────────
488
- const category = categoryFor(normalizedKind);
489
- let similarEntries = [];
490
-
491
- if (category === "knowledge" || category === "event") {
492
- const threshold = similarity_threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
493
- const embeddingText = [title, body].filter(Boolean).join(" ");
494
- const queryEmbedding = await ctx.embed(embeddingText);
495
- if (queryEmbedding) {
496
- similarEntries = await findSimilar(
497
- ctx,
498
- queryEmbedding,
499
- threshold,
500
- userId,
501
- { hydrate: suggestMode },
502
- );
503
- }
504
- }
505
-
506
- if (dry_run) {
507
- const parts = ["(dry run — nothing saved)"];
508
- if (similarEntries.length) {
509
- if (suggestMode) {
510
- const candidates = buildConflictCandidates(similarEntries);
511
- parts.push("", "⚠ Similar entries already exist:");
512
- for (const e of similarEntries) {
513
- const score = e.score.toFixed(2);
514
- const titleDisplay = e.title ? `"${e.title}"` : "(no title)";
515
- parts.push(` - ${titleDisplay} (${score}) — id: ${e.id}`);
516
- }
517
- parts.push(formatConflictSuggestions(candidates));
518
- parts.push(
519
- "",
520
- "Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
521
- );
522
- } else {
523
- parts.push("", "⚠ Similar entries already exist:");
524
- for (const e of similarEntries) {
525
- const score = e.score.toFixed(2);
526
- const titleDisplay = e.title ? `"${e.title}"` : "(no title)";
527
- parts.push(` - ${titleDisplay} (${score}) — id: ${e.id}`);
528
- }
529
- parts.push(
530
- "",
531
- "Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
532
- );
533
- }
534
- } else {
535
- parts.push("", "No similar entries found. Safe to save.");
536
- }
537
- return ok(parts.join("\n"));
538
- }
539
-
540
- const mergedMeta = { ...(meta || {}) };
541
- if (folder) mergedMeta.folder = folder;
542
- const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
543
-
544
- const effectiveTier = tier ?? defaultTierFor(normalizedKind);
545
-
546
- const entry = await captureAndIndex(ctx, {
547
- kind: normalizedKind,
548
- title,
549
- body,
550
- meta: finalMeta,
551
- tags,
552
- source,
553
- folder,
554
- identity_key,
555
- expires_at,
556
- supersedes,
557
- related_to,
558
- source_files,
559
- userId,
560
- tier: effectiveTier,
561
- });
562
-
563
- if (ctx.config?.dataDir) {
564
- maybeShowFeedbackPrompt(ctx.config.dataDir);
565
- }
566
-
567
- const relPath = entry.filePath
568
- ? entry.filePath.replace(config.vaultDir + "/", "")
569
- : entry.filePath;
570
- const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
571
- if (title) parts.push(` title: ${title}`);
572
- if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
573
- parts.push(` tier: ${effectiveTier}`);
574
- parts.push("", "_Use this id to update or delete later._");
575
- const hasBucketTag = (tags || []).some(
576
- (t) => typeof t === "string" && t.startsWith("bucket:"),
577
- );
578
- if (tags && tags.length > 0 && !hasBucketTag) {
579
- parts.push(
580
- "",
581
- "_Tip: Consider adding a `bucket:` tag (e.g., `bucket:myproject`) for project-scoped retrieval._",
582
- );
583
- }
584
- const bucketTags = (tags || []).filter(
585
- (t) => typeof t === "string" && t.startsWith("bucket:"),
586
- );
587
- for (const bt of bucketTags) {
588
- const bucketUserClause = userId !== undefined ? "AND user_id = ?" : "";
589
- const bucketParams = userId !== undefined ? [bt, userId] : [bt];
590
- const exists = ctx.db
591
- .prepare(
592
- `SELECT 1 FROM vault WHERE kind = 'bucket' AND identity_key = ? ${bucketUserClause} LIMIT 1`,
593
- )
594
- .get(...bucketParams);
595
- if (!exists) {
596
- parts.push(
597
- ``,
598
- `_Note: bucket '${bt}' is not registered. Use save_context(kind: "bucket", identity_key: "${bt}") to register it._`,
599
- );
600
- }
601
- }
602
- if (similarEntries.length) {
603
- if (suggestMode) {
604
- const candidates = buildConflictCandidates(similarEntries);
605
- parts.push(formatSimilarWarning(similarEntries));
606
- parts.push(formatConflictSuggestions(candidates));
607
- } else {
608
- parts.push(formatSimilarWarning(similarEntries));
609
- }
610
- }
611
-
612
- const criticalLimit = config.thresholds?.totalEntries?.critical;
613
- if (criticalLimit != null) {
614
- try {
615
- const countRow = ctx.db
616
- .prepare(
617
- userId !== undefined
618
- ? "SELECT COUNT(*) as c FROM vault WHERE user_id = ?"
619
- : "SELECT COUNT(*) as c FROM vault",
620
- )
621
- .get(...(userId !== undefined ? [userId] : []));
622
- if (countRow.c >= criticalLimit) {
623
- parts.push(
624
- ``,
625
- `ℹ Vault has ${countRow.c.toLocaleString()} entries. Consider running \`context-vault reindex\` or reviewing old entries.`,
626
- );
627
- }
628
- } catch {}
629
- }
630
-
631
- return ok(parts.join("\n"));
632
- }