chisle 3.6.0 → 3.6.1

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.6.0",
3
+ "version": "3.6.1",
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": [
@@ -22,7 +22,7 @@
22
22
  // Tunables (shared with the Claude/Pi/Copilot compressor, see chisle-config):
23
23
  // CHISLE_COMPRESS=0 kill switch
24
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
25
+ // CHISLE_COMPRESS_MAX_CHARS elision threshold and final OpenCode bound
26
26
  // CHISLE_COMPRESS_TOOLS=bash,… override the tool allowlist
27
27
 
28
28
  import { createRequire } from 'module';
@@ -52,12 +52,16 @@ const config = loadFrom([
52
52
  ]) || { getDefaultMode: () => 'on' };
53
53
 
54
54
  const compressForOpencode = core && core.compressForOpencode;
55
+ const recordSavings = core && core.recordSavings;
55
56
  const getDefaultMode = config.getDefaultMode || (() => 'on');
57
+ const getOpencodeDir = config.getOpencodeDir || (() => path.dirname(__dirname));
56
58
 
57
59
  // Already compressed: our elision marker. Skip such text so a second pass
58
60
  // (messages.transform after tool.execute.after already ran) never nests a
59
61
  // marker or eats an earlier salvage block. Keeps compression idempotent.
60
62
  const CHISLE_MARK = '[chisle:';
63
+ const getOpencodeMaxChars = () =>
64
+ (core && core.limitsFor && core.limitsFor().maxChars) || 8000;
61
65
 
62
66
  export default async () => {
63
67
  return {
@@ -75,10 +79,19 @@ export default async () => {
75
79
  // MCP tools can hand back a content[] array rather than a string;
76
80
  // built-ins give a string. Only the string case is ours to touch.
77
81
  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) {
82
+ if (output.output.includes(CHISLE_MARK) && output.output.length <= getOpencodeMaxChars()) return;
83
+ const original = output.output;
84
+ const updated = compressForOpencode(input && input.tool, original, {
85
+ mode: getDefaultMode(),
86
+ sessionId: input && input.sessionID,
87
+ callId: input && input.callID,
88
+ });
89
+ if (updated != null && updated.length < original.length) {
81
90
  output.output = updated;
91
+ if (recordSavings) {
92
+ recordSavings(original.length - updated.length,
93
+ process.env.CHISLE_STATE_DIR || getOpencodeDir());
94
+ }
82
95
  }
83
96
  } catch (e) { /* keep the original output */ }
84
97
  },
@@ -102,7 +115,7 @@ export default async () => {
102
115
  if (!part || part.type !== 'tool') continue;
103
116
  const st = part.state;
104
117
  if (!st || st.status !== 'completed' || typeof st.output !== 'string') continue;
105
- if (st.output.includes(CHISLE_MARK)) continue;
118
+ if (st.output.includes(CHISLE_MARK) && st.output.length <= getOpencodeMaxChars()) continue;
106
119
  const updated = compressForOpencode(part.tool, st.output, { mode });
107
120
  if (updated != null && updated.length < st.output.length) {
108
121
  st.output = updated;
package/bin/install.js CHANGED
@@ -709,7 +709,7 @@ function uninstall(ctx) {
709
709
  const dst = path.join(ocPlugins, rel);
710
710
  if (fs.existsSync(dst)) { if (!opts.dryRun) fs.rmSync(dst, { recursive: true, force: true }); note(' removed ' + dst); touched++; }
711
711
  }
712
- for (const f of ['chisle-spill', '.chisle-compress-stats.json', '.chisle-compress-last.json']) {
712
+ for (const f of ['chisle-spill', 'chisle-dedup', '.chisle-compress-stats.json', '.chisle-compress-last.json']) {
713
713
  const p = path.join(homeDir(), '.config', 'opencode', f);
714
714
  if (fs.existsSync(p)) { if (!opts.dryRun) fs.rmSync(p, { recursive: true, force: true }); note(' removed ' + p); touched++; }
715
715
  }
@@ -88,6 +88,46 @@ const SALVAGE_RE = /\b(error|err!|fail(ed|ure|ing)?|exception|traceback|panic|fa
88
88
  const MAX_SALVAGED = 12; // error lines rescued from the elided middle
89
89
  const MAX_SALVAGE_LINE = 300; // per-line char cap on salvaged lines
90
90
 
91
+ function splitsSurrogatePair(text, index) {
92
+ if (index <= 0 || index >= text.length) return false;
93
+ const before = text.charCodeAt(index - 1);
94
+ const after = text.charCodeAt(index);
95
+ return before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF;
96
+ }
97
+
98
+ function safePrefix(text, maxChars) {
99
+ let end = Math.max(0, Math.min(text.length, maxChars));
100
+ if (splitsSurrogatePair(text, end)) end -= 1;
101
+ return text.slice(0, end);
102
+ }
103
+
104
+ function safeSuffix(text, maxChars) {
105
+ let start = Math.max(0, text.length - Math.max(0, maxChars));
106
+ if (splitsSurrogatePair(text, start)) start += 1;
107
+ return text.slice(start);
108
+ }
109
+
110
+ function safeSlice(text, start, end) {
111
+ let safeStart = Math.max(0, Math.min(text.length, start));
112
+ let safeEnd = Math.max(safeStart, Math.min(text.length, end));
113
+ if (splitsSurrogatePair(text, safeStart)) safeStart += 1;
114
+ if (splitsSurrogatePair(text, safeEnd)) safeEnd -= 1;
115
+ return text.slice(safeStart, Math.max(safeStart, safeEnd));
116
+ }
117
+
118
+ function salvageLine(line) {
119
+ const match = SALVAGE_RE.exec(line.replace(/error-like/gi, 'diagnostic'));
120
+ if (!match) return null;
121
+ if (line.length <= MAX_SALVAGE_LINE) return line;
122
+ const contentLimit = MAX_SALVAGE_LINE - 2;
123
+ const start = Math.max(0, Math.min(
124
+ match.index - Math.floor((contentLimit - match[0].length) / 2),
125
+ line.length - contentLimit,
126
+ ));
127
+ const end = start + contentLimit;
128
+ return (start > 0 ? '…' : '') + safeSlice(line, start, end) + (end < line.length ? '…' : '');
129
+ }
130
+
91
131
  function toolAllowed(name) {
92
132
  if (!name || typeof name !== 'string') return false;
93
133
  if (NEVER_COMPRESS.includes(name.toLowerCase())) return false;
@@ -107,28 +147,54 @@ function copilotToolAllowed(name) {
107
147
  return list.includes(name);
108
148
  }
109
149
 
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.
150
+ // OpenCode runtime tool names are lowercase (bash, grep, glob, webfetch, ...).
151
+ // MCP tools use `server_tool`, not Claude's mcp__ prefix; their names do not
152
+ // establish read-only safety, so they require explicit allowlisting.
112
153
  // read/edit/write/patch are excluded on purpose: their output feeds exact-match
113
154
  // 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'];
155
+ const SAFE_TOOLS_OPENCODE = ['bash', 'grep', 'glob', 'webfetch', 'websearch', 'list'];
156
+ const UNSAFE_TOOLS_OPENCODE = [
157
+ 'read', 'edit', 'write', 'patch', 'apply_patch', 'multiedit',
158
+ 'notebookedit', 'notebookread', 'notebook_write', 'notebook_edit',
159
+ 'create', 'str_replace', 'insert', 'todowrite', 'todoread',
160
+ ];
116
161
 
117
162
  function opencodeToolAllowed(name) {
118
163
  if (!name || typeof name !== 'string') return false;
119
164
  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).
165
+ // Source-bearing and mutating tools are never compressed, even via
166
+ // CHISLE_COMPRESS_TOOLS: their output feeds exact-match edits, an invariant
167
+ // INSTALL.md promises rather than an overridable default.
122
168
  if (UNSAFE_TOOLS_OPENCODE.includes(lower)) return false;
123
169
  if (process.env.CHISLE_COMPRESS_TOOLS) {
124
170
  return process.env.CHISLE_COMPRESS_TOOLS
125
171
  .split(',').map(s => s.trim()).filter(Boolean).includes(name);
126
172
  }
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('_');
173
+ return SAFE_TOOLS_OPENCODE.includes(lower);
174
+ }
175
+
176
+ function boundForOpencode(text, spillPath, maxChars) {
177
+ const outputLimit = maxChars || THRESHOLDS.maxChars;
178
+ if (text.length <= outputLimit) return text;
179
+ if (outputLimit < 7) return safePrefix(text, outputLimit);
180
+ const recover = spillPath ? ' Full output: ' + spillPath + ' (grep it, do not re-run)' : '';
181
+ let marker = '... [chisle: rebound compressed output to ' +
182
+ outputLimit.toLocaleString('en-US') + ' chars.' + recover + '] ...';
183
+ if (marker.length > outputLimit - 2) marker = safePrefix(marker, outputLimit - 7) + '] ...';
184
+ const middle = [marker];
185
+ // Keep head/tail context without excluding diagnostics under small bounds.
186
+ const contextReserve = Math.min(2050, Math.floor(outputLimit / 4));
187
+ for (const line of text.split('\n')) {
188
+ if (middle.length > MAX_SALVAGED) break;
189
+ const candidate = salvageLine(line);
190
+ if (!candidate) continue;
191
+ if (middle.join('\n').length + candidate.length + 1 > outputLimit - contextReserve) break;
192
+ if (!middle.includes(candidate)) middle.push(candidate);
193
+ }
194
+ const center = middle.join('\n');
195
+ const remaining = Math.max(0, outputLimit - center.length - 2);
196
+ const headChars = Math.ceil(remaining / 2);
197
+ return safePrefix(text, headChars) + '\n' + center + '\n' + safeSuffix(text, Math.floor(remaining / 2));
132
198
  }
133
199
 
134
200
  // One OpenCode tool_result → replacement string, or null to keep the original.
@@ -149,7 +215,19 @@ function compressForOpencode(toolName, text, opts) {
149
215
  if (!opencodeToolAllowed(toolName)) return null;
150
216
  if (typeof text !== 'string' || !text) return null;
151
217
  const stateDir = opts.stateDir || process.env.CHISLE_STATE_DIR || getOpencodeDir();
152
- return transform(text, limitsFor(), toolName, stateDir);
218
+ const limits = limitsFor();
219
+ const dup = dedupCheck(toolName, text, opts.sessionId, opts.callId, stateDir, true);
220
+ if (dup != null && dup.length < text.length) return boundForOpencode(dup, null, limits.maxChars);
221
+ if (text.length <= SCRUB_MIN && text.length <= limits.maxChars) return null;
222
+ const spillPath = text.length > limits.maxChars && toolName ? spill(text, toolName, stateDir) : null;
223
+ let updated = process.env.CHISLE_COMPRESS_SCRUB === '0' ? text : scrub(text);
224
+ if (updated.length > limits.maxChars && updated.split('\n').length > limits.headLines + limits.tailLines) {
225
+ const elided = compress(updated, limits, spillPath);
226
+ if (elided != null) updated = elided;
227
+ }
228
+ updated = boundForOpencode(updated, spillPath, limits.maxChars);
229
+ if (text.length > limits.maxChars) return updated;
230
+ return text.length - updated.length >= MIN_WIN ? updated : null;
153
231
  }
154
232
 
155
233
  // Copilot CLI's postToolUse payload is flat and camelCase:
@@ -240,26 +318,103 @@ function duplicateMarker(toolName, text, reference = 'previous') {
240
318
  const lines = text.split('\n');
241
319
  const prior = reference === 'previous' ? 'the previous' : 'an earlier';
242
320
  const preview = lines.slice(0, 5)
243
- .map(l => l.length > MAX_SALVAGE_LINE ? l.slice(0, MAX_SALVAGE_LINE) + '…' : l);
321
+ .map(l => l.length > MAX_SALVAGE_LINE ? safePrefix(l, MAX_SALVAGE_LINE - 1) + '…' : l);
244
322
  return '[chisle: output byte-identical to ' + prior + ' ' + toolName + ' result — ' +
245
323
  text.length.toLocaleString('en-US') + ' chars / ' + lines.length +
246
324
  ' lines, unchanged. First lines:]\n' + preview.join('\n');
247
325
  }
248
326
 
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) {
327
+ const DEDUP_SESSION_KEEP = 20;
328
+ const DEDUP_DIR = 'chisle-dedup';
329
+
330
+ function readDedupFile(p) {
331
+ let st;
332
+ try { st = fs.lstatSync(p); }
333
+ catch (e) { return { state: null, unsafe: e.code !== 'ENOENT' }; }
334
+ if (st.isSymbolicLink() || !st.isFile()) return { state: null, unsafe: true };
335
+ const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
336
+ let fd;
337
+ let raw;
338
+ try {
339
+ fd = fs.openSync(p, fs.constants.O_RDONLY | noFollow);
340
+ raw = fs.readFileSync(fd, 'utf8');
341
+ } catch (e) {
342
+ return { state: null, unsafe: true };
343
+ } finally {
344
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
345
+ }
346
+ try { return { state: JSON.parse(raw) || null, unsafe: false }; }
347
+ catch (e) { return { state: null, unsafe: false }; }
348
+ }
349
+
350
+ function dedupStateDir(stateDir) {
351
+ const dir = path.join(stateDir || getClaudeDir(), DEDUP_DIR);
352
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (e) { return null; }
353
+ try {
354
+ const st = fs.lstatSync(dir);
355
+ return st.isDirectory() && !st.isSymbolicLink() ? dir : null;
356
+ } catch (e) { return null; }
357
+ }
358
+
359
+ function writeDedupState(p, state) {
360
+ const tmp = p + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
361
+ const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
362
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow;
363
+ let fd;
364
+ try {
365
+ fd = fs.openSync(tmp, flags, 0o600);
366
+ fs.writeFileSync(fd, JSON.stringify(state));
367
+ try { fs.fchmodSync(fd, 0o600); } catch (e) {}
368
+ fs.closeSync(fd);
369
+ fd = undefined;
370
+ fs.renameSync(tmp, p);
371
+ } catch (e) {
372
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (closeError) {} }
373
+ try { fs.unlinkSync(tmp); } catch (unlinkError) {}
374
+ throw e;
375
+ }
376
+ }
377
+
378
+ function pruneDedupSessions(dir) {
379
+ try {
380
+ const files = fs.readdirSync(dir)
381
+ .filter(name => /^[a-f0-9]{64}\.json$/.test(name))
382
+ .map(name => {
383
+ const p = path.join(dir, name);
384
+ const st = fs.lstatSync(p);
385
+ return st.isFile() || st.isSymbolicLink() ? { p, t: st.mtimeMs } : null;
386
+ })
387
+ .filter(Boolean)
388
+ .sort((a, b) => b.t - a.t || a.p.localeCompare(b.p));
389
+ for (const file of files.slice(DEDUP_SESSION_KEEP)) {
390
+ try { fs.unlinkSync(file.p); } catch (e) {}
391
+ }
392
+ } catch (e) {}
393
+ }
394
+
395
+ // `stateDir` defaults to getClaudeDir() so existing Claude/Pi callers keep
396
+ // their prior storage path. Copilot passes getCopilotDir(); OpenCode passes
397
+ // getOpencodeDir() and opts into per-session files for interleaved sessions.
398
+ function dedupCheck(toolName, text, sessionId, toolUseId, stateDir, perSession) {
253
399
  if (process.env.CHISLE_COMPRESS_DEDUP === '0') return null;
254
400
  if (!sessionId || typeof sessionId !== 'string') return null;
255
401
  if (text.length < DEDUP_MIN) return null;
256
402
  try {
257
- const dir = stateDir || getClaudeDir();
258
- const p = path.join(dir, '.chisle-compress-last.json');
259
- try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch (e) { if (e.code !== 'ENOENT') return null; }
260
- let state = {};
261
- try { state = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; } catch (e) {}
262
- if (state.session !== sessionId) state = { session: sessionId, tools: {} };
403
+ const dir = perSession ? dedupStateDir(stateDir) : stateDir || getClaudeDir();
404
+ if (!dir) return null;
405
+ const p = path.join(dir, perSession
406
+ ? crypto.createHash('sha256').update(sessionId).digest('hex') + '.json'
407
+ : '.chisle-compress-last.json');
408
+ let state;
409
+ if (perSession) {
410
+ const loaded = readDedupFile(p);
411
+ if (loaded.unsafe) return null;
412
+ state = loaded.state;
413
+ } else {
414
+ try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch (e) { if (e.code !== 'ENOENT') return null; }
415
+ try { state = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; } catch (e) {}
416
+ }
417
+ if (!state || state.session !== sessionId) state = { session: sessionId, tools: {} };
263
418
  if (!state.tools || typeof state.tools !== 'object') state.tools = {};
264
419
  const hash = crypto.createHash('sha256').update(text).digest('hex');
265
420
  // A second hook invocation for the SAME tool call is not the model seeing
@@ -274,10 +429,15 @@ function dedupCheck(toolName, text, sessionId, toolUseId, stateDir) {
274
429
  const sameCall = !!(toolUseId && rec.id && rec.id === toolUseId);
275
430
  const dup = rec.hash === hash && !sameCall;
276
431
  state.tools[toolName] = { hash, id: toolUseId || rec.id || null };
277
- fs.mkdirSync(dir, { recursive: true });
278
- const tmp = p + '.' + process.pid + '.tmp';
279
- fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
280
- fs.renameSync(tmp, p);
432
+ if (perSession) {
433
+ writeDedupState(p, state);
434
+ pruneDedupSessions(dir);
435
+ } else {
436
+ fs.mkdirSync(dir, { recursive: true });
437
+ const tmp = p + '.' + process.pid + '.tmp';
438
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
439
+ fs.renameSync(tmp, p);
440
+ }
281
441
  return dup ? duplicateMarker(toolName, text) : null;
282
442
  } catch (e) { return null; }
283
443
  }
@@ -336,9 +496,9 @@ function compress(text, limits, spillPath) {
336
496
  const keep = Math.floor(maxChars / 2);
337
497
  const elided = text.length - 2 * keep;
338
498
  if (elided <= 0) return null;
339
- return text.slice(0, keep) +
499
+ return safePrefix(text, keep) +
340
500
  '\n... [chisle: elided ' + elided.toLocaleString('en-US') + ' chars from the middle.' + recover + '] ...\n' +
341
- text.slice(-keep);
501
+ safeSuffix(text, keep);
342
502
  }
343
503
 
344
504
  const head = lines.slice(0, headLines);
@@ -348,7 +508,8 @@ function compress(text, limits, spillPath) {
348
508
  const salvaged = [];
349
509
  for (const line of middle) {
350
510
  if (salvaged.length >= MAX_SALVAGED) break;
351
- if (SALVAGE_RE.test(line)) salvaged.push(line.length > MAX_SALVAGE_LINE ? line.slice(0, MAX_SALVAGE_LINE) + '…' : line);
511
+ const candidate = salvageLine(line);
512
+ if (candidate) salvaged.push(candidate);
352
513
  }
353
514
 
354
515
  const marker = '... [chisle: elided ' + middle.length.toLocaleString('en-US') +
@@ -513,5 +674,6 @@ module.exports = {
513
674
  isCopilotPayload, copilotToolAllowed, SAFE_TOOLS_COPILOT, NEVER_COMPRESS_COPILOT,
514
675
  // OpenCode-specific additions (plugin uses tool.execute.after +
515
676
  // experimental.chat.messages.transform):
516
- opencodeToolAllowed, compressForOpencode, SAFE_TOOLS_OPENCODE,
677
+ opencodeToolAllowed, compressForOpencode, SAFE_TOOLS_OPENCODE, UNSAFE_TOOLS_OPENCODE,
678
+ boundForOpencode, recordSavings,
517
679
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chisle",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
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",