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 +877 -1
- package/dist/tools/context-status.d.ts.map +1 -1
- package/dist/tools/context-status.js +34 -0
- package/dist/tools/context-status.js.map +1 -1
- package/node_modules/@context-vault/core/dist/search.d.ts +13 -0
- package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/search.js +42 -0
- package/node_modules/@context-vault/core/dist/search.js.map +1 -1
- package/node_modules/@context-vault/core/dist/types.d.ts +2 -0
- package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
- package/node_modules/@context-vault/core/package.json +1 -1
- package/node_modules/@context-vault/core/src/search.ts +59 -0
- package/node_modules/@context-vault/core/src/types.ts +2 -0
- package/package.json +2 -2
- package/src/tools/context-status.ts +38 -0
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.`);
|