context-mode 0.9.15 → 0.9.17

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.
@@ -13,7 +13,7 @@
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": "0.9.15",
16
+ "version": "0.9.17",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
package/README.md CHANGED
@@ -190,7 +190,9 @@ with code examples. Then run /context-mode:stats.
190
190
  - **Claude Code** with MCP support
191
191
  - Optional: Bun (auto-detected, 3-5x faster JS/TS)
192
192
 
193
- ## Development
193
+ ## Contributing
194
+
195
+ We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for the full local development workflow, TDD guidelines, and how to test your changes in a live Claude Code session.
194
196
 
195
197
  ```bash
196
198
  git clone https://github.com/mksglu/claude-context-mode.git
package/build/executor.js CHANGED
@@ -36,7 +36,14 @@ export class PolyglotExecutor {
36
36
  const tmpDir = mkdtempSync(join(tmpdir(), "ctx-mode-"));
37
37
  try {
38
38
  const filePath = this.#writeScript(tmpDir, code, language);
39
- const cmd = buildCommand(this.#runtimes, language, filePath);
39
+ let cmd;
40
+ try {
41
+ cmd = buildCommand(this.#runtimes, language, filePath);
42
+ }
43
+ catch (err) {
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ return { exitCode: 1, stdout: "", stderr: msg, timedOut: false };
46
+ }
40
47
  // Rust: compile then run
41
48
  if (cmd[0] === "__rust_compile_run__") {
42
49
  return await this.#compileAndRun(filePath, tmpDir, timeout);
@@ -44,7 +51,13 @@ export class PolyglotExecutor {
44
51
  return await this.#spawn(cmd, tmpDir, timeout);
45
52
  }
46
53
  finally {
47
- rmSync(tmpDir, { recursive: true, force: true });
54
+ try {
55
+ rmSync(tmpDir, { recursive: true, force: true });
56
+ }
57
+ catch {
58
+ // On Windows, EBUSY/EPERM is common due to delayed handle release
59
+ // after child process exit. Silently ignore — OS cleans temp dirs.
60
+ }
48
61
  }
49
62
  }
50
63
  async executeFile(opts) {
@@ -9,7 +9,7 @@ export interface RuntimeMap {
9
9
  javascript: string;
10
10
  typescript: string | null;
11
11
  python: string | null;
12
- shell: string;
12
+ shell: string | null;
13
13
  ruby: string | null;
14
14
  go: string | null;
15
15
  rust: string | null;
package/build/runtime.js CHANGED
@@ -40,7 +40,15 @@ export function detectRuntimes() {
40
40
  : commandExists("python")
41
41
  ? "python"
42
42
  : null,
43
- shell: commandExists("bash") ? "bash" : "sh",
43
+ shell: commandExists("bash")
44
+ ? "bash"
45
+ : commandExists("sh")
46
+ ? "sh"
47
+ : commandExists("powershell")
48
+ ? "powershell"
49
+ : commandExists("cmd.exe")
50
+ ? "cmd.exe"
51
+ : null,
44
52
  ruby: commandExists("ruby") ? "ruby" : null,
45
53
  go: commandExists("go") ? "go" : null,
46
54
  rust: commandExists("rustc") ? "rustc" : null,
@@ -73,7 +81,12 @@ export function getRuntimeSummary(runtimes) {
73
81
  else {
74
82
  lines.push(` Python: not available`);
75
83
  }
76
- lines.push(` Shell: ${runtimes.shell} (${getVersion(runtimes.shell)})`);
84
+ if (runtimes.shell) {
85
+ lines.push(` Shell: ${runtimes.shell} (${getVersion(runtimes.shell)})`);
86
+ }
87
+ else {
88
+ lines.push(` Shell: not available`);
89
+ }
77
90
  // Optional runtimes — only show if available
78
91
  if (runtimes.ruby)
79
92
  lines.push(` Ruby: ${runtimes.ruby} (${getVersion(runtimes.ruby)})`);
@@ -96,7 +109,9 @@ export function getRuntimeSummary(runtimes) {
96
109
  return lines.join("\n");
97
110
  }
98
111
  export function getAvailableLanguages(runtimes) {
99
- const langs = ["javascript", "shell"];
112
+ const langs = ["javascript"];
113
+ if (runtimes.shell)
114
+ langs.push("shell");
100
115
  if (runtimes.typescript)
101
116
  langs.push("typescript");
102
117
  if (runtimes.python)
@@ -138,6 +153,9 @@ export function buildCommand(runtimes, language, filePath) {
138
153
  }
139
154
  return [runtimes.python, filePath];
140
155
  case "shell":
156
+ if (!runtimes.shell) {
157
+ throw new Error("No shell runtime available. Install bash, sh, powershell, or cmd.");
158
+ }
141
159
  return [runtimes.shell, filePath];
142
160
  case "ruby":
143
161
  if (!runtimes.ruby) {
package/build/server.d.ts CHANGED
@@ -1,2 +1,8 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * Parse FTS5 highlight markers to find match positions in the
4
+ * original (marker-free) text. Returns character offsets into the
5
+ * stripped content where each matched token begins.
6
+ */
7
+ export declare function positionsFromHighlight(highlighted: string): number[];
8
+ export declare function extractSnippet(content: string, query: string, maxLen?: number, highlighted?: string): string;
package/build/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createRequire } from "node:module";
4
5
  import { z } from "zod";
5
6
  import { PolyglotExecutor } from "./executor.js";
6
7
  import { ContentStore, cleanupStaleDBs } from "./store.js";
@@ -51,25 +52,76 @@ const bunNote = hasBunRuntime()
51
52
  // ─────────────────────────────────────────────────────────
52
53
  // Helper: smart snippet extraction — returns windows around
53
54
  // matching query terms instead of dumb truncation
55
+ //
56
+ // When `highlighted` is provided (from FTS5 `highlight()` with
57
+ // STX/ETX markers), match positions are derived from the markers.
58
+ // This is the authoritative source — FTS5 uses the exact same
59
+ // tokenizer that produced the BM25 match, so stemmed variants
60
+ // like "configuration" matching query "configure" are found
61
+ // correctly. Falls back to indexOf on raw terms when highlighted
62
+ // is absent (non-FTS codepath).
54
63
  // ─────────────────────────────────────────────────────────
55
- function extractSnippet(content, query, maxLen = 1500) {
64
+ const STX = "\x02";
65
+ const ETX = "\x03";
66
+ /**
67
+ * Parse FTS5 highlight markers to find match positions in the
68
+ * original (marker-free) text. Returns character offsets into the
69
+ * stripped content where each matched token begins.
70
+ */
71
+ export function positionsFromHighlight(highlighted) {
72
+ const positions = [];
73
+ let cleanOffset = 0;
74
+ let i = 0;
75
+ while (i < highlighted.length) {
76
+ if (highlighted[i] === STX) {
77
+ // Record position of this match in the clean text
78
+ positions.push(cleanOffset);
79
+ i++; // skip STX
80
+ // Advance through matched text until ETX
81
+ while (i < highlighted.length && highlighted[i] !== ETX) {
82
+ cleanOffset++;
83
+ i++;
84
+ }
85
+ if (i < highlighted.length)
86
+ i++; // skip ETX
87
+ }
88
+ else {
89
+ cleanOffset++;
90
+ i++;
91
+ }
92
+ }
93
+ return positions;
94
+ }
95
+ /** Strip STX/ETX markers to recover original content. */
96
+ function stripMarkers(highlighted) {
97
+ return highlighted.replaceAll(STX, "").replaceAll(ETX, "");
98
+ }
99
+ export function extractSnippet(content, query, maxLen = 1500, highlighted) {
56
100
  if (content.length <= maxLen)
57
101
  return content;
58
- const terms = query
59
- .toLowerCase()
60
- .split(/\s+/)
61
- .filter((t) => t.length > 2);
62
- const lower = content.toLowerCase();
63
- // Find all positions where query terms appear
102
+ // Derive match positions from FTS5 highlight markers when available
64
103
  const positions = [];
65
- for (const term of terms) {
66
- let idx = lower.indexOf(term);
67
- while (idx !== -1) {
68
- positions.push(idx);
69
- idx = lower.indexOf(term, idx + 1);
104
+ if (highlighted) {
105
+ for (const pos of positionsFromHighlight(highlighted)) {
106
+ positions.push(pos);
70
107
  }
71
108
  }
72
- // No term matches return start (BM25 matched on stems/variants)
109
+ // Fallback: indexOf on raw query terms (non-FTS codepath)
110
+ if (positions.length === 0) {
111
+ const terms = query
112
+ .toLowerCase()
113
+ .split(/\s+/)
114
+ .filter((t) => t.length > 2);
115
+ const lower = content.toLowerCase();
116
+ for (const term of terms) {
117
+ let idx = lower.indexOf(term);
118
+ while (idx !== -1) {
119
+ positions.push(idx);
120
+ idx = lower.indexOf(term, idx + 1);
121
+ }
122
+ }
123
+ }
124
+ // No matches at all — return prefix
73
125
  if (positions.length === 0) {
74
126
  return content.slice(0, maxLen) + "\n…";
75
127
  }
@@ -541,7 +593,7 @@ server.registerTool("search", {
541
593
  .map((r, i) => {
542
594
  const header = `--- [${r.source}] ---`;
543
595
  const heading = `### ${r.title}`;
544
- const snippet = extractSnippet(r.content, q);
596
+ const snippet = extractSnippet(r.content, q, 1500, r.highlighted);
545
597
  return `${header}\n${heading}\n\n${snippet}`;
546
598
  })
547
599
  .join("\n\n");
@@ -577,75 +629,48 @@ server.registerTool("search", {
577
629
  }
578
630
  });
579
631
  // ─────────────────────────────────────────────────────────
632
+ // Turndown path resolution (external dep, like better-sqlite3)
633
+ // ─────────────────────────────────────────────────────────
634
+ let _turndownPath = null;
635
+ let _gfmPluginPath = null;
636
+ function resolveTurndownPath() {
637
+ if (!_turndownPath) {
638
+ const require = createRequire(import.meta.url);
639
+ _turndownPath = require.resolve("turndown");
640
+ }
641
+ return _turndownPath;
642
+ }
643
+ function resolveGfmPluginPath() {
644
+ if (!_gfmPluginPath) {
645
+ const require = createRequire(import.meta.url);
646
+ _gfmPluginPath = require.resolve("turndown-plugin-gfm");
647
+ }
648
+ return _gfmPluginPath;
649
+ }
650
+ // ─────────────────────────────────────────────────────────
580
651
  // Tool: fetch_and_index
581
652
  // ─────────────────────────────────────────────────────────
582
- const HTML_TO_MARKDOWN_CODE = `
583
- const url = process.argv[1];
584
- if (!url) { console.error("No URL provided"); process.exit(1); }
653
+ function buildFetchCode(url) {
654
+ const turndownPath = JSON.stringify(resolveTurndownPath());
655
+ const gfmPath = JSON.stringify(resolveGfmPluginPath());
656
+ return `
657
+ const TurndownService = require(${turndownPath});
658
+ const { gfm } = require(${gfmPath});
659
+ const url = ${JSON.stringify(url)};
585
660
 
586
661
  async function main() {
587
662
  const resp = await fetch(url);
588
663
  if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
664
+ const html = await resp.text();
589
665
 
590
- let html = await resp.text();
591
-
592
- // Strip script, style, nav, header, footer tags with content
593
- html = html.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, "");
594
- html = html.replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, "");
595
- html = html.replace(/<nav[^>]*>[\\s\\S]*?<\\/nav>/gi, "");
596
- html = html.replace(/<header[^>]*>[\\s\\S]*?<\\/header>/gi, "");
597
- html = html.replace(/<footer[^>]*>[\\s\\S]*?<\\/footer>/gi, "");
598
-
599
- // Convert headings to markdown
600
- html = html.replace(/<h1[^>]*>(.*?)<\\/h1>/gi, "\\n# $1\\n");
601
- html = html.replace(/<h2[^>]*>(.*?)<\\/h2>/gi, "\\n## $1\\n");
602
- html = html.replace(/<h3[^>]*>(.*?)<\\/h3>/gi, "\\n### $1\\n");
603
- html = html.replace(/<h4[^>]*>(.*?)<\\/h4>/gi, "\\n#### $1\\n");
604
-
605
- // Convert code blocks
606
- html = html.replace(/<pre[^>]*><code[^>]*class="[^"]*language-(\\w+)"[^>]*>([\\s\\S]*?)<\\/code><\\/pre>/gi,
607
- (_, lang, code) => "\\n\\\`\\\`\\\`" + lang + "\\n" + decodeEntities(code) + "\\n\\\`\\\`\\\`\\n");
608
- html = html.replace(/<pre[^>]*><code[^>]*>([\\s\\S]*?)<\\/code><\\/pre>/gi,
609
- (_, code) => "\\n\\\`\\\`\\\`\\n" + decodeEntities(code) + "\\n\\\`\\\`\\\`\\n");
610
- html = html.replace(/<code[^>]*>([^<]*)<\\/code>/gi, "\\\`$1\\\`");
611
-
612
- // Convert links
613
- html = html.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\\/a>/gi, "[$2]($1)");
614
-
615
- // Convert lists
616
- html = html.replace(/<li[^>]*>(.*?)<\\/li>/gi, "- $1\\n");
617
-
618
- // Convert paragraphs and line breaks
619
- html = html.replace(/<p[^>]*>(.*?)<\\/p>/gi, "\\n$1\\n");
620
- html = html.replace(/<br\\s*\\/?>/gi, "\\n");
621
- html = html.replace(/<hr\\s*\\/?>/gi, "\\n---\\n");
622
-
623
- // Strip remaining HTML tags
624
- html = html.replace(/<[^>]+>/g, "");
625
-
626
- // Decode HTML entities
627
- html = decodeEntities(html);
628
-
629
- // Clean up whitespace
630
- html = html.replace(/\\n{3,}/g, "\\n\\n").trim();
631
-
632
- console.log(html);
666
+ const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
667
+ td.use(gfm);
668
+ td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
669
+ console.log(td.turndown(html));
633
670
  }
634
-
635
- function decodeEntities(s) {
636
- return s
637
- .replace(/&amp;/g, "&")
638
- .replace(/&lt;/g, "<")
639
- .replace(/&gt;/g, ">")
640
- .replace(/&quot;/g, '"')
641
- .replace(/&#39;/g, "'")
642
- .replace(/&#x27;/g, "'")
643
- .replace(/&#x2F;/g, "/")
644
- .replace(/&nbsp;/g, " ");
645
- }
646
-
647
671
  main();
648
672
  `;
673
+ }
649
674
  server.registerTool("fetch_and_index", {
650
675
  title: "Fetch & Index URL",
651
676
  description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
@@ -661,7 +686,7 @@ server.registerTool("fetch_and_index", {
661
686
  }, async ({ url, source }) => {
662
687
  try {
663
688
  // Execute fetch inside subprocess — raw HTML never enters context
664
- const fetchCode = `process.argv[1] = ${JSON.stringify(url)};\n${HTML_TO_MARKDOWN_CODE}`;
689
+ const fetchCode = buildFetchCode(url);
665
690
  const result = await executor.execute({
666
691
  language: "javascript",
667
692
  code: fetchCode,
@@ -822,7 +847,7 @@ server.registerTool("batch_execute", {
822
847
  queryResults.push("");
823
848
  if (results.length > 0) {
824
849
  for (const r of results) {
825
- const snippet = extractSnippet(r.content, query);
850
+ const snippet = extractSnippet(r.content, query, 1500, r.highlighted);
826
851
  queryResults.push(`### ${r.title}`);
827
852
  queryResults.push(snippet);
828
853
  queryResults.push("");
package/build/store.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface SearchResult {
20
20
  rank: number;
21
21
  contentType: "code" | "prose";
22
22
  matchLayer?: "porter" | "trigram" | "fuzzy";
23
+ highlighted?: string;
23
24
  }
24
25
  export interface StoreStats {
25
26
  sources: number;
package/build/store.js CHANGED
@@ -271,7 +271,8 @@ export class ContentStore {
271
271
  chunks.content,
272
272
  chunks.content_type,
273
273
  sources.label,
274
- bm25(chunks, 2.0, 1.0) AS rank
274
+ bm25(chunks, 2.0, 1.0) AS rank,
275
+ highlight(chunks, 1, char(2), char(3)) AS highlighted
275
276
  FROM chunks
276
277
  JOIN sources ON sources.id = chunks.source_id
277
278
  WHERE chunks MATCH ? ${sourceFilter}
@@ -288,6 +289,7 @@ export class ContentStore {
288
289
  source: r.label,
289
290
  rank: r.rank,
290
291
  contentType: r.content_type,
292
+ highlighted: r.highlighted,
291
293
  }));
292
294
  }
293
295
  // ── Trigram Search (Layer 2) ──
@@ -302,7 +304,8 @@ export class ContentStore {
302
304
  chunks_trigram.content,
303
305
  chunks_trigram.content_type,
304
306
  sources.label,
305
- bm25(chunks_trigram, 2.0, 1.0) AS rank
307
+ bm25(chunks_trigram, 2.0, 1.0) AS rank,
308
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
306
309
  FROM chunks_trigram
307
310
  JOIN sources ON sources.id = chunks_trigram.source_id
308
311
  WHERE chunks_trigram MATCH ? ${sourceFilter}
@@ -319,6 +322,7 @@ export class ContentStore {
319
322
  source: r.label,
320
323
  rank: r.rank,
321
324
  contentType: r.content_type,
325
+ highlighted: r.highlighted,
322
326
  }));
323
327
  }
324
328
  // ── Fuzzy Correction (Layer 3) ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
4
4
  "type": "module",
5
5
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
6
6
  "author": "Mert Koseoğlu",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "build": "tsc",
41
- "bundle": "esbuild src/server.ts --bundle --platform=node --target=node18 --format=esm --outfile=server.bundle.mjs --external:better-sqlite3 --minify",
41
+ "bundle": "esbuild src/server.ts --bundle --platform=node --target=node18 --format=esm --outfile=server.bundle.mjs --external:better-sqlite3 --external:turndown --external:turndown-plugin-gfm --external:@mixmark-io/domino --minify",
42
42
  "prepublishOnly": "npm run build",
43
43
  "dev": "npx tsx src/server.ts",
44
44
  "setup": "npx tsx src/cli.ts setup",
@@ -56,18 +56,23 @@
56
56
  "test:stream-cap": "npx tsx tests/stream-cap.test.ts",
57
57
  "test:search-wiring": "npx tsx tests/search-wiring.test.ts",
58
58
  "test:search-fallback": "npx tsx tests/search-fallback-integration.test.ts",
59
+ "test:turndown": "npx tsx tests/turndown.test.ts",
59
60
  "test:all": "for f in tests/*.test.ts; do npx tsx \"$f\" || exit 1; done"
60
61
  },
61
62
  "dependencies": {
62
63
  "@clack/prompts": "^1.0.1",
64
+ "@mixmark-io/domino": "^2.2.0",
63
65
  "@modelcontextprotocol/sdk": "^1.26.0",
64
66
  "better-sqlite3": "^12.6.2",
65
67
  "picocolors": "^1.1.1",
68
+ "turndown": "^7.2.0",
69
+ "turndown-plugin-gfm": "^1.0.2",
66
70
  "zod": "^3.25.0"
67
71
  },
68
72
  "devDependencies": {
69
73
  "@types/better-sqlite3": "^7.6.13",
70
74
  "@types/node": "^22.19.11",
75
+ "@types/turndown": "^5.0.5",
71
76
  "esbuild": "^0.27.3",
72
77
  "tsx": "^4.21.0",
73
78
  "typescript": "^5.7.0"