sandoichi 0.4.2 → 0.6.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,4 +1,5 @@
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';
@@ -9,6 +10,20 @@ const DEFAULT_POLICY = Object.freeze({
9
10
  maxColumns: 768, redact: true,
10
11
  });
11
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;
12
27
 
13
28
  function sha256(text) {
14
29
  return `sha256:${createHash('sha256').update(text).digest('hex')}`;
@@ -76,27 +91,69 @@ function capColumns(text, maxColumns) {
76
91
  return text.split('\n').map((line) => truncateLine(line, maxColumns)).join('\n');
77
92
  }
78
93
 
79
- function middleView(text, maxBytes, headBytes, tailBytes) {
94
+ function middleView(text, maxBytes, headBytes, tailBytes, salvageDiagnostics) {
80
95
  if (Buffer.byteLength(text) <= maxBytes) return text;
81
96
  const marker = '[middle elided]';
82
97
  const markerBytes = Buffer.byteLength(marker);
83
98
  if (maxBytes <= markerBytes) return truncateUtf8(marker, maxBytes);
84
99
  const available = maxBytes - markerBytes;
85
100
  const requested = Math.max(1, headBytes) + Math.max(1, tailBytes);
86
- const head = Math.max(1, Math.floor(available * Math.max(1, headBytes) / requested));
87
- const tail = Math.max(1, available - head);
88
- 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)}`;
89
144
  }
90
145
 
91
- function inlineView(text, maxBytes, headBytes, tailBytes, maxColumns) {
92
- 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);
93
148
  }
94
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
+
95
152
  function structuralRead(text) {
96
153
  const lines = text.split('\n');
97
- 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*\()/;
98
- 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}`] : []);
99
155
  if (!selected.length) return null;
156
+ if (lines.length >= 100 && selected.length < 2) return null;
100
157
  const outline = `[sando read structure: ${selected.length}/${lines.length} lines]\n${selected.join('\n')}`;
101
158
  return Buffer.byteLength(outline) + 64 < Buffer.byteLength(text) ? outline : null;
102
159
  }
@@ -125,6 +182,134 @@ function readSelector(toolInput) {
125
182
  .some((key) => Object.hasOwn(toolInput, key));
126
183
  }
127
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
+
128
313
  export function estimateTokens(text) {
129
314
  if (typeof text !== 'string') throw new TypeError('text must be a string');
130
315
  return text.length === 0 ? 0 : Math.ceil(Buffer.byteLength(text) / 4);
@@ -156,40 +341,80 @@ export function optimizeToolOutput({
156
341
  const normalizedPolicy = normalizePolicy(policy);
157
342
  const input = textOutput(output);
158
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
+ }
159
361
  const derivedLineCount = lineCount ?? (name === 'read' ? input.split(/\r?\n/).length : lineCount);
160
362
  const derivedFileBytes = fileBytes ?? (name === 'read' ? Buffer.byteLength(input) : fileBytes);
161
363
  let route = planToolRoute({
162
- toolName, selector: selector ?? readSelector(toolInput), raw: raw ?? toolInput?.raw === true,
364
+ toolName, selector: isTargetedSelector, raw: raw ?? toolInput?.raw === true,
163
365
  lineCount: derivedLineCount, fileBytes: derivedFileBytes, prose, summarizeProse, summarizeEnabled, grepScope,
164
366
  outputBytes: outputBytes ?? Buffer.byteLength(input),
165
367
  });
166
368
  const profile = normalizedPolicy.redact ? resolveRedactionProfile(cwd, redactionProfile) : null;
167
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;
168
376
  let modelText = name === 'bash' && normalizedPolicy.maxColumns >= 32
169
- ? collapseRepeatedLines(redacted.text)
170
- : redacted.text;
171
- if (route.route === 'summary') {
172
- const outline = structuralRead(redacted.text);
173
- if (outline) modelText = outline;
174
- else route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'sando-read-bounded' };
175
- }
377
+ ? collapseRepeatedLines(previewText)
378
+ : previewText;
176
379
  const routePolicy = route.route === 'artifact' || route.route === 'structured'
177
380
  ? {
178
381
  ...normalizedPolicy,
382
+ maxInlineBytes: Math.min(baseInlineBudget, route.route === 'artifact' ? (route.limits.headBytes + route.limits.tailBytes) : baseInlineBudget),
179
383
  ...(route.route === 'artifact' ? {
180
- maxInlineBytes: Math.min(normalizedPolicy.maxInlineBytes, route.limits.headBytes + route.limits.tailBytes),
181
- headBytes: Math.min(normalizedPolicy.headBytes, route.limits.headBytes),
182
- tailBytes: Math.min(normalizedPolicy.tailBytes, route.limits.tailBytes),
183
- } : {}),
384
+ headBytes: Math.min(baseHeadBytes, route.limits.headBytes),
385
+ tailBytes: Math.min(baseTailBytes, route.limits.tailBytes),
386
+ } : {
387
+ headBytes: baseHeadBytes,
388
+ tailBytes: baseTailBytes,
389
+ }),
184
390
  maxColumns: Math.min(normalizedPolicy.maxColumns, route.limits.maxColumns),
185
391
  }
186
- : normalizedPolicy;
187
- 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);
188
412
  let inline = modelText;
189
413
  let artifact;
190
414
  const artifactAdmitted = sourceBytes <= normalizedPolicy.maxArtifactBytes;
191
415
  const hasLongLine = routePolicy.maxColumns > 0
192
416
  && modelText.split('\n').some((line) => Buffer.byteLength(line) > routePolicy.maxColumns);
417
+ let recoveryHintAffordable = false;
193
418
  if (!artifactAdmitted && (route.route === 'summary' || route.route === 'artifact'
194
419
  || sourceBytes > routePolicy.maxInlineBytes || hasLongLine)) {
195
420
  route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'artifact-admission-limit' };
@@ -199,29 +424,48 @@ export function optimizeToolOutput({
199
424
  routePolicy.headBytes,
200
425
  routePolicy.tailBytes,
201
426
  routePolicy.maxColumns,
427
+ name === 'bash',
202
428
  ), normalizedPolicy.maxInlineBytes);
203
429
  } else if (route.route === 'summary' || route.route === 'artifact' || sourceBytes > routePolicy.maxInlineBytes || hasLongLine) {
204
- const sourceDigest = sha256(redacted.text);
430
+ const sourceDigest = sha256(sourceText);
205
431
  artifact = {
206
432
  schema: 'sando-artifact/v1',
207
433
  ref: `sando:${sourceDigest.slice(0, 23)}`,
208
434
  digest: sourceDigest,
209
435
  sourceDigest,
210
436
  mediaType: 'text/plain; charset=utf-8',
211
- content: redacted.text,
437
+ content: sourceText,
212
438
  bytes: sourceBytes,
213
439
  sourceBytes,
214
440
  truncated: false,
215
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.
216
447
  const header = `[sando] artifact ${artifact.ref} ${artifact.bytes}B\n`;
217
- const viewBudget = Math.max(1, routePolicy.maxInlineBytes - Buffer.byteLength(header));
218
- inline = `${truncateUtf8(header, routePolicy.maxInlineBytes)}${inlineView(
219
- modelText,
220
- viewBudget,
221
- routePolicy.headBytes,
222
- routePolicy.tailBytes,
223
- routePolicy.maxColumns,
224
- )}`;
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
+ }
225
469
  inline = truncateUtf8(inline, routePolicy.maxInlineBytes);
226
470
  }
227
471
  const stats = {
@@ -232,15 +476,20 @@ export function optimizeToolOutput({
232
476
  artifactBytes: artifact?.bytes ?? 0,
233
477
  estimatedInputTokens: estimateTokens(input),
234
478
  estimatedInlineTokens: estimateTokens(inline),
235
- redactions: redacted.count,
479
+ redactions: redacted.count + previewRedacted.count,
236
480
  artifactTruncated: artifact?.truncated ?? false,
237
481
  };
482
+ const elidedRange = artifact && inline.includes('[middle elided]')
483
+ ? calculateElidedRange(sourceText, inline)
484
+ : undefined;
485
+ if (artifact && recoveryHintAffordable) inline = withRecoveryHint(inline, artifact, elidedRange);
238
486
  const result = {
239
487
  inline, route: route.route, reason: route.source, policyVersion: ROUTING_POLICY_VERSION,
240
488
  redactionProfileDigest: profile?.digest ?? null, stats,
241
489
  disclosure: buildResultDisclosure({
242
490
  toolName, route: route.route, reason: route.source, inline,
243
- redactedText: redacted.text, inputBytes: Buffer.byteLength(input), redactedBytes: sourceBytes, artifact,
491
+ redactedText: sourceText, inputBytes: Buffer.byteLength(input), redactedBytes: sourceBytes, artifact,
492
+ elidedRange,
244
493
  }),
245
494
  };
246
495
  if (artifact) result.artifact = artifact;
@@ -0,0 +1,80 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
6
+ const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
7
+
8
+ function shellQuote(value) {
9
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
10
+ }
11
+
12
+ function safeDirectory(target) {
13
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
14
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('history artifact directory is unsafe');
15
+ if (!stat) fs.mkdirSync(target, { mode: 0o700 });
16
+ }
17
+
18
+ function storedBytes(directory, destination) {
19
+ let total = 0;
20
+ for (const name of fs.readdirSync(directory)) {
21
+ if (!/^[a-f0-9]{64}\.txt$/.test(name)) continue;
22
+ const target = path.join(directory, name);
23
+ const stat = fs.lstatSync(target);
24
+ if (!stat.isFile() || stat.isSymbolicLink() || fs.realpathSync(target) !== target) {
25
+ throw new Error('history artifact directory contains an unsafe entry');
26
+ }
27
+ if (target !== destination) total += stat.size;
28
+ }
29
+ return total;
30
+ }
31
+
32
+ export function prepareHistoryArtifact({ root, content } = {}) {
33
+ if (typeof root !== 'string' || !path.isAbsolute(root)) throw new TypeError('history archive root must be an absolute path');
34
+ if (typeof content !== 'string') throw new TypeError('history artifact content is invalid');
35
+ const canonicalRoot = fs.realpathSync(root);
36
+ if (!fs.statSync(canonicalRoot).isDirectory()) throw new TypeError('history archive root is not a directory');
37
+ const bytes = Buffer.byteLength(content);
38
+ if (bytes > MAX_ARTIFACT_BYTES) throw new RangeError('history artifact exceeds recovery limit');
39
+ const digest = `sha256:${createHash('sha256').update(content).digest('hex')}`;
40
+ const ref = `sando:${digest}`;
41
+ const artifactPath = path.join(canonicalRoot, '.sando', 'sando', 'artifacts', `${digest.slice('sha256:'.length)}.txt`);
42
+ const lines = content.split('\n').length;
43
+ const firstPageEnd = Math.min(80, lines);
44
+ return {
45
+ root: canonicalRoot,
46
+ content,
47
+ bytes,
48
+ digest,
49
+ ref,
50
+ marker: `[sando archived result ${ref}; ${bytes}B, ${lines} lines; use rtk grep -n on archive ${shellQuote(artifactPath)}; full exact text: use native Read on the archive file; optional first page: sando artifact get --root ${shellQuote(canonicalRoot)} --ref ${ref} --start-line 1 --end-line ${firstPageEnd} --max-bytes 8192; bounded, continue with valid line ranges up to ${lines}]`,
51
+ };
52
+ }
53
+
54
+ export function persistHistoryArtifact(artifact) {
55
+ if (!artifact || typeof artifact.root !== 'string' || typeof artifact.content !== 'string'
56
+ || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest ?? '') || artifact.ref !== `sando:${artifact.digest}`) {
57
+ throw new TypeError('history artifact is invalid');
58
+ }
59
+ const stateRoot = path.join(artifact.root, '.sando');
60
+ const privateRoot = path.join(stateRoot, 'sando');
61
+ const directory = path.join(privateRoot, 'artifacts');
62
+ for (const target of [stateRoot, privateRoot, directory]) safeDirectory(target);
63
+ const name = `${artifact.digest.slice('sha256:'.length)}.txt`;
64
+ const destination = path.join(directory, name);
65
+ if (storedBytes(directory, destination) + artifact.bytes > MAX_ARCHIVE_BYTES) {
66
+ throw new Error('history artifact archive is full');
67
+ }
68
+ const temporary = path.join(directory, `.${name}.${process.pid}.${randomUUID()}`);
69
+ try {
70
+ fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
71
+ try { fs.linkSync(temporary, destination); }
72
+ catch (error) {
73
+ if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
74
+ }
75
+ } finally {
76
+ fs.rmSync(temporary, { force: true });
77
+ }
78
+ fs.chmodSync(destination, 0o600);
79
+ return artifact;
80
+ }
package/src/hook-cli.mjs CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
 
5
5
  import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
6
+ import { cleanupArtifacts } from './artifact-lifecycle.mjs';
6
7
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
7
8
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
8
9
  import {
@@ -12,6 +13,13 @@ import { PLUGIN_VERSION } from './version.mjs';
12
13
 
13
14
  function todayUtc() { return new Date().toISOString().slice(0, 10); }
14
15
 
16
+ function artifactPresent(target) {
17
+ let stat;
18
+ try { stat = fs.lstatSync(target); } catch { return false; }
19
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
20
+ try { return fs.realpathSync(target) === target; } catch { return false; }
21
+ }
22
+
15
23
  /** Only counts (never content, paths, or IDs). */
16
24
  function recordHookTelemetry({ host, env, policy, optimization }) {
17
25
  try {
@@ -23,6 +31,7 @@ function recordHookTelemetry({ host, env, policy, optimization }) {
23
31
  incrementCounter({
24
32
  statePaths,
25
33
  day: todayUtc(),
34
+ pluginVersion: PLUGIN_VERSION,
26
35
  event: 'hook_summary',
27
36
  host,
28
37
  mode: policy.mode === 'apply' ? 'enforce' : policy.mode === 'dry-run' ? 'dry_run' : 'observe',
@@ -46,7 +55,7 @@ function recordHookFailure({ host, env, failureStage }) {
46
55
  const statePaths = defaultTelemetryStatePaths(env);
47
56
  const day = todayUtc();
48
57
  recordActiveDay({ statePaths, day, pluginVersion: PLUGIN_VERSION, host });
49
- recordFailure({ statePaths, day, event: 'hook_failure_summary', host, failureStage });
58
+ recordFailure({ statePaths, day, pluginVersion: PLUGIN_VERSION, event: 'hook_failure_summary', host, failureStage });
50
59
  closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
51
60
  } catch { /* telemetry is best-effort and must never affect hook output */ }
52
61
  }
@@ -69,6 +78,7 @@ function artifactPath(cwd, artifact) {
69
78
  if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('artifact directory is unsafe');
70
79
  if (!stat) fs.mkdirSync(target, { mode: 0o700 });
71
80
  }
81
+ cleanupArtifacts(directory);
72
82
  const name = `${artifact.sourceDigest.slice('sha256:'.length)}.txt`;
73
83
  const destination = path.join(directory, name);
74
84
  const temporary = path.join(directory, `.${name}.${process.pid}.${randomUUID()}`);
@@ -82,6 +92,8 @@ function artifactPath(cwd, artifact) {
82
92
  fs.rmSync(temporary, { force: true });
83
93
  }
84
94
  fs.chmodSync(destination, 0o600);
95
+ cleanupArtifacts(directory, { preserveName: name });
96
+ if (!artifactPresent(destination)) throw new Error('artifact storage limit removed the new artifact');
85
97
  return path.posix.join('.sando/sando', 'artifacts', name);
86
98
  }
87
99
 
@@ -101,6 +113,10 @@ export function runHookCli({ host, env = process.env } = {}) {
101
113
  const eventName = input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName;
102
114
  if (eventName === 'PostToolUse') {
103
115
  const event = normalizeEvent(input);
116
+ if (host === 'claude' && event.toolName.startsWith('mcp__')) {
117
+ process.stdout.write('{}\n');
118
+ return;
119
+ }
104
120
  failureStage = 'redaction';
105
121
  const redactionProfile = policy.redact ? loadProjectRedactionProfile(event.cwd).profile : undefined;
106
122
  failureStage = 'optimization';
@@ -4,11 +4,13 @@ export const LAZY_MCP_GATEWAY_SCHEMA = 'sando-lazy-mcp-gateway/v1';
4
4
  export const GATEWAY_CATALOG_TOOL = 'sando_catalog';
5
5
  export const GATEWAY_CALL_TOOL = 'sando_call';
6
6
  const MAX_CATALOG_RESULTS = 50;
7
+ const DESCRIBED_CATALOG_RESULTS = 10;
7
8
  const GATEWAY_CATALOG_SCHEMA = {
8
9
  type: 'object', additionalProperties: false,
9
10
  properties: {
10
11
  query: { type: 'string' },
11
12
  limit: { type: 'integer', minimum: 1, maximum: MAX_CATALOG_RESULTS },
13
+ describe: { type: 'boolean' },
12
14
  },
13
15
  };
14
16
  const GATEWAY_CALL_SCHEMA = {
@@ -182,7 +184,9 @@ export function createLazyMcpGateway(config) {
182
184
  tools.set(`${name}/${descriptor.name}`, { ...descriptor, server: name, capability: descriptor.name });
183
185
  }
184
186
  }
185
- async function catalog(query = '', limit = MAX_CATALOG_RESULTS) {
187
+ async function catalog(query = '', limit, describe = false) {
188
+ // Schemas are far heavier than names, so describe gets a smaller default.
189
+ const effectiveLimit = limit ?? (describe ? DESCRIBED_CATALOG_RESULTS : MAX_CATALOG_RESULTS);
186
190
  const startedAt = Date.now();
187
191
  if (!options.enabled) {
188
192
  emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt });
@@ -197,10 +201,10 @@ export function createLazyMcpGateway(config) {
197
201
  for (const [qualified, descriptor] of tools) if (descriptor.server === name) {
198
202
  const text = tokens(`${qualified} ${descriptor.description ?? ''} ${name} ${(options.servers.get(name).capabilities ?? []).join(' ')}`);
199
203
  const score = queryTokens.reduce((total, token) => total + (text.includes(token) ? 1 : 0), 0);
200
- if (!queryTokens.length || score) records.push({ namespace: name, description: String(descriptor.description ?? '').slice(0, 160), server: name, capability: descriptor.capability, name: qualified, score });
204
+ if (!queryTokens.length || score) records.push({ namespace: name, description: String(descriptor.description ?? '').slice(0, 160), server: name, capability: descriptor.capability, name: qualified, score, ...(describe && descriptor.inputSchema ? { inputSchema: descriptor.inputSchema } : {}) });
201
205
  }
202
206
  }
203
- const result = records.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, Math.min(limit, MAX_CATALOG_RESULTS)).map(({ score, ...record }) => record);
207
+ const result = records.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, Math.min(effectiveLimit, MAX_CATALOG_RESULTS)).map(({ score, ...record }) => record);
204
208
  emitF4Event({ operation: 'catalog', outcome: 'success', startedAt, resultCount: result.length });
205
209
  return result;
206
210
  } catch (error) {
@@ -261,10 +265,10 @@ export function createLazyMcpGateway(config) {
261
265
  emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt: Date.now() });
262
266
  return rpcError(message.id, -32602, `Invalid catalog arguments: ${validation.message}`);
263
267
  }
264
- return response(message.id, { schema: LAZY_MCP_GATEWAY_SCHEMA, entries: await catalog(arguments_.query, arguments_.limit) });
268
+ return response(message.id, { schema: LAZY_MCP_GATEWAY_SCHEMA, entries: await catalog(arguments_.query, arguments_.limit, arguments_.describe) });
265
269
  }
266
270
  if (message.method === 'tools/list') return response(message.id, { tools: [
267
- { name: GATEWAY_CATALOG_TOOL, description: 'Search the explicit allowlisted MCP catalog.', inputSchema: GATEWAY_CATALOG_SCHEMA },
271
+ { name: GATEWAY_CATALOG_TOOL, description: 'Search the explicit allowlisted MCP catalog. Pass describe:true to include each tool argument schema, needed before sando_call.', inputSchema: GATEWAY_CATALOG_SCHEMA },
268
272
  { name: GATEWAY_CALL_TOOL, description: 'Call one exact qualified name returned by sando_catalog.', inputSchema: GATEWAY_CALL_SCHEMA },
269
273
  ] });
270
274
  if (message.method === 'tools/call' && message.params?.name === GATEWAY_CATALOG_TOOL) {
@@ -274,7 +278,7 @@ export function createLazyMcpGateway(config) {
274
278
  emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt: Date.now() });
275
279
  return rpcError(message.id, -32602, `Invalid catalog arguments: ${validation.message}`);
276
280
  }
277
- return response(message.id, { content: [{ type: 'text', text: JSON.stringify(await catalog(arguments_.query, arguments_.limit)) }] });
281
+ return response(message.id, { content: [{ type: 'text', text: JSON.stringify(await catalog(arguments_.query, arguments_.limit, arguments_.describe)) }] });
278
282
  }
279
283
  if (message.method === 'tools/call' && message.params?.name === GATEWAY_CALL_TOOL) {
280
284
  const validation = validateJsonSchema(GATEWAY_CALL_SCHEMA, message.params?.arguments);