sandoichi 0.1.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/LICENSE +21 -0
- package/README.md +25 -0
- package/index.mjs +54 -0
- package/package.json +27 -0
- package/src/active-session.mjs +136 -0
- package/src/cache-attribution.mjs +182 -0
- package/src/context-transform.mjs +492 -0
- package/src/core.mjs +258 -0
- package/src/history-budget.mjs +36 -0
- package/src/history-dedupe.mjs +98 -0
- package/src/history-shake.mjs +66 -0
- package/src/history-structure.mjs +28 -0
- package/src/hook-cli.mjs +118 -0
- package/src/mcp-server.mjs +45 -0
- package/src/metrics-cli.mjs +34 -0
- package/src/metrics.mjs +390 -0
- package/src/provider-ledger.mjs +138 -0
- package/src/provider-usage.mjs +224 -0
- package/src/proxy-metrics.mjs +34 -0
- package/src/proxy.mjs +212 -0
- package/src/routing.mjs +89 -0
- package/src/secret-redaction.mjs +21 -0
- package/src/semantic-compactor.mjs +218 -0
- package/src/statusline.mjs +100 -0
package/src/core.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { planToolRoute, ROUTING_POLICY_VERSION } from './routing.mjs';
|
|
4
|
+
import { redact } from './secret-redaction.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
7
|
+
mode: 'apply', maxInlineBytes: 4096, maxArtifactBytes: 65536, headBytes: undefined, tailBytes: undefined,
|
|
8
|
+
maxColumns: 768, redact: true,
|
|
9
|
+
});
|
|
10
|
+
const POLICY_FIELDS = new Set(Object.keys(DEFAULT_POLICY));
|
|
11
|
+
|
|
12
|
+
function sha256(text) {
|
|
13
|
+
return `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stableJson(value, seen = new Set()) {
|
|
17
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
18
|
+
if (seen.has(value)) throw new Error('output must not be cyclic');
|
|
19
|
+
seen.add(value);
|
|
20
|
+
let result;
|
|
21
|
+
if (Array.isArray(value)) result = `[${value.map((item) => stableJson(item, seen)).join(',')}]`;
|
|
22
|
+
else result = `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
|
|
23
|
+
seen.delete(value);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function textOutput(output) {
|
|
28
|
+
if (typeof output === 'string') return output;
|
|
29
|
+
const value = stableJson(output);
|
|
30
|
+
if (value === undefined) throw new Error('output must be a string or JSON value');
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function truncateUtf8(text, maxBytes) {
|
|
35
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
let output = '';
|
|
38
|
+
for (const character of text) {
|
|
39
|
+
const size = Buffer.byteLength(character);
|
|
40
|
+
if (bytes + size > maxBytes) break;
|
|
41
|
+
output += character;
|
|
42
|
+
bytes += size;
|
|
43
|
+
}
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function suffixUtf8(text, maxBytes) {
|
|
48
|
+
let bytes = 0;
|
|
49
|
+
const characters = [];
|
|
50
|
+
for (const character of [...text].reverse()) {
|
|
51
|
+
const size = Buffer.byteLength(character);
|
|
52
|
+
if (bytes + size > maxBytes) break;
|
|
53
|
+
characters.push(character);
|
|
54
|
+
bytes += size;
|
|
55
|
+
}
|
|
56
|
+
return characters.reverse().join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function truncateLine(text, maxBytes) {
|
|
60
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
61
|
+
if (maxBytes <= 1) return '~';
|
|
62
|
+
return `${truncateUtf8(text, maxBytes - 1)}~`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function capColumns(text, maxColumns) {
|
|
66
|
+
if (!maxColumns) return text;
|
|
67
|
+
return text.split('\n').map((line) => truncateLine(line, maxColumns)).join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function middleView(text, maxBytes, headBytes, tailBytes) {
|
|
71
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
72
|
+
const marker = '[middle elided]';
|
|
73
|
+
const markerBytes = Buffer.byteLength(marker);
|
|
74
|
+
if (maxBytes <= markerBytes) return truncateUtf8(marker, maxBytes);
|
|
75
|
+
const available = maxBytes - markerBytes;
|
|
76
|
+
const requested = Math.max(1, headBytes) + Math.max(1, tailBytes);
|
|
77
|
+
const head = Math.max(1, Math.floor(available * Math.max(1, headBytes) / requested));
|
|
78
|
+
const tail = Math.max(1, available - head);
|
|
79
|
+
return `${truncateUtf8(text, head)}${marker}${suffixUtf8(text, tail)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inlineView(text, maxBytes, headBytes, tailBytes, maxColumns) {
|
|
83
|
+
return middleView(capColumns(text, maxColumns), maxBytes, headBytes, tailBytes);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function structuralRead(text) {
|
|
87
|
+
const lines = text.split('\n');
|
|
88
|
+
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*\()/;
|
|
89
|
+
const selected = lines.flatMap((line, index) => declaration.test(line) ? [`${index + 1}:${line}`] : []);
|
|
90
|
+
if (!selected.length) return null;
|
|
91
|
+
const outline = `[sando read structure: ${selected.length}/${lines.length} lines]\n${selected.join('\n')}`;
|
|
92
|
+
return Buffer.byteLength(outline) + 64 < Buffer.byteLength(text) ? outline : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function collapseRepeatedLines(text) {
|
|
96
|
+
const lines = text.split('\n');
|
|
97
|
+
const compacted = [];
|
|
98
|
+
for (let index = 0; index < lines.length;) {
|
|
99
|
+
let end = index + 1;
|
|
100
|
+
while (end < lines.length && lines[end] === lines[index]) end += 1;
|
|
101
|
+
const count = end - index;
|
|
102
|
+
if (count >= 3 && lines[index] !== '') {
|
|
103
|
+
compacted.push(lines[index], `[sando repeated x${count}]`);
|
|
104
|
+
} else {
|
|
105
|
+
compacted.push(...lines.slice(index, end));
|
|
106
|
+
}
|
|
107
|
+
index = end;
|
|
108
|
+
}
|
|
109
|
+
const result = compacted.join('\n');
|
|
110
|
+
return Buffer.byteLength(result) + 32 < Buffer.byteLength(text) ? result : text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readSelector(toolInput) {
|
|
114
|
+
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) return false;
|
|
115
|
+
return ['offset', 'limit', 'line_start', 'line_end', 'start_line', 'end_line']
|
|
116
|
+
.some((key) => Object.hasOwn(toolInput, key));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function estimateTokens(text) {
|
|
120
|
+
if (typeof text !== 'string') throw new TypeError('text must be a string');
|
|
121
|
+
return text.length === 0 ? 0 : Math.ceil(Buffer.byteLength(text) / 4);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function normalizePolicy(policy = {}) {
|
|
125
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy)
|
|
126
|
+
|| Object.keys(policy).some((key) => !POLICY_FIELDS.has(key))) throw new Error('invalid policy');
|
|
127
|
+
const result = { ...DEFAULT_POLICY, ...policy };
|
|
128
|
+
result.headBytes = result.headBytes ?? Math.floor(result.maxInlineBytes * 0.6);
|
|
129
|
+
result.tailBytes = result.tailBytes ?? Math.floor(result.maxInlineBytes * 0.25);
|
|
130
|
+
if (!['apply', 'dry-run', 'observe'].includes(result.mode)
|
|
131
|
+
|| !Number.isInteger(result.maxInlineBytes) || result.maxInlineBytes < 64 || result.maxInlineBytes > 1_048_576
|
|
132
|
+
|| !Number.isInteger(result.maxArtifactBytes) || result.maxArtifactBytes < 256 || result.maxArtifactBytes > 16_777_216
|
|
133
|
+
|| !Number.isInteger(result.headBytes) || result.headBytes < 1
|
|
134
|
+
|| !Number.isInteger(result.tailBytes) || result.tailBytes < 1
|
|
135
|
+
|| result.headBytes + result.tailBytes > result.maxInlineBytes
|
|
136
|
+
|| !Number.isInteger(result.maxColumns) || result.maxColumns < 1 || result.maxColumns > 1_048_576
|
|
137
|
+
|| typeof result.redact !== 'boolean') throw new Error('invalid policy');
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function optimizeToolOutput({
|
|
142
|
+
toolName, output, cwd, policy, selector, raw, lineCount, fileBytes, prose, summarizeProse,
|
|
143
|
+
summarizeEnabled, grepScope, outputBytes, toolInput,
|
|
144
|
+
} = {}) {
|
|
145
|
+
if (typeof toolName !== 'string' || !toolName.trim() || toolName.length > 128) throw new Error('toolName is invalid');
|
|
146
|
+
if (typeof cwd !== 'string' || !cwd) throw new Error('cwd is invalid');
|
|
147
|
+
const normalizedPolicy = normalizePolicy(policy);
|
|
148
|
+
const input = textOutput(output);
|
|
149
|
+
const name = toolName.toLowerCase();
|
|
150
|
+
const derivedLineCount = lineCount ?? (name === 'read' ? input.split(/\r?\n/).length : lineCount);
|
|
151
|
+
const derivedFileBytes = fileBytes ?? (name === 'read' ? Buffer.byteLength(input) : fileBytes);
|
|
152
|
+
let route = planToolRoute({
|
|
153
|
+
toolName, selector: selector ?? readSelector(toolInput), raw: raw ?? toolInput?.raw === true,
|
|
154
|
+
lineCount: derivedLineCount, fileBytes: derivedFileBytes, prose, summarizeProse, summarizeEnabled, grepScope,
|
|
155
|
+
outputBytes: outputBytes ?? Buffer.byteLength(input),
|
|
156
|
+
});
|
|
157
|
+
const redacted = normalizedPolicy.redact ? redact(input) : { text: input, count: 0 };
|
|
158
|
+
let modelText = name === 'bash' && normalizedPolicy.maxColumns >= 32
|
|
159
|
+
? collapseRepeatedLines(redacted.text)
|
|
160
|
+
: redacted.text;
|
|
161
|
+
if (route.route === 'summary') {
|
|
162
|
+
const outline = structuralRead(redacted.text);
|
|
163
|
+
if (outline) modelText = outline;
|
|
164
|
+
else route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'sando-read-bounded' };
|
|
165
|
+
}
|
|
166
|
+
const routePolicy = route.route === 'artifact' || route.route === 'structured'
|
|
167
|
+
? {
|
|
168
|
+
...normalizedPolicy,
|
|
169
|
+
...(route.route === 'artifact' ? {
|
|
170
|
+
maxInlineBytes: Math.min(normalizedPolicy.maxInlineBytes, route.limits.headBytes + route.limits.tailBytes),
|
|
171
|
+
headBytes: Math.min(normalizedPolicy.headBytes, route.limits.headBytes),
|
|
172
|
+
tailBytes: Math.min(normalizedPolicy.tailBytes, route.limits.tailBytes),
|
|
173
|
+
} : {}),
|
|
174
|
+
maxColumns: Math.min(normalizedPolicy.maxColumns, route.limits.maxColumns),
|
|
175
|
+
}
|
|
176
|
+
: normalizedPolicy;
|
|
177
|
+
const sourceBytes = Buffer.byteLength(redacted.text);
|
|
178
|
+
let inline = modelText;
|
|
179
|
+
let artifact;
|
|
180
|
+
const hasLongLine = routePolicy.maxColumns > 0
|
|
181
|
+
&& modelText.split('\n').some((line) => Buffer.byteLength(line) > routePolicy.maxColumns);
|
|
182
|
+
if (route.route === 'summary' || route.route === 'artifact' || sourceBytes > routePolicy.maxInlineBytes || hasLongLine) {
|
|
183
|
+
const sourceDigest = sha256(redacted.text);
|
|
184
|
+
artifact = {
|
|
185
|
+
schema: 'sando-artifact/v1',
|
|
186
|
+
ref: `sando:${sourceDigest.slice(0, 23)}`,
|
|
187
|
+
digest: sourceDigest,
|
|
188
|
+
sourceDigest,
|
|
189
|
+
mediaType: 'text/plain; charset=utf-8',
|
|
190
|
+
content: redacted.text,
|
|
191
|
+
bytes: sourceBytes,
|
|
192
|
+
sourceBytes,
|
|
193
|
+
truncated: false,
|
|
194
|
+
};
|
|
195
|
+
const header = `artifact ${artifact.ref} ${artifact.bytes}B\n`;
|
|
196
|
+
const viewBudget = Math.max(1, routePolicy.maxInlineBytes - Buffer.byteLength(header));
|
|
197
|
+
inline = `${truncateUtf8(header, routePolicy.maxInlineBytes)}${inlineView(
|
|
198
|
+
modelText,
|
|
199
|
+
viewBudget,
|
|
200
|
+
routePolicy.headBytes,
|
|
201
|
+
routePolicy.tailBytes,
|
|
202
|
+
routePolicy.maxColumns,
|
|
203
|
+
)}`;
|
|
204
|
+
inline = truncateUtf8(inline, routePolicy.maxInlineBytes);
|
|
205
|
+
}
|
|
206
|
+
const stats = {
|
|
207
|
+
mode: normalizedPolicy.mode,
|
|
208
|
+
inputBytes: Buffer.byteLength(input),
|
|
209
|
+
redactedBytes: sourceBytes,
|
|
210
|
+
inlineBytes: Buffer.byteLength(inline),
|
|
211
|
+
artifactBytes: artifact?.bytes ?? 0,
|
|
212
|
+
estimatedInputTokens: estimateTokens(input),
|
|
213
|
+
estimatedInlineTokens: estimateTokens(inline),
|
|
214
|
+
redactions: redacted.count,
|
|
215
|
+
artifactTruncated: artifact?.truncated ?? false,
|
|
216
|
+
};
|
|
217
|
+
const result = { inline, route: route.route, reason: route.source, policyVersion: ROUTING_POLICY_VERSION, stats };
|
|
218
|
+
if (artifact) result.artifact = artifact;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function normalizeEvent(input) {
|
|
223
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('event must be an object');
|
|
224
|
+
const event = {
|
|
225
|
+
eventName: input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName,
|
|
226
|
+
toolName: input.tool_name ?? input.toolName,
|
|
227
|
+
toolInput: input.tool_input ?? input.toolInput,
|
|
228
|
+
output: input.tool_response ?? input.toolResponse ?? input.tool_output ?? input.toolOutput ?? input.output,
|
|
229
|
+
cwd: input.cwd,
|
|
230
|
+
sessionId: input.session_id ?? input.sessionId ?? input.thread_id ?? input.threadId
|
|
231
|
+
?? input.conversation_id ?? input.conversationId,
|
|
232
|
+
eventId: input.event_id ?? input.eventId ?? input.uuid ?? input.id,
|
|
233
|
+
client: input.client ?? input.client_name ?? input.clientName,
|
|
234
|
+
clientVersion: input.client_version ?? input.clientVersion,
|
|
235
|
+
model: input.model ?? input.model_name ?? input.modelName,
|
|
236
|
+
timestamp: input.timestamp ?? input.event_timestamp ?? input.eventTimestamp
|
|
237
|
+
?? input.occurred_at ?? input.occurredAt,
|
|
238
|
+
providerUsage: input.provider_usage ?? input.providerUsage,
|
|
239
|
+
};
|
|
240
|
+
if (typeof event.eventName !== 'string' || typeof event.toolName !== 'string'
|
|
241
|
+
|| event.output === undefined || typeof event.cwd !== 'string' || !event.cwd) throw new Error('event is incomplete');
|
|
242
|
+
for (const field of ['toolInput', 'sessionId', 'eventId', 'client', 'clientVersion', 'model', 'timestamp', 'providerUsage']) {
|
|
243
|
+
if (event[field] === undefined) delete event[field];
|
|
244
|
+
}
|
|
245
|
+
return event;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function createReceipt({ host, event, optimization, replacement } = {}) {
|
|
249
|
+
if (typeof host !== 'string' || !host || !event || !optimization?.stats) throw new Error('receipt input is invalid');
|
|
250
|
+
const body = {
|
|
251
|
+
schema: 'sando-receipt/v1', host, eventName: event.eventName, toolName: event.toolName,
|
|
252
|
+
sessionId: event.sessionId ?? null, inputDigest: sha256(textOutput(event.output)),
|
|
253
|
+
inlineDigest: sha256(textOutput(replacement === undefined ? optimization.inline : replacement)), artifactRef: optimization.artifact?.ref ?? null,
|
|
254
|
+
route: optimization.route ?? 'passthrough', reason: optimization.reason ?? 'spike-default',
|
|
255
|
+
policyVersion: optimization.policyVersion ?? ROUTING_POLICY_VERSION, stats: optimization.stats,
|
|
256
|
+
};
|
|
257
|
+
return { ...body, digest: sha256(stableJson(body)) };
|
|
258
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const INVALID_BUDGET = 'maxHistoryTokens must be a positive safe integer';
|
|
2
|
+
|
|
3
|
+
export function validateMaxHistoryTokens(value) {
|
|
4
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(INVALID_BUDGET);
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function selectHistoryCandidates({ bodyTokens, maxHistoryTokens, candidates } = {}) {
|
|
9
|
+
validateMaxHistoryTokens(maxHistoryTokens);
|
|
10
|
+
if (!Number.isSafeInteger(bodyTokens) || bodyTokens < 0 || !Array.isArray(candidates)) return [];
|
|
11
|
+
if (BigInt(bodyTokens) * 5n <= BigInt(maxHistoryTokens) * 4n) return [];
|
|
12
|
+
|
|
13
|
+
const idCounts = new Map();
|
|
14
|
+
for (const candidate of candidates) {
|
|
15
|
+
if (typeof candidate?.id === 'string' && candidate.id.length > 0) {
|
|
16
|
+
idCounts.set(candidate.id, (idCounts.get(candidate.id) ?? 0) + 1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return candidates.filter((candidate) =>
|
|
21
|
+
candidate !== null
|
|
22
|
+
&& typeof candidate === 'object'
|
|
23
|
+
&& typeof candidate.id === 'string'
|
|
24
|
+
&& candidate.id.length > 0
|
|
25
|
+
&& idCounts.get(candidate.id) === 1
|
|
26
|
+
&& candidate.safe === true
|
|
27
|
+
&& candidate.historical === true
|
|
28
|
+
&& candidate.error !== true
|
|
29
|
+
&& candidate.current !== true
|
|
30
|
+
&& Number.isSafeInteger(candidate.position)
|
|
31
|
+
&& candidate.position >= 0
|
|
32
|
+
&& Number.isSafeInteger(candidate.estimatedTokens)
|
|
33
|
+
&& candidate.estimatedTokens > 0)
|
|
34
|
+
.sort((left, right) =>
|
|
35
|
+
left.position - right.position || right.estimatedTokens - left.estimatedTokens);
|
|
36
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DUPLICATE = '[sando duplicate historical result]';
|
|
2
|
+
const TOOLS = new Set(['read', 'exec', 'grep', 'bash']);
|
|
3
|
+
|
|
4
|
+
function canonical(value, seen = new Set()) {
|
|
5
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
|
|
6
|
+
if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : null;
|
|
7
|
+
if (typeof value !== 'object' || seen.has(value)) return null;
|
|
8
|
+
|
|
9
|
+
seen.add(value);
|
|
10
|
+
let result = null;
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
const items = [];
|
|
13
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
14
|
+
if (!Object.hasOwn(value, index)) return null;
|
|
15
|
+
const item = canonical(value[index], seen);
|
|
16
|
+
if (item === null) return null;
|
|
17
|
+
items.push(item);
|
|
18
|
+
}
|
|
19
|
+
result = `[${items.join(',')}]`;
|
|
20
|
+
} else if ([Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
21
|
+
&& Object.getOwnPropertySymbols(value).length === 0) {
|
|
22
|
+
const items = [];
|
|
23
|
+
for (const key of Object.keys(value).sort()) {
|
|
24
|
+
const item = canonical(value[key], seen);
|
|
25
|
+
if (item === null) return null;
|
|
26
|
+
items.push(`${JSON.stringify(key)}:${item}`);
|
|
27
|
+
}
|
|
28
|
+
result = `{${items.join(',')}}`;
|
|
29
|
+
}
|
|
30
|
+
seen.delete(value);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function outputText(output) {
|
|
35
|
+
if (typeof output === 'string') return output;
|
|
36
|
+
if (!Array.isArray(output) || output.length === 0
|
|
37
|
+
|| !output.every((block) => ['text', 'input_text'].includes(block?.type) && typeof block.text === 'string')) return null;
|
|
38
|
+
return output.map((block) => block.text).join('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function error(entry, text) {
|
|
42
|
+
return entry.isError === true || Object.hasOwn(entry, 'error')
|
|
43
|
+
|| ['error', 'failed'].includes(entry.status)
|
|
44
|
+
|| /^\s*(?:error|failed|failure)\b[:\s-]*/i.test(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function identity(entry) {
|
|
48
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
|
49
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0 || typeof entry.toolName !== 'string') return null;
|
|
50
|
+
const tool = entry.toolName.toLowerCase();
|
|
51
|
+
if (!TOOLS.has(tool)) return null;
|
|
52
|
+
if (entry.input === null || (typeof entry.input !== 'object' && typeof entry.input !== 'string') || Array.isArray(entry.input)) return null;
|
|
53
|
+
const input = canonical(entry.input);
|
|
54
|
+
const output = canonical(entry.output);
|
|
55
|
+
const text = outputText(entry.output);
|
|
56
|
+
if (input === null || output === null || text === null || error(entry, text)) return null;
|
|
57
|
+
return `${tool}\0${input}\0${output}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function elide(output) {
|
|
61
|
+
if (typeof output === 'string') return DUPLICATE;
|
|
62
|
+
return output.map((block, index) => ({ ...block, text: index === 0 ? DUPLICATE : '' }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function serializedBytes(value) {
|
|
66
|
+
const serialized = JSON.stringify(value);
|
|
67
|
+
return serialized === undefined ? Infinity : Buffer.byteLength(serialized);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function dedupeHistory(entries) {
|
|
71
|
+
if (!Array.isArray(entries)) return { entries, deduplicated: 0 };
|
|
72
|
+
|
|
73
|
+
const idCounts = new Map();
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (typeof entry?.id === 'string' && entry.id.length > 0) {
|
|
76
|
+
idCounts.set(entry.id, (idCounts.get(entry.id) ?? 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const result = [...entries];
|
|
82
|
+
let deduplicated = 0;
|
|
83
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
const entry = entries[index];
|
|
85
|
+
if (idCounts.get(entry?.id) !== 1) continue;
|
|
86
|
+
const key = identity(entry);
|
|
87
|
+
if (key === null) continue;
|
|
88
|
+
if (seen.has(key) && entry.current !== true) {
|
|
89
|
+
const replacement = elide(entry.output);
|
|
90
|
+
if (serializedBytes(replacement) >= serializedBytes(entry.output)) continue;
|
|
91
|
+
result[index] = { ...entry, output: replacement };
|
|
92
|
+
deduplicated += 1;
|
|
93
|
+
} else {
|
|
94
|
+
seen.add(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { entries: result, deduplicated };
|
|
98
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { estimateTokens } from './core.mjs';
|
|
2
|
+
|
|
3
|
+
const SUPPORTED_TOOLS = new Set(['bash', 'exec', 'grep', 'log']);
|
|
4
|
+
const DEFAULT_MIN_TOKENS = 400;
|
|
5
|
+
const HEAD_LINES = 3;
|
|
6
|
+
const TAIL_LINES = 3;
|
|
7
|
+
const MAX_SIGNAL_LINES = 12;
|
|
8
|
+
const SIGNAL_LINE = /\b(?:error|failed|failure|fatal|panic|exception|warning|todo|fixme)\b/i;
|
|
9
|
+
|
|
10
|
+
function result(text, changed = false, compactedText = text) {
|
|
11
|
+
return {
|
|
12
|
+
text: compactedText,
|
|
13
|
+
changed,
|
|
14
|
+
originalTokens: estimateTokens(text),
|
|
15
|
+
compactedTokens: estimateTokens(compactedText),
|
|
16
|
+
originalLines: text.split('\n').length,
|
|
17
|
+
compactedLines: compactedText.split('\n').length,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function selectedLines(lines) {
|
|
22
|
+
const selected = new Set();
|
|
23
|
+
for (let index = 0; index < Math.min(HEAD_LINES, lines.length); index += 1) selected.add(index);
|
|
24
|
+
for (let index = Math.max(0, lines.length - TAIL_LINES); index < lines.length; index += 1) selected.add(index);
|
|
25
|
+
|
|
26
|
+
let signalCount = 0;
|
|
27
|
+
for (let index = 0; index < lines.length && signalCount < MAX_SIGNAL_LINES; index += 1) {
|
|
28
|
+
if (!SIGNAL_LINE.test(lines[index])) continue;
|
|
29
|
+
selected.add(index);
|
|
30
|
+
signalCount += 1;
|
|
31
|
+
}
|
|
32
|
+
return selected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function compact(lines, selected) {
|
|
36
|
+
const output = [];
|
|
37
|
+
for (let index = 0; index < lines.length;) {
|
|
38
|
+
if (selected.has(index)) {
|
|
39
|
+
output.push(lines[index]);
|
|
40
|
+
index += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const start = index;
|
|
44
|
+
while (index < lines.length && !selected.has(index)) index += 1;
|
|
45
|
+
output.push(`[sando history shake: ${index - start} lines elided; rerun tool if needed]`);
|
|
46
|
+
}
|
|
47
|
+
return output.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function shakeHistoricalResult({ toolName, text, historical, isError, minTokens = DEFAULT_MIN_TOKENS } = {}) {
|
|
51
|
+
if (typeof text !== 'string') throw new TypeError('text must be a string');
|
|
52
|
+
if (!Number.isSafeInteger(minTokens) || minTokens <= 0) throw new TypeError('minTokens must be a positive safe integer');
|
|
53
|
+
const normalizedTool = typeof toolName === 'string' ? toolName.toLowerCase() : '';
|
|
54
|
+
const originalTokens = estimateTokens(text);
|
|
55
|
+
if (!historical || isError === true || !SUPPORTED_TOOLS.has(normalizedTool) || originalTokens < minTokens) {
|
|
56
|
+
return result(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lines = text.split('\n');
|
|
60
|
+
const selected = selectedLines(lines);
|
|
61
|
+
if (selected.size >= lines.length) return result(text);
|
|
62
|
+
|
|
63
|
+
const compacted = compact(lines, selected);
|
|
64
|
+
if (Buffer.byteLength(compacted) >= Buffer.byteLength(text)) return result(text);
|
|
65
|
+
return result(text, true, compacted);
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Matches history-shake.mjs's SUPPORTED_TOOLS. These two modules do variants of the
|
|
2
|
+
// same job — collapsing repetitive historical line output — and previously disagreed
|
|
3
|
+
// on which tools qualify: shake allowed exec/grep, this one did not. Codex reports
|
|
4
|
+
// tool results as `exec`/`grep`, so structural collapse could never run there, which
|
|
5
|
+
// is the whole reason the recorded Claude and Codex proxy runs show mirrored
|
|
6
|
+
// compactedStructures/shakenResults counts. This transform is the more conservative
|
|
7
|
+
// of the two (identical consecutive lines only, and it returns the original unless
|
|
8
|
+
// the result is strictly smaller), so anything shake may touch it may touch too.
|
|
9
|
+
const SUPPORTED_TOOLS = new Set(['bash', 'exec', 'grep', 'log']);
|
|
10
|
+
|
|
11
|
+
export function compactHistoricalStructure({ toolName, text, historical, isError }) {
|
|
12
|
+
const normalizedTool = typeof toolName === 'string' ? toolName.toLowerCase() : '';
|
|
13
|
+
if (!historical || isError || !SUPPORTED_TOOLS.has(normalizedTool)) return text;
|
|
14
|
+
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const compacted = [];
|
|
17
|
+
for (let index = 0; index < lines.length;) {
|
|
18
|
+
let end = index + 1;
|
|
19
|
+
while (end < lines.length && lines[end] === lines[index]) end += 1;
|
|
20
|
+
const count = end - index;
|
|
21
|
+
if (lines[index] && count > 1) compacted.push(lines[index], `[sando repeated x${count}]`);
|
|
22
|
+
else compacted.push(...lines.slice(index, end));
|
|
23
|
+
index = end;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = compacted.join('\n');
|
|
27
|
+
return Buffer.byteLength(result) < Buffer.byteLength(text) ? result : text;
|
|
28
|
+
}
|
package/src/hook-cli.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
|
|
6
|
+
import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
|
|
7
|
+
|
|
8
|
+
function hookPolicy(env, host) {
|
|
9
|
+
const policy = env.SANDO_POLICY
|
|
10
|
+
? JSON.parse(env.SANDO_POLICY)
|
|
11
|
+
: { mode: env.SANDO_MODE || (host === 'claude' ? 'apply' : 'observe') };
|
|
12
|
+
if (/^(1|true|yes)$/i.test(env.SANDO_OBSERVE_ONLY || '')) policy.mode = 'observe';
|
|
13
|
+
return normalizePolicy(policy);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function artifactPath(cwd, artifact) {
|
|
17
|
+
const root = fs.realpathSync(cwd);
|
|
18
|
+
const stateRoot = path.join(root, '.sando');
|
|
19
|
+
const privateRoot = path.join(stateRoot, 'sando');
|
|
20
|
+
const directory = path.join(privateRoot, 'artifacts');
|
|
21
|
+
for (const target of [stateRoot, privateRoot, directory]) {
|
|
22
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
23
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('artifact directory is unsafe');
|
|
24
|
+
if (!stat) fs.mkdirSync(target, { mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
const name = `${artifact.sourceDigest.slice('sha256:'.length)}.txt`;
|
|
27
|
+
const destination = path.join(directory, name);
|
|
28
|
+
const temporary = path.join(directory, `.${name}.${process.pid}.${randomUUID()}`);
|
|
29
|
+
try {
|
|
30
|
+
fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
|
|
31
|
+
try { fs.linkSync(temporary, destination); }
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
fs.rmSync(temporary, { force: true });
|
|
37
|
+
}
|
|
38
|
+
fs.chmodSync(destination, 0o600);
|
|
39
|
+
return path.posix.join('.sando/sando', 'artifacts', name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function runHookCli({ host, env = process.env } = {}) {
|
|
43
|
+
let policy;
|
|
44
|
+
try {
|
|
45
|
+
policy = hookPolicy(env, host);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
process.stderr.write(`sando invalid policy: ${error instanceof Error ? error.message : 'invalid input'}\n`);
|
|
48
|
+
process.exitCode = 2;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
53
|
+
const eventName = input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName;
|
|
54
|
+
if (eventName === 'PostToolUse') {
|
|
55
|
+
const event = normalizeEvent(input);
|
|
56
|
+
const optimization = optimizeToolOutput({ toolName: event.toolName, toolInput: event.toolInput, output: event.output, cwd: event.cwd, policy });
|
|
57
|
+
let shaped;
|
|
58
|
+
if (host === 'claude' && policy.mode === 'apply') {
|
|
59
|
+
shaped = shapeForClaude({
|
|
60
|
+
original: event.output,
|
|
61
|
+
optimization,
|
|
62
|
+
toolName: event.toolName,
|
|
63
|
+
toolInput: event.toolInput,
|
|
64
|
+
cwd: event.cwd,
|
|
65
|
+
policy,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const receipt = createReceipt({ host, event, optimization, replacement: shaped });
|
|
69
|
+
try {
|
|
70
|
+
recordMetrics({ storagePath: defaultMetricsPath(env), host, event, optimization, receipt });
|
|
71
|
+
} catch {}
|
|
72
|
+
if (host === 'codex' && policy.mode === 'apply' && env.SANDO_CODEX_FALLBACK === 'feedback') {
|
|
73
|
+
process.stdout.write(`${JSON.stringify(buildCodexFallback({ optimization, cwd: event.cwd }))}\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (host === 'claude' && policy.mode === 'apply') {
|
|
77
|
+
if (shaped !== undefined) {
|
|
78
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: {
|
|
79
|
+
hookEventName: 'PostToolUse', updatedToolOutput: shaped,
|
|
80
|
+
} })}\n`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
process.stdout.write('{}\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function materialize(optimization, cwd) {
|
|
90
|
+
if (!optimization.artifact) return optimization.inline;
|
|
91
|
+
return optimization.inline.replace(optimization.artifact.ref, artifactPath(cwd, optimization.artifact));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildCodexFallback({ optimization, cwd }) {
|
|
95
|
+
const reference = optimization.artifact ? artifactPath(cwd, optimization.artifact) : 'inline output';
|
|
96
|
+
return {
|
|
97
|
+
continue: false,
|
|
98
|
+
stopReason: 'Sando fallback: Codex cannot transparently rewrite tool output',
|
|
99
|
+
systemMessage: `Sando fallback prepared ${reference}; tool output was not rewritten.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function shapeForClaude({ original, optimization, toolName, toolInput, cwd, policy }) {
|
|
104
|
+
if (typeof original === 'string') return materialize(optimization, cwd);
|
|
105
|
+
if (!original || typeof original !== 'object' || Array.isArray(original)
|
|
106
|
+
|| !Object.hasOwn(original, 'stdout') || !Object.hasOwn(original, 'stderr')
|
|
107
|
+
|| typeof original.stdout !== 'string' || typeof original.stderr !== 'string'
|
|
108
|
+
|| (Object.hasOwn(original, 'interrupted') && typeof original.interrupted !== 'boolean')
|
|
109
|
+
|| (Object.hasOwn(original, 'isImage') && typeof original.isImage !== 'boolean')) return undefined;
|
|
110
|
+
const result = { ...original };
|
|
111
|
+
if (typeof original.stdout === 'string') {
|
|
112
|
+
result.stdout = materialize(optimizeToolOutput({ toolName, toolInput, output: original.stdout, cwd, policy }), cwd);
|
|
113
|
+
}
|
|
114
|
+
if (typeof original.stderr === 'string') {
|
|
115
|
+
result.stderr = materialize(optimizeToolOutput({ toolName, toolInput, output: original.stderr, cwd, policy }), cwd);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
|
|
3
|
+
import { optimizeToolOutput } from './core.mjs';
|
|
4
|
+
|
|
5
|
+
const TOOL = {
|
|
6
|
+
name: 'prepare_tool_output',
|
|
7
|
+
description: 'Prepare deterministic bounded inline output and an optional redacted artifact payload. Performs no writes or network access.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object', additionalProperties: false, required: ['toolName', 'output', 'cwd'],
|
|
10
|
+
properties: { toolName: { type: 'string', minLength: 1, maxLength: 128 }, output: {}, cwd: { type: 'string', minLength: 1 }, policy: { type: 'object' } },
|
|
11
|
+
},
|
|
12
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function response(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
16
|
+
function error(id, code, message) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; }
|
|
17
|
+
|
|
18
|
+
function dispatch(message) {
|
|
19
|
+
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(message?.id, -32600, 'Invalid Request');
|
|
20
|
+
if (message.id === undefined) return null;
|
|
21
|
+
if (message.method === 'initialize') return response(message.id, {
|
|
22
|
+
protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: '0.1.0' },
|
|
23
|
+
});
|
|
24
|
+
if (message.method === 'ping') return response(message.id, {});
|
|
25
|
+
if (message.method === 'tools/list') return response(message.id, { tools: [TOOL] });
|
|
26
|
+
if (message.method === 'tools/call') {
|
|
27
|
+
if (message.params?.name !== TOOL.name) return error(message.id, -32602, 'Unknown tool');
|
|
28
|
+
try {
|
|
29
|
+
const result = optimizeToolOutput(message.params.arguments);
|
|
30
|
+
return response(message.id, { content: [{ type: 'text', text: result.inline }], structuredContent: result, isError: false });
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
return response(message.id, { content: [{ type: 'text', text: cause instanceof Error ? cause.message : 'invalid tool input' }], isError: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return error(message.id, -32601, 'Method not found');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function startMcpServer() {
|
|
39
|
+
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
40
|
+
lines.on('line', (line) => {
|
|
41
|
+
let output;
|
|
42
|
+
try { output = dispatch(JSON.parse(line)); } catch { output = error(null, -32700, 'Parse error'); }
|
|
43
|
+
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
44
|
+
});
|
|
45
|
+
}
|