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.
Files changed (102) hide show
  1. package/.claude-plugin/hooks/hooks.json +46 -4
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/.claude-plugin/plugin.json +4 -4
  4. package/README.md +377 -191
  5. package/build/adapters/claude-code/config.d.ts +8 -0
  6. package/build/adapters/claude-code/config.js +8 -0
  7. package/build/adapters/claude-code/hooks.d.ts +53 -0
  8. package/build/adapters/claude-code/hooks.js +88 -0
  9. package/build/adapters/claude-code/index.d.ts +50 -0
  10. package/build/adapters/claude-code/index.js +523 -0
  11. package/build/adapters/codex/config.d.ts +8 -0
  12. package/build/adapters/codex/config.js +8 -0
  13. package/build/adapters/codex/hooks.d.ts +21 -0
  14. package/build/adapters/codex/hooks.js +27 -0
  15. package/build/adapters/codex/index.d.ts +44 -0
  16. package/build/adapters/codex/index.js +223 -0
  17. package/build/adapters/detect.d.ts +26 -0
  18. package/build/adapters/detect.js +131 -0
  19. package/build/adapters/gemini-cli/config.d.ts +8 -0
  20. package/build/adapters/gemini-cli/config.js +8 -0
  21. package/build/adapters/gemini-cli/hooks.d.ts +44 -0
  22. package/build/adapters/gemini-cli/hooks.js +64 -0
  23. package/build/adapters/gemini-cli/index.d.ts +57 -0
  24. package/build/adapters/gemini-cli/index.js +468 -0
  25. package/build/adapters/opencode/config.d.ts +8 -0
  26. package/build/adapters/opencode/config.js +8 -0
  27. package/build/adapters/opencode/hooks.d.ts +38 -0
  28. package/build/adapters/opencode/hooks.js +50 -0
  29. package/build/adapters/opencode/index.d.ts +52 -0
  30. package/build/adapters/opencode/index.js +386 -0
  31. package/build/adapters/types.d.ts +218 -0
  32. package/build/adapters/types.js +13 -0
  33. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  34. package/build/adapters/vscode-copilot/config.js +8 -0
  35. package/build/adapters/vscode-copilot/hooks.d.ts +49 -0
  36. package/build/adapters/vscode-copilot/hooks.js +76 -0
  37. package/build/adapters/vscode-copilot/index.d.ts +58 -0
  38. package/build/adapters/vscode-copilot/index.js +512 -0
  39. package/build/cli.d.ts +9 -6
  40. package/build/cli.js +133 -423
  41. package/build/db-base.d.ts +84 -0
  42. package/build/db-base.js +128 -0
  43. package/build/executor.d.ts +6 -7
  44. package/build/executor.js +111 -51
  45. package/build/opencode-plugin.d.ts +37 -0
  46. package/build/opencode-plugin.js +118 -0
  47. package/build/runtime.js +1 -1
  48. package/build/server.js +436 -117
  49. package/build/session/db.d.ts +110 -0
  50. package/build/session/db.js +285 -0
  51. package/build/session/extract.d.ts +51 -0
  52. package/build/session/extract.js +407 -0
  53. package/build/session/snapshot.d.ts +70 -0
  54. package/build/session/snapshot.js +309 -0
  55. package/build/store.d.ts +4 -22
  56. package/build/store.js +67 -55
  57. package/build/truncate.d.ts +59 -0
  58. package/build/truncate.js +157 -0
  59. package/build/types.d.ts +101 -0
  60. package/build/types.js +20 -0
  61. package/configs/claude-code/CLAUDE.md +62 -0
  62. package/configs/codex/AGENTS.md +58 -0
  63. package/configs/codex/config.toml +5 -0
  64. package/configs/gemini-cli/GEMINI.md +58 -0
  65. package/configs/gemini-cli/mcp.json +7 -0
  66. package/configs/gemini-cli/settings.json +49 -0
  67. package/configs/opencode/AGENTS.md +58 -0
  68. package/configs/opencode/opencode.json +10 -0
  69. package/configs/vscode-copilot/copilot-instructions.md +58 -0
  70. package/configs/vscode-copilot/hooks.json +16 -0
  71. package/configs/vscode-copilot/mcp.json +8 -0
  72. package/hooks/core/formatters.mjs +86 -0
  73. package/hooks/core/routing.mjs +262 -0
  74. package/hooks/core/stdin.mjs +19 -0
  75. package/hooks/formatters/claude-code.mjs +57 -0
  76. package/hooks/formatters/gemini-cli.mjs +55 -0
  77. package/hooks/formatters/vscode-copilot.mjs +55 -0
  78. package/hooks/gemini-cli/aftertool.mjs +58 -0
  79. package/hooks/gemini-cli/beforetool.mjs +25 -0
  80. package/hooks/gemini-cli/precompress.mjs +51 -0
  81. package/hooks/gemini-cli/sessionstart.mjs +117 -0
  82. package/hooks/hooks.json +46 -4
  83. package/hooks/posttooluse.mjs +53 -0
  84. package/hooks/precompact.mjs +55 -0
  85. package/hooks/pretooluse.mjs +23 -266
  86. package/hooks/routing-block.mjs +19 -6
  87. package/hooks/session-directive.mjs +353 -0
  88. package/hooks/session-helpers.mjs +112 -0
  89. package/hooks/sessionstart.mjs +123 -16
  90. package/hooks/userpromptsubmit.mjs +58 -0
  91. package/hooks/vscode-copilot/posttooluse.mjs +58 -0
  92. package/hooks/vscode-copilot/precompact.mjs +51 -0
  93. package/hooks/vscode-copilot/pretooluse.mjs +25 -0
  94. package/hooks/vscode-copilot/sessionstart.mjs +115 -0
  95. package/package.json +20 -17
  96. package/skills/context-mode/SKILL.md +49 -49
  97. package/skills/{doctor → ctx-doctor}/SKILL.md +3 -3
  98. package/skills/{stats → ctx-stats}/SKILL.md +3 -3
  99. package/skills/{upgrade → ctx-upgrade}/SKILL.md +3 -3
  100. package/start.mjs +47 -0
  101. package/hooks/pretooluse.sh +0 -147
  102. 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
- export interface IndexResult {
11
- sourceId: number;
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 { createRequire } from "node:module";
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.pragma("journal_mode = WAL");
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
- this.#extractAndStoreVocabulary(text);
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 1: Porter stemming (existing FTS5 MATCH)
440
- const porterResults = this.search(query, limit, source);
441
- if (porterResults.length > 0) {
442
- return porterResults.map((r) => ({ ...r, matchLayer: "porter" }));
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 2: Trigram substring matching
445
- const trigramResults = this.searchTrigram(query, limit, source);
446
- if (trigramResults.length > 0) {
447
- return trigramResults.map((r) => ({
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 3: Fuzzy correction + re-search
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
- // Try Porter with corrected query first
463
- const fuzzyPorter = this.search(correctedQuery, limit, source);
464
- if (fuzzyPorter.length > 0) {
465
- return fuzzyPorter.map((r) => ({
466
- ...r,
467
- matchLayer: "fuzzy",
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
- // Try trigram with corrected query
471
- const fuzzyTrigram = this.searchTrigram(correctedQuery, limit, source);
472
- if (fuzzyTrigram.length > 0) {
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;