opencode-anthropic-fix 0.1.20 → 0.1.21

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/index.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from "./lib/backoff.mjs";
22
22
  import { callHaiku } from "./lib/haiku-call.mjs";
23
23
  import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
24
+ import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Account management CLI prompts
@@ -4058,6 +4059,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4058
4059
  },
4059
4060
  ],
4060
4061
  },
4062
+ /**
4063
+ * Stateless message-list transforms. Previously fork-only patches
4064
+ * (4c3f4fc19 stale-read eviction, 797ae24d8 per-tool-class prune)
4065
+ * now live here and apply on the cloned request messages. Hook
4066
+ * input is `{}` — no sessionID, so these are global policies.
4067
+ */
4068
+ "experimental.chat.messages.transform": async (_input, output) => {
4069
+ const strategies = config?.token_economy_strategies;
4070
+ if (!strategies) return;
4071
+ if (!output?.messages) return;
4072
+
4073
+ if (strategies.stale_read_eviction) {
4074
+ staleReadEviction({ messages: output.messages });
4075
+ }
4076
+ if (strategies.per_tool_class_prune) {
4077
+ perToolClassPrune({ messages: output.messages });
4078
+ }
4079
+ },
4061
4080
  "experimental.session.compacting": async (input, output) => {
4062
4081
  // Reset adaptive context state on session compaction (new conversation boundary).
4063
4082
  // This prevents sticky escalation from leaking across conversations.
package/lib/config.mjs CHANGED
@@ -311,6 +311,17 @@ export const DEFAULT_CONFIG = {
311
311
  * summary, bypassing the main model. Requires opencode fork support
312
312
  * — no-op without it. Off by default. */
313
313
  haiku_rolling_summary: false,
314
+ /** Stale-read eviction: replace `read`/`view` tool outputs from
315
+ * messages older than N turns with a placeholder. Runs on every
316
+ * request via `experimental.chat.messages.transform`. Saves
317
+ * ~1-2KB per old read × conversation depth. Off by default. */
318
+ stale_read_eviction: false,
319
+ /** Per-tool-class prune thresholds: apply different token budgets
320
+ * to reproducible (read/grep/glob/ls) vs stateful (bash/edit/write)
321
+ * tool outputs during request assembly. Reproducible outputs can
322
+ * be re-run on demand, so they prune at a lower threshold. Off by
323
+ * default — when ON, uses 10_000/40_000 token budgets. */
324
+ per_tool_class_prune: false,
314
325
  },
315
326
  /** Output cap: default max_tokens to save context window. */
316
327
  output_cap: {
@@ -791,6 +802,14 @@ function validateConfig(raw) {
791
802
  typeof tes.haiku_rolling_summary === "boolean"
792
803
  ? tes.haiku_rolling_summary
793
804
  : DEFAULT_CONFIG.token_economy_strategies.haiku_rolling_summary,
805
+ stale_read_eviction:
806
+ typeof tes.stale_read_eviction === "boolean"
807
+ ? tes.stale_read_eviction
808
+ : DEFAULT_CONFIG.token_economy_strategies.stale_read_eviction,
809
+ per_tool_class_prune:
810
+ typeof tes.per_tool_class_prune === "boolean"
811
+ ? tes.per_tool_class_prune
812
+ : DEFAULT_CONFIG.token_economy_strategies.per_tool_class_prune,
794
813
  };
795
814
  }
796
815
 
@@ -410,3 +410,51 @@ describe("token_economy_strategies.haiku_rolling_summary", () => {
410
410
  expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(false);
411
411
  });
412
412
  });
413
+
414
+ describe("token_economy_strategies.stale_read_eviction", () => {
415
+ beforeEach(() => {
416
+ vi.resetAllMocks();
417
+ });
418
+
419
+ it("defaults to false in DEFAULT_CONFIG", () => {
420
+ expect(DEFAULT_CONFIG.token_economy_strategies.stale_read_eviction).toBe(false);
421
+ });
422
+
423
+ it("accepts true when explicitly set", () => {
424
+ existsSync.mockReturnValue(true);
425
+ readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { stale_read_eviction: true } }));
426
+ const cfg = loadConfig();
427
+ expect(cfg.token_economy_strategies.stale_read_eviction).toBe(true);
428
+ });
429
+
430
+ it("falls back to default on non-boolean value", () => {
431
+ existsSync.mockReturnValue(true);
432
+ readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { stale_read_eviction: 1 } }));
433
+ const cfg = loadConfig();
434
+ expect(cfg.token_economy_strategies.stale_read_eviction).toBe(false);
435
+ });
436
+ });
437
+
438
+ describe("token_economy_strategies.per_tool_class_prune", () => {
439
+ beforeEach(() => {
440
+ vi.resetAllMocks();
441
+ });
442
+
443
+ it("defaults to false in DEFAULT_CONFIG", () => {
444
+ expect(DEFAULT_CONFIG.token_economy_strategies.per_tool_class_prune).toBe(false);
445
+ });
446
+
447
+ it("accepts true when explicitly set", () => {
448
+ existsSync.mockReturnValue(true);
449
+ readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { per_tool_class_prune: true } }));
450
+ const cfg = loadConfig();
451
+ expect(cfg.token_economy_strategies.per_tool_class_prune).toBe(true);
452
+ });
453
+
454
+ it("falls back to default on non-boolean value", () => {
455
+ existsSync.mockReturnValue(true);
456
+ readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { per_tool_class_prune: "on" } }));
457
+ const cfg = loadConfig();
458
+ expect(cfg.token_economy_strategies.per_tool_class_prune).toBe(false);
459
+ });
460
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Stateless message-list transforms applied via
3
+ * `experimental.chat.messages.transform` hook. The hook input is empty
4
+ * (`{}` — no sessionID, no agent role), so these policies are global to
5
+ * all sessions when enabled.
6
+ *
7
+ * The `messages` array passed to the hook is a `structuredClone` of the
8
+ * session state, so mutation is safe and ONLY affects the outbound
9
+ * request — storage is untouched.
10
+ */
11
+
12
+ /** Tool names treated as read-class (content can be re-fetched). */
13
+ export const STALE_READ_TOOLS = new Set(["read", "view"]);
14
+
15
+ /** Tool names trivially re-runnable (idempotent + cheap). */
16
+ export const REPRODUCIBLE_TOOLS = new Set(["read", "grep", "glob", "ls", "list", "find"]);
17
+
18
+ /** Tools never pruned (provider-critical, e.g. skill catalog). */
19
+ const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
20
+
21
+ /** Replacement text for stale read outputs. */
22
+ const STALE_READ_PLACEHOLDER = "[File was read earlier in this session — re-read if you need the current contents]";
23
+
24
+ /** Rough char-to-token ratio — same heuristic opencode uses for estimates. */
25
+ function estimateTokens(text) {
26
+ if (typeof text !== "string" || text.length === 0) return 0;
27
+ return Math.ceil(text.length / 4);
28
+ }
29
+
30
+ /**
31
+ * Replace read/view tool outputs from messages older than `threshold`
32
+ * messages-from-end with a placeholder note, and drop their attachments.
33
+ *
34
+ * Stateless. Does NOT mutate storage. Only affects the outbound request.
35
+ *
36
+ * @param {{messages: Array<{info: any, parts: any[]}>, threshold?: number, tools?: Set<string>}} args
37
+ * @returns {{evicted: number}} — how many tool parts were rewritten
38
+ */
39
+ export function staleReadEviction({ messages, threshold = 10, tools = STALE_READ_TOOLS }) {
40
+ if (!Array.isArray(messages) || messages.length <= threshold) {
41
+ return { evicted: 0 };
42
+ }
43
+
44
+ const cutoff = messages.length - threshold;
45
+ let evicted = 0;
46
+
47
+ for (let i = 0; i < cutoff; i++) {
48
+ const msg = messages[i];
49
+ if (!msg?.parts) continue;
50
+
51
+ for (const part of msg.parts) {
52
+ if (part?.type !== "tool") continue;
53
+ if (part.state?.status !== "completed") continue;
54
+ if (part.state.time?.compacted) continue;
55
+ if (!tools.has(part.tool)) continue;
56
+
57
+ part.state.output = STALE_READ_PLACEHOLDER;
58
+ if (part.state.attachments) {
59
+ part.state.attachments = [];
60
+ }
61
+ evicted++;
62
+ }
63
+ }
64
+
65
+ return { evicted };
66
+ }
67
+
68
+ /**
69
+ * Prune older tool outputs by class, keeping the most recent N tokens
70
+ * worth per class (reproducible vs stateful). Walks backward from the
71
+ * end; once a class's running total exceeds its threshold, older outputs
72
+ * of that class are replaced with empty strings.
73
+ *
74
+ * Stateless. Mirrors the core `compaction.prune()` logic but applies at
75
+ * request-assembly time instead of persisting to storage.
76
+ *
77
+ * @param {{messages: Array<{info: any, parts: any[]}>, reproducibleThreshold?: number, statefulThreshold?: number, reproducibleTools?: Set<string>}} args
78
+ * @returns {{pruned: number, tokensSaved: number}}
79
+ */
80
+ export function perToolClassPrune({
81
+ messages,
82
+ reproducibleThreshold = 10_000,
83
+ statefulThreshold = 40_000,
84
+ reproducibleTools = REPRODUCIBLE_TOOLS,
85
+ }) {
86
+ if (!Array.isArray(messages) || messages.length === 0) {
87
+ return { pruned: 0, tokensSaved: 0 };
88
+ }
89
+
90
+ let totalReproducible = 0;
91
+ let totalStateful = 0;
92
+ let pruned = 0;
93
+ let tokensSaved = 0;
94
+
95
+ // Walk backward through messages; within each message, also backward
96
+ // through parts — matches the original prune() ordering.
97
+ outer: for (let i = messages.length - 1; i >= 0; i--) {
98
+ const msg = messages[i];
99
+ if (!msg?.parts?.length) continue;
100
+
101
+ for (let j = msg.parts.length - 1; j >= 0; j--) {
102
+ const part = msg.parts[j];
103
+ if (part?.type !== "tool") continue;
104
+ if (part.state?.status !== "completed") continue;
105
+ if (PRUNE_PROTECTED_TOOLS.has(part.tool)) continue;
106
+ if (part.state.time?.compacted) break outer;
107
+
108
+ const estimate = estimateTokens(part.state.output);
109
+ const isReproducible = reproducibleTools.has(part.tool.toLowerCase());
110
+
111
+ if (isReproducible) {
112
+ totalReproducible += estimate;
113
+ if (totalReproducible > reproducibleThreshold) {
114
+ part.state.output = "";
115
+ if (part.state.attachments) part.state.attachments = [];
116
+ pruned++;
117
+ tokensSaved += estimate;
118
+ }
119
+ } else {
120
+ totalStateful += estimate;
121
+ if (totalStateful > statefulThreshold) {
122
+ part.state.output = "";
123
+ if (part.state.attachments) part.state.attachments = [];
124
+ pruned++;
125
+ tokensSaved += estimate;
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ return { pruned, tokensSaved };
132
+ }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { staleReadEviction, perToolClassPrune, STALE_READ_TOOLS, REPRODUCIBLE_TOOLS } from "./message-transform.mjs";
3
+
4
+ /** Build a message with a single tool part. */
5
+ function toolMsg(tool, output, { compacted = false, attachments } = {}) {
6
+ return {
7
+ info: { role: "assistant", id: `msg-${Math.random()}` },
8
+ parts: [
9
+ {
10
+ type: "tool",
11
+ callID: "c1",
12
+ tool,
13
+ state: {
14
+ status: "completed",
15
+ input: {},
16
+ output,
17
+ title: "t",
18
+ metadata: {},
19
+ time: { start: 0, end: 1, ...(compacted ? { compacted: 1 } : {}) },
20
+ ...(attachments ? { attachments } : {}),
21
+ },
22
+ },
23
+ ],
24
+ };
25
+ }
26
+
27
+ describe("staleReadEviction", () => {
28
+ it("is a no-op when messages <= threshold", () => {
29
+ const msgs = [toolMsg("read", "content")];
30
+ const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
31
+ expect(evicted).toBe(0);
32
+ expect(msgs[0].parts[0].state.output).toBe("content");
33
+ });
34
+
35
+ it("replaces read output older than threshold with placeholder", () => {
36
+ const msgs = Array.from({ length: 15 }, (_, i) => toolMsg("read", `file ${i} content`));
37
+ const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
38
+ expect(evicted).toBe(5);
39
+ expect(msgs[0].parts[0].state.output).toMatch(/re-read if you need/);
40
+ expect(msgs[4].parts[0].state.output).toMatch(/re-read if you need/);
41
+ expect(msgs[5].parts[0].state.output).toBe("file 5 content");
42
+ expect(msgs[14].parts[0].state.output).toBe("file 14 content");
43
+ });
44
+
45
+ it("leaves non-read tools untouched", () => {
46
+ const msgs = Array.from({ length: 15 }, (_, i) => toolMsg("bash", `cmd ${i} output`));
47
+ const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
48
+ expect(evicted).toBe(0);
49
+ expect(msgs[0].parts[0].state.output).toBe("cmd 0 output");
50
+ });
51
+
52
+ it("skips already-compacted tool parts", () => {
53
+ const msgs = [
54
+ toolMsg("read", "stale but compacted", { compacted: true }),
55
+ ...Array.from({ length: 11 }, (_, i) => toolMsg("read", `x${i}`)),
56
+ ];
57
+ const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
58
+ expect(evicted).toBe(1);
59
+ expect(msgs[0].parts[0].state.output).toBe("stale but compacted");
60
+ expect(msgs[1].parts[0].state.output).toMatch(/re-read if you need/);
61
+ });
62
+
63
+ it("strips attachments when evicting", () => {
64
+ const msgs = Array.from({ length: 15 }, (_, i) =>
65
+ toolMsg("read", `file ${i}`, {
66
+ attachments: [{ type: "file", mime: "image/png", url: "x" }],
67
+ }),
68
+ );
69
+ staleReadEviction({ messages: msgs, threshold: 10 });
70
+ expect(msgs[0].parts[0].state.attachments).toEqual([]);
71
+ expect(msgs[14].parts[0].state.attachments).toHaveLength(1);
72
+ });
73
+
74
+ it("accepts `view` as a read-class tool", () => {
75
+ expect(STALE_READ_TOOLS.has("view")).toBe(true);
76
+ const msgs = Array.from({ length: 15 }, (_, i) => toolMsg("view", `content ${i}`));
77
+ const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
78
+ expect(evicted).toBe(5);
79
+ });
80
+
81
+ it("handles empty/missing messages gracefully", () => {
82
+ expect(staleReadEviction({ messages: [] }).evicted).toBe(0);
83
+ expect(staleReadEviction({ messages: null }).evicted).toBe(0);
84
+ });
85
+ });
86
+
87
+ describe("perToolClassPrune", () => {
88
+ it("prunes old reproducible outputs over the 10k threshold", () => {
89
+ const output = "x".repeat(30_000); // ~7_500 tokens (chars/4)
90
+ const msgs = Array.from({ length: 3 }, () => toolMsg("read", output));
91
+ const { pruned, tokensSaved } = perToolClassPrune({
92
+ messages: msgs,
93
+ reproducibleThreshold: 10_000,
94
+ });
95
+ // Walk backward: newest (7_500) fits under 10k, older 2 push total over → pruned.
96
+ expect(pruned).toBe(2);
97
+ expect(tokensSaved).toBeGreaterThan(10_000);
98
+ expect(msgs[2].parts[0].state.output).toBe(output); // newest kept
99
+ expect(msgs[0].parts[0].state.output).toBe("");
100
+ expect(msgs[1].parts[0].state.output).toBe("");
101
+ });
102
+
103
+ it("uses higher threshold for stateful tools (bash)", () => {
104
+ const mid = "x".repeat(50_000); // ~12_500 tokens
105
+ const msgs = Array.from({ length: 3 }, () => toolMsg("bash", mid));
106
+ const { pruned } = perToolClassPrune({
107
+ messages: msgs,
108
+ statefulThreshold: 40_000,
109
+ });
110
+ // 3 × 12_500 = 37_500 < 40_000 → nothing pruned
111
+ expect(pruned).toBe(0);
112
+ });
113
+
114
+ it("never prunes protected tools (skill)", () => {
115
+ const big = "x".repeat(500_000); // ~125_000 tokens — would bust any threshold
116
+ const msgs = Array.from({ length: 3 }, () => toolMsg("skill", big));
117
+ const { pruned } = perToolClassPrune({ messages: msgs });
118
+ expect(pruned).toBe(0);
119
+ expect(msgs[0].parts[0].state.output).toBe(big);
120
+ });
121
+
122
+ it("stops walking back when it hits a compacted part", () => {
123
+ const big = "x".repeat(50_000);
124
+ const msgs = [
125
+ toolMsg("read", "must-not-prune-old", { compacted: true }),
126
+ ...Array.from({ length: 3 }, () => toolMsg("read", big)),
127
+ ];
128
+ perToolClassPrune({ messages: msgs, reproducibleThreshold: 10_000 });
129
+ // Compacted sentinel preserved; walk terminates before reaching it.
130
+ expect(msgs[0].parts[0].state.output).toBe("must-not-prune-old");
131
+ });
132
+
133
+ it("tracks reproducible and stateful budgets independently", () => {
134
+ const output = "x".repeat(30_000); // ~7_500 tokens each
135
+ // interleave: read, bash, read, bash, read, bash (oldest to newest)
136
+ const msgs = [
137
+ toolMsg("read", output),
138
+ toolMsg("bash", output),
139
+ toolMsg("read", output),
140
+ toolMsg("bash", output),
141
+ toolMsg("read", output),
142
+ toolMsg("bash", output),
143
+ ];
144
+ const { pruned } = perToolClassPrune({
145
+ messages: msgs,
146
+ reproducibleThreshold: 10_000,
147
+ statefulThreshold: 40_000,
148
+ });
149
+ // reads: 7_500, 15_000 → over → prune; 22_500 → prune. 2 old reads pruned.
150
+ // bash: 7_500, 15_000, 22_500 → all under 40k → 0 pruned.
151
+ expect(pruned).toBe(2);
152
+ expect(msgs[4].parts[0].state.output).toBe(output); // newest read kept
153
+ expect(msgs[5].parts[0].state.output).toBe(output); // newest bash kept
154
+ expect(msgs[0].parts[0].state.output).toBe(""); // oldest read pruned
155
+ expect(msgs[2].parts[0].state.output).toBe(""); // middle read pruned
156
+ });
157
+
158
+ it("normalizes tool name case when classifying", () => {
159
+ expect(REPRODUCIBLE_TOOLS.has("read")).toBe(true);
160
+ const output = "x".repeat(30_000); // ~7_500 tokens
161
+ const msgs = Array.from({ length: 3 }, () => toolMsg("READ", output));
162
+ const { pruned } = perToolClassPrune({
163
+ messages: msgs,
164
+ reproducibleThreshold: 10_000,
165
+ });
166
+ expect(pruned).toBe(2);
167
+ });
168
+
169
+ it("handles empty/missing messages gracefully", () => {
170
+ expect(perToolClassPrune({ messages: [] }).pruned).toBe(0);
171
+ expect(perToolClassPrune({ messages: null }).pruned).toBe(0);
172
+ });
173
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [