pi-mega-compact 0.21.10 → 0.21.11

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,5 +1,60 @@
1
- import { estimateBlockTokens, estimateMessageTokens } from "../../../src/tokens.js";
1
+ import { estimateBlockTokens } from "../../../src/tokens.js";
2
2
  import { messageContentText } from "./messageText.js";
3
+ /**
4
+ * Full-surface AgentMessage token estimate for BUDGET arithmetic (tail cap).
5
+ *
6
+ * convertToLlm (pi dist/core/messages.js) ships assistant/toolResult messages
7
+ * VERBATIM — every content block goes over the wire: text, thinking, toolCall
8
+ * (name + full `arguments` JSON), toolResult output, role wrappers. The text
9
+ * extractor (messageContentText) is lossy-on-purpose for analytics, and using
10
+ * it here made a GLM-4.7-style assistant message with ~11.6k bytes of toolCall
11
+ * arguments register as ~77 tokens — a 30k-token tail passed an 11.9k budget,
12
+ * the model overflowed, and pi's one-shot compact-and-retry failed
13
+ * ("Context overflow recovery failed", 2026-08-20 incident).
14
+ *
15
+ * Counts every byte the provider actually receives. Still a heuristic (len/4
16
+ * + 1 per block, like estimateBlockTokens) — just no longer lossy. Never
17
+ * throws: unknown block shapes fall back to their JSON serialization length,
18
+ * and a non-array/string content is counted as its serialization.
19
+ */
20
+ export function estimateAgentMessageBudgetTokens(m) {
21
+ try {
22
+ const c = m.content;
23
+ let bytes = 0;
24
+ if (typeof c === "string") {
25
+ bytes += c.length;
26
+ }
27
+ else if (Array.isArray(c)) {
28
+ for (const b of c) {
29
+ if (b == null || typeof b !== "object")
30
+ continue;
31
+ const o = b;
32
+ if (typeof o.text === "string")
33
+ bytes += o.text.length;
34
+ if (typeof o.thinking === "string")
35
+ bytes += o.thinking.length;
36
+ if (typeof o.name === "string")
37
+ bytes += o.name.length;
38
+ if (o.arguments != null)
39
+ bytes += JSON.stringify(o.arguments).length;
40
+ if (typeof o.output === "string")
41
+ bytes += o.output.length;
42
+ // Per-block envelope overhead (role/type markers), matching the
43
+ // len/4+1 block accounting in estimateBlockTokens.
44
+ bytes += 4;
45
+ }
46
+ }
47
+ else if (c != null) {
48
+ bytes += JSON.stringify(c).length;
49
+ }
50
+ return estimateBlockTokens(" ".repeat(Math.max(0, bytes)));
51
+ }
52
+ catch {
53
+ // non-fatal: fall back to the legacy text-only estimate rather than
54
+ // disable the cap on a pathological message.
55
+ return estimateBlockTokens(messageContentText(m));
56
+ }
57
+ }
3
58
  /**
4
59
  * The model's declared maxTokens is only trusted as the output budget when it
5
60
  * is plausible. models.json carries sentinel junk for some entries (1e9,
@@ -91,7 +146,7 @@ export function applyTailCap(opts) {
91
146
  tailTokens +=
92
147
  msgTokens != null
93
148
  ? Math.max(0, msgTokens[i])
94
- : estimateMessageTokens({ text: messageContentText(recentRaw[i]) });
149
+ : estimateAgentMessageBudgetTokens(recentRaw[i]);
95
150
  if (tailTokens > budget) {
96
151
  // Keep from i+1 onward; never drop below the FINAL message.
97
152
  start = Math.min(i + 1, recentRaw.length - 1);
@@ -119,7 +174,7 @@ export function applyTailCap(opts) {
119
174
  export function recapReplayedTail(opts) {
120
175
  return applyTailCap({
121
176
  recentRaw: opts.recentRaw,
122
- summaryTokens: estimateBlockTokens(messageContentText(opts.summaryAgentMsg)),
177
+ summaryTokens: estimateAgentMessageBudgetTokens(opts.summaryAgentMsg),
123
178
  ctxWindow: opts.ctxWindow,
124
179
  maxOutputTokens: opts.maxOutputTokens,
125
180
  outputReservePct: opts.outputReservePct,
@@ -16,9 +16,56 @@
16
16
  * Pure functions, no runtime dependency — trivially unit-testable headlessly.
17
17
  */
18
18
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
- import { estimateBlockTokens, estimateMessageTokens } from "../../../src/tokens.js";
19
+ import { estimateBlockTokens } from "../../../src/tokens.js";
20
20
  import { messageContentText } from "./messageText.js";
21
21
 
22
+ /**
23
+ * Full-surface AgentMessage token estimate for BUDGET arithmetic (tail cap).
24
+ *
25
+ * convertToLlm (pi dist/core/messages.js) ships assistant/toolResult messages
26
+ * VERBATIM — every content block goes over the wire: text, thinking, toolCall
27
+ * (name + full `arguments` JSON), toolResult output, role wrappers. The text
28
+ * extractor (messageContentText) is lossy-on-purpose for analytics, and using
29
+ * it here made a GLM-4.7-style assistant message with ~11.6k bytes of toolCall
30
+ * arguments register as ~77 tokens — a 30k-token tail passed an 11.9k budget,
31
+ * the model overflowed, and pi's one-shot compact-and-retry failed
32
+ * ("Context overflow recovery failed", 2026-08-20 incident).
33
+ *
34
+ * Counts every byte the provider actually receives. Still a heuristic (len/4
35
+ * + 1 per block, like estimateBlockTokens) — just no longer lossy. Never
36
+ * throws: unknown block shapes fall back to their JSON serialization length,
37
+ * and a non-array/string content is counted as its serialization.
38
+ */
39
+ export function estimateAgentMessageBudgetTokens(m: AgentMessage): number {
40
+ try {
41
+ const c = (m as { content?: unknown }).content;
42
+ let bytes = 0;
43
+ if (typeof c === "string") {
44
+ bytes += c.length;
45
+ } else if (Array.isArray(c)) {
46
+ for (const b of c) {
47
+ if (b == null || typeof b !== "object") continue;
48
+ const o = b as Record<string, unknown>;
49
+ if (typeof o.text === "string") bytes += o.text.length;
50
+ if (typeof o.thinking === "string") bytes += o.thinking.length;
51
+ if (typeof o.name === "string") bytes += o.name.length;
52
+ if (o.arguments != null) bytes += JSON.stringify(o.arguments).length;
53
+ if (typeof o.output === "string") bytes += o.output.length;
54
+ // Per-block envelope overhead (role/type markers), matching the
55
+ // len/4+1 block accounting in estimateBlockTokens.
56
+ bytes += 4;
57
+ }
58
+ } else if (c != null) {
59
+ bytes += JSON.stringify(c).length;
60
+ }
61
+ return estimateBlockTokens(" ".repeat(Math.max(0, bytes)));
62
+ } catch {
63
+ // non-fatal: fall back to the legacy text-only estimate rather than
64
+ // disable the cap on a pathological message.
65
+ return estimateBlockTokens(messageContentText(m));
66
+ }
67
+ }
68
+
22
69
  /**
23
70
  * The model's declared maxTokens is only trusted as the output budget when it
24
71
  * is plausible. models.json carries sentinel junk for some entries (1e9,
@@ -143,7 +190,7 @@ export function applyTailCap(opts: {
143
190
  tailTokens +=
144
191
  msgTokens != null
145
192
  ? Math.max(0, msgTokens[i])
146
- : estimateMessageTokens({ text: messageContentText(recentRaw[i]) });
193
+ : estimateAgentMessageBudgetTokens(recentRaw[i]);
147
194
  if (tailTokens > budget) {
148
195
  // Keep from i+1 onward; never drop below the FINAL message.
149
196
  start = Math.min(i + 1, recentRaw.length - 1);
@@ -181,7 +228,7 @@ export function recapReplayedTail(opts: {
181
228
  }): { recent: AgentMessage[]; dropped: number } {
182
229
  return applyTailCap({
183
230
  recentRaw: opts.recentRaw,
184
- summaryTokens: estimateBlockTokens(messageContentText(opts.summaryAgentMsg)),
231
+ summaryTokens: estimateAgentMessageBudgetTokens(opts.summaryAgentMsg),
185
232
  ctxWindow: opts.ctxWindow,
186
233
  maxOutputTokens: opts.maxOutputTokens,
187
234
  outputReservePct: opts.outputReservePct,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.21.10",
3
+ "version": "0.21.11",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",