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