context-vault 3.8.0 → 3.10.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 (68) hide show
  1. package/assets/agent-rules.md +28 -1
  2. package/assets/setup-prompt.md +16 -1
  3. package/bin/cli.js +1187 -4
  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 +186 -0
  12. package/dist/remote.d.ts.map +1 -0
  13. package/dist/remote.js +372 -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/tools/context-status.d.ts.map +1 -1
  20. package/dist/tools/context-status.js +19 -0
  21. package/dist/tools/context-status.js.map +1 -1
  22. package/dist/tools/get-context.d.ts.map +1 -1
  23. package/dist/tools/get-context.js +70 -0
  24. package/dist/tools/get-context.js.map +1 -1
  25. package/dist/tools/publish-to-team.d.ts +11 -0
  26. package/dist/tools/publish-to-team.d.ts.map +1 -0
  27. package/dist/tools/publish-to-team.js +91 -0
  28. package/dist/tools/publish-to-team.js.map +1 -0
  29. package/dist/tools/publish-to-team.test.d.ts +2 -0
  30. package/dist/tools/publish-to-team.test.d.ts.map +1 -0
  31. package/dist/tools/publish-to-team.test.js +95 -0
  32. package/dist/tools/publish-to-team.test.js.map +1 -0
  33. package/dist/tools/recall.d.ts +1 -1
  34. package/dist/tools/recall.d.ts.map +1 -1
  35. package/dist/tools/recall.js +120 -1
  36. package/dist/tools/recall.js.map +1 -1
  37. package/dist/tools/save-context.d.ts +5 -1
  38. package/dist/tools/save-context.d.ts.map +1 -1
  39. package/dist/tools/save-context.js +163 -2
  40. package/dist/tools/save-context.js.map +1 -1
  41. package/dist/tools/session-start.d.ts.map +1 -1
  42. package/dist/tools/session-start.js +134 -86
  43. package/dist/tools/session-start.js.map +1 -1
  44. package/node_modules/@context-vault/core/dist/config.d.ts +3 -1
  45. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  46. package/node_modules/@context-vault/core/dist/config.js +48 -2
  47. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  48. package/node_modules/@context-vault/core/dist/main.d.ts +1 -1
  49. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  50. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  51. package/node_modules/@context-vault/core/dist/types.d.ts +7 -0
  52. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  53. package/node_modules/@context-vault/core/package.json +1 -1
  54. package/node_modules/@context-vault/core/src/config.ts +50 -3
  55. package/node_modules/@context-vault/core/src/main.ts +1 -0
  56. package/node_modules/@context-vault/core/src/types.ts +8 -0
  57. package/package.json +2 -2
  58. package/src/auto-memory.ts +169 -0
  59. package/src/register-tools.ts +2 -0
  60. package/src/remote.test.ts +123 -0
  61. package/src/remote.ts +470 -0
  62. package/src/tools/context-status.ts +19 -0
  63. package/src/tools/get-context.ts +72 -0
  64. package/src/tools/publish-to-team.test.ts +115 -0
  65. package/src/tools/publish-to-team.ts +112 -0
  66. package/src/tools/recall.ts +113 -1
  67. package/src/tools/save-context.ts +167 -1
  68. package/src/tools/session-start.ts +133 -100
package/bin/cli.js CHANGED
@@ -414,6 +414,9 @@ ${bold('Commands:')}
414
414
  ${cyan('restore')} <id> Restore an archived entry back into the vault
415
415
  ${cyan('prune')} Remove expired entries (use --dry-run to preview)
416
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
419
+ ${cyan('public')} create|seed|list|add Manage and consume public vaults
417
420
  ${cyan('update')} Check for and install updates
418
421
  ${cyan('uninstall')} Remove MCP configs and optionally data
419
422
  `);
@@ -2783,8 +2786,8 @@ async function runStatus() {
2783
2786
  const filled = maxCount > 0 ? Math.round((c / maxCount) * BAR_WIDTH) : 0;
2784
2787
  const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
2785
2788
  const countStr = String(c).padStart(4);
2786
- const IRREGULAR_PLURALS = { activity: 'activities', inbox: 'inboxes', index: 'indexes', match: 'matches' };
2787
- const plural = IRREGULAR_PLURALS[kind] || (kind.endsWith('s') ? kind : kind + 's');
2789
+ const IRREGULAR_PLURALS = { activity: 'activities', inbox: 'inboxes', index: 'indexes', match: 'matches', entity: 'entities', summary: 'summaries' };
2790
+ 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');
2788
2791
  console.log(` ${dim(bar)} ${countStr} ${plural}`);
2789
2792
  }
2790
2793
  } else {
@@ -4390,6 +4393,35 @@ async function runPostToolCall() {
4390
4393
  }
4391
4394
 
4392
4395
  async function runSave() {
4396
+ if (flags.has('--help')) {
4397
+ console.log(`
4398
+ ${bold('context-vault save')}
4399
+
4400
+ Save an entry to the vault from CLI.
4401
+
4402
+ ${bold('Required:')}
4403
+ --kind <kind> Entry kind (insight, decision, pattern, reference, event)
4404
+ --title <title> Entry title
4405
+
4406
+ ${bold('Content')} (one of):
4407
+ --body <text> Inline body text
4408
+ --file <path> Read body from a file
4409
+ (stdin) Pipe content via stdin
4410
+
4411
+ ${bold('Optional:')}
4412
+ --tags <a,b,c> Comma-separated tags
4413
+ --tier <tier> working (default) or durable
4414
+ --source <source> Source label (default: cli)
4415
+ --identity-key <key> Identity key (for entity kinds)
4416
+ --meta <json> Additional metadata as JSON
4417
+
4418
+ ${bold('Examples:')}
4419
+ context-vault save --kind insight --title "Express 5 gotcha" --body "body parser changed"
4420
+ echo "content" | context-vault save --kind reference --title "API notes"
4421
+ `);
4422
+ return;
4423
+ }
4424
+
4393
4425
  const kind = getFlag('--kind');
4394
4426
  const title = getFlag('--title');
4395
4427
  const tags = getFlag('--tags');
@@ -4487,6 +4519,30 @@ async function runSave() {
4487
4519
  }
4488
4520
 
4489
4521
  async function runSearch() {
4522
+ if (flags.has('--help')) {
4523
+ console.log(`
4524
+ ${bold('context-vault search')} <query> [options]
4525
+
4526
+ Search vault entries from CLI using hybrid semantic + full-text search.
4527
+
4528
+ ${bold('Usage:')}
4529
+ context-vault search "express middleware"
4530
+ context-vault search --kind insight --tags "bucket:myproject"
4531
+ context-vault search "auth" --limit 20 --format json
4532
+
4533
+ ${bold('Options:')}
4534
+ --kind <kind> Filter by kind (insight, decision, pattern, reference, event)
4535
+ --tags <a,b,c> Filter by tags (comma-separated)
4536
+ --limit <n> Max results (default: 10)
4537
+ --sort <mode> Sort by: relevance (default), recent
4538
+ --format <fmt> Output format: plain (default), json
4539
+ --scope <scope> Search scope: hot (default), events, all
4540
+ --full Show full entry body (not truncated)
4541
+ --query <text> Explicit query (alternative to positional args)
4542
+ `);
4543
+ return;
4544
+ }
4545
+
4490
4546
  const kind = getFlag('--kind');
4491
4547
  const tagsStr = getFlag('--tags');
4492
4548
  const limit = parseInt(getFlag('--limit') || '10', 10);
@@ -7040,6 +7096,1119 @@ async function runServe() {
7040
7096
  await import('../dist/server.js');
7041
7097
  }
7042
7098
 
7099
+ async function runTeam() {
7100
+ const subcommand = args[1];
7101
+ const { getRemoteConfig, saveRemoteConfig } = await import('@context-vault/core/config');
7102
+ const dataDir = join(HOME, '.context-mcp');
7103
+
7104
+ if (subcommand === 'join') {
7105
+ const teamId = args[2];
7106
+ if (!teamId) {
7107
+ console.error(`\n ${red('Usage:')} context-vault team join <team-id>\n`);
7108
+ process.exit(1);
7109
+ }
7110
+
7111
+ const remote = getRemoteConfig(dataDir);
7112
+ if (!remote || !remote.enabled || !remote.apiKey) {
7113
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7114
+ process.exit(1);
7115
+ }
7116
+
7117
+ console.log(`\n Testing connection to team ${dim(teamId)}...`);
7118
+ try {
7119
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/team/${teamId}/status`, {
7120
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7121
+ signal: AbortSignal.timeout(10000),
7122
+ });
7123
+ if (res.ok) {
7124
+ const data = await res.json();
7125
+ saveRemoteConfig({ teamId }, dataDir);
7126
+ console.log(` ${green('✓')} Joined team: ${bold(data.name || teamId)}`);
7127
+ if (data.entry_count != null) {
7128
+ console.log(` ${dim(`${data.entry_count} entries, ${data.member_count || '?'} members`)}`);
7129
+ }
7130
+ } else {
7131
+ const text = await res.text().catch(() => '');
7132
+ console.error(` ${red('✘')} Failed to connect to team: HTTP ${res.status}`);
7133
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7134
+ process.exit(1);
7135
+ }
7136
+ } catch (e) {
7137
+ console.error(` ${red('✘')} Connection failed: ${e.message}`);
7138
+ process.exit(1);
7139
+ }
7140
+ console.log();
7141
+ return;
7142
+ }
7143
+
7144
+ if (subcommand === 'leave') {
7145
+ const remote = getRemoteConfig(dataDir);
7146
+ if (!remote?.teamId) {
7147
+ console.log(`\n ${dim('Not currently in a team.')}\n`);
7148
+ return;
7149
+ }
7150
+ saveRemoteConfig({ teamId: '' }, dataDir);
7151
+ console.log(`\n ${green('✓')} Left team. Team vault queries disabled.\n`);
7152
+ return;
7153
+ }
7154
+
7155
+ if (subcommand === 'status') {
7156
+ const remote = getRemoteConfig(dataDir);
7157
+ console.log();
7158
+ console.log(` ${bold('◇ Team Status')}`);
7159
+ console.log();
7160
+
7161
+ if (!remote || !remote.teamId) {
7162
+ console.log(` Team: ${dim('not joined')}`);
7163
+ console.log(` ${dim('Run')} ${cyan('context-vault team join <team-id>')} ${dim('to connect.')}`);
7164
+ console.log();
7165
+ return;
7166
+ }
7167
+
7168
+ console.log(` Team ID: ${remote.teamId}`);
7169
+
7170
+ if (remote.enabled && remote.apiKey) {
7171
+ console.log(` Checking status...`);
7172
+ try {
7173
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/team/${remote.teamId}/status`, {
7174
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7175
+ signal: AbortSignal.timeout(10000),
7176
+ });
7177
+ if (res.ok) {
7178
+ const data = await res.json();
7179
+ console.log(` Name: ${data.name || dim('(unnamed)')}`);
7180
+ console.log(` Members: ${data.member_count ?? dim('?')}`);
7181
+ console.log(` Entries: ${data.entry_count ?? dim('?')}`);
7182
+ console.log(` Health: ${green('connected')}`);
7183
+ } else {
7184
+ console.log(` Health: ${red('error')} (HTTP ${res.status})`);
7185
+ }
7186
+ } catch (e) {
7187
+ console.log(` Health: ${red('unreachable')} (${e.message})`);
7188
+ }
7189
+ } else {
7190
+ console.log(` Health: ${yellow('remote not configured')}`);
7191
+ }
7192
+ console.log();
7193
+ return;
7194
+ }
7195
+
7196
+ if (subcommand === 'seed') {
7197
+ const teamIdFlag = getFlag('--team');
7198
+ const tagsFlag = getFlag('--tags');
7199
+ const remote = getRemoteConfig(dataDir);
7200
+
7201
+ if (!remote || !remote.enabled || !remote.apiKey) {
7202
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7203
+ process.exit(1);
7204
+ }
7205
+
7206
+ const teamId = teamIdFlag || remote.teamId;
7207
+ if (!teamId) {
7208
+ console.error(`\n ${red('✘')} No team ID. Pass ${cyan('--team <id>')} or join a team first.\n`);
7209
+ process.exit(1);
7210
+ }
7211
+
7212
+ // Load local vault DB
7213
+ const { resolveConfig } = await import('@context-vault/core/config');
7214
+ const config = resolveConfig();
7215
+
7216
+ let DatabaseSync;
7217
+ try {
7218
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
7219
+ } catch {
7220
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
7221
+ process.exit(1);
7222
+ }
7223
+
7224
+ const db = new DatabaseSync(config.dbPath, { open: true });
7225
+
7226
+ // Build query based on tag filter
7227
+ let sql = 'SELECT id, kind, category, title, body, tags, meta, source, identity_key, tier FROM vault WHERE superseded_by IS NULL';
7228
+ const params = [];
7229
+
7230
+ if (tagsFlag) {
7231
+ sql += ' AND tags LIKE ?';
7232
+ params.push(`%"${tagsFlag}"%`);
7233
+ }
7234
+
7235
+ const rows = db.prepare(sql).all(...params);
7236
+ db.close();
7237
+
7238
+ let publishedKnowledge = 0;
7239
+ let federatedEntities = 0;
7240
+ let skippedEvents = 0;
7241
+ let blockedPrivacy = 0;
7242
+ const blockedEntries = [];
7243
+ let errors = 0;
7244
+
7245
+ const apiUrl = remote.url.replace(/\/$/, '');
7246
+
7247
+ console.log();
7248
+ console.log(` ${bold('◇ Team Seed')}`);
7249
+ console.log(` Team: ${teamId}`);
7250
+ console.log(` Filter: ${tagsFlag || dim('(all entries)')}`);
7251
+ console.log(` Entries: ${rows.length}`);
7252
+ console.log();
7253
+
7254
+ if (isDryRun) {
7255
+ for (const row of rows) {
7256
+ if (row.category === 'event') {
7257
+ skippedEvents++;
7258
+ } else if (row.category === 'entity') {
7259
+ federatedEntities++;
7260
+ } else {
7261
+ publishedKnowledge++;
7262
+ }
7263
+ }
7264
+ console.log(` ${dim('[dry run]')}`);
7265
+ console.log(` Would publish: ${green(publishedKnowledge)} knowledge, ${cyan(federatedEntities)} entities`);
7266
+ console.log(` Would skip: ${dim(skippedEvents)} events (private)`);
7267
+ console.log();
7268
+ return;
7269
+ }
7270
+
7271
+ for (const row of rows) {
7272
+ if (row.category === 'event') {
7273
+ skippedEvents++;
7274
+ continue;
7275
+ }
7276
+
7277
+ const tags = row.tags ? JSON.parse(row.tags) : [];
7278
+ const meta = row.meta ? JSON.parse(row.meta) : {};
7279
+
7280
+ try {
7281
+ const res = await fetch(`${apiUrl}/api/vault/publish`, {
7282
+ method: 'POST',
7283
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7284
+ signal: AbortSignal.timeout(15000),
7285
+ body: JSON.stringify({
7286
+ entryId: row.id,
7287
+ teamId,
7288
+ visibility: 'team',
7289
+ kind: row.kind,
7290
+ title: row.title,
7291
+ body: row.body,
7292
+ tags,
7293
+ meta,
7294
+ source: row.source,
7295
+ identity_key: row.identity_key,
7296
+ tier: row.tier,
7297
+ category: row.category,
7298
+ }),
7299
+ });
7300
+
7301
+ if (res.ok) {
7302
+ if (row.category === 'entity') {
7303
+ federatedEntities++;
7304
+ } else {
7305
+ publishedKnowledge++;
7306
+ }
7307
+ } else if (res.status === 422) {
7308
+ const data = await res.json().catch(() => ({}));
7309
+ if (data.code === 'PRIVACY_SCAN_FAILED') {
7310
+ const matchTypes = Array.isArray(data.matches) ? data.matches.map(m => m.type) : [];
7311
+ const uniqueTypes = [...new Set(matchTypes)];
7312
+ console.log(` ${yellow('!')} Blocked: "${row.title || row.id}" (${uniqueTypes.join(', ') || 'sensitive content'})`);
7313
+ blockedPrivacy++;
7314
+ blockedEntries.push({ title: row.title || row.id, types: uniqueTypes });
7315
+ } else {
7316
+ errors++;
7317
+ console.error(` ${red('!')} 422 for "${row.title || row.id}": ${data.error || 'unknown'}`);
7318
+ }
7319
+ } else {
7320
+ const data = await res.json().catch(() => ({}));
7321
+ if (data.conflict) {
7322
+ console.log(` ${yellow('!')} Conflict for "${row.title || row.id}": ${data.conflict.suggestion || 'similar entry exists'}`);
7323
+ }
7324
+ if (row.category === 'entity') {
7325
+ federatedEntities++;
7326
+ } else {
7327
+ publishedKnowledge++;
7328
+ }
7329
+ }
7330
+ } catch (e) {
7331
+ errors++;
7332
+ console.error(` ${red('✘')} Failed: ${row.title || row.id} (${e.message})`);
7333
+ }
7334
+
7335
+ // Progress indicator every 10 entries
7336
+ const processed = publishedKnowledge + federatedEntities + skippedEvents + blockedPrivacy + errors;
7337
+ if (processed % 10 === 0) {
7338
+ process.stdout.write(` ${dim(`${processed}/${rows.length}...`)}\r`);
7339
+ }
7340
+ }
7341
+
7342
+ console.log(` Seeded: ${green(publishedKnowledge)} published, ${cyan(federatedEntities)} federated, ${dim(skippedEvents)} skipped (events), ${blockedPrivacy > 0 ? yellow(blockedPrivacy) : dim(blockedPrivacy)} blocked (privacy scan)`);
7343
+ if (errors > 0) {
7344
+ console.log(` Errors: ${red(errors)}`);
7345
+ }
7346
+ if (blockedEntries.length > 0) {
7347
+ console.log();
7348
+ console.log(` ${yellow('Blocked entries (review and clean before re-seeding):')}`);
7349
+ for (const entry of blockedEntries) {
7350
+ console.log(` - ${entry.title} [${entry.types.join(', ')}]`);
7351
+ }
7352
+ }
7353
+ console.log();
7354
+ return;
7355
+ }
7356
+
7357
+ // Default: show team help
7358
+ console.log();
7359
+ console.log(` ${bold('◇ context-vault team')}`);
7360
+ console.log();
7361
+ console.log(` ${cyan('join <team-id>')} Join a team vault`);
7362
+ console.log(` ${cyan('leave')} Leave the current team`);
7363
+ console.log(` ${cyan('status')} Show team connection status`);
7364
+ console.log(` ${cyan('seed')} Publish local entries to team vault`);
7365
+ console.log(` ${dim('--team <id>')} Team ID (defaults to joined team)`);
7366
+ console.log(` ${dim('--tags <filter>')} Filter entries by tag (e.g. bucket:stormfors)`);
7367
+ console.log(` ${dim('--dry-run')} Preview without publishing`);
7368
+ console.log();
7369
+ }
7370
+
7371
+ async function runPublic() {
7372
+ const subcommand = args[1];
7373
+ const { getRemoteConfig, saveRemoteConfig } = await import('@context-vault/core/config');
7374
+ const dataDir = join(HOME, '.context-mcp');
7375
+
7376
+ if (subcommand === 'create') {
7377
+ const name = args[2];
7378
+ const slug = getFlag('--slug') || (name ? name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-') : null);
7379
+ const description = getFlag('--description') || '';
7380
+ const domainTags = getFlag('--domains') ? getFlag('--domains').split(',').map(s => s.trim()) : [];
7381
+ const visibility = getFlag('--visibility') || 'free';
7382
+
7383
+ if (!name || !slug) {
7384
+ console.error(`\n ${red('Usage:')} context-vault public create <name> [--slug <slug>] [--description <desc>] [--domains <tag1,tag2>] [--visibility free|pro]\n`);
7385
+ process.exit(1);
7386
+ }
7387
+
7388
+ const remote = getRemoteConfig(dataDir);
7389
+ if (!remote || !remote.enabled || !remote.apiKey) {
7390
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7391
+ process.exit(1);
7392
+ }
7393
+
7394
+ console.log(`\n Creating public vault "${bold(name)}" (${slug})...`);
7395
+ try {
7396
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/public/vaults`, {
7397
+ method: 'POST',
7398
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7399
+ signal: AbortSignal.timeout(15000),
7400
+ body: JSON.stringify({ name, slug, description, domain_tags: domainTags, visibility }),
7401
+ });
7402
+ if (res.ok) {
7403
+ const data = await res.json();
7404
+ console.log(` ${green('✓')} Created public vault: ${bold(data.slug || slug)}`);
7405
+ console.log(` Visibility: ${visibility}`);
7406
+ if (domainTags.length) console.log(` Domains: ${domainTags.join(', ')}`);
7407
+ } else {
7408
+ const data = await res.json().catch(() => ({}));
7409
+ console.error(` ${red('✘')} Failed: ${data.error || `HTTP ${res.status}`}`);
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 === 'seed') {
7421
+ const vaultSlug = getFlag('--vault');
7422
+ const tagsFlag = getFlag('--tags');
7423
+ const remote = getRemoteConfig(dataDir);
7424
+
7425
+ if (!remote || !remote.enabled || !remote.apiKey) {
7426
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7427
+ process.exit(1);
7428
+ }
7429
+
7430
+ if (!vaultSlug) {
7431
+ console.error(`\n ${red('Usage:')} context-vault public seed --vault <slug> [--tags <filter>] [--dry-run]\n`);
7432
+ process.exit(1);
7433
+ }
7434
+
7435
+ // Load local vault DB
7436
+ const { resolveConfig } = await import('@context-vault/core/config');
7437
+ const config = resolveConfig();
7438
+
7439
+ let DatabaseSync;
7440
+ try {
7441
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
7442
+ } catch {
7443
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
7444
+ process.exit(1);
7445
+ }
7446
+
7447
+ const db = new DatabaseSync(config.dbPath, { open: true });
7448
+
7449
+ let sql = 'SELECT id, kind, category, title, body, tags, meta, source, identity_key, tier FROM vault WHERE superseded_by IS NULL';
7450
+ const params = [];
7451
+ if (tagsFlag) {
7452
+ sql += ' AND tags LIKE ?';
7453
+ params.push(`%"${tagsFlag}"%`);
7454
+ }
7455
+
7456
+ const rows = db.prepare(sql).all(...params);
7457
+ db.close();
7458
+
7459
+ let publishedKnowledge = 0;
7460
+ let publishedEntities = 0;
7461
+ let skippedEvents = 0;
7462
+ let blockedPrivacy = 0;
7463
+ const blockedEntries = [];
7464
+ let errors = 0;
7465
+
7466
+ const apiUrl = remote.url.replace(/\/$/, '');
7467
+
7468
+ console.log();
7469
+ console.log(` ${bold('◇ Public Vault Seed')}`);
7470
+ console.log(` Vault: ${vaultSlug}`);
7471
+ console.log(` Filter: ${tagsFlag || dim('(all entries)')}`);
7472
+ console.log(` Entries: ${rows.length}`);
7473
+ console.log();
7474
+
7475
+ if (isDryRun) {
7476
+ for (const row of rows) {
7477
+ if (row.category === 'event') skippedEvents++;
7478
+ else if (row.category === 'entity') publishedEntities++;
7479
+ else publishedKnowledge++;
7480
+ }
7481
+ console.log(` ${dim('[dry run]')}`);
7482
+ console.log(` Would publish: ${green(publishedKnowledge)} knowledge, ${cyan(publishedEntities)} entities`);
7483
+ console.log(` Would skip: ${dim(skippedEvents)} events (blocked for public)`);
7484
+ console.log();
7485
+ return;
7486
+ }
7487
+
7488
+ for (const row of rows) {
7489
+ if (row.category === 'event') {
7490
+ skippedEvents++;
7491
+ continue;
7492
+ }
7493
+
7494
+ const tags = row.tags ? JSON.parse(row.tags) : [];
7495
+ const meta = row.meta ? JSON.parse(row.meta) : {};
7496
+
7497
+ try {
7498
+ const res = await fetch(`${apiUrl}/api/public/${vaultSlug}/entries`, {
7499
+ method: 'POST',
7500
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7501
+ signal: AbortSignal.timeout(15000),
7502
+ body: JSON.stringify({
7503
+ kind: row.kind,
7504
+ title: row.title,
7505
+ body: row.body,
7506
+ tags,
7507
+ meta,
7508
+ source: row.source,
7509
+ identity_key: row.identity_key,
7510
+ tier: row.tier,
7511
+ category: row.category,
7512
+ }),
7513
+ });
7514
+
7515
+ if (res.ok) {
7516
+ if (row.category === 'entity') publishedEntities++;
7517
+ else publishedKnowledge++;
7518
+ } else if (res.status === 422) {
7519
+ const data = await res.json().catch(() => ({}));
7520
+ if (data.code === 'PRIVACY_SCAN_FAILED') {
7521
+ const matchTypes = Array.isArray(data.matches) ? data.matches.map(m => m.type) : [];
7522
+ const uniqueTypes = [...new Set(matchTypes)];
7523
+ console.log(` ${yellow('!')} Blocked: "${row.title || row.id}" (${uniqueTypes.join(', ') || 'sensitive content'})`);
7524
+ blockedPrivacy++;
7525
+ blockedEntries.push({ title: row.title || row.id, types: uniqueTypes });
7526
+ } else {
7527
+ errors++;
7528
+ console.error(` ${red('!')} 422 for "${row.title || row.id}": ${data.error || 'unknown'}`);
7529
+ }
7530
+ } else {
7531
+ errors++;
7532
+ const text = await res.text().catch(() => '');
7533
+ console.error(` ${red('!')} HTTP ${res.status} for "${row.title || row.id}": ${text.slice(0, 200)}`);
7534
+ }
7535
+ } catch (e) {
7536
+ errors++;
7537
+ console.error(` ${red('✘')} Failed: ${row.title || row.id} (${e.message})`);
7538
+ }
7539
+
7540
+ const processed = publishedKnowledge + publishedEntities + skippedEvents + blockedPrivacy + errors;
7541
+ if (processed % 10 === 0) {
7542
+ process.stdout.write(` ${dim(`${processed}/${rows.length}...`)}\r`);
7543
+ }
7544
+ }
7545
+
7546
+ console.log(` Seeded: ${green(publishedKnowledge)} knowledge, ${cyan(publishedEntities)} entities, ${dim(skippedEvents)} skipped (events), ${blockedPrivacy > 0 ? yellow(blockedPrivacy) : dim(blockedPrivacy)} blocked (privacy)`);
7547
+ if (errors > 0) console.log(` Errors: ${red(errors)}`);
7548
+ if (blockedEntries.length > 0) {
7549
+ console.log();
7550
+ console.log(` ${yellow('Blocked entries (review and clean before re-seeding):')}`);
7551
+ for (const entry of blockedEntries) {
7552
+ console.log(` - ${entry.title} [${entry.types.join(', ')}]`);
7553
+ }
7554
+ }
7555
+ console.log();
7556
+ return;
7557
+ }
7558
+
7559
+ if (subcommand === 'list') {
7560
+ const domain = getFlag('--domain');
7561
+ const remote = getRemoteConfig(dataDir);
7562
+
7563
+ const apiUrl = remote?.url?.replace(/\/$/, '') || 'https://api.context-vault.com';
7564
+ const headers = {};
7565
+ if (remote?.apiKey) headers['Authorization'] = `Bearer ${remote.apiKey}`;
7566
+ headers['Content-Type'] = 'application/json';
7567
+
7568
+ const query = new URLSearchParams();
7569
+ if (domain) query.set('domain', domain);
7570
+
7571
+ console.log();
7572
+ console.log(` ${bold('◇ Public Vaults')}`);
7573
+ console.log();
7574
+
7575
+ try {
7576
+ const res = await fetch(`${apiUrl}/api/public/vaults?${query.toString()}`, {
7577
+ headers,
7578
+ signal: AbortSignal.timeout(10000),
7579
+ });
7580
+ if (res.ok) {
7581
+ const data = await res.json();
7582
+ const vaults = Array.isArray(data.vaults) ? data.vaults : Array.isArray(data) ? data : [];
7583
+ if (vaults.length === 0) {
7584
+ console.log(` ${dim('No public vaults found.')}`);
7585
+ } else {
7586
+ for (const v of vaults) {
7587
+ const domains = Array.isArray(v.domain_tags) ? v.domain_tags.join(', ') : '';
7588
+ console.log(` ${bold(v.name || v.slug)} ${dim(`(${v.slug})`)}`);
7589
+ if (v.description) console.log(` ${v.description}`);
7590
+ const stats = [`${v.consumer_count ?? 0} consumers`, `${v.total_recalls ?? 0} recalls`];
7591
+ if (domains) stats.push(domains);
7592
+ console.log(` ${dim(stats.join(' · '))}`);
7593
+ console.log();
7594
+ }
7595
+ }
7596
+ } else {
7597
+ console.error(` ${red('✘')} Failed to list vaults: HTTP ${res.status}`);
7598
+ }
7599
+ } catch (e) {
7600
+ console.error(` ${red('✘')} Connection failed: ${e.message}`);
7601
+ }
7602
+ console.log();
7603
+ return;
7604
+ }
7605
+
7606
+ if (subcommand === 'add') {
7607
+ const slug = args[2];
7608
+ if (!slug) {
7609
+ console.error(`\n ${red('Usage:')} context-vault public add <slug>\n`);
7610
+ process.exit(1);
7611
+ }
7612
+
7613
+ const configPath = join(dataDir, 'config.json');
7614
+ let config = {};
7615
+ try {
7616
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
7617
+ } catch {}
7618
+
7619
+ if (!config.remote) config.remote = {};
7620
+ if (!Array.isArray(config.remote.publicVaults)) config.remote.publicVaults = [];
7621
+
7622
+ if (config.remote.publicVaults.includes(slug)) {
7623
+ console.log(`\n ${dim('Already added:')} ${slug}\n`);
7624
+ return;
7625
+ }
7626
+
7627
+ config.remote.publicVaults.push(slug);
7628
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
7629
+ console.log(`\n ${green('✓')} Added public vault: ${bold(slug)}`);
7630
+ console.log(` Your agent will now query this vault in get_context, session_start, and recall.\n`);
7631
+ return;
7632
+ }
7633
+
7634
+ if (subcommand === 'remove') {
7635
+ const slug = args[2];
7636
+ if (!slug) {
7637
+ console.error(`\n ${red('Usage:')} context-vault public remove <slug>\n`);
7638
+ process.exit(1);
7639
+ }
7640
+
7641
+ const configPath = join(dataDir, 'config.json');
7642
+ let config = {};
7643
+ try {
7644
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
7645
+ } catch {}
7646
+
7647
+ if (!config.remote?.publicVaults || !config.remote.publicVaults.includes(slug)) {
7648
+ console.log(`\n ${dim('Not found:')} ${slug}\n`);
7649
+ return;
7650
+ }
7651
+
7652
+ config.remote.publicVaults = config.remote.publicVaults.filter(s => s !== slug);
7653
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
7654
+ console.log(`\n ${green('✓')} Removed public vault: ${bold(slug)}\n`);
7655
+ return;
7656
+ }
7657
+
7658
+ // Default: show public help
7659
+ console.log();
7660
+ console.log(` ${bold('◇ context-vault public')}`);
7661
+ console.log();
7662
+ console.log(` ${cyan('create <name>')} Create a new public vault`);
7663
+ console.log(` ${dim('--slug <slug>')} URL-friendly identifier`);
7664
+ console.log(` ${dim('--description <d>')} Vault description`);
7665
+ console.log(` ${dim('--domains <tags>')} Comma-separated domain tags`);
7666
+ console.log(` ${dim('--visibility <v>')} free (default) or pro`);
7667
+ console.log(` ${cyan('seed')} Publish local entries to a public vault`);
7668
+ console.log(` ${dim('--vault <slug>')} Target public vault slug (required)`);
7669
+ console.log(` ${dim('--tags <filter>')} Filter entries by tag`);
7670
+ console.log(` ${dim('--dry-run')} Preview without publishing`);
7671
+ console.log(` ${cyan('list')} Browse available public vaults`);
7672
+ console.log(` ${dim('--domain <tag>')} Filter by domain tag`);
7673
+ console.log(` ${cyan('add <slug>')} Add a public vault to your agent config`);
7674
+ console.log(` ${cyan('remove <slug>')} Remove a public vault from your config`);
7675
+ console.log();
7676
+ }
7677
+
7678
+ async function runRemote() {
7679
+ const subcommand = args[1];
7680
+ const { getRemoteConfig, saveRemoteConfig } = await import('@context-vault/core/config');
7681
+ const dataDir = join(HOME, '.context-mcp');
7682
+
7683
+ if (subcommand === 'setup') {
7684
+ const readline = await import('node:readline');
7685
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7686
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
7687
+
7688
+ console.log();
7689
+ console.log(` ${bold('◇ Remote Vault Setup')}`);
7690
+ console.log();
7691
+
7692
+ const defaultUrl = 'https://api.context-vault.com';
7693
+ const urlInput = await ask(` API URL ${dim(`(${defaultUrl})`)}: `);
7694
+ const url = urlInput.trim() || defaultUrl;
7695
+
7696
+ const apiKey = await ask(' API Key: ');
7697
+ rl.close();
7698
+
7699
+ if (!apiKey.trim()) {
7700
+ console.error(`\n ${red('✘')} API key is required.`);
7701
+ process.exit(1);
7702
+ }
7703
+
7704
+ console.log(`\n Testing connection to ${dim(url)}...`);
7705
+ try {
7706
+ const res = await fetch(`${url.replace(/\/$/, '')}/api/vault/status`, {
7707
+ headers: { 'Authorization': `Bearer ${apiKey.trim()}`, 'Content-Type': 'application/json' },
7708
+ signal: AbortSignal.timeout(10000),
7709
+ });
7710
+ if (res.ok) {
7711
+ saveRemoteConfig({ enabled: true, url, apiKey: apiKey.trim() }, dataDir);
7712
+ console.log(` ${green('✓')} Connected successfully. Remote sync enabled.`);
7713
+ console.log(dim(` Config saved to ${join(dataDir, 'config.json')}`));
7714
+ } else {
7715
+ const text = await res.text().catch(() => '');
7716
+ console.error(` ${red('✘')} Connection failed: HTTP ${res.status}`);
7717
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7718
+ process.exit(1);
7719
+ }
7720
+ } catch (e) {
7721
+ console.error(` ${red('✘')} Connection failed: ${e.message}`);
7722
+ process.exit(1);
7723
+ }
7724
+ console.log();
7725
+ return;
7726
+ }
7727
+
7728
+ if (subcommand === 'status') {
7729
+ const remote = getRemoteConfig(dataDir);
7730
+ console.log();
7731
+ console.log(` ${bold('◇ Remote Status')}`);
7732
+ console.log();
7733
+ if (!remote) {
7734
+ console.log(` Remote: ${dim('not configured')}`);
7735
+ console.log(` ${dim('Run')} ${cyan('context-vault remote setup')} ${dim('to connect.')}`);
7736
+ } else {
7737
+ const keyPreview = remote.apiKey ? remote.apiKey.slice(0, 6) + '...' : dim('(none)');
7738
+ console.log(` Enabled: ${remote.enabled ? green('yes') : red('no')}`);
7739
+ console.log(` URL: ${remote.url}`);
7740
+ console.log(` API Key: ${keyPreview}`);
7741
+
7742
+ if (remote.enabled && remote.apiKey) {
7743
+ console.log(`\n Testing connection...`);
7744
+ try {
7745
+ const res = await fetch(`${remote.url.replace(/\/$/, '')}/api/vault/status`, {
7746
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7747
+ signal: AbortSignal.timeout(10000),
7748
+ });
7749
+ if (res.ok) {
7750
+ console.log(` ${green('✓')} Remote is reachable.`);
7751
+ } else {
7752
+ console.log(` ${red('✘')} HTTP ${res.status}`);
7753
+ }
7754
+ } catch (e) {
7755
+ console.log(` ${red('✘')} ${e.message}`);
7756
+ }
7757
+ }
7758
+ }
7759
+ console.log();
7760
+ return;
7761
+ }
7762
+
7763
+ if (subcommand === 'disconnect') {
7764
+ const remote = getRemoteConfig(dataDir);
7765
+ if (!remote || !remote.enabled) {
7766
+ console.log(`\n ${dim('Remote sync is already disabled.')}\n`);
7767
+ return;
7768
+ }
7769
+ saveRemoteConfig({ enabled: false }, dataDir);
7770
+ console.log(`\n ${green('✓')} Remote sync disabled. API key preserved (re-enable with ${cyan('context-vault remote setup')}).\n`);
7771
+ return;
7772
+ }
7773
+
7774
+ if (subcommand === 'sync') {
7775
+ await runRemoteSync(getRemoteConfig, dataDir);
7776
+ return;
7777
+ }
7778
+
7779
+ if (subcommand === 'pull') {
7780
+ await runRemotePull(getRemoteConfig, dataDir);
7781
+ return;
7782
+ }
7783
+
7784
+ console.log();
7785
+ console.log(` ${bold('◇ context-vault remote')}`);
7786
+ console.log();
7787
+ console.log(` ${cyan('setup')} Connect to a hosted vault API`);
7788
+ console.log(` ${cyan('status')} Show remote config and test connection`);
7789
+ console.log(` ${cyan('sync')} ${dim('[--full] [--dry-run]')} Sync local vault to hosted`);
7790
+ console.log(` ${cyan('pull')} Pull remote entries to local`);
7791
+ console.log(` ${cyan('disconnect')} Disable remote sync (preserves API key)`);
7792
+ console.log();
7793
+ }
7794
+
7795
+ // ---------------------------------------------------------------------------
7796
+ // remote sync — push local vault to hosted
7797
+ // ---------------------------------------------------------------------------
7798
+ async function runRemoteSync(getRemoteConfig, dataDir) {
7799
+ const remote = getRemoteConfig(dataDir);
7800
+ if (!remote || !remote.enabled || !remote.apiKey) {
7801
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
7802
+ process.exit(1);
7803
+ }
7804
+
7805
+ const isFullSync = flags.has('--full');
7806
+ const { createHash } = await import('node:crypto');
7807
+ const { resolveConfig } = await import('@context-vault/core/config');
7808
+ const config = resolveConfig();
7809
+
7810
+ let DatabaseSync;
7811
+ try {
7812
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
7813
+ } catch {
7814
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
7815
+ process.exit(1);
7816
+ }
7817
+
7818
+ const db = new DatabaseSync(config.dbPath, { open: true });
7819
+ const apiUrl = remote.url.replace(/\/$/, '');
7820
+
7821
+ function contentHash(entry) {
7822
+ const data = (entry.title || '') + (entry.body || '') + JSON.stringify(entry.tags || []) + JSON.stringify(entry.meta || {});
7823
+ return createHash('sha256').update(data).digest('hex');
7824
+ }
7825
+
7826
+ console.log();
7827
+ console.log(` ${bold('◇ Remote Sync')}`);
7828
+ console.log();
7829
+
7830
+ // Read all local entries
7831
+ const localRows = db.prepare(
7832
+ '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'
7833
+ ).all();
7834
+ db.close();
7835
+
7836
+ const localEntries = localRows.map((row) => ({
7837
+ ...row,
7838
+ tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : (row.tags || []),
7839
+ meta: typeof row.meta === 'string' ? JSON.parse(row.meta) : (row.meta || {}),
7840
+ }));
7841
+
7842
+ console.log(` Local entries: ${localEntries.length}`);
7843
+
7844
+ let toUpload;
7845
+
7846
+ if (isFullSync) {
7847
+ console.log(` Mode: ${cyan('full')} (uploading all entries)`);
7848
+ toUpload = localEntries;
7849
+ } else {
7850
+ // Fetch remote manifest
7851
+ console.log(` Fetching remote manifest...`);
7852
+ let manifest;
7853
+ try {
7854
+ const res = await fetch(`${apiUrl}/api/vault/manifest`, {
7855
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
7856
+ signal: AbortSignal.timeout(30000),
7857
+ });
7858
+ if (!res.ok) {
7859
+ const text = await res.text().catch(() => '');
7860
+ console.error(` ${red('✘')} Manifest fetch failed: HTTP ${res.status}`);
7861
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7862
+ process.exit(1);
7863
+ }
7864
+ manifest = await res.json();
7865
+ } catch (e) {
7866
+ console.error(` ${red('✘')} Manifest fetch failed: ${e.message}`);
7867
+ process.exit(1);
7868
+ }
7869
+
7870
+ const remoteEntries = manifest.entries || [];
7871
+ const remoteMap = new Map();
7872
+ for (const entry of remoteEntries) {
7873
+ remoteMap.set(entry.id, entry);
7874
+ }
7875
+
7876
+ // If remote manifest has content_hash, do a hash diff. Otherwise fall back to full sync.
7877
+ const hasContentHash = remoteEntries.length > 0 && remoteEntries[0].content_hash;
7878
+
7879
+ if (!hasContentHash && remoteEntries.length > 0) {
7880
+ console.log(` ${yellow('!')} Remote manifest lacks content_hash. Falling back to full upload.`);
7881
+ toUpload = localEntries;
7882
+ } else {
7883
+ toUpload = [];
7884
+ let unchanged = 0;
7885
+ for (const entry of localEntries) {
7886
+ const remoteEntry = remoteMap.get(entry.id);
7887
+ if (!remoteEntry) {
7888
+ toUpload.push(entry);
7889
+ } else if (remoteEntry.content_hash !== contentHash(entry)) {
7890
+ toUpload.push(entry);
7891
+ } else {
7892
+ unchanged++;
7893
+ }
7894
+ }
7895
+ console.log(` Unchanged: ${unchanged}`);
7896
+ }
7897
+ }
7898
+
7899
+ const newCount = toUpload.length;
7900
+ console.log(` To upload: ${newCount}`);
7901
+
7902
+ if (newCount === 0) {
7903
+ console.log(`\n ${green('✓')} Everything is in sync.\n`);
7904
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7905
+ return;
7906
+ }
7907
+
7908
+ if (isDryRun) {
7909
+ console.log();
7910
+ for (const entry of toUpload.slice(0, 20)) {
7911
+ console.log(` ${dim(entry.id)} ${entry.kind || '?'} ${entry.title || dim('(untitled)')}`);
7912
+ }
7913
+ if (toUpload.length > 20) {
7914
+ console.log(` ${dim(`... and ${toUpload.length - 20} more`)}`);
7915
+ }
7916
+ console.log(`\n ${dim('Dry run. No changes made.')}\n`);
7917
+ return;
7918
+ }
7919
+
7920
+ // Stream as NDJSON
7921
+ console.log(` Uploading...`);
7922
+ const ndjsonBody = toUpload.map((entry) => JSON.stringify(entry)).join('\n') + '\n';
7923
+
7924
+ let jobId, entriesUploaded;
7925
+ try {
7926
+ const res = await fetch(`${apiUrl}/api/vault/import/stream`, {
7927
+ method: 'POST',
7928
+ headers: {
7929
+ 'Authorization': `Bearer ${remote.apiKey}`,
7930
+ 'Content-Type': 'application/x-ndjson',
7931
+ },
7932
+ body: ndjsonBody,
7933
+ signal: AbortSignal.timeout(120000),
7934
+ });
7935
+ if (!res.ok) {
7936
+ const text = await res.text().catch(() => '');
7937
+ console.error(` ${red('✘')} Upload failed: HTTP ${res.status}`);
7938
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
7939
+ process.exit(1);
7940
+ }
7941
+ const data = await res.json();
7942
+ jobId = data.job_id;
7943
+ entriesUploaded = data.entries_uploaded || newCount;
7944
+ } catch (e) {
7945
+ console.error(` ${red('✘')} Upload failed: ${e.message}`);
7946
+ process.exit(1);
7947
+ }
7948
+
7949
+ console.log(` ${green('✓')} Uploaded ${entriesUploaded} entries.`);
7950
+
7951
+ if (!jobId) {
7952
+ console.log(`\n ${green('✓')} Sync complete (no job tracking available).\n`);
7953
+ await drainOfflineQueue(apiUrl, remote.apiKey);
7954
+ return;
7955
+ }
7956
+
7957
+ // Poll job status until embeddings complete
7958
+ console.log(` Job: ${dim(jobId)}`);
7959
+ console.log(` Waiting for embeddings...`);
7960
+
7961
+ let attempts = 0;
7962
+ const maxAttempts = 300; // 10 min max
7963
+ while (attempts < maxAttempts) {
7964
+ await new Promise((r) => setTimeout(r, 2000));
7965
+ attempts++;
7966
+ try {
7967
+ const res = await fetch(`${apiUrl}/api/vault/jobs/${jobId}`, {
7968
+ headers: { 'Authorization': `Bearer ${remote.apiKey}` },
7969
+ signal: AbortSignal.timeout(10000),
7970
+ });
7971
+ if (!res.ok) {
7972
+ if (res.status === 404) {
7973
+ console.log(` ${yellow('!')} Job not found (server may not support job tracking yet).`);
7974
+ break;
7975
+ }
7976
+ continue;
7977
+ }
7978
+ const job = await res.json();
7979
+ const embedded = job.entries_embedded || 0;
7980
+ const total = job.total_entries || entriesUploaded;
7981
+ const pct = total > 0 ? Math.round((embedded / total) * 100) : 0;
7982
+ process.stdout.write(`\r Embeddings: ${embedded}/${total} (${pct}%) `);
7983
+
7984
+ if (job.status === 'complete') {
7985
+ process.stdout.write('\n');
7986
+ console.log(` ${green('✓')} Embeddings complete.`);
7987
+ break;
7988
+ }
7989
+ if (job.status === 'failed') {
7990
+ process.stdout.write('\n');
7991
+ console.error(` ${red('✘')} Embedding job failed.`);
7992
+ if (job.errors) console.error(` ${dim(JSON.stringify(job.errors).slice(0, 300))}`);
7993
+ break;
7994
+ }
7995
+ } catch {
7996
+ // Transient error, keep polling
7997
+ }
7998
+ }
7999
+
8000
+ if (attempts >= maxAttempts) {
8001
+ console.log(`\n ${yellow('!')} Timed out waiting for embeddings. They will complete in the background.`);
8002
+ }
8003
+
8004
+ console.log(`\n ${green('✓')} Synced ${entriesUploaded} entries.\n`);
8005
+ await drainOfflineQueue(apiUrl, remote.apiKey);
8006
+ }
8007
+
8008
+ // ---------------------------------------------------------------------------
8009
+ // remote pull — fetch remote entries to local
8010
+ // ---------------------------------------------------------------------------
8011
+ async function runRemotePull(getRemoteConfig, dataDir) {
8012
+ const remote = getRemoteConfig(dataDir);
8013
+ if (!remote || !remote.enabled || !remote.apiKey) {
8014
+ console.error(`\n ${red('✘')} Remote is not configured. Run ${cyan('context-vault remote setup')} first.\n`);
8015
+ process.exit(1);
8016
+ }
8017
+
8018
+ const { resolveConfig } = await import('@context-vault/core/config');
8019
+ const config = resolveConfig();
8020
+
8021
+ let DatabaseSync;
8022
+ try {
8023
+ DatabaseSync = (await import('node:sqlite')).DatabaseSync;
8024
+ } catch {
8025
+ console.error(`\n ${red('✘')} Node.js SQLite not available. Requires Node >= 22.5.\n`);
8026
+ process.exit(1);
8027
+ }
8028
+
8029
+ const db = new DatabaseSync(config.dbPath, { open: true });
8030
+ const apiUrl = remote.url.replace(/\/$/, '');
8031
+
8032
+ console.log();
8033
+ console.log(` ${bold('◇ Remote Pull')}`);
8034
+ console.log();
8035
+
8036
+ // Fetch remote manifest
8037
+ console.log(` Fetching remote manifest...`);
8038
+ let manifest;
8039
+ try {
8040
+ const res = await fetch(`${apiUrl}/api/vault/manifest`, {
8041
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
8042
+ signal: AbortSignal.timeout(30000),
8043
+ });
8044
+ if (!res.ok) {
8045
+ const text = await res.text().catch(() => '');
8046
+ console.error(` ${red('✘')} Manifest fetch failed: HTTP ${res.status}`);
8047
+ if (text) console.error(` ${dim(text.slice(0, 200))}`);
8048
+ db.close();
8049
+ process.exit(1);
8050
+ }
8051
+ manifest = await res.json();
8052
+ } catch (e) {
8053
+ console.error(` ${red('✘')} Manifest fetch failed: ${e.message}`);
8054
+ db.close();
8055
+ process.exit(1);
8056
+ }
8057
+
8058
+ const remoteEntries = manifest.entries || [];
8059
+ console.log(` Remote entries: ${remoteEntries.length}`);
8060
+
8061
+ // Build local manifest
8062
+ const localRows = db.prepare('SELECT id, updated_at FROM vault').all();
8063
+ const localMap = new Map();
8064
+ for (const row of localRows) {
8065
+ localMap.set(row.id, row.updated_at);
8066
+ }
8067
+
8068
+ // Diff: remote entries not in local, or remote.updated_at > local.updated_at
8069
+ const toPull = [];
8070
+ for (const entry of remoteEntries) {
8071
+ const localUpdated = localMap.get(entry.id);
8072
+ if (!localUpdated) {
8073
+ toPull.push(entry.id);
8074
+ } else if (entry.updated_at && entry.updated_at > localUpdated) {
8075
+ toPull.push(entry.id);
8076
+ }
8077
+ }
8078
+
8079
+ console.log(` To pull: ${toPull.length}`);
8080
+
8081
+ if (toPull.length === 0) {
8082
+ console.log(`\n ${green('✓')} Local vault is up to date.\n`);
8083
+ db.close();
8084
+ await drainOfflineQueue(apiUrl, remote.apiKey);
8085
+ return;
8086
+ }
8087
+
8088
+ if (isDryRun) {
8089
+ for (const id of toPull.slice(0, 20)) {
8090
+ const remoteEntry = remoteEntries.find((e) => e.id === id);
8091
+ console.log(` ${dim(id)} ${remoteEntry?.kind || '?'} ${remoteEntry?.title || dim('(untitled)')}`);
8092
+ }
8093
+ if (toPull.length > 20) {
8094
+ console.log(` ${dim(`... and ${toPull.length - 20} more`)}`);
8095
+ }
8096
+ console.log(`\n ${dim('Dry run. No changes made.')}\n`);
8097
+ db.close();
8098
+ return;
8099
+ }
8100
+
8101
+ // Batch fetch in groups of 100
8102
+ let pulled = 0;
8103
+
8104
+ // Ensure vault table has the columns we need for INSERT OR REPLACE
8105
+ const insertStmt = db.prepare(`
8106
+ INSERT OR REPLACE INTO vault (id, kind, category, title, body, tags, meta, source, identity_key, tier, expires_at, created_at, updated_at)
8107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8108
+ `);
8109
+
8110
+ for (let i = 0; i < toPull.length; i += 100) {
8111
+ const batch = toPull.slice(i, i + 100);
8112
+ const idsParam = batch.join(',');
8113
+ try {
8114
+ const res = await fetch(`${apiUrl}/api/vault/entries?ids=${encodeURIComponent(idsParam)}`, {
8115
+ headers: { 'Authorization': `Bearer ${remote.apiKey}`, 'Content-Type': 'application/json' },
8116
+ signal: AbortSignal.timeout(30000),
8117
+ });
8118
+ if (!res.ok) {
8119
+ console.error(` ${red('✘')} Batch fetch failed: HTTP ${res.status}`);
8120
+ continue;
8121
+ }
8122
+ const data = await res.json();
8123
+ const entries = data.entries || data;
8124
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
8125
+ const tags = typeof entry.tags === 'string' ? entry.tags : JSON.stringify(entry.tags || []);
8126
+ const meta = typeof entry.meta === 'string' ? entry.meta : JSON.stringify(entry.meta || {});
8127
+ insertStmt.run(
8128
+ entry.id,
8129
+ entry.kind || null,
8130
+ entry.category || 'knowledge',
8131
+ entry.title || null,
8132
+ entry.body || '',
8133
+ tags,
8134
+ meta,
8135
+ entry.source || null,
8136
+ entry.identity_key || null,
8137
+ entry.tier || 'working',
8138
+ entry.expires_at || null,
8139
+ entry.created_at || new Date().toISOString(),
8140
+ entry.updated_at || new Date().toISOString()
8141
+ );
8142
+ pulled++;
8143
+ }
8144
+ } catch (e) {
8145
+ console.error(` ${yellow('!')} Batch fetch error: ${e.message}`);
8146
+ }
8147
+ process.stdout.write(`\r Progress: ${Math.min(i + 100, toPull.length)}/${toPull.length} `);
8148
+ }
8149
+
8150
+ db.close();
8151
+ process.stdout.write('\n');
8152
+ console.log(`\n ${green('✓')} Pulled ${pulled} entries from remote.\n`);
8153
+ await drainOfflineQueue(apiUrl, remote.apiKey);
8154
+ }
8155
+
8156
+ // ---------------------------------------------------------------------------
8157
+ // Offline queue drain — best-effort, runs after any successful remote call
8158
+ // ---------------------------------------------------------------------------
8159
+ async function drainOfflineQueue(apiUrl, apiKey) {
8160
+ const queuePath = join(HOME, '.context-mcp', 'sync-queue.jsonl');
8161
+ if (!existsSync(queuePath)) return;
8162
+
8163
+ let lines;
8164
+ try {
8165
+ const content = readFileSync(queuePath, 'utf-8').trim();
8166
+ if (!content) {
8167
+ unlinkSync(queuePath);
8168
+ return;
8169
+ }
8170
+ lines = content.split('\n').filter(Boolean);
8171
+ } catch {
8172
+ return;
8173
+ }
8174
+
8175
+ if (lines.length === 0) {
8176
+ try { unlinkSync(queuePath); } catch {}
8177
+ return;
8178
+ }
8179
+
8180
+ console.log(` ${dim(`Draining offline queue (${lines.length} entries)...`)}`);
8181
+ const remaining = [];
8182
+
8183
+ for (const line of lines) {
8184
+ try {
8185
+ const entry = JSON.parse(line);
8186
+ const res = await fetch(`${apiUrl}/api/vault/entries`, {
8187
+ method: 'POST',
8188
+ headers: {
8189
+ 'Authorization': `Bearer ${apiKey}`,
8190
+ 'Content-Type': 'application/json',
8191
+ },
8192
+ body: JSON.stringify(entry),
8193
+ signal: AbortSignal.timeout(10000),
8194
+ });
8195
+ if (!res.ok) {
8196
+ remaining.push(line);
8197
+ }
8198
+ } catch {
8199
+ remaining.push(line);
8200
+ }
8201
+ }
8202
+
8203
+ if (remaining.length > 0) {
8204
+ writeFileSync(queuePath, remaining.join('\n') + '\n');
8205
+ console.log(` ${yellow('!')} ${remaining.length} entries still queued (will retry next sync).`);
8206
+ } else {
8207
+ try { unlinkSync(queuePath); } catch {}
8208
+ console.log(` ${green('✓')} Offline queue drained.`);
8209
+ }
8210
+ }
8211
+
7043
8212
  async function main() {
7044
8213
  if (flags.has('--version') || command === 'version') {
7045
8214
  console.log(VERSION);
@@ -7047,8 +8216,13 @@ async function main() {
7047
8216
  }
7048
8217
 
7049
8218
  if (flags.has('--help') || command === 'help') {
7050
- showHelp(flags.has('--all'));
7051
- return;
8219
+ // Commands with their own --help handling: delegate to them
8220
+ const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'daemon', 'team', 'remote']);
8221
+ if (!command || command === 'help' || !commandsWithHelp.has(command)) {
8222
+ showHelp(flags.has('--all'));
8223
+ return;
8224
+ }
8225
+ // Fall through to command switch for commands with built-in --help
7052
8226
  }
7053
8227
 
7054
8228
  if (!command) {
@@ -7173,6 +8347,15 @@ async function main() {
7173
8347
  case 'stats':
7174
8348
  await runStats();
7175
8349
  break;
8350
+ case 'remote':
8351
+ await runRemote();
8352
+ break;
8353
+ case 'team':
8354
+ await runTeam();
8355
+ break;
8356
+ case 'public':
8357
+ await runPublic();
8358
+ break;
7176
8359
  default:
7177
8360
  console.error(red(`Unknown command: ${command}`));
7178
8361
  console.error(`Run ${cyan('context-vault --help')} for usage.`);