fix(v1.0.132): stats measurement + #563/#564/#567/#569 + PR follow-ups

- stats: populate bytes_avoided for external_ref via ctx_fetch_and_index preamble; indexer chunks now FK-attributed (chunks.session_id/event_id) at all 7 callers in server.ts
- #563: drop .refine() from ctx_purge schema (MCP SDK normalizeObjectSchema requires .shape); ambiguity check moved to handler; class-wide CI guard added for all 11 tools
- #564: engines.node>=22.5 + scripts/postinstall.mjs hard-fail on Linux+Node<22.5+no-Bun + ctx_doctor RED FAIL + README/docs sync to canonical 22.5 floor
- #567: vscode-copilot + jetbrains-copilot mcp.json npx-y -> global context-mode (npx-y was scaffold residue from Mar 2026, ghost-installs bypass user's npm i -g causing better-sqlite3 ABI mismatch)
- #569: anti-pattern docs centralized to anti-patterns.md §8 + SKILL.md ref (capture-vs-filter principle, no tool enumeration)
- #571 follow-up: vswhere timeout 5s->15s, year regex caps at currentYear+5
- #568 follow-up: documented CONTEXT_MODE_IDLE_TIMEOUT_MS + CONTEXT_MODE_STARTUP_SWEEP env vars; realpath guard in lifecycle-e2e-real-binary.test.ts
This commit is contained in:
Mert Koseoglu
2026-05-14 20:46:51 +03:00
parent ac11f10333
commit 61e680f1f3
21 changed files with 715 additions and 61 deletions
+26 -15
View File
@@ -124,7 +124,7 @@ This gives you all 11 MCP tools without automatic routing. The model can still u
<details>
<summary><strong>Gemini CLI</strong> — one config file, hooks included</summary>
**Prerequisites:** Node.js 18+, Gemini CLI installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), Gemini CLI installed.
**Install:**
@@ -197,7 +197,7 @@ Full config reference: [`configs/gemini-cli/settings.json`](configs/gemini-cli/s
<details>
<summary><strong>VS Code Copilot</strong> — hooks with SessionStart</summary>
**Prerequisites:** Node.js 18+, VS Code with Copilot Chat v0.32+.
**Prerequisites:** Node.js >= 22.5 (or Bun), VS Code with Copilot Chat v0.32+.
**Install:**
@@ -254,7 +254,7 @@ Full hook config including PreCompact: [`configs/vscode-copilot/hooks.json`](con
<details>
<summary><strong>JetBrains Copilot</strong> — hooks with SessionStart</summary>
**Prerequisites:** Node.js 18+, JetBrains IDE with GitHub Copilot plugin v1.5.57+.
**Prerequisites:** Node.js >= 22.5 (or Bun), JetBrains IDE with GitHub Copilot plugin v1.5.57+.
**Install:**
@@ -305,7 +305,7 @@ Full setup guide: [`docs/jetbrains-copilot.md`](docs/jetbrains-copilot.md)
<details>
<summary><strong>Cursor</strong> — hooks with stop support</summary>
**Prerequisites:** Node.js 18+, Cursor with agent mode.
**Prerequisites:** Node.js >= 22.5 (or Bun), Cursor with agent mode.
> **🚧 Work in progress** — the Marketplace plugin is **awaiting Cursor team review**. Until it's listed, install via the local-folder path described in Option A. Tracking in [#485](https://github.com/mksglu/context-mode/issues/485) / [#489](https://github.com/mksglu/context-mode/pull/489).
@@ -406,7 +406,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
<details>
<summary><strong>OpenCode</strong> — TypeScript plugin with hooks</summary>
**Prerequisites:** Node.js 18+, OpenCode installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), OpenCode installed.
**Install:**
@@ -456,7 +456,7 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
<details>
<summary><strong>KiloCode</strong> — TypeScript plugin with hooks</summary>
**Prerequisites:** Node.js 18+, KiloCode installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), KiloCode installed.
**Install:**
@@ -541,7 +541,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
<details>
<summary><strong>Codex CLI</strong> — MCP + hooks</summary>
**Prerequisites:** Node.js 18+, Codex CLI installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), Codex CLI installed.
**Install:**
@@ -606,7 +606,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
<details>
<summary><strong>Qwen Code</strong> — MCP + hooks (identical wire protocol to Claude Code)</summary>
**Prerequisites:** Node.js 18+, Qwen Code installed (`npm install -g @qwen-code/qwen-code`).
**Prerequisites:** Node.js >= 22.5 (or Bun), Qwen Code installed (`npm install -g @qwen-code/qwen-code`).
1. Install context-mode:
@@ -660,7 +660,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
<details>
<summary><strong>Antigravity</strong> — MCP-only, no hooks</summary>
**Prerequisites:** Node.js 18+, Antigravity installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), Antigravity installed.
**Install:**
@@ -701,7 +701,7 @@ Full configs: [`configs/antigravity/mcp_config.json`](configs/antigravity/mcp_co
<details>
<summary><strong>Kiro</strong> — hooks with steering file</summary>
**Prerequisites:** Node.js 18+, Kiro with MCP enabled (Settings > search "MCP").
**Prerequisites:** Node.js >= 22.5 (or Bun), Kiro with MCP enabled (Settings > search "MCP").
**Install:**
@@ -759,7 +759,7 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
<details>
<summary><strong>Zed</strong> — MCP-only, no hooks</summary>
**Prerequisites:** Node.js 18+, Zed installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), Zed installed.
**Install:**
@@ -802,7 +802,7 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
<details>
<summary><strong>Pi Coding Agent</strong> — extension with full hook support</summary>
**Prerequisites:** Node.js 18+, Pi Coding Agent installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), Pi Coding Agent installed.
**Install:**
@@ -849,7 +849,7 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
<details>
<summary><strong>OMP (Oh My Pi)</strong> — plugin with full hook support</summary>
**Prerequisites:** Node.js 18+, Oh My Pi installed.
**Prerequisites:** Node.js >= 22.5 (or Bun), Oh My Pi installed.
**Install — plugin path (recommended):**
@@ -924,7 +924,7 @@ Full configs: [`configs/omp/mcp.json`](configs/omp/mcp.json) | [`configs/omp/SYS
Context Mode uses [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) on Node.js, which ships prebuilt native binaries for most platforms. On glibc >= 2.31 systems (Ubuntu 20.04+, Debian 11+, Fedora 34+, macOS, Windows), `npm install` works without any build tools.
**Linux + Node.js >= 22.13:** Context Mode automatically uses the built-in `node:sqlite` module instead of `better-sqlite3`. This eliminates the native addon entirely, avoiding [sporadic SIGSEGV crashes](https://github.com/nodejs/node/issues/62515) caused by V8's `madvise(MADV_DONTNEED)` corrupting the addon's `.got.plt` section on Linux. No configuration needed — detection is automatic. Falls back to `better-sqlite3` on older Node.js versions.
**Linux + Node.js >= 22.5:** Context Mode automatically uses the built-in `node:sqlite` module instead of `better-sqlite3`. This eliminates the native addon entirely, avoiding [sporadic SIGSEGV crashes](https://github.com/nodejs/node/issues/62515) caused by V8's `madvise(MADV_DONTNEED)` corrupting the addon's `.got.plt` section on Linux. No configuration needed — detection is automatic. **Linux + Node < 22.5 is unsupported** ([#564](https://github.com/mksglu/context-mode/issues/564)) — `npm install` will fail with remediation instructions.
**Bun users:** No native compilation needed. Context Mode automatically detects Bun and uses the built-in `bun:sqlite` module via a compatibility adapter. `better-sqlite3` and all its build dependencies are skipped entirely.
@@ -986,7 +986,7 @@ When output exceeds 5 KB and an `intent` is provided, Context Mode switches to i
## How the Knowledge Base Works
The `ctx_index` tool chunks markdown content by headings while keeping code blocks intact, then stores them in a **SQLite FTS5** (Full-Text Search 5) virtual table. The SQLite backend is selected automatically at runtime: `bun:sqlite` on Bun, `node:sqlite` on Linux + Node.js >= 22.13, and `better-sqlite3` everywhere else. Search uses **BM25 ranking** — a probabilistic relevance algorithm that scores documents based on term frequency, inverse document frequency, and document length normalization. **Porter stemming** is applied at index time so "running", "runs", and "ran" match the same stem. Titles and headings are weighted **5x** in BM25 scoring for precise navigational queries.
The `ctx_index` tool chunks markdown content by headings while keeping code blocks intact, then stores them in a **SQLite FTS5** (Full-Text Search 5) virtual table. The SQLite backend is selected automatically at runtime: `bun:sqlite` on Bun, `node:sqlite` on Node.js >= 22.5, and `better-sqlite3` everywhere else. Search uses **BM25 ranking** — a probabilistic relevance algorithm that scores documents based on term frequency, inverse document frequency, and document length normalization. **Porter stemming** is applied at index time so "running", "runs", and "ran" match the same stem. Titles and headings are weighted **5x** in BM25 scoring for precise navigational queries.
When you call `ctx_search`, it returns relevant content snippets focused around matching query terms — not full documents, not approximations, the actual indexed content with smart extraction around what you're looking for. `ctx_fetch_and_index` extends this to URLs: fetch, convert HTML to markdown, chunk, index. The raw page never enters context. Use the `contentType` parameter to filter results by type (e.g. `code` or `prose`).
@@ -1361,6 +1361,17 @@ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. U
`tool_input` for any `mcp__*` tool call is also redacted before persistence — keys matching `authorization`, `token`, `secret`, `password`, `api_key`, `cookie`, `signature`, `private_key` get masked to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
### Lifecycle environment variables
Two runtime knobs control how MCP server processes self-manage. Defaults are safe — only set these to opt-out of the leak-fix introduced in v1.0.132 ([#565](https://github.com/mksglu/context-mode/issues/565) / [#568](https://github.com/mksglu/context-mode/pull/568)).
| Variable | Default | Purpose |
|---|---|---|
| `CONTEXT_MODE_IDLE_TIMEOUT_MS` | `900000` (15 min) | An MCP child self-exits cleanly after this many milliseconds of stdin/request inactivity. Hosts like OpenCode and KiloCode open one MCP child per session and per subagent — without this, idle children accumulate to 25+ processes / 1.6 GB RSS in long-lived shells. Set to `0` to disable self-shutdown (rarely needed; useful only for daemons that must outlive their parent). |
| `CONTEXT_MODE_STARTUP_SWEEP` | `1` (enabled) | At boot, a newly-spawned MCP child reaps any other context-mode MCP server pids that share its parent process (`sameParentOnly: true` — never touches MCP children of a different host). This reclaims accumulated siblings immediately instead of waiting for each idle timer to fire. Set to `0` or `false` to disable (useful when you intentionally want multiple concurrent MCP children under the same host, e.g. multi-tenant test runners). |
Both vars are read fresh at MCP server start — no restart of the host CLI is required, just spawn a new MCP child (open a new session) for changes to take effect. Invalid values (non-numeric `CONTEXT_MODE_IDLE_TIMEOUT_MS`, unrecognized `CONTEXT_MODE_STARTUP_SWEEP`) fall back to defaults silently.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and TDD guidelines.
+1 -2
View File
@@ -1,8 +1,7 @@
{
"servers": {
"context-mode": {
"command": "npx",
"args": ["-y", "context-mode"]
"command": "context-mode"
}
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
{
"servers": {
"context-mode": {
"command": "npx",
"args": ["-y", "context-mode"]
"command": "context-mode"
}
}
}
+2 -2
View File
@@ -749,12 +749,12 @@ context-mode automatically selects the best SQLite backend at runtime based on t
| Priority | Condition | Backend | Why |
|----------|-----------|---------|-----|
| 1 | Bun runtime | `bun:sqlite` | Built-in, no native addon |
| 2 | Linux + Node.js >= 22.13 | `node:sqlite` | Built-in, avoids [SIGSEGV from V8 madvise bug](https://github.com/nodejs/node/issues/62515) |
| 2 | Linux + Node.js >= 22.5 | `node:sqlite` | Built-in, avoids [SIGSEGV from V8 madvise bug](https://github.com/nodejs/node/issues/62515) |
| 3 | All other environments | `better-sqlite3` | Mature native addon, prebuilt binaries |
**Why node:sqlite on Linux?** Node.js's V8 garbage collector can call `madvise(MADV_DONTNEED)` on memory ranges that overlap `better-sqlite3`'s native addon `.got.plt` section, corrupting resolved symbol addresses and causing sporadic SIGSEGV crashes (1-4/hour on Node v22-v24). `node:sqlite` is compiled into the Node.js binary itself — no separate `.node` file, no `dlopen()`, no `.got.plt` to corrupt.
**Fallback:** If `node:sqlite` is unavailable (Node < 22.13), context-mode silently falls back to `better-sqlite3`. No user configuration needed.
**Fallback:** If `node:sqlite` is unavailable (Node < 22.5), context-mode silently falls back to `better-sqlite3`. No user configuration needed.
**Override:** Not currently supported — backend selection is automatic. If you need to force a specific backend, open an issue.
+15 -2
View File
@@ -74,13 +74,26 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
workspaceRoots: Array.isArray(input.workspace_roots) ? input.workspace_roots : [],
lastKnownProjectDir,
});
// Build a parallel bytesList from event-level bytes_avoided (currently
// populated by external_ref's ctx_fetch_and_index preamble parser). When
// no event carries a positive value we leave bytesList undefined so
// SessionDB falls back to its 0-default for bytes_avoided/bytes_returned
// — preserves backward compat with older callers / tests.
let bytesList;
if (events.some((e) => typeof e?.bytes_avoided === "number" && e.bytes_avoided > 0)) {
bytesList = events.map((e) =>
typeof e?.bytes_avoided === "number" && e.bytes_avoided > 0
? { bytesAvoided: e.bytes_avoided }
: undefined,
);
}
// Prefer bulk path (single transaction = single WAL commit). Falls back
// to per-event insert for older SessionDB instances that lack bulkInsertEvents.
if (typeof db.bulkInsertEvents === "function") {
db.bulkInsertEvents(sessionId, events, hookName, attributions);
db.bulkInsertEvents(sessionId, events, hookName, attributions, bytesList);
} else {
for (let i = 0; i < events.length; i++) {
db.insertEvent(sessionId, events[i], hookName, attributions[i]);
db.insertEvent(sessionId, events[i], hookName, attributions[i], bytesList?.[i]);
}
}
return attributions;
+4 -1
View File
@@ -124,5 +124,8 @@
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b"
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
"engines": {
"node": ">=22.5.0"
}
}
+33 -2
View File
@@ -164,16 +164,30 @@ function isCondaActive(env = process.env) {
*
* Returns null on non-Windows, when vswhere is absent, or on any error.
*
* Timeout: 15s. Cold-disk vswhere queries on HDD-backed Windows CI runners
* with multiple VS installs have been observed to exceed the previous 5s
* budget (see ARCH-REVIEW #571 Part B). 15s comfortably covers slow-disk
* scenarios without freezing /ctx-upgrade.
*
* Year sanity cap: the regex matches any 21st-century 4-digit year, but
* we additionally reject anything > currentYear+5. Corrupted vswhere
* output or a future MS rebrand could surface a bogus "2099"; passing
* that through to `npm_config_msvs_version` would fail node-gyp
* silently. Cap-and-null lets the caller fall back to node-gyp's own
* detection and we log a single stderr breadcrumb for support triage.
*
* @param {object} [deps]
* @param {string} [deps.platform] - process.platform override
* @param {(p: string) => boolean} [deps.existsSync] - fs probe override
* @param {(cmd: string, opts: object) => string} [deps.exec] - execSync override
* @param {() => number} [deps.now] - clock override for sanity cap (test seam)
* @returns {string | null}
*/
export function detectWindowsVsYear({
platform = process.platform,
existsSync = fsExistsSync,
exec = execSync,
now = () => new Date().getFullYear(),
} = {}) {
if (platform !== "win32") return null;
try {
@@ -182,11 +196,28 @@ export function detectWindowsVsYear({
if (!existsSync(vswhere)) return null;
const displayName = exec(
`"${vswhere}" -latest -property displayName`,
{ encoding: "utf-8", stdio: "pipe", timeout: 5000 },
{ encoding: "utf-8", stdio: "pipe", timeout: 15000 },
).trim();
// "Visual Studio Community 2026" → "2026"
const match = displayName.match(/\b(20\d{2})\b/);
return match ? match[1] : null;
if (!match) return null;
const year = Number(match[1]);
const ceiling = now() + 5;
if (year > ceiling) {
// Fail LOUD, not silent: poisoning npm_config_msvs_version with
// a bogus year would manifest as opaque node-gyp errors deep in
// the rebuild. Surface a breadcrumb and return null so the caller
// falls back to node-gyp's own version detection.
try {
process.stderr.write(
`[context-mode] vswhere displayName reports VS year ${year} ` +
`(> ${ceiling}); ignoring as likely corrupted output. ` +
`Falling back to node-gyp default detection.\n`,
);
} catch { /* stderr unavailable — proceed silently */ }
return null;
}
return match[1];
} catch {
return null;
}
+58
View File
@@ -19,6 +19,64 @@ import { healInstalledPlugins, healSettingsEnabledPlugins, healPluginJsonMcpServ
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(__dirname, "..");
// ── -2. Issue #564 — Linux SIGSEGV class hard-fail (v1.0.132) ────────
// On Linux + Node < 22.5 + no Bun, better-sqlite3's native addon is
// vulnerable to V8 calling `madvise(MADV_DONTNEED)` on memory ranges
// that overlap the addon's `.got.plt` section, corrupting resolved
// symbol addresses and causing sporadic SIGSEGV (1-4/hour) — see
// https://github.com/nodejs/node/issues/62515 and our internal #564.
//
// node:sqlite (built-in, no native addon, no .got.plt to corrupt) ships
// from Node 22.5 onward — that is the contract `hasModernSqlite()` in
// src/db-base.ts encodes. Six prior fixes (#228, #331, #461, #540,
// #551, #556) silently assumed users had Node >= 22.5 on Linux; #564
// is the second confirmed report (after #556) of the same SIGSEGV
// class on Node 20.
//
// The architect mandate for v1.0.132 is HARD-FAIL, not warn-then-
// degrade. `engines.node >= 22.5.0` in package.json is cosmetic under
// the default npm `engine-strict=false`, so the contract has to be
// enforced HERE — preinstall/postinstall is the only place that can
// `process.exit(1)` across npm/pnpm/yarn.
//
// Linux + Bun is allowed through (bun:sqlite sidesteps better-sqlite3
// entirely). Non-Linux platforms are unaffected by the madvise bug
// and pass through unchanged.
{
const isLinux = process.platform === "linux";
const hasBun =
typeof globalThis.Bun !== "undefined" ||
typeof process.versions.bun === "string";
const [majStr, minStr] = (process.versions.node ?? "0.0.0").split(".");
const major = Number(majStr);
const minor = Number(minStr);
const hasModernNode =
Number.isFinite(major) &&
Number.isFinite(minor) &&
(major > 22 || (major === 22 && minor >= 5));
if (isLinux && !hasBun && !hasModernNode) {
process.stderr.write(
"\n" +
"context-mode: install aborted\n" +
" Linux + Node " + (process.versions.node ?? "?") + " is unsupported.\n" +
" context-mode requires Node.js >= 22.5 (or Bun) on Linux to avoid the\n" +
" V8 madvise(MADV_DONTNEED) SIGSEGV affecting better-sqlite3 (1-4/hour).\n" +
" Tracking: https://github.com/nodejs/node/issues/62515\n" +
" https://github.com/mksglu/context-mode/issues/564\n" +
"\n" +
" Fix: upgrade Node (recommended)\n" +
" nvm install 22.5 && nvm use 22.5\n" +
" npm install -g context-mode\n" +
"\n" +
" Or: run under Bun\n" +
" curl -fsSL https://bun.sh/install | bash\n" +
" bun add -g context-mode\n" +
"\n",
);
process.exit(1);
}
}
/**
* True when running as a real `npm install -g context-mode`. We use this
* to keep contributors' local `npm install` runs from rewriting their HOME's
+1
View File
@@ -283,6 +283,7 @@ Subagents automatically receive context-mode tool routing via a PreToolUse hook.
- Using `cat large-file.json` via Bash → entire file in context. Use `ctx_execute_file` instead.
- Using `gh pr list` via Bash → raw JSON in context. Use `ctx_execute` with `--jq` filter instead.
- Piping Bash output through `| head -20` → you lose the rest. Use `ctx_execute` to analyze ALL data and print summary.
- Narrowing `ctx_execute` output upstream of capture → `ctx_execute` captures, `ctx_search` filters; merging the layers drops data that the index never sees. See `references/anti-patterns.md` §8.
- Running `npm test` via Bash → full test output in context. Use `ctx_execute` to capture and summarize.
- Calling `browser_snapshot()` WITHOUT `filename` parameter → 135K tokens flood context. **Always** use `browser_snapshot(filename: "/tmp/snap.md")`.
- Calling `browser_console_messages()` or `browser_network_requests()` WITHOUT `filename` → entire output floods context. **Always** use the `filename` parameter.
@@ -244,6 +244,32 @@ GOOD — specific and actionable:
---
## 8. `ctx_execute` Captures, `ctx_search` Filters — Don't Merge the Layers
`ctx_execute` and `ctx_search` are two layers, not one. `ctx_execute` exists to **capture** full output into the index. `ctx_search` exists to **filter** what was captured. When you narrow the output *inside* `ctx_execute` — at the shell layer, in script logic, anywhere upstream of capture — the dropped lines never reach the index. `ctx_search` cannot recover what was never written. You've spent the capture budget and lost the data you'd want to query later, for no context-window benefit: large stdout is already auto-indexed, not returned inline.
The mental model:
```
┌──────────────────────┐ ┌──────────────────────┐
│ ctx_execute │ ───▶ │ ctx_search │
│ (capture layer) │ │ (filter layer) │
│ │ │ │
│ produces full │ │ queries the │
│ output into index │ │ captured index │
└──────────────────────┘ └──────────────────────┘
▲ ▲
│ │
Job: capture Job: narrow
Do NOT narrow here. Do all narrowing here.
```
**Rule:** Treat `ctx_execute`'s output as write-once to the index. Run the command in full and let it index. Do every narrowing step downstream, via `ctx_search`. If you find yourself trimming inside `ctx_execute`, you are doing the filter layer's job in the capture layer — stop and move the narrowing to a `ctx_search` call.
**Why the layer separation matters:** the index is what survives across calls and across sessions. Anything you discard before the index is gone permanently from this session's queryable surface. Anything you keep is queryable, repeatedly, with different questions, at zero re-execution cost.
---
## Summary Checklist
Before using `execute`, verify:
+35
View File
@@ -358,6 +358,41 @@ async function doctor(): Promise<number> {
// Runtime check
p.note(getRuntimeSummary(runtimes), "Runtimes");
// ── Issue #564 — Linux + Node < 22.5 + no Bun is unsafe ────────────
// V8's madvise(MADV_DONTNEED) can corrupt better-sqlite3's native addon
// `.got.plt` on Linux, causing sporadic SIGSEGV (1-4/hour). The 22.5
// gate (`hasModernSqlite()` in src/db-base.ts:226-244) is the contract:
// at or above it we use node:sqlite (built-in, no native addon, no
// .got.plt to corrupt); below it we fall through to better-sqlite3
// which WILL crash. engines.node + a hard-fail postinstall guard this
// at install time, but doctor() surfaces it for already-installed users
// (and for adapters whose MCP host swallows stderr during install).
// Refs:
// - https://github.com/nodejs/node/issues/62515
// - https://github.com/mksglu/context-mode/issues/564
{
const { hasModernSqlite } = await import("./db-base.js");
if (
process.platform === "linux" &&
!hasModernSqlite() &&
!hasBunRuntime()
) {
criticalFails++;
p.log.error(
color.red("Node version: FAIL") +
` — Linux + Node ${process.versions.node} is unsafe (SIGSEGV)` +
color.dim(
"\n context-mode requires Node.js >= 22.5 (or Bun) on Linux to avoid the" +
"\n V8 madvise(MADV_DONTNEED) SIGSEGV in better-sqlite3 (1-4/hour)." +
"\n Refs: https://github.com/nodejs/node/issues/62515" +
"\n https://github.com/mksglu/context-mode/issues/564" +
"\n Fix: nvm install 22.5 && nvm use 22.5 && npm install -g context-mode" +
"\n Or: curl -fsSL https://bun.sh/install | bash && bun add -g context-mode",
),
);
}
}
// Speed tier
if (hasBunRuntime()) {
p.log.success(
+48 -17
View File
@@ -100,6 +100,20 @@ writeFileSync(
// Lazy singleton — no DB overhead unless index/search is used
let _store: ContentStore | null = null;
/**
* Build the FK-attribution object passed to every ContentStore.index*() call
* in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
* the current session — eventId stays undefined because MCP tool invocations
* are not paired with PostToolUse event rows at index time (the hook fires
* AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
* legacy unattributed rows readable.
*/
function currentAttribution(): { sessionId?: string } | undefined {
const sessionId = process.env.CLAUDE_SESSION_ID;
if (!sessionId) return undefined;
return { sessionId };
}
/**
* Auto-index session events files written by SessionStart hook.
* Scans ~/.claude/context-mode/sessions/ for *-events.md files.
@@ -116,7 +130,7 @@ function maybeIndexSessionEvents(store: ContentStore): void {
for (const file of files) {
const filePath = join(sessionsDir, file);
try {
store.index({ path: filePath, source: "session-events" });
store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
unlinkSync(filePath);
} catch { /* best-effort per file */ }
}
@@ -1315,7 +1329,7 @@ function indexStdout(
): { content: Array<{ type: "text"; text: string }> } {
const store = getStore();
trackIndexed(Buffer.byteLength(stdout));
const indexed = store.index({ content: stdout, source });
const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
return {
content: [
{
@@ -1344,7 +1358,7 @@ function intentSearch(
// Index into the PERSISTENT store so user can ctx_search() later
const persistent = getStore();
const indexed = persistent.indexPlainText(stdout, source);
const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution());
// Search the persistent store directly (porter → trigram → fuzzy)
let results = persistent.searchWithFallback(intent, maxResults, source);
@@ -1611,7 +1625,7 @@ server.registerTool(
} catch { /* ignore — file read errors handled by store */ }
}
const store = getStore();
const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath });
const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath, attribution: currentAttribution() });
return trackResponse("ctx_index", {
content: [
@@ -2383,13 +2397,14 @@ function indexFetched(f: { url: string; source?: string; markdown: string; heade
// `source` label do not overwrite each other (commit 1f1243e). ctx_search()
// still finds both via LIKE-mode source filter on the `source` substring.
const storageLabel = composeFetchCacheKey(f.source, f.url);
const attribution = currentAttribution();
let indexed: IndexResult;
if (f.header === "__CM_CT__:json") {
indexed = store.indexJSON(f.markdown, storageLabel);
indexed = store.indexJSON(f.markdown, storageLabel, undefined, attribution);
} else if (f.header === "__CM_CT__:text") {
indexed = store.indexPlainText(f.markdown, storageLabel);
indexed = store.indexPlainText(f.markdown, storageLabel, undefined, attribution);
} else {
indexed = store.index({ content: f.markdown, source: storageLabel });
indexed = store.index({ content: f.markdown, source: storageLabel, attribution });
}
// Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
trackIndexed(Buffer.byteLength(f.markdown));
@@ -2751,7 +2766,7 @@ server.registerTool(
.map((c) => c.label)
.join(",")
.slice(0, 80)}`;
const indexed = store.index({ content: stdout, source });
const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
// Build section inventory — direct query by source_id (no FTS5 MATCH needed)
const allSections = store.getChunksBySource(indexed.sourceId);
@@ -3169,7 +3184,12 @@ server.registerTool(
// files (events.md, FTS5 store file, stats file) are preserved.
// Passing both sessionId AND scope:"project" is ambiguous (does the
// caller want a per-session wipe or a project-wide one?) and is
// rejected by the schema's refine().
// rejected by an explicit check in the handler body — NOT a schema-level
// .refine(). MCP SDK's normalizeObjectSchema() reads `.shape` to project
// inputSchema → JSON Schema for tools/list; a ZodEffects (refine wrapper)
// has no `.shape`, so the SDK silently emits `properties: {}`, and Claude
// Code's strict-input-validation gate then rejects EVERY call to this
// tool with "input_schema does not support fields". Issue #563.
server.registerTool(
"ctx_purge",
{
@@ -3193,6 +3213,9 @@ server.registerTool(
"Use sessionId when the user asks to clear a specific conversation's data.\n" +
"Use scope:'project' ONLY when the user explicitly asks to reset everything.\n" +
"NEVER call with bare {confirm:true} — always specify the scope.",
// NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
// .superRefine() wrapper. See block comment above & issue #563. The
// cross-field ambiguity check lives in the handler body below.
inputSchema: z.object({
confirm: z.boolean().describe(
"MUST be true. Destructive operation; false returns 'purge cancelled'."
@@ -3207,16 +3230,24 @@ server.registerTool(
"the entire project (FTS5 + every session + stats). Omit only for the " +
"deprecated bare-{confirm:true} back-compat path."
),
}).refine(
(v) => !(v.sessionId && v.scope === "project"),
{
message: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
"Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
path: ["scope"],
},
),
}),
},
async ({ confirm, sessionId, scope }) => {
// Cross-field ambiguity check — formerly a schema .refine(), moved
// into the handler so the inputSchema stays a plain ZodObject and
// the MCP SDK can serialize `.shape` into JSON Schema (issue #563).
// Same human-readable message as the original refine() preserved.
if (sessionId && scope === "project") {
return trackResponse("ctx_purge", {
content: [{
type: "text" as const,
text:
"Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
"Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
}],
isError: true,
});
}
if (!confirm) {
return trackResponse("ctx_purge", {
content: [{
+27 -2
View File
@@ -18,6 +18,13 @@ export interface SessionEvent {
data: string;
/** 1=critical (rules, files, tasks) … 5=low */
priority: number;
/**
* Optional — bytes context-mode prevented from entering the model context
* window for this event. Currently populated by external_ref when a
* ctx_fetch_and_index tool_response carries the
* `Fetched and indexed N sections (XKB)` preamble.
*/
bytes_avoided?: number;
}
export interface ToolCall {
@@ -778,12 +785,30 @@ function extractExternalRef(input: HookInput): SessionEvent[] {
if (refs.size === 0) return [];
return [{
// ctx_fetch_and_index returns a preamble like
// "Fetched and indexed **5 sections** (47.50KB) from: <label>"
// Parse the size to credit bytes_avoided on the event so per-session
// honest-savings stats reflect what was kept out of the context window.
// KB literal in the preamble is decimal (KB = 1024 bytes per the formatter).
let bytesAvoided: number | undefined;
const preambleMatch = safeString(input.tool_response).match(
/Fetched and indexed[^\(]*\(([\d.]+)\s*KB\)/i,
);
if (preambleMatch) {
const kb = Number(preambleMatch[1]);
if (Number.isFinite(kb) && kb > 0) {
bytesAvoided = Math.round(kb * 1024);
}
}
const event: SessionEvent = {
type: "external_ref",
category: "external-ref",
data: safeString(Array.from(refs).join(", ")),
priority: 3,
}];
};
if (bytesAvoided !== undefined) event.bytes_avoided = bytesAvoided;
return [event];
}
/**
+32 -10
View File
@@ -807,8 +807,14 @@ export class ContentStore {
content?: string;
path?: string;
source?: string;
/**
* Optional FK metadata recorded on each indexed chunk so per-session
* honest-savings stats can join chunks → session_events. When omitted,
* chunks fall back to empty-string columns (legacy behaviour).
*/
attribution?: { sessionId?: string; eventId?: string };
}): IndexResult {
const { content, path, source } = options;
const { content, path, source, attribution } = options;
// Treat empty string as "no content" so an empty `content` paired with a
// valid `path` falls back to reading the file. Some MCP clients
@@ -851,7 +857,7 @@ export class ContentStore {
const filePath = path ?? undefined;
const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash));
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
}
// ── Index Plain Text ──
@@ -865,9 +871,10 @@ export class ContentStore {
content: string,
source: string,
linesPerChunk: number = 20,
attribution?: { sessionId?: string; eventId?: string },
): IndexResult {
if (!content || content.trim().length === 0) {
return this.#insertChunks([], source, "");
return this.#insertChunks([], source, "", undefined, undefined, attribution);
}
const chunks = this.#chunkPlainText(content, linesPerChunk);
@@ -876,6 +883,9 @@ export class ContentStore {
chunks.map((c) => ({ ...c, hasCode: false })),
source,
content,
undefined,
undefined,
attribution,
));
}
@@ -892,26 +902,27 @@ export class ContentStore {
content: string,
source: string,
maxChunkBytes: number = MAX_CHUNK_BYTES,
attribution?: { sessionId?: string; eventId?: string },
): IndexResult {
if (!content || content.trim().length === 0) {
return this.indexPlainText("", source);
return this.indexPlainText("", source, undefined, attribution);
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return this.indexPlainText(content, source);
return this.indexPlainText(content, source, undefined, attribution);
}
const chunks: Chunk[] = [];
this.#walkJSON(parsed, [], chunks, maxChunkBytes);
if (chunks.length === 0) {
return this.indexPlainText(content, source);
return this.indexPlainText(content, source, undefined, attribution);
}
return withRetry(() => this.#insertChunks(chunks, source, content));
return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
}
// ── Shared DB Insertion ──
@@ -921,8 +932,19 @@ export class ContentStore {
* into both FTS5 tables within a transaction and extracts vocabulary.
* Uses cached prepared statements from #prepareStatements().
*/
#insertChunks(chunks: Chunk[], label: string, text: string, filePath?: string, contentHash?: string): IndexResult {
#insertChunks(
chunks: Chunk[],
label: string,
text: string,
filePath?: string,
contentHash?: string,
attribution?: { sessionId?: string; eventId?: string },
): IndexResult {
const codeChunks = chunks.filter((c) => c.hasCode).length;
// FK columns on chunks. Empty-string fallback preserves the FTS5-friendly
// "not-null but unattributed" sentinel used by legacy rows.
const sessionIdCol = attribution?.sessionId ?? "";
const eventIdCol = attribution?.eventId ?? "";
// Atomic dedup + insert: delete previous source with same label,
// then insert new content — all within a single transaction.
@@ -943,8 +965,8 @@ export class ContentStore {
const now = new Date().toISOString();
for (const chunk of chunks) {
const ct = chunk.hasCode ? "code" : "prose";
this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, null, null, now);
this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, null, null, now);
this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
}
return sourceId;
+100
View File
@@ -1963,6 +1963,106 @@ describe("better-sqlite3 binding self-heal (#408)", () => {
});
});
// ── Issue #564 — docs sync to hasModernSqlite() source of truth ────────
// README and docs/platform-support.md historically promised "Node 18+"
// (9 spots in README) and "Node >= 22.13" (platform-support), while the
// runtime gate (`hasModernSqlite()` in src/db-base.ts:226-244) uses 22.5.
// Three numbers, three contracts. v1.0.132 collapses them to one — the
// runtime gate is the canonical source.
describe("Issue #564 — docs match hasModernSqlite() source of truth", () => {
const DB_BASE_SRC = readFileSync(resolve(ROOT, "src", "db-base.ts"), "utf-8");
it("src/db-base.ts hasModernSqlite() uses the 22.5 floor (sanity / source of truth)", () => {
// If this fails, the floor moved — the README + docs assertions
// below need their threshold updated in lockstep. This test pins the
// contract so the docs assertions can not silently drift.
expect(DB_BASE_SRC).toContain("export function hasModernSqlite");
// Inline major/minor compare must reference 22 and 5.
expect(DB_BASE_SRC).toMatch(/major\s*===\s*22\s*&&\s*minor\s*>=\s*5/);
});
it("README.md does NOT promise Node.js 18+ on platforms where Linux is unsafe", () => {
const readme = readFileSync(resolve(ROOT, "README.md"), "utf-8");
// The literal string "Node.js 18+" must be gone from prerequisites
// lines — it's a false promise on Linux.
const nodeJs18PrereqLines = readme
.split("\n")
.filter((l) => /Node\.js\s+18\s*\+/.test(l));
expect(nodeJs18PrereqLines).toEqual([]);
// README must positively state the 22.5 (or Bun) floor somewhere.
expect(readme).toMatch(/22\.5/);
});
it("docs/platform-support.md SQLite Backend Selection table uses 22.5, not 22.13", () => {
const doc = readFileSync(resolve(ROOT, "docs", "platform-support.md"), "utf-8");
// The literal "22.13" must be gone — it disagrees with hasModernSqlite().
expect(doc).not.toMatch(/22\.13/);
// The 22.5 floor must be present.
expect(doc).toMatch(/22\.5/);
});
});
// ── Issue #564 — doctor RED FAIL on Linux + Node < 22.5 + no Bun ──────
// Six prior fixes (#228, #331, #461, #540, #551, #556) silently assumed
// Node >= 22.5 on Linux. Reporter #564 hit SIGSEGV on Node 20 because
// engines.node was absent and doctor never flagged the unsafe config.
//
// Architect contract for v1.0.132: doctor MUST emit an explicit RED FAIL
// (not a warn / not a passing note) for the predicate
// process.platform === "linux" && !hasModernSqlite() && globalThis.Bun === undefined
// linking to issue #564.
//
// Static-analysis assertion (same pattern as cli.test.ts:289, :820, :970):
// runtime spawning would need a fake-Linux fake-Node-20 environment that
// is not portable; asserting the gate exists in source catches the
// regression at PR time and is the precedent used elsewhere in this file.
describe("Issue #564 — doctor() flags Linux + Node < 22.5 + no Bun", () => {
const CLI_SRC = readFileSync(resolve(ROOT, "src", "cli.ts"), "utf-8");
function doctorBody(): string {
const start = CLI_SRC.indexOf("async function doctor(");
expect(start).toBeGreaterThan(-1);
// doctor() spans ~300 lines; grab a generous window that ends before
// the next top-level function declaration (`async function insight`).
const end = CLI_SRC.indexOf("async function insight", start);
expect(end).toBeGreaterThan(start);
return CLI_SRC.slice(start, end);
}
it("doctor fails on Linux + Node < 22.5 + no bun (RED FAIL line)", () => {
const body = doctorBody();
// The Linux predicate must be in doctor().
expect(body).toMatch(/process\.platform\s*===\s*["']linux["']/);
// Must consult the 22.5 gate via hasModernSqlite (the source of truth
// in src/db-base.ts:226-244) OR an equivalent inline major/minor check.
const usesHelper = /hasModernSqlite/.test(body);
const usesInlineGate =
/process\.versions\.node/.test(body) && /22(?:\.5|[^0-9])/.test(body);
expect(usesHelper || usesInlineGate).toBe(true);
// Bun must be allowed through (Linux + Bun is fine).
expect(body).toMatch(/globalThis\.Bun|hasBunRuntime|process\.versions\.bun/);
// Must be a RED FAIL (architect mandate) — not a warn/info. Reuses
// the existing FAIL surface: `p.log.error(color.red(... FAIL ...`.
// We assert FAIL appears in the new block by matching against an
// anchor unique to it (issue #564 reference).
const issueIdx = body.indexOf("#564");
expect(issueIdx).toBeGreaterThan(-1);
// Look at a wide window around the #564 anchor — the comment block
// sits above the predicate and the FAIL emission sits below, so we
// grab text on both sides.
const surrounding = body.slice(
Math.max(0, issueIdx - 1500),
issueIdx + 2000,
);
expect(surrounding).toMatch(/p\.log\.error/);
expect(surrounding).toMatch(/FAIL/);
// The block must increment criticalFails so the doctor exits non-zero.
expect(surrounding).toMatch(/criticalFails\+\+/);
// Remediation: must point users at 22.5+ (or Bun).
expect(body).toMatch(/22\.5/);
});
});
// ── Upgrade flow: stale ABI guard ─────────────────────────────────────
// `/ctx-upgrade` must not declare success just because better_sqlite3.node
// exists. On modern Node the startup probe is skipped, so the ABI-specific
+83 -3
View File
@@ -2548,9 +2548,45 @@ describe("ctx_purge scoped handler (issue #520)", () => {
});
// Slice 6 — schema rejects {confirm, sessionId, scope:"project"} (ambiguous).
test("slice 6: schema refuses ambiguous {sessionId + scope:'project'}", () => {
// Implemented via z.object(...).refine(...) on the inputSchema.
expect(purgeBody).toMatch(/\.refine\(/);
// The MCP SDK's normalizeObjectSchema() requires a plain ZodObject so it
// can read `.shape` when serializing inputSchema → JSON Schema for
// tools/list. A `.refine()` wrapper produces a ZodEffects which has no
// `.shape`, so the SDK falls back to `properties: {}` — and Claude Code's
// strict-input-validation gate then rejects the tool call before the
// handler ever runs. Issue #563.
//
// Therefore the cross-field check MUST live in the handler body, not on
// the schema. Verify (a) the inputSchema is NOT wrapped in refine() and
// (b) the handler still rejects the ambiguous combo at runtime.
test("slice 6: inputSchema is plain z.object — no .refine/.transform/.superRefine wrapper (#563)", () => {
// Locate the inputSchema literal (between `inputSchema:` and the next
// top-level handler comma `},`). Anchor narrowly so we only inspect
// the schema, not the handler body that legitimately contains checks.
const schemaStart = purgeBody.indexOf("inputSchema:");
expect(schemaStart).toBeGreaterThan(-1);
// The schema literal ends at the matching close of registerTool's
// options object — i.e. just before `},\n async (`.
const handlerStart = purgeBody.indexOf("async ({");
expect(handlerStart).toBeGreaterThan(schemaStart);
const schemaSlice = purgeBody.slice(schemaStart, handlerStart);
expect(schemaSlice).not.toMatch(/\.refine\(/);
expect(schemaSlice).not.toMatch(/\.superRefine\(/);
expect(schemaSlice).not.toMatch(/\.transform\(/);
});
test("slice 6b: handler rejects ambiguous {sessionId + scope:'project'} at runtime (#563)", () => {
// The cross-field ambiguity check moved out of the schema into the
// handler body. Verify a guard exists that fires when sessionId is
// present AND scope === "project", and that it returns isError:true
// rather than throwing.
const handlerSlice = purgeBody.slice(purgeBody.indexOf("async ({"));
expect(handlerSlice).toMatch(
/sessionId\s*&&\s*scope\s*===\s*["']project["']|scope\s*===\s*["']project["']\s*&&\s*sessionId/,
);
expect(handlerSlice).toMatch(/isError:\s*true/);
// Human-readable message preserved (matches the original refine() text
// so consumers see the same guidance).
expect(handlerSlice).toMatch(/[Aa]mbiguous/);
});
// Slice 7 — schema accepts {confirm:true, sessionId:"<uuid>"}.
@@ -2565,6 +2601,50 @@ describe("ctx_purge scoped handler (issue #520)", () => {
test("slice 8: handler emits deprecation warning when scope+sessionId both omitted", () => {
expect(purgeBody).toMatch(/console\.warn\([^)]*deprecat/i);
});
// Slice 9 (#563 regression — class-wide guard) — NO registered MCP tool
// may wrap its inputSchema in .refine(), .superRefine(), or .transform().
// All three produce a ZodEffects, which the MCP SDK's
// normalizeObjectSchema() does not recognize (it reads `.shape`), so the
// serialized JSON Schema collapses to `properties: {}` — and Claude Code
// (and any strict-input client) then refuses every call to that tool
// with "input_schema does not support fields". Move cross-field checks
// into the handler body. This test catches the entire class for ALL
// registered tools, not just ctx_purge.
test("slice 9: all registered MCP tools must have non-empty input schema (regression for #563)", () => {
// Match every registerTool(...) block — same anchor pattern used by
// the per-tool slices above. Greedy [\s\S]*? + line-anchored ^);
// terminator = the body of one registerTool call.
const blocks = [
...serverSrc.matchAll(
/server\.registerTool\(\s*"([^"]+)"[\s\S]*?^\);/gm,
),
];
expect(blocks.length).toBeGreaterThan(5);
const violations: string[] = [];
for (const m of blocks) {
const name = m[1];
const body = m[0];
// Isolate just the inputSchema literal (between `inputSchema:` and
// the start of the handler arrow `async (`). Tools without an
// inputSchema (none currently) are skipped silently.
const sIdx = body.indexOf("inputSchema:");
if (sIdx < 0) continue;
const hIdx = body.indexOf("async (", sIdx);
const schemaSlice = hIdx > sIdx ? body.slice(sIdx, hIdx) : body.slice(sIdx);
if (/\.refine\(/.test(schemaSlice)) violations.push(`${name}: .refine()`);
if (/\.superRefine\(/.test(schemaSlice)) violations.push(`${name}: .superRefine()`);
if (/\.transform\(/.test(schemaSlice)) violations.push(`${name}: .transform()`);
}
expect(
violations,
"ZodEffects on inputSchema breaks MCP SDK normalizeObjectSchema → JSON " +
"Schema collapses to properties:{} → Claude Code rejects with " +
"'input_schema does not support fields'. Move cross-field checks into " +
"the handler body. See issue #563.",
).toEqual([]);
});
});
// ─── KB purge behavioral (ContentStore) ─────────────────────────────────────
+39 -3
View File
@@ -30,13 +30,26 @@
import { describe, it, expect, beforeAll } from "vitest";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import {
existsSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, "..");
// Realpath-canonicalize REPO_ROOT so START_MJS is anchored to the on-disk
// path regardless of symlinked test invocations (#568 follow-up). Without
// this, a vitest run reached via a symlinked worktree alias produces a
// non-canonical START_MJS string; spawned decoy children inherit that
// string as argv, and the production POSIX_PGREP_PATTERN — which matches
// canonical install shapes — fails to discover them.
const REPO_ROOT = realpathSync(resolve(__dirname, ".."));
const START_MJS = resolve(REPO_ROOT, "start.mjs");
const BUNDLE = resolve(REPO_ROOT, "server.bundle.mjs");
@@ -61,6 +74,29 @@ describe.skipIf(!RUN)("lifecycle e2e — real binary (#565)", () => {
}
});
it("realpath guard canonicalizes symlinked worktree paths (#568 follow-up)", () => {
// Replicates the path-fragility scenario: a test loader reaching this
// file via a symlinked alias would otherwise compute a non-canonical
// REPO_ROOT, and decoy children spawned with that path get an argv
// that the production POSIX_PGREP_PATTERN can't match. Asserts the
// realpath-canonicalization at module load survives a symlink alias.
const tmpRoot = mkdtempSync(join(tmpdir(), "cm-realpath-fragility-"));
const symAlias = join(tmpRoot, "alias");
try {
symlinkSync(REPO_ROOT, symAlias, "dir");
const symlinkedStartMjs = join(symAlias, "start.mjs");
// Symlink alias resolves back to the canonical START_MJS.
expect(realpathSync(symlinkedStartMjs)).toBe(START_MJS);
// REPO_ROOT itself is canonical — fixed point under realpath.
expect(REPO_ROOT).toBe(realpathSync(REPO_ROOT));
// START_MJS is canonical too (defends against future regressions
// where a contributor reverts the realpath guard at module load).
expect(START_MJS).toBe(realpathSync(START_MJS));
} finally {
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
}
});
it("idle timeout fires on real wall-clock against built start.mjs", async () => {
// Use a short idle window for test speed; keep it well above test/runner
// jitter (CI workers sometimes pause for a couple seconds).
+28
View File
@@ -1956,6 +1956,34 @@ describe("External Ref Events", () => {
assert.ok(refs[0].data.includes("github.com/user/repo/pull/100"), "should include PR URL");
assert.ok(refs[0].data.includes("docs.example.com"), "should include doc URL");
});
test("attaches bytes_avoided parsed from ctx_fetch_and_index preamble", () => {
// SLICE 2: when a ctx_fetch_and_index call returns its single-fetch
// preamble ("Fetched and indexed **N sections** (XKB) from: ..."),
// the external_ref event must carry the bytes-avoided figure so the
// honest-savings stats line is non-zero. Without this, indexed bytes
// never reach the session_events.bytes_avoided column.
const input = {
tool_name: "mcp__plugin_context-mode_context-mode__ctx_fetch_and_index",
tool_input: { url: "https://example.com/guide" },
tool_response:
"Fetched and indexed **5 sections** (47.50KB) from: example-guide\n" +
"Full content indexed in sandbox — use ctx_search(queries: [...], source: \"example-guide\") for specific lookups.\n" +
"\n---\n\n" +
"Visit https://example.com/guide for the full doc.",
};
const events = extractEvents(input);
const refs = events.filter((e) => e.type === "external_ref");
assert.equal(refs.length, 1, "external_ref should fire on the preamble");
const ref = refs[0] as { type: string; data: string; bytes_avoided?: number };
assert.ok(
typeof ref.bytes_avoided === "number" && ref.bytes_avoided > 0,
`expected bytes_avoided > 0, got ${ref.bytes_avoided}`,
);
// 47.50KB = 47.50 * 1024 = 48640 bytes
assert.equal(ref.bytes_avoided, Math.round(47.5 * 1024));
});
});
// ════════════════════════════════════════════
+53
View File
@@ -273,6 +273,59 @@ describe("Basic Indexing", () => {
assert.ok(stats.chunks >= 1);
store.close();
});
test("attribution flows through to chunks.session_id and chunks.event_id (#FK)", () => {
// SLICE 1: index*() must accept an optional `attribution` so chunks rows
// carry the session/event that triggered them. Hardcoded "" defeats the
// FK to session_events that powers per-session honest-savings stats.
const dbPath = join(
tmpdir(),
`context-mode-attrfk-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
const store = new ContentStore(dbPath);
store.index({
content: "# Hello\n\nAttribution test body.",
source: "attr-doc",
attribution: { sessionId: "sess-FK-1", eventId: "evt-FK-9" },
} as Parameters<typeof store.index>[0]);
store.indexPlainText(
"log line one\nlog line two\nlog line three",
"attr-plain",
20,
{ sessionId: "sess-FK-2", eventId: "evt-FK-10" },
);
store.indexJSON(
JSON.stringify({ a: 1, b: { c: "x" } }),
"attr-json",
undefined,
{ sessionId: "sess-FK-3", eventId: "evt-FK-11" },
);
// Read raw rows back through a fresh handle to confirm persisted columns.
store.close();
const Database = loadDatabase();
const db = new Database(dbPath, { readonly: true });
try {
const rows = db
.prepare(
"SELECT title, source_id, session_id, event_id FROM chunks WHERE session_id != '' ORDER BY rowid",
)
.all() as Array<{ title: string; session_id: string; event_id: string }>;
const sessions = rows.map((r) => r.session_id);
const events = rows.map((r) => r.event_id);
assert.ok(rows.length >= 3, `expected attributed chunks across 3 indexers, got ${rows.length}`);
assert.ok(sessions.includes("sess-FK-1"), "index() must persist sessionId");
assert.ok(sessions.includes("sess-FK-2"), "indexPlainText() must persist sessionId");
assert.ok(sessions.includes("sess-FK-3"), "indexJSON() must persist sessionId");
assert.ok(events.includes("evt-FK-9"), "index() must persist eventId");
assert.ok(events.includes("evt-FK-10"), "indexPlainText() must persist eventId");
assert.ok(events.includes("evt-FK-11"), "indexJSON() must persist eventId");
} finally {
closeDB(db);
}
});
});
describe("Heading-Aware Chunking", () => {
+31
View File
@@ -213,4 +213,35 @@ describe("heal-better-sqlite3.mjs — Windows VS year detection (VS 2026+)", ()
it("buildSafeEnv only sets npm_config_msvs_version when not already present", () => {
expect(HEAL_SRC).toMatch(/!\s*env\.npm_config_msvs_version/);
});
// ── Follow-up (ARCH-REVIEW Part B #2 + #3): timeout 15s + year sanity cap ──
// 1. Slow Windows CI/HDD vswhere queries can exceed 5s — bump to 15s.
// 2. Any year > currentYear+5 indicates corrupted vswhere output or future
// MS rebrand; fail loud (return null + stderr) instead of silently
// poisoning npm_config_msvs_version with bogus "2099".
it("uses a 15s vswhere timeout and rejects years beyond currentYear+5", async () => {
// (a) Source contract: timeout literal must be 15000ms, not 5000.
expect(HEAL_SRC).toMatch(/timeout:\s*15000/);
expect(HEAL_SRC).not.toMatch(/timeout:\s*5000/);
// (b) Behavioral contract: years > currentYear+5 are rejected → null.
const { detectWindowsVsYear } = await import(
"../../scripts/heal-better-sqlite3.mjs"
);
const bogusYear = String(new Date().getFullYear() + 10);
expect(detectWindowsVsYear({
platform: "win32",
existsSync: () => true,
exec: () => `Visual Studio Community ${bogusYear}`,
})).toBeNull();
// Sanity: a year within the +5 envelope is still accepted (this is what
// distinguishes a real cap from a hardcoded current-year reject).
const futureOkYear = String(new Date().getFullYear() + 3);
expect(detectWindowsVsYear({
platform: "win32",
existsSync: () => true,
exec: () => `Visual Studio Community ${futureOkYear}`,
})).toBe(futureOkYear);
});
});
+72
View File
@@ -486,3 +486,75 @@ describe("postinstall — global install, registry already healthy", () => {
expect(ctxLines[0]).toMatch(/no heal needed/i);
});
});
// ─────────────────────────────────────────────────────────────────────────
// Slice 9 — Issue #564 — Linux SIGSEGV class hard-fail
//
// Six prior fixes (#228, #331, #461, #540, #551, #556) silently assumed
// Node >= 22.5 on Linux. Reporter #564 hit the SIGSEGV from V8's
// madvise(MADV_DONTNEED) corrupting better-sqlite3 .got.plt on Linux + Node 20.
//
// Contract for v1.0.132:
// 1. `package.json` declares `engines.node >= 22.5.0` (cosmetic in npm but
// load-bearing for pnpm/yarn and tooling).
// 2. `scripts/postinstall.mjs` HARD-FAILS (process.exit(1)) on
// Linux + Node < 22.5 + no Bun. Architect rejected "warn nicely" — the
// contract IS Node >= 22.5 on Linux; make it real.
//
// Static-analysis tests (same pattern as cli.test.ts:289, :820) — spawning
// a fake older Node is not portable, but asserting the gate exists in source
// catches the regression at PR time.
// ─────────────────────────────────────────────────────────────────────────
describe("postinstall — Issue #564 Linux SIGSEGV hard-fail (engines.node + Node-version gate)", () => {
it("package.json declares engines.node >= 22.5.0", () => {
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf-8"));
// Field must exist
expect(pkg.engines).toBeDefined();
expect(typeof pkg.engines.node).toBe("string");
// Must enforce the 22.5 floor used by hasModernSqlite() in src/db-base.ts.
// Looser ranges (>=18, >=20) would bless the SIGSEGV-prone versions.
expect(pkg.engines.node).toMatch(/>=\s*22\.5/);
// Sanity: Node 20.x must NOT satisfy the declared range.
// We don't pull semver from npm; assert no `||` clause widens to 20.x.
expect(pkg.engines.node).not.toMatch(/>=\s*1[0-9]\b/);
expect(pkg.engines.node).not.toMatch(/>=\s*20\b/);
expect(pkg.engines.node).not.toMatch(/>=\s*21\b/);
});
it("postinstall.mjs hard-fails on Linux + Node < 22.5 + no Bun (process.exit(1))", () => {
const src = readFileSync(REPO_POSTINSTALL, "utf-8");
// The gate must reference Linux explicitly.
expect(src).toMatch(/process\.platform\s*===\s*["']linux["']/);
// The gate must reference the 22.5 Node-version floor (via either an
// inline major/minor compare or by importing hasModernSqlite).
const has225InlineGate =
/process\.versions\.node/.test(src) && /22(?:\.5|[^0-9])/.test(src);
const importsModernSqliteHelper =
/hasModernSqlite/.test(src);
expect(has225InlineGate || importsModernSqliteHelper).toBe(true);
// The gate must allow Bun through (Linux + Bun is fine because bun:sqlite
// sidesteps better-sqlite3 entirely).
expect(src).toMatch(/globalThis\.Bun|process\.versions\.bun|typeof\s+Bun/);
// The gate must HARD-FAIL — `process.exit(1)` is the architect contract.
// A bare `process.stderr.write` warning would be a soft-warn regression.
const sigsegvBlock = src.slice(
Math.max(
0,
Math.min(
...["#564", "nodejs/node#62515", "SIGSEGV", "22.5"]
.map((needle) => {
const i = src.indexOf(needle);
return i === -1 ? Infinity : i;
}),
) - 200,
),
src.length,
);
expect(sigsegvBlock).toMatch(/process\.exit\(\s*1\s*\)/);
// Remediation must point users at Node 22.5+ OR Bun, and reference #564
// so the GitHub thread is discoverable.
expect(src).toMatch(/22\.5|22\.13/);
expect(src).toMatch(/#564|issues\/564/);
});
});