context-mode 1.0.87 → 1.0.89

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.87"
9
+ "version": "1.0.89"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.87",
16
+ "version": "1.0.89",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.87",
3
+ "version": "1.0.89",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.87",
6
+ "version": "1.0.89",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.87",
3
+ "version": "1.0.89",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -421,7 +421,21 @@ export class ClaudeCodeAdapter {
421
421
  if (pluginHooks) {
422
422
  const allCovered = REQUIRED_HOOKS.every((ht) => this.checkHookType(undefined, pluginHooks, ht));
423
423
  if (allCovered) {
424
- // Still write cleaned settings (stale removal) but don't add new entries
424
+ // Remove ALL existing context-mode hooks from settings.json hooks.json
425
+ // is the source of truth. Keeping them causes duplicate concurrent hook
426
+ // processes (one from settings.json, one from hooks.json), which triggers
427
+ // "non-blocking hook error" warnings on every tool call.
428
+ for (const hookType of Object.keys(hooks)) {
429
+ const entries = hooks[hookType];
430
+ if (!Array.isArray(entries))
431
+ continue;
432
+ const filtered = entries.filter((entry) => !isAnyContextModeHook(entry));
433
+ const removed = entries.length - filtered.length;
434
+ if (removed > 0) {
435
+ hooks[hookType] = filtered;
436
+ changes.push(`Removed ${removed} duplicate ${hookType} hook(s) — covered by plugin hooks.json`);
437
+ }
438
+ }
425
439
  settings.hooks = hooks;
426
440
  this.writeSettings(settings);
427
441
  changes.push("Skipped settings.json registration — plugin hooks.json is sufficient");
package/build/cli.js CHANGED
@@ -360,55 +360,108 @@ async function doctor() {
360
360
  * Insight — analytics dashboard
361
361
  * ------------------------------------------------------- */
362
362
  async function insight(port) {
363
- const { execSync, spawn } = await import("node:child_process");
364
- const { statSync, mkdirSync, cpSync } = await import("node:fs");
365
- const insightSource = resolve(getPluginRoot(), "insight");
366
- // Adapter-agnostic cache: use ~/.claude/context-mode/insight-cache as default
367
- // (matches server.ts pattern but CLI doesn't have adapter detection)
368
- const cacheDir = join(homedir(), ".claude", "context-mode", "insight-cache");
369
- if (!existsSync(join(insightSource, "server.mjs"))) {
370
- console.error("Error: Insight source not found. Try upgrading context-mode.");
363
+ try {
364
+ const { execSync, spawn } = await import("node:child_process");
365
+ const { statSync, mkdirSync, cpSync } = await import("node:fs");
366
+ const insightSource = resolve(getPluginRoot(), "insight");
367
+ // Detect platform + adapter for correct session/content paths
368
+ const detection = detectPlatform();
369
+ const adapter = await getAdapter(detection.platform);
370
+ const sessDir = adapter.getSessionDir();
371
+ const contentDir = join(dirname(sessDir), "content");
372
+ const cacheDir = join(dirname(sessDir), "insight-cache");
373
+ if (!existsSync(join(insightSource, "server.mjs"))) {
374
+ console.error("Error: Insight source not found. Try upgrading context-mode.");
375
+ process.exit(1);
376
+ }
377
+ mkdirSync(cacheDir, { recursive: true });
378
+ // Copy source if newer
379
+ const srcMtime = statSync(join(insightSource, "server.mjs")).mtimeMs;
380
+ const cacheMtime = existsSync(join(cacheDir, "server.mjs"))
381
+ ? statSync(join(cacheDir, "server.mjs")).mtimeMs : 0;
382
+ if (srcMtime > cacheMtime) {
383
+ console.log("Copying Insight source...");
384
+ cpSync(insightSource, cacheDir, { recursive: true, force: true });
385
+ }
386
+ // Install deps
387
+ if (!existsSync(join(cacheDir, "node_modules"))) {
388
+ console.log("Installing dependencies (first run)...");
389
+ try {
390
+ execSync("npm install --production=false", { cwd: cacheDir, stdio: "inherit", timeout: 300000 });
391
+ }
392
+ catch {
393
+ // Clean up partial install so next run retries fresh
394
+ try {
395
+ rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
396
+ }
397
+ catch { }
398
+ throw new Error("npm install failed — please retry");
399
+ }
400
+ // Sentinel check: verify install completed (cold cache can timeout leaving partial node_modules)
401
+ if (!existsSync(join(cacheDir, "node_modules", "vite")) || !existsSync(join(cacheDir, "node_modules", "better-sqlite3"))) {
402
+ rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
403
+ throw new Error("npm install incomplete — please retry");
404
+ }
405
+ }
406
+ // Build
407
+ console.log("Building dashboard...");
408
+ execSync("npx vite build", { cwd: cacheDir, stdio: "pipe", timeout: 60000 });
409
+ // Start server
410
+ const url = `http://localhost:${port}`;
411
+ console.log(`\n context-mode Insight\n ${url}\n`);
412
+ const child = spawn("node", [join(cacheDir, "server.mjs")], {
413
+ cwd: cacheDir,
414
+ env: {
415
+ ...process.env,
416
+ PORT: String(port),
417
+ INSIGHT_SESSION_DIR: sessDir,
418
+ INSIGHT_CONTENT_DIR: contentDir,
419
+ },
420
+ stdio: "inherit",
421
+ });
422
+ child.on("error", () => { }); // prevent unhandled error crash
423
+ // Wait for server to be ready, then verify it started
424
+ await new Promise(r => setTimeout(r, 1500));
425
+ try {
426
+ const { request } = await import("node:http");
427
+ await new Promise((resolve, reject) => {
428
+ const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 3000 }, (res) => {
429
+ resolve();
430
+ res.resume();
431
+ });
432
+ req.on("error", reject);
433
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
434
+ req.end();
435
+ });
436
+ }
437
+ catch {
438
+ console.error(`\nError: Port ${port} appears to be in use. Either a previous dashboard is still running, or another service is using this port.`);
439
+ console.error(`\nTo fix:`);
440
+ console.error(` Kill the existing process: ${process.platform === "win32" ? `netstat -ano | findstr :${port}` : `lsof -ti:${port} | xargs kill`}`);
441
+ console.error(` Or use a different port: context-mode insight ${port + 1}`);
442
+ child.kill();
443
+ process.exit(1);
444
+ }
445
+ // Open browser
446
+ const platform = process.platform;
447
+ try {
448
+ if (platform === "darwin")
449
+ execSync(`open "${url}"`, { stdio: "pipe" });
450
+ else if (platform === "win32")
451
+ execSync(`start "" "${url}"`, { stdio: "pipe" });
452
+ else
453
+ execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null`, { stdio: "pipe" });
454
+ }
455
+ catch { /* best effort */ }
456
+ // Keep alive until Ctrl+C
457
+ process.on("SIGINT", () => { child.kill(); process.exit(0); });
458
+ process.on("SIGTERM", () => { child.kill(); process.exit(0); });
459
+ }
460
+ catch (err) {
461
+ const msg = err instanceof Error ? err.message : String(err);
462
+ console.error(`\nInsight error: ${msg}`);
371
463
  process.exit(1);
372
464
  }
373
- mkdirSync(cacheDir, { recursive: true });
374
- // Copy source if newer
375
- const srcMtime = statSync(join(insightSource, "server.mjs")).mtimeMs;
376
- const cacheMtime = existsSync(join(cacheDir, "server.mjs"))
377
- ? statSync(join(cacheDir, "server.mjs")).mtimeMs : 0;
378
- if (srcMtime > cacheMtime) {
379
- console.log("Copying Insight source...");
380
- cpSync(insightSource, cacheDir, { recursive: true, force: true });
381
- }
382
- // Install deps
383
- if (!existsSync(join(cacheDir, "node_modules"))) {
384
- console.log("Installing dependencies (first run)...");
385
- execSync("npm install --production=false", { cwd: cacheDir, stdio: "inherit", timeout: 120000 });
386
- }
387
- // Build
388
- console.log("Building dashboard...");
389
- execSync("npx vite build", { cwd: cacheDir, stdio: "pipe", timeout: 30000 });
390
- // Start server
391
- const url = `http://localhost:${port}`;
392
- console.log(`\n context-mode Insight\n ${url}\n`);
393
- const child = spawn("node", [join(cacheDir, "server.mjs")], {
394
- cwd: cacheDir,
395
- env: { ...process.env, PORT: String(port) },
396
- stdio: "inherit",
397
- });
398
- // Open browser
399
- const platform = process.platform;
400
- try {
401
- if (platform === "darwin")
402
- execSync(`open "${url}"`, { stdio: "pipe" });
403
- else if (platform === "win32")
404
- execSync(`start "" "${url}"`, { stdio: "pipe" });
405
- else
406
- execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null`, { stdio: "pipe" });
407
- }
408
- catch { /* best effort */ }
409
- // Keep alive until Ctrl+C
410
- process.on("SIGINT", () => { child.kill(); process.exit(0); });
411
- process.on("SIGTERM", () => { child.kill(); process.exit(0); });
412
465
  }
413
466
  /* -------------------------------------------------------
414
467
  * Upgrade — adapter-aware hook configuration
@@ -110,6 +110,20 @@ function buildStatsText(db, sessionId) {
110
110
  return "context-mode stats unavailable (session DB error)";
111
111
  }
112
112
  }
113
+ function resolveCommandContext(argsOrCtx, ctx) {
114
+ if (ctx !== undefined)
115
+ return ctx;
116
+ if (argsOrCtx && typeof argsOrCtx === "object")
117
+ return argsOrCtx;
118
+ return undefined;
119
+ }
120
+ function handleCommandText(text, ctx) {
121
+ if (ctx?.hasUI) {
122
+ ctx.ui.notify(text, "info");
123
+ return;
124
+ }
125
+ return { text };
126
+ }
113
127
  // ── Extension entry point ────────────────────────────────
114
128
  /** Pi extension default export. Called once by Pi runtime with the extension API. */
115
129
  export default function piExtension(pi) {
@@ -300,16 +314,18 @@ export default function piExtension(pi) {
300
314
  // ── 8. Slash commands ──────────────────────────────────
301
315
  pi.registerCommand("ctx-stats", {
302
316
  description: "Show context-mode session statistics",
303
- handler: () => {
304
- if (!_db || !_sessionId) {
305
- return { text: "context-mode: no active session" };
306
- }
307
- return { text: buildStatsText(_db, _sessionId) };
317
+ handler: async (argsOrCtx, maybeCtx) => {
318
+ const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
319
+ const text = !_db || !_sessionId
320
+ ? "context-mode: no active session"
321
+ : buildStatsText(_db, _sessionId);
322
+ return handleCommandText(text, ctx);
308
323
  },
309
324
  });
310
325
  pi.registerCommand("ctx-doctor", {
311
326
  description: "Run context-mode diagnostics",
312
- handler: () => {
327
+ handler: async (argsOrCtx, maybeCtx) => {
328
+ const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
313
329
  const dbPath = getDBPath();
314
330
  const dbExists = existsSync(dbPath);
315
331
  const lines = [
@@ -334,7 +350,8 @@ export default function piExtension(pi) {
334
350
  lines.push("- DB query error");
335
351
  }
336
352
  }
337
- return { text: lines.join("\n") };
353
+ const text = lines.join("\n");
354
+ return handleCommandText(text, ctx);
338
355
  },
339
356
  });
340
357
  }
package/build/server.js CHANGED
@@ -502,7 +502,7 @@ server.registerTool("ctx_execute", {
502
502
  .string()
503
503
  .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), or IO.puts (Elixir) to output a summary to context."),
504
504
  timeout: z
505
- .number()
505
+ .coerce.number()
506
506
  .optional()
507
507
  .default(30000)
508
508
  .describe("Max execution time in ms"),
@@ -798,7 +798,7 @@ server.registerTool("ctx_execute_file", {
798
798
  .string()
799
799
  .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts."),
800
800
  timeout: z
801
- .number()
801
+ .coerce.number()
802
802
  .optional()
803
803
  .default(30000)
804
804
  .describe("Max execution time in ms"),
@@ -1399,7 +1399,7 @@ server.registerTool("ctx_batch_execute", {
1399
1399
  "Each returns top 5 matching sections with full content. " +
1400
1400
  "This is your ONLY chance — put ALL your questions here. No follow-up calls needed.")),
1401
1401
  timeout: z
1402
- .number()
1402
+ .coerce.number()
1403
1403
  .optional()
1404
1404
  .default(60000)
1405
1405
  .describe("Max execution time in ms (default: 60s)"),
@@ -1862,7 +1862,7 @@ server.registerTool("ctx_insight", {
1862
1862
  "parallel work patterns, project focus, and actionable insights. " +
1863
1863
  "First run installs dependencies (~30s). Subsequent runs open instantly.",
1864
1864
  inputSchema: z.object({
1865
- port: z.number().optional().describe("Port to serve on (default: 4747)"),
1865
+ port: z.coerce.number().optional().describe("Port to serve on (default: 4747)"),
1866
1866
  }),
1867
1867
  }, async ({ port: userPort }) => {
1868
1868
  const port = userPort || 4747;
@@ -1895,11 +1895,26 @@ server.registerTool("ctx_insight", {
1895
1895
  const hasNodeModules = existsSync(join(cacheDir, "node_modules"));
1896
1896
  if (!hasNodeModules) {
1897
1897
  steps.push("Installing dependencies (first run, ~30s)...");
1898
- execSync("npm install --production=false", {
1899
- cwd: cacheDir,
1900
- stdio: "pipe",
1901
- timeout: 120000,
1902
- });
1898
+ try {
1899
+ execSync("npm install --production=false", {
1900
+ cwd: cacheDir,
1901
+ stdio: "pipe",
1902
+ timeout: 300000,
1903
+ });
1904
+ }
1905
+ catch {
1906
+ // Clean up partial install so next run retries fresh
1907
+ try {
1908
+ rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
1909
+ }
1910
+ catch { }
1911
+ throw new Error("npm install failed — please retry");
1912
+ }
1913
+ // Sentinel check: verify install completed (cold cache can timeout leaving partial node_modules)
1914
+ if (!existsSync(join(cacheDir, "node_modules", "vite")) || !existsSync(join(cacheDir, "node_modules", "better-sqlite3"))) {
1915
+ rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
1916
+ throw new Error("npm install incomplete — please retry");
1917
+ }
1903
1918
  steps.push("Dependencies installed.");
1904
1919
  }
1905
1920
  // Build
@@ -1907,20 +1922,81 @@ server.registerTool("ctx_insight", {
1907
1922
  execSync("npx vite build", {
1908
1923
  cwd: cacheDir,
1909
1924
  stdio: "pipe",
1910
- timeout: 30000,
1925
+ timeout: 60000,
1911
1926
  });
1912
1927
  steps.push("Build complete.");
1928
+ // Pre-check: is port already in use? (prevents orphan zombie processes)
1929
+ try {
1930
+ const { request } = await import("node:http");
1931
+ await new Promise((resolve, reject) => {
1932
+ const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 2000 }, (res) => {
1933
+ res.resume();
1934
+ resolve(); // port is responding = already running
1935
+ });
1936
+ req.on("error", () => reject()); // port free
1937
+ req.on("timeout", () => { req.destroy(); reject(); });
1938
+ req.end();
1939
+ });
1940
+ // If we get here, port is already responding
1941
+ steps.push("Dashboard already running.");
1942
+ // Open browser anyway
1943
+ const url = `http://localhost:${port}`;
1944
+ const platform = process.platform;
1945
+ try {
1946
+ if (platform === "darwin")
1947
+ execSync(`open "${url}"`, { stdio: "pipe" });
1948
+ else if (platform === "win32")
1949
+ execSync(`start "" "${url}"`, { stdio: "pipe" });
1950
+ else
1951
+ execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null`, { stdio: "pipe" });
1952
+ }
1953
+ catch { /* browser open is best-effort */ }
1954
+ return trackResponse("ctx_insight", {
1955
+ content: [{ type: "text", text: `Dashboard already running at http://localhost:${port}` }],
1956
+ });
1957
+ }
1958
+ catch {
1959
+ // Port is free, proceed with spawn
1960
+ }
1913
1961
  // Start server in background
1914
1962
  const { spawn } = await import("node:child_process");
1915
1963
  const child = spawn("node", [join(cacheDir, "server.mjs")], {
1916
1964
  cwd: cacheDir,
1917
- env: { ...process.env, PORT: String(port) },
1965
+ env: {
1966
+ ...process.env,
1967
+ PORT: String(port),
1968
+ INSIGHT_SESSION_DIR: getSessionDir(),
1969
+ INSIGHT_CONTENT_DIR: join(dirname(getSessionDir()), "content"),
1970
+ },
1918
1971
  detached: true,
1919
1972
  stdio: "ignore",
1920
1973
  });
1974
+ child.on("error", () => { }); // prevent unhandled error crash
1921
1975
  child.unref();
1922
1976
  // Wait for server to be ready
1923
1977
  await new Promise(r => setTimeout(r, 1500));
1978
+ // Verify server is actually running
1979
+ try {
1980
+ const { request } = await import("node:http");
1981
+ await new Promise((resolve, reject) => {
1982
+ const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 3000 }, (res) => {
1983
+ resolve();
1984
+ res.resume();
1985
+ });
1986
+ req.on("error", reject);
1987
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
1988
+ req.end();
1989
+ });
1990
+ }
1991
+ catch {
1992
+ // Server didn't start — likely port in use
1993
+ return trackResponse("ctx_insight", {
1994
+ content: [{
1995
+ type: "text",
1996
+ text: `Port ${port} appears to be in use. Either a previous dashboard is still running, or another service is using this port.\n\nTo fix:\n- Kill the existing process: ${process.platform === "win32" ? `netstat -ano | findstr :${port}` : `lsof -ti:${port} | xargs kill`}\n- Or use a different port: ctx_insight({ port: ${port + 1} })`,
1997
+ }],
1998
+ });
1999
+ }
1924
2000
  // Open browser (cross-platform)
1925
2001
  const url = `http://localhost:${port}`;
1926
2002
  const platform = process.platform;
@@ -1937,7 +2013,7 @@ server.registerTool("ctx_insight", {
1937
2013
  return trackResponse("ctx_insight", {
1938
2014
  content: [{
1939
2015
  type: "text",
1940
- text: steps.map(s => `- ${s}`).join("\n") + `\n\nOpen: ${url}\nPID: ${child.pid} · Stop: kill ${child.pid}`,
2016
+ text: steps.map(s => `- ${s}`).join("\n") + `\n\nOpen: ${url}\nPID: ${child.pid} · Stop: ${process.platform === "win32" ? `taskkill /PID ${child.pid} /F` : `kill ${child.pid}`}`,
1941
2017
  }],
1942
2018
  });
1943
2019
  }
package/build/store.d.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  type SourceMatchMode = "like" | "exact";
11
11
  import type { IndexResult, SearchResult, StoreStats } from "./types.js";
12
12
  export type { IndexResult, SearchResult, StoreStats } from "./types.js";
13
+ export declare function sanitizeQuery(query: string, mode?: "AND" | "OR"): string;
14
+ export declare function sanitizeTrigramQuery(query: string, mode?: "AND" | "OR"): string;
13
15
  /**
14
16
  * Remove stale DB files from previous sessions whose processes no longer exist.
15
17
  */
@@ -24,6 +26,7 @@ export declare function cleanupStaleContentDBs(contentDir: string, maxAgeDays: n
24
26
  export declare class ContentStore {
25
27
  #private;
26
28
  static readonly OPTIMIZE_EVERY = 50;
29
+ static readonly FUZZY_CACHE_SIZE = 256;
27
30
  constructor(dbPath?: string);
28
31
  /** Delete this session's DB files. Call on process exit. */
29
32
  cleanup(): void;
package/build/store.js CHANGED
@@ -33,12 +33,30 @@ const STOPWORDS = new Set([
33
33
  // ─────────────────────────────────────────────────────────
34
34
  // Helpers
35
35
  // ─────────────────────────────────────────────────────────
36
- function sanitizeQuery(query, mode = "AND") {
37
- const words = query
36
+ /**
37
+ * Remove case-insensitive duplicate tokens while preserving the first
38
+ * occurrence's original casing. FTS5's unicode61 tokenizer lowercases on
39
+ * both sides, so `"Error" OR "error"` produces no extra recall — just
40
+ * redundant index lookups. Dedup keeps the compiled query minimal.
41
+ */
42
+ function dedupeTokens(tokens) {
43
+ const seen = new Set();
44
+ const out = [];
45
+ for (const t of tokens) {
46
+ const key = t.toLowerCase();
47
+ if (!seen.has(key)) {
48
+ seen.add(key);
49
+ out.push(t);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ export function sanitizeQuery(query, mode = "AND") {
55
+ const words = dedupeTokens(query
38
56
  .replace(/['"(){}[\]*:^~]/g, " ")
39
57
  .split(/\s+/)
40
58
  .filter((w) => w.length > 0 &&
41
- !["AND", "OR", "NOT", "NEAR"].includes(w.toUpperCase()));
59
+ !["AND", "OR", "NOT", "NEAR"].includes(w.toUpperCase())));
42
60
  if (words.length === 0)
43
61
  return '""';
44
62
  // Filter stopwords to improve BM25 ranking — common terms like "update",
@@ -48,11 +66,11 @@ function sanitizeQuery(query, mode = "AND") {
48
66
  const final = meaningful.length > 0 ? meaningful : words;
49
67
  return final.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
50
68
  }
51
- function sanitizeTrigramQuery(query, mode = "AND") {
69
+ export function sanitizeTrigramQuery(query, mode = "AND") {
52
70
  const cleaned = query.replace(/["'(){}[\]*:^~]/g, "").trim();
53
71
  if (cleaned.length < 3)
54
72
  return "";
55
- const words = cleaned.split(/\s+/).filter((w) => w.length >= 3);
73
+ const words = dedupeTokens(cleaned.split(/\s+/).filter((w) => w.length >= 3));
56
74
  if (words.length === 0)
57
75
  return "";
58
76
  const meaningful = words.filter((w) => !STOPWORDS.has(w.toLowerCase()));
@@ -280,6 +298,13 @@ export class ContentStore {
280
298
  // search performance. SQLite's built-in 'optimize' merges b-tree segments.
281
299
  #insertCount = 0;
282
300
  static OPTIMIZE_EVERY = 50;
301
+ // Fuzzy correction cache (process-local LRU). fuzzyCorrect() hits the vocab
302
+ // DB and runs levenshtein against every candidate within length tolerance,
303
+ // which is CPU-linear in |candidates|. Repeated queries ("erro", "erro" …)
304
+ // recompute the same answer. The vocabulary table is insert-only, so cache
305
+ // entries only become stale when new words enter — we clear on actual insert.
306
+ #fuzzyCache = new Map();
307
+ static FUZZY_CACHE_SIZE = 256;
283
308
  constructor(dbPath) {
284
309
  const Database = loadDatabase();
285
310
  this.#dbPath =
@@ -733,20 +758,38 @@ export class ContentStore {
733
758
  const word = query.toLowerCase().trim();
734
759
  if (word.length < 3)
735
760
  return null;
761
+ // Cache hit: promote to tail (Map preserves insertion order → LRU).
762
+ if (this.#fuzzyCache.has(word)) {
763
+ const cached = this.#fuzzyCache.get(word) ?? null;
764
+ this.#fuzzyCache.delete(word);
765
+ this.#fuzzyCache.set(word, cached);
766
+ return cached;
767
+ }
736
768
  const maxDist = maxEditDistance(word.length);
737
769
  const candidates = this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist);
738
770
  let bestWord = null;
739
771
  let bestDist = maxDist + 1;
772
+ let exactMatch = false;
740
773
  for (const { word: candidate } of candidates) {
741
- if (candidate === word)
742
- return null; // exact match — no correction
774
+ if (candidate === word) {
775
+ exactMatch = true;
776
+ break;
777
+ }
743
778
  const dist = levenshtein(word, candidate);
744
779
  if (dist < bestDist) {
745
780
  bestDist = dist;
746
781
  bestWord = candidate;
747
782
  }
748
783
  }
749
- return bestDist <= maxDist ? bestWord : null;
784
+ const result = exactMatch ? null : bestDist <= maxDist ? bestWord : null;
785
+ // Evict the oldest entry before insert if we hit the size cap.
786
+ if (this.#fuzzyCache.size >= _a.FUZZY_CACHE_SIZE) {
787
+ const oldestKey = this.#fuzzyCache.keys().next().value;
788
+ if (oldestKey !== undefined)
789
+ this.#fuzzyCache.delete(oldestKey);
790
+ }
791
+ this.#fuzzyCache.set(word, result);
792
+ return result;
750
793
  }
751
794
  // ── Reciprocal Rank Fusion (Cormack et al. 2009) ──
752
795
  #rrfSearch(query, limit, source, contentType, sourceMatchMode = "like") {
@@ -955,11 +998,18 @@ export class ContentStore {
955
998
  .split(/[^\p{L}\p{N}_-]+/u)
956
999
  .filter((w) => w.length >= 3 && !STOPWORDS.has(w));
957
1000
  const unique = [...new Set(words)];
1001
+ let inserted = 0;
958
1002
  this.#db.transaction(() => {
959
1003
  for (const word of unique) {
960
- this.#stmtInsertVocab.run(word);
1004
+ const info = this.#stmtInsertVocab.run(word);
1005
+ inserted += info.changes;
961
1006
  }
962
1007
  })();
1008
+ // Invalidate fuzzy cache when new vocab words actually land. INSERT OR
1009
+ // IGNORE reports changes=0 for duplicates, so re-indexing identical
1010
+ // content does not thrash the cache during iterative workflows.
1011
+ if (inserted > 0)
1012
+ this.#fuzzyCache.clear();
963
1013
  }
964
1014
  // ── Chunking ──
965
1015
  #chunkMarkdown(text, maxChunkBytes = MAX_CHUNK_BYTES) {
@@ -11,6 +11,9 @@
11
11
  * "... [truncated]" is appended. The result is NOT guaranteed to be valid
12
12
  * JSON after truncation — it is suitable only for display/logging.
13
13
  *
14
+ * The returned string is always <= `maxBytes` bytes. When `maxBytes` is
15
+ * smaller than the marker, the marker itself is byte-safely truncated.
16
+ *
14
17
  * @param value - Any JSON-serializable value.
15
18
  * @param maxBytes - Maximum byte length of the returned string.
16
19
  * @param indent - JSON indentation spaces (default 2). Pass 0 for compact.
@@ -30,6 +33,9 @@ export declare function escapeXML(str: string): string;
30
33
  * byte-safe slice with an ellipsis appended. Useful for single-value fields
31
34
  * (e.g., tool response strings) where head+tail splitting is not needed.
32
35
  *
36
+ * The returned string is always <= `maxBytes` bytes. When `maxBytes` is
37
+ * smaller than the ellipsis marker, the marker itself is byte-safely truncated.
38
+ *
33
39
  * @param str - Input string.
34
40
  * @param maxBytes - Hard byte cap.
35
41
  */