context-mode 1.0.53 → 1.0.54

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/build/executor.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn, execSync } from "node:child_process";
1
+ import { spawn, execSync, execFileSync } from "node:child_process";
2
2
  import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -50,7 +50,7 @@ export class PolyglotExecutor {
50
50
  }
51
51
  async execute(opts) {
52
52
  const { language, code, timeout = 30_000, background = false } = opts;
53
- const tmpDir = mkdtempSync(join(tmpdir(), "ctx-mode-"));
53
+ const tmpDir = mkdtempSync(join(tmpdir(), ".ctx-mode-"));
54
54
  try {
55
55
  const filePath = this.#writeScript(tmpDir, code, language);
56
56
  const cmd = buildCommand(this.#runtimes, language, filePath);
@@ -127,7 +127,7 @@ export class PolyglotExecutor {
127
127
  const binPath = srcPath.replace(/\.rs$/, "") + binSuffix;
128
128
  // Compile
129
129
  try {
130
- execSync(`rustc ${srcPath} -o ${binPath}`, {
130
+ execFileSync("rustc", [srcPath, "-o", binPath], {
131
131
  cwd,
132
132
  timeout: Math.min(timeout, 60_000),
133
133
  encoding: "utf-8",
@@ -130,17 +130,17 @@ export default {
130
130
  catch {
131
131
  // best effort
132
132
  }
133
- // Async init: load routing module + write AGENTS.md. Hooks await this.
133
+ // Async init: load routing module. Hooks await this.
134
+ // NOTE: writeRoutingInstructions is intentionally NOT called here.
135
+ // process.cwd() at plugin load time is the gateway's working directory, not
136
+ // the agent's workspace. Writing AGENTS.md to cwd() caused the file to be
137
+ // created in arbitrary directories (repo roots, config dirs, $HOME, etc.).
138
+ // The write is now deferred to session_start where the real workspace path
139
+ // is known via the sessionKey → workspace mapping.
134
140
  const initPromise = (async () => {
135
141
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
136
142
  const routing = await import(pathToFileURL(routingPath).href);
137
143
  await routing.initSecurity(buildDir);
138
- try {
139
- new OpenClawAdapter().writeRoutingInstructions(projectDir, pluginRoot);
140
- }
141
- catch {
142
- // best effort — never break plugin init
143
- }
144
144
  return { routing };
145
145
  })();
146
146
  // ── 1. tool_call:before — Routing enforcement ──────────
@@ -315,6 +315,33 @@ export default {
315
315
  workspaceRouter.registerSession(key, sessionId);
316
316
  }
317
317
  resumeInjected = false;
318
+ // Write routing instructions (AGENTS.md) now that we know the real
319
+ // workspace. Derive the workspace directory from the sessionKey so we
320
+ // only write into recognised /.openclaw/workspace* paths, never into
321
+ // the gateway's cwd or any other arbitrary directory.
322
+ if (key) {
323
+ try {
324
+ const adapter = new OpenClawAdapter();
325
+ const openclawBase = resolve(homedir(), ".openclaw");
326
+ // Resolve workspace dir from sessionKey (pattern: agent:<name>:*)
327
+ // Restrict agent name to safe characters to prevent path traversal (#183)
328
+ const wsMatch = key.match(/^agent:([a-zA-Z0-9_-]+):/);
329
+ let wsDir;
330
+ if (wsMatch) {
331
+ wsDir = resolve(openclawBase, `workspace-${wsMatch[1]}`);
332
+ }
333
+ else {
334
+ wsDir = resolve(openclawBase, "workspace");
335
+ }
336
+ // Containment check: never write outside ~/.openclaw/
337
+ if (wsDir.startsWith(openclawBase)) {
338
+ adapter.writeRoutingInstructions(wsDir, pluginRoot);
339
+ }
340
+ }
341
+ catch {
342
+ // best effort — never break session start
343
+ }
344
+ }
318
345
  }
319
346
  catch {
320
347
  // best effort — never break session start
@@ -402,7 +429,7 @@ export default {
402
429
  info: {
403
430
  id: "context-mode",
404
431
  name: "Context Mode",
405
- ownsCompaction: true,
432
+ ownsCompaction: false,
406
433
  },
407
434
  async ingest() {
408
435
  return { ingested: true };
@@ -410,31 +437,12 @@ export default {
410
437
  async assemble({ messages }) {
411
438
  return { messages, estimatedTokens: 0 };
412
439
  },
413
- async compact({ currentTokenCount } = {}) {
414
- try {
415
- const sid = sessionId;
416
- const events = db.getEvents(sid);
417
- if (events.length === 0)
418
- return { ok: true, compacted: false };
419
- const stats = db.getSessionStats(sid);
420
- const compactCount = (stats?.compact_count ?? 0) + 1;
421
- const snapshot = buildResumeSnapshot(events, { compactCount });
422
- db.upsertResume(sid, snapshot, events.length);
423
- db.incrementCompactCount(sid);
424
- return {
425
- ok: true,
426
- compacted: true,
427
- result: {
428
- summary: snapshot,
429
- firstKeptEntryId: "", // clear all history before this compaction
430
- tokensBefore: currentTokenCount ?? 0,
431
- tokensAfter: 0,
432
- },
433
- };
434
- }
435
- catch {
436
- return { ok: false, compacted: false };
437
- }
440
+ async compact() {
441
+ // No-op: session continuity is handled by before_compaction / after_compaction hooks.
442
+ // Returning ownsCompaction: false + compacted: false lets the host platform (OpenClaw)
443
+ // manage conversation truncation, preserving Anthropic thinking/redacted_thinking blocks.
444
+ // See: https://github.com/mksglu/context-mode/issues/191
445
+ return { ok: true, compacted: false };
438
446
  },
439
447
  }));
440
448
  // ── 10. Auto-reply commands — ctx slash commands ──────
@@ -23,8 +23,11 @@ import { extractEvents } from "./session/extract.js";
23
23
  import { buildResumeSnapshot } from "./session/snapshot.js";
24
24
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
25
25
  // ── Helpers ───────────────────────────────────────────────
26
+ function getPlatform() {
27
+ return process.env.KILO ? "kilo" : "opencode";
28
+ }
26
29
  function getSessionDir() {
27
- const dir = join(homedir(), ".config", "opencode", "context-mode", "sessions");
30
+ const dir = join(homedir(), ".config", getPlatform(), "context-mode", "sessions");
28
31
  mkdirSync(dir, { recursive: true });
29
32
  return dir;
30
33
  }
@@ -54,7 +57,7 @@ export const ContextModePlugin = async (ctx) => {
54
57
  db.ensureSession(sessionId, projectDir);
55
58
  // Auto-write AGENTS.md on startup for OpenCode projects
56
59
  try {
57
- new OpenCodeAdapter().writeRoutingInstructions(projectDir, resolve(buildDir, ".."));
60
+ new OpenCodeAdapter(getPlatform()).writeRoutingInstructions(projectDir, resolve(buildDir, ".."));
58
61
  }
59
62
  catch {
60
63
  // best effort — never break plugin init
package/build/runtime.js CHANGED
@@ -11,6 +11,23 @@ function commandExists(cmd) {
11
11
  return false;
12
12
  }
13
13
  }
14
+ function bunExists() {
15
+ if (commandExists("bun"))
16
+ return true;
17
+ // Bun installs to ~/.bun/bin which may not be in PATH in MCP server environments
18
+ if (!isWindows) {
19
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
20
+ if (home && existsSync(`${home}/.bun/bin/bun`))
21
+ return true;
22
+ }
23
+ return false;
24
+ }
25
+ function bunCommand() {
26
+ if (commandExists("bun"))
27
+ return "bun";
28
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
29
+ return `${home}/.bun/bin/bun`;
30
+ }
14
31
  /**
15
32
  * On Windows, resolve the first non-WSL bash in PATH.
16
33
  * WSL bash (C:\Windows\System32\bash.exe) cannot handle Windows paths,
@@ -59,11 +76,12 @@ function getVersion(cmd) {
59
76
  }
60
77
  }
61
78
  export function detectRuntimes() {
62
- const hasBun = commandExists("bun");
79
+ const hasBun = bunExists();
80
+ const bun = hasBun ? bunCommand() : null;
63
81
  return {
64
- javascript: hasBun ? "bun" : "node",
65
- typescript: hasBun
66
- ? "bun"
82
+ javascript: bun ?? process.execPath,
83
+ typescript: bun
84
+ ? bun
67
85
  : commandExists("tsx")
68
86
  ? "tsx"
69
87
  : commandExists("ts-node")
@@ -91,11 +109,11 @@ export function detectRuntimes() {
91
109
  };
92
110
  }
93
111
  export function hasBunRuntime() {
94
- return commandExists("bun");
112
+ return bunExists();
95
113
  }
96
114
  export function getRuntimeSummary(runtimes) {
97
115
  const lines = [];
98
- const bunPreferred = runtimes.javascript === "bun";
116
+ const bunPreferred = runtimes.javascript?.endsWith("bun") ?? false;
99
117
  lines.push(` JavaScript: ${runtimes.javascript} (${getVersion(runtimes.javascript)})${bunPreferred ? " ⚡" : ""}`);
100
118
  if (runtimes.typescript) {
101
119
  lines.push(` TypeScript: ${runtimes.typescript} (${getVersion(runtimes.typescript)})`);
@@ -156,15 +174,15 @@ export function getAvailableLanguages(runtimes) {
156
174
  export function buildCommand(runtimes, language, filePath) {
157
175
  switch (language) {
158
176
  case "javascript":
159
- return runtimes.javascript === "bun"
160
- ? ["bun", "run", filePath]
161
- : ["node", filePath];
177
+ return runtimes.javascript.endsWith("bun")
178
+ ? [runtimes.javascript, "run", filePath]
179
+ : [runtimes.javascript, filePath];
162
180
  case "typescript":
163
181
  if (!runtimes.typescript) {
164
182
  throw new Error("No TypeScript runtime available. Install one of: bun (recommended), tsx (npm i -g tsx), or ts-node.");
165
183
  }
166
- if (runtimes.typescript === "bun")
167
- return ["bun", "run", filePath];
184
+ if (runtimes.typescript?.endsWith("bun"))
185
+ return [runtimes.typescript, "run", filePath];
168
186
  if (runtimes.typescript === "tsx")
169
187
  return ["tsx", filePath];
170
188
  return ["ts-node", filePath];
package/build/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { ContentStore } from "./store.js";
2
3
  /**
3
4
  * Parse FTS5 highlight markers to find match positions in the
4
5
  * original (marker-free) text. Returns character offsets into the
@@ -6,3 +7,4 @@
6
7
  */
7
8
  export declare function positionsFromHighlight(highlighted: string): number[];
8
9
  export declare function extractSnippet(content: string, query: string, maxLen?: number, highlighted?: string): string;
10
+ export declare function formatBatchQueryResults(store: ContentStore, queries: string[], source: string, maxOutput?: number): string[];
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
  // ─────────────────────────────────────────────────────────
@@ -1272,45 +1299,9 @@ server.registerTool("ctx_batch_execute", {
1272
1299
  inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
1273
1300
  sectionTitles.push(s.title);
1274
1301
  }
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
- }
1302
+ // Run all search queries — source scoped only.
1303
+ // Cross-source search remains available via explicit search().
1304
+ const queryResults = formatBatchQueryResults(store, queries, source);
1314
1305
  // Get searchable terms for edge cases where follow-up is needed
1315
1306
  const distinctiveTerms = store.getDistinctiveTerms
1316
1307
  ? store.getDistinctiveTerms(indexed.sourceId)
@@ -1608,7 +1599,7 @@ server.registerTool("ctx_upgrade", {
1608
1599
  // Write inline script to a temp .mjs file — avoids quote-escaping issues
1609
1600
  // across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
1610
1601
  const scriptLines = [
1611
- `import{execSync}from"node:child_process";`,
1602
+ `import{execFileSync}from"node:child_process";`,
1612
1603
  `import{cpSync,rmSync,existsSync,mkdtempSync}from"node:fs";`,
1613
1604
  `import{join}from"node:path";`,
1614
1605
  `import{tmpdir}from"node:os";`,
@@ -1616,15 +1607,15 @@ server.registerTool("ctx_upgrade", {
1616
1607
  `const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
1617
1608
  `try{`,
1618
1609
  `console.log("- [x] Starting inline upgrade (no CLI found)");`,
1619
- `execSync("git clone --depth 1 ${repoUrl} \\""+T+"\\"",{stdio:"inherit"});`,
1610
+ `execFileSync("git",["clone","--depth","1","${repoUrl}",T],{stdio:"inherit"});`,
1620
1611
  `console.log("- [x] Cloned latest source");`,
1621
- `execSync("npm install",{cwd:T,stdio:"inherit"});`,
1622
- `execSync("npm run build",{cwd:T,stdio:"inherit"});`,
1612
+ `execFileSync("npm",["install"],{cwd:T,stdio:"inherit"});`,
1613
+ `execFileSync("npm",["run","build"],{cwd:T,stdio:"inherit"});`,
1623
1614
  `console.log("- [x] Built from source");`,
1624
1615
  ...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
1616
  ...copyFiles.map((f) => `if(existsSync(join(T,${JSON.stringify(f)})))cpSync(join(T,${JSON.stringify(f)}),join(P,${JSON.stringify(f)}),{force:true});`),
1626
1617
  `console.log("- [x] Copied build artifacts");`,
1627
- `execSync("npm install --production",{cwd:P,stdio:"inherit"});`,
1618
+ `execFileSync("npm",["install","--production"],{cwd:P,stdio:"inherit"});`,
1628
1619
  `console.log("- [x] Installed production dependencies");`,
1629
1620
  `console.log("## context-mode upgrade complete");`,
1630
1621
  `}catch(e){`,
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" }));