chisle 3.2.1 → 3.4.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chisle",
3
- "version": "3.2.1",
3
+ "version": "3.4.0",
4
4
  "description": "Maximum-efficiency dev mode. Zero-fluff prose combined with YAGNI/ladder-first code decisions. One persona: the senior dev who deletes code for fun and bills by the syllable.",
5
5
  "hooks": {
6
6
  "SessionStart": [
@@ -0,0 +1,115 @@
1
+ // chisle — OpenCode plugin: tool-output compression (the input axis).
2
+ //
3
+ // Prompt-side rules (the fenced ruleset the installer writes into
4
+ // ~/.config/opencode/AGENTS.md) shrink what the model WRITES. This plugin
5
+ // shrinks what it READS: oversized tool results (bash dumps, subagent reports,
6
+ // web fetches, MCP payloads) get their repetitive middle elided — head kept,
7
+ // tail kept, error lines salvaged — before the model sees them. Deterministic,
8
+ // zero LLM, zero network. Read/Edit/Write output is never touched.
9
+ //
10
+ // OpenCode exposes two relevant plugin hooks. `tool.execute.after` receives a
11
+ // completed tool result whose `output.output` is the plain text the model will
12
+ // see; mutating it in place also persists onto the stored tool part, so every
13
+ // later turn reuses the compressed form. `experimental.chat.messages.transform`
14
+ // runs just before parts are converted to model messages, a request-time safety
15
+ // net that re-compresses any full output the store still holds (e.g. recorded
16
+ // before this plugin was installed). Neither needs a schema rebuild.
17
+ //
18
+ // Load it by installing via `npx chisle --only opencode`, which drops this file
19
+ // and the shared compressor core into ~/.config/opencode/plugins/. OpenCode
20
+ // loads every file in that dir at startup.
21
+ //
22
+ // Tunables (shared with the Claude/Pi/Copilot compressor, see chisle-config):
23
+ // CHISLE_COMPRESS=0 kill switch
24
+ // CHISLE_DEFAULT_MODE=off disable by default (env or ~/.config/chisle)
25
+ // CHISLE_COMPRESS_MAX_CHARS outputs at/under this size are not elided
26
+ // CHISLE_COMPRESS_TOOLS=bash,… override the tool allowlist
27
+
28
+ import { createRequire } from 'module';
29
+ import path from 'path';
30
+ import { fileURLToPath } from 'url';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+
35
+ // The compressor core is CommonJS. When installed by `chisle --only opencode`
36
+ // it sits in ./chisle-hooks/; running straight from the repo it's ../../hooks/.
37
+ function loadFrom(names) {
38
+ for (const rel of names) {
39
+ try { return require(rel); }
40
+ catch (e) { if (e && e.code !== 'MODULE_NOT_FOUND' && e.code !== 'ERR_MODULE_NOT_FOUND') throw e; }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ const core = loadFrom([
46
+ './chisle-hooks/chisle-compress-output.js',
47
+ '../../hooks/chisle-compress-output.js',
48
+ ]);
49
+ const config = loadFrom([
50
+ './chisle-hooks/chisle-config.js',
51
+ '../../hooks/chisle-config.js',
52
+ ]) || { getDefaultMode: () => 'on' };
53
+
54
+ const compressForOpencode = core && core.compressForOpencode;
55
+ const getDefaultMode = config.getDefaultMode || (() => 'on');
56
+
57
+ // Already compressed: our elision marker. Skip such text so a second pass
58
+ // (messages.transform after tool.execute.after already ran) never nests a
59
+ // marker or eats an earlier salvage block. Keeps compression idempotent.
60
+ const CHISLE_MARK = '[chisle:';
61
+
62
+ export default async () => {
63
+ return {
64
+ // Compress oversized read-only tool output before the model sees it.
65
+ // Never throws: a broken compressor must not break the tool pipeline.
66
+ //
67
+ // For outputs under OpenCode's on-disk store limits (~50 KB / 2000 lines)
68
+ // output.output is the FULL text and mutating it here persists onto the
69
+ // tool part's state.output — so every later turn sees the compressed form.
70
+ // Larger outputs reach this hook already replaced by OpenCode with a notice
71
+ // plus a retained tail; that tail still compresses here.
72
+ 'tool.execute.after': async (input, output) => {
73
+ try {
74
+ if (!compressForOpencode) return;
75
+ // MCP tools can hand back a content[] array rather than a string;
76
+ // built-ins give a string. Only the string case is ours to touch.
77
+ if (!output || typeof output.output !== 'string') return;
78
+ if (output.output.includes(CHISLE_MARK)) return;
79
+ const updated = compressForOpencode(input && input.tool, output.output, { mode: getDefaultMode() });
80
+ if (updated != null && updated.length < output.output.length) {
81
+ output.output = updated;
82
+ }
83
+ } catch (e) { /* keep the original output */ }
84
+ },
85
+
86
+ // Request-time safety net over completed tool parts. Runs after MCP
87
+ // normalization and BEFORE OpenCode converts parts to model messages
88
+ // (and before compaction summarization), so it re-applies compression to
89
+ // any full output that reached the model store uncompressed — e.g. parts
90
+ // recorded before this plugin was installed. Skips parts already carrying
91
+ // our marker, so each part is compressed at most once; the elided original
92
+ // still spills to disk exactly as it does on the tool.execute.after path.
93
+ 'experimental.chat.messages.transform': async (_input, output) => {
94
+ try {
95
+ if (!compressForOpencode) return;
96
+ if (!output || !Array.isArray(output.messages)) return;
97
+ const mode = getDefaultMode();
98
+ for (const msg of output.messages) {
99
+ const parts = msg && msg.parts;
100
+ if (!Array.isArray(parts)) continue;
101
+ for (const part of parts) {
102
+ if (!part || part.type !== 'tool') continue;
103
+ const st = part.state;
104
+ if (!st || st.status !== 'completed' || typeof st.output !== 'string') continue;
105
+ if (st.output.includes(CHISLE_MARK)) continue;
106
+ const updated = compressForOpencode(part.tool, st.output, { mode });
107
+ if (updated != null && updated.length < st.output.length) {
108
+ st.output = updated;
109
+ }
110
+ }
111
+ }
112
+ } catch (e) { /* leave messages untouched */ }
113
+ },
114
+ };
115
+ };
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  <p align="center">
16
16
  <a href="https://www.npmjs.com/package/chisle"><img src="https://img.shields.io/npm/v/chisle?style=flat-square&color=d78a3c" alt="npm version"></a>
17
- <img src="https://img.shields.io/badge/works%20with-9%20agents-d78a3c?style=flat-square" alt="Works with 9 agents">
17
+ <img src="https://img.shields.io/badge/works%20with-11%20agents-d78a3c?style=flat-square" alt="Works with 11 agents">
18
18
  <a href="https://github.com/JayPokale/Chisle/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/JayPokale/Chisle/test.yml?style=flat-square&label=CI" alt="CI"></a>
19
19
  <img src="https://img.shields.io/badge/deps-0-2da44e?style=flat-square" alt="Zero deps">
20
20
  <img src="https://img.shields.io/badge/license-MIT-d78a3c?style=flat-square" alt="MIT">
@@ -22,7 +22,7 @@
22
22
  </p>
23
23
 
24
24
  <p align="center">
25
- <strong>44% of a bare model on coding work &middot; 41% on the long ones &middot; one command</strong>
25
+ <strong>44% of a bare model's output tokens on coding prompts &middot; 11 agents &middot; zero dependencies &middot; one command</strong>
26
26
  </p>
27
27
 
28
28
  <p align="center">
@@ -122,7 +122,7 @@ Every "be concise" tool has a worst day, the day it makes the model write *more*
122
122
 
123
123
  ## Install
124
124
 
125
- One command. Auto-detects your agents (Claude Code, Pi, Cursor, Windsurf, Cline, Kiro, Codex, Gemini, Copilot) and wires each one. `--uninstall` puts everything back.
125
+ One command. Auto-detects your agents (Claude Code, Pi, Cursor, Windsurf, Cline, Kiro, Codex, Gemini, Copilot, OpenCode, Hermes) and wires each one. `--uninstall` puts everything back.
126
126
 
127
127
  ```bash
128
128
  npx chisle
@@ -226,6 +226,8 @@ Nothing here is estimated. Every figure below is recomputed from committed raw d
226
226
 
227
227
  ### Output axis: vs caveman & ponytail, 20 live tasks
228
228
 
229
+ **What this suite measures, and what it does not.** Every figure below is *billed output tokens on single-turn prompts with no tools available*. That isolates the ruleset's effect on how the model writes, which is what it was built to measure. It is not whole-session cost: a real agentic session is dominated by tool output and cached input, so a tool can win here and still fail to pay for itself end to end. The input axis below is measured separately, and against its own baseline.
230
+
229
231
  **59+ live model runs** across two suites (June 4-arm matrix on Haiku + Sonnet sweep; July re-verification run). Arms differ only in the injected system prompt. Billed output tokens vs the no-tool baseline:
230
232
 
231
233
  | | total bill (all 20 tasks) | average task | worst case | backfires |
@@ -340,7 +342,7 @@ node benchmarks/replay-compress.js # Claude Code
340
342
  node benchmarks/replay-compress.js pi # Pi
341
343
  ```
342
344
 
343
- Outputs over 8k chars are elided. `stop chisle`, `CHISLE_COMPRESS=0`, `CHISLE_COMPRESS_SCRUB=0`, `CHISLE_COMPRESS_DEDUP=0`. Every tier has an off switch.
345
+ Outputs over 8k chars are elided, and the elided original spills to `<config>/chisle-spill/` so the dropped middle stays reachable. The marker carries the path, so recovering one line is a targeted grep rather than a re-run of the command, which matters most when the command is not idempotent: a test run, a build, a `git log` at a moment in time. The newest 40 spills are kept, owner-readable only. `stop chisle`, `CHISLE_COMPRESS=0`, `CHISLE_COMPRESS_SCRUB=0`, `CHISLE_COMPRESS_DEDUP=0`, `CHISLE_COMPRESS_SPILL=0`. Every tier has an off switch.
344
346
 
345
347
  ### Pi arm: where the output axis lost
346
348
 
@@ -464,7 +466,7 @@ Chisle borrows the best published token-saving techniques and implements the one
464
466
 
465
467
  ## Multi-agent
466
468
 
467
- Ships to nine agents: Claude Code and Pi get both axes, live `/chisle` toggling, and a status badge; Cursor, Windsurf, Cline, Kiro, Codex, Gemini, and Copilot get the always-on ruleset. Per-agent static copies come from `scripts/build-rules.js`; Pi uses its package extension plus the Agent Skills standard. See [`docs/agent-portability.md`](./docs/agent-portability.md).
469
+ Ships to eleven agents: Claude Code and Pi get both axes, live `/chisle` toggling, and a status badge; Cursor, Windsurf, Cline, Kiro, Codex, Gemini, and Copilot get the always-on ruleset; OpenCode gets the global fenced ruleset plus on-demand skills; Hermes gets the skills as `/chisle` commands. Per-agent static copies come from `scripts/build-rules.js`; Pi uses its package extension plus the Agent Skills standard. See [`docs/agent-portability.md`](./docs/agent-portability.md).
468
470
 
469
471
  ## FAQ
470
472
 
package/bin/install.js CHANGED
@@ -7,6 +7,8 @@
7
7
  // - Pi → Pi package (extension + skill)
8
8
  // - Gemini CLI → gemini extensions install
9
9
  // - Codex → fenced ruleset appended to ~/.codex/AGENTS.md
10
+ // - OpenCode → fenced ruleset in ~/.config/opencode/AGENTS.md + skills copy
11
+ // - Hermes → skills copy in ~/.hermes/skills (Agent Skills standard)
10
12
  // - Cursor/Windsurf/Cline/Kiro/Copilot → project rule file dropped into CWD
11
13
  //
12
14
  // Usage:
@@ -43,6 +45,8 @@ const PROVIDERS = [
43
45
  { id: 'pi', label: 'Pi', scope: 'global', detect: 'cmd:pi' },
44
46
  { id: 'gemini', label: 'Gemini CLI', scope: 'global', detect: 'cmd:gemini' },
45
47
  { id: 'codex', label: 'Codex CLI', scope: 'global', detect: 'cmd:codex||dir:~/.codex' },
48
+ { id: 'opencode', label: 'OpenCode', scope: 'global', detect: 'cmd:opencode||dir:~/.config/opencode' },
49
+ { id: 'hermes', label: 'Hermes Agent', scope: 'global', detect: 'cmd:hermes||dir:~/.hermes' },
46
50
  { id: 'cursor', label: 'Cursor', scope: 'project', detect: 'cmd:cursor||dir:~/.cursor',
47
51
  rule: '.cursor/rules/chisle.mdc' },
48
52
  { id: 'windsurf', label: 'Windsurf', scope: 'project', detect: 'cmd:windsurf||dir:~/.windsurf||dir:~/.codeium/windsurf',
@@ -157,6 +161,14 @@ function run(cmd, args, dry) {
157
161
  process.stdout.write(` $ ${cmd} ${args.join(' ')}\n`);
158
162
  return spawnX(cmd, args, { stdio: 'inherit' });
159
163
  }
164
+
165
+ // spawnSync reports a missing binary as { status: null, error: ENOENT } and a
166
+ // signal death as { status: null, signal }. `(r.status || 0) === 0` reads both
167
+ // as success, so a machine without the CLI was told the install succeeded.
168
+ // Only an explicit exit code of 0 counts.
169
+ function ranOk(r) {
170
+ return !!r && !r.error && r.status === 0;
171
+ }
160
172
  function capture(cmd, args) {
161
173
  try { return spawnX(cmd, args, { encoding: 'utf8' }); } catch (_) { return { status: 1, stdout: '', stderr: '' }; }
162
174
  }
@@ -168,6 +180,10 @@ function claudeDir(opts) {
168
180
  return path.join(os.homedir(), '.claude');
169
181
  }
170
182
 
183
+ // Home dir for Chisle-managed global targets. CHISLE_HOME overrides it
184
+ // (tests, containers); mirrors the CLAUDE_CONFIG_DIR precedent for Claude.
185
+ function homeDir() { return process.env.CHISLE_HOME || os.homedir(); }
186
+
171
187
  // ── what is already installed ───────────────────────────────────────────────
172
188
  // Every install path skips when Chisle is already present, which is right for
173
189
  // `npx chisle` and useless for an upgrade: the run reports success and changes
@@ -200,9 +216,18 @@ function installedFor(id, opts) {
200
216
  return r.status === 0 && /chisle/i.test(r.stdout || '');
201
217
  }
202
218
  case 'codex': {
203
- const md = path.join(os.homedir(), '.codex', 'AGENTS.md');
219
+ const md = path.join(homeDir(), '.codex', 'AGENTS.md');
204
220
  return fs.existsSync(md) && fs.readFileSync(md, 'utf8').includes(FENCE_BEGIN);
205
221
  }
222
+ case 'opencode': {
223
+ try {
224
+ const md = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
225
+ if (fs.existsSync(md) && fs.readFileSync(md, 'utf8').includes(FENCE_BEGIN)) return true;
226
+ } catch (_) {}
227
+ return fs.existsSync(path.join(homeDir(), '.config', 'opencode', 'skills', 'chisle', 'SKILL.md'));
228
+ }
229
+ case 'hermes':
230
+ return fs.existsSync(path.join(homeDir(), '.hermes', 'skills', 'chisle', 'SKILL.md'));
206
231
  default: {
207
232
  const prov = PROVIDERS.find(x => x.id === id);
208
233
  return !!(prov && prov.rule && fs.existsSync(path.join(process.cwd(), prov.rule)));
@@ -231,7 +256,7 @@ function installClaude(ctx) {
231
256
  } else {
232
257
  const r1 = run('claude', ['plugin', 'marketplace', 'add', REPO], opts.dryRun);
233
258
  const r2 = run('claude', ['plugin', 'install', 'chisle@chisle'], opts.dryRun);
234
- if ((r1.status || 0) === 0 && (r2.status || 0) === 0) { results.installed.push('claude'); pluginOK = true; }
259
+ if (ranOk(r1) && ranOk(r2)) { results.installed.push('claude'); pluginOK = true; }
235
260
  else warn(' claude plugin install failed, falling back to standalone hooks');
236
261
  }
237
262
  } else {
@@ -343,7 +368,7 @@ function installPi(ctx) {
343
368
  }
344
369
  }
345
370
  const r = run('pi', ['install', 'npm:chisle'], opts.dryRun);
346
- if ((r.status || 0) === 0) results.installed.push('pi');
371
+ if (ranOk(r)) results.installed.push('pi');
347
372
  else results.failed.push(['pi', 'pi package install failed']);
348
373
  process.stdout.write('\n');
349
374
  }
@@ -361,11 +386,105 @@ function installGemini(ctx) {
361
386
  }
362
387
  }
363
388
  const r = run('gemini', ['extensions', 'install', `https://github.com/${REPO}`], opts.dryRun);
364
- if ((r.status || 0) === 0) results.installed.push('gemini');
389
+ if (ranOk(r)) results.installed.push('gemini');
365
390
  else results.failed.push(['gemini', 'gemini extensions install failed']);
366
391
  process.stdout.write('\n');
367
392
  }
368
393
 
394
+ // Skills-root helpers for Agent-Skills hosts (OpenCode, Hermes). Both load
395
+ // one dir per skill through the host's native `skill` tool, so the bundled
396
+ // skills/ tree copies over verbatim: no conversion, no second system
397
+ // message, existing user instructions untouched.
398
+ function ownedSkillNames() {
399
+ var srcRoot = path.join(REPO_ROOT, 'skills');
400
+ try {
401
+ return fs.readdirSync(srcRoot).filter(function (n) {
402
+ try {
403
+ return fs.statSync(path.join(srcRoot, n)).isDirectory()
404
+ && fs.existsSync(path.join(srcRoot, n, 'SKILL.md'));
405
+ } catch (_) { return false; }
406
+ });
407
+ } catch (_) { return []; }
408
+ }
409
+
410
+ // Overwrites stale files, keeps foreign siblings, never touches files
411
+ // outside the named skill dirs. Returns files written.
412
+ function copySkills(dstRoot, opts) {
413
+ var names = ownedSkillNames();
414
+ var written = 0;
415
+ for (var i = 0; i < names.length; i++) {
416
+ var src = path.join(REPO_ROOT, 'skills', names[i]);
417
+ var dst = path.join(dstRoot, names[i]);
418
+ var files = fs.readdirSync(src);
419
+ if (!opts.dryRun) fs.mkdirSync(dst, { recursive: true });
420
+ for (var j = 0; j < files.length; j++) {
421
+ var st = null;
422
+ try { st = fs.statSync(path.join(src, files[j])); } catch (_) { continue; }
423
+ if (!st.isFile()) continue;
424
+ if (opts.dryRun) { written++; continue; }
425
+ fs.copyFileSync(path.join(src, files[j]), path.join(dst, files[j]));
426
+ written++;
427
+ }
428
+ }
429
+ return written;
430
+ }
431
+
432
+ // Removes only the skill dirs Chisle owns; foreign skills stay put.
433
+ function pruneSkills(dstRoot) {
434
+ var removed = 0;
435
+ var names = ownedSkillNames();
436
+ for (var i = 0; i < names.length; i++) {
437
+ var dst = path.join(dstRoot, names[i]);
438
+ if (fs.existsSync(dst)) {
439
+ fs.rmSync(dst, { recursive: true, force: true });
440
+ removed++;
441
+ }
442
+ }
443
+ return removed;
444
+ }
445
+
446
+ // Fenced AGENTS.md append/refresh for Codex-style targets. Appends after
447
+ // existing content (never prepends, never clobbers), refreshes one fenced
448
+ // block in place under --force. Returns 'installed', 'refreshed', 'skipped'.
449
+ function writeFencedRuleset(target, opts, note) {
450
+ var raw = fs.readFileSync(path.join(REPO_ROOT, 'AGENTS.md'), 'utf8');
451
+ var block = FENCE_BEGIN + '\n' + raw.trimEnd() + '\n' + FENCE_END + '\n';
452
+ fs.mkdirSync(path.dirname(target), { recursive: true });
453
+ var existing = '';
454
+ try { existing = fs.readFileSync(target, 'utf8'); } catch (_) {}
455
+ if (existing.indexOf(FENCE_BEGIN) !== -1) {
456
+ if (!opts.force) {
457
+ note(' ' + target + ' already contains chisle ruleset (--force to refresh)');
458
+ return 'skipped';
459
+ }
460
+ var b = existing.indexOf(FENCE_BEGIN);
461
+ var e = existing.indexOf(FENCE_END, b);
462
+ // A begin marker with no end marker means the file was hand-edited or a
463
+ // write was cut short. Everything after the begin marker is then user
464
+ // content of unknown extent, so refuse rather than guess: rewriting would
465
+ // delete the rest of their global instructions.
466
+ if (e === -1) {
467
+ note(' ' + target + ' has a chisle-begin marker with no chisle-end; leaving it alone');
468
+ note(' remove the stray marker by hand, then re-run');
469
+ return 'skipped';
470
+ }
471
+ var tail = existing.slice(e + FENCE_END.length);
472
+ if (tail.charAt(0) === '\n') tail = tail.slice(1);
473
+ fs.writeFileSync(target, existing.slice(0, b) + block + tail, { mode: 0o644 });
474
+ process.stdout.write(' refreshed ruleset in ' + target + '\n');
475
+ return 'refreshed';
476
+ }
477
+ var sep = '';
478
+ if (existing) {
479
+ if (existing.slice(-2) === '\n\n') sep = '';
480
+ else if (existing.slice(-1) === '\n') sep = '\n';
481
+ else sep = '\n\n';
482
+ }
483
+ fs.writeFileSync(target, existing + sep + block, { mode: 0o644 });
484
+ process.stdout.write(' installed: ' + target + '\n');
485
+ return 'installed';
486
+ }
487
+
369
488
  // ── Codex (fenced ruleset in ~/.codex/AGENTS.md) ────────────────────────────
370
489
  const FENCE_BEGIN = '<!-- chisle-begin -->';
371
490
  const FENCE_END = '<!-- chisle-end -->';
@@ -373,34 +492,111 @@ const FENCE_END = '<!-- chisle-end -->';
373
492
  function installCodex(ctx) {
374
493
  const { say, note, opts, results } = ctx;
375
494
  results.detected++;
376
- say('→ Codex detected');
377
- const target = path.join(os.homedir(), '.codex', 'AGENTS.md');
378
- const body = fs.readFileSync(path.join(REPO_ROOT, 'AGENTS.md'), 'utf8').trimEnd() + '\n';
379
- const block = `${FENCE_BEGIN}\n${body}${FENCE_END}\n`;
495
+ say('\u2192 Codex detected');
496
+ const target = path.join(homeDir(), '.codex', 'AGENTS.md');
380
497
 
381
- if (opts.dryRun) { note(` would write chisle ruleset → ${target}`); results.installed.push('codex'); process.stdout.write('\n'); return; }
498
+ if (opts.dryRun) { note(' would write chisle ruleset to ' + target); results.installed.push('codex'); process.stdout.write('\n'); return; }
382
499
 
383
500
  try {
384
- fs.mkdirSync(path.dirname(target), { recursive: true });
385
- let existing = '';
386
- try { existing = fs.readFileSync(target, 'utf8'); } catch (_) {}
387
- if (existing.includes(FENCE_BEGIN)) {
388
- if (opts.force) {
389
- const rewritten = existing.replace(new RegExp(`${FENCE_BEGIN}[\\s\\S]*?${FENCE_END}\\n?`), block);
390
- fs.writeFileSync(target, rewritten, { mode: 0o644 });
391
- process.stdout.write(` refreshed ruleset in ${target}\n`);
392
- } else { note(` ${target} already contains chisle ruleset (--force to refresh)`); }
393
- results.skipped.push(['codex', 'already present']);
394
- } else {
395
- const sep = existing && !existing.endsWith('\n\n') ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
396
- fs.writeFileSync(target, existing + sep + block, { mode: 0o644 });
397
- process.stdout.write(` installed: ${target}\n`);
398
- results.installed.push('codex');
399
- }
501
+ const st = writeFencedRuleset(target, opts, note);
502
+ if (st === 'installed') results.installed.push('codex');
503
+ else results.skipped.push(['codex', 'already present']);
400
504
  } catch (e) { results.failed.push(['codex', (e && e.message) || 'write failed']); }
401
505
  process.stdout.write('\n');
402
506
  }
403
507
 
508
+ // ── OpenCode ─────────────────────────────────────────────────────────────
509
+ // Global ruleset (~/.config/opencode/AGENTS.md) carries the always-on output
510
+ // axis next to existing user instructions; the bundled skills copy into
511
+ // ~/.config/opencode/skills for on-demand loading via the native skill tool
512
+ // (the global skills dir is scanned by default, so skills.paths needs no
513
+ // edit and no second system message is added).
514
+ // Copies the OpenCode compression plugin and the shared compressor core it
515
+ // requires into ~/.config/opencode/plugins/. OpenCode auto-discovers any *.js
516
+ // or *.ts file in that dir at startup (not *.mjs), and its config dir is
517
+ // type:module, so the ESM source ships as chisle.js. Returns the number of
518
+ // files written. Never called on a dry run (installOpencode returns early),
519
+ // so no dry-run branch.
520
+ //
521
+ // chisle-hooks/package.json pins the core as CommonJS. Without it the config
522
+ // dir's own "type": "module" propagates down to these .js files, and the core
523
+ // dies on its first `require` with a ReferenceError that takes the whole
524
+ // plugin with it (loadFrom only swallows MODULE_NOT_FOUND). Bun is lenient
525
+ // enough not to care; Node is not, so pin it rather than rely on the runtime.
526
+ function copyOpencodePlugin() {
527
+ const pluginDir = path.join(homeDir(), '.config', 'opencode', 'plugins');
528
+ const hooksDir = path.join(pluginDir, 'chisle-hooks');
529
+ const files = [
530
+ ['.opencode/plugins/chisle.mjs', path.join(pluginDir, 'chisle.js')],
531
+ ['hooks/chisle-compress-output.js', path.join(hooksDir, 'chisle-compress-output.js')],
532
+ ['hooks/chisle-config.js', path.join(hooksDir, 'chisle-config.js')],
533
+ ];
534
+ fs.mkdirSync(hooksDir, { recursive: true });
535
+ var n = 0;
536
+ for (var i = 0; i < files.length; i++) {
537
+ fs.copyFileSync(path.join(REPO_ROOT, files[i][0]), files[i][1]);
538
+ n++;
539
+ }
540
+ fs.writeFileSync(path.join(hooksDir, 'package.json'), '{ "type": "commonjs" }\n');
541
+ n++;
542
+ return n;
543
+ }
544
+
545
+ function installOpencode(ctx) {
546
+ const { say, note, opts, results } = ctx;
547
+ results.detected++;
548
+ say('\u2192 OpenCode detected');
549
+ const target = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
550
+ const skillsDst = path.join(homeDir(), '.config', 'opencode', 'skills');
551
+ const pluginDst = path.join(homeDir(), '.config', 'opencode', 'plugins');
552
+
553
+ if (opts.dryRun) {
554
+ note(' would write chisle ruleset to ' + target);
555
+ note(' would copy skills to ' + skillsDst);
556
+ note(' would copy compression plugin to ' + pluginDst);
557
+ results.installed.push('opencode');
558
+ process.stdout.write('\n');
559
+ return;
560
+ }
561
+
562
+ try {
563
+ const st = writeFencedRuleset(target, opts, note);
564
+ const n = copySkills(skillsDst, opts);
565
+ process.stdout.write(' installed: ' + n + ' skill file(s) to ' + skillsDst + '\n');
566
+ const p = copyOpencodePlugin();
567
+ process.stdout.write(' installed: ' + p + ' plugin file(s) to ' + pluginDst + '\n');
568
+ if (st === 'installed') results.installed.push('opencode');
569
+ else results.skipped.push(['opencode', 'ruleset already present; skills + plugin refreshed']);
570
+ } catch (e) { results.failed.push(['opencode', (e && e.message) || 'write failed']); }
571
+ process.stdout.write('\n');
572
+ }
573
+
574
+ // ── Hermes ──────────────────────────────────────────────────────────
575
+ // Agent-Skills host: bundled skills land verbatim in ~/.hermes/skills, where
576
+ // Hermes discovers them as /chisle slash commands. No ruleset is injected:
577
+ // Hermes already reads project AGENTS.md, which carries the always-on axis
578
+ // when the repo ships it. Portable: honors CHISLE_HOME, no hardcoded paths.
579
+ function installHermes(ctx) {
580
+ const { say, opts, results } = ctx;
581
+ results.detected++;
582
+ say('\u2192 Hermes Agent detected');
583
+ const skillsDst = path.join(homeDir(), '.hermes', 'skills');
584
+
585
+ if (opts.dryRun) {
586
+ process.stdout.write(' would copy skills to ' + skillsDst + '\n');
587
+ results.installed.push('hermes');
588
+ process.stdout.write('\n');
589
+ return;
590
+ }
591
+
592
+ try {
593
+ const n = copySkills(skillsDst, opts);
594
+ process.stdout.write(' installed: ' + n + ' skill file(s) to ' + skillsDst + '\n');
595
+ results.installed.push('hermes');
596
+ } catch (e) { results.failed.push(['hermes', (e && e.message) || 'copy failed']); }
597
+ process.stdout.write('\n');
598
+ }
599
+
404
600
  // ── Project-scoped rule agents (Cursor/Windsurf/Cline/Kiro/Copilot) ─────────
405
601
  function installProjectRule(ctx, prov) {
406
602
  const { say, note, warn, opts, results } = ctx;
@@ -458,8 +654,10 @@ function uninstall(ctx) {
458
654
  touched++;
459
655
  }
460
656
  }
461
- const hooksDst = path.join(cfg, 'chisle-hooks');
462
- if (fs.existsSync(hooksDst)) { if (!opts.dryRun) fs.rmSync(hooksDst, { recursive: true, force: true }); note(` removed ${hooksDst}`); touched++; }
657
+ for (const d of ['chisle-hooks', 'chisle-spill']) {
658
+ const dst = path.join(cfg, d);
659
+ if (fs.existsSync(dst)) { if (!opts.dryRun) fs.rmSync(dst, { recursive: true, force: true }); note(` removed ${dst}`); touched++; }
660
+ }
463
661
 
464
662
  for (const f of ['.chisle-active', '.chisle-session-turns', '.chisle-statusline-suffix',
465
663
  '.chisle-compress-stats.json', '.chisle-compress-last.json', '.chisle-update-check.json']) {
@@ -471,12 +669,12 @@ function uninstall(ctx) {
471
669
  if (wants('pi') && hasCmd('pi')) {
472
670
  const sources = piSources(capture('pi', ['list']).stdout);
473
671
  for (const source of sources) {
474
- if ((run('pi', ['remove', source], opts.dryRun).status || 0) === 0) touched++;
672
+ if (ranOk(run('pi', ['remove', source], opts.dryRun))) touched++;
475
673
  }
476
674
  }
477
675
 
478
676
  if (wants('codex')) {
479
- const codexMd = path.join(os.homedir(), '.codex', 'AGENTS.md');
677
+ const codexMd = path.join(homeDir(), '.codex', 'AGENTS.md');
480
678
  if (fs.existsSync(codexMd)) {
481
679
  const txt = fs.readFileSync(codexMd, 'utf8');
482
680
  if (txt.includes(FENCE_BEGIN)) {
@@ -487,6 +685,42 @@ function uninstall(ctx) {
487
685
  }
488
686
  }
489
687
 
688
+ if (wants('opencode')) {
689
+ const ocMd = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
690
+ if (fs.existsSync(ocMd)) {
691
+ const txt = fs.readFileSync(ocMd, 'utf8');
692
+ const b = txt.indexOf(FENCE_BEGIN);
693
+ const e = b === -1 ? -1 : txt.indexOf(FENCE_END, b);
694
+ if (b !== -1 && e === -1) {
695
+ note(' ' + ocMd + ' has a chisle-begin marker with no chisle-end; leaving it alone');
696
+ } else if (b !== -1) {
697
+ const tail = txt.slice(e + FENCE_END.length).replace(/^\n/, '');
698
+ const stripped = (txt.slice(0, b) + tail).replace(/\n{3,}/g, '\n\n');
699
+ if (!opts.dryRun) fs.writeFileSync(ocMd, stripped, { mode: 0o644 });
700
+ note(' removed chisle block from ' + ocMd); touched++;
701
+ }
702
+ }
703
+ const ocSkills = path.join(homeDir(), '.config', 'opencode', 'skills');
704
+ const ocGone = opts.dryRun ? ownedSkillNames().filter(function (nm) { return fs.existsSync(path.join(ocSkills, nm)); }).length : pruneSkills(ocSkills);
705
+ if (ocGone > 0) { note(' removed ' + ocGone + ' chisle skill dir(s) from ' + ocSkills); touched++; }
706
+
707
+ const ocPlugins = path.join(homeDir(), '.config', 'opencode', 'plugins');
708
+ for (const rel of ['chisle.js', 'chisle-hooks']) {
709
+ const dst = path.join(ocPlugins, rel);
710
+ if (fs.existsSync(dst)) { if (!opts.dryRun) fs.rmSync(dst, { recursive: true, force: true }); note(' removed ' + dst); touched++; }
711
+ }
712
+ for (const f of ['chisle-spill', '.chisle-compress-stats.json', '.chisle-compress-last.json']) {
713
+ const p = path.join(homeDir(), '.config', 'opencode', f);
714
+ if (fs.existsSync(p)) { if (!opts.dryRun) fs.rmSync(p, { recursive: true, force: true }); note(' removed ' + p); touched++; }
715
+ }
716
+ }
717
+
718
+ if (wants('hermes')) {
719
+ const hSkills = path.join(homeDir(), '.hermes', 'skills');
720
+ const hGone = opts.dryRun ? ownedSkillNames().filter(function (nm) { return fs.existsSync(path.join(hSkills, nm)); }).length : pruneSkills(hSkills);
721
+ if (hGone > 0) { note(' removed ' + hGone + ' chisle skill dir(s) from ' + hSkills); touched++; }
722
+ }
723
+
490
724
  if (!opts.only.length || opts.only.some(id => PROVIDERS.find(p => p.id === id).scope === 'project')) {
491
725
  note('');
492
726
  note('Project-scoped rule files (.cursor/, .windsurf/, .clinerules/, .kiro/, .github/copilot-instructions.md)');
@@ -616,6 +850,8 @@ function main() {
616
850
  else if (p.id === 'pi') installPi(ctx);
617
851
  else if (p.id === 'gemini') installGemini(ctx);
618
852
  else if (p.id === 'codex') installCodex(ctx);
853
+ else if (p.id === 'opencode') installOpencode(ctx);
854
+ else if (p.id === 'hermes') installHermes(ctx);
619
855
  else installProjectRule(ctx, p);
620
856
  }
621
857
 
@@ -5,8 +5,20 @@
5
5
  // Oversized tool results (Bash dumps, subagent reports, web fetches) get their
6
6
  // repetitive middle elided — head kept (command context), tail kept (results/
7
7
  // errors), error-looking lines salvaged from the cut — and the trimmed version
8
- // replaces what the model sees via `updatedToolOutput`. Deterministic, zero
9
- // LLM, zero network. Small outputs pass through untouched.
8
+ // replaces what the model sees. Deterministic, zero LLM, zero network. Small
9
+ // outputs pass through untouched.
10
+ //
11
+ // Two harnesses call into this same core:
12
+ // - Claude Code / Pi: `updatedToolOutput`, snake_case payload
13
+ // (tool_name, tool_response, session_id, tool_use_id), gated behind the
14
+ // /chisle on/off flag file (see chisle-config.js: getDefaultMode/readFlag).
15
+ // - GitHub Copilot CLI: `modifiedResult.textResultForLlm`, camelCase flat
16
+ // payload (toolName, toolResult.textResultForLlm, sessionId). Copilot has
17
+ // no /chisle-equivalent mode toggle (no UserPromptSubmit-driven flag), so
18
+ // compression is always-on for Copilot once the hook is installed —
19
+ // exactly like the static ruleset Copilot already gets unconditionally
20
+ // (see README: "the always-on ruleset still ships to every other agent").
21
+ // CHISLE_COMPRESS=0 is still honored as the one kill switch for both.
10
22
  //
11
23
  // Correctness guardrails (why this never touches Read/Edit/Write):
12
24
  // - Read output feeds later Edit old_string matching — eliding it makes the
@@ -23,21 +35,27 @@
23
35
  // Plus dedup: a tool output byte-identical to that tool's immediately previous
24
36
  // output is replaced by a short marker — the content is already in context.
25
37
  //
26
- // Compression follows the /chisle flag (off → untouched). Tunables via env:
38
+ // Compression follows the /chisle flag for Claude/Pi (off → untouched); it is
39
+ // unconditional for Copilot (see above). Tunables via env, shared across all
40
+ // three harnesses:
27
41
  // CHISLE_COMPRESS=0 — kill switch
28
42
  // CHISLE_COMPRESS_SCRUB=0 — disable the lossless scrub tier
29
43
  // CHISLE_COMPRESS_DEDUP=0 — disable duplicate-output markers
30
44
  // CHISLE_COMPRESS_MAX_CHARS — outputs at/under this size are not elided
31
45
  // CHISLE_COMPRESS_HEAD_LINES — lines kept from the top
32
46
  // CHISLE_COMPRESS_TAIL_LINES — lines kept from the bottom
33
- // CHISLE_COMPRESS_TOOLS=Bash,Grep — override the tool allowlist
47
+ // CHISLE_COMPRESS_TOOLS=Bash,Grep — override the tool allowlist (both harnesses)
48
+ // CHISLE_COMPRESS_SPILL=0 — elide without writing the recovery copy
34
49
  //
35
- // Savings accrue in <claudeDir>/.chisle-compress-stats.json for the statusline.
50
+ // Savings accrue in <stateDir>/.chisle-compress-stats.json for the statusline
51
+ // (Claude/Pi: <claudeDir>; Copilot: <copilotDir>, see chisle-config.js).
52
+ // Elided originals spill to <stateDir>/chisle-spill/ so the dropped middle can
53
+ // be grepped back instead of re-running the command; the newest 40 are kept.
36
54
 
37
55
  const fs = require('fs');
38
56
  const path = require('path');
39
57
  const crypto = require('crypto');
40
- const { getClaudeDir, readFlag } = require('./chisle-config');
58
+ const { getClaudeDir, getCopilotDir, getOpencodeDir, readFlag } = require('./chisle-config');
41
59
 
42
60
  // One threshold. Env overrides all.
43
61
  // These are the values the old 'full' level used — the default,
@@ -47,18 +65,105 @@ const THRESHOLDS = { maxChars: 8000, headLines: 60, tailLines: 40 };
47
65
  // Tools whose output is safe to elide. Read/Edit/Write are absent on purpose:
48
66
  // their output feeds exact-match edits. mcp__* are read-only info tools.
49
67
  const SAFE_TOOLS = ['Bash', 'Agent', 'WebFetch', 'WebSearch', 'Grep', 'Glob'];
68
+
69
+ // The correctness invariant, enforced rather than merely defaulted: these tools'
70
+ // output feeds later exact-match edits, so eliding it makes the model edit text
71
+ // it never saw. CHISLE_COMPRESS_TOOLS is an override for the *allowlist*, not a
72
+ // way to switch this off — the Pi extension (NEVER_COMPRESS) and the OpenCode
73
+ // path (UNSAFE_TOOLS_OPENCODE) have always checked it first, and this is the
74
+ // same guard for the Claude/Pi hook payloads.
75
+ const NEVER_COMPRESS = ['read', 'edit', 'write', 'multiedit', 'notebookedit', 'notebookread'];
76
+
77
+ // Copilot CLI's own runtime tool names (lowercase, no mcp__ prefix scheme).
78
+ // Same allowlist rule as SAFE_TOOLS: Copilot's Read/Edit/Write equivalents
79
+ // are `view`/`create`/`edit` and are deliberately absent here too. Verified
80
+ // against Copilot CLI's real tool set, not guessed — see the accompanying PR
81
+ // description for the live-verification evidence (Beyin chisle-port project).
82
+ const SAFE_TOOLS_COPILOT = ['bash', 'powershell', 'grep', 'glob', 'web_fetch', 'web_search', 'task'];
83
+ // Copilot's Read/Edit/Write family, hard-blocked for the same reason as
84
+ // NEVER_COMPRESS above — not merely absent from the allowlist.
85
+ const NEVER_COMPRESS_COPILOT = ['view', 'create', 'edit', 'str_replace', 'insert'];
86
+
50
87
  const SALVAGE_RE = /\b(error|err!|fail(ed|ure|ing)?|exception|traceback|panic|fatal|denied|refused|timed?[ _-]?out|assert(ion)?|segfault|npe|undefined reference|cannot find|not found|warning)\b/i;
51
88
  const MAX_SALVAGED = 12; // error lines rescued from the elided middle
52
89
  const MAX_SALVAGE_LINE = 300; // per-line char cap on salvaged lines
53
90
 
54
91
  function toolAllowed(name) {
55
92
  if (!name || typeof name !== 'string') return false;
93
+ if (NEVER_COMPRESS.includes(name.toLowerCase())) return false;
56
94
  const list = process.env.CHISLE_COMPRESS_TOOLS
57
95
  ? process.env.CHISLE_COMPRESS_TOOLS.split(',').map(s => s.trim()).filter(Boolean)
58
96
  : SAFE_TOOLS;
59
97
  return list.includes(name) || (!process.env.CHISLE_COMPRESS_TOOLS && name.startsWith('mcp__'));
60
98
  }
61
99
 
100
+ // Same override semantics as toolAllowed, against Copilot's tool-name scheme.
101
+ function copilotToolAllowed(name) {
102
+ if (!name || typeof name !== 'string') return false;
103
+ if (NEVER_COMPRESS_COPILOT.includes(name.toLowerCase())) return false;
104
+ const list = process.env.CHISLE_COMPRESS_TOOLS
105
+ ? process.env.CHISLE_COMPRESS_TOOLS.split(',').map(s => s.trim()).filter(Boolean)
106
+ : SAFE_TOOLS_COPILOT;
107
+ return list.includes(name);
108
+ }
109
+
110
+ // OpenCode runtime tool names are lowercase (bash, grep, glob, webfetch, ...)
111
+ // and MCP tools are `server_tool` (an underscore), not Claude's mcp__ prefix.
112
+ // read/edit/write/patch are excluded on purpose: their output feeds exact-match
113
+ // edits, exactly as SAFE_TOOLS omits Read/Edit/Write.
114
+ const SAFE_TOOLS_OPENCODE = ['bash', 'grep', 'glob', 'webfetch', 'websearch', 'task', 'list'];
115
+ const UNSAFE_TOOLS_OPENCODE = ['read', 'edit', 'write', 'patch', 'todowrite', 'todoread'];
116
+
117
+ function opencodeToolAllowed(name) {
118
+ if (!name || typeof name !== 'string') return false;
119
+ const lower = name.toLowerCase();
120
+ // read/edit/write/patch are never compressed, even via CHISLE_COMPRESS_TOOLS:
121
+ // their output feeds exact-match edits (the invariant INSTALL.md promises).
122
+ if (UNSAFE_TOOLS_OPENCODE.includes(lower)) return false;
123
+ if (process.env.CHISLE_COMPRESS_TOOLS) {
124
+ return process.env.CHISLE_COMPRESS_TOOLS
125
+ .split(',').map(s => s.trim()).filter(Boolean).includes(name);
126
+ }
127
+ if (SAFE_TOOLS_OPENCODE.includes(lower)) return true;
128
+ // Heuristic: underscore = MCP `server_tool`, treated as read-only info tool
129
+ // like SAFE_TOOLS' mcp__ clause. Set CHISLE_COMPRESS_TOOLS to override if a
130
+ // custom underscore-named tool ever needs protecting.
131
+ return name.includes('_');
132
+ }
133
+
134
+ // One OpenCode tool_result → replacement string, or null to keep the original.
135
+ // The plugin passes output.output (already a plain string), so no extractText/
136
+ // rebuild dance is needed — just gate on mode/kill-switch/allowlist and reuse
137
+ // the shared scrub+elide core. stateDir keeps spill/recovery beside OpenCode's
138
+ // config (CHISLE_STATE_DIR overrides, used by tests).
139
+ //
140
+ // Fires for the common case: outputs under OpenCode's own on-disk store limits
141
+ // (~50 KB / 2000 lines) arrive here FULL, both in tool.execute.after (whose
142
+ // mutation persists to the tool part's state.output) and again, request-time,
143
+ // in experimental.chat.messages.transform. Larger outputs arrive as OpenCode's
144
+ // notice plus a retained tail; the tail still compresses here.
145
+ function compressForOpencode(toolName, text, opts) {
146
+ opts = opts || {};
147
+ if (opts.mode === 'off') return null;
148
+ if (process.env.CHISLE_COMPRESS === '0') return null;
149
+ if (!opencodeToolAllowed(toolName)) return null;
150
+ if (typeof text !== 'string' || !text) return null;
151
+ const stateDir = opts.stateDir || process.env.CHISLE_STATE_DIR || getOpencodeDir();
152
+ return transform(text, limitsFor(), toolName, stateDir);
153
+ }
154
+
155
+ // Copilot CLI's postToolUse payload is flat and camelCase:
156
+ // { sessionId, timestamp, cwd, toolName, toolArgs,
157
+ // toolResult: { resultType: "success", textResultForLlm: string } }
158
+ // Detected structurally (no explicit "harness" field exists on either
159
+ // payload shape), by the presence of toolResult.textResultForLlm together
160
+ // with the camelCase toolName — Claude/Pi payloads use tool_name/tool_response
161
+ // and never carry this shape.
162
+ function isCopilotPayload(payload) {
163
+ return !!(payload && typeof payload.toolName === 'string' &&
164
+ payload.toolResult && typeof payload.toolResult.textResultForLlm === 'string');
165
+ }
166
+
62
167
  function envInt(name, fallback) {
63
168
  const n = parseInt(process.env[name], 10);
64
169
  return Number.isFinite(n) && n > 0 ? n : fallback;
@@ -74,6 +179,7 @@ function limitsFor() {
74
179
  }
75
180
 
76
181
  // Pull a plain-text payload out of tool_response, whatever its shape.
182
+ // Also handles Copilot's already-flat textResultForLlm string unchanged.
77
183
  function extractText(response) {
78
184
  if (response == null) return null;
79
185
  if (typeof response === 'string') return response;
@@ -140,12 +246,16 @@ function duplicateMarker(toolName, text, reference = 'previous') {
140
246
  ' lines, unchanged. First lines:]\n' + preview.join('\n');
141
247
  }
142
248
 
143
- function dedupCheck(toolName, text, sessionId, toolUseId) {
249
+ // `stateDir` defaults to getClaudeDir() so every existing Claude/Pi call site
250
+ // and test (none of which pass a third argument) keeps its exact prior
251
+ // behaviour. Copilot's caller (processPayload, below) passes getCopilotDir().
252
+ function dedupCheck(toolName, text, sessionId, toolUseId, stateDir) {
144
253
  if (process.env.CHISLE_COMPRESS_DEDUP === '0') return null;
145
254
  if (!sessionId || typeof sessionId !== 'string') return null;
146
255
  if (text.length < DEDUP_MIN) return null;
147
256
  try {
148
- const p = path.join(getClaudeDir(), '.chisle-compress-last.json');
257
+ const dir = stateDir || getClaudeDir();
258
+ const p = path.join(dir, '.chisle-compress-last.json');
149
259
  try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch (e) { if (e.code !== 'ENOENT') return null; }
150
260
  let state = {};
151
261
  try { state = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; } catch (e) {}
@@ -164,6 +274,7 @@ function dedupCheck(toolName, text, sessionId, toolUseId) {
164
274
  const sameCall = !!(toolUseId && rec.id && rec.id === toolUseId);
165
275
  const dup = rec.hash === hash && !sameCall;
166
276
  state.tools[toolName] = { hash, id: toolUseId || rec.id || null };
277
+ fs.mkdirSync(dir, { recursive: true });
167
278
  const tmp = p + '.' + process.pid + '.tmp';
168
279
  fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
169
280
  fs.renameSync(tmp, p);
@@ -174,17 +285,59 @@ function dedupCheck(toolName, text, sessionId, toolUseId) {
174
285
  // ── Tier 2: elision ──────────────────────────────────────────────────────────
175
286
  // Keep head + tail; salvage error-looking lines from the elided middle so the
176
287
  // one line that mattered in a 3000-line build log survives the cut.
177
- function compress(text, limits) {
288
+ // ── recoverable elision ─────────────────────────────────────────────────────
289
+ // Elision used to destroy the middle. If the agent then needed a line from it,
290
+ // its only recourse was re-running the command: more expensive than the elision
291
+ // saved, and wrong outright when the command is not idempotent (a test run, a
292
+ // build, `git log` at a moment in time). So the full text spills to disk first
293
+ // and the marker carries the path. Recovery becomes a targeted grep instead of
294
+ // a re-run. Best-effort throughout: if the spill fails, the elision still
295
+ // happens, it just loses the escape hatch.
296
+ const SPILL_DIR = 'chisle-spill';
297
+ const SPILL_KEEP = 40;
298
+
299
+ function spillDir(stateDir) {
300
+ return path.join(stateDir || getClaudeDir(), SPILL_DIR);
301
+ }
302
+
303
+ // Keep the directory bounded. Oldest-first by mtime; cheap enough at this size
304
+ // and it runs at most once per elided output.
305
+ function pruneSpill(dir) {
306
+ try {
307
+ const files = fs.readdirSync(dir)
308
+ .filter(f => f.endsWith('.txt'))
309
+ .map(f => { const p = path.join(dir, f); return { p, t: fs.statSync(p).mtimeMs }; })
310
+ .sort((a, b) => b.t - a.t);
311
+ for (const f of files.slice(SPILL_KEEP)) { try { fs.unlinkSync(f.p); } catch (e) {} }
312
+ } catch (e) {}
313
+ }
314
+
315
+ function spill(text, toolName, stateDir) {
316
+ if (process.env.CHISLE_COMPRESS_SPILL === '0') return null;
317
+ try {
318
+ const dir = spillDir(stateDir);
319
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
320
+ const hash = crypto.createHash('sha256').update(text).digest('hex').slice(0, 12);
321
+ const safeTool = String(toolName || 'tool').replace(/[^A-Za-z0-9_-]/g, '') || 'tool';
322
+ const file = path.join(dir, `${safeTool}-${hash}.txt`);
323
+ if (!fs.existsSync(file)) fs.writeFileSync(file, text, { mode: 0o600 });
324
+ pruneSpill(dir);
325
+ return file;
326
+ } catch (e) { return null; }
327
+ }
328
+
329
+ function compress(text, limits, spillPath) {
178
330
  const { maxChars, headLines, tailLines } = limits;
179
331
  const lines = text.split('\n');
332
+ const recover = spillPath ? ' Full output: ' + spillPath + ' (grep it, do not re-run)' : '';
180
333
 
181
334
  if (lines.length <= headLines + tailLines) {
182
- // Big in bytes, few lines (one giant line) — hard char cut.
335
+ // Big in bytes, few lines (one giant line): hard char cut.
183
336
  const keep = Math.floor(maxChars / 2);
184
337
  const elided = text.length - 2 * keep;
185
338
  if (elided <= 0) return null;
186
339
  return text.slice(0, keep) +
187
- '\n... [chisle: elided ' + elided.toLocaleString('en-US') + ' chars from the middle] ...\n' +
340
+ '\n... [chisle: elided ' + elided.toLocaleString('en-US') + ' chars from the middle.' + recover + '] ...\n' +
188
341
  text.slice(-keep);
189
342
  }
190
343
 
@@ -199,8 +352,9 @@ function compress(text, limits) {
199
352
  }
200
353
 
201
354
  const marker = '... [chisle: elided ' + middle.length.toLocaleString('en-US') +
202
- ' lines — kept first ' + headLines + ', last ' + tailLines +
203
- (salvaged.length ? ', and ' + salvaged.length + ' error-like line(s) below' : '') + '] ...';
355
+ ' lines, kept first ' + headLines + ', last ' + tailLines +
356
+ (salvaged.length ? ', and ' + salvaged.length + ' error-like line(s) below' : '') +
357
+ '.' + recover + '] ...';
204
358
 
205
359
  const out = head.concat([marker], salvaged, tail).join('\n');
206
360
  return out.length < text.length ? out : null;
@@ -208,9 +362,10 @@ function compress(text, limits) {
208
362
 
209
363
  // Best-effort savings ledger. Races between parallel tool calls can drop a
210
364
  // count — stats only, never worth a lock file.
211
- function recordSavings(saved) {
365
+ function recordSavings(saved, stateDir) {
212
366
  try {
213
- const p = path.join(getClaudeDir(), '.chisle-compress-stats.json');
367
+ const dir = stateDir || getClaudeDir();
368
+ const p = path.join(dir, '.chisle-compress-stats.json');
214
369
  try { if (fs.lstatSync(p).isSymbolicLink()) return; } catch (e) { if (e.code !== 'ENOENT') return; }
215
370
  let stats = { savedChars: 0, events: 0 };
216
371
  try {
@@ -219,6 +374,7 @@ function recordSavings(saved) {
219
374
  } catch (e) {}
220
375
  stats.savedChars += saved;
221
376
  stats.events += 1;
377
+ fs.mkdirSync(dir, { recursive: true });
222
378
  const tmp = p + '.' + process.pid + '.tmp';
223
379
  fs.writeFileSync(tmp, JSON.stringify(stats), { mode: 0o600 });
224
380
  fs.renameSync(tmp, p);
@@ -226,12 +382,13 @@ function recordSavings(saved) {
226
382
  }
227
383
 
228
384
  // Scrub + elide on plain text → transformed text, or null if no meaningful
229
- // win. Stateless — this is what the replay benchmark measures.
230
- function transform(text, limits) {
385
+ // win. Stateless (aside from the spill side-effect) — this is what the
386
+ // replay benchmark measures for Claude/Pi.
387
+ function transform(text, limits, toolName, stateDir) {
231
388
  if (!text || text.length <= SCRUB_MIN) return null;
232
389
  let t = process.env.CHISLE_COMPRESS_SCRUB === '0' ? text : scrub(text);
233
390
  if (t.length > limits.maxChars) {
234
- const elided = compress(t, limits);
391
+ const elided = compress(t, limits, toolName ? spill(t, toolName, stateDir) : null);
235
392
  if (elided != null) t = elided;
236
393
  }
237
394
  return text.length - t.length >= MIN_WIN ? t : null;
@@ -245,6 +402,10 @@ function transform(text, limits) {
245
402
  // work, logs savings, and the model still receives the full output. Returning
246
403
  // null (skip) is always safer than emitting a shape the harness will refuse.
247
404
  // Reported with the transcript evidence by @sovdchains (#3).
405
+ //
406
+ // Not used for Copilot: its toolResult.textResultForLlm is already a flat
407
+ // string, so main() builds { modifiedResult: { textResultForLlm } } directly
408
+ // and never calls this function for a Copilot-shaped payload.
248
409
  function rebuildResponse(response, updated) {
249
410
  if (response == null || typeof response === 'string') return updated;
250
411
  if (typeof response === 'object' && !Array.isArray(response)) {
@@ -264,18 +425,35 @@ function rebuildResponse(response, updated) {
264
425
  }
265
426
 
266
427
  // Full pipeline on one hook payload → updated output string, or null to keep
267
- // the original. Pure given (payload, mode, env) except dedup state.
428
+ // the original. Pure given (payload, mode, env) except dedup/spill/stats state.
429
+ //
430
+ // `mode` keeps meaning "off disables everything" for Claude/Pi, exactly as
431
+ // before. For a Copilot-shaped payload the caller (main()) always passes
432
+ // 'on' — Copilot has no /chisle toggle to read a flag file for — so this
433
+ // function's Copilot branch below never actually sees 'off' in practice, but
434
+ // still honors it if a future caller passes it explicitly.
268
435
  function processPayload(payload, mode) {
269
436
  if (!payload || typeof payload !== 'object') return null;
270
437
  if (!mode || mode === 'off') return null;
271
438
  if (process.env.CHISLE_COMPRESS === '0') return null;
272
- if (!toolAllowed(payload.tool_name)) return null;
273
439
 
440
+ if (isCopilotPayload(payload)) {
441
+ const toolName = payload.toolName;
442
+ if (!copilotToolAllowed(toolName)) return null;
443
+ const text = payload.toolResult.textResultForLlm;
444
+ if (!text) return null;
445
+ const stateDir = getCopilotDir();
446
+ const dup = dedupCheck(toolName, text, payload.sessionId, null, stateDir);
447
+ if (dup != null && dup.length < text.length) return dup;
448
+ return transform(text, limitsFor(), toolName, stateDir);
449
+ }
450
+
451
+ if (!toolAllowed(payload.tool_name)) return null;
274
452
  const text = extractText(payload.tool_response != null ? payload.tool_response : payload.tool_output);
275
453
  if (!text) return null;
276
- const dup = dedupCheck(payload.tool_name, text, payload.session_id, payload.tool_use_id);
454
+ const dup = dedupCheck(payload.tool_name, text, payload.session_id, payload.tool_use_id, getClaudeDir());
277
455
  if (dup != null && dup.length < text.length) return dup;
278
- return transform(text, limitsFor());
456
+ return transform(text, limitsFor(), payload.tool_name, getClaudeDir());
279
457
  }
280
458
 
281
459
  function main() {
@@ -284,9 +462,30 @@ function main() {
284
462
  process.stdin.on('end', () => {
285
463
  try {
286
464
  const payload = JSON.parse(input.replace(/^/, ''));
287
- const mode = readFlag(path.join(getClaudeDir(), '.chisle-active'));
465
+ const copilot = isCopilotPayload(payload);
466
+
467
+ // Copilot has no /chisle mode-toggle mechanism (no UserPromptSubmit
468
+ // hook wired for it yet, so there is nothing to flip a flag file with):
469
+ // treat it as always-on, matching the always-on ruleset it already
470
+ // gets. Claude/Pi keep reading the real flag file exactly as before.
471
+ const mode = copilot ? 'on' : readFlag(path.join(getClaudeDir(), '.chisle-active'));
472
+
288
473
  const updated = processPayload(payload, mode);
289
474
  if (updated == null) return;
475
+
476
+ if (copilot) {
477
+ const original = payload.toolResult.textResultForLlm;
478
+ recordSavings(original.length - updated.length, getCopilotDir());
479
+ // Echo back the result type we were handed. Hard-coding 'success'
480
+ // would relabel a failed tool call as a successful one on its way to
481
+ // the model — the compressor only ever rewrites text, never status.
482
+ const resultType = payload.toolResult.resultType || 'success';
483
+ process.stdout.write(JSON.stringify({
484
+ modifiedResult: { resultType, textResultForLlm: updated },
485
+ }));
486
+ return;
487
+ }
488
+
290
489
  const response = payload.tool_response != null ? payload.tool_response : payload.tool_output;
291
490
  const rebuilt = rebuildResponse(response, updated);
292
491
  // Shape we can't safely rebuild — emit nothing and count nothing.
@@ -294,7 +493,7 @@ function main() {
294
493
  const original = extractText(response);
295
494
  // Only after we know a replacement is actually going out, so the stats
296
495
  // file stops crediting savings the harness never applied.
297
- recordSavings(original.length - updated.length);
496
+ recordSavings(original.length - updated.length, getClaudeDir());
298
497
  process.stdout.write(JSON.stringify({
299
498
  hookSpecificOutput: {
300
499
  hookEventName: 'PostToolUse',
@@ -307,4 +506,12 @@ function main() {
307
506
 
308
507
  if (require.main === module) main();
309
508
 
310
- module.exports = { extractText, rebuildResponse, scrub, compress, transform, limitsFor, toolAllowed, processPayload, duplicateMarker, THRESHOLDS, SAFE_TOOLS };
509
+ module.exports = {
510
+ extractText, rebuildResponse, scrub, compress, transform, limitsFor, toolAllowed, processPayload,
511
+ duplicateMarker, THRESHOLDS, SAFE_TOOLS, NEVER_COMPRESS,
512
+ // Copilot-specific additions (see PR description for rationale):
513
+ isCopilotPayload, copilotToolAllowed, SAFE_TOOLS_COPILOT, NEVER_COMPRESS_COPILOT,
514
+ // OpenCode-specific additions (plugin uses tool.execute.after +
515
+ // experimental.chat.messages.transform):
516
+ opencodeToolAllowed, compressForOpencode, SAFE_TOOLS_OPENCODE,
517
+ };
@@ -38,6 +38,25 @@ function getClaudeDir() {
38
38
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
39
39
  }
40
40
 
41
+ // OpenCode's global config dir. The compressor plugin keeps its spill/dedup/
42
+ // stats here (as Copilot uses getCopilotDir), so recovery files sit beside
43
+ // OpenCode's own config instead of leaking into ~/.claude.
44
+ function getOpencodeDir() {
45
+ if (process.env.XDG_CONFIG_HOME) {
46
+ return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
47
+ }
48
+ return path.join(os.homedir(), '.config', 'opencode');
49
+ }
50
+
51
+ // GitHub Copilot CLI's own user-level state directory (mirrors the
52
+ // CLAUDE_CONFIG_DIR precedent above). Copilot CLI already reads/writes hook
53
+ // config under this same directory (~/.copilot/hooks/), so chisle's
54
+ // dedup/spill/stats state lives alongside it rather than under ~/.claude,
55
+ // which would be misleading for a machine that never runs Claude Code.
56
+ function getCopilotDir() {
57
+ return process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot');
58
+ }
59
+
41
60
  function getConfigDir() {
42
61
  if (process.env.XDG_CONFIG_HOME) {
43
62
  return path.join(process.env.XDG_CONFIG_HOME, 'chisle');
@@ -172,6 +191,6 @@ function readFlag(flagPath) {
172
191
  }
173
192
 
174
193
  module.exports = {
175
- LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir,
194
+ LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir,
176
195
  VALID_MODES, safeWriteFlag, readFlag,
177
196
  };
package/package.json CHANGED
@@ -1,12 +1,27 @@
1
1
  {
2
2
  "name": "chisle",
3
- "version": "3.2.1",
3
+ "version": "3.4.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",
7
7
  "claude-code-plugin",
8
8
  "pi-package",
9
9
  "pi-coding-agent",
10
+ "token-optimization",
11
+ "token-reduction",
12
+ "context-compression",
13
+ "llm",
14
+ "ai",
15
+ "anthropic",
16
+ "cost-optimization",
17
+ "cursor",
18
+ "codex",
19
+ "copilot",
20
+ "opencode",
21
+ "hermes-agent",
22
+ "hermes",
23
+ "gemini",
24
+ "agents",
10
25
  "productivity",
11
26
  "yagni",
12
27
  "minimal"
@@ -40,6 +55,14 @@
40
55
  "engines": {
41
56
  "node": ">=18"
42
57
  },
58
+ "omp": {
59
+ "extensions": [
60
+ "./pi-extension/index.cjs"
61
+ ],
62
+ "skills": [
63
+ "./skills"
64
+ ]
65
+ },
43
66
  "pi": {
44
67
  "extensions": [
45
68
  "./pi-extension/index.js"
@@ -72,6 +95,7 @@
72
95
  ".kiro/",
73
96
  ".github/copilot-instructions.md",
74
97
  ".claude-plugin/",
98
+ ".opencode/",
75
99
  "README.md",
76
100
  "LICENSE"
77
101
  ],
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ // OMP entry point. OMP only maps `module.exports` onto `default` for modules it
4
+ // classifies as graph-owned CommonJS, and its classifier excludes the entry file
5
+ // itself unless the filename ends in `.cjs` (see isGraphOwnedCommonJsModule in
6
+ // oh-my-pi's legacy-pi-compat.ts). A CommonJS entry named `.js` is imported as
7
+ // ESM, so `default` is undefined and the factory lookup fails with
8
+ // "Extension does not export a valid factory function".
9
+ //
10
+ // Requiring index.js from here makes it a graph-owned dependency, which the
11
+ // classifier does handle, so both runtimes get the same factory. Pi keeps
12
+ // loading index.js directly via the `pi` manifest block below.
13
+ module.exports = require('./index.js');
@@ -166,7 +166,7 @@ function chisleExtension(pi) {
166
166
  lastByToolBlock.set(key, hash);
167
167
  if (previous === hash) updated = duplicateMarker(event.toolName, text, 'earlier');
168
168
  }
169
- if (updated == null) updated = transform(text, limitsFor());
169
+ if (updated == null) updated = transform(text, limitsFor(), event.toolName);
170
170
  if (updated == null || updated.length >= text.length) return block;
171
171
  changed = true;
172
172
  savedChars += text.length - updated.length;