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.
- 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/build/adapters/claude-code/index.js +15 -1
- package/build/cli.js +100 -47
- package/build/pi-extension.js +24 -7
- package/build/server.js +88 -12
- package/build/store.d.ts +3 -0
- package/build/store.js +59 -9
- package/build/truncate.d.ts +6 -0
- package/build/truncate.js +51 -29
- package/cli.bundle.mjs +139 -132
- package/hooks/pretooluse.mjs +38 -16
- package/hooks/session-snapshot.bundle.mjs +14 -14
- package/insight/package.json +2 -1
- package/insight/server.mjs +25 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +99 -95
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "1.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
package/build/pi-extension.js
CHANGED
|
@@ -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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/build/truncate.d.ts
CHANGED
|
@@ -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
|
*/
|