context-vault 3.16.1 → 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
@@ -451,6 +451,10 @@ ${bold('Commands:')}
451
451
  ${cyan('export')} Export vault entries (JSON, CSV, or portable ZIP)
452
452
  ${cyan('ingest')} <url> Fetch URL and save as vault entry
453
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
454
458
  ${cyan('reindex')} Rebuild search index from knowledge files
455
459
  ${cyan('reclassify')} Move prompt-history entries from knowledge to event category
456
460
  ${cyan('sync')} [dir] Index .context/ files into vault DB (use --dry-run to preview)
@@ -458,6 +462,7 @@ ${bold('Commands:')}
458
462
  ${cyan('archive')} Archive old ephemeral/event entries (use --dry-run to preview)
459
463
  ${cyan('restore')} <id> Restore an archived entry back into the vault
460
464
  ${cyan('prune')} Remove expired entries (use --dry-run to preview)
465
+ ${cyan('stale')} List entries with low freshness scores
461
466
  ${cyan('stats')} recall|co-retrieval Measure recall ratio and co-retrieval graph
462
467
  ${cyan('remote')} setup|status|sync|pull Connect to hosted vault (cloud sync)
463
468
  ${cyan('team')} join|leave|status|browse Join or manage a team vault
@@ -3961,6 +3966,555 @@ async function runIngest() {
3961
3966
  console.log();
3962
3967
  }
3963
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
+
3964
4518
  async function runIngestProject() {
3965
4519
  const rawPath = args[1];
3966
4520
  if (!rawPath) {
@@ -8060,6 +8614,313 @@ async function drainOfflineQueue(apiUrl, apiKey) {
8060
8614
  }
8061
8615
  }
8062
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
+
8063
8924
  async function main() {
8064
8925
  if (flags.has('--version') || command === 'version') {
8065
8926
  console.log(VERSION);
@@ -8068,7 +8929,7 @@ async function main() {
8068
8929
 
8069
8930
  if (flags.has('--help') || command === 'help') {
8070
8931
  // Commands with their own --help handling: delegate to them
8071
- const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'team', 'remote']);
8932
+ const commandsWithHelp = new Set(['save', 'search', 'rules', 'hooks', 'team', 'remote', 'ingest-comms', 'gmail-bridge', 'slack-bridge', 'contacts']);
8072
8933
  if (!command || command === 'help' || !commandsWithHelp.has(command)) {
8073
8934
  showHelp(flags.has('--all'));
8074
8935
  return;
@@ -8148,6 +9009,18 @@ async function main() {
8148
9009
  case 'ingest-project':
8149
9010
  await runIngestProject();
8150
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;
8151
9024
  case 'reindex':
8152
9025
  await runReindex();
8153
9026
  break;
@@ -8218,6 +9091,9 @@ async function main() {
8218
9091
  case 'compact':
8219
9092
  await runCompact();
8220
9093
  break;
9094
+ case 'stale':
9095
+ await runStale();
9096
+ break;
8221
9097
  default:
8222
9098
  console.error(red(`Unknown command: ${command}`));
8223
9099
  console.error(`Run ${cyan('context-vault --help')} for usage.`);