sandoichi 0.4.1 → 0.5.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.
package/src/core.mjs CHANGED
@@ -1,13 +1,29 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { stripVTControlCharacters } from 'node:util';
2
3
 
3
4
  import { planToolRoute, ROUTING_POLICY_VERSION } from './routing.mjs';
4
5
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
6
+ import { buildResultDisclosure } from './result-disclosure.mjs';
5
7
 
6
8
  const DEFAULT_POLICY = Object.freeze({
7
- mode: 'apply', maxInlineBytes: 4096, maxArtifactBytes: 65536, headBytes: undefined, tailBytes: undefined,
9
+ mode: 'apply', maxInlineBytes: 4096, maxArtifactBytes: 1_048_576, headBytes: undefined, tailBytes: undefined,
8
10
  maxColumns: 768, redact: true,
9
11
  });
10
12
  const POLICY_FIELDS = new Set(Object.keys(DEFAULT_POLICY));
13
+ const SEVERE_DIAGNOSTIC_LINE = /\b(?:error|fail(?:ed|ure)?|exception|fatal|panic|traceback|assertion)\b/i;
14
+ const WARNING_LINE = /\bwarning\b/i;
15
+ // A test runner puts its totals in the middle of its own output as often as at the end — node's
16
+ // TAP summary lands there whenever a second suite follows. Eliding those lines leaves a model
17
+ // reading a plausible but partial count, with nothing to signal that a block went missing.
18
+ const SUMMARY_LINE = /^\s*(?:#\s*(?:tests|pass|fail|skipped|todo|cancelled|suites)\b|test result:|(?:Tests|Test Suites):\s|=+[^=]*\b\d+\s+(?:passed|failed)\b)/i;
19
+ const MAX_DIAGNOSTIC_LINES = 8;
20
+ // Summaries get a reserved share: in a TAP stream most test names contain "error" or "fail", so
21
+ // severe lines would otherwise crowd out the totals that actually answer the question.
22
+ const MAX_SUMMARY_LINES = 4;
23
+ // A TAP block prints seven counters and only three of them answer "did it pass": keeping the
24
+ // block in source order would spend the quota on `cancelled` and `todo`.
25
+ const HEADLINE_SUMMARY_LINE = /^\s*(?:#\s*(?:tests|pass|fail)\b|test result:|Tests:\s)/i;
26
+ const MAX_DIAGNOSTIC_LINE_BYTES = 256;
11
27
 
12
28
  function sha256(text) {
13
29
  return `sha256:${createHash('sha256').update(text).digest('hex')}`;
@@ -75,27 +91,69 @@ function capColumns(text, maxColumns) {
75
91
  return text.split('\n').map((line) => truncateLine(line, maxColumns)).join('\n');
76
92
  }
77
93
 
78
- function middleView(text, maxBytes, headBytes, tailBytes) {
94
+ function middleView(text, maxBytes, headBytes, tailBytes, salvageDiagnostics) {
79
95
  if (Buffer.byteLength(text) <= maxBytes) return text;
80
96
  const marker = '[middle elided]';
81
97
  const markerBytes = Buffer.byteLength(marker);
82
98
  if (maxBytes <= markerBytes) return truncateUtf8(marker, maxBytes);
83
99
  const available = maxBytes - markerBytes;
84
100
  const requested = Math.max(1, headBytes) + Math.max(1, tailBytes);
85
- const head = Math.max(1, Math.floor(available * Math.max(1, headBytes) / requested));
86
- const tail = Math.max(1, available - head);
87
- return `${truncateUtf8(text, head)}${marker}${suffixUtf8(text, tail)}`;
101
+ if (!salvageDiagnostics) {
102
+ const head = Math.max(1, Math.floor(available * Math.max(1, headBytes) / requested));
103
+ const tail = Math.max(1, available - head);
104
+ return `${truncateUtf8(text, head)}${marker}${suffixUtf8(text, tail)}`;
105
+ }
106
+ const totalBytes = Buffer.byteLength(text);
107
+ const diagnosticBudget = Math.max(0, Math.floor(available / 2) - 2);
108
+ const minimumEdgeBytes = available - diagnosticBudget - 2;
109
+ const minimumHead = Math.max(1, Math.floor(minimumEdgeBytes * Math.max(1, headBytes) / requested));
110
+ const minimumTail = Math.max(1, minimumEdgeBytes - minimumHead);
111
+ const summaries = [];
112
+ const severe = [];
113
+ const warnings = [];
114
+ let offset = 0;
115
+ const lines = text.split('\n');
116
+ for (const [index, line] of lines.entries()) {
117
+ const lineBytes = Buffer.byteLength(line);
118
+ if (offset >= minimumHead && offset + lineBytes <= totalBytes - minimumTail) {
119
+ if (SUMMARY_LINE.test(line)) summaries.push(line);
120
+ else if (SEVERE_DIAGNOSTIC_LINE.test(line)) severe.push(line);
121
+ else if (WARNING_LINE.test(line)) warnings.push(line);
122
+ }
123
+ offset += lineBytes + (index < lines.length - 1 ? 1 : 0);
124
+ }
125
+ const keptSummaries = [
126
+ ...summaries.filter((line) => HEADLINE_SUMMARY_LINE.test(line)),
127
+ ...summaries.filter((line) => !HEADLINE_SUMMARY_LINE.test(line)),
128
+ ].slice(0, MAX_SUMMARY_LINES);
129
+ const diagnostics = [];
130
+ let diagnosticBytes = 0;
131
+ for (const line of [...keptSummaries, ...severe, ...warnings].slice(0, MAX_DIAGNOSTIC_LINES)) {
132
+ const separatorBytes = diagnostics.length ? 1 : 0;
133
+ const remaining = diagnosticBudget - diagnosticBytes - separatorBytes;
134
+ if (remaining < 1) break;
135
+ const salvaged = truncateLine(line, Math.min(MAX_DIAGNOSTIC_LINE_BYTES, remaining));
136
+ diagnostics.push(salvaged);
137
+ diagnosticBytes += separatorBytes + Buffer.byteLength(salvaged);
138
+ }
139
+ const diagnosticBlock = diagnostics.length ? `\n${diagnostics.join('\n')}\n` : '';
140
+ const edgeBytes = available - Buffer.byteLength(diagnosticBlock);
141
+ const head = Math.max(1, Math.floor(edgeBytes * Math.max(1, headBytes) / requested));
142
+ const tail = Math.max(1, edgeBytes - head);
143
+ return `${truncateUtf8(text, head)}${marker}${diagnosticBlock}${suffixUtf8(text, tail)}`;
88
144
  }
89
145
 
90
- function inlineView(text, maxBytes, headBytes, tailBytes, maxColumns) {
91
- return middleView(capColumns(text, maxColumns), maxBytes, headBytes, tailBytes);
146
+ function inlineView(text, maxBytes, headBytes, tailBytes, maxColumns, salvageDiagnostics) {
147
+ return middleView(capColumns(text, maxColumns), maxBytes, headBytes, tailBytes, salvageDiagnostics);
92
148
  }
93
149
 
150
+ const DECLARATION_REGEX = /^\s*(?:import\b|from\b|export\b|use\b|package\b|(?:async\s+)?(?:def|function)\b|class\b|interface\b|trait\b|impl\b|struct\b|enum\b|namespace\b|module\b|type\b|(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=|func\b|(?:pub(?:\([^)]+\))?\s+)?fn\b|(?:(?:public|private|protected|static|abstract|async|get|set)\s+)+[A-Za-z_$][\w$]*\s*\(|@[A-Za-z_])/;
151
+
94
152
  function structuralRead(text) {
95
153
  const lines = text.split('\n');
96
- const declaration = /^\s*(?:import\b|export\b|(?:async\s+)?function\b|class\b|interface\b|type\s+[A-Za-z_$][\w$]*\s*=|enum\b|namespace\b|module\b|(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=|(?:(?:public|private|protected|static|abstract|async|get|set)\s+)+[A-Za-z_$][\w$]*\s*\()/;
97
- const selected = lines.flatMap((line, index) => declaration.test(line) ? [`${index + 1}:${line}`] : []);
154
+ const selected = lines.flatMap((line, index) => DECLARATION_REGEX.test(line) ? [`${index + 1}:${line}`] : []);
98
155
  if (!selected.length) return null;
156
+ if (lines.length >= 100 && selected.length < 2) return null;
99
157
  const outline = `[sando read structure: ${selected.length}/${lines.length} lines]\n${selected.join('\n')}`;
100
158
  return Buffer.byteLength(outline) + 64 < Buffer.byteLength(text) ? outline : null;
101
159
  }
@@ -124,6 +182,134 @@ function readSelector(toolInput) {
124
182
  .some((key) => Object.hasOwn(toolInput, key));
125
183
  }
126
184
 
185
+ const SOURCE_EXTENSIONS = new Set([
186
+ '.js', '.mjs', '.cjs', '.jsx',
187
+ '.ts', '.mts', '.cts', '.tsx',
188
+ '.py', '.pyw',
189
+ '.go',
190
+ '.rs',
191
+ '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx',
192
+ '.java', '.kt', '.kts', '.scala',
193
+ '.cs', '.fs',
194
+ '.rb',
195
+ '.php',
196
+ '.swift',
197
+ '.sh', '.bash', '.zsh',
198
+ '.sql',
199
+ '.html', '.htm', '.css', '.scss', '.sass', '.less',
200
+ '.vue', '.svelte',
201
+ '.lua', '.zig', '.nim',
202
+ ]);
203
+
204
+ const STRUCTURED_EXTENSIONS = new Set([
205
+ '.json', '.yaml', '.yml', '.toml', '.xml', '.csv',
206
+ ]);
207
+
208
+ function parseReadCommand(command) {
209
+ if (typeof command !== 'string') return null;
210
+ const trimmed = command.trim();
211
+ const match = /^(?:cat|head|tail|sed)\b\s*(.*)$/.exec(trimmed);
212
+ if (!match) return null;
213
+ const rest = match[1];
214
+ if (/[|><;]/.test(rest)) return null;
215
+ const tokens = rest.split(/\s+/).filter(Boolean);
216
+ if (!tokens.length) return null;
217
+ const last = tokens[tokens.length - 1];
218
+ const cleanedPath = last.replace(/^['"]|['"]$/g, '');
219
+ const isSedSelector = /^sed\b/.test(trimmed) && /-n\b/.test(trimmed);
220
+ const isHeadOrTail = /^(?:head|tail)\b/.test(trimmed);
221
+ return {
222
+ filePath: cleanedPath,
223
+ selector: isSedSelector || isHeadOrTail,
224
+ };
225
+ }
226
+
227
+ function resolveSourceClass({ toolName, toolInput }) {
228
+ const name = typeof toolName === 'string' ? toolName.toLowerCase() : '';
229
+ if (name === 'grep') return { sourceClass: 'structured', selector: false, filePath: null };
230
+ let filePath = null;
231
+ let isSelector = false;
232
+ if (name === 'read') {
233
+ filePath = toolInput?.file_path ?? toolInput?.filePath ?? toolInput?.path ?? null;
234
+ isSelector = readSelector(toolInput);
235
+ } else if (name === 'bash') {
236
+ const cmd = typeof toolInput?.command === 'string' ? toolInput.command : null;
237
+ const parsed = cmd ? parseReadCommand(cmd) : null;
238
+ if (parsed) {
239
+ filePath = parsed.filePath;
240
+ isSelector = parsed.selector;
241
+ } else {
242
+ return { sourceClass: 'process-output', selector: false, filePath: null };
243
+ }
244
+ } else {
245
+ return { sourceClass: 'generic', selector: false, filePath: null };
246
+ }
247
+
248
+ if (filePath) {
249
+ const dotIndex = filePath.lastIndexOf('.');
250
+ if (dotIndex !== -1) {
251
+ const ext = filePath.slice(dotIndex).toLowerCase();
252
+ if (STRUCTURED_EXTENSIONS.has(ext)) return { sourceClass: 'structured-data', selector: isSelector, filePath };
253
+ if (ext === '.log' || filePath.endsWith('.min.js') || ext === '.map') return { sourceClass: 'bulk', selector: isSelector, filePath };
254
+ if (SOURCE_EXTENSIONS.has(ext)) return { sourceClass: 'source', selector: isSelector, filePath };
255
+ }
256
+ }
257
+ return { sourceClass: 'source', selector: isSelector, filePath };
258
+ }
259
+
260
+ const SOURCE_CLASS_LIMITS = Object.freeze({
261
+ source: Object.freeze({
262
+ maxInlineBytes: 32 * 1024,
263
+ headBytes: 20 * 1024,
264
+ tailBytes: 10 * 1024,
265
+ }),
266
+ 'structured-data': Object.freeze({
267
+ maxInlineBytes: 8 * 1024,
268
+ headBytes: 5 * 1024,
269
+ tailBytes: 2 * 1024,
270
+ }),
271
+ 'process-output': Object.freeze({
272
+ maxInlineBytes: 4 * 1024,
273
+ headBytes: 2457,
274
+ tailBytes: 1024,
275
+ }),
276
+ bulk: Object.freeze({
277
+ maxInlineBytes: 4 * 1024,
278
+ headBytes: 2048,
279
+ tailBytes: 1024,
280
+ }),
281
+ generic: Object.freeze({
282
+ maxInlineBytes: 4 * 1024,
283
+ headBytes: 2457,
284
+ tailBytes: 1024,
285
+ }),
286
+ });
287
+
288
+ // Appends the recovery command to the `[sando] artifact ...` line so the model reading a bounded
289
+ // result can see how to get the rest back. Space for it was reserved before the view was cut.
290
+ function withRecoveryHint(inline, artifact, elidedRange) {
291
+ const header = `[sando] artifact ${artifact.ref} ${artifact.bytes}B`;
292
+ if (!inline.startsWith(header)) return inline;
293
+ const range = elidedRange && Number.isInteger(elidedRange.startLine) && Number.isInteger(elidedRange.endLine)
294
+ ? ` --start-line ${elidedRange.startLine} --end-line ${elidedRange.endLine}`
295
+ : ' --max-bytes 65536';
296
+ return `${header} recover: sando artifact get --ref ${artifact.ref}${range}${inline.slice(header.length)}`;
297
+ }
298
+
299
+ function calculateElidedRange(fullText, inlineText) {
300
+ const marker = '[middle elided]';
301
+ const markerIndex = inlineText.indexOf(marker);
302
+ if (markerIndex === -1) return null;
303
+ const headPart = inlineText.slice(0, markerIndex);
304
+ const tailPart = inlineText.slice(markerIndex + marker.length);
305
+ const totalLines = fullText.split('\n').length;
306
+ const headLines = headPart.split('\n').length;
307
+ const tailLines = tailPart.split('\n').length;
308
+ const startLine = Math.max(1, headLines);
309
+ const endLine = Math.max(startLine, totalLines - tailLines + 1);
310
+ return { startLine, endLine };
311
+ }
312
+
127
313
  export function estimateTokens(text) {
128
314
  if (typeof text !== 'string') throw new TypeError('text must be a string');
129
315
  return text.length === 0 ? 0 : Math.ceil(Buffer.byteLength(text) / 4);
@@ -155,61 +341,131 @@ export function optimizeToolOutput({
155
341
  const normalizedPolicy = normalizePolicy(policy);
156
342
  const input = textOutput(output);
157
343
  const name = toolName.toLowerCase();
344
+ const { sourceClass, selector: detectedSelector } = resolveSourceClass({ toolName, toolInput });
345
+ const isTargetedSelector = selector ?? (detectedSelector || readSelector(toolInput));
346
+ let baseInlineBudget = normalizedPolicy.maxInlineBytes;
347
+ let baseHeadBytes = normalizedPolicy.headBytes;
348
+ let baseTailBytes = normalizedPolicy.tailBytes;
349
+
350
+ if (sourceClass === 'source' || sourceClass === 'structured-data') {
351
+ const classLimit = SOURCE_CLASS_LIMITS[sourceClass];
352
+ const hasCustomInline = policy !== undefined && policy !== null
353
+ && Object.hasOwn(policy, 'maxInlineBytes')
354
+ && policy.maxInlineBytes !== DEFAULT_POLICY.maxInlineBytes;
355
+ if (!hasCustomInline) {
356
+ baseInlineBudget = classLimit.maxInlineBytes;
357
+ if (!policy || !Object.hasOwn(policy, 'headBytes')) baseHeadBytes = classLimit.headBytes;
358
+ if (!policy || !Object.hasOwn(policy, 'tailBytes')) baseTailBytes = classLimit.tailBytes;
359
+ }
360
+ }
158
361
  const derivedLineCount = lineCount ?? (name === 'read' ? input.split(/\r?\n/).length : lineCount);
159
362
  const derivedFileBytes = fileBytes ?? (name === 'read' ? Buffer.byteLength(input) : fileBytes);
160
363
  let route = planToolRoute({
161
- toolName, selector: selector ?? readSelector(toolInput), raw: raw ?? toolInput?.raw === true,
364
+ toolName, selector: isTargetedSelector, raw: raw ?? toolInput?.raw === true,
162
365
  lineCount: derivedLineCount, fileBytes: derivedFileBytes, prose, summarizeProse, summarizeEnabled, grepScope,
163
366
  outputBytes: outputBytes ?? Buffer.byteLength(input),
164
367
  });
165
368
  const profile = normalizedPolicy.redact ? resolveRedactionProfile(cwd, redactionProfile) : null;
166
369
  const redacted = profile ? profile.redact(input) : { text: input, count: 0 };
370
+ const cleanedPreview = name === 'bash' ? stripVTControlCharacters(redacted.text) : redacted.text;
371
+ const previewRedacted = profile && name === 'bash'
372
+ ? profile.redact(cleanedPreview)
373
+ : { text: cleanedPreview, count: 0 };
374
+ const previewText = previewRedacted.text;
375
+ const sourceText = previewRedacted.count ? previewText : redacted.text;
167
376
  let modelText = name === 'bash' && normalizedPolicy.maxColumns >= 32
168
- ? collapseRepeatedLines(redacted.text)
169
- : redacted.text;
170
- if (route.route === 'summary') {
171
- const outline = structuralRead(redacted.text);
172
- if (outline) modelText = outline;
173
- else route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'sando-read-bounded' };
174
- }
377
+ ? collapseRepeatedLines(previewText)
378
+ : previewText;
175
379
  const routePolicy = route.route === 'artifact' || route.route === 'structured'
176
380
  ? {
177
381
  ...normalizedPolicy,
382
+ maxInlineBytes: Math.min(baseInlineBudget, route.route === 'artifact' ? (route.limits.headBytes + route.limits.tailBytes) : baseInlineBudget),
178
383
  ...(route.route === 'artifact' ? {
179
- maxInlineBytes: Math.min(normalizedPolicy.maxInlineBytes, route.limits.headBytes + route.limits.tailBytes),
180
- headBytes: Math.min(normalizedPolicy.headBytes, route.limits.headBytes),
181
- tailBytes: Math.min(normalizedPolicy.tailBytes, route.limits.tailBytes),
182
- } : {}),
384
+ headBytes: Math.min(baseHeadBytes, route.limits.headBytes),
385
+ tailBytes: Math.min(baseTailBytes, route.limits.tailBytes),
386
+ } : {
387
+ headBytes: baseHeadBytes,
388
+ tailBytes: baseTailBytes,
389
+ }),
183
390
  maxColumns: Math.min(normalizedPolicy.maxColumns, route.limits.maxColumns),
184
391
  }
185
- : normalizedPolicy;
186
- const sourceBytes = Buffer.byteLength(redacted.text);
392
+ : {
393
+ ...normalizedPolicy,
394
+ maxInlineBytes: baseInlineBudget,
395
+ headBytes: baseHeadBytes,
396
+ tailBytes: baseTailBytes,
397
+ };
398
+ const shouldSummarize = route.route === 'summary'
399
+ || (sourceClass === 'source' && !isTargetedSelector && (derivedLineCount ?? 0) >= 100 && (outputBytes ?? Buffer.byteLength(input)) > routePolicy.maxInlineBytes);
400
+ if (shouldSummarize) {
401
+ const outline = structuralRead(previewText);
402
+ if (outline) {
403
+ modelText = outline;
404
+ if (route.route !== 'summary') {
405
+ route = { route: 'summary', modelVisible: 'elided-structure', source: 'sando-read-summarize' };
406
+ }
407
+ } else if (route.route === 'summary') {
408
+ route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'sando-read-bounded' };
409
+ }
410
+ }
411
+ const sourceBytes = Buffer.byteLength(sourceText);
187
412
  let inline = modelText;
188
413
  let artifact;
414
+ const artifactAdmitted = sourceBytes <= normalizedPolicy.maxArtifactBytes;
189
415
  const hasLongLine = routePolicy.maxColumns > 0
190
416
  && modelText.split('\n').some((line) => Buffer.byteLength(line) > routePolicy.maxColumns);
191
- if (route.route === 'summary' || route.route === 'artifact' || sourceBytes > routePolicy.maxInlineBytes || hasLongLine) {
192
- const sourceDigest = sha256(redacted.text);
417
+ let recoveryHintAffordable = false;
418
+ if (!artifactAdmitted && (route.route === 'summary' || route.route === 'artifact'
419
+ || sourceBytes > routePolicy.maxInlineBytes || hasLongLine)) {
420
+ route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'artifact-admission-limit' };
421
+ inline = truncateUtf8(inlineView(
422
+ modelText,
423
+ routePolicy.maxInlineBytes,
424
+ routePolicy.headBytes,
425
+ routePolicy.tailBytes,
426
+ routePolicy.maxColumns,
427
+ name === 'bash',
428
+ ), normalizedPolicy.maxInlineBytes);
429
+ } else if (route.route === 'summary' || route.route === 'artifact' || sourceBytes > routePolicy.maxInlineBytes || hasLongLine) {
430
+ const sourceDigest = sha256(sourceText);
193
431
  artifact = {
194
432
  schema: 'sando-artifact/v1',
195
433
  ref: `sando:${sourceDigest.slice(0, 23)}`,
196
434
  digest: sourceDigest,
197
435
  sourceDigest,
198
436
  mediaType: 'text/plain; charset=utf-8',
199
- content: redacted.text,
437
+ content: sourceText,
200
438
  bytes: sourceBytes,
201
439
  sourceBytes,
202
440
  truncated: false,
203
441
  };
442
+ // The recovery command has to reach the model, not just the disclosure object: on the CLI
443
+ // surface the structured disclosure is never rendered, so a bare artifact path leaves the
444
+ // model to improvise its way back to the elided middle. The exact line range is only known
445
+ // after the view is cut, so reserve the widest header the range could need and rewrite it
446
+ // once the range is settled -- reserving after the fact would push the payload over the cap.
204
447
  const header = `[sando] artifact ${artifact.ref} ${artifact.bytes}B\n`;
205
- const viewBudget = Math.max(1, routePolicy.maxInlineBytes - Buffer.byteLength(header));
206
- inline = `${truncateUtf8(header, routePolicy.maxInlineBytes)}${inlineView(
207
- modelText,
208
- viewBudget,
209
- routePolicy.headBytes,
210
- routePolicy.tailBytes,
211
- routePolicy.maxColumns,
212
- )}`;
448
+ const widestLineNumber = String(sourceText.split('\n').length).length;
449
+ const reservedHeader = Buffer.byteLength(header)
450
+ + Buffer.byteLength(` recover: sando artifact get --ref ${artifact.ref} --start-line --end-line `)
451
+ + (widestLineNumber * 2);
452
+ // Under a tight cap the hint would cost more room than the content it points at, so it is
453
+ // only affordable when the whole header stays a small fraction of the budget.
454
+ recoveryHintAffordable = reservedHeader * 4 <= routePolicy.maxInlineBytes;
455
+ const viewBudget = Math.max(1, routePolicy.maxInlineBytes - (recoveryHintAffordable ? reservedHeader : Buffer.byteLength(header)));
456
+ const isOutline = typeof modelText === 'string' && modelText.startsWith('[sando read structure:');
457
+ if (isOutline && Buffer.byteLength(modelText) <= viewBudget) {
458
+ inline = `${truncateUtf8(header, routePolicy.maxInlineBytes)}${modelText}`;
459
+ } else {
460
+ inline = `${truncateUtf8(header, routePolicy.maxInlineBytes)}${inlineView(
461
+ modelText,
462
+ viewBudget,
463
+ routePolicy.headBytes,
464
+ routePolicy.tailBytes,
465
+ routePolicy.maxColumns,
466
+ name === 'bash',
467
+ )}`;
468
+ }
213
469
  inline = truncateUtf8(inline, routePolicy.maxInlineBytes);
214
470
  }
215
471
  const stats = {
@@ -220,12 +476,21 @@ export function optimizeToolOutput({
220
476
  artifactBytes: artifact?.bytes ?? 0,
221
477
  estimatedInputTokens: estimateTokens(input),
222
478
  estimatedInlineTokens: estimateTokens(inline),
223
- redactions: redacted.count,
479
+ redactions: redacted.count + previewRedacted.count,
224
480
  artifactTruncated: artifact?.truncated ?? false,
225
481
  };
482
+ const elidedRange = artifact && inline.includes('[middle elided]')
483
+ ? calculateElidedRange(sourceText, inline)
484
+ : undefined;
485
+ if (artifact && recoveryHintAffordable) inline = withRecoveryHint(inline, artifact, elidedRange);
226
486
  const result = {
227
487
  inline, route: route.route, reason: route.source, policyVersion: ROUTING_POLICY_VERSION,
228
488
  redactionProfileDigest: profile?.digest ?? null, stats,
489
+ disclosure: buildResultDisclosure({
490
+ toolName, route: route.route, reason: route.source, inline,
491
+ redactedText: sourceText, inputBytes: Buffer.byteLength(input), redactedBytes: sourceBytes, artifact,
492
+ elidedRange,
493
+ }),
229
494
  };
230
495
  if (artifact) result.artifact = artifact;
231
496
  return result;
@@ -0,0 +1,80 @@
1
+ import { PLUGIN_VERSION } from './version.mjs';
2
+ import { SCHEMA_VERSION, byteBucket, countBucket, serializeEvent, toOtlpLogs } from './telemetry.mjs';
3
+
4
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:4319/v1/logs';
5
+ const HOSTS = ['claude', 'codex'];
6
+ const STATUSES = ['complete', 'partial', 'unavailable'];
7
+ const RATIO_BUCKETS = ['zero', 'lt_1pct', '1_to_10pct', 'gt_10pct', 'unavailable'];
8
+
9
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
10
+
11
+ function optionalCounter(value, name) {
12
+ if (value === null || value === undefined) return null;
13
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
14
+ return value;
15
+ }
16
+
17
+ function optionalBytes(value) { return optionalCounter(value, 'F1 body bytes'); }
18
+
19
+ function ratioBucket(value) {
20
+ if (value === null || value === undefined) return 'unavailable';
21
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
22
+ throw new TypeError('F1 unknown ratio is invalid');
23
+ }
24
+ if (value === 0) return 'zero';
25
+ if (value <= 0.01) return 'lt_1pct';
26
+ if (value <= 0.1) return '1_to_10pct';
27
+ return 'gt_10pct';
28
+ }
29
+
30
+ function bucket(value, build, name) {
31
+ if (value === null || value === undefined) return 'unavailable';
32
+ try { return build(value); } catch { throw new TypeError(`${name} is invalid`); }
33
+ }
34
+
35
+ export function buildF1TelemetryEvent(record) {
36
+ if (!object(record) || !HOSTS.includes(record.host) || typeof record.at !== 'string') {
37
+ throw new TypeError('F1 capture record is invalid');
38
+ }
39
+ const at = new Date(record.at);
40
+ if (Number.isNaN(at.getTime()) || !object(record.report) || !object(record.report.attribution)) {
41
+ throw new TypeError('F1 capture report is invalid');
42
+ }
43
+ const status = record.report.attribution.status;
44
+ if (!STATUSES.includes(status)) throw new TypeError('F1 attribution status is invalid');
45
+ const bodyBytes = optionalBytes(record.report.attribution.bodyBytes);
46
+ const providerReported = record.report.tokenAccounting?.providerReported;
47
+ const inputTokens = optionalCounter(providerReported?.inputTokens, 'F1 provider input tokens');
48
+ const event = {
49
+ schema_version: SCHEMA_VERSION,
50
+ event: 'f1_footprint',
51
+ day_utc: at.toISOString().slice(0, 10),
52
+ plugin_version: PLUGIN_VERSION,
53
+ f1_host: record.host,
54
+ f1_status: status,
55
+ f1_unknown_ratio_bucket: ratioBucket(record.report.attribution.unknownRatio),
56
+ f1_body_size_bucket: bucket(bodyBytes, byteBucket, 'F1 body bytes'),
57
+ f1_input_tokens_bucket: bucket(inputTokens, countBucket, 'F1 provider input tokens'),
58
+ };
59
+ serializeEvent(event);
60
+ return event;
61
+ }
62
+
63
+ export async function publishF1Telemetry({ record, endpoint = process.env.SANDO_F1_TELEMETRY_ENDPOINT || DEFAULT_ENDPOINT,
64
+ fetchImpl = fetch, timeoutMs = 2500 } = {}) {
65
+ const event = buildF1TelemetryEvent(record);
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
68
+ try {
69
+ const response = await fetchImpl(endpoint, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json' },
72
+ body: JSON.stringify(toOtlpLogs([{ ...event, _timeUnixNano: (BigInt(Date.now()) * 1000000n).toString() }])),
73
+ signal: controller.signal,
74
+ });
75
+ if (!response.ok) throw new Error(`F1 telemetry endpoint returned ${response.status}`);
76
+ return { events: 1, status: response.status };
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }