context-vault 3.7.0 → 3.9.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 (73) hide show
  1. package/assets/agent-rules.md +28 -1
  2. package/assets/setup-prompt.md +16 -1
  3. package/bin/cli.js +1003 -7
  4. package/dist/auto-memory.d.ts +52 -0
  5. package/dist/auto-memory.d.ts.map +1 -0
  6. package/dist/auto-memory.js +142 -0
  7. package/dist/auto-memory.js.map +1 -0
  8. package/dist/register-tools.d.ts.map +1 -1
  9. package/dist/register-tools.js +2 -0
  10. package/dist/register-tools.js.map +1 -1
  11. package/dist/remote.d.ts +134 -0
  12. package/dist/remote.d.ts.map +1 -0
  13. package/dist/remote.js +242 -0
  14. package/dist/remote.js.map +1 -0
  15. package/dist/remote.test.d.ts +2 -0
  16. package/dist/remote.test.d.ts.map +1 -0
  17. package/dist/remote.test.js +107 -0
  18. package/dist/remote.test.js.map +1 -0
  19. package/dist/stats/recall.d.ts +33 -0
  20. package/dist/stats/recall.d.ts.map +1 -0
  21. package/dist/stats/recall.js +86 -0
  22. package/dist/stats/recall.js.map +1 -0
  23. package/dist/tools/context-status.d.ts.map +1 -1
  24. package/dist/tools/context-status.js +40 -0
  25. package/dist/tools/context-status.js.map +1 -1
  26. package/dist/tools/get-context.d.ts.map +1 -1
  27. package/dist/tools/get-context.js +44 -0
  28. package/dist/tools/get-context.js.map +1 -1
  29. package/dist/tools/publish-to-team.d.ts +11 -0
  30. package/dist/tools/publish-to-team.d.ts.map +1 -0
  31. package/dist/tools/publish-to-team.js +91 -0
  32. package/dist/tools/publish-to-team.js.map +1 -0
  33. package/dist/tools/publish-to-team.test.d.ts +2 -0
  34. package/dist/tools/publish-to-team.test.d.ts.map +1 -0
  35. package/dist/tools/publish-to-team.test.js +95 -0
  36. package/dist/tools/publish-to-team.test.js.map +1 -0
  37. package/dist/tools/recall.d.ts +1 -1
  38. package/dist/tools/recall.d.ts.map +1 -1
  39. package/dist/tools/recall.js +85 -1
  40. package/dist/tools/recall.js.map +1 -1
  41. package/dist/tools/save-context.d.ts +5 -1
  42. package/dist/tools/save-context.d.ts.map +1 -1
  43. package/dist/tools/save-context.js +163 -2
  44. package/dist/tools/save-context.js.map +1 -1
  45. package/dist/tools/session-start.d.ts.map +1 -1
  46. package/dist/tools/session-start.js +90 -86
  47. package/dist/tools/session-start.js.map +1 -1
  48. package/node_modules/@context-vault/core/dist/config.d.ts +3 -1
  49. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  50. package/node_modules/@context-vault/core/dist/config.js +48 -2
  51. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  52. package/node_modules/@context-vault/core/dist/main.d.ts +1 -1
  53. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  54. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  55. package/node_modules/@context-vault/core/dist/types.d.ts +7 -0
  56. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  57. package/node_modules/@context-vault/core/package.json +1 -1
  58. package/node_modules/@context-vault/core/src/config.ts +50 -3
  59. package/node_modules/@context-vault/core/src/main.ts +1 -0
  60. package/node_modules/@context-vault/core/src/types.ts +8 -0
  61. package/package.json +2 -2
  62. package/src/auto-memory.ts +169 -0
  63. package/src/register-tools.ts +2 -0
  64. package/src/remote.test.ts +123 -0
  65. package/src/remote.ts +325 -0
  66. package/src/stats/recall.ts +139 -0
  67. package/src/tools/context-status.ts +40 -0
  68. package/src/tools/get-context.ts +44 -0
  69. package/src/tools/publish-to-team.test.ts +115 -0
  70. package/src/tools/publish-to-team.ts +112 -0
  71. package/src/tools/recall.ts +79 -1
  72. package/src/tools/save-context.ts +167 -1
  73. package/src/tools/session-start.ts +88 -100
package/bin/cli.js CHANGED
@@ -301,12 +301,24 @@ const TOOLS = [
301
301
  {
302
302
  id: 'antigravity',
303
303
  name: 'Antigravity (Gemini CLI)',
304
- detect: () => anyDirExists(join(HOME, '.gemini', 'antigravity'), join(HOME, '.gemini')),
304
+ detect: async () =>
305
+ anyDirExists(join(HOME, '.gemini', 'antigravity'), join(HOME, '.gemini')) ||
306
+ (await commandExistsAsync('gemini')),
305
307
  configType: 'json',
306
308
  configPath: join(HOME, '.gemini', 'antigravity', 'mcp_config.json'),
307
309
  configKey: 'mcpServers',
308
- rulesPath: null,
309
- rulesMethod: null,
310
+ rulesPath: join(HOME, '.gemini', 'antigravity', 'rules', 'context-vault.md'),
311
+ rulesMethod: 'write',
312
+ },
313
+ {
314
+ id: 'google-ai',
315
+ name: 'Google AI / Gemini CLI',
316
+ detect: () => existsSync(join(HOME, '.gemini', 'mcp_config.json')),
317
+ configType: 'json',
318
+ configPath: join(HOME, '.gemini', 'mcp_config.json'),
319
+ configKey: 'mcpServers',
320
+ rulesPath: join(HOME, '.gemini', 'rules', 'context-vault.md'),
321
+ rulesMethod: 'write',
310
322
  },
311
323
  {
312
324
  id: 'cline',
@@ -401,6 +413,9 @@ ${bold('Commands:')}
401
413
  ${cyan('archive')} Archive old ephemeral/event entries (use --dry-run to preview)
402
414
  ${cyan('restore')} <id> Restore an archived entry back into the vault
403
415
  ${cyan('prune')} Remove expired entries (use --dry-run to preview)
416
+ ${cyan('stats')} recall|co-retrieval Measure recall ratio and co-retrieval graph
417
+ ${cyan('remote')} setup|status|sync|pull Connect to hosted vault (cloud sync)
418
+ ${cyan('team')} join|leave|status|browse Join or manage a team vault
404
419
  ${cyan('update')} Check for and install updates
405
420
  ${cyan('uninstall')} Remove MCP configs and optionally data
406
421
  `);
@@ -757,6 +772,7 @@ async function runSetup() {
757
772
  if (userLevel === 'beginner') {
758
773
  console.log(' Install an AI tool first:');
759
774
  console.log(dim(' Claude Code: https://docs.anthropic.com/en/docs/claude-code'));
775
+ console.log(dim(' Gemini CLI: https://github.com/google-gemini/gemini-cli'));
760
776
  console.log(dim(' Cursor: https://cursor.com'));
761
777
  console.log(dim(' Windsurf: https://codeium.com/windsurf'));
762
778
  console.log();
@@ -2769,8 +2785,8 @@ async function runStatus() {
2769
2785
  const filled = maxCount > 0 ? Math.round((c / maxCount) * BAR_WIDTH) : 0;
2770
2786
  const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
2771
2787
  const countStr = String(c).padStart(4);
2772
- const IRREGULAR_PLURALS = { activity: 'activities', inbox: 'inboxes', index: 'indexes', match: 'matches' };
2773
- const plural = IRREGULAR_PLURALS[kind] || (kind.endsWith('s') ? kind : kind + 's');
2788
+ const IRREGULAR_PLURALS = { activity: 'activities', inbox: 'inboxes', index: 'indexes', match: 'matches', entity: 'entities', summary: 'summaries' };
2789
+ const plural = IRREGULAR_PLURALS[kind] || (kind.endsWith('y') ? kind.slice(0, -1) + 'ies' : kind.endsWith('s') || kind.endsWith('x') || kind.endsWith('ch') || kind.endsWith('sh') ? kind + 'es' : kind + 's');
2774
2790
  console.log(` ${dim(bar)} ${countStr} ${plural}`);
2775
2791
  }
2776
2792
  } else {
@@ -4376,6 +4392,35 @@ async function runPostToolCall() {
4376
4392
  }
4377
4393
 
4378
4394
  async function runSave() {
4395
+ if (flags.has('--help')) {
4396
+ console.log(`
4397
+ ${bold('context-vault save')}
4398
+
4399
+ Save an entry to the vault from CLI.
4400
+
4401
+ ${bold('Required:')}
4402
+ --kind <kind> Entry kind (insight, decision, pattern, reference, event)
4403
+ --title <title> Entry title
4404
+
4405
+ ${bold('Content')} (one of):
4406
+ --body <text> Inline body text
4407
+ --file <path> Read body from a file
4408
+ (stdin) Pipe content via stdin
4409
+
4410
+ ${bold('Optional:')}
4411
+ --tags <a,b,c> Comma-separated tags
4412
+ --tier <tier> working (default) or durable
4413
+ --source <source> Source label (default: cli)
4414
+ --identity-key <key> Identity key (for entity kinds)
4415
+ --meta <json> Additional metadata as JSON
4416
+
4417
+ ${bold('Examples:')}
4418
+ context-vault save --kind insight --title "Express 5 gotcha" --body "body parser changed"
4419
+ echo "content" | context-vault save --kind reference --title "API notes"
4420
+ `);
4421
+ return;
4422
+ }
4423
+
4379
4424
  const kind = getFlag('--kind');
4380
4425
  const title = getFlag('--title');
4381
4426
  const tags = getFlag('--tags');
@@ -4473,6 +4518,30 @@ async function runSave() {
4473
4518
  }
4474
4519
 
4475
4520
  async function runSearch() {
4521
+ if (flags.has('--help')) {
4522
+ console.log(`
4523
+ ${bold('context-vault search')} <query> [options]
4524
+
4525
+ Search vault entries from CLI using hybrid semantic + full-text search.
4526
+
4527
+ ${bold('Usage:')}
4528
+ context-vault search "express middleware"
4529
+ context-vault search --kind insight --tags "bucket:myproject"
4530
+ context-vault search "auth" --limit 20 --format json
4531
+
4532
+ ${bold('Options:')}
4533
+ --kind <kind> Filter by kind (insight, decision, pattern, reference, event)
4534
+ --tags <a,b,c> Filter by tags (comma-separated)
4535
+ --limit <n> Max results (default: 10)
4536
+ --sort <mode> Sort by: relevance (default), recent
4537
+ --format <fmt> Output format: plain (default), json
4538
+ --scope <scope> Search scope: hot (default), events, all
4539
+ --full Show full entry body (not truncated)
4540
+ --query <text> Explicit query (alternative to positional args)
4541
+ `);
4542
+ return;
4543
+ }
4544
+
4476
4545
  const kind = getFlag('--kind');
4477
4546
  const tagsStr = getFlag('--tags');
4478
4547
  const limit = parseInt(getFlag('--limit') || '10', 10);
@@ -6915,10 +6984,923 @@ ${progArgs.map(a => ` <string>${a}</string>`).join('\n')}
6915
6984
  }
6916
6985
  }
6917
6986
 
6987
+ async function runStats() {
6988
+ const { resolveConfig } = await import('@context-vault/core/config');
6989
+ const { initDatabase } = await import('@context-vault/core/db');
6990
+ const { gatherRecallSummary, gatherCoRetrievalSummary } = await import('../dist/stats/recall.js');
6991
+
6992
+ const sub = args[1];
6993
+ if (!sub || sub === 'recall') {
6994
+ await runStatsRecall({ resolveConfig, initDatabase, gatherRecallSummary });
6995
+ } else if (sub === 'co-retrieval') {
6996
+ await runStatsCoRetrieval({ resolveConfig, initDatabase, gatherCoRetrievalSummary });
6997
+ } else {
6998
+ console.error(red(` Unknown stats subcommand: ${sub}`));
6999
+ console.error(` Available: recall, co-retrieval`);
7000
+ process.exit(1);
7001
+ }
7002
+ }
7003
+
7004
+ async function runStatsRecall({ resolveConfig, initDatabase, gatherRecallSummary }) {
7005
+ const config = resolveConfig();
7006
+ let db;
7007
+ try {
7008
+ db = await initDatabase(config.dbPath);
7009
+ } catch (e) {
7010
+ console.error(red(` Database not accessible: ${e.message}`));
7011
+ process.exit(1);
7012
+ }
7013
+
7014
+ let s;
7015
+ try {
7016
+ s = gatherRecallSummary({ db, config });
7017
+ } finally {
7018
+ db.close();
7019
+ }
7020
+
7021
+ const ratioPct = Math.round(s.ratio * 100);
7022
+ const targetPct = Math.round(s.target * 100);
7023
+ const statusIcon = s.ratio >= s.target ? green('✓') : yellow('·');
7024
+ console.log();
7025
+ console.log(` ${bold('◇ context-vault stats recall')}`);
7026
+ console.log();
7027
+ console.log(` ${statusIcon} Recall ratio: ${bold(s.ratio.toFixed(2))} (target: ${s.target.toFixed(2)})`);
7028
+ console.log(` Total entries: ${s.total_entries}`);
7029
+ console.log(` Recalled (1+): ${s.recalled_entries} (${ratioPct}%)`);
7030
+ console.log(` Never recalled: ${s.never_recalled} (${100 - ratioPct}%)`);
7031
+ console.log(` Avg recall count: ${s.avg_recall_count} (among recalled entries)`);
7032
+
7033
+ if (s.top_recalled.length) {
7034
+ console.log();
7035
+ console.log(` ${bold('Top recalled:')}`);
7036
+ for (let i = 0; i < s.top_recalled.length; i++) {
7037
+ const e = s.top_recalled[i];
7038
+ const title = (e.title || '(untitled)').slice(0, 50);
7039
+ console.log(` ${i + 1}. "${title}" (recall: ${e.recall_count}, sessions: ${e.recall_sessions})`);
7040
+ }
7041
+ }
7042
+
7043
+ if (s.dead_entry_count > 0) {
7044
+ console.log();
7045
+ console.log(` ${bold('Dead entries')} ${dim('(saved >30 days ago, never recalled):')}`);
7046
+ console.log(` - ${s.dead_entry_count} entries across ${s.dead_bucket_count} buckets`);
7047
+ if (s.top_dead_buckets.length) {
7048
+ const bucketStr = s.top_dead_buckets.map((b) => `${b.bucket} (${b.count})`).join(', ');
7049
+ console.log(` - Top dead buckets: ${bucketStr}`);
7050
+ }
7051
+ }
7052
+
7053
+ console.log();
7054
+ }
7055
+
7056
+ async function runStatsCoRetrieval({ resolveConfig, initDatabase, gatherCoRetrievalSummary }) {
7057
+ const config = resolveConfig();
7058
+ let db;
7059
+ try {
7060
+ db = await initDatabase(config.dbPath);
7061
+ } catch (e) {
7062
+ console.error(red(` Database not accessible: ${e.message}`));
7063
+ process.exit(1);
7064
+ }
7065
+
7066
+ let s;
7067
+ try {
7068
+ s = gatherCoRetrievalSummary({ db, config });
7069
+ } finally {
7070
+ db.close();
7071
+ }
7072
+
7073
+ console.log();
7074
+ console.log(` ${bold('◇ context-vault stats co-retrieval')}`);
7075
+ console.log();
7076
+ console.log(` Co-retrieval pairs: ${bold(String(s.total_pairs))}`);
7077
+
7078
+ if (s.top_pairs.length) {
7079
+ console.log();
7080
+ console.log(` ${bold('Strongest pairs:')}`);
7081
+ for (let i = 0; i < s.top_pairs.length; i++) {
7082
+ const p = s.top_pairs[i];
7083
+ const titleA = (p.title_a || '(untitled)').slice(0, 40);
7084
+ const titleB = (p.title_b || '(untitled)').slice(0, 40);
7085
+ console.log(` ${i + 1}. "${titleA}" <-> "${titleB}" (weight: ${p.weight})`);
7086
+ }
7087
+ }
7088
+
7089
+ console.log();
7090
+ console.log(` Graph density: ${s.graph_density.toFixed(4)} ${dim('(sparse, expected for early usage)')}`);
7091
+ console.log();
7092
+ }
7093
+
6918
7094
  async function runServe() {
6919
7095
  await import('../dist/server.js');
6920
7096
  }
6921
7097
 
7098
+ async function runTeam() {
7099
+ const subcommand = args[1];
7100
+ const { getRemoteConfig, saveRemoteConfig } = await import('@context-vault/core/config');
7101
+ const dataDir = join(HOME, '.context-mcp');
7102
+
7103
+ if (subcommand === 'join') {
7104
+ const teamId = args[2];
7105
+ if (!teamId) {
7106
+ console.error(`\n ${red('Usage:')} context-vault team join <team-id>\n`);
7107
+ process.exit(1);
7108
+ }
7109
+
7110
+ const remote = getRemoteConfig(dataDir);
7111
+ if (!remote || !remote.enabled || !remote.apiKey) {
7112
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7113
+ process.exit(1);
7114
+ }
7115
+
7116
+ console.log(`\n Testing connection to team ${dim(teamId)}...`);
7117
+ try {
7118
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/team/${teamId}/status`, {
7119
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7120
+ signal: AbortSignal.timeout(10000),
7121
+ });
7122
+ if (res.ok) {
7123
+ const data = await res.json();
7124
+ saveRemoteConfig({ teamId }, dataDir);
7125
+ console.log(` ${green('✓')} Joined team: ${bold(data.name || teamId)}`);
7126
+ if (data.entry_count != null) {
7127
+ console.log(` ${dim(`${data.entry_count} entries, ${data.member_count || '?'} members`)}`);
7128
+ }
7129
+ } else {
7130
+ const text = await res.text().catch(() => '');
7131
+ console.error(` ${red('✘')} Failed to connect to team: HTTP ${res.status}`);
7132
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7133
+ process.exit(1);
7134
+ }
7135
+ } catch (e) {
7136
+ console.error(` ${red('✘')} Connection failed: ${e.message}`);
7137
+ process.exit(1);
7138
+ }
7139
+ console.log();
7140
+ return;
7141
+ }
7142
+
7143
+ if (subcommand === 'leave') {
7144
+ const remote = getRemoteConfig(dataDir);
7145
+ if (!remote?.teamId) {
7146
+ console.log(`\n ${dim('Not currently in a team.')}\n`);
7147
+ return;
7148
+ }
7149
+ saveRemoteConfig({ teamId: '' }, dataDir);
7150
+ console.log(`\n ${green('✓')} Left team. Team vault queries disabled.\n`);
7151
+ return;
7152
+ }
7153
+
7154
+ if (subcommand === 'status') {
7155
+ const remote = getRemoteConfig(dataDir);
7156
+ console.log();
7157
+ console.log(` ${bold('◇ Team Status')}`);
7158
+ console.log();
7159
+
7160
+ if (!remote || !remote.teamId) {
7161
+ console.log(` Team: ${dim('not joined')}`);
7162
+ console.log(` ${dim('Run')} ${cyan('context-vault team join <team-id>')} ${dim('to connect.')}`);
7163
+ console.log();
7164
+ return;
7165
+ }
7166
+
7167
+ console.log(` Team ID: ${remote.teamId}`);
7168
+
7169
+ if (remote.enabled && remote.apiKey) {
7170
+ console.log(` Checking status...`);
7171
+ try {
7172
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/team/${remote.teamId}/status`, {
7173
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7174
+ signal: AbortSignal.timeout(10000),
7175
+ });
7176
+ if (res.ok) {
7177
+ const data = await res.json();
7178
+ console.log(` Name: ${data.name || dim('(unnamed)')}`);
7179
+ console.log(` Members: ${data.member_count ?? dim('?')}`);
7180
+ console.log(` Entries: ${data.entry_count ?? dim('?')}`);
7181
+ console.log(` Health: ${green('connected')}`);
7182
+ } else {
7183
+ console.log(` Health: ${red('error')} (HTTP ${res.status})`);
7184
+ }
7185
+ } catch (e) {
7186
+ console.log(` Health: ${red('unreachable')} (${e.message})`);
7187
+ }
7188
+ } else {
7189
+ console.log(` Health: ${yellow('remote not configured')}`);
7190
+ }
7191
+ console.log();
7192
+ return;
7193
+ }
7194
+
7195
+ if (subcommand === 'seed') {
7196
+ const teamIdFlag = getFlag('--team');
7197
+ const tagsFlag = getFlag('--tags');
7198
+ const remote = getRemoteConfig(dataDir);
7199
+
7200
+ if (!remote || !remote.enabled || !remote.apiKey) {
7201
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7202
+ process.exit(1);
7203
+ }
7204
+
7205
+ const teamId = teamIdFlag || remote.teamId;
7206
+ if (!teamId) {
7207
+ console.error(`\n ${red('✘')} No team ID. Pass ${cyan('--team <id>')} or join a team first.\n`);
7208
+ process.exit(1);
7209
+ }
7210
+
7211
+ // Load local vault DB
7212
+ const { resolveConfig } = await import('@context-vault/core/config');
7213
+ const config = resolveConfig();
7214
+
7215
+ let DatabaseSync;
7216
+ try {
7217
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
7218
+ } catch {
7219
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
7220
+ process.exit(1);
7221
+ }
7222
+
7223
+ const db = new DatabaseSync(config.dbPath, { open: true });
7224
+
7225
+ // Build query based on tag filter
7226
+ let sql = 'SELECT id, kind, category, title, body, tags, meta, source, identity_key, tier FROM vault WHERE superseded_by IS NULL';
7227
+ const params = [];
7228
+
7229
+ if (tagsFlag) {
7230
+ sql += ' AND tags LIKE ?';
7231
+ params.push(`%"${tagsFlag}"%`);
7232
+ }
7233
+
7234
+ const rows = db.prepare(sql).all(...params);
7235
+ db.close();
7236
+
7237
+ let publishedKnowledge = 0;
7238
+ let federatedEntities = 0;
7239
+ let skippedEvents = 0;
7240
+ let blockedPrivacy = 0;
7241
+ const blockedEntries = [];
7242
+ let errors = 0;
7243
+
7244
+ const apiUrl = remote.url.replace(/\/$/, '');
7245
+
7246
+ console.log();
7247
+ console.log(` ${bold('◇ Team Seed')}`);
7248
+ console.log(` Team: ${teamId}`);
7249
+ console.log(` Filter: ${tagsFlag || dim('(all entries)')}`);
7250
+ console.log(` Entries: ${rows.length}`);
7251
+ console.log();
7252
+
7253
+ if (isDryRun) {
7254
+ for (const row of rows) {
7255
+ if (row.category === 'event') {
7256
+ skippedEvents++;
7257
+ } else if (row.category === 'entity') {
7258
+ federatedEntities++;
7259
+ } else {
7260
+ publishedKnowledge++;
7261
+ }
7262
+ }
7263
+ console.log(` ${dim('[dry run]')}`);
7264
+ console.log(` Would publish: ${green(publishedKnowledge)} knowledge, ${cyan(federatedEntities)} entities`);
7265
+ console.log(` Would skip: ${dim(skippedEvents)} events (private)`);
7266
+ console.log();
7267
+ return;
7268
+ }
7269
+
7270
+ for (const row of rows) {
7271
+ if (row.category === 'event') {
7272
+ skippedEvents++;
7273
+ continue;
7274
+ }
7275
+
7276
+ const tags = row.tags ? JSON.parse(row.tags) : [];
7277
+ const meta = row.meta ? JSON.parse(row.meta) : {};
7278
+
7279
+ try {
7280
+ const res = await fetch(`${apiUrl}/api/vault/publish`, {
7281
+ method: 'POST',
7282
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7283
+ signal: AbortSignal.timeout(15000),
7284
+ body: JSON.stringify({
7285
+ entryId: row.id,
7286
+ teamId,
7287
+ visibility: 'team',
7288
+ kind: row.kind,
7289
+ title: row.title,
7290
+ body: row.body,
7291
+ tags,
7292
+ meta,
7293
+ source: row.source,
7294
+ identity_key: row.identity_key,
7295
+ tier: row.tier,
7296
+ category: row.category,
7297
+ }),
7298
+ });
7299
+
7300
+ if (res.ok) {
7301
+ if (row.category === 'entity') {
7302
+ federatedEntities++;
7303
+ } else {
7304
+ publishedKnowledge++;
7305
+ }
7306
+ } else if (res.status === 422) {
7307
+ const data = await res.json().catch(() => ({}));
7308
+ if (data.code === 'PRIVACY_SCAN_FAILED') {
7309
+ const matchTypes = Array.isArray(data.matches) ? data.matches.map(m => m.type) : [];
7310
+ const uniqueTypes = [...new Set(matchTypes)];
7311
+ console.log(` ${yellow('!')} Blocked: "${row.title || row.id}" (${uniqueTypes.join(', ') || 'sensitive content'})`);
7312
+ blockedPrivacy++;
7313
+ blockedEntries.push({ title: row.title || row.id, types: uniqueTypes });
7314
+ } else {
7315
+ errors++;
7316
+ console.error(` ${red('!')} 422 for "${row.title || row.id}": ${data.error || 'unknown'}`);
7317
+ }
7318
+ } else {
7319
+ const data = await res.json().catch(() => ({}));
7320
+ if (data.conflict) {
7321
+ console.log(` ${yellow('!')} Conflict for "${row.title || row.id}": ${data.conflict.suggestion || 'similar entry exists'}`);
7322
+ }
7323
+ if (row.category === 'entity') {
7324
+ federatedEntities++;
7325
+ } else {
7326
+ publishedKnowledge++;
7327
+ }
7328
+ }
7329
+ } catch (e) {
7330
+ errors++;
7331
+ console.error(` ${red('✘')} Failed: ${row.title || row.id} (${e.message})`);
7332
+ }
7333
+
7334
+ // Progress indicator every 10 entries
7335
+ const processed = publishedKnowledge + federatedEntities + skippedEvents + blockedPrivacy + errors;
7336
+ if (processed % 10 === 0) {
7337
+ process.stdout.write(` ${dim(`${processed}/${rows.length}...`)}\r`);
7338
+ }
7339
+ }
7340
+
7341
+ console.log(` Seeded: ${green(publishedKnowledge)} published, ${cyan(federatedEntities)} federated, ${dim(skippedEvents)} skipped (events), ${blockedPrivacy > 0 ? yellow(blockedPrivacy) : dim(blockedPrivacy)} blocked (privacy scan)`);
7342
+ if (errors > 0) {
7343
+ console.log(` Errors: ${red(errors)}`);
7344
+ }
7345
+ if (blockedEntries.length > 0) {
7346
+ console.log();
7347
+ console.log(` ${yellow('Blocked entries (review and clean before re-seeding):')}`);
7348
+ for (const entry of blockedEntries) {
7349
+ console.log(` - ${entry.title} [${entry.types.join(', ')}]`);
7350
+ }
7351
+ }
7352
+ console.log();
7353
+ return;
7354
+ }
7355
+
7356
+ // Default: show team help
7357
+ console.log();
7358
+ console.log(` ${bold('◇ context-vault team')}`);
7359
+ console.log();
7360
+ console.log(` ${cyan('join <team-id>')} Join a team vault`);
7361
+ console.log(` ${cyan('leave')} Leave the current team`);
7362
+ console.log(` ${cyan('status')} Show team connection status`);
7363
+ console.log(` ${cyan('seed')} Publish local entries to team vault`);
7364
+ console.log(` ${dim('--team <id>')} Team ID (defaults to joined team)`);
7365
+ console.log(` ${dim('--tags <filter>')} Filter entries by tag (e.g. bucket:stormfors)`);
7366
+ console.log(` ${dim('--dry-run')} Preview without publishing`);
7367
+ console.log();
7368
+ }
7369
+
7370
+ async function runRemote() {
7371
+ const subcommand = args[1];
7372
+ const { getRemoteConfig, saveRemoteConfig } = await import('@context-vault/core/config');
7373
+ const dataDir = join(HOME, '.context-mcp');
7374
+
7375
+ if (subcommand === 'setup') {
7376
+ const readline = await import('node:readline');
7377
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7378
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
7379
+
7380
+ console.log();
7381
+ console.log(` ${bold('◇ Remote Vault Setup')}`);
7382
+ console.log();
7383
+
7384
+ const defaultUrl = 'https://api.context-vault.com';
7385
+ const urlInput = await ask(` API URL ${dim(`(${defaultUrl})`)}: `);
7386
+ const url = urlInput.trim() || defaultUrl;
7387
+
7388
+ const apiKey = await ask(' API Key: ');
7389
+ rl.close();
7390
+
7391
+ if (!apiKey.trim()) {
7392
+ console.error(`\n ${red('✘')} API key is required.`);
7393
+ process.exit(1);
7394
+ }
7395
+
7396
+ console.log(`\n Testing connection to ${dim(url)}...`);
7397
+ try {
7398
+ const res = await fetch(`${url.replace(/\/$/, '')}/api/vault/status`, {
7399
+ headers: { 'Authorization': `Bearer ${apiKey.trim()}`, 'Content-Type': 'application/json' },
7400
+ signal: AbortSignal.timeout(10000),
7401
+ });
7402
+ if (res.ok) {
7403
+ saveRemoteConfig({ enabled: true, url, apiKey: apiKey.trim() }, dataDir);
7404
+ console.log(` ${green('✓')} Connected successfully. Remote sync enabled.`);
7405
+ console.log(dim(` Config saved to ${join(dataDir, 'config.json')}`));
7406
+ } else {
7407
+ const text = await res.text().catch(() => '');
7408
+ console.error(` ${red('✘')} Connection failed: HTTP ${res.status}`);
7409
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7410
+ process.exit(1);
7411
+ }
7412
+ } catch (e) {
7413
+ console.error(` ${red('✘')} Connection failed: ${e.message}`);
7414
+ process.exit(1);
7415
+ }
7416
+ console.log();
7417
+ return;
7418
+ }
7419
+
7420
+ if (subcommand === 'status') {
7421
+ const remote = getRemoteConfig(dataDir);
7422
+ console.log();
7423
+ console.log(` ${bold('◇ Remote Status')}`);
7424
+ console.log();
7425
+ if (!remote) {
7426
+ console.log(` Remote: ${dim('not configured')}`);
7427
+ console.log(` ${dim('Run')} ${cyan('context-vault remote setup')} ${dim('to connect.')}`);
7428
+ } else {
7429
+ const keyPreview = remote.apiKey ? remote.apiKey.slice(0, 6) + '...' : dim('(none)');
7430
+ console.log(` Enabled: ${remote.enabled ? green('yes') : red('no')}`);
7431
+ console.log(` URL: ${remote.url}`);
7432
+ console.log(` API Key: ${keyPreview}`);
7433
+
7434
+ if (remote.enabled && remote.apiKey) {
7435
+ console.log(`\n Testing connection...`);
7436
+ try {
7437
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/vault/status`, {
7438
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7439
+ signal: AbortSignal.timeout(10000),
7440
+ });
7441
+ if (res.ok) {
7442
+ console.log(` ${green('✓')} Remote is reachable.`);
7443
+ } else {
7444
+ console.log(` ${red('✘')} HTTP ${res.status}`);
7445
+ }
7446
+ } catch (e) {
7447
+ console.log(` ${red('✘')} ${e.message}`);
7448
+ }
7449
+ }
7450
+ }
7451
+ console.log();
7452
+ return;
7453
+ }
7454
+
7455
+ if (subcommand === 'disconnect') {
7456
+ const remote = getRemoteConfig(dataDir);
7457
+ if (!remote || !remote.enabled) {
7458
+ console.log(`\n ${dim('Remote sync is already disabled.')}\n`);
7459
+ return;
7460
+ }
7461
+ saveRemoteConfig({ enabled: false }, dataDir);
7462
+ console.log(`\n ${green('✓')} Remote sync disabled. API key preserved (re-enable with ${cyan('context-vault remote setup')}).\n`);
7463
+ return;
7464
+ }
7465
+
7466
+ if (subcommand === 'sync') {
7467
+ await runRemoteSync(getRemoteConfig, dataDir);
7468
+ return;
7469
+ }
7470
+
7471
+ if (subcommand === 'pull') {
7472
+ await runRemotePull(getRemoteConfig, dataDir);
7473
+ return;
7474
+ }
7475
+
7476
+ console.log();
7477
+ console.log(` ${bold('◇ context-vault remote')}`);
7478
+ console.log();
7479
+ console.log(` ${cyan('setup')} Connect to a hosted vault API`);
7480
+ console.log(` ${cyan('status')} Show remote config and test connection`);
7481
+ console.log(` ${cyan('sync')} ${dim('[--full] [--dry-run]')} Sync local vault to hosted`);
7482
+ console.log(` ${cyan('pull')} Pull remote entries to local`);
7483
+ console.log(` ${cyan('disconnect')} Disable remote sync (preserves API key)`);
7484
+ console.log();
7485
+ }
7486
+
7487
+ // ---------------------------------------------------------------------------
7488
+ // remote sync — push local vault to hosted
7489
+ // ---------------------------------------------------------------------------
7490
+ async function runRemoteSync(getRemoteConfig, dataDir) {
7491
+ const remote = getRemoteConfig(dataDir);
7492
+ if (!remote || !remote.enabled || !remote.apiKey) {
7493
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7494
+ process.exit(1);
7495
+ }
7496
+
7497
+ const isFullSync = flags.has('--full');
7498
+ const { createHash } = await import('node:crypto');
7499
+ const { resolveConfig } = await import('@context-vault/core/config');
7500
+ const config = resolveConfig();
7501
+
7502
+ let DatabaseSync;
7503
+ try {
7504
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
7505
+ } catch {
7506
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
7507
+ process.exit(1);
7508
+ }
7509
+
7510
+ const db = new DatabaseSync(config.dbPath, { open: true });
7511
+ const apiUrl = remote.url.replace(/\/$/, '');
7512
+
7513
+ function contentHash(entry) {
7514
+ const data = (entry.title || '') + (entry.body || '') + JSON.stringify(entry.tags || []) + JSON.stringify(entry.meta || {});
7515
+ return createHash('sha256').update(data).digest('hex');
7516
+ }
7517
+
7518
+ console.log();
7519
+ console.log(` ${bold('◇ Remote Sync')}`);
7520
+ console.log();
7521
+
7522
+ // Read all local entries
7523
+ const localRows = db.prepare(
7524
+ 'SELECT id, kind, category, title, body, tags, meta, source, identity_key, tier, expires_at, created_at, updated_at FROM vault WHERE superseded_by IS NULL'
7525
+ ).all();
7526
+ db.close();
7527
+
7528
+ const localEntries = localRows.map((row) => ({
7529
+ ...row,
7530
+ tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : (row.tags || []),
7531
+ meta: typeof row.meta === 'string' ? JSON.parse(row.meta) : (row.meta || {}),
7532
+ }));
7533
+
7534
+ console.log(` Local entries: ${localEntries.length}`);
7535
+
7536
+ let toUpload;
7537
+
7538
+ if (isFullSync) {
7539
+ console.log(` Mode: ${cyan('full')} (uploading all entries)`);
7540
+ toUpload = localEntries;
7541
+ } else {
7542
+ // Fetch remote manifest
7543
+ console.log(` Fetching remote manifest...`);
7544
+ let manifest;
7545
+ try {
7546
+ const res = await fetch(`${apiUrl}/api/vault/manifest`, {
7547
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7548
+ signal: AbortSignal.timeout(30000),
7549
+ });
7550
+ if (!res.ok) {
7551
+ const text = await res.text().catch(() => '');
7552
+ console.error(` ${red('✘')} Manifest fetch failed: HTTP ${res.status}`);
7553
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7554
+ process.exit(1);
7555
+ }
7556
+ manifest = await res.json();
7557
+ } catch (e) {
7558
+ console.error(` ${red('✘')} Manifest fetch failed: ${e.message}`);
7559
+ process.exit(1);
7560
+ }
7561
+
7562
+ const remoteEntries = manifest.entries || [];
7563
+ const remoteMap = new Map();
7564
+ for (const entry of remoteEntries) {
7565
+ remoteMap.set(entry.id, entry);
7566
+ }
7567
+
7568
+ // If remote manifest has content_hash, do a hash diff. Otherwise fall back to full sync.
7569
+ const hasContentHash = remoteEntries.length > 0 && remoteEntries[0].content_hash;
7570
+
7571
+ if (!hasContentHash && remoteEntries.length > 0) {
7572
+ console.log(` ${yellow('!')} Remote manifest lacks content_hash. Falling back to full upload.`);
7573
+ toUpload = localEntries;
7574
+ } else {
7575
+ toUpload = [];
7576
+ let unchanged = 0;
7577
+ for (const entry of localEntries) {
7578
+ const remoteEntry = remoteMap.get(entry.id);
7579
+ if (!remoteEntry) {
7580
+ toUpload.push(entry);
7581
+ } else if (remoteEntry.content_hash !== contentHash(entry)) {
7582
+ toUpload.push(entry);
7583
+ } else {
7584
+ unchanged++;
7585
+ }
7586
+ }
7587
+ console.log(` Unchanged: ${unchanged}`);
7588
+ }
7589
+ }
7590
+
7591
+ const newCount = toUpload.length;
7592
+ console.log(` To upload: ${newCount}`);
7593
+
7594
+ if (newCount === 0) {
7595
+ console.log(`\n ${green('✓')} Everything is in sync.\n`);
7596
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7597
+ return;
7598
+ }
7599
+
7600
+ if (isDryRun) {
7601
+ console.log();
7602
+ for (const entry of toUpload.slice(0, 20)) {
7603
+ console.log(` ${dim(entry.id)} ${entry.kind || '?'} ${entry.title || dim('(untitled)')}`);
7604
+ }
7605
+ if (toUpload.length > 20) {
7606
+ console.log(` ${dim(`... and ${toUpload.length - 20} more`)}`);
7607
+ }
7608
+ console.log(`\n ${dim('Dry run. No changes made.')}\n`);
7609
+ return;
7610
+ }
7611
+
7612
+ // Stream as NDJSON
7613
+ console.log(` Uploading...`);
7614
+ const ndjsonBody = toUpload.map((entry) => JSON.stringify(entry)).join('\n') + '\n';
7615
+
7616
+ let jobId, entriesUploaded;
7617
+ try {
7618
+ const res = await fetch(`${apiUrl}/api/vault/import/stream`, {
7619
+ method: 'POST',
7620
+ headers: {
7621
+ 'Authorization': `Bearer ${remote.apiKey}`,
7622
+ 'Content-Type': 'application/x-ndjson',
7623
+ },
7624
+ body: ndjsonBody,
7625
+ signal: AbortSignal.timeout(120000),
7626
+ });
7627
+ if (!res.ok) {
7628
+ const text = await res.text().catch(() => '');
7629
+ console.error(` ${red('✘')} Upload failed: HTTP ${res.status}`);
7630
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7631
+ process.exit(1);
7632
+ }
7633
+ const data = await res.json();
7634
+ jobId = data.job_id;
7635
+ entriesUploaded = data.entries_uploaded || newCount;
7636
+ } catch (e) {
7637
+ console.error(` ${red('✘')} Upload failed: ${e.message}`);
7638
+ process.exit(1);
7639
+ }
7640
+
7641
+ console.log(` ${green('✓')} Uploaded ${entriesUploaded} entries.`);
7642
+
7643
+ if (!jobId) {
7644
+ console.log(`\n ${green('✓')} Sync complete (no job tracking available).\n`);
7645
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7646
+ return;
7647
+ }
7648
+
7649
+ // Poll job status until embeddings complete
7650
+ console.log(` Job: ${dim(jobId)}`);
7651
+ console.log(` Waiting for embeddings...`);
7652
+
7653
+ let attempts = 0;
7654
+ const maxAttempts = 300; // 10 min max
7655
+ while (attempts < maxAttempts) {
7656
+ await new Promise((r) => setTimeout(r, 2000));
7657
+ attempts++;
7658
+ try {
7659
+ const res = await fetch(`${apiUrl}/api/vault/jobs/${jobId}`, {
7660
+ headers: { 'Authorization': `Bearer ${remote.apiKey}` },
7661
+ signal: AbortSignal.timeout(10000),
7662
+ });
7663
+ if (!res.ok) {
7664
+ if (res.status === 404) {
7665
+ console.log(` ${yellow('!')} Job not found (server may not support job tracking yet).`);
7666
+ break;
7667
+ }
7668
+ continue;
7669
+ }
7670
+ const job = await res.json();
7671
+ const embedded = job.entries_embedded || 0;
7672
+ const total = job.total_entries || entriesUploaded;
7673
+ const pct = total > 0 ? Math.round((embedded / total) * 100) : 0;
7674
+ process.stdout.write(`\r Embeddings: ${embedded}/${total} (${pct}%) `);
7675
+
7676
+ if (job.status === 'complete') {
7677
+ process.stdout.write('\n');
7678
+ console.log(` ${green('✓')} Embeddings complete.`);
7679
+ break;
7680
+ }
7681
+ if (job.status === 'failed') {
7682
+ process.stdout.write('\n');
7683
+ console.error(` ${red('✘')} Embedding job failed.`);
7684
+ if (job.errors) console.error(` ${dim(JSON.stringify(job.errors).slice(0, 300))}`);
7685
+ break;
7686
+ }
7687
+ } catch {
7688
+ // Transient error, keep polling
7689
+ }
7690
+ }
7691
+
7692
+ if (attempts >= maxAttempts) {
7693
+ console.log(`\n ${yellow('!')} Timed out waiting for embeddings. They will complete in the background.`);
7694
+ }
7695
+
7696
+ console.log(`\n ${green('✓')} Synced ${entriesUploaded} entries.\n`);
7697
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7698
+ }
7699
+
7700
+ // ---------------------------------------------------------------------------
7701
+ // remote pull — fetch remote entries to local
7702
+ // ---------------------------------------------------------------------------
7703
+ async function runRemotePull(getRemoteConfig, dataDir) {
7704
+ const remote = getRemoteConfig(dataDir);
7705
+ if (!remote || !remote.enabled || !remote.apiKey) {
7706
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7707
+ process.exit(1);
7708
+ }
7709
+
7710
+ const { resolveConfig } = await import('@context-vault/core/config');
7711
+ const config = resolveConfig();
7712
+
7713
+ let DatabaseSync;
7714
+ try {
7715
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
7716
+ } catch {
7717
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
7718
+ process.exit(1);
7719
+ }
7720
+
7721
+ const db = new DatabaseSync(config.dbPath, { open: true });
7722
+ const apiUrl = remote.url.replace(/\/$/, '');
7723
+
7724
+ console.log();
7725
+ console.log(` ${bold('◇ Remote Pull')}`);
7726
+ console.log();
7727
+
7728
+ // Fetch remote manifest
7729
+ console.log(` Fetching remote manifest...`);
7730
+ let manifest;
7731
+ try {
7732
+ const res = await fetch(`${apiUrl}/api/vault/manifest`, {
7733
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7734
+ signal: AbortSignal.timeout(30000),
7735
+ });
7736
+ if (!res.ok) {
7737
+ const text = await res.text().catch(() => '');
7738
+ console.error(` ${red('✘')} Manifest fetch failed: HTTP ${res.status}`);
7739
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7740
+ db.close();
7741
+ process.exit(1);
7742
+ }
7743
+ manifest = await res.json();
7744
+ } catch (e) {
7745
+ console.error(` ${red('✘')} Manifest fetch failed: ${e.message}`);
7746
+ db.close();
7747
+ process.exit(1);
7748
+ }
7749
+
7750
+ const remoteEntries = manifest.entries || [];
7751
+ console.log(` Remote entries: ${remoteEntries.length}`);
7752
+
7753
+ // Build local manifest
7754
+ const localRows = db.prepare('SELECT id, updated_at FROM vault').all();
7755
+ const localMap = new Map();
7756
+ for (const row of localRows) {
7757
+ localMap.set(row.id, row.updated_at);
7758
+ }
7759
+
7760
+ // Diff: remote entries not in local, or remote.updated_at > local.updated_at
7761
+ const toPull = [];
7762
+ for (const entry of remoteEntries) {
7763
+ const localUpdated = localMap.get(entry.id);
7764
+ if (!localUpdated) {
7765
+ toPull.push(entry.id);
7766
+ } else if (entry.updated_at && entry.updated_at > localUpdated) {
7767
+ toPull.push(entry.id);
7768
+ }
7769
+ }
7770
+
7771
+ console.log(` To pull: ${toPull.length}`);
7772
+
7773
+ if (toPull.length === 0) {
7774
+ console.log(`\n ${green('✓')} Local vault is up to date.\n`);
7775
+ db.close();
7776
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7777
+ return;
7778
+ }
7779
+
7780
+ if (isDryRun) {
7781
+ for (const id of toPull.slice(0, 20)) {
7782
+ const remoteEntry = remoteEntries.find((e) => e.id === id);
7783
+ console.log(` ${dim(id)} ${remoteEntry?.kind || '?'} ${remoteEntry?.title || dim('(untitled)')}`);
7784
+ }
7785
+ if (toPull.length > 20) {
7786
+ console.log(` ${dim(`... and ${toPull.length - 20} more`)}`);
7787
+ }
7788
+ console.log(`\n ${dim('Dry run. No changes made.')}\n`);
7789
+ db.close();
7790
+ return;
7791
+ }
7792
+
7793
+ // Batch fetch in groups of 100
7794
+ let pulled = 0;
7795
+
7796
+ // Ensure vault table has the columns we need for INSERT OR REPLACE
7797
+ const insertStmt = db.prepare(`
7798
+ INSERT OR REPLACE INTO vault (id, kind, category, title, body, tags, meta, source, identity_key, tier, expires_at, created_at, updated_at)
7799
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7800
+ `);
7801
+
7802
+ for (let i = 0; i < toPull.length; i += 100) {
7803
+ const batch = toPull.slice(i, i + 100);
7804
+ const idsParam = batch.join(',');
7805
+ try {
7806
+ const res = await fetch(`${apiUrl}/api/vault/entries?ids=${encodeURIComponent(idsParam)}`, {
7807
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7808
+ signal: AbortSignal.timeout(30000),
7809
+ });
7810
+ if (!res.ok) {
7811
+ console.error(` ${red('✘')} Batch fetch failed: HTTP ${res.status}`);
7812
+ continue;
7813
+ }
7814
+ const data = await res.json();
7815
+ const entries = data.entries || data;
7816
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
7817
+ const tags = typeof entry.tags === 'string' ? entry.tags : JSON.stringify(entry.tags || []);
7818
+ const meta = typeof entry.meta === 'string' ? entry.meta : JSON.stringify(entry.meta || {});
7819
+ insertStmt.run(
7820
+ entry.id,
7821
+ entry.kind || null,
7822
+ entry.category || 'knowledge',
7823
+ entry.title || null,
7824
+ entry.body || '',
7825
+ tags,
7826
+ meta,
7827
+ entry.source || null,
7828
+ entry.identity_key || null,
7829
+ entry.tier || 'working',
7830
+ entry.expires_at || null,
7831
+ entry.created_at || new Date().toISOString(),
7832
+ entry.updated_at || new Date().toISOString()
7833
+ );
7834
+ pulled++;
7835
+ }
7836
+ } catch (e) {
7837
+ console.error(` ${yellow('!')} Batch fetch error: ${e.message}`);
7838
+ }
7839
+ process.stdout.write(`\r Progress: ${Math.min(i + 100, toPull.length)}/${toPull.length} `);
7840
+ }
7841
+
7842
+ db.close();
7843
+ process.stdout.write('\n');
7844
+ console.log(`\n ${green('✓')} Pulled ${pulled} entries from remote.\n`);
7845
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7846
+ }
7847
+
7848
+ // ---------------------------------------------------------------------------
7849
+ // Offline queue drain — best-effort, runs after any successful remote call
7850
+ // ---------------------------------------------------------------------------
7851
+ async function drainOfflineQueue(apiUrl, apiKey) {
7852
+ const queuePath = join(HOME, '.context-mcp', 'sync-queue.jsonl');
7853
+ if (!existsSync(queuePath)) return;
7854
+
7855
+ let lines;
7856
+ try {
7857
+ const content = readFileSync(queuePath, 'utf-8').trim();
7858
+ if (!content) {
7859
+ unlinkSync(queuePath);
7860
+ return;
7861
+ }
7862
+ lines = content.split('\n').filter(Boolean);
7863
+ } catch {
7864
+ return;
7865
+ }
7866
+
7867
+ if (lines.length === 0) {
7868
+ try { unlinkSync(queuePath); } catch {}
7869
+ return;
7870
+ }
7871
+
7872
+ console.log(` ${dim(`Draining offline queue (${lines.length} entries)...`)}`);
7873
+ const remaining = [];
7874
+
7875
+ for (const line of lines) {
7876
+ try {
7877
+ const entry = JSON.parse(line);
7878
+ const res = await fetch(`${apiUrl}/api/vault/entries`, {
7879
+ method: 'POST',
7880
+ headers: {
7881
+ 'Authorization': `Bearer ${apiKey}`,
7882
+ 'Content-Type': 'application/json',
7883
+ },
7884
+ body: JSON.stringify(entry),
7885
+ signal: AbortSignal.timeout(10000),
7886
+ });
7887
+ if (!res.ok) {
7888
+ remaining.push(line);
7889
+ }
7890
+ } catch {
7891
+ remaining.push(line);
7892
+ }
7893
+ }
7894
+
7895
+ if (remaining.length > 0) {
7896
+ writeFileSync(queuePath, remaining.join('\n') + '\n');
7897
+ console.log(` ${yellow('!')} ${remaining.length} entries still queued (will retry next sync).`);
7898
+ } else {
7899
+ try { unlinkSync(queuePath); } catch {}
7900
+ console.log(` ${green('✓')} Offline queue drained.`);
7901
+ }
7902
+ }
7903
+
6922
7904
  async function main() {
6923
7905
  if (flags.has('--version') || command === 'version') {
6924
7906
  console.log(VERSION);
@@ -6926,8 +7908,13 @@ async function main() {
6926
7908
  }
6927
7909
 
6928
7910
  if (flags.has('--help') || command === 'help') {
6929
- showHelp(flags.has('--all'));
6930
- return;
7911
+ // Commands with their own --help handling: delegate to them
7912
+ const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'daemon', 'team', 'remote']);
7913
+ if (!command || command === 'help' || !commandsWithHelp.has(command)) {
7914
+ showHelp(flags.has('--all'));
7915
+ return;
7916
+ }
7917
+ // Fall through to command switch for commands with built-in --help
6931
7918
  }
6932
7919
 
6933
7920
  if (!command) {
@@ -7049,6 +8036,15 @@ async function main() {
7049
8036
  case 'debug':
7050
8037
  await runDebug();
7051
8038
  break;
8039
+ case 'stats':
8040
+ await runStats();
8041
+ break;
8042
+ case 'remote':
8043
+ await runRemote();
8044
+ break;
8045
+ case 'team':
8046
+ await runTeam();
8047
+ break;
7052
8048
  default:
7053
8049
  console.error(red(`Unknown command: ${command}`));
7054
8050
  console.error(`Run ${cyan('context-vault --help')} for usage.`);