context-mode 0.4.1 → 0.5.0

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 94% of your context window. Sandboxed code execution in 10 languages, FTS5 knowledge base with BM25 ranking, and smart truncation.",
16
- "version": "0.4.1",
16
+ "version": "0.5.0",
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.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code MCP plugin that saves 94% of your context window. Sandboxed code execution in 10 languages, FTS5 knowledge base with BM25 ranking, and smart truncation.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
package/README.md CHANGED
@@ -75,11 +75,22 @@ Claude calls: execute({ language: "shell", code: "gh pr list --json title,state
75
75
  Returns: "3" ← 2 bytes instead of 8KB JSON
76
76
  ```
77
77
 
78
+ **Intent-driven search** (v0.5.0): When you provide an `intent` parameter and output exceeds 5KB, Context Mode uses BM25 search to return only the relevant sections — instead of blind head/tail truncation.
79
+
80
+ ```
81
+ Claude calls: execute({
82
+ language: "shell",
83
+ code: "cat /var/log/app.log",
84
+ intent: "connection refused database error"
85
+ })
86
+ Returns: only the 3 matching log sections (1.5KB) ← instead of 100KB truncated log
87
+ ```
88
+
78
89
  Authenticated CLIs work out of the box — `gh`, `aws`, `gcloud`, `kubectl`, `docker` credentials are passed through securely. Bun auto-detected for 3-5x faster JS/TS.
79
90
 
80
91
  ### `execute_file` — Process Files Without Loading
81
92
 
82
- File contents never enter context. The file is read into a `FILE_CONTENT` variable inside the sandbox.
93
+ File contents never enter context. The file is read into a `FILE_CONTENT` variable inside the sandbox. Also supports `intent` parameter for intent-driven search on large outputs.
83
94
 
84
95
  ```
85
96
  Claude calls: execute_file({ path: "access.log", language: "python", code: "..." })
@@ -214,6 +225,31 @@ Tail (40%): Final output with errors/results
214
225
 
215
226
  Line-boundary snapping — never cuts mid-line. Error messages at the bottom are always preserved.
216
227
 
228
+ ### Intent-Driven Search (v0.5.0)
229
+
230
+ When `execute` or `execute_file` is called with an `intent` parameter and output exceeds 5KB, Context Mode replaces blind truncation with intelligent BM25 search:
231
+
232
+ ```
233
+ Traditional truncation:
234
+ stdout (100KB) → head(60%) + tail(40%) → ~100KB in context
235
+ Problem: relevant info in the middle is lost
236
+
237
+ Intent-driven search:
238
+ stdout (100KB) → chunk by lines → in-memory FTS5 → search(intent) → 2-5KB relevant sections
239
+ Result: only what you need enters context
240
+ ```
241
+
242
+ Tested across 4 real-world scenarios:
243
+
244
+ | Scenario | Smart Truncation | Intent Search | Intent Size | Truncation Size |
245
+ |---|---|---|---|---|
246
+ | Server log error (line 347/500) | **missed** | **found** | 1.5 KB | 5.0 KB |
247
+ | 3 test failures among 200 tests | found 2/3 | **found 3/3** | 2.4 KB | 5.0 KB |
248
+ | 2 build warnings among 300 lines | **missed both** | **found both** | 2.1 KB | 5.0 KB |
249
+ | API auth error (line 743/1000) | **missed** | **found** | 1.2 KB | 4.9 KB |
250
+
251
+ Smart truncation fails on 3 of 4 scenarios because relevant content is in the dropped middle section. Intent search finds the target every time while using 50-75% fewer bytes.
252
+
217
253
  ### HTML to Markdown Conversion
218
254
 
219
255
  `fetch_and_index` converts HTML in a subprocess (raw HTML never enters context):
@@ -352,12 +388,13 @@ Just ask naturally — Claude automatically routes through Context Mode when it
352
388
 
353
389
  ## Test Suite
354
390
 
355
- 113 tests across 3 suites:
391
+ 99+ tests across 4 suites:
356
392
 
357
393
  | Suite | Tests | Coverage |
358
394
  |---|---|---|
359
395
  | Executor | 55 | 10 languages, sandbox, truncation, concurrency, timeouts |
360
- | ContentStore | 34 | FTS5 schema, BM25 ranking, chunking, stemming, fixtures |
396
+ | ContentStore | 40 | FTS5 schema, BM25 ranking, chunking, stemming, plain text indexing |
397
+ | Intent Search | 4 | Smart truncation vs intent-driven search across 4 real-world scenarios |
361
398
  | MCP Integration | 24 | JSON-RPC protocol, all 5 tools, fetch_and_index, errors |
362
399
 
363
400
  ## Development
@@ -368,8 +405,8 @@ cd claude-context-mode
368
405
  npm install
369
406
  npm run build
370
407
  npm test # executor (55 tests)
371
- npm run test:store # FTS5/BM25 (34 tests)
372
- npm run test:all # all suites (113 tests)
408
+ npm run test:store # FTS5/BM25 (40 tests)
409
+ npm run test:all # all suites (99+ tests)
373
410
  ```
374
411
 
375
412
  ## License
package/build/server.js CHANGED
@@ -9,7 +9,7 @@ const runtimes = detectRuntimes();
9
9
  const available = getAvailableLanguages(runtimes);
10
10
  const server = new McpServer({
11
11
  name: "context-mode",
12
- version: "0.4.1",
12
+ version: "0.5.0",
13
13
  });
14
14
  const executor = new PolyglotExecutor({ runtimes });
15
15
  // Lazy singleton — no DB overhead unless index/search is used
@@ -53,8 +53,14 @@ server.registerTool("execute", {
53
53
  .optional()
54
54
  .default(30000)
55
55
  .describe("Max execution time in ms"),
56
+ intent: z
57
+ .string()
58
+ .optional()
59
+ .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
60
+ "returns only matching sections via BM25 search instead of truncated output. " +
61
+ "Example: 'find failing tests', 'HTTP 500 errors', 'memory usage statistics'."),
56
62
  }),
57
- }, async ({ language, code, timeout }) => {
63
+ }, async ({ language, code, timeout, intent }) => {
58
64
  try {
59
65
  const result = await executor.execute({ language, code, timeout });
60
66
  if (result.timedOut) {
@@ -69,19 +75,34 @@ server.registerTool("execute", {
69
75
  };
70
76
  }
71
77
  if (result.exitCode !== 0) {
78
+ const output = `Exit code: ${result.exitCode}\n\nstdout:\n${result.stdout}\n\nstderr:\n${result.stderr}`;
79
+ if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
80
+ return {
81
+ content: [
82
+ { type: "text", text: intentSearch(output, intent) },
83
+ ],
84
+ isError: true,
85
+ };
86
+ }
72
87
  return {
73
88
  content: [
74
- {
75
- type: "text",
76
- text: `Exit code: ${result.exitCode}\n\nstdout:\n${result.stdout}\n\nstderr:\n${result.stderr}`,
77
- },
89
+ { type: "text", text: output },
78
90
  ],
79
91
  isError: true,
80
92
  };
81
93
  }
94
+ const stdout = result.stdout || "(no output)";
95
+ // Intent-driven search: if intent provided and output is large enough
96
+ if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
97
+ return {
98
+ content: [
99
+ { type: "text", text: intentSearch(stdout, intent) },
100
+ ],
101
+ };
102
+ }
82
103
  return {
83
104
  content: [
84
- { type: "text", text: result.stdout || "(no output)" },
105
+ { type: "text", text: stdout },
85
106
  ],
86
107
  };
87
108
  }
@@ -111,6 +132,36 @@ function indexStdout(stdout, source) {
111
132
  };
112
133
  }
113
134
  // ─────────────────────────────────────────────────────────
135
+ // Helper: intent-driven search on execution output
136
+ // ─────────────────────────────────────────────────────────
137
+ const INTENT_SEARCH_THRESHOLD = 5_000; // bytes — ~80-100 lines
138
+ function intentSearch(stdout, intent, maxResults = 5) {
139
+ const store = new ContentStore(":memory:");
140
+ try {
141
+ const totalLines = stdout.split("\n").length;
142
+ const totalBytes = Buffer.byteLength(stdout);
143
+ store.indexPlainText(stdout, "exec-output");
144
+ const results = store.search(intent, maxResults);
145
+ if (results.length === 0) {
146
+ return (`[Intent search: no matches for "${intent}" in ${totalLines}-line output. Returning full output.]\n\n` +
147
+ stdout);
148
+ }
149
+ const totalChunks = store.getStats().chunks;
150
+ const header = `[Intent search: ${results.length} of ${totalChunks} sections matched "${intent}" from ${totalLines}-line output (${(totalBytes / 1024).toFixed(1)}KB)]`;
151
+ const formatted = results
152
+ .map((r, i) => {
153
+ const matchLabel = i === 0 ? " (best match)" : "";
154
+ return `--- ${r.title}${matchLabel} ---\n${r.content}`;
155
+ })
156
+ .join("\n\n");
157
+ const footer = `[Full output: ${totalLines} lines / ${(totalBytes / 1024).toFixed(1)}KB. Re-run without intent to see raw output.]`;
158
+ return `${header}\n\n${formatted}\n\n${footer}`;
159
+ }
160
+ finally {
161
+ store.close();
162
+ }
163
+ }
164
+ // ─────────────────────────────────────────────────────────
114
165
  // Tool: execute_file
115
166
  // ─────────────────────────────────────────────────────────
116
167
  server.registerTool("execute_file", {
@@ -142,8 +193,13 @@ server.registerTool("execute_file", {
142
193
  .optional()
143
194
  .default(30000)
144
195
  .describe("Max execution time in ms"),
196
+ intent: z
197
+ .string()
198
+ .optional()
199
+ .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
200
+ "returns only matching sections via BM25 search instead of truncated output."),
145
201
  }),
146
- }, async ({ path, language, code, timeout }) => {
202
+ }, async ({ path, language, code, timeout, intent }) => {
147
203
  try {
148
204
  const result = await executor.executeFile({
149
205
  path,
@@ -163,19 +219,33 @@ server.registerTool("execute_file", {
163
219
  };
164
220
  }
165
221
  if (result.exitCode !== 0) {
222
+ const output = `Error processing ${path} (exit ${result.exitCode}):\n${result.stderr || result.stdout}`;
223
+ if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
224
+ return {
225
+ content: [
226
+ { type: "text", text: intentSearch(output, intent) },
227
+ ],
228
+ isError: true,
229
+ };
230
+ }
166
231
  return {
167
232
  content: [
168
- {
169
- type: "text",
170
- text: `Error processing ${path} (exit ${result.exitCode}):\n${result.stderr || result.stdout}`,
171
- },
233
+ { type: "text", text: output },
172
234
  ],
173
235
  isError: true,
174
236
  };
175
237
  }
238
+ const stdout = result.stdout || "(no output)";
239
+ if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
240
+ return {
241
+ content: [
242
+ { type: "text", text: intentSearch(stdout, intent) },
243
+ ],
244
+ };
245
+ }
176
246
  return {
177
247
  content: [
178
- { type: "text", text: result.stdout || "(no output)" },
248
+ { type: "text", text: stdout },
179
249
  ],
180
250
  };
181
251
  }
package/build/store.d.ts CHANGED
@@ -33,6 +33,12 @@ export declare class ContentStore {
33
33
  path?: string;
34
34
  source?: string;
35
35
  }): IndexResult;
36
+ /**
37
+ * Index plain-text output (logs, build output, test results) by splitting
38
+ * into fixed-size line groups. Unlike markdown indexing, this does not
39
+ * look for headings — it chunks by line count with overlap.
40
+ */
41
+ indexPlainText(content: string, source: string, linesPerChunk?: number): IndexResult;
36
42
  search(query: string, limit?: number): SearchResult[];
37
43
  getStats(): StoreStats;
38
44
  close(): void;
package/build/store.js CHANGED
@@ -94,6 +94,42 @@ export class ContentStore {
94
94
  codeChunks,
95
95
  };
96
96
  }
97
+ // ── Index Plain Text ──
98
+ /**
99
+ * Index plain-text output (logs, build output, test results) by splitting
100
+ * into fixed-size line groups. Unlike markdown indexing, this does not
101
+ * look for headings — it chunks by line count with overlap.
102
+ */
103
+ indexPlainText(content, source, linesPerChunk = 20) {
104
+ if (!content || content.trim().length === 0) {
105
+ const insertSource = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count) VALUES (?, 0, 0)");
106
+ const info = insertSource.run(source);
107
+ return {
108
+ sourceId: Number(info.lastInsertRowid),
109
+ label: source,
110
+ totalChunks: 0,
111
+ codeChunks: 0,
112
+ };
113
+ }
114
+ const chunks = this.#chunkPlainText(content, linesPerChunk);
115
+ const insertSource = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count) VALUES (?, ?, ?)");
116
+ const insertChunk = this.#db.prepare("INSERT INTO chunks (title, content, source_id, content_type) VALUES (?, ?, ?, ?)");
117
+ const transaction = this.#db.transaction(() => {
118
+ const info = insertSource.run(source, chunks.length, 0);
119
+ const sourceId = Number(info.lastInsertRowid);
120
+ for (const chunk of chunks) {
121
+ insertChunk.run(chunk.title, chunk.content, sourceId, "prose");
122
+ }
123
+ return sourceId;
124
+ });
125
+ const sourceId = transaction();
126
+ return {
127
+ sourceId,
128
+ label: source,
129
+ totalChunks: chunks.length,
130
+ codeChunks: 0,
131
+ };
132
+ }
97
133
  // ── Search ──
98
134
  search(query, limit = 3) {
99
135
  const sanitized = sanitizeQuery(query);
@@ -203,6 +239,41 @@ export class ContentStore {
203
239
  flush();
204
240
  return chunks;
205
241
  }
242
+ #chunkPlainText(text, linesPerChunk) {
243
+ // Try blank-line splitting first for naturally-sectioned output
244
+ const sections = text.split(/\n\s*\n/);
245
+ if (sections.length >= 3 &&
246
+ sections.length <= 200 &&
247
+ sections.every((s) => Buffer.byteLength(s) < 5000)) {
248
+ return sections
249
+ .map((section, i) => ({
250
+ title: `Section ${i + 1}`,
251
+ content: section.trim(),
252
+ }))
253
+ .filter((s) => s.content.length > 0);
254
+ }
255
+ const lines = text.split("\n");
256
+ // Small enough for a single chunk
257
+ if (lines.length <= linesPerChunk) {
258
+ return [{ title: "Output", content: text }];
259
+ }
260
+ // Fixed-size line groups with 2-line overlap
261
+ const chunks = [];
262
+ const overlap = 2;
263
+ const step = Math.max(linesPerChunk - overlap, 1);
264
+ for (let i = 0; i < lines.length; i += step) {
265
+ const slice = lines.slice(i, i + linesPerChunk);
266
+ if (slice.length === 0)
267
+ break;
268
+ const startLine = i + 1;
269
+ const endLine = Math.min(i + slice.length, lines.length);
270
+ chunks.push({
271
+ title: `Lines ${startLine}-${endLine}`,
272
+ content: slice.join("\n"),
273
+ });
274
+ }
275
+ return chunks;
276
+ }
206
277
  #buildTitle(headingStack, currentHeading) {
207
278
  if (headingStack.length === 0) {
208
279
  return currentHeading || "Untitled";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code MCP plugin that saves 94% of your context window. Sandboxed code execution, FTS5 knowledge base, and smart truncation.",
6
6
  "author": "Mert Koseoğlu",