pi-ui-extend 1.0.42 → 1.0.45

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.
Files changed (233) hide show
  1. package/README.md +41 -12
  2. package/dist/app/app.d.ts +10 -0
  3. package/dist/app/app.js +132 -8
  4. package/dist/app/cli/install.js +2 -2
  5. package/dist/app/commands/command-controller.d.ts +4 -0
  6. package/dist/app/commands/command-controller.js +11 -0
  7. package/dist/app/commands/command-git-actions.d.ts +25 -0
  8. package/dist/app/commands/command-git-actions.js +381 -0
  9. package/dist/app/commands/command-host.d.ts +3 -0
  10. package/dist/app/commands/command-host.js +5 -2
  11. package/dist/app/commands/command-model-actions.d.ts +1 -0
  12. package/dist/app/commands/command-model-actions.js +51 -34
  13. package/dist/app/commands/command-navigation-actions.d.ts +1 -0
  14. package/dist/app/commands/command-navigation-actions.js +16 -3
  15. package/dist/app/commands/command-registry.d.ts +2 -0
  16. package/dist/app/commands/command-registry.js +14 -0
  17. package/dist/app/commands/reload-context-inventory.js +12 -1
  18. package/dist/app/input/autocomplete-controller.js +61 -34
  19. package/dist/app/input/input-action-controller.d.ts +2 -0
  20. package/dist/app/input/input-action-controller.js +8 -1
  21. package/dist/app/input/input-controller.d.ts +2 -1
  22. package/dist/app/input/input-controller.js +7 -2
  23. package/dist/app/input/prompt-enhancer-controller.js +53 -35
  24. package/dist/app/input/voice-controller.d.ts +41 -45
  25. package/dist/app/input/voice-controller.js +351 -419
  26. package/dist/app/model/model-usage-controller.js +17 -7
  27. package/dist/app/model/model-usage-status.d.ts +4 -1
  28. package/dist/app/model/model-usage-status.js +270 -46
  29. package/dist/app/popup/menu-items-controller.d.ts +13 -3
  30. package/dist/app/popup/menu-items-controller.js +37 -21
  31. package/dist/app/popup/popup-action-controller.d.ts +12 -2
  32. package/dist/app/popup/popup-action-controller.js +77 -24
  33. package/dist/app/popup/popup-menu-controller.d.ts +36 -14
  34. package/dist/app/popup/popup-menu-controller.js +239 -69
  35. package/dist/app/rendering/dcp-stats.d.ts +9 -4
  36. package/dist/app/rendering/dcp-stats.js +40 -425
  37. package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
  38. package/dist/app/rendering/popup-menu-renderer.js +42 -37
  39. package/dist/app/rendering/render-controller.js +23 -2
  40. package/dist/app/rendering/status-line-renderer.d.ts +5 -0
  41. package/dist/app/rendering/status-line-renderer.js +41 -5
  42. package/dist/app/rendering/tab-line-renderer.js +26 -20
  43. package/dist/app/runtime.d.ts +12 -1
  44. package/dist/app/runtime.js +120 -12
  45. package/dist/app/screen/mouse-controller.d.ts +2 -0
  46. package/dist/app/screen/mouse-controller.js +17 -7
  47. package/dist/app/screen/status-controller.d.ts +4 -0
  48. package/dist/app/screen/status-controller.js +5 -0
  49. package/dist/app/session/lazy-session-manager.js +34 -0
  50. package/dist/app/session/session-event-controller.d.ts +1 -0
  51. package/dist/app/session/session-event-controller.js +10 -1
  52. package/dist/app/session/session-history.d.ts +1 -0
  53. package/dist/app/session/session-history.js +12 -1
  54. package/dist/app/session/session-lifecycle-controller.d.ts +6 -1
  55. package/dist/app/session/session-lifecycle-controller.js +10 -1
  56. package/dist/app/session/tabs-controller.d.ts +25 -1
  57. package/dist/app/session/tabs-controller.js +442 -153
  58. package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
  59. package/dist/app/subagents/subagents-widget-controller.js +6 -0
  60. package/dist/app/types.d.ts +11 -1
  61. package/dist/app/workspace/workspace-actions-controller.js +10 -2
  62. package/dist/app/workspace/workspace-undo.d.ts +1 -0
  63. package/dist/app/workspace/workspace-undo.js +1 -0
  64. package/dist/bundled-extensions/question/index.js +9 -1
  65. package/dist/bundled-extensions/question/remote.d.ts +4 -0
  66. package/dist/bundled-extensions/question/remote.js +33 -0
  67. package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
  68. package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
  69. package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
  70. package/dist/bundled-extensions/telegram-connector/config.js +87 -0
  71. package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
  72. package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
  73. package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
  74. package/dist/bundled-extensions/telegram-connector/index.js +195 -0
  75. package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
  76. package/dist/bundled-extensions/terminal-bell/index.js +0 -76
  77. package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
  78. package/dist/bundled-extensions/workspace-undo/index.js +191 -0
  79. package/dist/config.d.ts +21 -2
  80. package/dist/config.js +159 -32
  81. package/dist/default-pix-config.js +20 -10
  82. package/dist/schemas/pi-tools-suite-schema.d.ts +8 -4
  83. package/dist/schemas/pi-tools-suite-schema.js +11 -5
  84. package/dist/schemas/pix-schema.d.ts +12 -2
  85. package/dist/schemas/pix-schema.js +37 -9
  86. package/dist/schemas/tasks-schema.js +1 -1
  87. package/docs/concurrency.md +3 -1
  88. package/docs/desktop-mvp.md +10 -5
  89. package/docs/desktop-task-manager.md +81 -28
  90. package/docs/release.md +30 -6
  91. package/external/pi-tools-suite/README.md +165 -39
  92. package/external/pi-tools-suite/docs/session-recovery.md +7 -0
  93. package/external/pi-tools-suite/docs/subagent-model-pools.md +12 -5
  94. package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
  95. package/external/pi-tools-suite/package.json +6 -1
  96. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
  97. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
  98. package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
  99. package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
  100. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
  101. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
  102. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
  103. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
  104. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
  105. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
  106. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
  107. package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
  108. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
  109. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
  110. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
  111. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
  112. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
  113. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
  114. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
  115. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
  116. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
  117. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
  118. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
  119. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
  120. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
  121. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
  122. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
  123. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
  124. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
  125. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
  126. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
  127. package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
  128. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  129. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +0 -2
  130. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
  131. package/external/pi-tools-suite/src/async-subagents/core/config.ts +40 -17
  132. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
  133. package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
  134. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +7 -4
  135. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +34 -24
  136. package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
  137. package/external/pi-tools-suite/src/async-subagents/lib.ts +9 -1
  138. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +0 -1
  139. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
  140. package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
  141. package/external/pi-tools-suite/src/config.ts +14 -0
  142. package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
  143. package/external/pi-tools-suite/src/context-gateway/config.ts +66 -1
  144. package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
  145. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +123 -3
  146. package/external/pi-tools-suite/src/context-gateway/index.ts +137 -4
  147. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +5 -5
  148. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +2 -2
  149. package/external/pi-tools-suite/src/context-gateway/types.ts +8 -1
  150. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
  151. package/external/pi-tools-suite/src/dcp/auto-compress.ts +41 -14
  152. package/external/pi-tools-suite/src/dcp/commands.ts +14 -156
  153. package/external/pi-tools-suite/src/dcp/compress-tool.ts +8 -2
  154. package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
  155. package/external/pi-tools-suite/src/dcp/config.ts +58 -4
  156. package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
  157. package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
  158. package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
  159. package/external/pi-tools-suite/src/dcp/index.ts +338 -66
  160. package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
  161. package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
  162. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +92 -2
  163. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
  164. package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
  165. package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
  166. package/external/pi-tools-suite/src/dcp/state.ts +9 -0
  167. package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
  168. package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
  169. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +68 -16
  170. package/external/pi-tools-suite/src/lib/project.ts +36 -1
  171. package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
  172. package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
  173. package/external/pi-tools-suite/src/resource-registry/index.ts +282 -21
  174. package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
  175. package/external/pi-tools-suite/src/todo/index.ts +21 -0
  176. package/external/pi-tools-suite/src/todo/todo.ts +19 -1
  177. package/external/pi-tools-suite/src/tool-descriptions.ts +44 -15
  178. package/package.json +7 -8
  179. package/schemas/pi-tools-suite.json +44 -12
  180. package/schemas/pix.json +109 -15
  181. package/schemas/tasks.json +1 -2
  182. package/skills/simplify/SKILL.md +33 -5
  183. package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -196
  184. package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
  185. package/skills/playwright-cli/SKILL.md +0 -420
  186. package/skills/playwright-cli/references/element-attributes.md +0 -23
  187. package/skills/playwright-cli/references/playwright-tests.md +0 -50
  188. package/skills/playwright-cli/references/request-mocking.md +0 -87
  189. package/skills/playwright-cli/references/running-code.md +0 -241
  190. package/skills/playwright-cli/references/session-management.md +0 -273
  191. package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
  192. package/skills/playwright-cli/references/storage-state.md +0 -290
  193. package/skills/playwright-cli/references/test-generation.md +0 -142
  194. package/skills/playwright-cli/references/tracing.md +0 -154
  195. package/skills/playwright-cli/references/video-recording.md +0 -147
  196. package/skills/spec-wiki/SKILL.md +0 -55
  197. package/skills/spec-wiki/evals/evals.json +0 -132
  198. package/skills/spec-wiki/evals/files/bootstrap/architecture/rfc-007.md +0 -14
  199. package/skills/spec-wiki/evals/files/bootstrap/docs/how-to-pay.md +0 -4
  200. package/skills/spec-wiki/evals/files/bootstrap/docs/overview.md +0 -9
  201. package/skills/spec-wiki/evals/files/bootstrap/requirements/payment-behavior.md +0 -17
  202. package/skills/spec-wiki/evals/files/bootstrap/src/payments.ts +0 -12
  203. package/skills/spec-wiki/evals/files/bootstrap/tests/payments.test.ts +0 -11
  204. package/skills/spec-wiki/evals/files/focused/src/session.ts +0 -5
  205. package/skills/spec-wiki/evals/files/focused/tests/session.test.ts +0 -17
  206. package/skills/spec-wiki/evals/files/maintenance/low-signal/incoming/capture.txt +0 -2
  207. package/skills/spec-wiki/evals/files/maintenance/low-signal/src/capture.ts +0 -3
  208. package/skills/spec-wiki/evals/files/maintenance/move/docs/old-job-contract.md +0 -18
  209. package/skills/spec-wiki/evals/files/maintenance/move/src/job.ts +0 -5
  210. package/skills/spec-wiki/evals/files/maintenance/new-relation/docs/session-refresh.md +0 -24
  211. package/skills/spec-wiki/evals/files/maintenance/new-relation/incoming/refresh-worker.ts +0 -7
  212. package/skills/spec-wiki/evals/files/maintenance/new-relation/src/session.ts +0 -7
  213. package/skills/spec-wiki/evals/files/maintenance/no-impact/docs/auth-contract.md +0 -17
  214. package/skills/spec-wiki/evals/files/maintenance/no-impact/incoming/telemetry-format.ts +0 -3
  215. package/skills/spec-wiki/evals/files/maintenance/no-impact/src/auth.ts +0 -3
  216. package/skills/spec-wiki/evals/files/query/docs/how-to-run-jobs.md +0 -4
  217. package/skills/spec-wiki/evals/files/query/docs/spec-overview.md +0 -9
  218. package/skills/spec-wiki/evals/files/query/features/jobs/cancellation-contract.md +0 -15
  219. package/skills/spec-wiki/evals/files/query/features/jobs/retry-spec.md +0 -6
  220. package/skills/spec-wiki/evals/maintenance_regression.py +0 -311
  221. package/skills/spec-wiki/evals/retrieval-eval.md +0 -37
  222. package/skills/spec-wiki/evals/retrieval-evals.json +0 -37
  223. package/skills/spec-wiki/evals/trigger-evals.json +0 -94
  224. package/skills/spec-wiki/references/bootstrap.md +0 -100
  225. package/skills/spec-wiki/references/focused-spec.md +0 -117
  226. package/skills/spec-wiki/references/index-schema.md +0 -330
  227. package/skills/spec-wiki/references/maintenance.md +0 -134
  228. package/skills/spec-wiki/references/query.md +0 -74
  229. package/skills/spec-wiki/scripts/spec_wiki.py +0 -2076
  230. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
  231. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
  232. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
  233. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
@@ -4,6 +4,7 @@ import { parse as parseJsonc } from "jsonc-parser";
4
4
 
5
5
  import { getPiToolsSuiteUserConfigPath } from "../config.js";
6
6
  import type {
7
+ ContextGatewayAccountingLogConfig,
7
8
  ContextGatewayBudgets,
8
9
  ContextGatewayMode,
9
10
  ContextGatewayResolvedConfig,
@@ -18,6 +19,12 @@ export const DEFAULT_CONTEXT_GATEWAY_BUDGETS: Readonly<ContextGatewayBudgets> =
18
19
  maxSearchMatches: 12,
19
20
  });
20
21
 
22
+ export const DEFAULT_CONTEXT_GATEWAY_ACCOUNTING_LOG: Readonly<ContextGatewayAccountingLogConfig> = Object.freeze({
23
+ enabled: true,
24
+ maxBytes: 5 * 1024 * 1024,
25
+ maxBackups: 3,
26
+ });
27
+
21
28
  export function contextGatewayBudgetForClass(
22
29
  toolClass: ContextGatewayToolClass,
23
30
  budgets: ContextGatewayBudgets,
@@ -66,6 +73,24 @@ function parseMode(value: unknown): ContextGatewayMode | undefined {
66
73
  return MODES.has(normalized) ? normalized : undefined;
67
74
  }
68
75
 
76
+ function parseBoolean(value: unknown): boolean | undefined {
77
+ return typeof value === "boolean" ? value : undefined;
78
+ }
79
+
80
+ function parseBooleanEnv(value: string): boolean | undefined {
81
+ const normalized = value.trim().toLowerCase();
82
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
83
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
84
+ return undefined;
85
+ }
86
+
87
+ function parsePositiveIntegerEnv(value: string): number | undefined {
88
+ const normalized = value.trim();
89
+ if (!/^\d+$/.test(normalized)) return undefined;
90
+ const parsed = Number(normalized);
91
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
92
+ }
93
+
69
94
  function mergeBudget(
70
95
  budgets: ContextGatewayBudgets,
71
96
  issues: string[],
@@ -88,6 +113,7 @@ export function loadContextGatewayConfig(
88
113
  ): ContextGatewayResolvedConfig {
89
114
  let mode: ContextGatewayMode = "off";
90
115
  const budgets: ContextGatewayBudgets = { ...DEFAULT_CONTEXT_GATEWAY_BUDGETS };
116
+ const accountingLog: ContextGatewayAccountingLogConfig = { ...DEFAULT_CONTEXT_GATEWAY_ACCOUNTING_LOG };
91
117
  const issues: string[] = [];
92
118
 
93
119
  const layers = [getPiToolsSuiteUserConfigPath(homeDir)];
@@ -108,6 +134,29 @@ export function loadContextGatewayConfig(
108
134
  mergeBudget(budgets, issues, key, section.budgets[key]);
109
135
  }
110
136
  }
137
+ if (isRecord(section.accountingLog)) {
138
+ if (Object.prototype.hasOwnProperty.call(section.accountingLog, "enabled")) {
139
+ const enabled = parseBoolean(section.accountingLog.enabled);
140
+ if (enabled !== undefined) accountingLog.enabled = enabled;
141
+ else issues.push(`contextGateway.accountingLog.enabled must be boolean; keeping ${accountingLog.enabled}.`);
142
+ }
143
+ const maxBytes = section.accountingLog.maxBytes;
144
+ if (maxBytes !== undefined) {
145
+ if (typeof maxBytes === "number" && Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= MAX_BYTE_BUDGET) {
146
+ accountingLog.maxBytes = maxBytes;
147
+ } else {
148
+ issues.push(`contextGateway.accountingLog.maxBytes must be an integer in 1..${MAX_BYTE_BUDGET}; keeping ${accountingLog.maxBytes}.`);
149
+ }
150
+ }
151
+ const maxBackups = section.accountingLog.maxBackups;
152
+ if (maxBackups !== undefined) {
153
+ if (typeof maxBackups === "number" && Number.isSafeInteger(maxBackups) && maxBackups >= 1 && maxBackups <= 100) {
154
+ accountingLog.maxBackups = maxBackups;
155
+ } else {
156
+ issues.push(`contextGateway.accountingLog.maxBackups must be an integer in 1..100; keeping ${accountingLog.maxBackups}.`);
157
+ }
158
+ }
159
+ }
111
160
  }
112
161
 
113
162
  if (env.PI_CONTEXT_GATEWAY_MODE !== undefined) {
@@ -116,5 +165,21 @@ export function loadContextGatewayConfig(
116
165
  else issues.push("PI_CONTEXT_GATEWAY_MODE must be off, observe, or enforce; ignoring it.");
117
166
  }
118
167
 
119
- return { mode, budgets, issues };
168
+ if (env.PI_CONTEXT_GATEWAY_ACCOUNTING_LOG_ENABLED !== undefined) {
169
+ const enabled = parseBooleanEnv(env.PI_CONTEXT_GATEWAY_ACCOUNTING_LOG_ENABLED);
170
+ if (enabled !== undefined) accountingLog.enabled = enabled;
171
+ else issues.push("PI_CONTEXT_GATEWAY_ACCOUNTING_LOG_ENABLED must be true or false; ignoring it.");
172
+ }
173
+ if (env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BYTES !== undefined) {
174
+ const maxBytes = parsePositiveIntegerEnv(env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BYTES);
175
+ if (maxBytes !== undefined && maxBytes <= MAX_BYTE_BUDGET) accountingLog.maxBytes = maxBytes;
176
+ else issues.push(`PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BYTES must be an integer in 1..${MAX_BYTE_BUDGET}; ignoring it.`);
177
+ }
178
+ if (env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BACKUPS !== undefined) {
179
+ const maxBackups = parsePositiveIntegerEnv(env.PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BACKUPS);
180
+ if (maxBackups !== undefined && maxBackups <= 100) accountingLog.maxBackups = maxBackups;
181
+ else issues.push("PI_CONTEXT_GATEWAY_ACCOUNTING_MAX_BACKUPS must be an integer in 1..100; ignoring it.");
182
+ }
183
+
184
+ return { mode, budgets, accountingLog, issues };
120
185
  }
@@ -0,0 +1,414 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ import { contextGatewayReadIdentity } from "./telemetry.js";
4
+ import type { ContextGatewayObservation } from "./types.js";
5
+
6
+ type ToolCallLike = {
7
+ toolCallId?: unknown;
8
+ toolName?: unknown;
9
+ input?: unknown;
10
+ };
11
+
12
+ type ToolResultLike = {
13
+ toolCallId?: unknown;
14
+ toolName?: unknown;
15
+ content?: unknown;
16
+ details?: unknown;
17
+ isError?: unknown;
18
+ };
19
+
20
+ type AssistantMessageLike = {
21
+ provider?: unknown;
22
+ model?: unknown;
23
+ stopReason?: unknown;
24
+ usage?: unknown;
25
+ };
26
+
27
+ export type ContextGatewayRetrievalKind =
28
+ | "artifact"
29
+ | "session-recovery"
30
+ | "repeat-read"
31
+ | "read-continuation";
32
+
33
+ export interface ContextGatewayEfficiencySnapshot {
34
+ toolCalls: number;
35
+ toolResults: number;
36
+ enforcedResults: number;
37
+ sourceContentBytes: number;
38
+ deliveredContentBytes: number;
39
+ grossBytesSaved: number;
40
+ grossEstimatedTokensSaved: number;
41
+ retrievalCalls: number;
42
+ retrievalResults: number;
43
+ retrievalBytes: number;
44
+ retrievalEstimatedTokens: number;
45
+ conservativeNetBytes: number;
46
+ conservativeNetEstimatedTokens: number;
47
+ providerAttempts: number;
48
+ providerCompletions: number;
49
+ providerInputTokens: number;
50
+ providerOutputTokens: number;
51
+ providerCacheReadTokens: number;
52
+ providerCacheWriteTokens: number;
53
+ providerTotalTokens: number;
54
+ providerCost: number;
55
+ byToolClass: Partial<Record<ContextGatewayObservation["toolClass"], {
56
+ results: number;
57
+ enforcedResults: number;
58
+ sourceContentBytes: number;
59
+ deliveredContentBytes: number;
60
+ grossBytesSaved: number;
61
+ grossEstimatedTokensSaved: number;
62
+ }>>;
63
+ byRetrievalKind: Partial<Record<ContextGatewayRetrievalKind, {
64
+ calls: number;
65
+ results: number;
66
+ bytes: number;
67
+ estimatedTokens: number;
68
+ }>>;
69
+ }
70
+
71
+ export interface ContextGatewayEfficiencyToolCall {
72
+ callRef: string;
73
+ toolName: string;
74
+ retrievalKind?: ContextGatewayRetrievalKind;
75
+ }
76
+
77
+ export interface ContextGatewayEfficiencyToolResult extends ContextGatewayEfficiencyToolCall {
78
+ toolClass: ContextGatewayObservation["toolClass"];
79
+ outcome: ContextGatewayObservation["outcome"];
80
+ representation: ContextGatewayObservation["delivery"]["representation"];
81
+ overBudget: boolean;
82
+ potentialBytesOverBudget: number;
83
+ sourceContentBytes: number;
84
+ deliveredContentBytes: number;
85
+ sourceEstimatedTokens: number;
86
+ deliveredEstimatedTokens: number;
87
+ grossBytesSaved: number;
88
+ grossEstimatedTokensSaved: number;
89
+ retrievalBytes: number;
90
+ retrievalEstimatedTokens: number;
91
+ }
92
+
93
+ export interface ContextGatewayProviderCompletion {
94
+ provider?: string;
95
+ model?: string;
96
+ stopReason?: string;
97
+ attemptsSincePreviousCompletion: number;
98
+ usage: {
99
+ input: number;
100
+ output: number;
101
+ cacheRead: number;
102
+ cacheWrite: number;
103
+ totalTokens: number;
104
+ cost: number;
105
+ };
106
+ }
107
+
108
+ type BoundCall = ContextGatewayEfficiencyToolCall;
109
+ type TokenCounter = ((text: string) => number) | undefined;
110
+
111
+ const MAX_TRACKED_BINDINGS = 4_096;
112
+ const MAX_TRACKED_READ_IDENTITIES = 4_096;
113
+ const MAX_TRACKED_ARTIFACT_SOURCES = 4_096;
114
+
115
+ let optionalTokenCounter: TokenCounter | null = null;
116
+
117
+ function loadOptionalTokenCounter(): TokenCounter {
118
+ if (optionalTokenCounter !== null) return optionalTokenCounter;
119
+ try {
120
+ const require = createRequire(import.meta.url);
121
+ const tokenizer = require("@anthropic-ai/tokenizer") as {
122
+ countTokens?: unknown;
123
+ default?: { countTokens?: unknown };
124
+ };
125
+ const counter = tokenizer.countTokens ?? tokenizer.default?.countTokens;
126
+ optionalTokenCounter = typeof counter === "function" ? counter as (text: string) => number : undefined;
127
+ } catch {
128
+ optionalTokenCounter = undefined;
129
+ }
130
+ return optionalTokenCounter;
131
+ }
132
+
133
+ export function estimateContextGatewayTokens(text: string): number {
134
+ if (!text) return 0;
135
+ const counter = loadOptionalTokenCounter();
136
+ if (counter) {
137
+ try {
138
+ const counted = counter(text);
139
+ if (Number.isFinite(counted)) return Math.max(0, Math.round(counted));
140
+ } catch {
141
+ // Fall through to the same cheap approximation used when no tokenizer is installed.
142
+ }
143
+ }
144
+ return Math.max(0, Math.round(text.length / 4));
145
+ }
146
+
147
+ function contentText(content: unknown): string {
148
+ if (typeof content === "string") return content;
149
+ if (!Array.isArray(content)) return "";
150
+ let text = "";
151
+ for (const part of content) {
152
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
153
+ const block = part as Record<string, unknown>;
154
+ if (block.type === "text" && typeof block.text === "string") text += block.text;
155
+ }
156
+ return text;
157
+ }
158
+
159
+ function finiteNonNegative(value: unknown): number {
160
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
161
+ }
162
+
163
+ function assistantUsage(message: AssistantMessageLike): ContextGatewayProviderCompletion["usage"] {
164
+ const usage = message.usage && typeof message.usage === "object" && !Array.isArray(message.usage)
165
+ ? message.usage as Record<string, unknown>
166
+ : {};
167
+ const costValue = usage.cost && typeof usage.cost === "object" && !Array.isArray(usage.cost)
168
+ ? finiteNonNegative((usage.cost as Record<string, unknown>).total)
169
+ : 0;
170
+ return {
171
+ input: finiteNonNegative(usage.input),
172
+ output: finiteNonNegative(usage.output),
173
+ cacheRead: finiteNonNegative(usage.cacheRead),
174
+ cacheWrite: finiteNonNegative(usage.cacheWrite),
175
+ totalTokens: finiteNonNegative(usage.totalTokens),
176
+ cost: costValue,
177
+ };
178
+ }
179
+
180
+ function retrievalKindForToolName(toolName: string): ContextGatewayRetrievalKind | undefined {
181
+ if (toolName === "artifact_read" || toolName === "artifact_search") return "artifact";
182
+ if (["session_overview", "session_read_section", "session_search", "session_recovery_context"].includes(toolName)) {
183
+ return "session-recovery";
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ function artifactHandlePaths(details: unknown): string[] {
189
+ if (!details || typeof details !== "object" || Array.isArray(details)) return [];
190
+ const record = details as Record<string, unknown>;
191
+ const paths: string[] = [];
192
+ for (const key of ["fullOutputPath", "artifactPath", "outputPath"]) {
193
+ const value = record[key];
194
+ if (typeof value === "string" && value.trim()) paths.push(value);
195
+ }
196
+ const artifacts = record.artifacts;
197
+ if (artifacts && typeof artifacts === "object" && !Array.isArray(artifacts)) {
198
+ for (const value of Object.values(artifacts as Record<string, unknown>)) {
199
+ if (typeof value === "string" && value.trim()) paths.push(value);
200
+ }
201
+ }
202
+ return paths;
203
+ }
204
+
205
+ function rememberBounded(set: Set<string>, value: string, maximum: number): void {
206
+ if (set.delete(value)) {
207
+ set.add(value);
208
+ return;
209
+ }
210
+ if (set.size >= maximum) {
211
+ const oldest = set.values().next().value;
212
+ if (oldest !== undefined) set.delete(oldest);
213
+ }
214
+ set.add(value);
215
+ }
216
+
217
+ function bindBounded(map: Map<string, BoundCall>, key: string, value: BoundCall): void {
218
+ if (!map.has(key) && map.size >= MAX_TRACKED_BINDINGS) {
219
+ const oldest = map.keys().next().value;
220
+ if (oldest !== undefined) map.delete(oldest);
221
+ }
222
+ map.set(key, value);
223
+ }
224
+
225
+ function emptySnapshot(): ContextGatewayEfficiencySnapshot {
226
+ return {
227
+ toolCalls: 0,
228
+ toolResults: 0,
229
+ enforcedResults: 0,
230
+ sourceContentBytes: 0,
231
+ deliveredContentBytes: 0,
232
+ grossBytesSaved: 0,
233
+ grossEstimatedTokensSaved: 0,
234
+ retrievalCalls: 0,
235
+ retrievalResults: 0,
236
+ retrievalBytes: 0,
237
+ retrievalEstimatedTokens: 0,
238
+ conservativeNetBytes: 0,
239
+ conservativeNetEstimatedTokens: 0,
240
+ providerAttempts: 0,
241
+ providerCompletions: 0,
242
+ providerInputTokens: 0,
243
+ providerOutputTokens: 0,
244
+ providerCacheReadTokens: 0,
245
+ providerCacheWriteTokens: 0,
246
+ providerTotalTokens: 0,
247
+ providerCost: 0,
248
+ byToolClass: {},
249
+ byRetrievalKind: {},
250
+ };
251
+ }
252
+
253
+ export class ContextGatewayEfficiencyTracker {
254
+ private totals = emptySnapshot();
255
+ private bindings = new Map<string, BoundCall>();
256
+ private seenExactReads = new Set<string>();
257
+ private seenReadSources = new Set<string>();
258
+ private artifactReadSources = new Set<string>();
259
+ private callSequence = 0;
260
+ private attemptsAtPreviousCompletion = 0;
261
+
262
+ recordToolCall(event: ToolCallLike): ContextGatewayEfficiencyToolCall | undefined {
263
+ if (typeof event.toolCallId !== "string" || typeof event.toolName !== "string") return undefined;
264
+ const toolName = event.toolName.trim().toLowerCase();
265
+ let retrievalKind = retrievalKindForToolName(toolName);
266
+ if (toolName === "read") {
267
+ const identity = contextGatewayReadIdentity(event.input);
268
+ if (identity.source && this.artifactReadSources.has(identity.source)) {
269
+ retrievalKind = "artifact";
270
+ rememberBounded(this.artifactReadSources, identity.source, MAX_TRACKED_ARTIFACT_SOURCES);
271
+ }
272
+ else if (identity.exact && this.seenExactReads.has(identity.exact)) retrievalKind = "repeat-read";
273
+ else if (identity.source && this.seenReadSources.has(identity.source)) retrievalKind = "read-continuation";
274
+ if (identity.exact) rememberBounded(this.seenExactReads, identity.exact, MAX_TRACKED_READ_IDENTITIES);
275
+ if (identity.source) rememberBounded(this.seenReadSources, identity.source, MAX_TRACKED_READ_IDENTITIES);
276
+ }
277
+
278
+ const binding: BoundCall = {
279
+ callRef: `c${++this.callSequence}`,
280
+ toolName,
281
+ ...(retrievalKind ? { retrievalKind } : {}),
282
+ };
283
+ bindBounded(this.bindings, event.toolCallId, binding);
284
+ this.totals.toolCalls += 1;
285
+ if (retrievalKind) {
286
+ this.totals.retrievalCalls += 1;
287
+ const counters = this.totals.byRetrievalKind[retrievalKind] ?? { calls: 0, results: 0, bytes: 0, estimatedTokens: 0 };
288
+ counters.calls += 1;
289
+ this.totals.byRetrievalKind[retrievalKind] = counters;
290
+ }
291
+ return { ...binding };
292
+ }
293
+
294
+ recordToolResult(
295
+ event: ToolResultLike,
296
+ observation: ContextGatewayObservation,
297
+ deliveredContent: unknown = event.content,
298
+ ): ContextGatewayEfficiencyToolResult | undefined {
299
+ if (typeof event.toolCallId !== "string") return undefined;
300
+ const binding = this.bindings.get(event.toolCallId);
301
+ if (!binding) return undefined;
302
+ this.bindings.delete(event.toolCallId);
303
+
304
+ const sourceEstimatedTokens = estimateContextGatewayTokens(contentText(event.content));
305
+ const deliveredEstimatedTokens = estimateContextGatewayTokens(contentText(deliveredContent));
306
+ const grossEstimatedTokensSaved = Math.max(0, sourceEstimatedTokens - deliveredEstimatedTokens);
307
+ const grossBytesSaved = observation.actualBytesSaved;
308
+ const retrievalBytes = binding.retrievalKind ? observation.delivery.contentBytes : 0;
309
+ const retrievalEstimatedTokens = binding.retrievalKind ? deliveredEstimatedTokens : 0;
310
+
311
+ this.totals.toolResults += 1;
312
+ this.totals.sourceContentBytes += observation.source.contentBytes;
313
+ this.totals.deliveredContentBytes += observation.delivery.contentBytes;
314
+ if (observation.delivery.representation !== "passthrough") this.totals.enforcedResults += 1;
315
+ this.totals.grossBytesSaved += grossBytesSaved;
316
+ this.totals.grossEstimatedTokensSaved += grossEstimatedTokensSaved;
317
+ const classCounters = this.totals.byToolClass[observation.toolClass] ?? {
318
+ results: 0,
319
+ enforcedResults: 0,
320
+ sourceContentBytes: 0,
321
+ deliveredContentBytes: 0,
322
+ grossBytesSaved: 0,
323
+ grossEstimatedTokensSaved: 0,
324
+ };
325
+ classCounters.results += 1;
326
+ if (observation.delivery.representation !== "passthrough") classCounters.enforcedResults += 1;
327
+ classCounters.sourceContentBytes += observation.source.contentBytes;
328
+ classCounters.deliveredContentBytes += observation.delivery.contentBytes;
329
+ classCounters.grossBytesSaved += grossBytesSaved;
330
+ classCounters.grossEstimatedTokensSaved += grossEstimatedTokensSaved;
331
+ this.totals.byToolClass[observation.toolClass] = classCounters;
332
+ if (binding.retrievalKind) {
333
+ this.totals.retrievalResults += 1;
334
+ this.totals.retrievalBytes += retrievalBytes;
335
+ this.totals.retrievalEstimatedTokens += retrievalEstimatedTokens;
336
+ const counters = this.totals.byRetrievalKind[binding.retrievalKind]
337
+ ?? { calls: 0, results: 0, bytes: 0, estimatedTokens: 0 };
338
+ counters.results += 1;
339
+ counters.bytes += retrievalBytes;
340
+ counters.estimatedTokens += retrievalEstimatedTokens;
341
+ this.totals.byRetrievalKind[binding.retrievalKind] = counters;
342
+ }
343
+ this.refreshNet();
344
+ for (const artifactPath of artifactHandlePaths(event.details)) {
345
+ const source = contextGatewayReadIdentity({ path: artifactPath }).source;
346
+ if (source) rememberBounded(this.artifactReadSources, source, MAX_TRACKED_ARTIFACT_SOURCES);
347
+ }
348
+
349
+ return {
350
+ ...binding,
351
+ toolClass: observation.toolClass,
352
+ outcome: observation.outcome,
353
+ representation: observation.delivery.representation,
354
+ overBudget: observation.overBudget,
355
+ potentialBytesOverBudget: observation.potentialBytesOverBudget,
356
+ sourceContentBytes: observation.source.contentBytes,
357
+ deliveredContentBytes: observation.delivery.contentBytes,
358
+ sourceEstimatedTokens,
359
+ deliveredEstimatedTokens,
360
+ grossBytesSaved,
361
+ grossEstimatedTokensSaved,
362
+ retrievalBytes,
363
+ retrievalEstimatedTokens,
364
+ };
365
+ }
366
+
367
+ recordProviderAttempt(): number {
368
+ this.totals.providerAttempts += 1;
369
+ return this.totals.providerAttempts;
370
+ }
371
+
372
+ recordProviderCompletion(message: AssistantMessageLike): ContextGatewayProviderCompletion {
373
+ const usage = assistantUsage(message);
374
+ this.totals.providerCompletions += 1;
375
+ this.totals.providerInputTokens += usage.input;
376
+ this.totals.providerOutputTokens += usage.output;
377
+ this.totals.providerCacheReadTokens += usage.cacheRead;
378
+ this.totals.providerCacheWriteTokens += usage.cacheWrite;
379
+ this.totals.providerTotalTokens += usage.totalTokens;
380
+ this.totals.providerCost += usage.cost;
381
+ const attemptsSincePreviousCompletion = this.totals.providerAttempts - this.attemptsAtPreviousCompletion;
382
+ this.attemptsAtPreviousCompletion = this.totals.providerAttempts;
383
+ return {
384
+ ...(typeof message.provider === "string" ? { provider: message.provider } : {}),
385
+ ...(typeof message.model === "string" ? { model: message.model } : {}),
386
+ ...(typeof message.stopReason === "string" ? { stopReason: message.stopReason } : {}),
387
+ attemptsSincePreviousCompletion,
388
+ usage,
389
+ };
390
+ }
391
+
392
+ clearTransientBindings(): void {
393
+ this.bindings.clear();
394
+ }
395
+
396
+ reset(): void {
397
+ this.totals = emptySnapshot();
398
+ this.bindings.clear();
399
+ this.seenExactReads.clear();
400
+ this.seenReadSources.clear();
401
+ this.artifactReadSources.clear();
402
+ this.callSequence = 0;
403
+ this.attemptsAtPreviousCompletion = 0;
404
+ }
405
+
406
+ snapshot(): ContextGatewayEfficiencySnapshot {
407
+ return structuredClone(this.totals);
408
+ }
409
+
410
+ private refreshNet(): void {
411
+ this.totals.conservativeNetBytes = this.totals.grossBytesSaved - this.totals.retrievalBytes;
412
+ this.totals.conservativeNetEstimatedTokens = this.totals.grossEstimatedTokensSaved - this.totals.retrievalEstimatedTokens;
413
+ }
414
+ }
@@ -5,7 +5,7 @@ import type { ContextGatewayToolClass } from "./types.js";
5
5
  type ResultContent = unknown;
6
6
 
7
7
  export interface ContextGatewayEnforcementPlan {
8
- representation: "passthrough" | "test-build-compact";
8
+ representation: "passthrough" | "test-build-compact" | "web-recoverable-compact";
9
9
  content: ResultContent;
10
10
  sourceContentBytes: number;
11
11
  contentBytes: number;
@@ -40,6 +40,109 @@ function textOnlyContent(content: unknown): boolean {
40
40
  && typeof (part as Record<string, unknown>).text === "string");
41
41
  }
42
42
 
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return value !== null && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+
47
+ function boundedTextBytes(text: string, maximumBytes: number): string {
48
+ const encoder = new TextEncoder();
49
+ if (encoder.encode(text).byteLength <= maximumBytes) return text;
50
+ const suffix = "\n… [compact view truncated; full structured result is recoverable from raw session details]";
51
+ const suffixBytes = encoder.encode(suffix).byteLength;
52
+ if (maximumBytes <= suffixBytes) {
53
+ let low = 0;
54
+ let high = text.length;
55
+ while (low < high) {
56
+ const mid = Math.ceil((low + high) / 2);
57
+ if (encoder.encode(text.slice(0, mid)).byteLength <= maximumBytes) low = mid;
58
+ else high = mid - 1;
59
+ }
60
+ return text.slice(0, low);
61
+ }
62
+ const target = Math.max(0, maximumBytes - suffixBytes);
63
+ let low = 0;
64
+ let high = text.length;
65
+ while (low < high) {
66
+ const mid = Math.ceil((low + high) / 2);
67
+ if (encoder.encode(text.slice(0, mid)).byteLength <= target) low = mid;
68
+ else high = mid - 1;
69
+ }
70
+ return `${text.slice(0, low).trimEnd()}${suffix}`;
71
+ }
72
+
73
+ function boundedProviderTextContent(text: string, maximumBytes: number): {
74
+ content: Array<{ type: "text"; text: string }>;
75
+ contentBytes: number;
76
+ textBytes: number;
77
+ } {
78
+ const limit = Math.max(1, Math.floor(maximumBytes));
79
+ let low = 1;
80
+ let high = limit;
81
+ let bestText = boundedTextBytes(text, 1);
82
+ let bestContent = [{ type: "text" as const, text: bestText }];
83
+ let bestContentBytes = byteLengthJson(bestContent);
84
+
85
+ while (low <= high) {
86
+ const textBudget = Math.floor((low + high) / 2);
87
+ const candidateText = boundedTextBytes(text, textBudget);
88
+ const candidateContent = [{ type: "text" as const, text: candidateText }];
89
+ const candidateBytes = byteLengthJson(candidateContent);
90
+ if (candidateBytes <= limit) {
91
+ bestText = candidateText;
92
+ bestContent = candidateContent;
93
+ bestContentBytes = candidateBytes;
94
+ low = textBudget + 1;
95
+ } else {
96
+ high = textBudget - 1;
97
+ }
98
+ }
99
+
100
+ return {
101
+ content: bestContent,
102
+ contentBytes: bestContentBytes,
103
+ textBytes: new TextEncoder().encode(bestText).byteLength,
104
+ };
105
+ }
106
+
107
+ function planRecoverableWebCompact(details: unknown, toolCallId: string | undefined, maximumBytes: number): string | undefined {
108
+ if (!isRecord(details)) return undefined;
109
+ const recovery = [
110
+ "[Context Gateway: over-budget web result compacted for provider context.]",
111
+ "Full structured source is retained in the raw session toolResult.details and can be recovered with session-recovery.",
112
+ toolCallId ? `Recovery key: toolCallId=${toolCallId}` : undefined,
113
+ ].filter((line): line is string => Boolean(line));
114
+
115
+ if (Array.isArray(details.results)) {
116
+ const results = details.results.filter(isRecord);
117
+ if (results.length === 0) return undefined;
118
+ const lines = [...recovery, `Search results: ${results.length}`];
119
+ for (let index = 0; index < results.length; index++) {
120
+ const result = results[index]!;
121
+ const title = typeof result.title === "string" ? result.title : `Result ${index + 1}`;
122
+ const url = typeof result.url === "string" ? result.url : "";
123
+ const content = typeof result.content === "string" ? result.content.replace(/\s+/g, " ").trim() : "";
124
+ lines.push(`${index + 1}. ${title}`);
125
+ if (url) lines.push(` URL: ${url}`);
126
+ if (content) lines.push(` ${content.slice(0, 900)}${content.length > 900 ? "…" : ""}`);
127
+ }
128
+ return boundedTextBytes(lines.join("\n"), maximumBytes);
129
+ }
130
+
131
+ if (typeof details.content === "string") {
132
+ const title = typeof details.title === "string" ? details.title : "Fetched document";
133
+ const links = Array.isArray(details.links) ? details.links.filter((link): link is string => typeof link === "string") : [];
134
+ const lines = [
135
+ ...recovery,
136
+ `Title: ${title}`,
137
+ `Content preview: ${details.content.slice(0, Math.max(1_000, maximumBytes))}`,
138
+ ...(links.length > 0 ? ["Links:", ...links.slice(0, 8).map((link) => ` - ${link}`)] : []),
139
+ ];
140
+ return boundedTextBytes(lines.join("\n"), maximumBytes);
141
+ }
142
+
143
+ return undefined;
144
+ }
145
+
43
146
  function upstreamTruncated(details: unknown): boolean {
44
147
  if (!details || typeof details !== "object" || Array.isArray(details)) return false;
45
148
  const record = details as Record<string, unknown>;
@@ -51,11 +154,12 @@ function upstreamTruncated(details: unknown): boolean {
51
154
 
52
155
  /**
53
156
  * Selective storeless enforcement. It never performs generic truncation.
54
- * Only recognised, complete, simple test/build output is compacted; every
55
- * other result stays byte-for-byte passthrough until a recovery contract exists.
157
+ * Recognised complete test/build output and structured recoverable web results
158
+ * have explicit compact contracts; other results remain passthrough.
56
159
  */
57
160
  export function planContextGatewayEnforcement(input: {
58
161
  event: { content?: unknown; details?: unknown; isError?: boolean };
162
+ toolCallId?: string;
59
163
  toolClass: ContextGatewayToolClass;
60
164
  shell: ShellCommandClassification;
61
165
  budgetBytes: number;
@@ -72,6 +176,22 @@ export function planContextGatewayEnforcement(input: {
72
176
  reason,
73
177
  });
74
178
 
179
+ if (input.toolClass === "web") {
180
+ if (!textOnlyContent(input.event.content)) return passthrough("non-text-content");
181
+ if (sourceContentBytes <= input.budgetBytes) return passthrough("within-class-budget");
182
+ const compactText = planRecoverableWebCompact(input.event.details, input.toolCallId, input.maxInlineBytes);
183
+ if (!compactText) return passthrough("web-recovery-source-unavailable");
184
+ const bounded = boundedProviderTextContent(compactText, input.maxInlineBytes);
185
+ return {
186
+ representation: "web-recoverable-compact",
187
+ content: bounded.content,
188
+ sourceContentBytes,
189
+ contentBytes: bounded.contentBytes,
190
+ textBytes: bounded.textBytes,
191
+ reason: "recoverable-structured-web-details",
192
+ };
193
+ }
194
+
75
195
  if (input.toolClass !== "shell") return passthrough("class-not-enforced");
76
196
  if (input.shell.scope !== "simple" || input.shell.kind !== "test-build") {
77
197
  return passthrough("shell-not-proven-test-build");