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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +86 -29
- package/build/adapters/claude-code/hooks.d.ts +18 -0
- package/build/adapters/claude-code/hooks.js +23 -0
- package/build/adapters/claude-code/index.js +34 -2
- package/build/adapters/client-map.js +1 -0
- package/build/adapters/detect.d.ts +1 -0
- package/build/adapters/detect.js +18 -2
- package/build/adapters/opencode/index.d.ts +5 -2
- package/build/adapters/opencode/index.js +24 -9
- package/build/adapters/types.d.ts +1 -1
- package/build/cli.js +12 -28
- package/build/executor.js +3 -3
- package/build/openclaw-plugin.js +41 -33
- package/build/opencode-plugin.js +5 -2
- package/build/runtime.js +29 -11
- package/build/server.d.ts +2 -0
- package/build/server.js +35 -44
- package/build/store.d.ts +4 -3
- package/build/store.js +101 -34
- package/cli.bundle.mjs +188 -134
- package/configs/codex/AGENTS.md +19 -0
- package/configs/kilo/AGENTS.md +58 -0
- package/configs/kilo/kilo.json +10 -0
- package/hooks/core/tool-naming.mjs +1 -0
- package/hooks/pretooluse.mjs +25 -20
- package/hooks/sessionstart.mjs +25 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +155 -101
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
|
-
|
|
130
|
+
execFileSync("rustc", [srcPath, "-o", binPath], {
|
|
131
131
|
cwd,
|
|
132
132
|
timeout: Math.min(timeout, 60_000),
|
|
133
133
|
encoding: "utf-8",
|
package/build/openclaw-plugin.js
CHANGED
|
@@ -130,17 +130,17 @@ export default {
|
|
|
130
130
|
catch {
|
|
131
131
|
// best effort
|
|
132
132
|
}
|
|
133
|
-
// Async init: load routing module
|
|
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:
|
|
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(
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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 ──────
|
package/build/opencode-plugin.js
CHANGED
|
@@ -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",
|
|
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 =
|
|
79
|
+
const hasBun = bunExists();
|
|
80
|
+
const bun = hasBun ? bunCommand() : null;
|
|
63
81
|
return {
|
|
64
|
-
javascript:
|
|
65
|
-
typescript:
|
|
66
|
-
?
|
|
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
|
|
112
|
+
return bunExists();
|
|
95
113
|
}
|
|
96
114
|
export function getRuntimeSummary(runtimes) {
|
|
97
115
|
const lines = [];
|
|
98
|
-
const bunPreferred = runtimes.javascript
|
|
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
|
|
160
|
-
? [
|
|
161
|
-
: [
|
|
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
|
|
167
|
-
return [
|
|
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 —
|
|
1276
|
-
//
|
|
1277
|
-
const
|
|
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{
|
|
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
|
-
`
|
|
1610
|
+
`execFileSync("git",["clone","--depth","1","${repoUrl}",T],{stdio:"inherit"});`,
|
|
1620
1611
|
`console.log("- [x] Cloned latest source");`,
|
|
1621
|
-
`
|
|
1622
|
-
`
|
|
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
|
-
`
|
|
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
|
-
|
|
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 =
|
|
523
|
-
|
|
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 =
|
|
527
|
-
|
|
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
|
-
|
|
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 =
|
|
556
|
-
|
|
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 =
|
|
560
|
-
|
|
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
|
-
|
|
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" }));
|