context-mode 0.9.21 → 1.0.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/.claude-plugin/hooks/hooks.json +46 -4
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -4
- package/README.md +377 -191
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +53 -0
- package/build/adapters/claude-code/hooks.js +88 -0
- package/build/adapters/claude-code/index.d.ts +50 -0
- package/build/adapters/claude-code/index.js +523 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +21 -0
- package/build/adapters/codex/hooks.js +27 -0
- package/build/adapters/codex/index.d.ts +44 -0
- package/build/adapters/codex/index.js +223 -0
- package/build/adapters/detect.d.ts +26 -0
- package/build/adapters/detect.js +131 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +44 -0
- package/build/adapters/gemini-cli/hooks.js +64 -0
- package/build/adapters/gemini-cli/index.d.ts +57 -0
- package/build/adapters/gemini-cli/index.js +468 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +52 -0
- package/build/adapters/opencode/index.js +386 -0
- package/build/adapters/types.d.ts +218 -0
- package/build/adapters/types.js +13 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +49 -0
- package/build/adapters/vscode-copilot/hooks.js +76 -0
- package/build/adapters/vscode-copilot/index.d.ts +58 -0
- package/build/adapters/vscode-copilot/index.js +512 -0
- package/build/cli.d.ts +9 -6
- package/build/cli.js +133 -423
- package/build/db-base.d.ts +84 -0
- package/build/db-base.js +128 -0
- package/build/executor.d.ts +6 -7
- package/build/executor.js +111 -51
- package/build/opencode-plugin.d.ts +37 -0
- package/build/opencode-plugin.js +118 -0
- package/build/runtime.js +1 -1
- package/build/server.js +436 -117
- package/build/session/db.d.ts +110 -0
- package/build/session/db.js +285 -0
- package/build/session/extract.d.ts +51 -0
- package/build/session/extract.js +407 -0
- package/build/session/snapshot.d.ts +70 -0
- package/build/session/snapshot.js +309 -0
- package/build/store.d.ts +4 -22
- package/build/store.js +67 -55
- package/build/truncate.d.ts +59 -0
- package/build/truncate.js +157 -0
- package/build/types.d.ts +101 -0
- package/build/types.js +20 -0
- package/configs/claude-code/CLAUDE.md +62 -0
- package/configs/codex/AGENTS.md +58 -0
- package/configs/codex/config.toml +5 -0
- package/configs/gemini-cli/GEMINI.md +58 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +49 -0
- package/configs/opencode/AGENTS.md +58 -0
- package/configs/opencode/opencode.json +10 -0
- package/configs/vscode-copilot/copilot-instructions.md +58 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +8 -0
- package/hooks/core/formatters.mjs +86 -0
- package/hooks/core/routing.mjs +262 -0
- package/hooks/core/stdin.mjs +19 -0
- package/hooks/formatters/claude-code.mjs +57 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftertool.mjs +58 -0
- package/hooks/gemini-cli/beforetool.mjs +25 -0
- package/hooks/gemini-cli/precompress.mjs +51 -0
- package/hooks/gemini-cli/sessionstart.mjs +117 -0
- package/hooks/hooks.json +46 -4
- package/hooks/posttooluse.mjs +53 -0
- package/hooks/precompact.mjs +55 -0
- package/hooks/pretooluse.mjs +23 -266
- package/hooks/routing-block.mjs +19 -6
- package/hooks/session-directive.mjs +353 -0
- package/hooks/session-helpers.mjs +112 -0
- package/hooks/sessionstart.mjs +123 -16
- package/hooks/userpromptsubmit.mjs +58 -0
- package/hooks/vscode-copilot/posttooluse.mjs +58 -0
- package/hooks/vscode-copilot/precompact.mjs +51 -0
- package/hooks/vscode-copilot/pretooluse.mjs +25 -0
- package/hooks/vscode-copilot/sessionstart.mjs +115 -0
- package/package.json +20 -17
- package/skills/context-mode/SKILL.md +49 -49
- package/skills/{doctor → ctx-doctor}/SKILL.md +3 -3
- package/skills/{stats → ctx-stats}/SKILL.md +3 -3
- package/skills/{upgrade → ctx-upgrade}/SKILL.md +3 -3
- package/start.mjs +47 -0
- package/hooks/pretooluse.sh +0 -147
- package/server.bundle.mjs +0 -341
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot builder — converts stored SessionEvents into an XML resume snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only. No database access, no file system, no side effects.
|
|
5
|
+
* The output XML is injected into Claude's context after a compact event to
|
|
6
|
+
* restore session awareness.
|
|
7
|
+
*
|
|
8
|
+
* Budget: default 2048 bytes, allocated by priority tier:
|
|
9
|
+
* P1 (file, task, rule): 50% = ~1024 bytes
|
|
10
|
+
* P2 (cwd, error, decision, env, git): 35% = ~716 bytes
|
|
11
|
+
* P3-P4 (subagent, skill, role, data, intent): 15% = ~308 bytes
|
|
12
|
+
*/
|
|
13
|
+
import { escapeXML, truncateString } from "../truncate.js";
|
|
14
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
15
|
+
const DEFAULT_MAX_BYTES = 2048;
|
|
16
|
+
const MAX_ACTIVE_FILES = 10;
|
|
17
|
+
// Priority tier category groupings
|
|
18
|
+
const P1_CATEGORIES = new Set(["file", "task", "rule"]);
|
|
19
|
+
const P2_CATEGORIES = new Set(["cwd", "error", "decision", "env", "git"]);
|
|
20
|
+
// P3-P4: everything else (subagent, skill, role, data, intent, mcp)
|
|
21
|
+
// ── Section renderers ────────────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Render <active_files> from file events.
|
|
24
|
+
* Deduplicates by path, counts operations, keeps the last 10 files.
|
|
25
|
+
*/
|
|
26
|
+
export function renderActiveFiles(fileEvents) {
|
|
27
|
+
if (fileEvents.length === 0)
|
|
28
|
+
return "";
|
|
29
|
+
// Build per-file operation counts and track last operation
|
|
30
|
+
const fileMap = new Map();
|
|
31
|
+
for (const ev of fileEvents) {
|
|
32
|
+
const path = ev.data;
|
|
33
|
+
let entry = fileMap.get(path);
|
|
34
|
+
if (!entry) {
|
|
35
|
+
entry = { ops: new Map(), last: "" };
|
|
36
|
+
fileMap.set(path, entry);
|
|
37
|
+
}
|
|
38
|
+
// Derive operation from event type
|
|
39
|
+
let op;
|
|
40
|
+
if (ev.type === "file_write")
|
|
41
|
+
op = "write";
|
|
42
|
+
else if (ev.type === "file_read")
|
|
43
|
+
op = "read";
|
|
44
|
+
else
|
|
45
|
+
op = "edit"; // type === "file" (from Edit tool)
|
|
46
|
+
entry.ops.set(op, (entry.ops.get(op) ?? 0) + 1);
|
|
47
|
+
entry.last = op;
|
|
48
|
+
}
|
|
49
|
+
// Limit to last MAX_ACTIVE_FILES files (by insertion order = chronological)
|
|
50
|
+
const entries = Array.from(fileMap.entries());
|
|
51
|
+
const limited = entries.slice(-MAX_ACTIVE_FILES);
|
|
52
|
+
const lines = [" <active_files>"];
|
|
53
|
+
for (const [path, { ops, last }] of limited) {
|
|
54
|
+
const opsStr = Array.from(ops.entries())
|
|
55
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
56
|
+
.join(",");
|
|
57
|
+
lines.push(` <file path="${escapeXML(path)}" ops="${escapeXML(opsStr)}" last="${escapeXML(last)}" />`);
|
|
58
|
+
}
|
|
59
|
+
lines.push(" </active_files>");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Render <task_state> from task events.
|
|
64
|
+
* Shows the most recent task state (last event's data).
|
|
65
|
+
*/
|
|
66
|
+
export function renderTaskState(taskEvents) {
|
|
67
|
+
if (taskEvents.length === 0)
|
|
68
|
+
return "";
|
|
69
|
+
// Use the last task event as the most current state
|
|
70
|
+
const lastTask = taskEvents[taskEvents.length - 1];
|
|
71
|
+
const data = truncateString(escapeXML(lastTask.data), 200);
|
|
72
|
+
return ` <task_state>\n ${data}\n </task_state>`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Render <rules> from rule events.
|
|
76
|
+
* Lists each unique rule source path + content summaries.
|
|
77
|
+
*/
|
|
78
|
+
export function renderRules(ruleEvents) {
|
|
79
|
+
if (ruleEvents.length === 0)
|
|
80
|
+
return "";
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const lines = [" <rules>"];
|
|
83
|
+
for (const ev of ruleEvents) {
|
|
84
|
+
const key = ev.data;
|
|
85
|
+
if (seen.has(key))
|
|
86
|
+
continue;
|
|
87
|
+
seen.add(key);
|
|
88
|
+
if (ev.type === "rule_content") {
|
|
89
|
+
// Rule content: render as content block (survives compact)
|
|
90
|
+
lines.push(` <rule_content>${escapeXML(truncateString(ev.data, 400))}</rule_content>`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Rule path
|
|
94
|
+
lines.push(` - ${escapeXML(truncateString(ev.data, 200))}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
lines.push(" </rules>");
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Render <decisions> from decision events.
|
|
102
|
+
*/
|
|
103
|
+
export function renderDecisions(decisionEvents) {
|
|
104
|
+
if (decisionEvents.length === 0)
|
|
105
|
+
return "";
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const lines = [" <decisions>"];
|
|
108
|
+
for (const ev of decisionEvents) {
|
|
109
|
+
const key = ev.data;
|
|
110
|
+
if (seen.has(key))
|
|
111
|
+
continue;
|
|
112
|
+
seen.add(key);
|
|
113
|
+
lines.push(` - ${escapeXML(truncateString(ev.data, 200))}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push(" </decisions>");
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Render <environment> from cwd, env, and git events.
|
|
120
|
+
*/
|
|
121
|
+
export function renderEnvironment(cwdEvent, envEvents, gitEvent) {
|
|
122
|
+
const parts = [];
|
|
123
|
+
if (!cwdEvent && envEvents.length === 0 && !gitEvent)
|
|
124
|
+
return "";
|
|
125
|
+
parts.push(" <environment>");
|
|
126
|
+
if (cwdEvent) {
|
|
127
|
+
parts.push(` <cwd>${escapeXML(cwdEvent.data)}</cwd>`);
|
|
128
|
+
}
|
|
129
|
+
if (gitEvent) {
|
|
130
|
+
// git event data is the operation type (branch, commit, push, etc.)
|
|
131
|
+
parts.push(` <git op="${escapeXML(gitEvent.data)}" />`);
|
|
132
|
+
}
|
|
133
|
+
for (const env of envEvents) {
|
|
134
|
+
parts.push(` <env>${escapeXML(truncateString(env.data, 150))}</env>`);
|
|
135
|
+
}
|
|
136
|
+
parts.push(" </environment>");
|
|
137
|
+
return parts.join("\n");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Render <errors_resolved> from error events.
|
|
141
|
+
*/
|
|
142
|
+
export function renderErrors(errorEvents) {
|
|
143
|
+
if (errorEvents.length === 0)
|
|
144
|
+
return "";
|
|
145
|
+
const lines = [" <errors_resolved>"];
|
|
146
|
+
for (const ev of errorEvents) {
|
|
147
|
+
lines.push(` - ${escapeXML(truncateString(ev.data, 150))}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(" </errors_resolved>");
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Render <intent> from the most recent intent event.
|
|
154
|
+
*/
|
|
155
|
+
export function renderIntent(intentEvent) {
|
|
156
|
+
return ` <intent mode="${escapeXML(intentEvent.data)}">${escapeXML(truncateString(intentEvent.data, 100))}</intent>`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Render <mcp_tools> from MCP tool call events.
|
|
160
|
+
* Deduplicates by tool name, shows usage count.
|
|
161
|
+
*/
|
|
162
|
+
export function renderMcpTools(mcpEvents) {
|
|
163
|
+
if (mcpEvents.length === 0)
|
|
164
|
+
return "";
|
|
165
|
+
// Count usage per tool
|
|
166
|
+
const toolCounts = new Map();
|
|
167
|
+
for (const ev of mcpEvents) {
|
|
168
|
+
const tool = ev.data.split(":")[0].trim();
|
|
169
|
+
toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + 1);
|
|
170
|
+
}
|
|
171
|
+
const lines = [" <mcp_tools>"];
|
|
172
|
+
for (const [tool, count] of toolCounts) {
|
|
173
|
+
lines.push(` <tool name="${escapeXML(tool)}" calls="${count}" />`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(" </mcp_tools>");
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
// ── Main builder ─────────────────────────────────────────────────────────────
|
|
179
|
+
/**
|
|
180
|
+
* Build a resume snapshot XML string from stored session events.
|
|
181
|
+
*
|
|
182
|
+
* Algorithm:
|
|
183
|
+
* 1. Group events by category
|
|
184
|
+
* 2. Render each section
|
|
185
|
+
* 3. Assemble by priority tier with budget trimming
|
|
186
|
+
* 4. If over maxBytes, drop lowest priority sections first
|
|
187
|
+
*/
|
|
188
|
+
export function buildResumeSnapshot(events, opts) {
|
|
189
|
+
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
190
|
+
const compactCount = opts?.compactCount ?? 1;
|
|
191
|
+
const now = new Date().toISOString();
|
|
192
|
+
// ── Group events by category ──
|
|
193
|
+
const fileEvents = [];
|
|
194
|
+
const taskEvents = [];
|
|
195
|
+
const ruleEvents = [];
|
|
196
|
+
const decisionEvents = [];
|
|
197
|
+
const cwdEvents = [];
|
|
198
|
+
const errorEvents = [];
|
|
199
|
+
const envEvents = [];
|
|
200
|
+
const gitEvents = [];
|
|
201
|
+
const skillEvents = [];
|
|
202
|
+
const subagentEvents = [];
|
|
203
|
+
const roleEvents = [];
|
|
204
|
+
const dataEvents = [];
|
|
205
|
+
const intentEvents = [];
|
|
206
|
+
const mcpEvents = [];
|
|
207
|
+
for (const ev of events) {
|
|
208
|
+
switch (ev.category) {
|
|
209
|
+
case "file":
|
|
210
|
+
fileEvents.push(ev);
|
|
211
|
+
break;
|
|
212
|
+
case "task":
|
|
213
|
+
taskEvents.push(ev);
|
|
214
|
+
break;
|
|
215
|
+
case "rule":
|
|
216
|
+
ruleEvents.push(ev);
|
|
217
|
+
break;
|
|
218
|
+
case "decision":
|
|
219
|
+
decisionEvents.push(ev);
|
|
220
|
+
break;
|
|
221
|
+
case "cwd":
|
|
222
|
+
cwdEvents.push(ev);
|
|
223
|
+
break;
|
|
224
|
+
case "error":
|
|
225
|
+
errorEvents.push(ev);
|
|
226
|
+
break;
|
|
227
|
+
case "env":
|
|
228
|
+
envEvents.push(ev);
|
|
229
|
+
break;
|
|
230
|
+
case "git":
|
|
231
|
+
gitEvents.push(ev);
|
|
232
|
+
break;
|
|
233
|
+
case "skill":
|
|
234
|
+
skillEvents.push(ev);
|
|
235
|
+
break;
|
|
236
|
+
case "subagent":
|
|
237
|
+
subagentEvents.push(ev);
|
|
238
|
+
break;
|
|
239
|
+
case "role":
|
|
240
|
+
roleEvents.push(ev);
|
|
241
|
+
break;
|
|
242
|
+
case "data":
|
|
243
|
+
dataEvents.push(ev);
|
|
244
|
+
break;
|
|
245
|
+
case "intent":
|
|
246
|
+
intentEvents.push(ev);
|
|
247
|
+
break;
|
|
248
|
+
case "mcp":
|
|
249
|
+
mcpEvents.push(ev);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ── Render sections by priority tier ──
|
|
254
|
+
// P1 sections (50% budget): active_files, task_state, rules
|
|
255
|
+
const p1Sections = [];
|
|
256
|
+
const activeFiles = renderActiveFiles(fileEvents);
|
|
257
|
+
if (activeFiles)
|
|
258
|
+
p1Sections.push(activeFiles);
|
|
259
|
+
const taskState = renderTaskState(taskEvents);
|
|
260
|
+
if (taskState)
|
|
261
|
+
p1Sections.push(taskState);
|
|
262
|
+
const rules = renderRules(ruleEvents);
|
|
263
|
+
if (rules)
|
|
264
|
+
p1Sections.push(rules);
|
|
265
|
+
// P2 sections (35% budget): decisions, environment, errors_resolved
|
|
266
|
+
const p2Sections = [];
|
|
267
|
+
const decisions = renderDecisions(decisionEvents);
|
|
268
|
+
if (decisions)
|
|
269
|
+
p2Sections.push(decisions);
|
|
270
|
+
const lastCwd = cwdEvents.length > 0 ? cwdEvents[cwdEvents.length - 1] : undefined;
|
|
271
|
+
const lastGit = gitEvents.length > 0 ? gitEvents[gitEvents.length - 1] : undefined;
|
|
272
|
+
const environment = renderEnvironment(lastCwd, envEvents, lastGit);
|
|
273
|
+
if (environment)
|
|
274
|
+
p2Sections.push(environment);
|
|
275
|
+
const errors = renderErrors(errorEvents);
|
|
276
|
+
if (errors)
|
|
277
|
+
p2Sections.push(errors);
|
|
278
|
+
// P3-P4 sections (15% budget): intent, mcp_tools
|
|
279
|
+
const p3Sections = [];
|
|
280
|
+
if (intentEvents.length > 0) {
|
|
281
|
+
const lastIntent = intentEvents[intentEvents.length - 1];
|
|
282
|
+
p3Sections.push(renderIntent(lastIntent));
|
|
283
|
+
}
|
|
284
|
+
const mcpTools = renderMcpTools(mcpEvents);
|
|
285
|
+
if (mcpTools)
|
|
286
|
+
p3Sections.push(mcpTools);
|
|
287
|
+
// ── Assemble with budget trimming ──
|
|
288
|
+
const header = `<session_resume compact_count="${compactCount}" events_captured="${events.length}" generated_at="${now}">`;
|
|
289
|
+
const footer = `</session_resume>`;
|
|
290
|
+
// Try assembling all tiers, drop lowest priority first if over budget
|
|
291
|
+
const tiers = [p1Sections, p2Sections, p3Sections];
|
|
292
|
+
// Start with all tiers and progressively drop from the back
|
|
293
|
+
for (let dropFrom = tiers.length; dropFrom >= 0; dropFrom--) {
|
|
294
|
+
const activeTiers = tiers.slice(0, dropFrom);
|
|
295
|
+
const body = activeTiers.flat().join("\n");
|
|
296
|
+
let xml;
|
|
297
|
+
if (body) {
|
|
298
|
+
xml = `${header}\n${body}\n${footer}`;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
xml = `${header}\n${footer}`;
|
|
302
|
+
}
|
|
303
|
+
if (Buffer.byteLength(xml) <= maxBytes) {
|
|
304
|
+
return xml;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// If even header+footer is over budget, return the minimal XML
|
|
308
|
+
return `${header}\n${footer}`;
|
|
309
|
+
}
|
package/build/store.d.ts
CHANGED
|
@@ -7,26 +7,8 @@
|
|
|
7
7
|
* Use for documentation, API references, and any content where
|
|
8
8
|
* you need EXACT text later — not summaries.
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
label: string;
|
|
13
|
-
totalChunks: number;
|
|
14
|
-
codeChunks: number;
|
|
15
|
-
}
|
|
16
|
-
export interface SearchResult {
|
|
17
|
-
title: string;
|
|
18
|
-
content: string;
|
|
19
|
-
source: string;
|
|
20
|
-
rank: number;
|
|
21
|
-
contentType: "code" | "prose";
|
|
22
|
-
matchLayer?: "porter" | "trigram" | "fuzzy";
|
|
23
|
-
highlighted?: string;
|
|
24
|
-
}
|
|
25
|
-
export interface StoreStats {
|
|
26
|
-
sources: number;
|
|
27
|
-
chunks: number;
|
|
28
|
-
codeChunks: number;
|
|
29
|
-
}
|
|
10
|
+
import type { IndexResult, SearchResult, StoreStats } from "./types.js";
|
|
11
|
+
export type { IndexResult, SearchResult, StoreStats } from "./types.js";
|
|
30
12
|
/**
|
|
31
13
|
* Remove stale DB files from previous sessions whose processes no longer exist.
|
|
32
14
|
*/
|
|
@@ -55,8 +37,8 @@ export declare class ContentStore {
|
|
|
55
37
|
* Falls back to `indexPlainText` if the content is not valid JSON.
|
|
56
38
|
*/
|
|
57
39
|
indexJSON(content: string, source: string, maxChunkBytes?: number): IndexResult;
|
|
58
|
-
search(query: string, limit?: number, source?: string): SearchResult[];
|
|
59
|
-
searchTrigram(query: string, limit?: number, source?: string): SearchResult[];
|
|
40
|
+
search(query: string, limit?: number, source?: string, mode?: "AND" | "OR"): SearchResult[];
|
|
41
|
+
searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR"): SearchResult[];
|
|
60
42
|
fuzzyCorrect(query: string): string | null;
|
|
61
43
|
searchWithFallback(query: string, limit?: number, source?: string): SearchResult[];
|
|
62
44
|
listSources(): Array<{
|
package/build/store.js
CHANGED
|
@@ -7,21 +7,10 @@
|
|
|
7
7
|
* Use for documentation, API references, and any content where
|
|
8
8
|
* you need EXACT text later — not summaries.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { loadDatabase, applyWALPragmas } from "./db-base.js";
|
|
11
11
|
import { readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
// Lazy-load better-sqlite3 — only when ContentStore is first used.
|
|
15
|
-
// This lets the MCP server start instantly even if the native module
|
|
16
|
-
// isn't installed yet (marketplace first-run scenario).
|
|
17
|
-
let _Database = null;
|
|
18
|
-
function loadDatabase() {
|
|
19
|
-
if (!_Database) {
|
|
20
|
-
const require = createRequire(import.meta.url);
|
|
21
|
-
_Database = require("better-sqlite3");
|
|
22
|
-
}
|
|
23
|
-
return _Database;
|
|
24
|
-
}
|
|
25
14
|
// ─────────────────────────────────────────────────────────
|
|
26
15
|
// Constants
|
|
27
16
|
// ─────────────────────────────────────────────────────────
|
|
@@ -43,7 +32,7 @@ const STOPWORDS = new Set([
|
|
|
43
32
|
// ─────────────────────────────────────────────────────────
|
|
44
33
|
// Helpers
|
|
45
34
|
// ─────────────────────────────────────────────────────────
|
|
46
|
-
function sanitizeQuery(query) {
|
|
35
|
+
function sanitizeQuery(query, mode = "AND") {
|
|
47
36
|
const words = query
|
|
48
37
|
.replace(/['"(){}[\]*:^~]/g, " ")
|
|
49
38
|
.split(/\s+/)
|
|
@@ -51,16 +40,16 @@ function sanitizeQuery(query) {
|
|
|
51
40
|
!["AND", "OR", "NOT", "NEAR"].includes(w.toUpperCase()));
|
|
52
41
|
if (words.length === 0)
|
|
53
42
|
return '""';
|
|
54
|
-
return words.map((w) => `"${w}"`).join(" OR ");
|
|
43
|
+
return words.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
|
|
55
44
|
}
|
|
56
|
-
function sanitizeTrigramQuery(query) {
|
|
45
|
+
function sanitizeTrigramQuery(query, mode = "AND") {
|
|
57
46
|
const cleaned = query.replace(/["'(){}[\]*:^~]/g, "").trim();
|
|
58
47
|
if (cleaned.length < 3)
|
|
59
48
|
return "";
|
|
60
49
|
const words = cleaned.split(/\s+/).filter((w) => w.length >= 3);
|
|
61
50
|
if (words.length === 0)
|
|
62
51
|
return "";
|
|
63
|
-
return words.map((w) => `"${w}"`).join(" OR ");
|
|
52
|
+
return words.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
|
|
64
53
|
}
|
|
65
54
|
function levenshtein(a, b) {
|
|
66
55
|
if (a.length === 0)
|
|
@@ -139,6 +128,10 @@ export class ContentStore {
|
|
|
139
128
|
#stmtInsertChunk;
|
|
140
129
|
#stmtInsertChunkTrigram;
|
|
141
130
|
#stmtInsertVocab;
|
|
131
|
+
// Dedup path (delete previous source with same label before re-indexing)
|
|
132
|
+
#stmtDeleteChunksByLabel;
|
|
133
|
+
#stmtDeleteChunksTrigramByLabel;
|
|
134
|
+
#stmtDeleteSourcesByLabel;
|
|
142
135
|
// Search path (hot)
|
|
143
136
|
#stmtSearchPorter;
|
|
144
137
|
#stmtSearchPorterFiltered;
|
|
@@ -156,8 +149,7 @@ export class ContentStore {
|
|
|
156
149
|
this.#dbPath =
|
|
157
150
|
dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
|
|
158
151
|
this.#db = new Database(this.#dbPath, { timeout: 5000 });
|
|
159
|
-
this.#db
|
|
160
|
-
this.#db.pragma("synchronous = NORMAL");
|
|
152
|
+
applyWALPragmas(this.#db);
|
|
161
153
|
this.#initSchema();
|
|
162
154
|
this.#prepareStatements();
|
|
163
155
|
}
|
|
@@ -213,6 +205,11 @@ export class ContentStore {
|
|
|
213
205
|
this.#stmtInsertChunk = this.#db.prepare("INSERT INTO chunks (title, content, source_id, content_type) VALUES (?, ?, ?, ?)");
|
|
214
206
|
this.#stmtInsertChunkTrigram = this.#db.prepare("INSERT INTO chunks_trigram (title, content, source_id, content_type) VALUES (?, ?, ?, ?)");
|
|
215
207
|
this.#stmtInsertVocab = this.#db.prepare("INSERT OR IGNORE INTO vocabulary (word) VALUES (?)");
|
|
208
|
+
// Dedup path: delete previous source with same label before re-indexing
|
|
209
|
+
// Prevents stale outputs from accumulating in iterative workflows (build-fix-build)
|
|
210
|
+
this.#stmtDeleteChunksByLabel = this.#db.prepare("DELETE FROM chunks WHERE source_id IN (SELECT id FROM sources WHERE label = ?)");
|
|
211
|
+
this.#stmtDeleteChunksTrigramByLabel = this.#db.prepare("DELETE FROM chunks_trigram WHERE source_id IN (SELECT id FROM sources WHERE label = ?)");
|
|
212
|
+
this.#stmtDeleteSourcesByLabel = this.#db.prepare("DELETE FROM sources WHERE label = ?");
|
|
216
213
|
// Search path (hot)
|
|
217
214
|
this.#stmtSearchPorter = this.#db.prepare(`
|
|
218
215
|
SELECT
|
|
@@ -345,17 +342,18 @@ export class ContentStore {
|
|
|
345
342
|
* Uses cached prepared statements from #prepareStatements().
|
|
346
343
|
*/
|
|
347
344
|
#insertChunks(chunks, label, text) {
|
|
348
|
-
if (chunks.length === 0) {
|
|
349
|
-
const info = this.#stmtInsertSourceEmpty.run(label);
|
|
350
|
-
return {
|
|
351
|
-
sourceId: Number(info.lastInsertRowid),
|
|
352
|
-
label,
|
|
353
|
-
totalChunks: 0,
|
|
354
|
-
codeChunks: 0,
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
345
|
const codeChunks = chunks.filter((c) => c.hasCode).length;
|
|
346
|
+
// Atomic dedup + insert: delete previous source with same label,
|
|
347
|
+
// then insert new content — all within a single transaction.
|
|
348
|
+
// Prevents stale results in iterative workflows. (See: GitHub issue #67)
|
|
358
349
|
const transaction = this.#db.transaction(() => {
|
|
350
|
+
this.#stmtDeleteChunksByLabel.run(label);
|
|
351
|
+
this.#stmtDeleteChunksTrigramByLabel.run(label);
|
|
352
|
+
this.#stmtDeleteSourcesByLabel.run(label);
|
|
353
|
+
if (chunks.length === 0) {
|
|
354
|
+
const info = this.#stmtInsertSourceEmpty.run(label);
|
|
355
|
+
return Number(info.lastInsertRowid);
|
|
356
|
+
}
|
|
359
357
|
const info = this.#stmtInsertSource.run(label, chunks.length, codeChunks);
|
|
360
358
|
const sourceId = Number(info.lastInsertRowid);
|
|
361
359
|
for (const chunk of chunks) {
|
|
@@ -366,7 +364,8 @@ export class ContentStore {
|
|
|
366
364
|
return sourceId;
|
|
367
365
|
});
|
|
368
366
|
const sourceId = transaction();
|
|
369
|
-
|
|
367
|
+
if (text)
|
|
368
|
+
this.#extractAndStoreVocabulary(text);
|
|
370
369
|
return {
|
|
371
370
|
sourceId,
|
|
372
371
|
label,
|
|
@@ -375,8 +374,8 @@ export class ContentStore {
|
|
|
375
374
|
};
|
|
376
375
|
}
|
|
377
376
|
// ── Search ──
|
|
378
|
-
search(query, limit = 3, source) {
|
|
379
|
-
const sanitized = sanitizeQuery(query);
|
|
377
|
+
search(query, limit = 3, source, mode = "AND") {
|
|
378
|
+
const sanitized = sanitizeQuery(query, mode);
|
|
380
379
|
const stmt = source
|
|
381
380
|
? this.#stmtSearchPorterFiltered
|
|
382
381
|
: this.#stmtSearchPorter;
|
|
@@ -394,8 +393,8 @@ export class ContentStore {
|
|
|
394
393
|
}));
|
|
395
394
|
}
|
|
396
395
|
// ── Trigram Search (Layer 2) ──
|
|
397
|
-
searchTrigram(query, limit = 3, source) {
|
|
398
|
-
const sanitized = sanitizeTrigramQuery(query);
|
|
396
|
+
searchTrigram(query, limit = 3, source, mode = "AND") {
|
|
397
|
+
const sanitized = sanitizeTrigramQuery(query, mode);
|
|
399
398
|
if (!sanitized)
|
|
400
399
|
return [];
|
|
401
400
|
const stmt = source
|
|
@@ -436,20 +435,33 @@ export class ContentStore {
|
|
|
436
435
|
}
|
|
437
436
|
// ── Unified Fallback Search ──
|
|
438
437
|
searchWithFallback(query, limit = 3, source) {
|
|
439
|
-
// Layer
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
442
|
-
return
|
|
438
|
+
// Layer 1a: Porter + AND (most precise)
|
|
439
|
+
const porterAnd = this.search(query, limit, source, "AND");
|
|
440
|
+
if (porterAnd.length > 0) {
|
|
441
|
+
return porterAnd.map((r) => ({ ...r, matchLayer: "porter" }));
|
|
442
|
+
}
|
|
443
|
+
// Layer 1b: Porter + OR (fallback when AND finds nothing)
|
|
444
|
+
const porterOr = this.search(query, limit, source, "OR");
|
|
445
|
+
if (porterOr.length > 0) {
|
|
446
|
+
return porterOr.map((r) => ({ ...r, matchLayer: "porter" }));
|
|
443
447
|
}
|
|
444
|
-
// Layer
|
|
445
|
-
const
|
|
446
|
-
if (
|
|
447
|
-
return
|
|
448
|
+
// Layer 2a: Trigram + AND
|
|
449
|
+
const trigramAnd = this.searchTrigram(query, limit, source, "AND");
|
|
450
|
+
if (trigramAnd.length > 0) {
|
|
451
|
+
return trigramAnd.map((r) => ({
|
|
448
452
|
...r,
|
|
449
453
|
matchLayer: "trigram",
|
|
450
454
|
}));
|
|
451
455
|
}
|
|
452
|
-
// Layer
|
|
456
|
+
// Layer 2b: Trigram + OR
|
|
457
|
+
const trigramOr = this.searchTrigram(query, limit, source, "OR");
|
|
458
|
+
if (trigramOr.length > 0) {
|
|
459
|
+
return trigramOr.map((r) => ({
|
|
460
|
+
...r,
|
|
461
|
+
matchLayer: "trigram",
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
// Layer 3: Fuzzy correction + re-search (AND then OR)
|
|
453
465
|
const words = query
|
|
454
466
|
.toLowerCase()
|
|
455
467
|
.trim()
|
|
@@ -459,21 +471,21 @@ export class ContentStore {
|
|
|
459
471
|
const correctedWords = words.map((w) => this.fuzzyCorrect(w) ?? w);
|
|
460
472
|
const correctedQuery = correctedWords.join(" ");
|
|
461
473
|
if (correctedQuery !== original) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}));
|
|
474
|
+
const fuzzyPorterAnd = this.search(correctedQuery, limit, source, "AND");
|
|
475
|
+
if (fuzzyPorterAnd.length > 0) {
|
|
476
|
+
return fuzzyPorterAnd.map((r) => ({ ...r, matchLayer: "fuzzy" }));
|
|
477
|
+
}
|
|
478
|
+
const fuzzyPorterOr = this.search(correctedQuery, limit, source, "OR");
|
|
479
|
+
if (fuzzyPorterOr.length > 0) {
|
|
480
|
+
return fuzzyPorterOr.map((r) => ({ ...r, matchLayer: "fuzzy" }));
|
|
481
|
+
}
|
|
482
|
+
const fuzzyTrigramAnd = this.searchTrigram(correctedQuery, limit, source, "AND");
|
|
483
|
+
if (fuzzyTrigramAnd.length > 0) {
|
|
484
|
+
return fuzzyTrigramAnd.map((r) => ({ ...r, matchLayer: "fuzzy" }));
|
|
469
485
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
return fuzzyTrigram.map((r) => ({
|
|
474
|
-
...r,
|
|
475
|
-
matchLayer: "fuzzy",
|
|
476
|
-
}));
|
|
486
|
+
const fuzzyTrigramOr = this.searchTrigram(correctedQuery, limit, source, "OR");
|
|
487
|
+
if (fuzzyTrigramOr.length > 0) {
|
|
488
|
+
return fuzzyTrigramOr.map((r) => ({ ...r, matchLayer: "fuzzy" }));
|
|
477
489
|
}
|
|
478
490
|
}
|
|
479
491
|
return [];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* truncate — Pure string and output truncation utilities for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* These helpers are used by both the core ContentStore (chunking) and the
|
|
5
|
+
* PolyglotExecutor (smart output truncation). They are extracted here so
|
|
6
|
+
* SessionDB and any other future consumer can import them without pulling
|
|
7
|
+
* in the full store or executor.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Truncate a string to at most `maxChars` characters, appending an ellipsis
|
|
11
|
+
* when truncation occurs.
|
|
12
|
+
*
|
|
13
|
+
* @param str - Input string.
|
|
14
|
+
* @param maxChars - Maximum character count (inclusive). Must be >= 3.
|
|
15
|
+
* @returns The original string if short enough, otherwise a truncated string
|
|
16
|
+
* ending with "...".
|
|
17
|
+
*/
|
|
18
|
+
export declare function truncateString(str: string, maxChars: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Smart truncation that keeps the head (60%) and tail (40%) of output,
|
|
21
|
+
* preserving both initial context and final error messages.
|
|
22
|
+
* Snaps to line boundaries and handles UTF-8 safely via `Buffer.byteLength`.
|
|
23
|
+
*
|
|
24
|
+
* Used by PolyglotExecutor to cap stdout/stderr before returning to context.
|
|
25
|
+
*
|
|
26
|
+
* @param raw - Raw output string.
|
|
27
|
+
* @param maxBytes - Soft cap in bytes. Output below this threshold is returned as-is.
|
|
28
|
+
* @returns The original string if within budget, otherwise head + separator + tail.
|
|
29
|
+
*/
|
|
30
|
+
export declare function smartTruncate(raw: string, maxBytes: number): string;
|
|
31
|
+
/**
|
|
32
|
+
* Serialize a value to JSON, then truncate the result to `maxBytes` bytes.
|
|
33
|
+
* If truncation occurs, the string is cut at a UTF-8-safe boundary and
|
|
34
|
+
* "... [truncated]" is appended. The result is NOT guaranteed to be valid
|
|
35
|
+
* JSON after truncation — it is suitable only for display/logging.
|
|
36
|
+
*
|
|
37
|
+
* @param value - Any JSON-serializable value.
|
|
38
|
+
* @param maxBytes - Maximum byte length of the returned string.
|
|
39
|
+
* @param indent - JSON indentation spaces (default 2). Pass 0 for compact.
|
|
40
|
+
*/
|
|
41
|
+
export declare function truncateJSON(value: unknown, maxBytes: number, indent?: number): string;
|
|
42
|
+
/**
|
|
43
|
+
* Escape a string for safe embedding in an XML or HTML attribute or text node.
|
|
44
|
+
* Replaces the five XML-reserved characters: `&`, `<`, `>`, `"`, `'`.
|
|
45
|
+
*
|
|
46
|
+
* Used by the resume snapshot template builder to embed user content in
|
|
47
|
+
* `<tool_response>` and `<user_message>` XML tags without breaking the
|
|
48
|
+
* structured prompt format.
|
|
49
|
+
*/
|
|
50
|
+
export declare function escapeXML(str: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Return `str` unchanged if it fits within `maxBytes`, otherwise return a
|
|
53
|
+
* byte-safe slice with an ellipsis appended. Useful for single-value fields
|
|
54
|
+
* (e.g., tool response strings) where head+tail splitting is not needed.
|
|
55
|
+
*
|
|
56
|
+
* @param str - Input string.
|
|
57
|
+
* @param maxBytes - Hard byte cap.
|
|
58
|
+
*/
|
|
59
|
+
export declare function capBytes(str: string, maxBytes: number): string;
|