context-mode 1.0.53 → 1.0.56

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +103 -32
  6. package/build/adapters/antigravity/index.d.ts +1 -3
  7. package/build/adapters/antigravity/index.js +0 -30
  8. package/build/adapters/claude-code/hooks.d.ts +18 -0
  9. package/build/adapters/claude-code/hooks.js +23 -0
  10. package/build/adapters/claude-code/index.d.ts +1 -3
  11. package/build/adapters/claude-code/index.js +48 -35
  12. package/build/adapters/client-map.js +1 -0
  13. package/build/adapters/codex/index.d.ts +1 -3
  14. package/build/adapters/codex/index.js +1 -31
  15. package/build/adapters/cursor/index.d.ts +1 -3
  16. package/build/adapters/cursor/index.js +0 -11
  17. package/build/adapters/detect.d.ts +1 -0
  18. package/build/adapters/detect.js +18 -2
  19. package/build/adapters/gemini-cli/index.d.ts +1 -3
  20. package/build/adapters/gemini-cli/index.js +0 -30
  21. package/build/adapters/kiro/index.d.ts +1 -3
  22. package/build/adapters/kiro/index.js +0 -30
  23. package/build/adapters/openclaw/index.d.ts +1 -3
  24. package/build/adapters/openclaw/index.js +0 -38
  25. package/build/adapters/opencode/index.d.ts +5 -4
  26. package/build/adapters/opencode/index.js +37 -41
  27. package/build/adapters/types.d.ts +1 -14
  28. package/build/adapters/vscode-copilot/index.d.ts +1 -3
  29. package/build/adapters/vscode-copilot/index.js +0 -32
  30. package/build/adapters/zed/index.d.ts +1 -3
  31. package/build/adapters/zed/index.js +0 -30
  32. package/build/cli.js +12 -28
  33. package/build/executor.d.ts +0 -1
  34. package/build/executor.js +28 -16
  35. package/build/openclaw-plugin.js +12 -34
  36. package/build/opencode-plugin.d.ts +1 -0
  37. package/build/opencode-plugin.js +5 -9
  38. package/build/runtime.js +29 -11
  39. package/build/server.d.ts +2 -0
  40. package/build/server.js +69 -61
  41. package/build/store.d.ts +4 -3
  42. package/build/store.js +101 -34
  43. package/build/truncate.d.ts +4 -17
  44. package/build/truncate.js +4 -52
  45. package/cli.bundle.mjs +184 -157
  46. package/configs/codex/AGENTS.md +19 -0
  47. package/configs/kilo/AGENTS.md +58 -0
  48. package/configs/kilo/kilo.json +10 -0
  49. package/hooks/core/tool-naming.mjs +1 -0
  50. package/hooks/ensure-deps.mjs +80 -2
  51. package/hooks/pretooluse.mjs +25 -20
  52. package/hooks/routing-block.mjs +10 -1
  53. package/hooks/session-snapshot.bundle.mjs +13 -13
  54. package/hooks/sessionstart.mjs +25 -1
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +1 -1
  57. package/server.bundle.mjs +159 -129
  58. package/skills/context-mode-ops/SKILL.md +111 -0
  59. package/skills/context-mode-ops/agent-teams.md +198 -0
  60. package/skills/context-mode-ops/communication.md +224 -0
  61. package/skills/context-mode-ops/release.md +199 -0
  62. package/skills/context-mode-ops/review-pr.md +269 -0
  63. package/skills/context-mode-ops/tdd.md +329 -0
  64. package/skills/context-mode-ops/triage-issue.md +218 -0
  65. package/skills/context-mode-ops/validation.md +238 -0
  66. package/start.mjs +5 -52
package/build/server.js CHANGED
@@ -321,6 +321,33 @@ export function extractSnippet(content, query, maxLen = 1500, highlighted) {
321
321
  }
322
322
  return parts.join("\n\n");
323
323
  }
324
+ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024) {
325
+ const sections = [];
326
+ let outputSize = 0;
327
+ for (const query of queries) {
328
+ if (outputSize > maxOutput) {
329
+ sections.push(`## ${query}\n(output cap reached — use search(queries: ["${query}"]) for details)\n`);
330
+ continue;
331
+ }
332
+ const results = store.searchWithFallback(query, 3, source, undefined, "exact");
333
+ sections.push(`## ${query}`);
334
+ sections.push("");
335
+ if (results.length > 0) {
336
+ for (const result of results) {
337
+ const snippet = extractSnippet(result.content, query, 3000, result.highlighted);
338
+ sections.push(`### ${result.title}`);
339
+ sections.push(snippet);
340
+ sections.push("");
341
+ outputSize += snippet.length + result.title.length;
342
+ }
343
+ continue;
344
+ }
345
+ sections.push("No matching sections found.");
346
+ sections.push("");
347
+ }
348
+ sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\`.`);
349
+ return sections;
350
+ }
324
351
  // ─────────────────────────────────────────────────────────
325
352
  // Tool: execute
326
353
  // ─────────────────────────────────────────────────────────
@@ -486,6 +513,16 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
486
513
  isError,
487
514
  });
488
515
  }
516
+ // Auto-index large error output into FTS5 — no data loss
517
+ if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
518
+ trackIndexed(Buffer.byteLength(output));
519
+ return trackResponse("ctx_execute", {
520
+ content: [
521
+ { type: "text", text: intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`) },
522
+ ],
523
+ isError,
524
+ });
525
+ }
489
526
  return trackResponse("ctx_execute", {
490
527
  content: [
491
528
  { type: "text", text: output },
@@ -503,6 +540,10 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
503
540
  ],
504
541
  });
505
542
  }
543
+ // Auto-index large stdout into FTS5 — return pointer, not raw content
544
+ if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
545
+ return trackResponse("ctx_execute", indexStdout(stdout, `execute:${language}`));
546
+ }
506
547
  return trackResponse("ctx_execute", {
507
548
  content: [
508
549
  { type: "text", text: stdout },
@@ -539,6 +580,7 @@ function indexStdout(stdout, source) {
539
580
  // Helper: intent-driven search on execution output
540
581
  // ─────────────────────────────────────────────────────────
541
582
  const INTENT_SEARCH_THRESHOLD = 5_000; // bytes — ~80-100 lines
583
+ const LARGE_OUTPUT_THRESHOLD = 102_400; // 100KB — auto-index into FTS5, return pointer
542
584
  function intentSearch(stdout, intent, source, maxResults = 5) {
543
585
  const totalLines = stdout.split("\n").length;
544
586
  const totalBytes = Buffer.byteLength(stdout);
@@ -666,6 +708,16 @@ server.registerTool("ctx_execute_file", {
666
708
  isError,
667
709
  });
668
710
  }
711
+ // Auto-index large error output into FTS5 — no data loss
712
+ if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
713
+ trackIndexed(Buffer.byteLength(output));
714
+ return trackResponse("ctx_execute_file", {
715
+ content: [
716
+ { type: "text", text: intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`) },
717
+ ],
718
+ isError,
719
+ });
720
+ }
669
721
  return trackResponse("ctx_execute_file", {
670
722
  content: [
671
723
  { type: "text", text: output },
@@ -682,6 +734,10 @@ server.registerTool("ctx_execute_file", {
682
734
  ],
683
735
  });
684
736
  }
737
+ // Auto-index large stdout into FTS5 — return pointer, not raw content
738
+ if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
739
+ return trackResponse("ctx_execute_file", indexStdout(stdout, `file:${path}`));
740
+ }
685
741
  return trackResponse("ctx_execute_file", {
686
742
  content: [
687
743
  { type: "text", text: stdout },
@@ -1091,7 +1147,7 @@ server.registerTool("ctx_fetch_and_index", {
1091
1147
  // Parse content-type marker from stdout (content is in the temp file)
1092
1148
  const store = getStore();
1093
1149
  const header = (result.stdout || "").trim();
1094
- // Read full content from temp file (bypasses smartTruncate)
1150
+ // Read full content from temp file
1095
1151
  let markdown;
1096
1152
  try {
1097
1153
  markdown = readFileSync(outputPath, "utf-8").trim();
@@ -1209,9 +1265,8 @@ server.registerTool("ctx_batch_execute", {
1209
1265
  }
1210
1266
  try {
1211
1267
  // Execute each command individually so every command gets its own
1212
- // smartTruncate budget (~100KB). Previously, all commands were
1213
- // concatenated into a single script where smartTruncate (60% head +
1214
- // 40% tail) could silently drop middle commands. (Issue #61)
1268
+ // output capture. Full stdout is preserved and indexed into FTS5.
1269
+ // (Issue #61, #197)
1215
1270
  const perCommandOutputs = [];
1216
1271
  const startTime = Date.now();
1217
1272
  let timedOut = false;
@@ -1272,45 +1327,9 @@ server.registerTool("ctx_batch_execute", {
1272
1327
  inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
1273
1328
  sectionTitles.push(s.title);
1274
1329
  }
1275
- // Run all search queries — 3 results each, smart snippets
1276
- // Three-tier fallback: scoped boosted global
1277
- const MAX_OUTPUT = 80 * 1024; // 80KB total output cap
1278
- const queryResults = [];
1279
- let outputSize = 0;
1280
- for (const query of queries) {
1281
- if (outputSize > MAX_OUTPUT) {
1282
- queryResults.push(`## ${query}\n(output cap reached — use search(queries: ["${query}"]) for details)\n`);
1283
- continue;
1284
- }
1285
- // Tier 1: scoped search with fallback (porter → trigram → fuzzy)
1286
- let results = store.searchWithFallback(query, 3, source);
1287
- let crossSource = false;
1288
- // Tier 2: global fallback (no source filter) — warn about cross-source (Issue #61)
1289
- if (results.length === 0) {
1290
- results = store.searchWithFallback(query, 3);
1291
- crossSource = results.length > 0;
1292
- }
1293
- queryResults.push(`## ${query}`);
1294
- if (crossSource) {
1295
- queryResults.push(`> **Note:** No results in current batch output. Showing results from previously indexed content.`);
1296
- }
1297
- queryResults.push("");
1298
- if (results.length > 0) {
1299
- for (const r of results) {
1300
- // Use larger snippet (3KB) for batch_execute to reduce tiny-fragment issue (Issue #61)
1301
- const snippet = extractSnippet(r.content, query, 3000, r.highlighted);
1302
- const sourceTag = crossSource ? ` _(source: ${r.source})_` : "";
1303
- queryResults.push(`### ${r.title}${sourceTag}`);
1304
- queryResults.push(snippet);
1305
- queryResults.push("");
1306
- outputSize += snippet.length + r.title.length;
1307
- }
1308
- }
1309
- else {
1310
- queryResults.push("No matching sections found.");
1311
- queryResults.push("");
1312
- }
1313
- }
1330
+ // Run all search queries — source scoped only.
1331
+ // Cross-source search remains available via explicit search().
1332
+ const queryResults = formatBatchQueryResults(store, queries, source);
1314
1333
  // Get searchable terms for edge cases where follow-up is needed
1315
1334
  const distinctiveTerms = store.getDistinctiveTerms
1316
1335
  ? store.getDistinctiveTerms(indexed.sourceId)
@@ -1608,7 +1627,7 @@ server.registerTool("ctx_upgrade", {
1608
1627
  // Write inline script to a temp .mjs file — avoids quote-escaping issues
1609
1628
  // across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
1610
1629
  const scriptLines = [
1611
- `import{execSync}from"node:child_process";`,
1630
+ `import{execFileSync}from"node:child_process";`,
1612
1631
  `import{cpSync,rmSync,existsSync,mkdtempSync}from"node:fs";`,
1613
1632
  `import{join}from"node:path";`,
1614
1633
  `import{tmpdir}from"node:os";`,
@@ -1616,15 +1635,15 @@ server.registerTool("ctx_upgrade", {
1616
1635
  `const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
1617
1636
  `try{`,
1618
1637
  `console.log("- [x] Starting inline upgrade (no CLI found)");`,
1619
- `execSync("git clone --depth 1 ${repoUrl} \\""+T+"\\"",{stdio:"inherit"});`,
1638
+ `execFileSync("git",["clone","--depth","1","${repoUrl}",T],{stdio:"inherit"});`,
1620
1639
  `console.log("- [x] Cloned latest source");`,
1621
- `execSync("npm install",{cwd:T,stdio:"inherit"});`,
1622
- `execSync("npm run build",{cwd:T,stdio:"inherit"});`,
1640
+ `execFileSync("npm",["install"],{cwd:T,stdio:"inherit"});`,
1641
+ `execFileSync("npm",["run","build"],{cwd:T,stdio:"inherit"});`,
1623
1642
  `console.log("- [x] Built from source");`,
1624
1643
  ...copyDirs.map((d) => `if(existsSync(join(T,${JSON.stringify(d)})))cpSync(join(T,${JSON.stringify(d)}),join(P,${JSON.stringify(d)}),{recursive:true,force:true});`),
1625
1644
  ...copyFiles.map((f) => `if(existsSync(join(T,${JSON.stringify(f)})))cpSync(join(T,${JSON.stringify(f)}),join(P,${JSON.stringify(f)}),{force:true});`),
1626
1645
  `console.log("- [x] Copied build artifacts");`,
1627
- `execSync("npm install --production",{cwd:P,stdio:"inherit"});`,
1646
+ `execFileSync("npm",["install","--production"],{cwd:P,stdio:"inherit"});`,
1628
1647
  `console.log("- [x] Installed production dependencies");`,
1629
1648
  `console.log("## context-mode upgrade complete");`,
1630
1649
  `}catch(e){`,
@@ -1692,26 +1711,15 @@ async function main() {
1692
1711
  startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
1693
1712
  const transport = new StdioServerTransport();
1694
1713
  await server.connect(transport);
1695
- // Write routing instructions for hookless platforms (e.g. Codex CLI, Antigravity)
1714
+ // Log detected MCP client for diagnostics
1696
1715
  try {
1697
1716
  const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
1698
1717
  const clientInfo = server.server.getClientVersion();
1699
1718
  const signal = detectPlatform(clientInfo ?? undefined);
1700
- const adapter = await getAdapter(signal.platform);
1719
+ await getAdapter(signal.platform);
1701
1720
  if (clientInfo) {
1702
1721
  console.error(`MCP client: ${clientInfo.name} v${clientInfo.version} → ${signal.platform}`);
1703
1722
  }
1704
- // Routing file auto-write DISABLED for all platforms (#158, #164).
1705
- // Writing to project dirs dirties git trees and env var detection at
1706
- // MCP startup is unreliable. Routing is injected via SessionStart hooks
1707
- // for hook-capable platforms. Non-hook platforms rely on manual setup
1708
- // until `context-mode init` command is implemented.
1709
- // if (!adapter.capabilities.sessionStart) {
1710
- // const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
1711
- // const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_HOME ?? process.cwd();
1712
- // const written = adapter.writeRoutingInstructions(projectDir, pluginRoot);
1713
- // if (written) console.error(`Wrote routing instructions: ${written}`);
1714
- // }
1715
1723
  }
1716
1724
  catch { /* best effort — don't block server startup */ }
1717
1725
  console.error(`Context Mode MCP server v${VERSION} running on stdio`);
package/build/store.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Use for documentation, API references, and any content where
8
8
  * you need EXACT text later — not summaries.
9
9
  */
10
+ type SourceMatchMode = "like" | "exact";
10
11
  import type { IndexResult, SearchResult, StoreStats } from "./types.js";
11
12
  export type { IndexResult, SearchResult, StoreStats } from "./types.js";
12
13
  /**
@@ -42,10 +43,10 @@ export declare class ContentStore {
42
43
  * Falls back to `indexPlainText` if the content is not valid JSON.
43
44
  */
44
45
  indexJSON(content: string, source: string, maxChunkBytes?: number): IndexResult;
45
- search(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose"): SearchResult[];
46
- searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose"): SearchResult[];
46
+ search(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
47
+ searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
47
48
  fuzzyCorrect(query: string): string | null;
48
- searchWithFallback(query: string, limit?: number, source?: string, contentType?: "code" | "prose"): SearchResult[];
49
+ searchWithFallback(query: string, limit?: number, source?: string, contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
49
50
  getSourceMeta(label: string): {
50
51
  label: string;
51
52
  chunkCount: number;
package/build/store.js CHANGED
@@ -212,13 +212,17 @@ export class ContentStore {
212
212
  // Search path (hot)
213
213
  #stmtSearchPorter;
214
214
  #stmtSearchPorterFiltered;
215
+ #stmtSearchPorterExact;
215
216
  #stmtSearchTrigram;
216
217
  #stmtSearchTrigramFiltered;
218
+ #stmtSearchTrigramExact;
217
219
  #stmtFuzzyVocab;
218
220
  #stmtSearchPorterContentType;
219
221
  #stmtSearchPorterFilteredContentType;
222
+ #stmtSearchPorterExactContentType;
220
223
  #stmtSearchTrigramContentType;
221
224
  #stmtSearchTrigramFilteredContentType;
225
+ #stmtSearchTrigramExactContentType;
222
226
  // Read path
223
227
  #stmtListSources;
224
228
  #stmtChunksBySource;
@@ -278,6 +282,8 @@ export class ContentStore {
278
282
  CREATE TABLE IF NOT EXISTS vocabulary (
279
283
  word TEXT PRIMARY KEY
280
284
  );
285
+
286
+ CREATE INDEX IF NOT EXISTS idx_sources_label ON sources(label);
281
287
  `);
282
288
  }
283
289
  #prepareStatements() {
@@ -320,6 +326,20 @@ export class ContentStore {
320
326
  WHERE chunks MATCH ? AND sources.label LIKE ?
321
327
  ORDER BY rank
322
328
  LIMIT ?
329
+ `);
330
+ this.#stmtSearchPorterExact = this.#db.prepare(`
331
+ SELECT
332
+ chunks.title,
333
+ chunks.content,
334
+ chunks.content_type,
335
+ sources.label,
336
+ bm25(chunks, 5.0, 1.0) AS rank,
337
+ highlight(chunks, 1, char(2), char(3)) AS highlighted
338
+ FROM chunks
339
+ JOIN sources ON sources.id = chunks.source_id
340
+ WHERE chunks MATCH ? AND sources.label = ?
341
+ ORDER BY rank
342
+ LIMIT ?
323
343
  `);
324
344
  this.#stmtSearchTrigram = this.#db.prepare(`
325
345
  SELECT
@@ -348,6 +368,20 @@ export class ContentStore {
348
368
  WHERE chunks_trigram MATCH ? AND sources.label LIKE ?
349
369
  ORDER BY rank
350
370
  LIMIT ?
371
+ `);
372
+ this.#stmtSearchTrigramExact = this.#db.prepare(`
373
+ SELECT
374
+ chunks_trigram.title,
375
+ chunks_trigram.content,
376
+ chunks_trigram.content_type,
377
+ sources.label,
378
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
379
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
380
+ FROM chunks_trigram
381
+ JOIN sources ON sources.id = chunks_trigram.source_id
382
+ WHERE chunks_trigram MATCH ? AND sources.label = ?
383
+ ORDER BY rank
384
+ LIMIT ?
351
385
  `);
352
386
  // Content-type filtered variants
353
387
  this.#stmtSearchPorterContentType = this.#db.prepare(`
@@ -377,6 +411,20 @@ export class ContentStore {
377
411
  WHERE chunks MATCH ? AND sources.label LIKE ? AND chunks.content_type = ?
378
412
  ORDER BY rank
379
413
  LIMIT ?
414
+ `);
415
+ this.#stmtSearchPorterExactContentType = this.#db.prepare(`
416
+ SELECT
417
+ chunks.title,
418
+ chunks.content,
419
+ chunks.content_type,
420
+ sources.label,
421
+ bm25(chunks, 5.0, 1.0) AS rank,
422
+ highlight(chunks, 1, char(2), char(3)) AS highlighted
423
+ FROM chunks
424
+ JOIN sources ON sources.id = chunks.source_id
425
+ WHERE chunks MATCH ? AND sources.label = ? AND chunks.content_type = ?
426
+ ORDER BY rank
427
+ LIMIT ?
380
428
  `);
381
429
  this.#stmtSearchTrigramContentType = this.#db.prepare(`
382
430
  SELECT
@@ -405,6 +453,20 @@ export class ContentStore {
405
453
  WHERE chunks_trigram MATCH ? AND sources.label LIKE ? AND chunks_trigram.content_type = ?
406
454
  ORDER BY rank
407
455
  LIMIT ?
456
+ `);
457
+ this.#stmtSearchTrigramExactContentType = this.#db.prepare(`
458
+ SELECT
459
+ chunks_trigram.title,
460
+ chunks_trigram.content,
461
+ chunks_trigram.content_type,
462
+ sources.label,
463
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
464
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
465
+ FROM chunks_trigram
466
+ JOIN sources ON sources.id = chunks_trigram.source_id
467
+ WHERE chunks_trigram MATCH ? AND sources.label = ? AND chunks_trigram.content_type = ?
468
+ ORDER BY rank
469
+ LIMIT ?
408
470
  `);
409
471
  // Fuzzy path
410
472
  this.#stmtFuzzyVocab = this.#db.prepare("SELECT word FROM vocabulary WHERE length(word) BETWEEN ? AND ?");
@@ -514,17 +576,34 @@ export class ContentStore {
514
576
  };
515
577
  }
516
578
  // ── Search ──
517
- search(query, limit = 3, source, mode = "AND", contentType) {
579
+ #mapSearchRows(rows) {
580
+ return rows.map((r) => ({
581
+ title: r.title,
582
+ content: r.content,
583
+ source: r.label,
584
+ rank: r.rank,
585
+ contentType: r.content_type,
586
+ highlighted: r.highlighted,
587
+ }));
588
+ }
589
+ #sourceFilterParam(source, sourceMatchMode) {
590
+ return sourceMatchMode === "exact" ? source : `%${source}%`;
591
+ }
592
+ search(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
518
593
  const sanitized = sanitizeQuery(query, mode);
519
594
  let stmt;
520
595
  let params;
521
596
  if (source && contentType) {
522
- stmt = this.#stmtSearchPorterFilteredContentType;
523
- params = [sanitized, `%${source}%`, contentType, limit];
597
+ stmt = sourceMatchMode === "exact"
598
+ ? this.#stmtSearchPorterExactContentType
599
+ : this.#stmtSearchPorterFilteredContentType;
600
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
524
601
  }
525
602
  else if (source) {
526
- stmt = this.#stmtSearchPorterFiltered;
527
- params = [sanitized, `%${source}%`, limit];
603
+ stmt = sourceMatchMode === "exact"
604
+ ? this.#stmtSearchPorterExact
605
+ : this.#stmtSearchPorterFiltered;
606
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
528
607
  }
529
608
  else if (contentType) {
530
609
  stmt = this.#stmtSearchPorterContentType;
@@ -534,30 +613,26 @@ export class ContentStore {
534
613
  stmt = this.#stmtSearchPorter;
535
614
  params = [sanitized, limit];
536
615
  }
537
- const rows = stmt.all(...params);
538
- return rows.map((r) => ({
539
- title: r.title,
540
- content: r.content,
541
- source: r.label,
542
- rank: r.rank,
543
- contentType: r.content_type,
544
- highlighted: r.highlighted,
545
- }));
616
+ return this.#mapSearchRows(stmt.all(...params));
546
617
  }
547
618
  // ── Trigram Search (Layer 2) ──
548
- searchTrigram(query, limit = 3, source, mode = "AND", contentType) {
619
+ searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
549
620
  const sanitized = sanitizeTrigramQuery(query, mode);
550
621
  if (!sanitized)
551
622
  return [];
552
623
  let stmt;
553
624
  let params;
554
625
  if (source && contentType) {
555
- stmt = this.#stmtSearchTrigramFilteredContentType;
556
- params = [sanitized, `%${source}%`, contentType, limit];
626
+ stmt = sourceMatchMode === "exact"
627
+ ? this.#stmtSearchTrigramExactContentType
628
+ : this.#stmtSearchTrigramFilteredContentType;
629
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
557
630
  }
558
631
  else if (source) {
559
- stmt = this.#stmtSearchTrigramFiltered;
560
- params = [sanitized, `%${source}%`, limit];
632
+ stmt = sourceMatchMode === "exact"
633
+ ? this.#stmtSearchTrigramExact
634
+ : this.#stmtSearchTrigramFiltered;
635
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
561
636
  }
562
637
  else if (contentType) {
563
638
  stmt = this.#stmtSearchTrigramContentType;
@@ -567,15 +642,7 @@ export class ContentStore {
567
642
  stmt = this.#stmtSearchTrigram;
568
643
  params = [sanitized, limit];
569
644
  }
570
- const rows = stmt.all(...params);
571
- return rows.map((r) => ({
572
- title: r.title,
573
- content: r.content,
574
- source: r.label,
575
- rank: r.rank,
576
- contentType: r.content_type,
577
- highlighted: r.highlighted,
578
- }));
645
+ return this.#mapSearchRows(stmt.all(...params));
579
646
  }
580
647
  // ── Fuzzy Correction (Layer 3) ──
581
648
  fuzzyCorrect(query) {
@@ -598,11 +665,11 @@ export class ContentStore {
598
665
  return bestDist <= maxDist ? bestWord : null;
599
666
  }
600
667
  // ── Reciprocal Rank Fusion (Cormack et al. 2009) ──
601
- #rrfSearch(query, limit, source, contentType) {
668
+ #rrfSearch(query, limit, source, contentType, sourceMatchMode = "like") {
602
669
  const K = 60; // Standard RRF constant
603
670
  const fetchLimit = Math.max(limit * 2, 10);
604
- const porterResults = this.search(query, fetchLimit, source, "OR", contentType);
605
- const trigramResults = this.searchTrigram(query, fetchLimit, source, "OR", contentType);
671
+ const porterResults = this.search(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
672
+ const trigramResults = this.searchTrigram(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
606
673
  const scoreMap = new Map();
607
674
  const key = (r) => `${r.source}::${r.title}`;
608
675
  for (const [i, r] of porterResults.entries()) {
@@ -655,9 +722,9 @@ export class ContentStore {
655
722
  .map(({ result }) => result);
656
723
  }
657
724
  // ── Unified Fallback Search ──
658
- searchWithFallback(query, limit = 3, source, contentType) {
725
+ searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like") {
659
726
  // Step 1: RRF fusion (porter OR + trigram OR → merge)
660
- const rrfResults = this.#rrfSearch(query, limit, source, contentType);
727
+ const rrfResults = this.#rrfSearch(query, limit, source, contentType, sourceMatchMode);
661
728
  if (rrfResults.length > 0) {
662
729
  const reranked = this.#applyProximityReranking(rrfResults, query);
663
730
  return reranked.map((r) => ({ ...r, matchLayer: "rrf" }));
@@ -672,7 +739,7 @@ export class ContentStore {
672
739
  const correctedWords = words.map((w) => this.fuzzyCorrect(w) ?? w);
673
740
  const correctedQuery = correctedWords.join(" ");
674
741
  if (correctedQuery !== original) {
675
- const fuzzyResults = this.#rrfSearch(correctedQuery, limit, source, contentType);
742
+ const fuzzyResults = this.#rrfSearch(correctedQuery, limit, source, contentType, sourceMatchMode);
676
743
  if (fuzzyResults.length > 0) {
677
744
  const reranked = this.#applyProximityReranking(fuzzyResults, correctedQuery);
678
745
  return reranked.map((r) => ({ ...r, matchLayer: "rrf-fuzzy" }));
@@ -1,10 +1,9 @@
1
1
  /**
2
- * truncate — Pure string and output truncation utilities for context-mode.
2
+ * truncate — Pure string truncation and escaping utilities for context-mode.
3
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.
4
+ * These helpers are used by the core ContentStore (chunking) and
5
+ * SessionDB (snapshot building). They are extracted here so any
6
+ * consumer can import them without pulling in the full store or executor.
8
7
  */
9
8
  /**
10
9
  * Truncate a string to at most `maxChars` characters, appending an ellipsis
@@ -16,18 +15,6 @@
16
15
  * ending with "...".
17
16
  */
18
17
  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
18
  /**
32
19
  * Serialize a value to JSON, then truncate the result to `maxBytes` bytes.
33
20
  * If truncation occurs, the string is cut at a UTF-8-safe boundary and
package/build/truncate.js CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * truncate — Pure string and output truncation utilities for context-mode.
2
+ * truncate — Pure string truncation and escaping utilities for context-mode.
3
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.
4
+ * These helpers are used by the core ContentStore (chunking) and
5
+ * SessionDB (snapshot building). They are extracted here so any
6
+ * consumer can import them without pulling in the full store or executor.
8
7
  */
9
8
  // ─────────────────────────────────────────────────────────
10
9
  // String truncation
@@ -24,53 +23,6 @@ export function truncateString(str, maxChars) {
24
23
  return str.slice(0, Math.max(0, maxChars - 3)) + "...";
25
24
  }
26
25
  // ─────────────────────────────────────────────────────────
27
- // Byte-aware smart truncation (head + tail)
28
- // ─────────────────────────────────────────────────────────
29
- /**
30
- * Smart truncation that keeps the head (60%) and tail (40%) of output,
31
- * preserving both initial context and final error messages.
32
- * Snaps to line boundaries and handles UTF-8 safely via `Buffer.byteLength`.
33
- *
34
- * Used by PolyglotExecutor to cap stdout/stderr before returning to context.
35
- *
36
- * @param raw - Raw output string.
37
- * @param maxBytes - Soft cap in bytes. Output below this threshold is returned as-is.
38
- * @returns The original string if within budget, otherwise head + separator + tail.
39
- */
40
- export function smartTruncate(raw, maxBytes) {
41
- if (Buffer.byteLength(raw) <= maxBytes)
42
- return raw;
43
- const lines = raw.split("\n");
44
- // Budget: 60% head, 40% tail (errors/results are usually at the end)
45
- const headBudget = Math.floor(maxBytes * 0.6);
46
- const tailBudget = maxBytes - headBudget;
47
- // Collect head lines
48
- const headLines = [];
49
- let headBytes = 0;
50
- for (const line of lines) {
51
- const lineBytes = Buffer.byteLength(line) + 1; // +1 for \n
52
- if (headBytes + lineBytes > headBudget)
53
- break;
54
- headLines.push(line);
55
- headBytes += lineBytes;
56
- }
57
- // Collect tail lines (from end)
58
- const tailLines = [];
59
- let tailBytes = 0;
60
- for (let i = lines.length - 1; i >= headLines.length; i--) {
61
- const lineBytes = Buffer.byteLength(lines[i]) + 1;
62
- if (tailBytes + lineBytes > tailBudget)
63
- break;
64
- tailLines.unshift(lines[i]);
65
- tailBytes += lineBytes;
66
- }
67
- const skippedLines = lines.length - headLines.length - tailLines.length;
68
- const skippedBytes = Buffer.byteLength(raw) - headBytes - tailBytes;
69
- const separator = `\n\n... [${skippedLines} lines / ${(skippedBytes / 1024).toFixed(1)}KB truncated` +
70
- ` — showing first ${headLines.length} + last ${tailLines.length} lines] ...\n\n`;
71
- return headLines.join("\n") + separator + tailLines.join("\n");
72
- }
73
- // ─────────────────────────────────────────────────────────
74
26
  // JSON truncation
75
27
  // ─────────────────────────────────────────────────────────
76
28
  /**