chisle 3.3.0 → 3.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/plugin.json +1 -1
- package/.opencode/plugins/chisle.mjs +115 -0
- package/README.md +41 -4
- package/bin/install.js +261 -27
- package/hooks/chisle-activate.js +12 -5
- package/hooks/chisle-compress-output.js +186 -25
- package/hooks/chisle-config.js +182 -1
- package/hooks/chisle-mode-tracker.js +20 -6
- package/package.json +25 -1
- package/pi-extension/index.cjs +13 -0
- package/pi-extension/index.js +41 -8
|
@@ -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
|
|
9
|
-
//
|
|
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,24 +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)
|
|
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)
|
|
34
48
|
// CHISLE_COMPRESS_SPILL=0 — elide without writing the recovery copy
|
|
35
49
|
//
|
|
36
|
-
// Savings accrue in <
|
|
37
|
-
//
|
|
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
|
|
38
53
|
// be grepped back instead of re-running the command; the newest 40 are kept.
|
|
39
54
|
|
|
40
55
|
const fs = require('fs');
|
|
41
56
|
const path = require('path');
|
|
42
57
|
const crypto = require('crypto');
|
|
43
|
-
const { getClaudeDir, readFlag } = require('./chisle-config');
|
|
58
|
+
const { getClaudeDir, getCopilotDir, getOpencodeDir, readFlag } = require('./chisle-config');
|
|
44
59
|
|
|
45
60
|
// One threshold. Env overrides all.
|
|
46
61
|
// These are the values the old 'full' level used — the default,
|
|
@@ -50,18 +65,105 @@ const THRESHOLDS = { maxChars: 8000, headLines: 60, tailLines: 40 };
|
|
|
50
65
|
// Tools whose output is safe to elide. Read/Edit/Write are absent on purpose:
|
|
51
66
|
// their output feeds exact-match edits. mcp__* are read-only info tools.
|
|
52
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
|
+
|
|
53
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;
|
|
54
88
|
const MAX_SALVAGED = 12; // error lines rescued from the elided middle
|
|
55
89
|
const MAX_SALVAGE_LINE = 300; // per-line char cap on salvaged lines
|
|
56
90
|
|
|
57
91
|
function toolAllowed(name) {
|
|
58
92
|
if (!name || typeof name !== 'string') return false;
|
|
93
|
+
if (NEVER_COMPRESS.includes(name.toLowerCase())) return false;
|
|
59
94
|
const list = process.env.CHISLE_COMPRESS_TOOLS
|
|
60
95
|
? process.env.CHISLE_COMPRESS_TOOLS.split(',').map(s => s.trim()).filter(Boolean)
|
|
61
96
|
: SAFE_TOOLS;
|
|
62
97
|
return list.includes(name) || (!process.env.CHISLE_COMPRESS_TOOLS && name.startsWith('mcp__'));
|
|
63
98
|
}
|
|
64
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
|
+
|
|
65
167
|
function envInt(name, fallback) {
|
|
66
168
|
const n = parseInt(process.env[name], 10);
|
|
67
169
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
@@ -77,6 +179,7 @@ function limitsFor() {
|
|
|
77
179
|
}
|
|
78
180
|
|
|
79
181
|
// Pull a plain-text payload out of tool_response, whatever its shape.
|
|
182
|
+
// Also handles Copilot's already-flat textResultForLlm string unchanged.
|
|
80
183
|
function extractText(response) {
|
|
81
184
|
if (response == null) return null;
|
|
82
185
|
if (typeof response === 'string') return response;
|
|
@@ -143,12 +246,16 @@ function duplicateMarker(toolName, text, reference = 'previous') {
|
|
|
143
246
|
' lines, unchanged. First lines:]\n' + preview.join('\n');
|
|
144
247
|
}
|
|
145
248
|
|
|
146
|
-
|
|
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) {
|
|
147
253
|
if (process.env.CHISLE_COMPRESS_DEDUP === '0') return null;
|
|
148
254
|
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
149
255
|
if (text.length < DEDUP_MIN) return null;
|
|
150
256
|
try {
|
|
151
|
-
const
|
|
257
|
+
const dir = stateDir || getClaudeDir();
|
|
258
|
+
const p = path.join(dir, '.chisle-compress-last.json');
|
|
152
259
|
try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch (e) { if (e.code !== 'ENOENT') return null; }
|
|
153
260
|
let state = {};
|
|
154
261
|
try { state = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; } catch (e) {}
|
|
@@ -167,6 +274,7 @@ function dedupCheck(toolName, text, sessionId, toolUseId) {
|
|
|
167
274
|
const sameCall = !!(toolUseId && rec.id && rec.id === toolUseId);
|
|
168
275
|
const dup = rec.hash === hash && !sameCall;
|
|
169
276
|
state.tools[toolName] = { hash, id: toolUseId || rec.id || null };
|
|
277
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
170
278
|
const tmp = p + '.' + process.pid + '.tmp';
|
|
171
279
|
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
172
280
|
fs.renameSync(tmp, p);
|
|
@@ -188,8 +296,8 @@ function dedupCheck(toolName, text, sessionId, toolUseId) {
|
|
|
188
296
|
const SPILL_DIR = 'chisle-spill';
|
|
189
297
|
const SPILL_KEEP = 40;
|
|
190
298
|
|
|
191
|
-
function spillDir() {
|
|
192
|
-
return path.join(getClaudeDir(), SPILL_DIR);
|
|
299
|
+
function spillDir(stateDir) {
|
|
300
|
+
return path.join(stateDir || getClaudeDir(), SPILL_DIR);
|
|
193
301
|
}
|
|
194
302
|
|
|
195
303
|
// Keep the directory bounded. Oldest-first by mtime; cheap enough at this size
|
|
@@ -204,10 +312,10 @@ function pruneSpill(dir) {
|
|
|
204
312
|
} catch (e) {}
|
|
205
313
|
}
|
|
206
314
|
|
|
207
|
-
function spill(text, toolName) {
|
|
315
|
+
function spill(text, toolName, stateDir) {
|
|
208
316
|
if (process.env.CHISLE_COMPRESS_SPILL === '0') return null;
|
|
209
317
|
try {
|
|
210
|
-
const dir = spillDir();
|
|
318
|
+
const dir = spillDir(stateDir);
|
|
211
319
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
212
320
|
const hash = crypto.createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
213
321
|
const safeTool = String(toolName || 'tool').replace(/[^A-Za-z0-9_-]/g, '') || 'tool';
|
|
@@ -254,9 +362,10 @@ function compress(text, limits, spillPath) {
|
|
|
254
362
|
|
|
255
363
|
// Best-effort savings ledger. Races between parallel tool calls can drop a
|
|
256
364
|
// count — stats only, never worth a lock file.
|
|
257
|
-
function recordSavings(saved) {
|
|
365
|
+
function recordSavings(saved, stateDir) {
|
|
258
366
|
try {
|
|
259
|
-
const
|
|
367
|
+
const dir = stateDir || getClaudeDir();
|
|
368
|
+
const p = path.join(dir, '.chisle-compress-stats.json');
|
|
260
369
|
try { if (fs.lstatSync(p).isSymbolicLink()) return; } catch (e) { if (e.code !== 'ENOENT') return; }
|
|
261
370
|
let stats = { savedChars: 0, events: 0 };
|
|
262
371
|
try {
|
|
@@ -265,6 +374,7 @@ function recordSavings(saved) {
|
|
|
265
374
|
} catch (e) {}
|
|
266
375
|
stats.savedChars += saved;
|
|
267
376
|
stats.events += 1;
|
|
377
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
268
378
|
const tmp = p + '.' + process.pid + '.tmp';
|
|
269
379
|
fs.writeFileSync(tmp, JSON.stringify(stats), { mode: 0o600 });
|
|
270
380
|
fs.renameSync(tmp, p);
|
|
@@ -272,12 +382,13 @@ function recordSavings(saved) {
|
|
|
272
382
|
}
|
|
273
383
|
|
|
274
384
|
// Scrub + elide on plain text → transformed text, or null if no meaningful
|
|
275
|
-
// win. Stateless — this is what the
|
|
276
|
-
|
|
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) {
|
|
277
388
|
if (!text || text.length <= SCRUB_MIN) return null;
|
|
278
389
|
let t = process.env.CHISLE_COMPRESS_SCRUB === '0' ? text : scrub(text);
|
|
279
390
|
if (t.length > limits.maxChars) {
|
|
280
|
-
const elided = compress(t, limits, toolName ? spill(t, toolName) : null);
|
|
391
|
+
const elided = compress(t, limits, toolName ? spill(t, toolName, stateDir) : null);
|
|
281
392
|
if (elided != null) t = elided;
|
|
282
393
|
}
|
|
283
394
|
return text.length - t.length >= MIN_WIN ? t : null;
|
|
@@ -291,6 +402,10 @@ function transform(text, limits, toolName) {
|
|
|
291
402
|
// work, logs savings, and the model still receives the full output. Returning
|
|
292
403
|
// null (skip) is always safer than emitting a shape the harness will refuse.
|
|
293
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.
|
|
294
409
|
function rebuildResponse(response, updated) {
|
|
295
410
|
if (response == null || typeof response === 'string') return updated;
|
|
296
411
|
if (typeof response === 'object' && !Array.isArray(response)) {
|
|
@@ -310,18 +425,35 @@ function rebuildResponse(response, updated) {
|
|
|
310
425
|
}
|
|
311
426
|
|
|
312
427
|
// Full pipeline on one hook payload → updated output string, or null to keep
|
|
313
|
-
// 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.
|
|
314
435
|
function processPayload(payload, mode) {
|
|
315
436
|
if (!payload || typeof payload !== 'object') return null;
|
|
316
437
|
if (!mode || mode === 'off') return null;
|
|
317
438
|
if (process.env.CHISLE_COMPRESS === '0') return null;
|
|
318
|
-
if (!toolAllowed(payload.tool_name)) return null;
|
|
319
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;
|
|
320
452
|
const text = extractText(payload.tool_response != null ? payload.tool_response : payload.tool_output);
|
|
321
453
|
if (!text) return null;
|
|
322
|
-
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());
|
|
323
455
|
if (dup != null && dup.length < text.length) return dup;
|
|
324
|
-
return transform(text, limitsFor(), payload.tool_name);
|
|
456
|
+
return transform(text, limitsFor(), payload.tool_name, getClaudeDir());
|
|
325
457
|
}
|
|
326
458
|
|
|
327
459
|
function main() {
|
|
@@ -330,9 +462,30 @@ function main() {
|
|
|
330
462
|
process.stdin.on('end', () => {
|
|
331
463
|
try {
|
|
332
464
|
const payload = JSON.parse(input.replace(/^/, ''));
|
|
333
|
-
const
|
|
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
|
+
|
|
334
473
|
const updated = processPayload(payload, mode);
|
|
335
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
|
+
|
|
336
489
|
const response = payload.tool_response != null ? payload.tool_response : payload.tool_output;
|
|
337
490
|
const rebuilt = rebuildResponse(response, updated);
|
|
338
491
|
// Shape we can't safely rebuild — emit nothing and count nothing.
|
|
@@ -340,7 +493,7 @@ function main() {
|
|
|
340
493
|
const original = extractText(response);
|
|
341
494
|
// Only after we know a replacement is actually going out, so the stats
|
|
342
495
|
// file stops crediting savings the harness never applied.
|
|
343
|
-
recordSavings(original.length - updated.length);
|
|
496
|
+
recordSavings(original.length - updated.length, getClaudeDir());
|
|
344
497
|
process.stdout.write(JSON.stringify({
|
|
345
498
|
hookSpecificOutput: {
|
|
346
499
|
hookEventName: 'PostToolUse',
|
|
@@ -353,4 +506,12 @@ function main() {
|
|
|
353
506
|
|
|
354
507
|
if (require.main === module) main();
|
|
355
508
|
|
|
356
|
-
module.exports = {
|
|
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
|
+
};
|
package/hooks/chisle-config.js
CHANGED
|
@@ -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');
|
|
@@ -73,6 +92,166 @@ function getDefaultMode() {
|
|
|
73
92
|
return 'on';
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
// ── Section suppression ─────────────────────────────────────────────────────
|
|
96
|
+
// Optional config key letting a user drop Chisle's prose rules or code rules
|
|
97
|
+
// independently, while keeping the other. Config file only — no env override,
|
|
98
|
+
// unlike defaultMode. Absent, malformed, or non-boolean always falls back to
|
|
99
|
+
// enabled (true); never throws.
|
|
100
|
+
// { "sections": { "prose": false } } // suppress prose rules, keep code
|
|
101
|
+
|
|
102
|
+
// Headings from skills/chisle/SKILL.md, grouped by section.
|
|
103
|
+
const PROSE_HEADINGS = [
|
|
104
|
+
'Prose: Maximum Signal Per Token',
|
|
105
|
+
'Output Format',
|
|
106
|
+
'What it sounds like',
|
|
107
|
+
];
|
|
108
|
+
const CODE_HEADINGS = [
|
|
109
|
+
'Code: The Efficiency Ladder',
|
|
110
|
+
'Code Rules',
|
|
111
|
+
'Context Diet: Read Less Into the Window',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
function readSectionsFromConfigFile(configPath) {
|
|
115
|
+
try {
|
|
116
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
117
|
+
const config = JSON.parse(raw);
|
|
118
|
+
const s = config && typeof config === 'object' ? config.sections : null;
|
|
119
|
+
return {
|
|
120
|
+
prose: !!(s && typeof s === 'object' && typeof s.prose === 'boolean' ? s.prose : true),
|
|
121
|
+
code: !!(s && typeof s === 'object' && typeof s.code === 'boolean' ? s.code : true),
|
|
122
|
+
};
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { prose: true, code: true };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getSections() {
|
|
129
|
+
return readSectionsFromConfigFile(path.join(getConfigDir(), 'config.json'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Drops whole `## Heading` sections (heading line through the line before the
|
|
133
|
+
// next `## ` heading) whose heading is in an excluded group. Byte-identical
|
|
134
|
+
// to `content` when nothing is excluded. Never throws — falls back to the
|
|
135
|
+
// unfiltered content. Assumes no `## ` line appears inside a fenced code
|
|
136
|
+
// block (true for SKILL.md and the fallback ruleset at 07c603b).
|
|
137
|
+
function filterSections(content, sections, proseHeadings, codeHeadings) {
|
|
138
|
+
try {
|
|
139
|
+
if (typeof content !== 'string' || !content) return content;
|
|
140
|
+
const prose = sections ? sections.prose !== false : true;
|
|
141
|
+
const code = sections ? sections.code !== false : true;
|
|
142
|
+
if (prose && code) return content;
|
|
143
|
+
|
|
144
|
+
const dropProse = proseHeadings || PROSE_HEADINGS;
|
|
145
|
+
const dropCode = codeHeadings || CODE_HEADINGS;
|
|
146
|
+
|
|
147
|
+
const chunks = content.split(/(?=^## .+$)/m);
|
|
148
|
+
const kept = chunks.filter((chunk) => {
|
|
149
|
+
const m = /^## (.+)$/m.exec(chunk);
|
|
150
|
+
if (!m) return true; // preamble chunk (no heading)
|
|
151
|
+
const heading = m[1].trim();
|
|
152
|
+
if (!prose && dropProse.includes(heading)) return false;
|
|
153
|
+
if (!code && dropCode.includes(heading)) return false;
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
return kept.join('');
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return content;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Output-style detection ──────────────────────────────────────────────────
|
|
163
|
+
// Claude Code's output-style mechanism also dictates prose structure, so an
|
|
164
|
+
// active style and Chisle's prose section issue contradicting instructions with
|
|
165
|
+
// no precedence between them (#15). `outputStyle` is a documented settings key
|
|
166
|
+
// (schemastore: claude-code-settings.json), and `/output-style` persists the
|
|
167
|
+
// selection to .claude/settings.local.json at the local project level, so the
|
|
168
|
+
// active style IS readable — it just isn't in the SessionStart payload.
|
|
169
|
+
//
|
|
170
|
+
// Kept dependency-free on purpose: this file is copied standalone into
|
|
171
|
+
// ~/.config/opencode/plugins/chisle-hooks/, so it must not require anything
|
|
172
|
+
// outside itself.
|
|
173
|
+
|
|
174
|
+
// Claude Code parses settings.json with strict JSON, but users do leave
|
|
175
|
+
// comments in it. Tolerate that rather than silently reading no style.
|
|
176
|
+
function parseJsonLoose(raw) {
|
|
177
|
+
try { return JSON.parse(raw.replace(/^\uFEFF/, '')); } catch (e) {}
|
|
178
|
+
try {
|
|
179
|
+
const stripped = raw
|
|
180
|
+
.replace(/^\uFEFF/, '')
|
|
181
|
+
.replace(/"(?:[^"\\]|\\.)*"|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
|
|
182
|
+
(m) => (m[0] === '"' ? m : ' '));
|
|
183
|
+
return JSON.parse(stripped);
|
|
184
|
+
} catch (e) { return null; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readJsonFile(p) {
|
|
188
|
+
try {
|
|
189
|
+
const st = fs.lstatSync(p);
|
|
190
|
+
if (st.isSymbolicLink() || !st.isFile()) return null;
|
|
191
|
+
return parseJsonLoose(fs.readFileSync(p, 'utf8'));
|
|
192
|
+
} catch (e) { return null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Claude Code's own precedence, most specific first. A style set for this
|
|
196
|
+
// project must beat a different one set globally.
|
|
197
|
+
function outputStyleSources(opts) {
|
|
198
|
+
const o = opts || {};
|
|
199
|
+
const cwd = o.cwd || process.cwd();
|
|
200
|
+
const claudeDir = o.claudeDir || getClaudeDir();
|
|
201
|
+
return [
|
|
202
|
+
path.join(cwd, '.claude', 'settings.local.json'),
|
|
203
|
+
path.join(cwd, '.claude', 'settings.json'),
|
|
204
|
+
path.join(claudeDir, 'settings.json'),
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The active output style, or null when none is. "default" is the built-in
|
|
209
|
+
// no-op style, so it counts as none.
|
|
210
|
+
function getActiveOutputStyle(opts) {
|
|
211
|
+
try {
|
|
212
|
+
for (const file of outputStyleSources(opts)) {
|
|
213
|
+
const json = readJsonFile(file);
|
|
214
|
+
if (!json || typeof json !== 'object') continue;
|
|
215
|
+
const style = json.outputStyle;
|
|
216
|
+
if (typeof style !== 'string') continue;
|
|
217
|
+
const trimmed = style.trim();
|
|
218
|
+
if (!trimmed) continue;
|
|
219
|
+
return trimmed.toLowerCase() === 'default' ? null : trimmed;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
} catch (e) { return null; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// What the user wrote, distinguishing "absent" from "explicitly true". Only an
|
|
226
|
+
// absent value may be overridden by output-style detection.
|
|
227
|
+
function explicitSections() {
|
|
228
|
+
try {
|
|
229
|
+
const json = readJsonFile(path.join(getConfigDir(), 'config.json'));
|
|
230
|
+
const s = json && typeof json === 'object' ? json.sections : null;
|
|
231
|
+
if (!s || typeof s !== 'object') return {};
|
|
232
|
+
const out = {};
|
|
233
|
+
if (typeof s.prose === 'boolean') out.prose = s.prose;
|
|
234
|
+
if (typeof s.code === 'boolean') out.code = s.code;
|
|
235
|
+
return out;
|
|
236
|
+
} catch (e) { return {}; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Final prose/code decision for the Claude Code hooks. Explicit config always
|
|
240
|
+
// wins — a user who wrote `sections.prose: true` has said they want the prose
|
|
241
|
+
// rules even alongside a style, and this must not overrule that. Only an unset
|
|
242
|
+
// prose key yields to an active style. `code` is never touched: output styles
|
|
243
|
+
// govern prose structure, not the efficiency ladder.
|
|
244
|
+
//
|
|
245
|
+
// Pi has no output-style mechanism, so pi-extension keeps calling getSections().
|
|
246
|
+
function resolveSections(opts) {
|
|
247
|
+
const explicit = explicitSections();
|
|
248
|
+
const base = getSections();
|
|
249
|
+
if (explicit.prose !== undefined) return { ...base, outputStyle: null };
|
|
250
|
+
const style = getActiveOutputStyle(opts);
|
|
251
|
+
if (!style) return { ...base, outputStyle: null };
|
|
252
|
+
return { prose: false, code: base.code, outputStyle: style };
|
|
253
|
+
}
|
|
254
|
+
|
|
76
255
|
// Symlink-safe, atomic flag write with 0600 perms.
|
|
77
256
|
// Defends against local attacker replacing predictable path with symlink to clobber other files.
|
|
78
257
|
function safeWriteFlag(flagPath, content) {
|
|
@@ -172,6 +351,8 @@ function readFlag(flagPath) {
|
|
|
172
351
|
}
|
|
173
352
|
|
|
174
353
|
module.exports = {
|
|
175
|
-
LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir,
|
|
354
|
+
LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir,
|
|
176
355
|
VALID_MODES, safeWriteFlag, readFlag,
|
|
356
|
+
PROSE_HEADINGS, CODE_HEADINGS, getSections, filterSections,
|
|
357
|
+
getActiveOutputStyle, explicitSections, resolveSections,
|
|
177
358
|
};
|
|
@@ -5,9 +5,27 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
-
const { getClaudeDir, safeWriteFlag, readFlag } = require('./chisle-config');
|
|
8
|
+
const { getClaudeDir, safeWriteFlag, readFlag, resolveSections } = require('./chisle-config');
|
|
9
9
|
const { requestedMode } = require('./chisle-mode');
|
|
10
10
|
|
|
11
|
+
// Per-turn reinforcement clauses. `Code/commits/security: write normal.` is
|
|
12
|
+
// the Boundaries carve-out (always-on in SKILL.md) and ships regardless of
|
|
13
|
+
// `sections`; the prose/code clauses honor it the same way the SessionStart
|
|
14
|
+
// ruleset does.
|
|
15
|
+
const PROSE_CLAUSE = 'Prose: drop articles/filler/pleasantries/hedging. Fragments OK.';
|
|
16
|
+
const CODE_CLAUSE = 'Code: YAGNI ladder first (reuse → stdlib → native → dep → one line → min code).';
|
|
17
|
+
const BOUNDARIES_CLAUSE = 'Code/commits/security: write normal.';
|
|
18
|
+
|
|
19
|
+
function reinforcementLine(sections) {
|
|
20
|
+
const prose = sections ? sections.prose !== false : true;
|
|
21
|
+
const code = sections ? sections.code !== false : true;
|
|
22
|
+
const parts = ['CHISLE ACTIVE.'];
|
|
23
|
+
if (prose) parts.push(PROSE_CLAUSE);
|
|
24
|
+
if (code) parts.push(CODE_CLAUSE);
|
|
25
|
+
parts.push(BOUNDARIES_CLAUSE);
|
|
26
|
+
return parts.join(' ');
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
const claudeDir = getClaudeDir();
|
|
12
30
|
const flagPath = path.join(claudeDir, '.chisle-active');
|
|
13
31
|
|
|
@@ -32,11 +50,7 @@ process.stdin.on('end', () => {
|
|
|
32
50
|
process.stdout.write(JSON.stringify({
|
|
33
51
|
hookSpecificOutput: {
|
|
34
52
|
hookEventName: 'UserPromptSubmit',
|
|
35
|
-
additionalContext:
|
|
36
|
-
'CHISLE ACTIVE. ' +
|
|
37
|
-
'Prose: drop articles/filler/pleasantries/hedging. Fragments OK. ' +
|
|
38
|
-
'Code: YAGNI ladder first (reuse → stdlib → native → dep → one line → min code). ' +
|
|
39
|
-
'Code/commits/security: write normal.'
|
|
53
|
+
additionalContext: reinforcementLine(resolveSections({ cwd: process.cwd(), claudeDir }))
|
|
40
54
|
}
|
|
41
55
|
}));
|
|
42
56
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chisle",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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');
|