context-vault 3.16.0 → 3.17.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.
package/bin/cli.js CHANGED
@@ -443,7 +443,6 @@ ${bold('Commands:')}
443
443
  ${cyan('status')} Show vault diagnostics
444
444
  ${cyan('doctor')} Diagnose and repair common issues
445
445
  ${cyan('debug')} Generate AI-pasteable debug report
446
- ${cyan('daemon')} start|stop|status Run vault as a shared HTTP daemon (one process, all sessions)
447
446
  ${cyan('restart')} Stop running MCP server processes (client auto-restarts)
448
447
  ${cyan('reconnect')} Fix vault path, kill stale servers, re-register MCP, reindex
449
448
  ${cyan('search')} Search vault entries from CLI
@@ -452,6 +451,10 @@ ${bold('Commands:')}
452
451
  ${cyan('export')} Export vault entries (JSON, CSV, or portable ZIP)
453
452
  ${cyan('ingest')} <url> Fetch URL and save as vault entry
454
453
  ${cyan('ingest-project')} <path> Scan project directory and register as project entity
454
+ ${cyan('ingest-comms')} Ingest structured comms from stdin (JSON lines, with dedup)
455
+ ${cyan('gmail-bridge')} Fetch recent emails via gmail-cli and ingest into vault
456
+ ${cyan('slack-bridge')} Fetch Slack messages from allowed channels and ingest
457
+ ${cyan('contacts')} list|show|add Manage contact entities in the vault
455
458
  ${cyan('reindex')} Rebuild search index from knowledge files
456
459
  ${cyan('reclassify')} Move prompt-history entries from knowledge to event category
457
460
  ${cyan('sync')} [dir] Index .context/ files into vault DB (use --dry-run to preview)
@@ -459,6 +462,7 @@ ${bold('Commands:')}
459
462
  ${cyan('archive')} Archive old ephemeral/event entries (use --dry-run to preview)
460
463
  ${cyan('restore')} <id> Restore an archived entry back into the vault
461
464
  ${cyan('prune')} Remove expired entries (use --dry-run to preview)
465
+ ${cyan('stale')} List entries with low freshness scores
462
466
  ${cyan('stats')} recall|co-retrieval Measure recall ratio and co-retrieval graph
463
467
  ${cyan('remote')} setup|status|sync|pull Connect to hosted vault (cloud sync)
464
468
  ${cyan('team')} join|leave|status|browse Join or manage a team vault
@@ -3962,6 +3966,555 @@ async function runIngest() {
3962
3966
  console.log();
3963
3967
  }
3964
3968
 
3969
+ /**
3970
+ * Shared ingest helper: dedup-check, build entry, save to vault.
3971
+ * Returns 'saved' | 'skipped' | 'error'.
3972
+ */
3973
+ async function ingestCommLine(parsed, { ctx, db, captureAndIndex, dryRun, verbose, sourceFlag, kindFlag, extraTags }) {
3974
+ if (!parsed.id || !parsed.body) {
3975
+ if (verbose) console.log(` ${red('!')} Missing required field (id, body)`);
3976
+ return 'error';
3977
+ }
3978
+
3979
+ const source = parsed.source || sourceFlag;
3980
+ if (!source) {
3981
+ if (verbose) console.log(` ${red('!')} No source (use --source flag or include in JSON)`);
3982
+ return 'error';
3983
+ }
3984
+
3985
+ const identityKey = `comms:${source}:${parsed.id}`;
3986
+
3987
+ if (!dryRun) {
3988
+ const exists = db.prepare('SELECT 1 FROM vault WHERE identity_key = ? LIMIT 1').get(identityKey);
3989
+ if (exists) {
3990
+ if (verbose) console.log(` ${dim('=')} [${source}] Already exists (${parsed.id})`);
3991
+ return 'skipped';
3992
+ }
3993
+ }
3994
+
3995
+ const title = parsed.subject
3996
+ ? `[${source}] ${parsed.subject}`
3997
+ : parsed.from
3998
+ ? `[${source}] Message from ${parsed.from}`
3999
+ : `[${source}] ${parsed.id}`;
4000
+
4001
+ const lineTags = Array.isArray(parsed.tags) ? parsed.tags : [];
4002
+ const allTags = [...new Set([...lineTags, ...extraTags, `source:${source}`])];
4003
+
4004
+ const meta = {};
4005
+ if (parsed.from) meta.from = parsed.from;
4006
+ if (parsed.to) meta.to = parsed.to;
4007
+ if (parsed.date) meta.date = parsed.date;
4008
+ if (parsed.thread_id) meta.thread_id = parsed.thread_id;
4009
+ if (parsed.channel) meta.channel = parsed.channel;
4010
+
4011
+ if (dryRun) {
4012
+ if (verbose) console.log(` ${green('+')} [${source}] ${parsed.subject || parsed.id}`);
4013
+ return 'saved';
4014
+ }
4015
+
4016
+ try {
4017
+ await captureAndIndex(ctx, {
4018
+ kind: kindFlag,
4019
+ title,
4020
+ body: parsed.body,
4021
+ tags: allTags,
4022
+ source,
4023
+ identity_key: identityKey,
4024
+ meta: Object.keys(meta).length ? meta : null,
4025
+ });
4026
+ if (verbose) console.log(` ${green('+')} [${source}] ${parsed.subject || parsed.id} (${parsed.id})`);
4027
+ return 'saved';
4028
+ } catch (e) {
4029
+ if (verbose) console.log(` ${red('!')} [${source}] Error saving ${parsed.id}: ${e.message}`);
4030
+ return 'error';
4031
+ }
4032
+ }
4033
+
4034
+ async function runIngestComms() {
4035
+ if (flags.has('--help')) {
4036
+ console.log(`
4037
+ ${bold('context-vault ingest-comms')}
4038
+
4039
+ Read structured communications from stdin (JSON lines) and save to vault with dedup.
4040
+
4041
+ ${bold('Flags:')}
4042
+ --source <name> Source identifier (gmail, slack, etc.) — required if not in JSON
4043
+ --kind <kind> Vault entry kind (default: event)
4044
+ --tags t1,t2 Additional tags (merged with per-line tags)
4045
+ --dry-run Parse and validate without saving
4046
+ --verbose Print each entry as it's processed
4047
+
4048
+ ${bold('JSON line schema:')}
4049
+ { "id": "msg-123", "body": "text", "source": "gmail", "subject": "...", "from": "...",
4050
+ "to": [...], "date": "...", "thread_id": "...", "channel": "...", "tags": [...] }
4051
+ Required fields: id, body. source from JSON overrides --source flag.
4052
+
4053
+ ${bold('Examples:')}
4054
+ cat emails.jsonl | context-vault ingest-comms --source gmail
4055
+ echo '{"id":"1","body":"hi","source":"slack"}' | context-vault ingest-comms
4056
+ `);
4057
+ return;
4058
+ }
4059
+
4060
+ const sourceFlag = getFlag('--source');
4061
+ const kindFlag = getFlag('--kind') || 'event';
4062
+ const tagsFlag = getFlag('--tags');
4063
+ const extraTags = tagsFlag ? tagsFlag.split(',').map((t) => t.trim()) : [];
4064
+ const dryRun = flags.has('--dry-run');
4065
+ const verbose = flags.has('--verbose');
4066
+
4067
+ // Read all of stdin
4068
+ let input;
4069
+ if (!process.stdin.isTTY) {
4070
+ input = await new Promise((res) => {
4071
+ let data = '';
4072
+ process.stdin.on('data', (chunk) => (data += chunk));
4073
+ process.stdin.on('end', () => res(data));
4074
+ });
4075
+ } else {
4076
+ console.log(`\n ${bold('context-vault ingest-comms')}`);
4077
+ console.log(`\n Pipe JSON lines to stdin. Example:`);
4078
+ console.log(` cat emails.jsonl | context-vault ingest-comms --source gmail\n`);
4079
+ return;
4080
+ }
4081
+
4082
+ const lines = input.split('\n').filter((l) => l.trim());
4083
+ if (!lines.length) {
4084
+ console.log(`\n No input lines received.\n`);
4085
+ return;
4086
+ }
4087
+
4088
+ // Bootstrap DB (unless dry-run)
4089
+ let ctx, db;
4090
+ if (!dryRun) {
4091
+ const { resolveConfig } = await import('@context-vault/core/config');
4092
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
4093
+ await import('@context-vault/core/db');
4094
+ const { embed } = await import('@context-vault/core/embed');
4095
+
4096
+ const config = resolveConfig();
4097
+ if (!config.vaultDirExists) {
4098
+ console.error(red(`\n Vault directory not found: ${config.vaultDir}`));
4099
+ process.exit(1);
4100
+ }
4101
+
4102
+ db = await initDatabase(config.dbPath);
4103
+ const stmts = prepareStatements(db);
4104
+ ctx = {
4105
+ db,
4106
+ config,
4107
+ stmts,
4108
+ embed,
4109
+ insertVec: (r, e) => insertVec(stmts, r, e),
4110
+ deleteVec: (r) => deleteVec(stmts, r),
4111
+ };
4112
+ }
4113
+
4114
+ const { captureAndIndex } = dryRun ? {} : await import('@context-vault/core/capture');
4115
+
4116
+ let saved = 0;
4117
+ let skipped = 0;
4118
+ let errors = 0;
4119
+
4120
+ console.log(dim(`\n Processing stdin...`));
4121
+
4122
+ const opts = { ctx, db, captureAndIndex, dryRun, verbose, sourceFlag, kindFlag, extraTags };
4123
+
4124
+ for (let i = 0; i < lines.length; i++) {
4125
+ let parsed;
4126
+ try {
4127
+ parsed = JSON.parse(lines[i]);
4128
+ } catch {
4129
+ errors++;
4130
+ if (verbose) console.log(` ${red('!')} Invalid JSON on line ${i + 1}`);
4131
+ continue;
4132
+ }
4133
+
4134
+ const result = await ingestCommLine(parsed, opts);
4135
+ if (result === 'saved') saved++;
4136
+ else if (result === 'skipped') skipped++;
4137
+ else errors++;
4138
+ }
4139
+
4140
+ if (db) db.close();
4141
+
4142
+ // Summary
4143
+ if (dryRun) {
4144
+ console.log(` Dry run: ${saved} would be saved, ${skipped} duplicates, ${errors} errors\n`);
4145
+ } else {
4146
+ console.log(` ${green('✓')} ${saved} saved, ${skipped} skipped (duplicates), ${errors} errors\n`);
4147
+ }
4148
+
4149
+ const summaryParts = [];
4150
+ if (sourceFlag) summaryParts.push(`Source: ${sourceFlag}`);
4151
+ summaryParts.push(`Kind: ${kindFlag}`);
4152
+ if (extraTags.length) summaryParts.push(`Tags: ${extraTags.join(', ')}`);
4153
+ if (summaryParts.length) console.log(` ${dim(summaryParts.join(' | '))}\n`);
4154
+ }
4155
+
4156
+ async function runGmailBridge() {
4157
+ if (flags.has('--help')) {
4158
+ console.log(`
4159
+ ${bold('context-vault gmail-bridge')}
4160
+
4161
+ Fetch recent emails via gmail-cli and ingest into the vault with dedup.
4162
+
4163
+ ${bold('Flags:')}
4164
+ --account <name> Gmail account (default: personal)
4165
+ --max <n> Maximum messages to fetch (default: 50)
4166
+ --query <q> Gmail search query (default: newer_than:1d)
4167
+ --dry-run Show what would be ingested without saving
4168
+ --verbose Print each entry as it's processed
4169
+
4170
+ ${bold('Examples:')}
4171
+ context-vault gmail-bridge --dry-run --max 3
4172
+ context-vault gmail-bridge --account stormfors --max 10
4173
+ context-vault gmail-bridge --query "is:unread newer_than:3d"
4174
+ `);
4175
+ return;
4176
+ }
4177
+
4178
+ const account = getFlag('--account') || 'personal';
4179
+ const max = getFlag('--max') || '50';
4180
+ const query = getFlag('--query') || 'newer_than:1d';
4181
+ const dryRun = flags.has('--dry-run');
4182
+ const verbose = flags.has('--verbose');
4183
+
4184
+ const GMAIL_CLI_PATH = '/Users/admin/omni/workspaces/_archive/agent-tools/gmail-cli/cli.ts';
4185
+ const SKIP_LABELS = new Set([
4186
+ 'CATEGORY_PROMOTIONS',
4187
+ 'CATEGORY_SOCIAL',
4188
+ 'CATEGORY_UPDATES',
4189
+ 'CATEGORY_FORUMS',
4190
+ ]);
4191
+ const SKIP_FROM_PATTERNS = [/noreply/i, /newsletter/i, /marketing/i, /digest/i];
4192
+
4193
+ console.log(dim(`\n Fetching emails (account: ${account}, max: ${max}, query: ${query})...`));
4194
+
4195
+ let listOutput;
4196
+ try {
4197
+ listOutput = execFileSync('npx', ['tsx', GMAIL_CLI_PATH, 'list', '--account', account, '--max', max, '--query', query], {
4198
+ encoding: 'utf-8',
4199
+ stdio: ['pipe', 'pipe', 'pipe'],
4200
+ timeout: 60000,
4201
+ });
4202
+ } catch (e) {
4203
+ const stderr = e.stderr ? e.stderr.trim() : '';
4204
+ const msg = stderr || e.message;
4205
+ console.error(red(`\n Failed to list emails: ${msg}`));
4206
+ process.exit(1);
4207
+ }
4208
+
4209
+ let listResult;
4210
+ try {
4211
+ listResult = JSON.parse(listOutput);
4212
+ } catch {
4213
+ console.error(red(`\n Failed to parse gmail-cli output as JSON.`));
4214
+ if (verbose) console.error(dim(` Raw output: ${listOutput.slice(0, 500)}`));
4215
+ process.exit(1);
4216
+ }
4217
+
4218
+ if (!listResult.ok || !Array.isArray(listResult.messages)) {
4219
+ console.error(red(`\n gmail-cli returned an error or unexpected format.`));
4220
+ if (listResult.error) console.error(red(` ${listResult.error}`));
4221
+ process.exit(1);
4222
+ }
4223
+
4224
+ const allMessages = listResult.messages;
4225
+ const filtered = allMessages.filter((msg) => {
4226
+ const labels = Array.isArray(msg.labels) ? msg.labels : [];
4227
+ if (labels.some((l) => SKIP_LABELS.has(l))) return false;
4228
+ const from = msg.from || '';
4229
+ if (SKIP_FROM_PATTERNS.some((p) => p.test(from))) return false;
4230
+ return true;
4231
+ });
4232
+
4233
+ const skippedNewsletter = allMessages.length - filtered.length;
4234
+ if (skippedNewsletter > 0) {
4235
+ console.log(dim(` Filtered out ${skippedNewsletter} newsletter/promo messages`));
4236
+ }
4237
+
4238
+ if (!filtered.length) {
4239
+ console.log(`\n No messages to ingest after filtering.\n`);
4240
+ return;
4241
+ }
4242
+
4243
+ console.log(dim(` ${filtered.length} messages to process...`));
4244
+
4245
+ // Bootstrap DB (unless dry-run)
4246
+ let ctx, db, captureAndIndex;
4247
+ if (!dryRun) {
4248
+ const { resolveConfig } = await import('@context-vault/core/config');
4249
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
4250
+ await import('@context-vault/core/db');
4251
+ const { embed } = await import('@context-vault/core/embed');
4252
+ const captureMod = await import('@context-vault/core/capture');
4253
+ captureAndIndex = captureMod.captureAndIndex;
4254
+
4255
+ const config = resolveConfig();
4256
+ if (!config.vaultDirExists) {
4257
+ console.error(red(`\n Vault directory not found: ${config.vaultDir}`));
4258
+ process.exit(1);
4259
+ }
4260
+
4261
+ db = await initDatabase(config.dbPath);
4262
+ const stmts = prepareStatements(db);
4263
+ ctx = {
4264
+ db,
4265
+ config,
4266
+ stmts,
4267
+ embed,
4268
+ insertVec: (r, e) => insertVec(stmts, r, e),
4269
+ deleteVec: (r) => deleteVec(stmts, r),
4270
+ };
4271
+ }
4272
+
4273
+ const extraTags = ['bucket:comms', `account:${account}`];
4274
+ const opts = { ctx, db, captureAndIndex, dryRun, verbose, sourceFlag: 'gmail', kindFlag: 'event', extraTags };
4275
+
4276
+ let saved = 0;
4277
+ let skipped = 0;
4278
+ let errors = 0;
4279
+
4280
+ for (const msg of filtered) {
4281
+ // Fetch full body for each message
4282
+ let body = msg.snippet || '';
4283
+ if (!dryRun) {
4284
+ try {
4285
+ const getOutput = execFileSync('npx', ['tsx', GMAIL_CLI_PATH, 'get', msg.id, '--account', account], {
4286
+ encoding: 'utf-8',
4287
+ stdio: ['pipe', 'pipe', 'pipe'],
4288
+ timeout: 30000,
4289
+ });
4290
+ const fullMsg = JSON.parse(getOutput);
4291
+ if (fullMsg.body) body = fullMsg.body;
4292
+ } catch {
4293
+ if (verbose) console.log(` ${yellow('!')} Could not fetch body for ${msg.id}, using snippet`);
4294
+ }
4295
+ }
4296
+
4297
+ const parsed = {
4298
+ id: msg.id,
4299
+ source: 'gmail',
4300
+ from: msg.from || '',
4301
+ to: Array.isArray(msg.to) ? msg.to : [],
4302
+ subject: msg.subject || '',
4303
+ body,
4304
+ date: msg.date || '',
4305
+ thread_id: msg.threadId || '',
4306
+ tags: extraTags,
4307
+ };
4308
+
4309
+ const result = await ingestCommLine(parsed, opts);
4310
+ if (result === 'saved') saved++;
4311
+ else if (result === 'skipped') skipped++;
4312
+ else errors++;
4313
+ }
4314
+
4315
+ if (db) db.close();
4316
+
4317
+ if (dryRun) {
4318
+ console.log(`\n Dry run: ${saved} would be saved, ${skipped} duplicates, ${errors} errors\n`);
4319
+ } else {
4320
+ console.log(`\n ${green('✓')} ${saved} saved, ${skipped} skipped (duplicates), ${errors} errors\n`);
4321
+ }
4322
+ }
4323
+
4324
+ async function runSlackBridge() {
4325
+ const SLACK_CLI_PATH = '/Users/admin/omni/workspaces/_archive/agent-tools/slack-cli/cli.ts';
4326
+
4327
+ if (flags.has('--help') || (!flags.has('--list-channels') && !getFlag('--channels'))) {
4328
+ console.log(`
4329
+ ${bold('context-vault slack-bridge')}
4330
+
4331
+ Fetch recent Slack messages from allowed channels and ingest into the vault.
4332
+
4333
+ ${bold('Flags:')}
4334
+ --channels <ids> Comma-separated channel IDs (required, explicit allowlist)
4335
+ --limit <n> Messages per channel (default: 50)
4336
+ --dry-run Show what would be ingested without saving
4337
+ --verbose Print each entry as it's processed
4338
+ --list-channels List available channels and exit
4339
+
4340
+ ${bold('Examples:')}
4341
+ context-vault slack-bridge --list-channels
4342
+ context-vault slack-bridge --channels C123,C456 --dry-run
4343
+ context-vault slack-bridge --channels C123 --limit 10
4344
+ `);
4345
+ return;
4346
+ }
4347
+
4348
+ // --list-channels: show available channels and exit
4349
+ if (flags.has('--list-channels')) {
4350
+ console.log(dim('\n Fetching Slack channels...'));
4351
+ let raw;
4352
+ try {
4353
+ raw = execFileSync('npx', ['tsx', SLACK_CLI_PATH, 'channels', '--limit', '100'], {
4354
+ encoding: 'utf-8',
4355
+ stdio: ['pipe', 'pipe', 'pipe'],
4356
+ timeout: 60000,
4357
+ });
4358
+ } catch (e) {
4359
+ const stderr = e.stderr ? e.stderr.trim() : '';
4360
+ const msg = stderr || e.message;
4361
+ console.error(red(`\n Failed to list channels: ${msg}`));
4362
+ process.exit(1);
4363
+ }
4364
+
4365
+ let result;
4366
+ try {
4367
+ result = JSON.parse(raw);
4368
+ } catch {
4369
+ console.error(red('\n Failed to parse slack-cli output as JSON.'));
4370
+ process.exit(1);
4371
+ }
4372
+
4373
+ const channels = Array.isArray(result.channels) ? result.channels : Array.isArray(result) ? result : [];
4374
+ if (!channels.length) {
4375
+ console.log('\n No channels found.\n');
4376
+ return;
4377
+ }
4378
+
4379
+ console.log(`\n ${bold('Available channels:')}\n`);
4380
+ for (const ch of channels) {
4381
+ const id = ch.id || ch.channel_id || '?';
4382
+ const name = ch.name || ch.channel_name || '?';
4383
+ console.log(` ${cyan(id)} ${name}`);
4384
+ }
4385
+ console.log();
4386
+ return;
4387
+ }
4388
+
4389
+ // Parse flags
4390
+ const channelsRaw = getFlag('--channels');
4391
+ const channelIds = channelsRaw.split(',').map((c) => c.trim()).filter(Boolean);
4392
+ if (!channelIds.length) {
4393
+ console.error(red('\n --channels requires at least one channel ID.'));
4394
+ process.exit(1);
4395
+ }
4396
+
4397
+ const limit = getFlag('--limit') || '50';
4398
+ const dryRun = flags.has('--dry-run');
4399
+ const verbose = flags.has('--verbose');
4400
+
4401
+ // Bootstrap DB (unless dry-run)
4402
+ let ctx, db, captureAndIndex;
4403
+ if (!dryRun) {
4404
+ const { resolveConfig } = await import('@context-vault/core/config');
4405
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
4406
+ await import('@context-vault/core/db');
4407
+ const { embed } = await import('@context-vault/core/embed');
4408
+ const captureMod = await import('@context-vault/core/capture');
4409
+ captureAndIndex = captureMod.captureAndIndex;
4410
+
4411
+ const config = resolveConfig();
4412
+ if (!config.vaultDirExists) {
4413
+ console.error(red(`\n Vault directory not found: ${config.vaultDir}`));
4414
+ process.exit(1);
4415
+ }
4416
+
4417
+ db = await initDatabase(config.dbPath);
4418
+ const stmts = prepareStatements(db);
4419
+ ctx = {
4420
+ db,
4421
+ config,
4422
+ stmts,
4423
+ embed,
4424
+ insertVec: (r, e) => insertVec(stmts, r, e),
4425
+ deleteVec: (r) => deleteVec(stmts, r),
4426
+ };
4427
+ }
4428
+
4429
+ const extraTags = ['bucket:comms'];
4430
+ const opts = { ctx, db, captureAndIndex, dryRun, verbose, sourceFlag: 'slack', kindFlag: 'event', extraTags };
4431
+
4432
+ let totalSaved = 0;
4433
+ let totalSkipped = 0;
4434
+ let totalErrors = 0;
4435
+
4436
+ for (const channelId of channelIds) {
4437
+ console.log(dim(`\n Fetching messages from ${channelId} (limit: ${limit})...`));
4438
+
4439
+ let raw;
4440
+ try {
4441
+ raw = execFileSync('npx', ['tsx', SLACK_CLI_PATH, 'channel-history', channelId, '--limit', limit], {
4442
+ encoding: 'utf-8',
4443
+ stdio: ['pipe', 'pipe', 'pipe'],
4444
+ timeout: 60000,
4445
+ });
4446
+ } catch (e) {
4447
+ const stderr = e.stderr ? e.stderr.trim() : '';
4448
+ const msg = stderr || e.message;
4449
+ console.error(red(` Failed to fetch channel ${channelId}: ${msg}`));
4450
+ totalErrors++;
4451
+ continue;
4452
+ }
4453
+
4454
+ let result;
4455
+ try {
4456
+ result = JSON.parse(raw);
4457
+ } catch {
4458
+ console.error(red(` Failed to parse output for channel ${channelId}.`));
4459
+ totalErrors++;
4460
+ continue;
4461
+ }
4462
+
4463
+ if (!result.ok || !Array.isArray(result.messages)) {
4464
+ console.error(red(` slack-cli returned an error for channel ${channelId}.`));
4465
+ if (result.error) console.error(red(` ${result.error}`));
4466
+ totalErrors++;
4467
+ continue;
4468
+ }
4469
+
4470
+ // Filter out bot messages
4471
+ const messages = result.messages.filter((m) => !m.bot_id);
4472
+ const botCount = result.messages.length - messages.length;
4473
+ if (botCount > 0 && verbose) {
4474
+ console.log(dim(` Filtered out ${botCount} bot messages`));
4475
+ }
4476
+
4477
+ if (!messages.length) {
4478
+ console.log(dim(` No messages to ingest for ${channelId}.`));
4479
+ continue;
4480
+ }
4481
+
4482
+ console.log(dim(` ${messages.length} messages to process...`));
4483
+
4484
+ for (const msg of messages) {
4485
+ const ts = msg.ts || '';
4486
+ const epochSec = parseFloat(ts) || 0;
4487
+ const isoDate = epochSec ? new Date(epochSec * 1000).toISOString() : '';
4488
+
4489
+ const parsed = {
4490
+ id: `${channelId}:${ts}`,
4491
+ source: 'slack',
4492
+ from: msg.user || '',
4493
+ to: [],
4494
+ subject: null,
4495
+ body: msg.text || '',
4496
+ date: isoDate,
4497
+ thread_id: msg.thread_ts || null,
4498
+ channel: channelId,
4499
+ tags: [...extraTags, `channel:${channelId}`],
4500
+ };
4501
+
4502
+ const res = await ingestCommLine(parsed, opts);
4503
+ if (res === 'saved') totalSaved++;
4504
+ else if (res === 'skipped') totalSkipped++;
4505
+ else totalErrors++;
4506
+ }
4507
+ }
4508
+
4509
+ if (db) db.close();
4510
+
4511
+ if (dryRun) {
4512
+ console.log(`\n Dry run: ${totalSaved} would be saved, ${totalSkipped} duplicates, ${totalErrors} errors\n`);
4513
+ } else {
4514
+ console.log(`\n ${green('✓')} ${totalSaved} saved, ${totalSkipped} skipped (duplicates), ${totalErrors} errors\n`);
4515
+ }
4516
+ }
4517
+
3965
4518
  async function runIngestProject() {
3966
4519
  const rawPath = args[1];
3967
4520
  if (!rawPath) {
@@ -6399,123 +6952,13 @@ async function runHealth() {
6399
6952
  console.log(red(`context-vault health — FAILED`));
6400
6953
  }
6401
6954
 
6402
- for (const line of lines) {
6403
- console.log(line);
6404
- }
6405
-
6406
- console.log(` status: ${healthy ? green('healthy') : red('unhealthy')}`);
6407
-
6408
- if (!healthy) process.exit(1);
6409
- }
6410
-
6411
- async function runRestart() {
6412
- const force = flags.has('--force');
6413
-
6414
- console.log();
6415
- console.log(` ${bold('◇ context-vault restart')}`);
6416
- console.log();
6417
-
6418
- const isWin = platform() === 'win32';
6419
- let psOutput;
6420
- try {
6421
- const psCmd = isWin
6422
- ? 'wmic process where "CommandLine like \'%context-vault%\'" get ProcessId,CommandLine /format:list'
6423
- : 'ps aux';
6424
- psOutput = execSync(psCmd, { encoding: 'utf-8', timeout: 5000 });
6425
- } catch (e) {
6426
- console.error(red(` Failed to list processes: ${e.message}`));
6427
- process.exit(1);
6428
- }
6429
-
6430
- const currentPid = process.pid;
6431
- const serverPids = [];
6432
-
6433
- if (isWin) {
6434
- const pidMatches = psOutput.matchAll(/ProcessId=(\d+)/g);
6435
- for (const m of pidMatches) {
6436
- const pid = parseInt(m[1], 10);
6437
- if (pid !== currentPid) serverPids.push(pid);
6438
- }
6439
- } else {
6440
- const lines = psOutput.split('\n');
6441
- for (const line of lines) {
6442
- const match = line.match(/^\S+\s+(\d+)\s/);
6443
- if (!match) continue;
6444
- const pid = parseInt(match[1], 10);
6445
- if (pid === currentPid) continue;
6446
- if (
6447
- /context-vault.*(serve|stdio|server\/index)/.test(line) ||
6448
- /server\/index\.js.*context-vault/.test(line)
6449
- ) {
6450
- serverPids.push(pid);
6451
- }
6452
- }
6453
- }
6454
-
6455
- if (serverPids.length === 0) {
6456
- console.log(dim(' No running context-vault MCP server processes found.'));
6457
- console.log(dim(' The MCP client will start the server automatically on the next tool call.'));
6458
- console.log();
6459
- return;
6460
- }
6461
-
6462
- console.log(
6463
- ` Found ${serverPids.length} server process${serverPids.length === 1 ? '' : 'es'}: ${dim(serverPids.join(', '))}`
6464
- );
6465
- console.log();
6466
-
6467
- const signal = force ? 'SIGKILL' : 'SIGTERM';
6468
- const killed = [];
6469
- const failed = [];
6470
-
6471
- for (const pid of serverPids) {
6472
- try {
6473
- process.kill(pid, signal);
6474
- killed.push(pid);
6475
- console.log(` ${green('✓')} Sent ${signal} to PID ${pid}`);
6476
- } catch (e) {
6477
- if (e.code === 'ESRCH') {
6478
- console.log(` ${dim('-')} PID ${pid} already gone`);
6479
- } else {
6480
- failed.push(pid);
6481
- console.log(` ${red('✘')} Failed to signal PID ${pid}: ${e.message}`);
6482
- }
6483
- }
6484
- }
6485
-
6486
- if (!force && killed.length > 0) {
6487
- await new Promise((resolve) => setTimeout(resolve, 2000));
6488
-
6489
- for (const pid of killed) {
6490
- try {
6491
- process.kill(pid, 0);
6492
- console.log(` ${yellow('!')} PID ${pid} still running — sending SIGKILL`);
6493
- try {
6494
- process.kill(pid, 'SIGKILL');
6495
- } catch {}
6496
- } catch {
6497
- // process is gone — expected
6498
- }
6499
- }
6500
- }
6501
-
6502
- console.log();
6503
-
6504
- if (failed.length > 0) {
6505
- console.log(
6506
- red(
6507
- ` Could not stop ${failed.length} process${failed.length === 1 ? '' : 'es'}. Try --force.`
6508
- )
6509
- );
6510
- process.exit(1);
6511
- } else {
6512
- console.log(
6513
- green(' Server stopped.') +
6514
- dim(' The MCP client will restart it automatically on the next tool call.')
6515
- );
6955
+ for (const line of lines) {
6956
+ console.log(line);
6516
6957
  }
6517
6958
 
6518
- console.log();
6959
+ console.log(` status: ${healthy ? green('healthy') : red('unhealthy')}`);
6960
+
6961
+ if (!healthy) process.exit(1);
6519
6962
  }
6520
6963
 
6521
6964
  async function runReconnect() {
@@ -6857,323 +7300,6 @@ async function runDebug() {
6857
7300
  console.log(lines.join('\n'));
6858
7301
  }
6859
7302
 
6860
- async function runDaemon() {
6861
- const sub = args[1];
6862
- const pidPath = join(HOME, '.context-mcp', 'daemon.pid');
6863
- const defaultPort = 3377;
6864
-
6865
- function readPid() {
6866
- try {
6867
- return JSON.parse(readFileSync(pidPath, 'utf-8'));
6868
- } catch {
6869
- return null;
6870
- }
6871
- }
6872
-
6873
- function isAlive(pid) {
6874
- try {
6875
- process.kill(pid, 0);
6876
- return true;
6877
- } catch {
6878
- return false;
6879
- }
6880
- }
6881
-
6882
- async function pollHealth(port, timeoutMs = 5000) {
6883
- const start = Date.now();
6884
- while (Date.now() - start < timeoutMs) {
6885
- try {
6886
- const res = await fetch(`http://localhost:${port}/health`);
6887
- if (res.ok) return await res.json();
6888
- } catch {}
6889
- await new Promise((r) => setTimeout(r, 200));
6890
- }
6891
- return null;
6892
- }
6893
-
6894
- function configureClaudeDaemon(port) {
6895
- const env = { ...process.env };
6896
- delete env.CLAUDECODE;
6897
-
6898
- for (const oldName of ['context-mcp', 'context-vault']) {
6899
- try {
6900
- execFileSync('claude', ['mcp', 'remove', oldName, '-s', 'user'], {
6901
- stdio: 'pipe',
6902
- env,
6903
- });
6904
- } catch {}
6905
- }
6906
-
6907
- try {
6908
- execFileSync(
6909
- 'claude',
6910
- [
6911
- 'mcp', 'add', '-s', 'user',
6912
- '--transport', 'http',
6913
- 'context-vault',
6914
- `http://localhost:${port}/mcp`,
6915
- ],
6916
- { stdio: 'pipe', env }
6917
- );
6918
- } catch (e) {
6919
- const stderr = e.stderr?.toString().trim();
6920
- throw new Error(stderr || e.message);
6921
- }
6922
- }
6923
-
6924
- if (!sub || sub === '--help') {
6925
- console.log(`
6926
- ${bold('◇ context-vault daemon')} ${dim('— shared HTTP daemon')}
6927
-
6928
- ${bold('Subcommands:')}
6929
- ${cyan('start')} [--port PORT] Start the daemon (default port: ${defaultPort})
6930
- ${cyan('stop')} Stop the running daemon
6931
- ${cyan('status')} Show daemon status
6932
- ${cyan('install')} Start daemon + configure Claude Code to use it
6933
- ${cyan('uninstall')} Stop daemon + revert Claude Code to stdio mode
6934
- `);
6935
- return;
6936
- }
6937
-
6938
- if (sub === 'start') {
6939
- const port = parseInt(getFlag('--port') || String(defaultPort), 10);
6940
- const existing = readPid();
6941
-
6942
- if (existing && isAlive(existing.pid)) {
6943
- console.log(` ${green('✓')} Daemon already running (PID ${existing.pid} on port ${existing.port})`);
6944
- return;
6945
- }
6946
-
6947
- if (existing) {
6948
- try { unlinkSync(pidPath); } catch {}
6949
- }
6950
-
6951
- console.log(` Starting daemon on port ${port}...`);
6952
-
6953
- const vaultDir = getFlag('--vault-dir');
6954
- const serverArgs = [SERVER_PATH, '--http', '--port', String(port)];
6955
- if (vaultDir) serverArgs.push('--vault-dir', vaultDir);
6956
-
6957
- const child = spawn(process.execPath, serverArgs, {
6958
- detached: true,
6959
- stdio: 'ignore',
6960
- env: { ...process.env, NODE_OPTIONS: '--no-warnings=ExperimentalWarning' },
6961
- });
6962
- child.unref();
6963
-
6964
- const health = await pollHealth(port);
6965
- if (health) {
6966
- console.log(` ${green('✓')} Daemon started on http://localhost:${port}/mcp (PID ${health.pid})`);
6967
- } else {
6968
- console.error(red(` Failed to start daemon. Check error log: ~/.context-mcp/error.log`));
6969
- process.exit(1);
6970
- }
6971
-
6972
- } else if (sub === 'stop') {
6973
- const existing = readPid();
6974
- if (!existing) {
6975
- console.log(dim(' No daemon running.'));
6976
- return;
6977
- }
6978
-
6979
- if (!isAlive(existing.pid)) {
6980
- console.log(dim(' Stale PID file (process not running). Cleaning up.'));
6981
- try { unlinkSync(pidPath); } catch {}
6982
- return;
6983
- }
6984
-
6985
- console.log(` Stopping daemon (PID ${existing.pid})...`);
6986
- process.kill(existing.pid, 'SIGTERM');
6987
-
6988
- const deadline = Date.now() + 3000;
6989
- while (Date.now() < deadline && isAlive(existing.pid)) {
6990
- await new Promise((r) => setTimeout(r, 200));
6991
- }
6992
-
6993
- if (isAlive(existing.pid)) {
6994
- console.log(` ${yellow('!')} Still alive, sending SIGKILL...`);
6995
- try { process.kill(existing.pid, 'SIGKILL'); } catch {}
6996
- }
6997
-
6998
- try { unlinkSync(pidPath); } catch {}
6999
- console.log(` ${green('✓')} Daemon stopped.`);
7000
-
7001
- } else if (sub === 'status') {
7002
- const existing = readPid();
7003
- if (!existing) {
7004
- console.log(dim(' Not running.'));
7005
- return;
7006
- }
7007
-
7008
- if (!isAlive(existing.pid)) {
7009
- console.log(` ${yellow('!')} Stale PID file (PID ${existing.pid} not found).`);
7010
- return;
7011
- }
7012
-
7013
- try {
7014
- const res = await fetch(`http://localhost:${existing.port}/health`);
7015
- const health = await res.json();
7016
- const uptimeMin = Math.floor(health.uptime / 60);
7017
- console.log(
7018
- ` ${green('●')} Running (PID ${health.pid}, port ${existing.port}, v${health.version}, ` +
7019
- `${health.sessions} session${health.sessions === 1 ? '' : 's'}, uptime ${uptimeMin}m)`
7020
- );
7021
- } catch (e) {
7022
- console.log(` ${yellow('!')} Process alive (PID ${existing.pid}) but health check failed: ${e.message}`);
7023
- }
7024
-
7025
- } else if (sub === 'install') {
7026
- const port = parseInt(getFlag('--port') || String(defaultPort), 10);
7027
-
7028
- // 1. Install LaunchAgent on macOS for auto-start on login
7029
- if (platform() === 'darwin') {
7030
- const launchAgentDir = join(HOME, 'Library', 'LaunchAgents');
7031
- const plistPath = join(launchAgentDir, 'com.context-vault.daemon.plist');
7032
- const logPath = join(HOME, '.context-mcp', 'daemon.log');
7033
- const vaultDir = getFlag('--vault-dir');
7034
- const progArgs = [process.execPath, SERVER_PATH, '--http', '--port', String(port)];
7035
- if (vaultDir) progArgs.push('--vault-dir', vaultDir);
7036
-
7037
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
7038
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
7039
- <plist version="1.0">
7040
- <dict>
7041
- <key>Label</key>
7042
- <string>com.context-vault.daemon</string>
7043
- <key>ProgramArguments</key>
7044
- <array>
7045
- ${progArgs.map(a => ` <string>${a}</string>`).join('\n')}
7046
- </array>
7047
- <key>RunAtLoad</key>
7048
- <true/>
7049
- <key>KeepAlive</key>
7050
- <dict>
7051
- <key>SuccessfulExit</key>
7052
- <false/>
7053
- </dict>
7054
- <key>StandardErrorPath</key>
7055
- <string>${logPath}</string>
7056
- <key>StandardOutPath</key>
7057
- <string>/dev/null</string>
7058
- <key>EnvironmentVariables</key>
7059
- <dict>
7060
- <key>NODE_OPTIONS</key>
7061
- <string>--no-warnings=ExperimentalWarning</string>
7062
- <key>CONTEXT_VAULT_NO_DAEMON</key>
7063
- <string>1</string>
7064
- </dict>
7065
- <key>ThrottleInterval</key>
7066
- <integer>5</integer>
7067
- </dict>
7068
- </plist>`;
7069
-
7070
- mkdirSync(launchAgentDir, { recursive: true });
7071
-
7072
- // Unload existing agent if present
7073
- try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
7074
-
7075
- writeFileSync(plistPath, plist);
7076
- try {
7077
- execSync(`launchctl load -w "${plistPath}"`, { stdio: 'pipe' });
7078
- console.log(` ${green('✓')} LaunchAgent installed (auto-starts on login, restarts on crash)`);
7079
- } catch (e) {
7080
- console.log(` ${yellow('!')} LaunchAgent write succeeded but launchctl load failed: ${e.message}`);
7081
- }
7082
-
7083
- // Wait for launchd to start the daemon
7084
- const health = await pollHealth(port, 8000);
7085
- if (health) {
7086
- console.log(` ${green('✓')} Daemon running (PID ${health.pid})`);
7087
- } else {
7088
- console.error(red(` Daemon did not start. Check log: ${logPath}`));
7089
- process.exit(1);
7090
- }
7091
- } else {
7092
- // Non-macOS: direct spawn (no service manager integration yet)
7093
- const existing = readPid();
7094
- if (!existing || !isAlive(existing.pid)) {
7095
- if (existing) try { unlinkSync(pidPath); } catch {}
7096
-
7097
- console.log(` Starting daemon on port ${port}...`);
7098
- const vaultDir = getFlag('--vault-dir');
7099
- const serverArgs = [SERVER_PATH, '--http', '--port', String(port)];
7100
- if (vaultDir) serverArgs.push('--vault-dir', vaultDir);
7101
-
7102
- const child = spawn(process.execPath, serverArgs, {
7103
- detached: true,
7104
- stdio: 'ignore',
7105
- env: { ...process.env, NODE_OPTIONS: '--no-warnings=ExperimentalWarning' },
7106
- });
7107
- child.unref();
7108
-
7109
- const health = await pollHealth(port);
7110
- if (!health) {
7111
- console.error(red(` Failed to start daemon.`));
7112
- process.exit(1);
7113
- }
7114
- console.log(` ${green('✓')} Daemon started (PID ${health.pid})`);
7115
- } else {
7116
- console.log(` ${green('✓')} Daemon already running (PID ${existing.pid})`);
7117
- }
7118
- }
7119
-
7120
- // 2. Configure Claude Code for HTTP transport
7121
- console.log(` Configuring Claude Code to use HTTP transport...`);
7122
- try {
7123
- configureClaudeDaemon(port);
7124
- console.log(` ${green('✓')} Claude Code configured for http://localhost:${port}/mcp`);
7125
- console.log();
7126
- console.log(dim(' Restart any open Claude Code sessions for the change to take effect.'));
7127
- } catch (e) {
7128
- console.error(red(` Failed to configure Claude Code: ${e.message}`));
7129
- process.exit(1);
7130
- }
7131
-
7132
- } else if (sub === 'uninstall') {
7133
- // 1. Revert Claude Code to stdio
7134
- console.log(` Reverting Claude Code to stdio mode...`);
7135
- try {
7136
- const vaultDir = getFlag('--vault-dir') || join(HOME, '.vault');
7137
- const tool = { name: 'Claude Code', configPath: null };
7138
- await configureClaude(tool, vaultDir);
7139
- console.log(` ${green('✓')} Claude Code reverted to stdio`);
7140
- } catch (e) {
7141
- console.error(red(` Failed to reconfigure Claude Code: ${e.message}`));
7142
- }
7143
-
7144
- // 2. Remove LaunchAgent on macOS
7145
- if (platform() === 'darwin') {
7146
- const plistPath = join(HOME, 'Library', 'LaunchAgents', 'com.context-vault.daemon.plist');
7147
- if (existsSync(plistPath)) {
7148
- try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
7149
- try { unlinkSync(plistPath); } catch {}
7150
- console.log(` ${green('✓')} LaunchAgent removed`);
7151
- }
7152
- }
7153
-
7154
- // 3. Stop daemon if running
7155
- const existing = readPid();
7156
- if (existing && isAlive(existing.pid)) {
7157
- console.log(` Stopping daemon (PID ${existing.pid})...`);
7158
- process.kill(existing.pid, 'SIGTERM');
7159
- const deadline = Date.now() + 3000;
7160
- while (Date.now() < deadline && isAlive(existing.pid)) {
7161
- await new Promise((r) => setTimeout(r, 200));
7162
- }
7163
- if (isAlive(existing.pid)) {
7164
- try { process.kill(existing.pid, 'SIGKILL'); } catch {}
7165
- }
7166
- try { unlinkSync(pidPath); } catch {}
7167
- console.log(` ${green('✓')} Daemon stopped.`);
7168
- }
7169
-
7170
- } else {
7171
- console.error(red(` Unknown daemon subcommand: ${sub}`));
7172
- console.error(` Run ${cyan('context-vault daemon --help')} for usage.`);
7173
- process.exit(1);
7174
- }
7175
- }
7176
-
7177
7303
  async function runTier() {
7178
7304
  const { resolveConfig } = await import('@context-vault/core/config');
7179
7305
  const { initDatabase, prepareStatements, insertVec, deleteVec, insertCtxVec, deleteCtxVec } = await import('@context-vault/core/db');
@@ -8488,6 +8614,313 @@ async function drainOfflineQueue(apiUrl, apiKey) {
8488
8614
  }
8489
8615
  }
8490
8616
 
8617
+ async function runContacts() {
8618
+ const sub = args[1];
8619
+
8620
+ if (flags.has('--help') || !sub) {
8621
+ console.log(`
8622
+ ${bold('context-vault contacts')} <subcommand> [options]
8623
+
8624
+ Manage contact entities in the vault.
8625
+
8626
+ ${bold('Subcommands:')}
8627
+ ${cyan('list')} List all contacts
8628
+ ${cyan('show')} <identity_key> Show a specific contact by identity key
8629
+ ${cyan('add')} Add a new contact
8630
+
8631
+ ${bold('List options:')}
8632
+ --tags <a,b,c> Filter by tags (comma-separated)
8633
+ --format <fmt> Output format: plain (default), json
8634
+
8635
+ ${bold('Show options:')}
8636
+ --format <fmt> Output format: plain (default), json
8637
+
8638
+ ${bold('Add options:')}
8639
+ --name <name> Contact name (required)
8640
+ --key <identity_key> Identity key for lookup (required)
8641
+ --email <email> Email address
8642
+ --role <role> Role or title
8643
+ --tags <a,b,c> Additional tags (comma-separated)
8644
+ --notes <text> Free-form notes
8645
+
8646
+ ${bold('Examples:')}
8647
+ context-vault contacts list
8648
+ context-vault contacts list --tags "bucket:stormfors" --format json
8649
+ context-vault contacts show felix-hellstrom
8650
+ context-vault contacts add --name "Jane Doe" --key "jane-doe" --email "jane@example.com" --role "Engineer"
8651
+ `);
8652
+ return;
8653
+ }
8654
+
8655
+ const format = getFlag('--format') || 'plain';
8656
+
8657
+ let db;
8658
+ try {
8659
+ const { resolveConfig } = await import('@context-vault/core/config');
8660
+ const config = resolveConfig();
8661
+ if (!config.vaultDirExists) {
8662
+ console.error(red('Error: vault not initialised — run `context-vault setup` first'));
8663
+ process.exit(1);
8664
+ }
8665
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
8666
+ await import('@context-vault/core/db');
8667
+ db = await initDatabase(config.dbPath);
8668
+ const stmts = prepareStatements(db);
8669
+
8670
+ if (sub === 'list') {
8671
+ const tagsStr = getFlag('--tags');
8672
+ let rows = db.prepare(
8673
+ `SELECT id, title, identity_key, tags, body FROM vault WHERE kind = 'contact' AND superseded_by IS NULL ORDER BY title`
8674
+ ).all();
8675
+
8676
+ if (tagsStr) {
8677
+ const filterTags = tagsStr.split(',').map((t) => t.trim().toLowerCase());
8678
+ rows = rows.filter((r) => {
8679
+ const entryTags = r.tags ? JSON.parse(r.tags).map((t) => t.toLowerCase()) : [];
8680
+ return filterTags.some((ft) => entryTags.includes(ft));
8681
+ });
8682
+ }
8683
+
8684
+ if (rows.length === 0) {
8685
+ console.log(dim('No contacts found.'));
8686
+ return;
8687
+ }
8688
+
8689
+ if (format === 'json') {
8690
+ const output = rows.map((r) => ({
8691
+ id: r.id,
8692
+ title: r.title,
8693
+ identity_key: r.identity_key,
8694
+ tags: r.tags ? JSON.parse(r.tags) : [],
8695
+ body: r.body || '',
8696
+ }));
8697
+ console.log(JSON.stringify(output, null, 2));
8698
+ } else {
8699
+ const nameW = 30;
8700
+ const keyW = 25;
8701
+ console.log(` ${bold('Name'.padEnd(nameW))} ${bold('Key'.padEnd(keyW))} ${bold('Tags')}`);
8702
+ console.log(` ${'─'.repeat(nameW)} ${'─'.repeat(keyW)} ${'─'.repeat(30)}`);
8703
+ for (const r of rows) {
8704
+ const name = (r.title || '').slice(0, nameW).padEnd(nameW);
8705
+ const key = (r.identity_key || '').slice(0, keyW).padEnd(keyW);
8706
+ const tags = r.tags ? JSON.parse(r.tags).join(', ') : '';
8707
+ console.log(` ${name} ${dim(key)} ${dim(tags)}`);
8708
+ }
8709
+ console.log(dim(`\n ${rows.length} contact(s)`));
8710
+ }
8711
+
8712
+ } else if (sub === 'show') {
8713
+ const identityKey = args[2];
8714
+ if (!identityKey || identityKey.startsWith('--')) {
8715
+ console.error(red('Error: provide an identity_key, e.g. context-vault contacts show felix-hellstrom'));
8716
+ process.exit(1);
8717
+ }
8718
+ const row = stmts.getByIdentityKey.get('contact', identityKey);
8719
+ if (!row) {
8720
+ console.error(red(`Error: no contact found with identity_key "${identityKey}"`));
8721
+ process.exit(1);
8722
+ }
8723
+
8724
+ if (format === 'json') {
8725
+ console.log(JSON.stringify({
8726
+ id: row.id,
8727
+ title: row.title,
8728
+ identity_key: row.identity_key,
8729
+ tags: row.tags ? JSON.parse(row.tags) : [],
8730
+ body: row.body || '',
8731
+ created_at: row.created_at,
8732
+ updated_at: row.updated_at,
8733
+ }, null, 2));
8734
+ } else {
8735
+ console.log(`\n ${bold(row.title || identityKey)}`);
8736
+ console.log(` ${dim('Key:')} ${row.identity_key}`);
8737
+ const tags = row.tags ? JSON.parse(row.tags) : [];
8738
+ if (tags.length) console.log(` ${dim('Tags:')} ${tags.join(', ')}`);
8739
+ if (row.created_at) console.log(` ${dim('Created:')} ${row.created_at}`);
8740
+ if (row.updated_at) console.log(` ${dim('Updated:')} ${row.updated_at}`);
8741
+ console.log(`\n${row.body || dim('(no body)')}\n`);
8742
+ }
8743
+
8744
+ } else if (sub === 'add') {
8745
+ const name = getFlag('--name');
8746
+ const key = getFlag('--key');
8747
+ const email = getFlag('--email');
8748
+ const role = getFlag('--role');
8749
+ const tagsStr = getFlag('--tags');
8750
+ const notes = getFlag('--notes');
8751
+
8752
+ if (!name) {
8753
+ console.error(red('Error: --name is required'));
8754
+ process.exit(1);
8755
+ }
8756
+ if (!key) {
8757
+ console.error(red('Error: --key is required'));
8758
+ process.exit(1);
8759
+ }
8760
+
8761
+ const existing = stmts.getByIdentityKey.get('contact', key);
8762
+ if (existing) {
8763
+ console.error(red(`Error: a contact with identity_key "${key}" already exists (id: ${existing.id}).`));
8764
+ console.error(`Use ${cyan('context-vault save --kind contact --identity-key "' + key + '" --body "..."')} to update.`);
8765
+ process.exit(1);
8766
+ }
8767
+
8768
+ let body = `# ${name}\n`;
8769
+ if (email) body += `\n## Contact\n${email}\n`;
8770
+ if (role) body += `\n## Role\n${role}\n`;
8771
+ if (notes) body += `\n## Notes\n${notes}\n`;
8772
+
8773
+ const parsedTags = ['contact'];
8774
+ if (tagsStr) {
8775
+ for (const t of tagsStr.split(',').map((t) => t.trim()).filter(Boolean)) {
8776
+ if (!parsedTags.includes(t)) parsedTags.push(t);
8777
+ }
8778
+ }
8779
+
8780
+ const { embed } = await import('@context-vault/core/embed');
8781
+ const { captureAndIndex } = await import('@context-vault/core/capture');
8782
+ const ctx = {
8783
+ db,
8784
+ config,
8785
+ stmts,
8786
+ embed,
8787
+ insertVec: (rowid, embedding) => insertVec(stmts, rowid, embedding),
8788
+ deleteVec: (rowid) => deleteVec(stmts, rowid),
8789
+ };
8790
+ const entry = await captureAndIndex(ctx, {
8791
+ kind: 'contact',
8792
+ title: name,
8793
+ body: body.trim(),
8794
+ tags: parsedTags,
8795
+ source: 'cli',
8796
+ identity_key: key,
8797
+ });
8798
+ console.log(`${green('✓')} Contact saved — id: ${entry.id}, key: ${key}`);
8799
+
8800
+ } else {
8801
+ console.error(red(`Unknown contacts subcommand: ${sub}`));
8802
+ console.error(`Run ${cyan('context-vault contacts --help')} for usage.`);
8803
+ process.exit(1);
8804
+ }
8805
+ } catch (e) {
8806
+ console.error(`${red('x')} contacts ${sub} failed: ${e.message}`);
8807
+ process.exit(1);
8808
+ } finally {
8809
+ try { db?.close(); } catch {}
8810
+ }
8811
+ }
8812
+
8813
+ async function runStale() {
8814
+ if (flags.has('--help')) {
8815
+ console.log(`
8816
+ ${bold('context-vault stale')} [options]
8817
+
8818
+ List vault entries with low freshness scores (context that may need attention).
8819
+
8820
+ ${bold('Usage:')}
8821
+ context-vault stale # Entries scoring < 50 (stale + dormant)
8822
+ context-vault stale --threshold 25 # Only dormant entries
8823
+ context-vault stale --kind insight # Filter by kind
8824
+ context-vault stale --format json # Structured output
8825
+
8826
+ ${bold('Options:')}
8827
+ --threshold <n> Score threshold (default: 50, entries below this are shown)
8828
+ --kind <kind> Filter by kind
8829
+ --format <fmt> Output format: plain (default), json
8830
+ --limit <n> Max entries to show (default: 50)
8831
+ `);
8832
+ return;
8833
+ }
8834
+
8835
+ const threshold = parseInt(getFlag('--threshold') || '50', 10);
8836
+ const kind = getFlag('--kind');
8837
+ const format = getFlag('--format') || 'plain';
8838
+ const limit = parseInt(getFlag('--limit') || '50', 10);
8839
+
8840
+ const { resolveConfig } = await import('@context-vault/core/config');
8841
+ const { initDatabase } = await import('@context-vault/core/db');
8842
+ const { computeFreshnessScore } = await import('@context-vault/core/search');
8843
+
8844
+ const config = resolveConfig();
8845
+ if (!config.vaultDirExists) {
8846
+ console.error(red('No vault found. Run: context-vault setup'));
8847
+ process.exit(1);
8848
+ }
8849
+
8850
+ let db;
8851
+ try {
8852
+ db = await initDatabase(config.dbPath);
8853
+ } catch (e) {
8854
+ console.error(red(`Database error: ${e.message}`));
8855
+ process.exit(1);
8856
+ }
8857
+
8858
+ try {
8859
+ let sql = `SELECT * FROM vault WHERE superseded_by IS NULL AND (expires_at IS NULL OR expires_at > datetime('now'))`;
8860
+ const params = [];
8861
+ if (kind) {
8862
+ sql += ' AND kind = ?';
8863
+ params.push(kind);
8864
+ }
8865
+
8866
+ const rows = db.prepare(sql).all(...params);
8867
+
8868
+ const scored = rows.map((row) => {
8869
+ const { score, label } = computeFreshnessScore(row);
8870
+ return { ...row, freshness_score: score, freshness_label: label };
8871
+ });
8872
+
8873
+ const staleEntries = scored
8874
+ .filter((e) => e.freshness_score < threshold)
8875
+ .sort((a, b) => a.freshness_score - b.freshness_score)
8876
+ .slice(0, limit);
8877
+
8878
+ if (format === 'json') {
8879
+ const output = staleEntries.map((e) => ({
8880
+ id: e.id,
8881
+ kind: e.kind,
8882
+ title: e.title,
8883
+ freshness_score: e.freshness_score,
8884
+ freshness_label: e.freshness_label,
8885
+ created_at: e.created_at,
8886
+ updated_at: e.updated_at,
8887
+ last_accessed_at: e.last_accessed_at,
8888
+ recall_count: e.recall_count,
8889
+ recall_sessions: e.recall_sessions,
8890
+ }));
8891
+ console.log(JSON.stringify(output, null, 2));
8892
+ } else {
8893
+ console.log();
8894
+ console.log(` ${bold('Stale Entries')} ${dim(`(freshness < ${threshold})`)}`);
8895
+ console.log();
8896
+
8897
+ if (staleEntries.length === 0) {
8898
+ console.log(` ${dim('No entries below threshold.')}`);
8899
+ } else {
8900
+ const labelColor = (label) => {
8901
+ if (label === 'dormant') return red(label);
8902
+ if (label === 'stale') return yellow(label);
8903
+ return dim(label);
8904
+ };
8905
+
8906
+ for (const entry of staleEntries) {
8907
+ const title = entry.title || '(untitled)';
8908
+ const score = String(entry.freshness_score).padStart(3);
8909
+ console.log(` ${dim(score)} ${labelColor(entry.freshness_label).padEnd(18)} ${entry.kind.padEnd(12)} ${title}`);
8910
+ }
8911
+
8912
+ console.log();
8913
+ const dormantCount = staleEntries.filter((e) => e.freshness_label === 'dormant').length;
8914
+ const staleCount = staleEntries.filter((e) => e.freshness_label === 'stale').length;
8915
+ console.log(` ${dim('Total:')} ${staleEntries.length} entries (${dormantCount} dormant, ${staleCount} stale)`);
8916
+ }
8917
+ console.log();
8918
+ }
8919
+ } finally {
8920
+ db.close();
8921
+ }
8922
+ }
8923
+
8491
8924
  async function main() {
8492
8925
  if (flags.has('--version') || command === 'version') {
8493
8926
  console.log(VERSION);
@@ -8496,7 +8929,7 @@ async function main() {
8496
8929
 
8497
8930
  if (flags.has('--help') || command === 'help') {
8498
8931
  // Commands with their own --help handling: delegate to them
8499
- const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'daemon', 'team', 'remote']);
8932
+ const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'team', 'remote', 'ingest-comms', 'gmail-bridge', 'slack-bridge', 'contacts']);
8500
8933
  if (!command || command === 'help' || !commandsWithHelp.has(command)) {
8501
8934
  showHelp(flags.has('--all'));
8502
8935
  return;
@@ -8525,7 +8958,8 @@ async function main() {
8525
8958
  await runSwitch();
8526
8959
  break;
8527
8960
  case 'daemon':
8528
- await runDaemon();
8961
+ console.log('The daemon command was removed in v3.16.1. context-vault now runs in stdio mode only.');
8962
+ process.exit(0);
8529
8963
  break;
8530
8964
  case 'serve':
8531
8965
  await runServe();
@@ -8575,6 +9009,18 @@ async function main() {
8575
9009
  case 'ingest-project':
8576
9010
  await runIngestProject();
8577
9011
  break;
9012
+ case 'ingest-comms':
9013
+ await runIngestComms();
9014
+ break;
9015
+ case 'gmail-bridge':
9016
+ await runGmailBridge();
9017
+ break;
9018
+ case 'slack-bridge':
9019
+ await runSlackBridge();
9020
+ break;
9021
+ case 'contacts':
9022
+ await runContacts();
9023
+ break;
8578
9024
  case 'reindex':
8579
9025
  await runReindex();
8580
9026
  break;
@@ -8615,7 +9061,8 @@ async function main() {
8615
9061
  await runHealth();
8616
9062
  break;
8617
9063
  case 'restart':
8618
- await runRestart();
9064
+ console.log('The restart command was removed in v3.16.1. Use "context-vault reconnect" instead.');
9065
+ process.exit(0);
8619
9066
  break;
8620
9067
  case 'reconnect':
8621
9068
  await runReconnect();
@@ -8644,6 +9091,9 @@ async function main() {
8644
9091
  case 'compact':
8645
9092
  await runCompact();
8646
9093
  break;
9094
+ case 'stale':
9095
+ await runStale();
9096
+ break;
8647
9097
  default:
8648
9098
  console.error(red(`Unknown command: ${command}`));
8649
9099
  console.error(`Run ${cyan('context-vault --help')} for usage.`);