context-vault 3.18.0 → 3.20.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 (80) hide show
  1. package/bin/cli.js +673 -4
  2. package/dist/register-tools.d.ts.map +1 -1
  3. package/dist/register-tools.js +0 -2
  4. package/dist/register-tools.js.map +1 -1
  5. package/dist/server.js +78 -1
  6. package/dist/server.js.map +1 -1
  7. package/dist/tools/recall.d.ts +1 -1
  8. package/dist/tools/recall.d.ts.map +1 -1
  9. package/dist/tools/recall.js +50 -100
  10. package/dist/tools/recall.js.map +1 -1
  11. package/node_modules/@context-vault/core/dist/assemble.d.ts +22 -0
  12. package/node_modules/@context-vault/core/dist/assemble.d.ts.map +1 -0
  13. package/node_modules/@context-vault/core/dist/assemble.js +143 -0
  14. package/node_modules/@context-vault/core/dist/assemble.js.map +1 -0
  15. package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
  16. package/node_modules/@context-vault/core/dist/capture.js +10 -5
  17. package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
  18. package/node_modules/@context-vault/core/dist/consolidation.d.ts +40 -0
  19. package/node_modules/@context-vault/core/dist/consolidation.d.ts.map +1 -0
  20. package/node_modules/@context-vault/core/dist/consolidation.js +229 -0
  21. package/node_modules/@context-vault/core/dist/consolidation.js.map +1 -0
  22. package/node_modules/@context-vault/core/dist/db.d.ts +25 -1
  23. package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
  24. package/node_modules/@context-vault/core/dist/db.js +92 -4
  25. package/node_modules/@context-vault/core/dist/db.js.map +1 -1
  26. package/node_modules/@context-vault/core/dist/frontmatter.d.ts.map +1 -1
  27. package/node_modules/@context-vault/core/dist/frontmatter.js +26 -3
  28. package/node_modules/@context-vault/core/dist/frontmatter.js.map +1 -1
  29. package/node_modules/@context-vault/core/dist/index.d.ts.map +1 -1
  30. package/node_modules/@context-vault/core/dist/index.js +225 -184
  31. package/node_modules/@context-vault/core/dist/index.js.map +1 -1
  32. package/node_modules/@context-vault/core/dist/main.d.ts +3 -0
  33. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  34. package/node_modules/@context-vault/core/dist/main.js +4 -0
  35. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  36. package/node_modules/@context-vault/core/dist/search.d.ts +6 -0
  37. package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
  38. package/node_modules/@context-vault/core/dist/search.js +106 -5
  39. package/node_modules/@context-vault/core/dist/search.js.map +1 -1
  40. package/node_modules/@context-vault/core/dist/search.test.d.ts +2 -0
  41. package/node_modules/@context-vault/core/dist/search.test.d.ts.map +1 -0
  42. package/node_modules/@context-vault/core/dist/search.test.js +49 -0
  43. package/node_modules/@context-vault/core/dist/search.test.js.map +1 -0
  44. package/node_modules/@context-vault/core/dist/summarize.d.ts +5 -0
  45. package/node_modules/@context-vault/core/dist/summarize.d.ts.map +1 -0
  46. package/node_modules/@context-vault/core/dist/summarize.js +146 -0
  47. package/node_modules/@context-vault/core/dist/summarize.js.map +1 -0
  48. package/node_modules/@context-vault/core/dist/types.d.ts +2 -0
  49. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  50. package/node_modules/@context-vault/core/package.json +13 -1
  51. package/node_modules/@context-vault/core/src/assemble.ts +187 -0
  52. package/node_modules/@context-vault/core/src/capture.ts +10 -5
  53. package/node_modules/@context-vault/core/src/consolidation.ts +356 -0
  54. package/node_modules/@context-vault/core/src/db.ts +95 -4
  55. package/node_modules/@context-vault/core/src/frontmatter.ts +25 -4
  56. package/node_modules/@context-vault/core/src/index.ts +127 -88
  57. package/node_modules/@context-vault/core/src/main.ts +7 -0
  58. package/node_modules/@context-vault/core/src/search.test.ts +59 -0
  59. package/node_modules/@context-vault/core/src/search.ts +112 -5
  60. package/node_modules/@context-vault/core/src/summarize.ts +157 -0
  61. package/node_modules/@context-vault/core/src/types.ts +2 -0
  62. package/package.json +2 -2
  63. package/scripts/validate-epipe-shutdown.mjs +183 -0
  64. package/scripts/validate-sqlite-busy-retry.mjs +243 -0
  65. package/src/register-tools.ts +0 -2
  66. package/src/server.ts +76 -1
  67. package/src/tools/recall.ts +51 -110
  68. package/.claude-plugin/README.md +0 -219
  69. package/.claude-plugin/plugin.json +0 -11
  70. package/commands/vault-cleanup.md +0 -43
  71. package/commands/vault-snapshot.md +0 -43
  72. package/commands/vault-status.md +0 -35
  73. package/dist/tools/session-start.d.ts +0 -25
  74. package/dist/tools/session-start.d.ts.map +0 -1
  75. package/dist/tools/session-start.js +0 -469
  76. package/dist/tools/session-start.js.map +0 -1
  77. package/skills/context-assembly/SKILL.md +0 -308
  78. package/skills/knowledge-capture/SKILL.md +0 -303
  79. package/skills/memory-management/SKILL.md +0 -237
  80. package/src/tools/session-start.ts +0 -527
package/bin/cli.js CHANGED
@@ -240,6 +240,17 @@ function getFlag(name) {
240
240
  return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
241
241
  }
242
242
 
243
+ function getFlagAll(name) {
244
+ const results = [];
245
+ for (let i = 0; i < args.length - 1; i++) {
246
+ if (args[i] === name) {
247
+ const val = args[i + 1];
248
+ if (val && !val.startsWith('--')) results.push(val);
249
+ }
250
+ }
251
+ return results;
252
+ }
253
+
243
254
  function prompt(question, defaultVal) {
244
255
  if (isNonInteractive) return Promise.resolve(defaultVal || '');
245
256
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -447,6 +458,9 @@ ${bold('Commands:')}
447
458
  ${cyan('reconnect')} Fix vault path, kill stale servers, re-register MCP, reindex
448
459
  ${cyan('search')} Search vault entries from CLI
449
460
  ${cyan('save')} Save an entry to the vault from CLI
461
+ ${cyan('get')} <id|key> Fetch a single entry by ULID or identity key
462
+ ${cyan('buckets')} List registered buckets with entry counts
463
+ ${cyan('dupes')} Find near-duplicate entries (read-only)
450
464
  ${cyan('import')} <path> Import entries from file, directory, or .zip archive
451
465
  ${cyan('export')} Export vault entries (JSON, CSV, or portable ZIP)
452
466
  ${cyan('ingest')} <url> Fetch URL and save as vault entry
@@ -462,6 +476,7 @@ ${bold('Commands:')}
462
476
  ${cyan('archive')} Archive old ephemeral/event entries (use --dry-run to preview)
463
477
  ${cyan('restore')} <id> Restore an archived entry back into the vault
464
478
  ${cyan('prune')} Remove expired entries (use --dry-run to preview)
479
+ ${cyan('delete')} <id> Hard-delete entry by ULID (DB + vec + file; clears superseded_by refs)
465
480
  ${cyan('stale')} List entries with low freshness scores
466
481
  ${cyan('stats')} recall|co-retrieval Measure recall ratio and co-retrieval graph
467
482
  ${cyan('remote')} setup|status|sync|pull Connect to hosted vault (cloud sync)
@@ -2683,6 +2698,118 @@ async function runPrune() {
2683
2698
  }
2684
2699
  }
2685
2700
 
2701
+ async function runDelete() {
2702
+ if (flags.has('--help')) {
2703
+ console.log(`
2704
+ ${bold('context-vault delete')} <id> [options]
2705
+
2706
+ Hard-delete a vault entry by ULID. Removes the DB row, vector-index row,
2707
+ and the markdown file on disk. Also clears any ${cyan('superseded_by')} refs
2708
+ on other entries that pointed at this one (no dangling links).
2709
+
2710
+ Distinct from ${cyan('archive')} (soft, reversible via ${cyan('restore')})
2711
+ and ${cyan('prune')} (bulk by expiry policy).
2712
+
2713
+ ${bold('Usage:')}
2714
+ context-vault delete <id> Delete entry by ULID
2715
+ context-vault delete <id> --dry-run Show what would be deleted, no changes
2716
+ context-vault delete <id> --yes Skip confirmation prompt
2717
+
2718
+ ${bold('Examples:')}
2719
+ context-vault delete 01KQ0123456789ABCDEFGHJKMN
2720
+ context-vault delete 01KQ0123456789ABCDEFGHJKMN --yes
2721
+ `);
2722
+ return;
2723
+ }
2724
+
2725
+ const entryId = args[1];
2726
+ if (!entryId || entryId.startsWith('--')) {
2727
+ console.error(red('Error: entry id is required'));
2728
+ console.error(`Usage: ${cyan('context-vault delete <id>')}`);
2729
+ process.exit(1);
2730
+ }
2731
+
2732
+ const dryRun = flags.has('--dry-run');
2733
+
2734
+ const { resolveConfig } = await import('@context-vault/core/config');
2735
+ const { initDatabase, prepareStatements, deleteVec } = await import('@context-vault/core/db');
2736
+
2737
+ const config = resolveConfig();
2738
+ if (!config.vaultDirExists) {
2739
+ console.error(red(`Vault directory not found: ${config.vaultDir}`));
2740
+ console.error('Run ' + cyan('context-vault setup') + ' to configure.');
2741
+ process.exit(1);
2742
+ }
2743
+
2744
+ const db = await initDatabase(config.dbPath);
2745
+ try {
2746
+ const stmts = prepareStatements(db);
2747
+ const row = stmts.getEntryById.get(entryId);
2748
+ if (!row) {
2749
+ console.error(red(`Error: no entry found with id ${entryId}`));
2750
+ process.exit(1);
2751
+ }
2752
+
2753
+ const label = row.title ? `${row.kind}: ${row.title}` : `${row.kind} (${row.id})`;
2754
+ const filePath = row.file_path || null;
2755
+
2756
+ if (dryRun) {
2757
+ console.log(`\n Would delete: ${label}`);
2758
+ if (filePath) console.log(dim(` file: ${filePath}`));
2759
+ console.log(dim(` id: ${row.id}`));
2760
+ const referrers = db
2761
+ .prepare('SELECT id, kind, title FROM vault WHERE superseded_by = ?')
2762
+ .all(entryId);
2763
+ if (referrers.length) {
2764
+ console.log(dim(` ${referrers.length} entr${referrers.length === 1 ? 'y has' : 'ies have'} superseded_by pointing here; those refs would be cleared:`));
2765
+ for (const r of referrers) {
2766
+ const rlabel = r.title ? `${r.kind}: ${r.title}` : `${r.kind} (${r.id})`;
2767
+ console.log(dim(` - ${rlabel}`));
2768
+ }
2769
+ }
2770
+ console.log(dim('\n Dry run — nothing changed.'));
2771
+ return;
2772
+ }
2773
+
2774
+ if (!isNonInteractive) {
2775
+ const answer = await prompt(`Delete ${label}? (y/N)`, 'N');
2776
+ if (!/^y(es)?$/i.test((answer || '').trim())) {
2777
+ console.log(dim(' Aborted.'));
2778
+ return;
2779
+ }
2780
+ }
2781
+
2782
+ const rowidRow = stmts.getRowid.get(entryId);
2783
+ if (rowidRow?.rowid) {
2784
+ try {
2785
+ deleteVec(stmts, Number(rowidRow.rowid));
2786
+ } catch {}
2787
+ }
2788
+ try {
2789
+ stmts.clearSupersededByRef.run(entryId);
2790
+ } catch {}
2791
+ stmts.deleteEntry.run(entryId);
2792
+
2793
+ if (filePath) {
2794
+ try {
2795
+ unlinkSync(filePath);
2796
+ } catch (e) {
2797
+ if (e?.code !== 'ENOENT') {
2798
+ console.error(yellow(` Warning: could not remove file: ${e.message}`));
2799
+ }
2800
+ }
2801
+ }
2802
+
2803
+ console.log(`${green('✓')} Deleted ${label}`);
2804
+ console.log(dim(` id: ${entryId}`));
2805
+ if (filePath) console.log(dim(` file removed: ${filePath}`));
2806
+ } finally {
2807
+ try {
2808
+ db.close();
2809
+ } catch {}
2810
+ }
2811
+ }
2812
+
2686
2813
  async function runArchive() {
2687
2814
  const dryRun = flags.has('--dry-run');
2688
2815
 
@@ -2908,6 +3035,226 @@ async function runCompact() {
2908
3035
  console.log(dim(' Use context-vault restore <id> to recover full body.'));
2909
3036
  }
2910
3037
 
3038
+ async function runGet() {
3039
+ if (flags.has('--help')) {
3040
+ console.log(`
3041
+ ${bold('context-vault get')} <id|identity-key> [options]
3042
+
3043
+ Fetch a single vault entry by ULID or by identity key.
3044
+
3045
+ ${bold('Usage:')}
3046
+ context-vault get 01KQ0123456789ABCDEFGHJKMN
3047
+ context-vault get bucket:myproject --kind bucket
3048
+ context-vault get myproject --kind bucket
3049
+ context-vault get <id> --format json
3050
+
3051
+ ${bold('Options:')}
3052
+ --kind <kind> Resolve the argument as an identity key for this kind
3053
+ --format <fmt> Output format: plain (default), json
3054
+ --full Show full entry body (not truncated)
3055
+ `);
3056
+ return;
3057
+ }
3058
+
3059
+ const ref = args[1];
3060
+ if (!ref || ref.startsWith('--')) {
3061
+ console.error(red('Error: entry id or identity key is required'));
3062
+ console.error(`Usage: ${cyan('context-vault get <id|identity-key>')}`);
3063
+ process.exit(1);
3064
+ }
3065
+
3066
+ const kindFlag = getFlag('--kind');
3067
+ const format = getFlag('--format') || 'plain';
3068
+ const showFull = flags.has('--full');
3069
+
3070
+ const { resolveConfig } = await import('@context-vault/core/config');
3071
+ const { initDatabase, prepareStatements } = await import('@context-vault/core/db');
3072
+
3073
+ const config = resolveConfig();
3074
+ if (!config.vaultDirExists) {
3075
+ console.error(red('No vault found. Run: context-vault setup'));
3076
+ process.exit(1);
3077
+ }
3078
+
3079
+ const db = await initDatabase(config.dbPath);
3080
+ try {
3081
+ const stmts = prepareStatements(db);
3082
+
3083
+ let row = null;
3084
+ if (kindFlag) {
3085
+ const key = ref.includes(':') ? ref : `${kindFlag}:${ref}`;
3086
+ row = stmts.getByIdentityKey.get(kindFlag, key) || stmts.getByIdentityKey.get(kindFlag, ref);
3087
+ } else {
3088
+ row = stmts.getEntryById.get(ref);
3089
+ if (!row && ref.includes(':')) {
3090
+ const inferredKind = ref.slice(0, ref.indexOf(':'));
3091
+ row = stmts.getByIdentityKey.get(inferredKind, ref);
3092
+ }
3093
+ }
3094
+
3095
+ if (!row) {
3096
+ console.error(red(`Error: no entry found for "${ref}"${kindFlag ? ` (kind ${kindFlag})` : ''}`));
3097
+ process.exit(1);
3098
+ }
3099
+
3100
+ if (format === 'json') {
3101
+ console.log(JSON.stringify(row, null, 2));
3102
+ return;
3103
+ }
3104
+
3105
+ const tags = row.tags ? JSON.parse(row.tags) : [];
3106
+ console.log(`\n ${bold(row.title || `${row.kind} (${row.id})`)}`);
3107
+ console.log(dim(` id: ${row.id}`));
3108
+ console.log(dim(` kind: ${row.kind}${row.category ? ` category: ${row.category}` : ''}`));
3109
+ if (row.identity_key) console.log(dim(` key: ${row.identity_key}`));
3110
+ if (tags.length) console.log(dim(` tags: ${tags.join(', ')}`));
3111
+ console.log(dim(` created: ${row.created_at}${row.updated_at ? ` updated: ${row.updated_at}` : ''}`));
3112
+ if (row.superseded_by) console.log(yellow(` superseded_by: ${row.superseded_by}`));
3113
+ const body = row.body || '';
3114
+ const shown = showFull ? body : body.slice(0, 2000);
3115
+ console.log('\n' + shown + (shown.length < body.length ? dim('\n … (truncated; use --full)') : ''));
3116
+ } finally {
3117
+ db.close();
3118
+ }
3119
+ }
3120
+
3121
+ async function runBuckets() {
3122
+ if (flags.has('--help')) {
3123
+ console.log(`
3124
+ ${bold('context-vault buckets')} [options]
3125
+
3126
+ List all registered bucket entities with entry counts. Buckets are named
3127
+ scopes that group entries via ${cyan('bucket:')}-prefixed tags.
3128
+
3129
+ ${bold('Options:')}
3130
+ --format <fmt> Output format: plain (default), json
3131
+ `);
3132
+ return;
3133
+ }
3134
+
3135
+ const format = getFlag('--format') || 'plain';
3136
+
3137
+ const { resolveConfig } = await import('@context-vault/core/config');
3138
+ const { initDatabase } = await import('@context-vault/core/db');
3139
+
3140
+ const config = resolveConfig();
3141
+ if (!config.vaultDirExists) {
3142
+ console.error(red('No vault found. Run: context-vault setup'));
3143
+ process.exit(1);
3144
+ }
3145
+
3146
+ const db = await initDatabase(config.dbPath);
3147
+ try {
3148
+ const buckets = db
3149
+ .prepare(
3150
+ `SELECT id, title, identity_key, body, tags, created_at, updated_at
3151
+ FROM vault
3152
+ WHERE kind = 'bucket'
3153
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
3154
+ AND superseded_by IS NULL
3155
+ ORDER BY title ASC`
3156
+ )
3157
+ .all();
3158
+
3159
+ const withCounts = buckets.map((b) => {
3160
+ const name = b.identity_key?.replace(/^bucket:/, '') || b.title;
3161
+ const c = db
3162
+ .prepare(
3163
+ `SELECT COUNT(*) AS c FROM vault
3164
+ WHERE superseded_by IS NULL
3165
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
3166
+ AND tags LIKE ?`
3167
+ )
3168
+ .get(`%"bucket:${name}"%`);
3169
+ return { ...b, name, entry_count: c?.c ?? 0 };
3170
+ });
3171
+
3172
+ if (format === 'json') {
3173
+ console.log(JSON.stringify(withCounts, null, 2));
3174
+ return;
3175
+ }
3176
+
3177
+ if (!withCounts.length) {
3178
+ console.log(dim(' No buckets registered.'));
3179
+ return;
3180
+ }
3181
+
3182
+ console.log(`\n ${bold(`Registered buckets (${withCounts.length})`)}\n`);
3183
+ for (const b of withCounts) {
3184
+ const desc = b.body ? ` — ${b.body.split('\n')[0].slice(0, 60)}` : '';
3185
+ console.log(` ${cyan(b.name)} ${dim(`(${b.entry_count})`)}${dim(desc)}`);
3186
+ }
3187
+ } finally {
3188
+ db.close();
3189
+ }
3190
+ }
3191
+
3192
+ async function runDupes() {
3193
+ if (flags.has('--help')) {
3194
+ console.log(`
3195
+ ${bold('context-vault dupes')} [options]
3196
+
3197
+ Find near-duplicate entries by semantic + title similarity. Read-only —
3198
+ reports groups, changes nothing.
3199
+
3200
+ ${bold('Options:')}
3201
+ --threshold <n> Similarity threshold 0..1 (default: 0.85)
3202
+ --limit <n> Max duplicate groups (default: 50)
3203
+ --kind <kind> Restrict to a single kind
3204
+ --format <fmt> Output format: plain (default), json
3205
+ `);
3206
+ return;
3207
+ }
3208
+
3209
+ const threshold = parseFloat(getFlag('--threshold') || '0.85');
3210
+ const limit = parseInt(getFlag('--limit') || '50', 10);
3211
+ const kind = getFlag('--kind') || undefined;
3212
+ const format = getFlag('--format') || 'plain';
3213
+
3214
+ const { resolveConfig } = await import('@context-vault/core/config');
3215
+ const { initDatabase, prepareStatements } = await import('@context-vault/core/db');
3216
+ const { embed } = await import('@context-vault/core/embed');
3217
+ const { findDuplicates } = await import('@context-vault/core/consolidation');
3218
+
3219
+ const config = resolveConfig();
3220
+ if (!config.vaultDirExists) {
3221
+ console.error(red('No vault found. Run: context-vault setup'));
3222
+ process.exit(1);
3223
+ }
3224
+
3225
+ const db = await initDatabase(config.dbPath);
3226
+ try {
3227
+ const stmts = prepareStatements(db);
3228
+ const ctx = { db, config, stmts, embed };
3229
+ const groups = await findDuplicates(ctx, { threshold, limit, kind, dryRun: true });
3230
+
3231
+ if (format === 'json') {
3232
+ console.log(JSON.stringify(groups, null, 2));
3233
+ return;
3234
+ }
3235
+
3236
+ if (!groups.length) {
3237
+ console.log(dim(` No near-duplicates found (threshold ${threshold}).`));
3238
+ return;
3239
+ }
3240
+
3241
+ console.log(`\n ${bold(`Near-duplicate groups (${groups.length})`)} ${dim(`threshold ${threshold}`)}\n`);
3242
+ for (const g of groups) {
3243
+ const dupCount = (g.duplicate_ids?.length ?? 0) + 1;
3244
+ console.log(` ${yellow(`${dupCount}×`)} ${g.canonical_title || ''} ${dim(`sim ${g.similarity}`)}`);
3245
+ console.log(dim(` canonical: ${g.canonical_id}`));
3246
+ for (const id of g.duplicate_ids ?? []) {
3247
+ console.log(dim(` dup: ${id}`));
3248
+ }
3249
+ for (const t of g.sample_titles ?? []) {
3250
+ console.log(dim(` · ${t}`));
3251
+ }
3252
+ }
3253
+ } finally {
3254
+ db.close();
3255
+ }
3256
+ }
3257
+
2911
3258
  async function runStatus() {
2912
3259
  const { resolveConfig } = await import('@context-vault/core/config');
2913
3260
  const { initDatabase } = await import('@context-vault/core/db');
@@ -4181,7 +4528,7 @@ ${bold('Examples:')}
4181
4528
  const dryRun = flags.has('--dry-run');
4182
4529
  const verbose = flags.has('--verbose');
4183
4530
 
4184
- const GMAIL_CLI_PATH = '/Users/admin/omni/workspaces/_archive/agent-tools/gmail-cli/cli.ts';
4531
+ const GMAIL_CLI_PATH = join(homedir(), 'omni/workspaces/_archive/agent-tools/gmail-cli/cli.ts');
4185
4532
  const SKIP_LABELS = new Set([
4186
4533
  'CATEGORY_PROMOTIONS',
4187
4534
  'CATEGORY_SOCIAL',
@@ -4322,7 +4669,7 @@ ${bold('Examples:')}
4322
4669
  }
4323
4670
 
4324
4671
  async function runSlackBridge() {
4325
- const SLACK_CLI_PATH = '/Users/admin/omni/workspaces/_archive/agent-tools/slack-cli/cli.ts';
4672
+ const SLACK_CLI_PATH = join(homedir(), 'omni/workspaces/_archive/agent-tools/slack-cli/cli.ts');
4326
4673
 
4327
4674
  if (flags.has('--help') || (!flags.has('--list-channels') && !getFlag('--channels'))) {
4328
4675
  console.log(`
@@ -5156,10 +5503,19 @@ ${bold('Optional:')}
5156
5503
  --source <source> Source label (default: cli)
5157
5504
  --identity-key <key> Identity key (for entity kinds)
5158
5505
  --meta <json> Additional metadata as JSON
5506
+ --supersedes <id> Mark this entry as superseding an existing entry
5507
+ (can be passed multiple times; old entries get
5508
+ reciprocal superseded_by link to this new entry)
5509
+ --related-to <id> Add a related-to link to another entry by ULID
5510
+ (can be passed multiple times; builds the graph)
5159
5511
 
5160
5512
  ${bold('Examples:')}
5161
5513
  context-vault save --kind insight --title "Express 5 gotcha" --body "body parser changed"
5162
5514
  echo "content" | context-vault save --kind reference --title "API notes"
5515
+ context-vault save --kind decision --title "revised plan" --body "..." \\
5516
+ --supersedes 01K9... --supersedes 01KA...
5517
+ context-vault save --kind insight --title "graph node" --body "..." \\
5518
+ --related-to 01KB... --related-to 01KC...
5163
5519
  `);
5164
5520
  return;
5165
5521
  }
@@ -5173,6 +5529,8 @@ ${bold('Examples:')}
5173
5529
  const bodyFlag = getFlag('--body');
5174
5530
  const identityKey = getFlag('--identity-key');
5175
5531
  const metaRaw = getFlag('--meta');
5532
+ const supersedesList = getFlagAll('--supersedes');
5533
+ const relatedToList = getFlagAll('--related-to');
5176
5534
 
5177
5535
  if (!kind) {
5178
5536
  console.error(red('Error: --kind is required'));
@@ -5248,8 +5606,17 @@ ${bold('Examples:')}
5248
5606
  ...(tier ? { tier } : {}),
5249
5607
  ...(identityKey ? { identity_key: identityKey } : {}),
5250
5608
  ...(meta !== undefined ? { meta } : {}),
5609
+ ...(supersedesList.length ? { supersedes: supersedesList } : {}),
5610
+ ...(relatedToList.length ? { related_to: relatedToList } : {}),
5251
5611
  });
5252
5612
  console.log(`${green('✓')} Saved ${kind} — id: ${entry.id}`);
5613
+ if (supersedesList.length) {
5614
+ console.log(dim(` supersedes: ${supersedesList.join(', ')}`));
5615
+ console.log(dim(` (reciprocal superseded_by written to ${supersedesList.length} entr${supersedesList.length === 1 ? 'y' : 'ies'})`));
5616
+ }
5617
+ if (relatedToList.length) {
5618
+ console.log(dim(` related_to: ${relatedToList.join(', ')}`));
5619
+ }
5253
5620
  } catch (e) {
5254
5621
  console.error(`${red('x')} Failed to save: ${e.message}`);
5255
5622
  process.exit(1);
@@ -5387,6 +5754,8 @@ ${bold('Options:')}
5387
5754
  created_at: r.created_at,
5388
5755
  updated_at: r.updated_at,
5389
5756
  body: showFull ? r.body : r.body?.slice(0, 200) || '',
5757
+ summary_condensed: r.summary_condensed || null,
5758
+ summary_keypoint: r.summary_keypoint || null,
5390
5759
  }));
5391
5760
  console.log(JSON.stringify(output, null, 2));
5392
5761
  } else if (format === 'table') {
@@ -6555,6 +6924,104 @@ async function runDoctor() {
6555
6924
  console.log(` ${dim('Fix: run context-vault setup to download the model')}`);
6556
6925
  }
6557
6926
 
6927
+ // ── Save/search roundtrip ────────────────────────────────────────────
6928
+ // Canary save→search check against the LIVE vault. Detects within seconds
6929
+ // when captureAndIndex returns success but data didn't land in the DB or
6930
+ // isn't findable via search (the 2026-04-19 silent-drift incident class).
6931
+ // kind='event' so the canary is never embedded — keeps the test stable
6932
+ // when embeddings are degraded.
6933
+ if (db) {
6934
+ let canaryEntry = null;
6935
+ let canaryStmts = null;
6936
+ let dbMod = null;
6937
+ try {
6938
+ dbMod = await import('@context-vault/core/db');
6939
+ const { embed } = await import('@context-vault/core/embed');
6940
+ const { captureAndIndex } = await import('@context-vault/core/capture');
6941
+ const { hybridSearch } = await import('@context-vault/core/search');
6942
+
6943
+ canaryStmts = dbMod.prepareStatements(db);
6944
+
6945
+ // Single alphanumeric token: stays one FTS term (no hyphen/underscore
6946
+ // splitting), uniquely identifies the canary in tag-search fallback,
6947
+ // and avoids collisions with anything else in the live vault.
6948
+ const canaryRand =
6949
+ Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
6950
+ const canaryToken = `dcanary${canaryRand}`;
6951
+
6952
+ const ctx = {
6953
+ db,
6954
+ config,
6955
+ stmts: canaryStmts,
6956
+ embed,
6957
+ insertVec: (rowid, embedding) => dbMod.insertVec(canaryStmts, rowid, embedding),
6958
+ deleteVec: (rowid) => dbMod.deleteVec(canaryStmts, rowid),
6959
+ insertCtxVec: () => {},
6960
+ deleteCtxVec: () => {},
6961
+ };
6962
+
6963
+ canaryEntry = await captureAndIndex(ctx, {
6964
+ kind: 'event',
6965
+ title: 'doctor-canary',
6966
+ body: canaryToken,
6967
+ tags: [canaryToken, 'doctor-canary'],
6968
+ source: 'doctor',
6969
+ });
6970
+
6971
+ // Verify the persisted DB row matches what we saved before searching —
6972
+ // catches the silent-wedge case where captureAndIndex returns success
6973
+ // but the row never landed.
6974
+ const persisted = canaryStmts.getEntryById.get(canaryEntry.id);
6975
+ if (!persisted) {
6976
+ throw new Error('canary save returned success but row missing from DB');
6977
+ }
6978
+ if (persisted.body !== canaryToken) {
6979
+ throw new Error('canary persisted with wrong body');
6980
+ }
6981
+
6982
+ // kindFilter='event' scopes the search to events only. Events are not
6983
+ // embedded, so the vec lane is bypassed and the canary doesn't have to
6984
+ // out-rank thousands of recalled knowledge entries to make the page.
6985
+ const results = await hybridSearch(ctx, canaryToken, {
6986
+ kindFilter: 'event',
6987
+ limit: 10,
6988
+ });
6989
+ const found = results.find((r) => r.id === canaryEntry.id);
6990
+ if (!found) {
6991
+ throw new Error('canary saved but not returned by search');
6992
+ }
6993
+ if (found.body !== canaryToken) {
6994
+ throw new Error('canary found but body mismatch');
6995
+ }
6996
+
6997
+ console.log(` ${green('✓')} Save/search roundtrip`);
6998
+ } catch (e) {
6999
+ console.log(` ${red('✘')} Save/search roundtrip: ${e.message}`);
7000
+ allOk = false;
7001
+ } finally {
7002
+ if (canaryEntry) {
7003
+ try {
7004
+ if (existsSync(canaryEntry.filePath)) {
7005
+ unlinkSync(canaryEntry.filePath);
7006
+ }
7007
+ } catch {}
7008
+ if (canaryStmts && dbMod) {
7009
+ try {
7010
+ const rowidRow = canaryStmts.getRowid.get(canaryEntry.id);
7011
+ if (rowidRow?.rowid) {
7012
+ try {
7013
+ dbMod.deleteVec(canaryStmts, Number(rowidRow.rowid));
7014
+ } catch {}
7015
+ }
7016
+ } catch {}
7017
+ try {
7018
+ canaryStmts.deleteEntry.run(canaryEntry.id);
7019
+ } catch {}
7020
+ }
7021
+ }
7022
+ }
7023
+ }
7024
+
6558
7025
  // ── DB/filesystem consistency ─────────────────────────────────────────
6559
7026
  if (db && existsSync(config.vaultDir)) {
6560
7027
  try {
@@ -7400,11 +7867,46 @@ async function runStats() {
7400
7867
  await runStatsRecall({ resolveConfig, initDatabase, gatherRecallSummary });
7401
7868
  } else if (sub === 'co-retrieval') {
7402
7869
  await runStatsCoRetrieval({ resolveConfig, initDatabase, gatherCoRetrievalSummary });
7870
+ } else if (sub === 'counts') {
7871
+ await runStatsCounts({ resolveConfig, initDatabase });
7403
7872
  } else {
7404
7873
  console.error(red(` Unknown stats subcommand: ${sub}`));
7405
- console.error(` Available: recall, co-retrieval`);
7874
+ console.error(` Available: recall, co-retrieval, counts`);
7875
+ process.exit(1);
7876
+ }
7877
+ }
7878
+
7879
+ async function runStatsCounts({ resolveConfig, initDatabase }) {
7880
+ const format = getFlag('--format') || 'plain';
7881
+ const config = resolveConfig();
7882
+ if (!config.vaultDirExists) {
7883
+ console.error(red('No vault found. Run: context-vault setup'));
7406
7884
  process.exit(1);
7407
7885
  }
7886
+ const db = await initDatabase(config.dbPath);
7887
+ try {
7888
+ const live = `superseded_by IS NULL AND (expires_at IS NULL OR expires_at > datetime('now'))`;
7889
+ const total = db.prepare(`SELECT COUNT(*) AS c FROM vault WHERE ${live}`).get()?.c ?? 0;
7890
+ const byKind = db
7891
+ .prepare(`SELECT kind AS name, COUNT(*) AS count FROM vault WHERE ${live} GROUP BY kind ORDER BY count DESC`)
7892
+ .all();
7893
+ const byCategory = db
7894
+ .prepare(`SELECT category AS name, COUNT(*) AS count FROM vault WHERE ${live} GROUP BY category ORDER BY count DESC`)
7895
+ .all();
7896
+
7897
+ if (format === 'json') {
7898
+ console.log(JSON.stringify({ total_entries: total, by_kind: byKind, by_category: byCategory }, null, 2));
7899
+ return;
7900
+ }
7901
+
7902
+ console.log(`\n ${bold('Vault counts')} ${dim(`total ${total}`)}\n`);
7903
+ console.log(` ${bold('By category')}`);
7904
+ for (const c of byCategory) console.log(` ${cyan((c.name || 'unknown').padEnd(12))} ${c.count}`);
7905
+ console.log(`\n ${bold('By kind')}`);
7906
+ for (const k of byKind) console.log(` ${cyan((k.name || 'unknown').padEnd(16))} ${k.count}`);
7907
+ } finally {
7908
+ db.close();
7909
+ }
7408
7910
  }
7409
7911
 
7410
7912
  async function runStatsRecall({ resolveConfig, initDatabase, gatherRecallSummary }) {
@@ -8810,6 +9312,155 @@ ${bold('Examples:')}
8810
9312
  }
8811
9313
  }
8812
9314
 
9315
+ async function runSummarize() {
9316
+ if (flags.has('--help')) {
9317
+ console.log(`
9318
+ ${bold('context-vault summarize')} [options]
9319
+
9320
+ Backfill precomputed summary tiers for vault entries.
9321
+
9322
+ ${bold('Usage:')}
9323
+ context-vault summarize # backfill entries missing summaries
9324
+ context-vault summarize --force # regenerate all summaries
9325
+ context-vault summarize --dry-run # show count of entries needing summaries
9326
+
9327
+ ${bold('Options:')}
9328
+ --dry-run Show count without processing
9329
+ --force Regenerate summaries for all entries (not just missing)
9330
+ `);
9331
+ return;
9332
+ }
9333
+
9334
+ const dryRun = flags.has('--dry-run');
9335
+ const force = flags.has('--force');
9336
+
9337
+ const { resolveConfig } = await import('@context-vault/core/config');
9338
+ const { initDatabase } = await import('@context-vault/core/db');
9339
+ const { generateSummaryTiers } = await import('@context-vault/core/summarize');
9340
+
9341
+ const config = resolveConfig();
9342
+ if (!config.vaultDirExists) {
9343
+ console.error(red('No vault found. Run: context-vault setup'));
9344
+ process.exit(1);
9345
+ }
9346
+
9347
+ let db;
9348
+ try {
9349
+ db = await initDatabase(config.dbPath);
9350
+ } catch (e) {
9351
+ console.error(red(`Database error: ${e.message}`));
9352
+ process.exit(1);
9353
+ }
9354
+
9355
+ try {
9356
+ const whereClause = force
9357
+ ? 'WHERE superseded_by IS NULL'
9358
+ : 'WHERE summary_condensed IS NULL AND superseded_by IS NULL';
9359
+ const rows = db.prepare(`SELECT id, body FROM vault ${whereClause}`).all();
9360
+
9361
+ if (dryRun) {
9362
+ console.log(`${rows.length} entries need summary generation${force ? ' (force mode)' : ''}`);
9363
+ db.close();
9364
+ return;
9365
+ }
9366
+
9367
+ if (rows.length === 0) {
9368
+ console.log(green('All entries already have summaries.'));
9369
+ db.close();
9370
+ return;
9371
+ }
9372
+
9373
+ const updateStmt = db.prepare(
9374
+ 'UPDATE vault SET summary_condensed = ?, summary_keypoint = ? WHERE id = ?'
9375
+ );
9376
+ const BATCH = 50;
9377
+ let processed = 0;
9378
+
9379
+ for (let i = 0; i < rows.length; i += BATCH) {
9380
+ const batch = rows.slice(i, i + BATCH);
9381
+ db.exec('BEGIN');
9382
+ try {
9383
+ for (const row of batch) {
9384
+ const { condensed, keypoint } = generateSummaryTiers(row.body || '');
9385
+ updateStmt.run(condensed || null, keypoint || null, row.id);
9386
+ processed++;
9387
+ }
9388
+ db.exec('COMMIT');
9389
+ } catch (e) {
9390
+ db.exec('ROLLBACK');
9391
+ throw e;
9392
+ }
9393
+ process.stderr.write(`\r Processing: ${processed}/${rows.length}`);
9394
+ }
9395
+
9396
+ process.stderr.write('\n');
9397
+ console.log(green(`Done. Generated summaries for ${processed} entries.`));
9398
+ } catch (e) {
9399
+ console.error(red(`Summarize failed: ${e.message}`));
9400
+ process.exit(1);
9401
+ } finally {
9402
+ try { db?.close(); } catch {}
9403
+ }
9404
+ }
9405
+
9406
+
9407
+ async function runAssemble() {
9408
+ if (flags.has("--help")) {
9409
+ console.log(`\n ${bold("context-vault assemble")} [options]\n\n Assemble a role-aware, task-specific context payload.\n\n${bold("Usage:")}\n context-vault assemble --role <role> --task <spec-path-or-text> --budget <tokens>\n\n${bold("Options:")}\n --role <role> Agent role (worker, pm, ceo, steer)\n --task <text> Task description or spec path\n --budget <n> Max tokens for the payload (default: 40000)\n --format <fmt> Output format: markdown (default), json\n --dry-run Show assembly plan but do not output full context\n`);
9410
+ return;
9411
+ }
9412
+
9413
+ const role = getFlag("--role") || "worker";
9414
+ const task = getFlag("--task");
9415
+ const format = getFlag("--format") || "markdown";
9416
+ const { resolveConfig } = await import("@context-vault/core/config");
9417
+ const { initDatabase } = await import("@context-vault/core/db");
9418
+ const { assembleContext } = await import("@context-vault/core");
9419
+
9420
+ const budget = parseInt(getFlag("--budget") || "40000", 10);
9421
+ const dryRun = flags.has("--dry-run");
9422
+
9423
+ if (!task) {
9424
+ console.error(`\n ${red("Usage:")} context-vault assemble --role <role> --task <task>\n`);
9425
+ process.exit(1);
9426
+ }
9427
+
9428
+ const config = resolveConfig();
9429
+ if (!config.vaultDirExists) {
9430
+ console.error(red("No vault found."));
9431
+ process.exit(1);
9432
+ }
9433
+ const db = await initDatabase(config.dbPath);
9434
+ const result = await assembleContext(db, config, { role: role, task, budget });
9435
+ if (format === "json") {
9436
+ console.log(JSON.stringify(result, null, 2));
9437
+ } else {
9438
+ if (dryRun) {
9439
+ const lines = [
9440
+ `Assembly Plan:`,
9441
+ `- Role: ${result.metadata.role}`,
9442
+ `- Budget: ${result.metadata.budget} tokens`,
9443
+ `- Used: ${result.metadata.tokens_used} tokens`,
9444
+ `- Entries Included: ${result.metadata.entries_included}`,
9445
+ ];
9446
+ const included = result.metadata.included_entries || [];
9447
+ if (included.length > 0) {
9448
+ lines.push(`\nIncluded Entries:`);
9449
+ for (const e of included) {
9450
+ lines.push(` [${e.status}] ${e.title}`);
9451
+ }
9452
+ } else {
9453
+ lines.push(`\nIncluded Entries: (none)`);
9454
+ }
9455
+ console.log(lines.join('\n'));
9456
+ } else {
9457
+ console.log(result.markdown);
9458
+ }
9459
+ }
9460
+ try { db.close(); } catch {}
9461
+
9462
+ }
9463
+
8813
9464
  async function runStale() {
8814
9465
  if (flags.has('--help')) {
8815
9466
  console.log(`
@@ -8929,7 +9580,7 @@ async function main() {
8929
9580
 
8930
9581
  if (flags.has('--help') || command === 'help') {
8931
9582
  // Commands with their own --help handling: delegate to them
8932
- const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'team', 'remote', 'ingest-comms', 'gmail-bridge', 'slack-bridge', 'contacts']);
9583
+ const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'team', 'remote', 'ingest-comms', 'gmail-bridge', 'slack-bridge', 'contacts', 'delete', 'get', 'buckets', 'dupes']);
8933
9584
  if (!command || command === 'help' || !commandsWithHelp.has(command)) {
8934
9585
  showHelp(flags.has('--all'));
8935
9586
  return;
@@ -9042,6 +9693,18 @@ async function main() {
9042
9693
  case 'prune':
9043
9694
  await runPrune();
9044
9695
  break;
9696
+ case 'delete':
9697
+ await runDelete();
9698
+ break;
9699
+ case 'get':
9700
+ await runGet();
9701
+ break;
9702
+ case 'buckets':
9703
+ await runBuckets();
9704
+ break;
9705
+ case 'dupes':
9706
+ await runDupes();
9707
+ break;
9045
9708
  case 'status':
9046
9709
  await runStatus();
9047
9710
  break;
@@ -9091,9 +9754,15 @@ async function main() {
9091
9754
  case 'compact':
9092
9755
  await runCompact();
9093
9756
  break;
9757
+ case 'assemble':
9758
+ await runAssemble();
9759
+ break;
9094
9760
  case 'stale':
9095
9761
  await runStale();
9096
9762
  break;
9763
+ case 'summarize':
9764
+ await runSummarize();
9765
+ break;
9097
9766
  default:
9098
9767
  console.error(red(`Unknown command: ${command}`));
9099
9768
  console.error(`Run ${cyan('context-vault --help')} for usage.`);