@orboto/mcp 0.178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,515 @@
1
+ /**
2
+ * ORB-1697 - the central MCP response budget.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * An MCP tool result is not paid once. The client re-sends the whole
7
+ * conversation on every subsequent request, so a result costs its size
8
+ * TIMES the number of turns that follow it. The 2026-08-09 transcript
9
+ * audit (6.453 real calls, 32 transcripts - see doc ORB-D20) measured
10
+ * `list_agent_instructions` at 238 Mtok of carry cost from only THREE
11
+ * calls, because 37k characters landed 15 % into a session and rode
12
+ * along for the remaining 85 %.
13
+ *
14
+ * Two facts from that audit shape this module:
15
+ *
16
+ * 1. In Claude Code, only `structuredContent` reaches the model - the
17
+ * Markdown `content` block the handlers also build is dropped. So
18
+ * the budget MUST measure and cut the structured payload; cutting
19
+ * only the text would look like a fix and change nothing. Other
20
+ * clients do the opposite, so both are measured and both are cut.
21
+ * 2. Per-tool discipline does not hold across 168 registered tools.
22
+ * The cap is applied once, in `with-metrics.ts`, which every tool
23
+ * is already registered through.
24
+ *
25
+ * CONTRACT
26
+ * --------
27
+ * Truncation is never silent. An over-budget result comes back with a
28
+ * `__truncation` block naming every cut path, plus a handle, and the
29
+ * omitted remainder stays fetchable via `orboto_response_expand` for
30
+ * as long as the handle lives (in-process, 15 min, 16 payloads).
31
+ *
32
+ * Shrinking is shape-preserving: keys never disappear, arrays stay
33
+ * arrays, and strings stay strings. That matters because 7 tools
34
+ * declare an `outputSchema` the SDK validates the payload against.
35
+ * Adding `__truncation` is safe (no schema uses `.strict()`, and Zod v3
36
+ * accepts unknown keys), and identifier-shaped strings are protected by
37
+ * MIN_STRING_CUT - a uuid (36) or a ticket key is far below it, so
38
+ * `.uuid()` / `.url()` fields are never cut in half.
39
+ *
40
+ * This module never throws. A malformed payload returns unbudgeted
41
+ * rather than failing the tool call.
42
+ */
43
+ import { randomUUID } from 'node:crypto';
44
+ import { z } from 'zod';
45
+ /** Default cap for a tool that does not declare its own. */
46
+ export const DEFAULT_BUDGET_CHARS = 4000;
47
+ /**
48
+ * Per-tool overrides.
49
+ *
50
+ * The default (4k) suits cards and lists: a response that answers
51
+ * "which of these do I open?" does not need more. Two categories
52
+ * legitimately need more and are raised deliberately:
53
+ *
54
+ * - CONTENT READS. The caller explicitly asked for a document, a
55
+ * generated report or a primer. Cutting those to 4k would break the
56
+ * tool's purpose rather than remove waste.
57
+ *
58
+ * `orboto_session_start` used to be the third category at 48.000
59
+ * characters, and ORB-1818 removed that exemption: the digest now
60
+ * answers with a rules INDEX plus a hash instead of the full rule text,
61
+ * so the default answer fits the 4k default like every other tool. The
62
+ * two answers that DO carry the full rules (`forceRules: true`,
63
+ * `rulesOnly: true`) protect them instead of buying size - see
64
+ * PROTECTED_PATHS and PROTECT_TEXT_META below.
65
+ */
66
+ export const TOOL_BUDGET_CHARS = {
67
+ orboto_get_project_primer: 16_000,
68
+ orboto_get_doc: 16_000,
69
+ orboto_get_doc_revision: 16_000,
70
+ orboto_export_doc_md: 16_000,
71
+ orboto_ask_docs: 12_000,
72
+ orboto_wiki_ask: 12_000,
73
+ orboto_customer_report: 16_000,
74
+ orboto_requirements_spec: 16_000,
75
+ orboto_get_attachment: 16_000,
76
+ // The continuation tool itself: a chunk is served AT the budget, so a
77
+ // cap equal to the default would truncate the chunk it just sized.
78
+ orboto_response_expand: 8_000,
79
+ // ORB-1518 - detail mode returns a full endpoint schema (parameters +
80
+ // request body + responses), which is the content the caller asked
81
+ // for; 4k would cut most real schemas in half.
82
+ orboto_api_search: 8_000,
83
+ // ORB-1519 - the proxy envelope IS the content the caller asked for
84
+ // (an arbitrary endpoint's response); the proxy already caps it, this
85
+ // budget keeps the MCP-side carry cost bounded on top.
86
+ orboto_api_call: 8_000,
87
+ };
88
+ /**
89
+ * Paths the shrinker must never touch, per tool.
90
+ *
91
+ * The shrinker cuts the LARGEST leaf first, which is the right default and
92
+ * exactly wrong for one case: in `session_start` the largest string is the
93
+ * workspace's binding rules. Truncating a mandatory rule to save context is
94
+ * worse than paying for it - an agent that never sees the second half of a
95
+ * rule breaks it silently. Measured on 2026-08-09: a cold
96
+ * `session_start({ ticketKey })` is 47.694 characters, and without this
97
+ * list the budget cut 2.917 characters out of the rules block.
98
+ *
99
+ * A protected path is not exempt from the budget - the shrinker moves on to
100
+ * the next-largest cuttable leaf (for that call: the project primer, which
101
+ * is a digest with its own server-side budget and is safe to cut
102
+ * explicitly). If nothing else can be cut, the response reports
103
+ * `atFloor: true` and stays whole.
104
+ */
105
+ export const PROTECTED_PATHS = {
106
+ orboto_session_start: ['rules'],
107
+ };
108
+ /**
109
+ * ORB-1818 - per-call protection of the TEXT half.
110
+ *
111
+ * PROTECTED_PATHS covers the structured half, which is what Claude Code
112
+ * pays for; a text-only client pays the Markdown block instead, and that
113
+ * half is cut on a line boundary with no notion of protected paths. For
114
+ * an answer whose whole payload IS the binding rules (`session_start`
115
+ * with `forceRules` or `rulesOnly`), cutting either half truncates a
116
+ * mandatory rule - the exact thing the protection above exists to
117
+ * prevent, just for the other client class.
118
+ *
119
+ * A handler marks such a result by setting `result._meta[PROTECT_TEXT_META]
120
+ * = true`. The flag is READ AND REMOVED here, so it never reaches the
121
+ * wire. It protects; it does not exempt: unprotected structured leaves in
122
+ * the same result are still cut normally.
123
+ *
124
+ * This is deliberately not a general "skip the budget" escape hatch -
125
+ * `protect-text-usage.test.ts` fails the build if a file other than the
126
+ * session-start tool sets it.
127
+ */
128
+ export const PROTECT_TEXT_META = 'orboto/protectText';
129
+ function takeProtectTextFlag(result) {
130
+ const meta = result._meta;
131
+ if (!meta || meta[PROTECT_TEXT_META] !== true)
132
+ return false;
133
+ delete meta[PROTECT_TEXT_META];
134
+ if (Object.keys(meta).length === 0)
135
+ delete result._meta;
136
+ return true;
137
+ }
138
+ /**
139
+ * Never cut a string shorter than this. Keeps identifiers, keys, urls,
140
+ * hashes and short labels intact - the things a caller feeds back into
141
+ * another call, and the things `outputSchema` validators constrain.
142
+ */
143
+ const MIN_STRING_CUT = 200;
144
+ /** A cut string always keeps at least this much of its head. */
145
+ const MIN_STRING_KEEP = 160;
146
+ /** An array is never cut below this many items. */
147
+ const MIN_ARRAY_KEEP = 1;
148
+ /** Arrays shorter than this are left alone (cutting 2 to 1 buys nothing). */
149
+ const MIN_ARRAY_CUT = 3;
150
+ /** Bound the cut loop; each pass removes the current largest offender. */
151
+ const MAX_CUT_PASSES = 64;
152
+ export const HANDLE_TTL_MS = 15 * 60 * 1000;
153
+ export const MAX_HANDLES = 16;
154
+ // ---------------------------------------------------------------------------
155
+ // Configuration
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * `ORBOTO_MCP_RESPONSE_BUDGET=off` disables the cap entirely (escape
159
+ * hatch for a client that genuinely wants everything);
160
+ * `ORBOTO_MCP_RESPONSE_BUDGET_CHARS=<n>` overrides the default cap.
161
+ * Per-tool overrides above still apply on top of a custom default.
162
+ */
163
+ export function budgetEnabled(env = process.env) {
164
+ return (env.ORBOTO_MCP_RESPONSE_BUDGET ?? '').toLowerCase() !== 'off';
165
+ }
166
+ export function budgetFor(toolName, env = process.env) {
167
+ const perTool = TOOL_BUDGET_CHARS[toolName];
168
+ if (perTool !== undefined)
169
+ return perTool;
170
+ const raw = Number(env.ORBOTO_MCP_RESPONSE_BUDGET_CHARS);
171
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_BUDGET_CHARS;
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // Measurement - what the client actually pays for
175
+ // ---------------------------------------------------------------------------
176
+ function textOf(part) {
177
+ if (part && typeof part === 'object' && 'text' in part) {
178
+ const t = part.text;
179
+ if (typeof t === 'string')
180
+ return t;
181
+ }
182
+ return null;
183
+ }
184
+ /**
185
+ * The two halves a result is made of, measured separately.
186
+ *
187
+ * A handler returns the SAME data twice: `structuredContent` for
188
+ * schema-aware clients and a Markdown text block for the rest. No known
189
+ * client charges for both - Claude Code stores only the structured JSON
190
+ * in its transcript (verified 2026-08-09), text-only clients see just the
191
+ * text - so summing them would double-count and cut twice as hard as the
192
+ * cost justifies. The budget is therefore enforced on each half, and the
193
+ * reported cost is the larger of the two: what one client actually pays.
194
+ */
195
+ export function measureHalves(result) {
196
+ let textChars = 0;
197
+ for (const part of result.content ?? []) {
198
+ const t = textOf(part);
199
+ textChars += t !== null ? t.length : safeStringify(part).length;
200
+ }
201
+ const structuredChars = result.structuredContent === undefined
202
+ ? 0
203
+ : safeStringify(result.structuredContent).length;
204
+ return { textChars, structuredChars };
205
+ }
206
+ /** Characters the heaviest single client pays for this result. */
207
+ export function measureResult(result) {
208
+ const { textChars, structuredChars } = measureHalves(result);
209
+ return Math.max(textChars, structuredChars);
210
+ }
211
+ function safeStringify(value) {
212
+ try {
213
+ return JSON.stringify(value) ?? '';
214
+ }
215
+ catch {
216
+ return '';
217
+ }
218
+ }
219
+ const handles = new Map();
220
+ function pruneHandles(now) {
221
+ for (const [key, value] of handles) {
222
+ if (now - value.storedAt > HANDLE_TTL_MS)
223
+ handles.delete(key);
224
+ }
225
+ // Map iteration order is insertion order, so the oldest entries go first.
226
+ while (handles.size > MAX_HANDLES) {
227
+ const oldest = handles.keys().next();
228
+ if (oldest.done)
229
+ break;
230
+ handles.delete(oldest.value);
231
+ }
232
+ }
233
+ export function storePayload(toolName, payload, now = Date.now()) {
234
+ const handle = randomUUID().slice(0, 8);
235
+ handles.set(handle, { toolName, storedAt: now, ...payload });
236
+ pruneHandles(now);
237
+ return handle;
238
+ }
239
+ export function readPayload(handle, now = Date.now()) {
240
+ pruneHandles(now);
241
+ return handles.get(handle) ?? null;
242
+ }
243
+ /** Record what the shrink pass cut, so the expand tool can list the paths. */
244
+ export function annotatePayload(handle, omitted) {
245
+ const stored = handles.get(handle);
246
+ if (stored)
247
+ stored.omitted = omitted;
248
+ }
249
+ /** Test seam - the store is process-global by design. */
250
+ export function resetPayloadStore() {
251
+ handles.clear();
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // Path addressing - `ticketBundle.primer.markdown`, `comments[3].body`
255
+ // ---------------------------------------------------------------------------
256
+ export function resolvePath(root, path) {
257
+ if (path === '' || path === '$')
258
+ return root;
259
+ let current = root;
260
+ // Split on `.` and `[i]` in one pass so both forms address the same tree.
261
+ for (const token of path.split(/\.|(?=\[)/)) {
262
+ if (current === null || current === undefined)
263
+ return undefined;
264
+ const arrayIndex = /^\[(\d+)\]$/.exec(token);
265
+ if (arrayIndex) {
266
+ if (!Array.isArray(current))
267
+ return undefined;
268
+ current = current[Number(arrayIndex[1])];
269
+ continue;
270
+ }
271
+ if (token === '')
272
+ continue;
273
+ if (typeof current !== 'object')
274
+ return undefined;
275
+ current = current[token];
276
+ }
277
+ return current;
278
+ }
279
+ /** Collect every cuttable leaf, largest first. `protected` paths are skipped
280
+ * entirely (see PROTECTED_PATHS) - including their subtrees, so protecting a
281
+ * parent protects what hangs off it. */
282
+ function collectCandidates(root, protectedPaths = []) {
283
+ const out = [];
284
+ const isProtected = (path) => protectedPaths.some((p) => path === p || path.startsWith(`${p}.`) || path.startsWith(`${p}[`));
285
+ const walk = (node, path) => {
286
+ if (Array.isArray(node)) {
287
+ node.forEach((item, index) => walk(item, `${path}[${index}]`));
288
+ return;
289
+ }
290
+ if (!node || typeof node !== 'object')
291
+ return;
292
+ for (const [key, value] of Object.entries(node)) {
293
+ // Never cut the truncation metadata itself - it is the way back.
294
+ if (key === '__truncation')
295
+ continue;
296
+ const childPath = path ? `${path}.${key}` : key;
297
+ if (isProtected(childPath))
298
+ continue;
299
+ if (typeof value === 'string') {
300
+ if (value.length >= MIN_STRING_CUT) {
301
+ out.push({
302
+ path: childPath, kind: 'string', size: value.length,
303
+ parent: node, key, value,
304
+ });
305
+ }
306
+ continue;
307
+ }
308
+ if (Array.isArray(value)) {
309
+ if (value.length >= MIN_ARRAY_CUT) {
310
+ out.push({
311
+ path: childPath, kind: 'array', size: safeStringify(value).length,
312
+ parent: node, key, value,
313
+ });
314
+ }
315
+ value.forEach((item, index) => walk(item, `${childPath}[${index}]`));
316
+ continue;
317
+ }
318
+ walk(value, childPath);
319
+ }
320
+ };
321
+ walk(root, '');
322
+ return out.sort((a, b) => b.size - a.size);
323
+ }
324
+ // ORB-1738 - the ADVERTISED zod shape of the marker, consumed centrally in
325
+ // registerWithMetrics: every declared outputSchema is extended with an
326
+ // optional `__truncation` so strict clients (which validate structured
327
+ // content against the advertised JSON schema, additionalProperties:false)
328
+ // accept over-budget responses. Keep in lockstep with TruncationBlock.
329
+ export const TruncationBlockSchema = z.object({
330
+ handle: z.string(),
331
+ budgetChars: z.number(),
332
+ originalChars: z.number(),
333
+ omittedChars: z.number(),
334
+ omitted: z.array(z.object({
335
+ path: z.string(),
336
+ kind: z.string(),
337
+ omittedChars: z.number().optional(),
338
+ omittedItems: z.number().optional(),
339
+ keptItems: z.number().optional(),
340
+ })),
341
+ howToGetTheRest: z.string(),
342
+ atFloor: z.boolean().optional(),
343
+ });
344
+ /**
345
+ * ORB-1805 - what registerWithMetrics actually advertises.
346
+ *
347
+ * `TruncationBlockSchema` above is the exact runtime shape (and stays the
348
+ * validator the tests assert against), but serialising all eight of its
349
+ * fields costs ~620 characters in EVERY tool that declares an
350
+ * outputSchema - 14 tools in the full manifest, 5 in the curated one -
351
+ * for a block a caller only ever reads. The advertised form declares the
352
+ * one field a caller acts on (`handle`) and stays open for the rest, so
353
+ * a strict client still accepts an over-budget payload (ORB-1738, the
354
+ * reason the marker is advertised at all) at a third of the bytes.
355
+ *
356
+ * Strictly more permissive than TruncationBlockSchema, so nothing that
357
+ * validated before can fail now.
358
+ */
359
+ export const TruncationBlockAdvertisedSchema = z.object({ handle: z.string() })
360
+ .passthrough()
361
+ .describe('Response was cut; pass `handle` to orboto_response_expand.');
362
+ function stringMarker(omitted, handle, path) {
363
+ return `... [truncated ${omitted} chars - full value: orboto_response_expand handle="${handle}" path="${path}"]`;
364
+ }
365
+ /**
366
+ * Cap a tool result at its budget. Returns the (possibly rewritten)
367
+ * result plus the numbers `with-metrics.ts` reports to
368
+ * `/admin/mcp/instrument`, so budget pressure stays visible in the
369
+ * admin MCP-usage panel instead of needing another transcript audit.
370
+ */
371
+ export function applyResponseBudget(toolName, result, env = process.env) {
372
+ // ORB-1818 - read (and strip) the per-call text protection before
373
+ // anything else, so the flag never rides the wire even on the
374
+ // under-budget path.
375
+ const protectText = takeProtectTextFlag(result);
376
+ const originalChars = measureResult(result);
377
+ if (!budgetEnabled(env)) {
378
+ return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
379
+ }
380
+ const budget = budgetFor(toolName, env);
381
+ if (originalChars <= budget) {
382
+ return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
383
+ }
384
+ try {
385
+ return shrink(toolName, result, budget, originalChars, protectText);
386
+ }
387
+ catch {
388
+ // A budget must never break a tool call. Report the real size so the
389
+ // panel still shows the pressure, and hand back the untouched result.
390
+ return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
391
+ }
392
+ }
393
+ function shrink(toolName, result, budget, originalChars, protectText = false) {
394
+ const fullText = (result.content ?? [])
395
+ .map((part) => textOf(part) ?? '')
396
+ .join('\n');
397
+ const handle = storePayload(toolName, {
398
+ structuredContent: result.structuredContent,
399
+ text: fullText,
400
+ });
401
+ const omitted = [];
402
+ const structured = result.structuredContent === undefined
403
+ ? undefined
404
+ : JSON.parse(safeStringify(result.structuredContent));
405
+ // Reserve room for the truncation block and the text notice so the
406
+ // final measurement lands under budget, not just near it.
407
+ const RESERVE = 700;
408
+ const target = Math.max(MIN_STRING_KEEP, budget - RESERVE);
409
+ // Each half is capped against the same target - see measureHalves().
410
+ const measure = () => (structured === undefined ? 0 : safeStringify(structured).length);
411
+ // The text half: cut on a line boundary so a reader never gets half a
412
+ // sentence, and record how much was dropped.
413
+ let currentTextLength = fullText.length;
414
+ let textOmitted = 0;
415
+ if (!protectText && fullText.length > target) {
416
+ const boundary = fullText.lastIndexOf('\n', target);
417
+ currentTextLength = boundary > MIN_STRING_KEEP ? boundary : target;
418
+ textOmitted = fullText.length - currentTextLength;
419
+ }
420
+ let passes = 0;
421
+ while (structured !== undefined && measure() > target && passes < MAX_CUT_PASSES) {
422
+ passes += 1;
423
+ const candidates = collectCandidates(structured, PROTECTED_PATHS[toolName] ?? []);
424
+ const largest = candidates[0];
425
+ if (!largest)
426
+ break;
427
+ const overshoot = measure() - target;
428
+ if (largest.kind === 'string') {
429
+ const value = largest.value;
430
+ const marker = stringMarker(0, handle, largest.path).length + 8;
431
+ const keep = Math.max(MIN_STRING_KEEP, value.length - overshoot - marker);
432
+ if (keep >= value.length)
433
+ break;
434
+ const omittedChars = value.length - keep;
435
+ largest.parent[largest.key] =
436
+ value.slice(0, keep) + stringMarker(omittedChars, handle, largest.path);
437
+ omitted.push({ path: largest.path, kind: 'string', omittedChars });
438
+ continue;
439
+ }
440
+ const items = largest.value;
441
+ // Drop from the tail: list tools order by relevance, so the head is
442
+ // the part a caller reads first.
443
+ let keptItems = items.length;
444
+ while (keptItems > MIN_ARRAY_KEEP) {
445
+ const trial = items.slice(0, keptItems - 1);
446
+ keptItems -= 1;
447
+ if (safeStringify(trial).length <= Math.max(0, safeStringify(items).length - overshoot))
448
+ break;
449
+ }
450
+ if (keptItems >= items.length)
451
+ break;
452
+ largest.parent[largest.key] = items.slice(0, keptItems);
453
+ omitted.push({
454
+ path: largest.path,
455
+ kind: 'array',
456
+ keptItems,
457
+ omittedItems: items.length - keptItems,
458
+ });
459
+ }
460
+ const allOmitted = textOmitted > 0
461
+ ? [...omitted, { path: '$text', kind: 'string', omittedChars: textOmitted }]
462
+ : omitted;
463
+ annotatePayload(handle, allOmitted);
464
+ const atFloor = measure() > target;
465
+ // ORB-1818 - the floor case gets its OWN notice. Telling an agent its
466
+ // response was truncated when nothing was cut sends it hunting for a
467
+ // remainder that does not exist - and that is exactly what a protected
468
+ // answer (the binding rules) produces every time.
469
+ const notice = allOmitted.length > 0
470
+ ? `[Response truncated to the MCP response budget (${budget} chars) - it would otherwise cost `
471
+ + `${originalChars} chars on EVERY later request in this session. Omitted content is not lost: `
472
+ + `call orboto_response_expand with handle "${handle}" and one of the paths in __truncation.omitted.]`
473
+ : `[Response is ${originalChars} chars, over the MCP response budget (${budget} chars), and nothing in it `
474
+ + 'could be cut safely - it is protected or uncuttable content. NOTHING was omitted; there is no remainder to fetch.]';
475
+ if (structured !== undefined && structured !== null && typeof structured === 'object' && !Array.isArray(structured)) {
476
+ const block = {
477
+ handle,
478
+ budgetChars: budget,
479
+ originalChars,
480
+ omittedChars: 0, // filled in below, once the final size is known
481
+ omitted: allOmitted,
482
+ howToGetTheRest: `Call orboto_response_expand { handle: "${handle}", path: "<one of omitted[].path>" } for the omitted `
483
+ + 'remainder. Omit `path` to list what is available. The handle expires 15 minutes after this call.',
484
+ ...(atFloor ? { atFloor: true } : {}),
485
+ };
486
+ structured.__truncation = block;
487
+ // The cost model is per-half (see measureHalves), so what was omitted
488
+ // is the shrinkage of the half that dominates the price.
489
+ const finalChars = Math.max(safeStringify(structured).length, currentTextLength + notice.length + 2);
490
+ block.omittedChars = Math.max(0, originalChars - finalChars);
491
+ }
492
+ // Rebuild the content parts: the (possibly shortened) text plus the notice.
493
+ const shortenedText = currentTextLength >= fullText.length
494
+ ? fullText
495
+ : fullText.slice(0, currentTextLength);
496
+ const content = fullText.length > 0
497
+ ? [{ type: 'text', text: `${shortenedText}\n\n${notice}` }]
498
+ : [{ type: 'text', text: notice }];
499
+ // Non-text parts (images, resources) are never cut - they are not the
500
+ // bloat this budget is about, and slicing their payload would corrupt them.
501
+ const nonText = (result.content ?? []).filter((part) => textOf(part) === null);
502
+ const shrunk = {
503
+ ...result,
504
+ content: [...content, ...nonText],
505
+ ...(structured === undefined ? {} : { structuredContent: structured }),
506
+ };
507
+ const responseChars = measureResult(shrunk);
508
+ return {
509
+ result: shrunk,
510
+ responseChars,
511
+ originalChars,
512
+ truncatedChars: Math.max(0, originalChars - responseChars),
513
+ handle,
514
+ };
515
+ }