context-vault 3.18.0 → 3.20.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 +673 -4
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +0 -2
- package/dist/register-tools.js.map +1 -1
- package/dist/server.js +78 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/recall.d.ts +1 -1
- package/dist/tools/recall.d.ts.map +1 -1
- package/dist/tools/recall.js +50 -100
- package/dist/tools/recall.js.map +1 -1
- package/node_modules/@context-vault/core/dist/assemble.d.ts +22 -0
- package/node_modules/@context-vault/core/dist/assemble.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/assemble.js +143 -0
- package/node_modules/@context-vault/core/dist/assemble.js.map +1 -0
- package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/capture.js +10 -5
- package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
- package/node_modules/@context-vault/core/dist/consolidation.d.ts +40 -0
- package/node_modules/@context-vault/core/dist/consolidation.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/consolidation.js +229 -0
- package/node_modules/@context-vault/core/dist/consolidation.js.map +1 -0
- package/node_modules/@context-vault/core/dist/db.d.ts +25 -1
- package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/db.js +92 -4
- package/node_modules/@context-vault/core/dist/db.js.map +1 -1
- package/node_modules/@context-vault/core/dist/frontmatter.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/frontmatter.js +26 -3
- package/node_modules/@context-vault/core/dist/frontmatter.js.map +1 -1
- package/node_modules/@context-vault/core/dist/index.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/index.js +225 -184
- package/node_modules/@context-vault/core/dist/index.js.map +1 -1
- package/node_modules/@context-vault/core/dist/main.d.ts +3 -0
- package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/main.js +4 -0
- package/node_modules/@context-vault/core/dist/main.js.map +1 -1
- package/node_modules/@context-vault/core/dist/search.d.ts +6 -0
- package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/search.js +106 -5
- package/node_modules/@context-vault/core/dist/search.js.map +1 -1
- package/node_modules/@context-vault/core/dist/search.test.d.ts +2 -0
- package/node_modules/@context-vault/core/dist/search.test.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/search.test.js +49 -0
- package/node_modules/@context-vault/core/dist/search.test.js.map +1 -0
- package/node_modules/@context-vault/core/dist/summarize.d.ts +5 -0
- package/node_modules/@context-vault/core/dist/summarize.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/summarize.js +146 -0
- package/node_modules/@context-vault/core/dist/summarize.js.map +1 -0
- 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 +13 -1
- package/node_modules/@context-vault/core/src/assemble.ts +187 -0
- package/node_modules/@context-vault/core/src/capture.ts +10 -5
- package/node_modules/@context-vault/core/src/consolidation.ts +356 -0
- package/node_modules/@context-vault/core/src/db.ts +95 -4
- package/node_modules/@context-vault/core/src/frontmatter.ts +25 -4
- package/node_modules/@context-vault/core/src/index.ts +127 -88
- package/node_modules/@context-vault/core/src/main.ts +7 -0
- package/node_modules/@context-vault/core/src/search.test.ts +59 -0
- package/node_modules/@context-vault/core/src/search.ts +112 -5
- package/node_modules/@context-vault/core/src/summarize.ts +157 -0
- package/node_modules/@context-vault/core/src/types.ts +2 -0
- package/package.json +2 -2
- package/scripts/validate-epipe-shutdown.mjs +183 -0
- package/scripts/validate-sqlite-busy-retry.mjs +243 -0
- package/src/register-tools.ts +0 -2
- package/src/server.ts +76 -1
- package/src/tools/recall.ts +51 -110
- package/.claude-plugin/README.md +0 -219
- package/.claude-plugin/plugin.json +0 -11
- package/commands/vault-cleanup.md +0 -43
- package/commands/vault-snapshot.md +0 -43
- package/commands/vault-status.md +0 -35
- package/dist/tools/session-start.d.ts +0 -25
- package/dist/tools/session-start.d.ts.map +0 -1
- package/dist/tools/session-start.js +0 -469
- package/dist/tools/session-start.js.map +0 -1
- package/skills/context-assembly/SKILL.md +0 -308
- package/skills/knowledge-capture/SKILL.md +0 -303
- package/skills/memory-management/SKILL.md +0 -237
- package/src/tools/session-start.ts +0 -527
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const CONDENSED_CAP = 300;
|
|
2
|
+
const KEYPOINT_CAP = 150;
|
|
3
|
+
const SHORT_THRESHOLD = 150;
|
|
4
|
+
|
|
5
|
+
const ABBREVS = /(?:Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|i\.e|e\.g|approx|dept|est|inc|ltd|corp)\.\s*$/i;
|
|
6
|
+
|
|
7
|
+
function stripFrontmatter(text: string): string {
|
|
8
|
+
if (!text.startsWith('---')) return text;
|
|
9
|
+
const end = text.indexOf('\n---', 3);
|
|
10
|
+
if (end === -1) return text;
|
|
11
|
+
return text.slice(end + 4).trimStart();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function splitSentences(text: string): string[] {
|
|
15
|
+
const sentences: string[] = [];
|
|
16
|
+
let current = '';
|
|
17
|
+
|
|
18
|
+
const lines = text.split('\n');
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
if (current.trim()) {
|
|
24
|
+
sentences.push(current.trim());
|
|
25
|
+
current = '';
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Skip markdown headers
|
|
31
|
+
if (trimmed.startsWith('#')) continue;
|
|
32
|
+
// Skip code fences
|
|
33
|
+
if (trimmed.startsWith('```')) continue;
|
|
34
|
+
// Skip list markers for sentence splitting but keep content
|
|
35
|
+
const listContent = trimmed.replace(/^[-*+]\s+/, '').replace(/^\d+\.\s+/, '');
|
|
36
|
+
|
|
37
|
+
current += (current ? ' ' : '') + listContent;
|
|
38
|
+
|
|
39
|
+
// Try to split on sentence-ending punctuation
|
|
40
|
+
const parts = current.split(/(?<=[.!?])\s+/);
|
|
41
|
+
if (parts.length > 1) {
|
|
42
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
43
|
+
const part = parts[i].trim();
|
|
44
|
+
if (part && !ABBREVS.test(part)) {
|
|
45
|
+
sentences.push(part);
|
|
46
|
+
} else if (part) {
|
|
47
|
+
// Reattach abbreviated segment to next part
|
|
48
|
+
parts[i + 1] = part + ' ' + parts[i + 1];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
current = parts[parts.length - 1];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (current.trim()) {
|
|
56
|
+
sentences.push(current.trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return sentences.filter(s => s.length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstHeaderText(text: string): string | null {
|
|
63
|
+
const lines = text.split('\n');
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const match = line.match(/^#{1,6}\s+(.+)/);
|
|
66
|
+
if (match) return match[1].trim();
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function firstCodeComment(text: string): string | null {
|
|
72
|
+
const lines = text.split('\n');
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (trimmed.startsWith('//')) return trimmed.slice(2).trim();
|
|
76
|
+
if (trimmed.startsWith('#') && !trimmed.startsWith('##')) return trimmed.slice(1).trim();
|
|
77
|
+
if (trimmed.startsWith('/*')) {
|
|
78
|
+
const content = trimmed.replace(/^\/\*\s*/, '').replace(/\s*\*\/$/, '');
|
|
79
|
+
if (content) return content;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isCodeOnly(text: string): boolean {
|
|
86
|
+
const stripped = stripFrontmatter(text);
|
|
87
|
+
const lines = stripped.split('\n').filter(l => l.trim());
|
|
88
|
+
if (lines.length === 0) return false;
|
|
89
|
+
const codeLines = lines.filter(l => {
|
|
90
|
+
const t = l.trim();
|
|
91
|
+
return t.startsWith('```') || t.startsWith('//') || t.startsWith('/*') ||
|
|
92
|
+
t.startsWith('import ') || t.startsWith('export ') || t.startsWith('const ') ||
|
|
93
|
+
t.startsWith('let ') || t.startsWith('function ') || t.startsWith('class ') ||
|
|
94
|
+
t.startsWith('{') || t.startsWith('}') || t.startsWith('def ') ||
|
|
95
|
+
t.startsWith('return ');
|
|
96
|
+
});
|
|
97
|
+
return codeLines.length / lines.length > 0.7;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cap(text: string, limit: number): string {
|
|
101
|
+
if (text.length <= limit) return text;
|
|
102
|
+
const truncated = text.slice(0, limit - 3);
|
|
103
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
104
|
+
return (lastSpace > limit * 0.5 ? truncated.slice(0, lastSpace) : truncated) + '...';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function generateSummaryTiers(body: string): {
|
|
108
|
+
condensed: string;
|
|
109
|
+
keypoint: string;
|
|
110
|
+
} {
|
|
111
|
+
const cleaned = stripFrontmatter(body).trim();
|
|
112
|
+
|
|
113
|
+
if (!cleaned) {
|
|
114
|
+
return { condensed: '', keypoint: '' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Short entries: use body as both
|
|
118
|
+
if (cleaned.length < SHORT_THRESHOLD) {
|
|
119
|
+
return { condensed: cleaned, keypoint: cleaned };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Code-only entries
|
|
123
|
+
if (isCodeOnly(cleaned)) {
|
|
124
|
+
const comment = firstCodeComment(cleaned);
|
|
125
|
+
const firstLine = cleaned.split('\n').find(l => l.trim())?.trim() || '';
|
|
126
|
+
const label = comment || `Code block: ${firstLine.slice(0, 80)}`;
|
|
127
|
+
return {
|
|
128
|
+
condensed: cap(label, CONDENSED_CAP),
|
|
129
|
+
keypoint: cap(label, KEYPOINT_CAP),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sentences = splitSentences(cleaned);
|
|
134
|
+
|
|
135
|
+
// Keypoint: prefer first header, then first sentence
|
|
136
|
+
const header = firstHeaderText(cleaned);
|
|
137
|
+
const keypoint = header || sentences[0] || cleaned.slice(0, KEYPOINT_CAP);
|
|
138
|
+
|
|
139
|
+
// Condensed: first sentence + last sentence
|
|
140
|
+
let condensed: string;
|
|
141
|
+
if (sentences.length <= 1) {
|
|
142
|
+
condensed = sentences[0] || cleaned.slice(0, CONDENSED_CAP);
|
|
143
|
+
} else {
|
|
144
|
+
const first = sentences[0];
|
|
145
|
+
const last = sentences[sentences.length - 1];
|
|
146
|
+
if (first === last) {
|
|
147
|
+
condensed = first;
|
|
148
|
+
} else {
|
|
149
|
+
condensed = `${first} ${last}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
condensed: cap(condensed, CONDENSED_CAP),
|
|
155
|
+
keypoint: cap(keypoint, KEYPOINT_CAP),
|
|
156
|
+
};
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-vault",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
|
|
6
6
|
"bin": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"@context-vault/core"
|
|
68
68
|
],
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@context-vault/core": "^3.
|
|
70
|
+
"@context-vault/core": "^3.20.0",
|
|
71
71
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
72
72
|
"adm-zip": "^0.5.16",
|
|
73
73
|
"sqlite-vec": "^0.1.0"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test for mcp-epipe-graceful-shutdown.
|
|
3
|
+
//
|
|
4
|
+
// Spawns the built server, completes the MCP handshake, then simulates a
|
|
5
|
+
// client disconnect by destroying the parent's read of child.stdout and
|
|
6
|
+
// closing child.stdin. Asserts:
|
|
7
|
+
// (a) the child exits cleanly (code 0) within 3s
|
|
8
|
+
// (b) vault.db-wal is 0 bytes (WAL was checkpointed)
|
|
9
|
+
// (c) error.log has NO `write EPIPE` uncaughtException entry for this run
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
statSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join, resolve, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const SERVER_JS = resolve(__dirname, '..', 'dist', 'server.js');
|
|
25
|
+
const EXIT_DEADLINE_MS = 3000;
|
|
26
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
27
|
+
const RESPONSE_TIMEOUT_MS = 5000;
|
|
28
|
+
|
|
29
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), 'cv-epipe-'));
|
|
30
|
+
const dataDir = join(tmpRoot, 'data');
|
|
31
|
+
const vaultDir = join(tmpRoot, 'vault');
|
|
32
|
+
|
|
33
|
+
let child = null;
|
|
34
|
+
let pass = false;
|
|
35
|
+
let failMsg = '';
|
|
36
|
+
let stdoutBuf = '';
|
|
37
|
+
let stderrBuf = '';
|
|
38
|
+
|
|
39
|
+
function cleanup() {
|
|
40
|
+
if (child && child.exitCode === null) {
|
|
41
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
42
|
+
}
|
|
43
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitFor(predicate, timeoutMs, label) {
|
|
47
|
+
const start = Date.now();
|
|
48
|
+
while (Date.now() - start < timeoutMs) {
|
|
49
|
+
if (predicate()) return;
|
|
50
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`timeout: ${label}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractJsonResponses(buffer) {
|
|
56
|
+
const results = [];
|
|
57
|
+
for (const line of buffer.split('\n')) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed) continue;
|
|
60
|
+
try { results.push(JSON.parse(trimmed)); } catch {}
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (!existsSync(SERVER_JS)) {
|
|
67
|
+
throw new Error(`server bundle missing: ${SERVER_JS} (run npm run build first)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
child = spawn(process.execPath, [SERVER_JS], {
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
CONTEXT_VAULT_DATA_DIR: dataDir,
|
|
74
|
+
CONTEXT_VAULT_VAULT_DIR: vaultDir,
|
|
75
|
+
CONTEXT_VAULT_DIR: vaultDir,
|
|
76
|
+
CONTEXT_VAULT_TELEMETRY: '0',
|
|
77
|
+
},
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
child.stdout.on('data', (chunk) => { stdoutBuf += chunk.toString('utf-8'); });
|
|
82
|
+
child.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
83
|
+
|
|
84
|
+
const exitPromise = new Promise((resolveExit) => {
|
|
85
|
+
child.once('exit', (code, signal) => resolveExit({ code, signal }));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Wait for the server to finish startup logs before sending JSON-RPC.
|
|
89
|
+
await waitFor(() => /Database:/.test(stderrBuf), READY_TIMEOUT_MS, 'server startup');
|
|
90
|
+
|
|
91
|
+
function send(msg) {
|
|
92
|
+
child.stdin.write(JSON.stringify(msg) + '\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
send({
|
|
96
|
+
jsonrpc: '2.0',
|
|
97
|
+
id: 1,
|
|
98
|
+
method: 'initialize',
|
|
99
|
+
params: {
|
|
100
|
+
protocolVersion: '2024-11-05',
|
|
101
|
+
capabilities: {},
|
|
102
|
+
clientInfo: { name: 'epipe-validator', version: '1.0.0' },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await waitFor(
|
|
107
|
+
() => extractJsonResponses(stdoutBuf).some((r) => r.id === 1),
|
|
108
|
+
RESPONSE_TIMEOUT_MS,
|
|
109
|
+
'initialize response',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
113
|
+
|
|
114
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
115
|
+
await waitFor(
|
|
116
|
+
() => extractJsonResponses(stdoutBuf).some((r) => r.id === 2),
|
|
117
|
+
RESPONSE_TIMEOUT_MS,
|
|
118
|
+
'tools/list response',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Simulate client disconnect: destroy the parent's read of child.stdout so
|
|
122
|
+
// the next write from the server raises EPIPE, then send another request
|
|
123
|
+
// to force the server to attempt a write.
|
|
124
|
+
child.stdout.destroy();
|
|
125
|
+
send({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
126
|
+
// Close stdin last so the server detects the full disconnect.
|
|
127
|
+
try { child.stdin.end(); } catch {}
|
|
128
|
+
|
|
129
|
+
const result = await Promise.race([
|
|
130
|
+
exitPromise,
|
|
131
|
+
new Promise((resolveTimeout) =>
|
|
132
|
+
setTimeout(() => resolveTimeout({ code: null, signal: 'deadline' }), EXIT_DEADLINE_MS),
|
|
133
|
+
),
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
if (result.signal === 'deadline') {
|
|
137
|
+
throw new Error(`server did not exit within ${EXIT_DEADLINE_MS}ms after disconnect`);
|
|
138
|
+
}
|
|
139
|
+
if (result.code !== 0) {
|
|
140
|
+
throw new Error(`unclean exit: code=${result.code} signal=${result.signal}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const walPath = join(dataDir, 'vault.db-wal');
|
|
144
|
+
if (existsSync(walPath)) {
|
|
145
|
+
const size = statSync(walPath).size;
|
|
146
|
+
if (size !== 0) {
|
|
147
|
+
throw new Error(`WAL not checkpointed: ${walPath} is ${size} bytes`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const errorLogPath = join(dataDir, 'error.log');
|
|
152
|
+
if (existsSync(errorLogPath)) {
|
|
153
|
+
const content = readFileSync(errorLogPath, 'utf-8');
|
|
154
|
+
for (const line of content.split('\n')) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
if (!trimmed) continue;
|
|
157
|
+
let entry;
|
|
158
|
+
try { entry = JSON.parse(trimmed); } catch { continue; }
|
|
159
|
+
const isUncaught = entry.error_type === 'uncaughtException';
|
|
160
|
+
const mentionsEpipe =
|
|
161
|
+
typeof entry.message === 'string' && /\bEPIPE\b/i.test(entry.message);
|
|
162
|
+
if (isUncaught && mentionsEpipe) {
|
|
163
|
+
throw new Error(`error.log contains write EPIPE uncaughtException: ${trimmed}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pass = true;
|
|
169
|
+
console.log('PASS: EPIPE graceful shutdown (exit 0, WAL checkpointed, no uncaught EPIPE)');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
failMsg = err && err.message ? err.message : String(err);
|
|
172
|
+
console.error(`FAIL: ${failMsg}`);
|
|
173
|
+
if (stderrBuf) {
|
|
174
|
+
const tail = stderrBuf.split('\n').slice(-15).join('\n');
|
|
175
|
+
if (tail.trim()) {
|
|
176
|
+
console.error('--- server stderr tail ---');
|
|
177
|
+
console.error(tail);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
cleanup();
|
|
182
|
+
process.exit(pass ? 0 : 1);
|
|
183
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test for mcp-sqlite-busy-retry.
|
|
3
|
+
//
|
|
4
|
+
// Spawns the built MCP server, completes the MCP handshake, then simulates
|
|
5
|
+
// a competing writer by holding a BEGIN IMMEDIATE transaction on the same
|
|
6
|
+
// vault.db for ~200ms. During that window, fires a save_context tool call
|
|
7
|
+
// and asserts the server retries through SQLITE_BUSY and succeeds — not
|
|
8
|
+
// returning INTERNAL_ERROR.
|
|
9
|
+
//
|
|
10
|
+
// Pass criteria:
|
|
11
|
+
// (a) Single scenario: tool call succeeds while lock is held (returns a
|
|
12
|
+
// JSON-RPC result with no isError flag).
|
|
13
|
+
// (b) Stability: 5 independent scenarios must succeed in at least 4.
|
|
14
|
+
//
|
|
15
|
+
// Exit 0 on pass, non-zero on fail.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join, resolve, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const SERVER_JS = resolve(__dirname, '..', 'dist', 'server.js');
|
|
31
|
+
|
|
32
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
33
|
+
const RESPONSE_TIMEOUT_MS = 15_000;
|
|
34
|
+
const LOCK_HOLD_MS = 200;
|
|
35
|
+
const SCENARIOS = 5;
|
|
36
|
+
const MIN_PASSES = 4;
|
|
37
|
+
|
|
38
|
+
if (!existsSync(SERVER_JS)) {
|
|
39
|
+
console.error(`FAIL: server bundle missing at ${SERVER_JS}. Run: cd mcp && npm run build`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractJsonResponses(buffer) {
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const line of buffer.split('\n')) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed) continue;
|
|
48
|
+
try { results.push(JSON.parse(trimmed)); } catch {}
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitFor(predicate, timeoutMs, label) {
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
while (Date.now() - start < timeoutMs) {
|
|
56
|
+
if (predicate()) return;
|
|
57
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`timeout: ${label}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runScenario(scenarioId) {
|
|
63
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), `cv-busy-${scenarioId}-`));
|
|
64
|
+
const dataDir = join(tmpRoot, 'data');
|
|
65
|
+
const vaultDir = join(tmpRoot, 'vault');
|
|
66
|
+
mkdirSync(dataDir, { recursive: true });
|
|
67
|
+
mkdirSync(vaultDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
let child = null;
|
|
70
|
+
let stdoutBuf = '';
|
|
71
|
+
let stderrBuf = '';
|
|
72
|
+
let holder = null;
|
|
73
|
+
let holderTxOpen = false;
|
|
74
|
+
|
|
75
|
+
const cleanup = () => {
|
|
76
|
+
if (holder) {
|
|
77
|
+
try {
|
|
78
|
+
if (holderTxOpen) {
|
|
79
|
+
try { holder.exec('ROLLBACK'); } catch {}
|
|
80
|
+
}
|
|
81
|
+
holder.close();
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
if (child && child.exitCode === null) {
|
|
85
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
86
|
+
}
|
|
87
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
child = spawn(process.execPath, [SERVER_JS], {
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
CONTEXT_VAULT_DATA_DIR: dataDir,
|
|
95
|
+
CONTEXT_VAULT_VAULT_DIR: vaultDir,
|
|
96
|
+
CONTEXT_VAULT_DIR: vaultDir,
|
|
97
|
+
CONTEXT_VAULT_TELEMETRY: '0',
|
|
98
|
+
},
|
|
99
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.stdout.on('data', (chunk) => { stdoutBuf += chunk.toString('utf-8'); });
|
|
103
|
+
child.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
104
|
+
|
|
105
|
+
// Wait for the server to finish startup (DB initialized)
|
|
106
|
+
await waitFor(() => /Database:/.test(stderrBuf), READY_TIMEOUT_MS, 'server startup');
|
|
107
|
+
|
|
108
|
+
const send = (msg) => { child.stdin.write(JSON.stringify(msg) + '\n'); };
|
|
109
|
+
|
|
110
|
+
send({
|
|
111
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
112
|
+
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'busy-validator', version: '1.0' } },
|
|
113
|
+
});
|
|
114
|
+
await waitFor(() => extractJsonResponses(stdoutBuf).some((r) => r.id === 1),
|
|
115
|
+
RESPONSE_TIMEOUT_MS, 'initialize response');
|
|
116
|
+
|
|
117
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
118
|
+
|
|
119
|
+
// Server has opened vault.db. Now we open a second connection to the same
|
|
120
|
+
// file and grab an immediate write lock. WAL keeps readers unblocked but
|
|
121
|
+
// a BEGIN IMMEDIATE holds the reserved lock — any writer (including the
|
|
122
|
+
// server) will see SQLITE_BUSY until we release.
|
|
123
|
+
const dbPath = join(dataDir, 'vault.db');
|
|
124
|
+
if (!existsSync(dbPath)) throw new Error(`vault.db not created at ${dbPath}`);
|
|
125
|
+
|
|
126
|
+
holder = new DatabaseSync(dbPath);
|
|
127
|
+
holder.exec('PRAGMA journal_mode = WAL');
|
|
128
|
+
// Do NOT set a long busy_timeout on the holder — we want it to release
|
|
129
|
+
// on demand, not wait.
|
|
130
|
+
holder.exec('BEGIN IMMEDIATE');
|
|
131
|
+
holderTxOpen = true;
|
|
132
|
+
|
|
133
|
+
// Schedule lock release after LOCK_HOLD_MS. The server's retry wrapper
|
|
134
|
+
// (50/150/500ms backoff) plus the PRAGMA busy_timeout=5000ms both give
|
|
135
|
+
// it plenty of time to retry successfully once we release.
|
|
136
|
+
const releaseTimer = setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
if (holderTxOpen) {
|
|
139
|
+
holder.exec('COMMIT');
|
|
140
|
+
holderTxOpen = false;
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// If commit fails because the writer won a race, that's fine — our
|
|
144
|
+
// purpose (holding for LOCK_HOLD_MS) is already done.
|
|
145
|
+
}
|
|
146
|
+
}, LOCK_HOLD_MS);
|
|
147
|
+
|
|
148
|
+
const callId = 100 + scenarioId;
|
|
149
|
+
send({
|
|
150
|
+
jsonrpc: '2.0', id: callId, method: 'tools/call',
|
|
151
|
+
params: {
|
|
152
|
+
name: 'save_context',
|
|
153
|
+
arguments: {
|
|
154
|
+
kind: 'event',
|
|
155
|
+
body: `busy-retry validation scenario ${scenarioId} at ${new Date().toISOString()}`,
|
|
156
|
+
tags: ['bucket:validation', 'busy-retry-test'],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await waitFor(() => extractJsonResponses(stdoutBuf).some((r) => r.id === callId),
|
|
162
|
+
RESPONSE_TIMEOUT_MS, `tools/call response (scenario ${scenarioId})`);
|
|
163
|
+
clearTimeout(releaseTimer);
|
|
164
|
+
|
|
165
|
+
// Ensure lock is released before asserting (in case timer didn't fire)
|
|
166
|
+
if (holderTxOpen) {
|
|
167
|
+
try { holder.exec('COMMIT'); holderTxOpen = false; } catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const responses = extractJsonResponses(stdoutBuf).filter((r) => r.id === callId);
|
|
171
|
+
if (responses.length === 0) throw new Error('no response received');
|
|
172
|
+
const response = responses[0];
|
|
173
|
+
|
|
174
|
+
if (response.error) {
|
|
175
|
+
throw new Error(`JSON-RPC error: ${JSON.stringify(response.error)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = response.result;
|
|
179
|
+
if (!result) throw new Error(`empty result: ${JSON.stringify(response)}`);
|
|
180
|
+
|
|
181
|
+
// The MCP tool-result shape: { content: [...], isError?: boolean }.
|
|
182
|
+
// INTERNAL_ERROR surfaces as isError: true plus error text.
|
|
183
|
+
if (result.isError) {
|
|
184
|
+
const text = (result.content || []).map((c) => c.text || '').join(' ');
|
|
185
|
+
throw new Error(`tool returned isError: ${text.slice(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { scenarioId, ok: true };
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = err && err.message ? err.message : String(err);
|
|
191
|
+
const stderrTail = stderrBuf.split('\n').slice(-10).join('\n');
|
|
192
|
+
return { scenarioId, ok: false, error: msg, stderrTail };
|
|
193
|
+
} finally {
|
|
194
|
+
cleanup();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function main() {
|
|
199
|
+
console.log(`[busy-retry] Running ${SCENARIOS} scenarios (each holds DB lock for ${LOCK_HOLD_MS}ms during save_context)`);
|
|
200
|
+
|
|
201
|
+
const results = [];
|
|
202
|
+
// Run sequentially — each scenario has its own vault dir, but running in
|
|
203
|
+
// parallel would conflate lock contention signals across scenarios.
|
|
204
|
+
for (let i = 1; i <= SCENARIOS; i++) {
|
|
205
|
+
const r = await runScenario(i);
|
|
206
|
+
results.push(r);
|
|
207
|
+
if (r.ok) {
|
|
208
|
+
console.log(` scenario ${i}: PASS`);
|
|
209
|
+
} else {
|
|
210
|
+
console.log(` scenario ${i}: FAIL — ${r.error}`);
|
|
211
|
+
if (r.stderrTail && r.stderrTail.trim()) {
|
|
212
|
+
console.log(` stderr tail:\n${r.stderrTail.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const passed = results.filter((r) => r.ok).length;
|
|
218
|
+
const firstPassed = results[0].ok;
|
|
219
|
+
|
|
220
|
+
console.log(`\n[busy-retry] ${passed}/${SCENARIOS} scenarios passed`);
|
|
221
|
+
|
|
222
|
+
// Assertion (a): first scenario must pass (single-shot proof that a held
|
|
223
|
+
// lock does not surface as INTERNAL_ERROR).
|
|
224
|
+
if (!firstPassed) {
|
|
225
|
+
console.error('FAIL: scenario 1 did not succeed — retry path is broken');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Assertion (b): stability — at least MIN_PASSES of SCENARIOS must succeed.
|
|
230
|
+
if (passed < MIN_PASSES) {
|
|
231
|
+
console.error(`FAIL: only ${passed}/${SCENARIOS} succeeded (required ${MIN_PASSES})`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log('PASS: SQLITE_BUSY retry wrapper absorbs transient contention');
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main().catch((err) => {
|
|
240
|
+
console.error('FAIL: unhandled error');
|
|
241
|
+
console.error(err);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
});
|
package/src/register-tools.ts
CHANGED
|
@@ -18,7 +18,6 @@ import * as ingestUrl from './tools/ingest-url.js';
|
|
|
18
18
|
import * as contextStatus from './tools/context-status.js';
|
|
19
19
|
import * as clearContext from './tools/clear-context.js';
|
|
20
20
|
import * as createSnapshot from './tools/create-snapshot.js';
|
|
21
|
-
import * as sessionStart from './tools/session-start.js';
|
|
22
21
|
import * as listBuckets from './tools/list-buckets.js';
|
|
23
22
|
import * as ingestProject from './tools/ingest-project.js';
|
|
24
23
|
import * as sessionEnd from './tools/session-end.js';
|
|
@@ -35,7 +34,6 @@ const toolModules = [
|
|
|
35
34
|
contextStatus,
|
|
36
35
|
clearContext,
|
|
37
36
|
createSnapshot,
|
|
38
|
-
sessionStart,
|
|
39
37
|
sessionEnd,
|
|
40
38
|
listBuckets,
|
|
41
39
|
recall,
|