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/README.md +5 -1
- package/index.mjs +18 -1
- package/package.json +1 -1
- package/src/accounting-cli.mjs +1 -1
- package/src/artifact-lifecycle.mjs +67 -0
- package/src/artifact-recovery.mjs +5 -0
- package/src/artifact-store.mjs +2 -1
- package/src/cache-attribution.mjs +13 -3
- package/src/context-transform.mjs +169 -17
- package/src/core.mjs +283 -34
- package/src/history-archive.mjs +80 -0
- package/src/hook-cli.mjs +17 -1
- package/src/lazy-mcp-gateway.mjs +10 -6
- package/src/mcp-server.mjs +47 -12
- package/src/metrics.mjs +4 -3
- package/src/provider-usage.mjs +103 -23
- package/src/proxy.mjs +83 -7
- package/src/result-disclosure.mjs +8 -2
- package/src/semantic-gate.mjs +69 -0
- package/src/semantic-judge.mjs +261 -0
- package/src/slice.mjs +419 -0
- package/src/statusline.mjs +5 -9
- package/src/telemetry.mjs +101 -19
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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:
|
|
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(
|
|
170
|
-
:
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
:
|
|
187
|
-
|
|
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(
|
|
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:
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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:
|
|
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';
|
package/src/lazy-mcp-gateway.mjs
CHANGED
|
@@ -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 =
|
|
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(
|
|
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);
|