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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -5
- package/build/server.js +83 -13
- package/build/store.d.ts +6 -0
- package/build/store.js +71 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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 |
|
|
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 (
|
|
372
|
-
npm run test:all # all suites (
|
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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",
|