chisle 3.6.1 → 3.8.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/.agents/rules/chisle.md +48 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/chisle.md +5 -6
- package/.cursor/rules/chisle.mdc +5 -6
- package/.github/copilot-instructions.md +5 -6
- package/.kiro/steering/chisle.md +5 -6
- package/.opencode/plugins/chisle.mjs +5 -6
- package/.windsurf/rules/chisle.md +5 -6
- package/AGENTS.md +5 -7
- package/NOTICE +12 -0
- package/README.md +74 -397
- package/bin/install.js +152 -26
- package/bin/lib/mcp.js +179 -0
- package/hooks/chisle-compress-output.js +96 -40
- package/hooks/chisle-config.js +13 -6
- package/hooks/chisle-mcp-proxy.js +195 -0
- package/package.json +5 -2
- package/skills/chisle/SKILL.md +1 -1
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
const fs = require('fs');
|
|
56
56
|
const path = require('path');
|
|
57
57
|
const crypto = require('crypto');
|
|
58
|
-
const { getClaudeDir, getCopilotDir,
|
|
58
|
+
const { getClaudeDir, getCopilotDir, getOpencodeStateDir, readFlag, getDefaultMode } = require('./chisle-config');
|
|
59
59
|
|
|
60
60
|
// One threshold. Env overrides all.
|
|
61
61
|
// These are the values the old 'full' level used — the default,
|
|
@@ -84,7 +84,10 @@ const SAFE_TOOLS_COPILOT = ['bash', 'powershell', 'grep', 'glob', 'web_fetch', '
|
|
|
84
84
|
// NEVER_COMPRESS above — not merely absent from the allowlist.
|
|
85
85
|
const NEVER_COMPRESS_COPILOT = ['view', 'create', 'edit', 'str_replace', 'insert'];
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
// Letter/digit boundaries, not \b: `_` is a word char, so \b missed severities
|
|
88
|
+
// inside codes like FATAL_MEMORY_PROBE / APP_ERROR_CODE (#38), and `err!` then
|
|
89
|
+
// a space has no trailing \b at all.
|
|
90
|
+
const SALVAGE_RE = /(?<![A-Za-z0-9])(?:err!|(?:error|fail(ed|ure|ing)?|exception|traceback|panic|fatal|denied|refused|timed?[ _-]?out|assert(ion)?|segfault|npe|undefined reference|cannot find|not found|warning)(?![A-Za-z0-9]))/i;
|
|
88
91
|
const MAX_SALVAGED = 12; // error lines rescued from the elided middle
|
|
89
92
|
const MAX_SALVAGE_LINE = 300; // per-line char cap on salvaged lines
|
|
90
93
|
|
|
@@ -214,7 +217,7 @@ function compressForOpencode(toolName, text, opts) {
|
|
|
214
217
|
if (process.env.CHISLE_COMPRESS === '0') return null;
|
|
215
218
|
if (!opencodeToolAllowed(toolName)) return null;
|
|
216
219
|
if (typeof text !== 'string' || !text) return null;
|
|
217
|
-
const stateDir = opts.stateDir ||
|
|
220
|
+
const stateDir = opts.stateDir || getOpencodeStateDir();
|
|
218
221
|
const limits = limitsFor();
|
|
219
222
|
const dup = dedupCheck(toolName, text, opts.sessionId, opts.callId, stateDir, true);
|
|
220
223
|
if (dup != null && dup.length < text.length) return boundForOpencode(dup, null, limits.maxChars);
|
|
@@ -327,54 +330,96 @@ function duplicateMarker(toolName, text, reference = 'previous') {
|
|
|
327
330
|
const DEDUP_SESSION_KEEP = 20;
|
|
328
331
|
const DEDUP_DIR = 'chisle-dedup';
|
|
329
332
|
|
|
330
|
-
|
|
333
|
+
// `root` (the user-owned state root) may itself be a symlink (e.g. dotfiles
|
|
334
|
+
// managed with GNU stow), so it is resolved with stat — but every subdirectory
|
|
335
|
+
// beneath it and the target file are verified with lstat and must not be links.
|
|
336
|
+
function secureDirectory(dir, root) {
|
|
337
|
+
try {
|
|
338
|
+
dir = path.resolve(dir);
|
|
339
|
+
root = root ? path.resolve(root) : dir;
|
|
340
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
341
|
+
let cur = dir;
|
|
342
|
+
while (cur !== root && cur.length > 1) {
|
|
343
|
+
const st = fs.lstatSync(cur);
|
|
344
|
+
if (!st.isDirectory() || st.isSymbolicLink()) return false;
|
|
345
|
+
const parent = path.dirname(cur);
|
|
346
|
+
if (parent === cur) break;
|
|
347
|
+
cur = parent;
|
|
348
|
+
}
|
|
349
|
+
const st = fs.statSync(root);
|
|
350
|
+
return st.isDirectory();
|
|
351
|
+
} catch (e) { return false; }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function safeReadText(p) {
|
|
331
355
|
let st;
|
|
332
356
|
try { st = fs.lstatSync(p); }
|
|
333
|
-
catch (e) { return {
|
|
334
|
-
if (st.isSymbolicLink() || !st.isFile()) return {
|
|
357
|
+
catch (e) { return { text: null, unsafe: e.code !== 'ENOENT' }; }
|
|
358
|
+
if (st.isSymbolicLink() || !st.isFile()) return { text: null, unsafe: true };
|
|
335
359
|
const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
336
360
|
let fd;
|
|
337
|
-
let raw;
|
|
338
361
|
try {
|
|
339
362
|
fd = fs.openSync(p, fs.constants.O_RDONLY | noFollow);
|
|
340
|
-
|
|
363
|
+
return { text: fs.readFileSync(fd, 'utf8'), unsafe: false };
|
|
341
364
|
} catch (e) {
|
|
342
|
-
return {
|
|
365
|
+
return { text: null, unsafe: true };
|
|
343
366
|
} finally {
|
|
344
367
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
|
345
368
|
}
|
|
346
|
-
try { return { state: JSON.parse(raw) || null, unsafe: false }; }
|
|
347
|
-
catch (e) { return { state: null, unsafe: false }; }
|
|
348
369
|
}
|
|
349
370
|
|
|
350
|
-
function
|
|
351
|
-
const dir = path.
|
|
352
|
-
|
|
371
|
+
function safeAtomicWrite(p, content, root) {
|
|
372
|
+
const dir = path.dirname(p);
|
|
373
|
+
if (!secureDirectory(dir, root)) return false;
|
|
353
374
|
try {
|
|
354
|
-
const st = fs.lstatSync(
|
|
355
|
-
|
|
356
|
-
} catch (e) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
function writeDedupState(p, state) {
|
|
375
|
+
const st = fs.lstatSync(p);
|
|
376
|
+
if (st.isSymbolicLink() || !st.isFile()) return false;
|
|
377
|
+
} catch (e) {
|
|
378
|
+
if (e.code !== 'ENOENT') return false;
|
|
379
|
+
}
|
|
360
380
|
const tmp = p + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
|
|
361
381
|
const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
362
382
|
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow;
|
|
363
383
|
let fd;
|
|
364
384
|
try {
|
|
365
385
|
fd = fs.openSync(tmp, flags, 0o600);
|
|
366
|
-
fs.writeFileSync(fd,
|
|
386
|
+
fs.writeFileSync(fd, content);
|
|
367
387
|
try { fs.fchmodSync(fd, 0o600); } catch (e) {}
|
|
368
388
|
fs.closeSync(fd);
|
|
369
389
|
fd = undefined;
|
|
390
|
+
try {
|
|
391
|
+
const st = fs.lstatSync(p);
|
|
392
|
+
if (st.isSymbolicLink() || !st.isFile()) return false;
|
|
393
|
+
} catch (e) {
|
|
394
|
+
if (e.code !== 'ENOENT') return false;
|
|
395
|
+
}
|
|
370
396
|
fs.renameSync(tmp, p);
|
|
397
|
+
return true;
|
|
371
398
|
} catch (e) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
399
|
+
return false;
|
|
400
|
+
} finally {
|
|
401
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
|
402
|
+
try { fs.unlinkSync(tmp); } catch (e) {}
|
|
375
403
|
}
|
|
376
404
|
}
|
|
377
405
|
|
|
406
|
+
function readDedupFile(p) {
|
|
407
|
+
const { text, unsafe } = safeReadText(p);
|
|
408
|
+
if (text == null) return { state: null, unsafe };
|
|
409
|
+
try { return { state: JSON.parse(text) || null, unsafe: false }; }
|
|
410
|
+
catch (e) { return { state: null, unsafe: false }; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function dedupStateDir(stateDir) {
|
|
414
|
+
const root = stateDir || getClaudeDir();
|
|
415
|
+
const dir = path.join(root, DEDUP_DIR);
|
|
416
|
+
return secureDirectory(dir, root) ? dir : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function writeDedupState(p, state, root) {
|
|
420
|
+
if (!safeAtomicWrite(p, JSON.stringify(state), root)) throw new Error('unsafe dedup state path');
|
|
421
|
+
}
|
|
422
|
+
|
|
378
423
|
function pruneDedupSessions(dir) {
|
|
379
424
|
try {
|
|
380
425
|
const files = fs.readdirSync(dir)
|
|
@@ -394,7 +439,7 @@ function pruneDedupSessions(dir) {
|
|
|
394
439
|
|
|
395
440
|
// `stateDir` defaults to getClaudeDir() so existing Claude/Pi callers keep
|
|
396
441
|
// their prior storage path. Copilot passes getCopilotDir(); OpenCode passes
|
|
397
|
-
//
|
|
442
|
+
// getOpencodeStateDir() and opts into per-session files for interleaved sessions.
|
|
398
443
|
function dedupCheck(toolName, text, sessionId, toolUseId, stateDir, perSession) {
|
|
399
444
|
if (process.env.CHISLE_COMPRESS_DEDUP === '0') return null;
|
|
400
445
|
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
@@ -430,7 +475,7 @@ function dedupCheck(toolName, text, sessionId, toolUseId, stateDir, perSession)
|
|
|
430
475
|
const dup = rec.hash === hash && !sameCall;
|
|
431
476
|
state.tools[toolName] = { hash, id: toolUseId || rec.id || null };
|
|
432
477
|
if (perSession) {
|
|
433
|
-
writeDedupState(p, state);
|
|
478
|
+
writeDedupState(p, state, stateDir || getClaudeDir());
|
|
434
479
|
pruneDedupSessions(dir);
|
|
435
480
|
} else {
|
|
436
481
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -466,7 +511,12 @@ function pruneSpill(dir) {
|
|
|
466
511
|
try {
|
|
467
512
|
const files = fs.readdirSync(dir)
|
|
468
513
|
.filter(f => f.endsWith('.txt'))
|
|
469
|
-
.map(f => {
|
|
514
|
+
.map(f => {
|
|
515
|
+
const p = path.join(dir, f);
|
|
516
|
+
const st = fs.lstatSync(p);
|
|
517
|
+
return st.isFile() && !st.isSymbolicLink() ? { p, t: st.mtimeMs } : null;
|
|
518
|
+
})
|
|
519
|
+
.filter(Boolean)
|
|
470
520
|
.sort((a, b) => b.t - a.t);
|
|
471
521
|
for (const f of files.slice(SPILL_KEEP)) { try { fs.unlinkSync(f.p); } catch (e) {} }
|
|
472
522
|
} catch (e) {}
|
|
@@ -475,12 +525,18 @@ function pruneSpill(dir) {
|
|
|
475
525
|
function spill(text, toolName, stateDir) {
|
|
476
526
|
if (process.env.CHISLE_COMPRESS_SPILL === '0') return null;
|
|
477
527
|
try {
|
|
528
|
+
const root = stateDir || getClaudeDir();
|
|
478
529
|
const dir = spillDir(stateDir);
|
|
479
|
-
|
|
530
|
+
if (!secureDirectory(dir, root)) return null;
|
|
480
531
|
const hash = crypto.createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
481
532
|
const safeTool = String(toolName || 'tool').replace(/[^A-Za-z0-9_-]/g, '') || 'tool';
|
|
482
533
|
const file = path.join(dir, `${safeTool}-${hash}.txt`);
|
|
483
|
-
|
|
534
|
+
// Same hash → same content (atomic writes never leave a partial file), so
|
|
535
|
+
// an existing regular file is reused. Re-reading to compare would also
|
|
536
|
+
// fail on lone surrogates, which UTF-8 cannot round-trip.
|
|
537
|
+
let st = null;
|
|
538
|
+
try { st = fs.lstatSync(file); } catch (e) { if (e.code !== 'ENOENT') return null; }
|
|
539
|
+
if (st ? st.isSymbolicLink() || !st.isFile() : !safeAtomicWrite(file, text, root)) return null;
|
|
484
540
|
pruneSpill(dir);
|
|
485
541
|
return file;
|
|
486
542
|
} catch (e) { return null; }
|
|
@@ -525,20 +581,19 @@ function compress(text, limits, spillPath) {
|
|
|
525
581
|
// count — stats only, never worth a lock file.
|
|
526
582
|
function recordSavings(saved, stateDir) {
|
|
527
583
|
try {
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
584
|
+
const root = stateDir || getClaudeDir();
|
|
585
|
+
if (!secureDirectory(root, root)) return;
|
|
586
|
+
const p = path.join(root, '.chisle-compress-stats.json');
|
|
587
|
+
const current = safeReadText(p);
|
|
588
|
+
if (current.unsafe) return;
|
|
531
589
|
let stats = { savedChars: 0, events: 0 };
|
|
532
590
|
try {
|
|
533
|
-
const parsed = JSON.parse(
|
|
591
|
+
const parsed = JSON.parse(current.text);
|
|
534
592
|
if (parsed && Number.isFinite(parsed.savedChars) && Number.isFinite(parsed.events)) stats = parsed;
|
|
535
593
|
} catch (e) {}
|
|
536
594
|
stats.savedChars += saved;
|
|
537
595
|
stats.events += 1;
|
|
538
|
-
|
|
539
|
-
const tmp = p + '.' + process.pid + '.tmp';
|
|
540
|
-
fs.writeFileSync(tmp, JSON.stringify(stats), { mode: 0o600 });
|
|
541
|
-
fs.renameSync(tmp, p);
|
|
596
|
+
safeAtomicWrite(p, JSON.stringify(stats), root);
|
|
542
597
|
} catch (e) {}
|
|
543
598
|
}
|
|
544
599
|
|
|
@@ -627,9 +682,10 @@ function main() {
|
|
|
627
682
|
|
|
628
683
|
// Copilot has no /chisle mode-toggle mechanism (no UserPromptSubmit
|
|
629
684
|
// hook wired for it yet, so there is nothing to flip a flag file with):
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
685
|
+
// it follows the default mode (CHISLE_DEFAULT_MODE or the shared
|
|
686
|
+
// config, 'on' when unset), like OpenCode. Claude/Pi keep reading the
|
|
687
|
+
// real flag file exactly as before.
|
|
688
|
+
const mode = copilot ? getDefaultMode() : readFlag(path.join(getClaudeDir(), '.chisle-active'));
|
|
633
689
|
|
|
634
690
|
const updated = processPayload(payload, mode);
|
|
635
691
|
if (updated == null) return;
|
package/hooks/chisle-config.js
CHANGED
|
@@ -41,11 +41,18 @@ function getClaudeDir() {
|
|
|
41
41
|
// OpenCode's global config dir. The compressor plugin keeps its spill/dedup/
|
|
42
42
|
// stats here (as Copilot uses getCopilotDir), so recovery files sit beside
|
|
43
43
|
// OpenCode's own config instead of leaking into ~/.claude.
|
|
44
|
-
function getOpencodeDir() {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
function getOpencodeDir(opts) {
|
|
45
|
+
const options = opts || {};
|
|
46
|
+
const env = options.env || process.env;
|
|
47
|
+
const home = options.home || os.homedir();
|
|
48
|
+
if (env.OPENCODE_CONFIG_DIR) return env.OPENCODE_CONFIG_DIR;
|
|
49
|
+
if (env.XDG_CONFIG_HOME) return path.join(env.XDG_CONFIG_HOME, 'opencode');
|
|
50
|
+
return path.join(home, '.config', 'opencode');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getOpencodeStateDir(opts) {
|
|
54
|
+
const env = (opts && opts.env) || process.env;
|
|
55
|
+
return env.CHISLE_STATE_DIR || getOpencodeDir(opts);
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
// GitHub Copilot CLI's own user-level state directory (mirrors the
|
|
@@ -350,7 +357,7 @@ function readFlag(flagPath) {
|
|
|
350
357
|
}
|
|
351
358
|
|
|
352
359
|
module.exports = {
|
|
353
|
-
LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir,
|
|
360
|
+
LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir, getOpencodeStateDir, getConfigDir,
|
|
354
361
|
VALID_MODES, safeWriteFlag, readFlag,
|
|
355
362
|
PROSE_HEADINGS, CODE_HEADINGS, getSections, filterSections,
|
|
356
363
|
getActiveOutputStyle, explicitSections, resolveSections,
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chisle — stdio MCP proxy: tool-output compression for any MCP host.
|
|
3
|
+
//
|
|
4
|
+
// Most agents cannot rewrite a tool result from a hook (Windsurf, Cline,
|
|
5
|
+
// Kiro, Gemini, Qwen; Cursor only for MCP), but every one of them launches
|
|
6
|
+
// stdio MCP servers from a command line it does not inspect. Wrapping that
|
|
7
|
+
// command puts Chisle between host and server:
|
|
8
|
+
//
|
|
9
|
+
// node chisle-mcp-proxy.js -- <original command> [args...]
|
|
10
|
+
//
|
|
11
|
+
// Bytes pass through untouched in both directions, except the JSON-RPC
|
|
12
|
+
// response to a `tools/call` request: its `content[]` text blocks go through
|
|
13
|
+
// the same scrub + elide core as every other harness (head/tail kept, error
|
|
14
|
+
// lines salvaged, full original spilled to disk). Everything else — init,
|
|
15
|
+
// tools/list, notifications, resources, malformed lines — is forwarded
|
|
16
|
+
// byte-identical. `npx chisle mcp wrap` rewrites host configs to use it.
|
|
17
|
+
//
|
|
18
|
+
// Deliberately absent:
|
|
19
|
+
// - dedup markers: one server process can outlive a chat and serve the
|
|
20
|
+
// next one, whose context never saw the earlier copy.
|
|
21
|
+
// - read/edit-shaped tools (read_file, get_file_contents, edit_file, ...):
|
|
22
|
+
// their output feeds exact-match edits. Skipped by name.
|
|
23
|
+
// - structuredContent: left as-is; hosts that forward it get it unchanged.
|
|
24
|
+
//
|
|
25
|
+
// Never breaks the server: any compressor error forwards the original line.
|
|
26
|
+
// Tunables: CHISLE_COMPRESS=0, CHISLE_DEFAULT_MODE=off, CHISLE_COMPRESS_MAX_CHARS
|
|
27
|
+
// (+ HEAD/TAIL_LINES), CHISLE_COMPRESS_SPILL=0, CHISLE_STATE_DIR (spill + stats,
|
|
28
|
+
// default ~/.config/chisle).
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const cp = require('child_process');
|
|
33
|
+
const { StringDecoder } = require('string_decoder');
|
|
34
|
+
const core = require('./chisle-compress-output');
|
|
35
|
+
const { getDefaultMode, getConfigDir } = require('./chisle-config');
|
|
36
|
+
|
|
37
|
+
const IS_WIN = process.platform === 'win32';
|
|
38
|
+
|
|
39
|
+
// Tool names whose output may feed a later exact-match edit, or is file
|
|
40
|
+
// content the model quotes back. Matched on the raw MCP tool name.
|
|
41
|
+
const UNSAFE_MCP_RE = /read|view|file|edit|write|patch|replace|insert/i;
|
|
42
|
+
const MAX_PENDING_LINE = 16 * 1024 * 1024; // drop observation of absurd lines
|
|
43
|
+
|
|
44
|
+
function mcpToolAllowed(name) {
|
|
45
|
+
return typeof name === 'string' && !!name && !UNSAFE_MCP_RE.test(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateDir() {
|
|
49
|
+
return process.env.CHISLE_STATE_DIR || getConfigDir();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Compress one tools/call result in place. Returns chars saved (0 = untouched).
|
|
53
|
+
function compressResult(result, toolName) {
|
|
54
|
+
if (!result || !Array.isArray(result.content)) return 0;
|
|
55
|
+
const limits = core.limitsFor();
|
|
56
|
+
let saved = 0;
|
|
57
|
+
for (const block of result.content) {
|
|
58
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') continue;
|
|
59
|
+
if (block.text.includes('[chisle:')) continue; // already compressed upstream
|
|
60
|
+
const updated = core.transform(block.text, limits, 'mcp-' + toolName, stateDir());
|
|
61
|
+
if (updated != null && updated.length < block.text.length) {
|
|
62
|
+
saved += block.text.length - updated.length;
|
|
63
|
+
block.text = updated;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return saved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One server→host line. Returns the line to forward (original when untouched).
|
|
70
|
+
function rewriteLine(line, pending) {
|
|
71
|
+
if (!pending.size || line.indexOf('"result"') === -1) return line;
|
|
72
|
+
let msg;
|
|
73
|
+
try { msg = JSON.parse(line); } catch (e) { return line; }
|
|
74
|
+
const msgs = Array.isArray(msg) ? msg : [msg];
|
|
75
|
+
let saved = 0;
|
|
76
|
+
for (const m of msgs) {
|
|
77
|
+
if (!m || m.id == null || !('result' in m)) continue;
|
|
78
|
+
const key = JSON.stringify(m.id);
|
|
79
|
+
const tool = pending.get(key);
|
|
80
|
+
if (tool === undefined) continue;
|
|
81
|
+
pending.delete(key);
|
|
82
|
+
if (!mcpToolAllowed(tool)) continue;
|
|
83
|
+
saved += compressResult(m.result, tool);
|
|
84
|
+
}
|
|
85
|
+
if (!saved) return line;
|
|
86
|
+
core.recordSavings(saved, stateDir());
|
|
87
|
+
return JSON.stringify(msg);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One host→server line: remember which request ids are tool calls.
|
|
91
|
+
function observeRequest(line, pending) {
|
|
92
|
+
if (line.indexOf('tools/call') === -1 && line.indexOf('notifications/cancelled') === -1) return;
|
|
93
|
+
let msg;
|
|
94
|
+
try { msg = JSON.parse(line); } catch (e) { return; }
|
|
95
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) {
|
|
96
|
+
if (m && m.method === 'tools/call' && m.id != null) {
|
|
97
|
+
pending.set(JSON.stringify(m.id), m.params && typeof m.params.name === 'string' ? m.params.name : '');
|
|
98
|
+
}
|
|
99
|
+
// A cancelled call never gets a result; forget it.
|
|
100
|
+
if (m && m.method === 'notifications/cancelled' && m.params && m.params.requestId != null) {
|
|
101
|
+
pending.delete(JSON.stringify(m.params.requestId));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Splits a byte stream into lines, calling onLine(lineWithoutNewline, eol).
|
|
107
|
+
function lineSplitter(onLine) {
|
|
108
|
+
let buf = '';
|
|
109
|
+
return {
|
|
110
|
+
push(chunk) {
|
|
111
|
+
buf += chunk;
|
|
112
|
+
let i;
|
|
113
|
+
while ((i = buf.indexOf('\n')) !== -1) {
|
|
114
|
+
const raw = buf.slice(0, i);
|
|
115
|
+
buf = buf.slice(i + 1);
|
|
116
|
+
const cr = raw.endsWith('\r');
|
|
117
|
+
onLine(cr ? raw.slice(0, -1) : raw, cr ? '\r\n' : '\n');
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
rest() { const r = buf; buf = ''; return r; },
|
|
121
|
+
get size() { return buf.length; },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function quoteWin(a) {
|
|
126
|
+
if (a === '' || /[\s"&|<>^()%!]/.test(a)) return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
127
|
+
return a;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function main(argv) {
|
|
131
|
+
const sep = argv.indexOf('--');
|
|
132
|
+
const cmd = sep === -1 ? argv : argv.slice(sep + 1);
|
|
133
|
+
if (!cmd.length) {
|
|
134
|
+
process.stderr.write('usage: chisle-mcp-proxy -- <command> [args...]\n');
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Windows MCP commands are usually npx/uvx shims (.cmd), which Node refuses
|
|
139
|
+
// to spawn without a shell; elsewhere spawn directly, no shell parsing.
|
|
140
|
+
const child = IS_WIN
|
|
141
|
+
? cp.spawn(cmd.map(quoteWin).join(' '), [], { stdio: ['pipe', 'pipe', 'inherit'], shell: true, windowsHide: true })
|
|
142
|
+
: cp.spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
143
|
+
|
|
144
|
+
const enabled = process.env.CHISLE_COMPRESS !== '0' && getDefaultMode() !== 'off';
|
|
145
|
+
const pending = new Map();
|
|
146
|
+
|
|
147
|
+
child.on('error', (e) => {
|
|
148
|
+
process.stderr.write('chisle-mcp-proxy: cannot start ' + cmd[0] + ': ' + e.message + '\n');
|
|
149
|
+
process.exit(127);
|
|
150
|
+
});
|
|
151
|
+
child.stdin.on('error', () => {}); // server gone; exit handler reports it
|
|
152
|
+
process.stdout.on('error', () => { try { child.kill(); } catch (e) {} process.exit(0); }); // host gone
|
|
153
|
+
|
|
154
|
+
// Host → server: forward raw, observe tool calls on the side.
|
|
155
|
+
const reqLines = lineSplitter(line => observeRequest(line, pending));
|
|
156
|
+
const decoder = new StringDecoder('utf8');
|
|
157
|
+
process.stdin.on('data', (chunk) => {
|
|
158
|
+
child.stdin.write(chunk);
|
|
159
|
+
if (!enabled) return;
|
|
160
|
+
reqLines.push(decoder.write(chunk));
|
|
161
|
+
if (reqLines.size > MAX_PENDING_LINE) reqLines.rest();
|
|
162
|
+
});
|
|
163
|
+
process.stdin.on('end', () => child.stdin.end());
|
|
164
|
+
|
|
165
|
+
// Server → host: rewrite only tool-call responses. Paused while the host is
|
|
166
|
+
// slow to read so a chatty server cannot balloon this process.
|
|
167
|
+
if (!enabled) {
|
|
168
|
+
child.stdout.pipe(process.stdout);
|
|
169
|
+
} else {
|
|
170
|
+
const resLines = lineSplitter((line, eol) => {
|
|
171
|
+
let out = line;
|
|
172
|
+
try { out = rewriteLine(line, pending); } catch (e) { out = line; }
|
|
173
|
+
if (!process.stdout.write(out + eol)) {
|
|
174
|
+
child.stdout.pause();
|
|
175
|
+
process.stdout.once('drain', () => child.stdout.resume());
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
child.stdout.setEncoding('utf8');
|
|
179
|
+
child.stdout.on('data', chunk => resLines.push(chunk));
|
|
180
|
+
child.stdout.on('end', () => { const r = resLines.rest(); if (r) process.stdout.write(r); });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
184
|
+
process.on(sig, () => { try { child.kill(sig); } catch (e) {} });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
child.on('close', (code, signal) => {
|
|
188
|
+
const status = code != null ? code : (signal ? 128 + (require('os').constants.signals[signal] || 1) : 1);
|
|
189
|
+
process.stdout.write('', () => process.exit(status));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (require.main === module) main(process.argv.slice(2));
|
|
194
|
+
|
|
195
|
+
module.exports = { mcpToolAllowed, compressResult, rewriteLine, observeRequest, lineSplitter };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chisle",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Maximum-efficiency dev mode for AI coding agents. Zero-fluff prose, YAGNI-first code, and safe tool-output compression.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"cursor",
|
|
18
18
|
"codex",
|
|
19
19
|
"copilot",
|
|
20
|
+
"antigravity",
|
|
20
21
|
"opencode",
|
|
21
22
|
"hermes-agent",
|
|
22
23
|
"hermes",
|
|
@@ -93,11 +94,13 @@
|
|
|
93
94
|
".windsurf/",
|
|
94
95
|
".clinerules/",
|
|
95
96
|
".kiro/",
|
|
97
|
+
".agents/",
|
|
96
98
|
".github/copilot-instructions.md",
|
|
97
99
|
".claude-plugin/",
|
|
98
100
|
".opencode/",
|
|
99
101
|
"README.md",
|
|
100
|
-
"LICENSE"
|
|
102
|
+
"LICENSE",
|
|
103
|
+
"NOTICE"
|
|
101
104
|
],
|
|
102
105
|
"publishConfig": {
|
|
103
106
|
"access": "public",
|
package/skills/chisle/SKILL.md
CHANGED
|
@@ -44,7 +44,7 @@ Ladder runs after understanding the problem, never instead. Bug fix = root cause
|
|
|
44
44
|
|
|
45
45
|
## Thinking Is Billed Too
|
|
46
46
|
|
|
47
|
-
Reasoning costs the same as output.
|
|
47
|
+
Reasoning costs the same as output. Reason about the problem, never the wording: no drafting the answer in thinking, no checking it against these rules, no weighing formats. Obvious answer → write it straight away. Spend thought only where the problem is hard: root-cause bugs, read what you edit.
|
|
48
48
|
|
|
49
49
|
## Code Rules
|
|
50
50
|
|