sandoichi 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/index.mjs +5 -0
- 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 +129 -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 +12 -5
- package/src/result-disclosure.mjs +8 -2
- package/src/slice.mjs +419 -0
- package/src/statusline.mjs +5 -9
- package/src/telemetry.mjs +101 -19
package/src/slice.mjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { loadProjectRedactionProfile } from './redaction-config.mjs';
|
|
6
|
+
import { PLUGIN_VERSION } from './version.mjs';
|
|
7
|
+
|
|
8
|
+
const READ_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
9
|
+
const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
10
|
+
const REPLACE_ANNOTATIONS = { ...WRITE_ANNOTATIONS, destructiveHint: true };
|
|
11
|
+
const INTEGER = { type: 'integer', minimum: 1 };
|
|
12
|
+
const MAX_FETCH_LINES = 400;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
15
|
+
const HANDLE_PATTERN = '^sym#[a-f0-9]{16}@[a-f0-9]{16}$';
|
|
16
|
+
const HANDLE_RE = new RegExp(HANDLE_PATTERN);
|
|
17
|
+
|
|
18
|
+
const definitions = [
|
|
19
|
+
{
|
|
20
|
+
name: 'sando_slice_for', upstream: 'for', write: false,
|
|
21
|
+
description: 'Discover task-relevant symbols from the configured workspace. Returns native content and index freshness metadata unchanged.',
|
|
22
|
+
required: ['task'], properties: { task: { type: 'string', minLength: 1 }, budget_tokens: INTEGER }, annotations: READ_ANNOTATIONS,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'sando_slice_find_symbol', upstream: 'find_symbol', write: false,
|
|
26
|
+
description: 'Find one symbol and its direct callers and callees. Preserves handles, ambiguity, floors, and index freshness metadata.',
|
|
27
|
+
required: ['symbol'], properties: { symbol: { type: 'string', minLength: 1 }, limit: INTEGER, offset: { type: 'integer', minimum: 0 } }, annotations: READ_ANNOTATIONS,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'sando_slice_find_referencing_symbols', upstream: 'find_referencing_symbols', write: false,
|
|
31
|
+
description: 'Find direct referencing symbols. Preserves handles, ambiguity, floors, and index freshness metadata.',
|
|
32
|
+
required: ['symbol'], properties: { symbol: { type: 'string', minLength: 1 }, limit: INTEGER, offset: { type: 'integer', minimum: 0 } }, annotations: READ_ANNOTATIONS,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'sando_slice_fetch_body', upstream: 'fetch_body', write: false,
|
|
36
|
+
description: `Fetch source for a symbol handle, bounded to at most ${MAX_FETCH_LINES} body-relative lines. Stale and ambiguous handles are refused.`,
|
|
37
|
+
required: ['handle'], properties: { handle: { type: 'string', pattern: HANDLE_PATTERN }, start_line: INTEGER, end_line: INTEGER }, annotations: READ_ANNOTATIONS,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'sando_slice_replace_symbol_body', upstream: 'replace_symbol_body', write: true,
|
|
41
|
+
description: 'Replace exactly the definition span returned by sando_slice_fetch_body. Modifiers outside that span are preserved; do not repeat them in new_body. Requires a fresh handle and SANDO_SLICE_WRITE=1.',
|
|
42
|
+
required: ['handle', 'new_body'], properties: { handle: { type: 'string', pattern: HANDLE_PATTERN }, new_body: { type: 'string', minLength: 1 }, post_check: { type: 'boolean' } }, annotations: REPLACE_ANNOTATIONS,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'sando_slice_insert_after_symbol', upstream: 'insert_after_symbol', write: true,
|
|
46
|
+
description: 'Insert text after the definition identified by a fresh handle. Requires SANDO_SLICE_WRITE=1; the native engine owns stale-handle refusal, newline handling, and atomic writes.',
|
|
47
|
+
required: ['handle', 'text'], properties: { handle: { type: 'string', pattern: HANDLE_PATTERN }, text: { type: 'string', minLength: 1 }, post_check: { type: 'boolean' } }, annotations: WRITE_ANNOTATIONS,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const byName = new Map(definitions.map((definition) => [definition.name, definition]));
|
|
52
|
+
|
|
53
|
+
export class SliceRpcError extends Error {
|
|
54
|
+
constructor(code, message, data) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'SliceRpcError';
|
|
57
|
+
this.code = code;
|
|
58
|
+
if (data !== undefined) this.data = data;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function configurationError(message) { return new SliceRpcError(-32603, message); }
|
|
63
|
+
|
|
64
|
+
function configuration(env) {
|
|
65
|
+
const binarySetting = env?.SANDO_SLICE_BINARY;
|
|
66
|
+
if (typeof binarySetting !== 'string' || !path.isAbsolute(binarySetting) || binarySetting.includes('\0')) {
|
|
67
|
+
throw configurationError('SANDO_SLICE_BINARY must be an absolute executable file');
|
|
68
|
+
}
|
|
69
|
+
let executable;
|
|
70
|
+
try {
|
|
71
|
+
executable = fs.realpathSync(binarySetting);
|
|
72
|
+
const stat = fs.statSync(executable);
|
|
73
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
74
|
+
fs.accessSync(executable, fs.constants.X_OK);
|
|
75
|
+
} catch {
|
|
76
|
+
throw configurationError('SANDO_SLICE_BINARY must be an absolute executable file');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rootSetting = env?.SANDO_SLICE_ROOT;
|
|
80
|
+
if (typeof rootSetting !== 'string' || !path.isAbsolute(rootSetting) || rootSetting.includes('\0')) {
|
|
81
|
+
throw configurationError('SANDO_SLICE_ROOT must be an absolute canonical directory');
|
|
82
|
+
}
|
|
83
|
+
let root;
|
|
84
|
+
try {
|
|
85
|
+
root = fs.realpathSync(rootSetting);
|
|
86
|
+
const stat = fs.lstatSync(rootSetting);
|
|
87
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || root !== path.resolve(rootSetting)) throw new Error('not canonical');
|
|
88
|
+
} catch {
|
|
89
|
+
throw configurationError('SANDO_SLICE_ROOT must be an absolute canonical directory');
|
|
90
|
+
}
|
|
91
|
+
return { executable, root };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function publicTool({ name, description, required, properties, annotations }) {
|
|
95
|
+
return { name, description, inputSchema: { type: 'object', additionalProperties: false, required, properties }, annotations };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function SLICE_TOOLS(env = process.env) {
|
|
99
|
+
try { configuration(env); } catch { return []; }
|
|
100
|
+
return definitions.filter((definition) => !definition.write || env?.SANDO_SLICE_WRITE === '1').map(publicTool);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function spawnSliceProcess({ executable, root }) {
|
|
104
|
+
return spawn(executable, [root, '--mcp'], { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class SliceSession {
|
|
108
|
+
constructor(child) {
|
|
109
|
+
if (!child?.stdin || !child?.stdout || !child?.stderr) throw configurationError('Slice backend did not provide stdio pipes');
|
|
110
|
+
this.child = child;
|
|
111
|
+
this.pending = new Map();
|
|
112
|
+
this.nextId = 1;
|
|
113
|
+
this.closed = false;
|
|
114
|
+
this.stdout = Buffer.alloc(0);
|
|
115
|
+
child.stdout.on('data', (chunk) => this.consume(Buffer.from(chunk)));
|
|
116
|
+
child.stderr.resume();
|
|
117
|
+
child.stdin.on('error', (error) => this.close(configurationError(`Slice backend stdin failed: ${error.message}`)));
|
|
118
|
+
child.on('error', (error) => this.close(configurationError(`Slice backend unavailable: ${error.message}`)));
|
|
119
|
+
child.on('exit', (code, signal) => this.fail(configurationError(
|
|
120
|
+
`Slice backend exited before replying (code=${code ?? 'null'}, signal=${signal ?? 'none'})`,
|
|
121
|
+
)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async initialize(context) {
|
|
125
|
+
const result = await this.request('initialize', {
|
|
126
|
+
protocolVersion: '2025-11-25',
|
|
127
|
+
capabilities: {},
|
|
128
|
+
clientInfo: { name: 'sando', version: PLUGIN_VERSION },
|
|
129
|
+
}, context);
|
|
130
|
+
if (!result || typeof result !== 'object' || typeof result.protocolVersion !== 'string'
|
|
131
|
+
|| !result.capabilities || typeof result.capabilities !== 'object'
|
|
132
|
+
|| !result.serverInfo || typeof result.serverInfo.name !== 'string') {
|
|
133
|
+
const error = configurationError('Slice backend returned an invalid initialize result');
|
|
134
|
+
this.close(error);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
this.notify('notifications/initialized', {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
request(method, params, { signal, deadline } = {}) {
|
|
141
|
+
if (this.closed) return Promise.reject(configurationError('Slice backend is closed'));
|
|
142
|
+
if (signal?.aborted) return Promise.reject(new SliceRpcError(-32800, 'Slice request cancelled'));
|
|
143
|
+
const remaining = (deadline ?? Date.now() + REQUEST_TIMEOUT_MS) - Date.now();
|
|
144
|
+
if (remaining <= 0) return Promise.reject(configurationError('Slice request timed out before execution'));
|
|
145
|
+
const id = this.nextId++;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const abort = () => {
|
|
148
|
+
const error = new SliceRpcError(-32800, 'Slice request cancelled');
|
|
149
|
+
this.close(error);
|
|
150
|
+
};
|
|
151
|
+
if (signal) signal.addEventListener('abort', abort, { once: true });
|
|
152
|
+
const timer = setTimeout(() => this.close(configurationError('Slice request timed out')), remaining);
|
|
153
|
+
this.pending.set(id, {
|
|
154
|
+
resolve,
|
|
155
|
+
reject,
|
|
156
|
+
cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); },
|
|
157
|
+
});
|
|
158
|
+
this.write({ jsonrpc: '2.0', id, method, params }, id);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
consume(chunk) {
|
|
163
|
+
this.stdout = Buffer.concat([this.stdout, chunk]);
|
|
164
|
+
while (true) {
|
|
165
|
+
const newline = this.stdout.indexOf(0x0a);
|
|
166
|
+
if (newline < 0) {
|
|
167
|
+
if (this.stdout.length > MAX_RESPONSE_BYTES) this.close(configurationError(`Slice response exceeded ${MAX_RESPONSE_BYTES} bytes`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (newline > MAX_RESPONSE_BYTES) {
|
|
171
|
+
this.close(configurationError(`Slice response exceeded ${MAX_RESPONSE_BYTES} bytes`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const line = this.stdout.subarray(0, newline).toString('utf8');
|
|
175
|
+
this.stdout = this.stdout.subarray(newline + 1);
|
|
176
|
+
this.receive(line);
|
|
177
|
+
if (this.closed) return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
notify(method, params) { this.write({ jsonrpc: '2.0', method, params }); }
|
|
182
|
+
|
|
183
|
+
write(message, id) {
|
|
184
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
|
|
185
|
+
if (!error || id === undefined) return;
|
|
186
|
+
const pending = this.pending.get(id);
|
|
187
|
+
if (!pending) return;
|
|
188
|
+
this.close(configurationError(`Slice request write failed: ${error.message}`));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
receive(line) {
|
|
193
|
+
let message;
|
|
194
|
+
try { message = JSON.parse(line); }
|
|
195
|
+
catch { this.close(configurationError('Slice backend returned invalid JSON')); return; }
|
|
196
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
197
|
+
this.close(configurationError('Slice backend returned an invalid JSON-RPC envelope'));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const pending = this.pending.get(message.id);
|
|
201
|
+
if (!pending) return;
|
|
202
|
+
this.pending.delete(message.id);
|
|
203
|
+
pending.cleanup();
|
|
204
|
+
if (message.error) pending.reject(new SliceRpcError(message.error.code, message.error.message, message.error.data));
|
|
205
|
+
else pending.resolve(message.result);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fail(error) {
|
|
209
|
+
if (this.closed) return;
|
|
210
|
+
this.closed = true;
|
|
211
|
+
for (const pending of this.pending.values()) {
|
|
212
|
+
pending.cleanup();
|
|
213
|
+
pending.reject(error);
|
|
214
|
+
}
|
|
215
|
+
this.pending.clear();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
close(error = configurationError('Slice backend closed')) {
|
|
219
|
+
if (this.closed) return;
|
|
220
|
+
this.fail(error);
|
|
221
|
+
this.child.kill();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function argumentsFor(definition, args) {
|
|
226
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)
|
|
227
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(args))) {
|
|
228
|
+
throw new SliceRpcError(-32602, 'Slice arguments must be an object');
|
|
229
|
+
}
|
|
230
|
+
const allowed = new Set(Object.keys(definition.properties));
|
|
231
|
+
for (const key of Reflect.ownKeys(args)) {
|
|
232
|
+
if (typeof key !== 'string') throw new SliceRpcError(-32602, 'unknown Slice argument');
|
|
233
|
+
if (!allowed.has(key)) throw new SliceRpcError(-32602, `unknown argument: ${key}`);
|
|
234
|
+
}
|
|
235
|
+
for (const key of definition.required) {
|
|
236
|
+
if (!Object.hasOwn(args, key)) throw new SliceRpcError(-32602, `missing required argument: ${key}`);
|
|
237
|
+
}
|
|
238
|
+
for (const [key, value] of Object.entries(args)) {
|
|
239
|
+
const schema = definition.properties[key];
|
|
240
|
+
const validType = schema.type === 'string' ? typeof value === 'string'
|
|
241
|
+
: schema.type === 'integer' ? Number.isSafeInteger(value)
|
|
242
|
+
: schema.type === 'boolean' ? typeof value === 'boolean'
|
|
243
|
+
: false;
|
|
244
|
+
if (!validType
|
|
245
|
+
|| (schema.minLength !== undefined && value.length < schema.minLength)
|
|
246
|
+
|| (schema.minimum !== undefined && value < schema.minimum)
|
|
247
|
+
|| (schema.pattern !== undefined && !(new RegExp(schema.pattern)).test(value))) {
|
|
248
|
+
throw new SliceRpcError(-32602, `invalid argument: ${key}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const forwarded = { ...args };
|
|
252
|
+
if (definition.upstream === 'fetch_body') {
|
|
253
|
+
const start = forwarded.start_line ?? 1;
|
|
254
|
+
const end = forwarded.end_line ?? (start + MAX_FETCH_LINES - 1);
|
|
255
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || end - start + 1 > MAX_FETCH_LINES) {
|
|
256
|
+
throw new SliceRpcError(-32602, `fetch_body range must span 1..${MAX_FETCH_LINES} positive body-relative lines`);
|
|
257
|
+
}
|
|
258
|
+
forwarded.start_line = start;
|
|
259
|
+
forwarded.end_line = end;
|
|
260
|
+
}
|
|
261
|
+
if (definition.write) {
|
|
262
|
+
if (typeof forwarded.handle !== 'string' || !HANDLE_RE.test(forwarded.handle)) {
|
|
263
|
+
throw new SliceRpcError(-32602, 'Slice writes require a fresh handle from sando_slice_find_symbol');
|
|
264
|
+
}
|
|
265
|
+
forwarded.symbol = forwarded.handle;
|
|
266
|
+
delete forwarded.handle;
|
|
267
|
+
}
|
|
268
|
+
return forwarded;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function isSliceTool(name) { return byName.has(name); }
|
|
272
|
+
|
|
273
|
+
function resolveRedactionProfile(root) {
|
|
274
|
+
try {
|
|
275
|
+
return loadProjectRedactionProfile(root).profile;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw configurationError(`Slice redaction config is invalid: ${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function redactError(error, profile) {
|
|
282
|
+
const message = profile.redact(error instanceof Error ? error.message : String(error)).text;
|
|
283
|
+
let data;
|
|
284
|
+
if (error?.data !== undefined) data = profile.redactStructured(error.data).value;
|
|
285
|
+
return new SliceRpcError(Number.isInteger(error?.code) ? error.code : -32603, message, data);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function redactContentText(text, profile) {
|
|
289
|
+
try {
|
|
290
|
+
const redacted = profile.redactStructured(JSON.parse(text));
|
|
291
|
+
return { value: JSON.stringify(redacted.value), count: redacted.count };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
294
|
+
const redacted = profile.redact(text);
|
|
295
|
+
return { value: redacted.text, count: redacted.count };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function redactResult(result, profile) {
|
|
300
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)
|
|
301
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(result))) {
|
|
302
|
+
throw configurationError('Slice backend returned an invalid tool result');
|
|
303
|
+
}
|
|
304
|
+
const outer = profile.redactStructured(Object.fromEntries(
|
|
305
|
+
Object.entries(result).filter(([key]) => key !== 'content'),
|
|
306
|
+
));
|
|
307
|
+
let count = outer.count;
|
|
308
|
+
let content;
|
|
309
|
+
if (Object.hasOwn(result, 'content')) {
|
|
310
|
+
if (!Array.isArray(result.content)) throw configurationError('Slice backend returned invalid tool content');
|
|
311
|
+
content = result.content.map((item) => {
|
|
312
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)
|
|
313
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(item))) {
|
|
314
|
+
throw configurationError('Slice backend returned invalid tool content');
|
|
315
|
+
}
|
|
316
|
+
const metadata = profile.redactStructured(Object.fromEntries(
|
|
317
|
+
Object.entries(item).filter(([key]) => key !== 'text'),
|
|
318
|
+
));
|
|
319
|
+
count += metadata.count;
|
|
320
|
+
if (!Object.hasOwn(item, 'text')) return metadata.value;
|
|
321
|
+
if (typeof item.text !== 'string') throw configurationError('Slice backend returned invalid tool content');
|
|
322
|
+
const text = redactContentText(item.text, profile);
|
|
323
|
+
count += text.count;
|
|
324
|
+
return { ...metadata.value, text: text.value };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const value = { ...outer.value, ...(content === undefined ? {} : { content }) };
|
|
328
|
+
if (count === 0) return { value, count };
|
|
329
|
+
return {
|
|
330
|
+
count,
|
|
331
|
+
value: {
|
|
332
|
+
...value,
|
|
333
|
+
_sando_redaction: {
|
|
334
|
+
count,
|
|
335
|
+
source_round_trip: false,
|
|
336
|
+
message: 'Redacted source is not round-trippable and cannot be used for symbol replacement.',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function createSliceBridge({
|
|
343
|
+
env = process.env,
|
|
344
|
+
spawnBackend = spawnSliceProcess,
|
|
345
|
+
contextKey = () => '',
|
|
346
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
347
|
+
} = {}) {
|
|
348
|
+
if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1) {
|
|
349
|
+
throw configurationError('Slice requestTimeoutMs must be a positive integer');
|
|
350
|
+
}
|
|
351
|
+
let session;
|
|
352
|
+
let sessionKey;
|
|
353
|
+
let queue = Promise.resolve();
|
|
354
|
+
let closed = false;
|
|
355
|
+
const redactedHandles = new Set();
|
|
356
|
+
|
|
357
|
+
async function invoke(name, args, context) {
|
|
358
|
+
if (closed) throw configurationError('Slice bridge is closed');
|
|
359
|
+
if (context.signal?.aborted) throw new SliceRpcError(-32800, 'Slice request cancelled');
|
|
360
|
+
if (Date.now() >= context.deadline) throw configurationError('Slice request timed out before execution');
|
|
361
|
+
const definition = byName.get(name);
|
|
362
|
+
if (!definition) throw new SliceRpcError(-32602, 'Unknown Slice tool');
|
|
363
|
+
const forwarded = argumentsFor(definition, args);
|
|
364
|
+
if (definition.write && env?.SANDO_SLICE_WRITE !== '1') {
|
|
365
|
+
throw new SliceRpcError(-32602, 'Slice writes are disabled; set SANDO_SLICE_WRITE=1 to enable them');
|
|
366
|
+
}
|
|
367
|
+
const config = configuration(env);
|
|
368
|
+
const profile = resolveRedactionProfile(config.root);
|
|
369
|
+
if (definition.upstream === 'replace_symbol_body' && redactedHandles.has(args.handle)) {
|
|
370
|
+
throw new SliceRpcError(-32602, 'Slice fetched redacted source is not round-trippable; fetch an unredacted handle before writing');
|
|
371
|
+
}
|
|
372
|
+
const key = `${config.executable}\0${config.root}\0${contextKey(context)}`;
|
|
373
|
+
try {
|
|
374
|
+
if (!session || session.closed || key !== sessionKey) {
|
|
375
|
+
session?.close();
|
|
376
|
+
session = new SliceSession(spawnBackend(config, context));
|
|
377
|
+
sessionKey = key;
|
|
378
|
+
await session.initialize(context);
|
|
379
|
+
}
|
|
380
|
+
const result = await session.request('tools/call', { name: definition.upstream, arguments: forwarded }, context);
|
|
381
|
+
const redacted = redactResult(result, profile);
|
|
382
|
+
if (definition.upstream === 'fetch_body' && redacted.count > 0) redactedHandles.add(args.handle);
|
|
383
|
+
return redacted.value;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
throw redactError(error, profile);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
call(name, args = {}, context = {}) {
|
|
391
|
+
if (closed) return Promise.reject(configurationError('Slice bridge is closed'));
|
|
392
|
+
if (context.signal?.aborted) return Promise.reject(new SliceRpcError(-32800, 'Slice request cancelled'));
|
|
393
|
+
const deadline = Date.now() + requestTimeoutMs;
|
|
394
|
+
const admitted = { ...context, deadline };
|
|
395
|
+
let timer;
|
|
396
|
+
let abort;
|
|
397
|
+
const waiting = new Promise((_, reject) => {
|
|
398
|
+
timer = setTimeout(() => reject(configurationError('Slice request timed out')), requestTimeoutMs);
|
|
399
|
+
if (context.signal) {
|
|
400
|
+
abort = () => reject(new SliceRpcError(-32800, 'Slice request cancelled'));
|
|
401
|
+
context.signal.addEventListener('abort', abort, { once: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
const result = queue.then(() => invoke(name, args, admitted));
|
|
405
|
+
queue = result.catch(() => {});
|
|
406
|
+
return Promise.race([result, waiting]).finally(() => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
context.signal?.removeEventListener('abort', abort);
|
|
409
|
+
});
|
|
410
|
+
},
|
|
411
|
+
close() {
|
|
412
|
+
if (closed) return;
|
|
413
|
+
closed = true;
|
|
414
|
+
session?.close();
|
|
415
|
+
session = undefined;
|
|
416
|
+
sessionKey = undefined;
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
package/src/statusline.mjs
CHANGED
|
@@ -33,19 +33,14 @@ function readMetricsSnapshot(metricsPath, { host, sessionId, model } = {}) {
|
|
|
33
33
|
const records = scopedRecords(state.records, { host, sessionId });
|
|
34
34
|
if (!records.length) return undefined;
|
|
35
35
|
const report = buildMetricsReport({ ...state, records }, { sessionId });
|
|
36
|
-
const
|
|
37
|
-
const hasEstimate = records.some((item) => item.providerReportedSavingsTokens === null);
|
|
38
|
-
const providerSavings = report.cumulative.providerReportedSavingsTokens;
|
|
39
|
-
const source = hasProvider && !hasEstimate && providerSavings !== null
|
|
40
|
-
? 'provider-reported' : 'estimate';
|
|
36
|
+
const source = 'estimate';
|
|
41
37
|
const estimatedInputTokens = records.reduce((total, item) => total + item.estimatedInputTokens, 0);
|
|
42
38
|
const latest = [...records].sort((left, right) => left.at.localeCompare(right.at)).at(-1);
|
|
43
39
|
return {
|
|
44
40
|
updatedAt: latestAt(records), source,
|
|
45
41
|
model: model ?? latest?.model,
|
|
46
42
|
estimatedInputTokens,
|
|
47
|
-
savedTokens:
|
|
48
|
-
? providerSavings : report.cumulative.estimatedTransformSavingsTokens,
|
|
43
|
+
savedTokens: report.cumulative.estimatedTransformSavingsTokens,
|
|
49
44
|
};
|
|
50
45
|
} catch {
|
|
51
46
|
return undefined;
|
|
@@ -85,7 +80,8 @@ function compactTokens(value) {
|
|
|
85
80
|
export function renderStatusLine({ metrics } = {}) {
|
|
86
81
|
if (!Number.isSafeInteger(metrics?.savedTokens) || metrics.savedTokens < 0
|
|
87
82
|
|| !Number.isSafeInteger(metrics?.estimatedInputTokens) || metrics.estimatedInputTokens <= 0) return '🥪 —';
|
|
88
|
-
const percentage = Math.round(metrics.savedTokens / metrics.estimatedInputTokens * 100);
|
|
89
83
|
const estimate = metrics.source === 'estimate' ? '~' : '';
|
|
90
|
-
|
|
84
|
+
const percentage = metrics.source === 'estimate'
|
|
85
|
+
? ` (${Math.round(metrics.savedTokens / metrics.estimatedInputTokens * 100)}%)` : '';
|
|
86
|
+
return `🥪 saved ${estimate}${compactTokens(metrics.savedTokens)} ctx tok${percentage}`;
|
|
91
87
|
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -29,6 +29,20 @@ const F1_STATUSES = ['complete', 'partial', 'unavailable'];
|
|
|
29
29
|
const F1_RATIO_BUCKETS = ['zero', 'lt_1pct', '1_to_10pct', 'gt_10pct', 'unavailable'];
|
|
30
30
|
const F1_SIZE_BUCKETS = [...BYTE_BUCKETS, 'unavailable'];
|
|
31
31
|
const F1_INPUT_BUCKETS = [...COUNT_BUCKETS, 'unavailable'];
|
|
32
|
+
// Reduction without coverage reads as better than it is: a day that bounds heavily on the 3% of
|
|
33
|
+
// commands it recognises looks identical to one that bounds everything. These are the reasons the
|
|
34
|
+
// shell classifier already emits, so they are a closed set and carry nothing free-form.
|
|
35
|
+
// Counts alone cannot answer "how much did it reach": 1,276 routed and 43,945 bypassed both land
|
|
36
|
+
// in `gt_100`, and so would the reverse. The ratio is the field that carries the answer.
|
|
37
|
+
export const COVERAGE_RATIO_BUCKETS = ['zero', 'lt_1pct', '1_to_10pct', '10_to_50pct', '50_to_90pct', 'gt_90pct'];
|
|
38
|
+
|
|
39
|
+
export const COVERAGE_REASONS = [
|
|
40
|
+
'ambiguous-shell', 'compound-feeds-pipeline', 'compound-has-redirect', 'compound-segment-ambiguous',
|
|
41
|
+
'grep-shape', 'head-shape', 'invalid-input', 'read-shape', 'routing-disabled', 'sed-shape',
|
|
42
|
+
'tail-unbounded-from-end', 'unsafe-cwd', 'unsafe-grep-pattern', 'unsafe-grep-target',
|
|
43
|
+
'unsafe-read-target', 'unsupported-shell', 'unsupported-tool', 'other',
|
|
44
|
+
];
|
|
45
|
+
|
|
32
46
|
export const FAILURE_STAGES = [
|
|
33
47
|
'policy', 'input', 'redaction', 'optimization', 'artifact', 'output', 'upstream', 'response',
|
|
34
48
|
];
|
|
@@ -37,7 +51,7 @@ const SHARED_FIELDS = {
|
|
|
37
51
|
schema_version: (value) => value === SCHEMA_VERSION,
|
|
38
52
|
event: (value) => [
|
|
39
53
|
'hook_summary', 'proxy_summary', 'active_day', 'hook_failure_summary', 'proxy_failure_summary',
|
|
40
|
-
'f1_footprint', 'f4_gateway',
|
|
54
|
+
'f1_footprint', 'f4_gateway', 'coverage_summary',
|
|
41
55
|
].includes(value),
|
|
42
56
|
day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
|
|
43
57
|
plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+(?:\.\d+)?$/.test(value) && value.length <= MAX_STRING_LENGTH,
|
|
@@ -69,6 +83,14 @@ const PROXY_FAILURE_FIELDS = {
|
|
|
69
83
|
provider: (value) => PROVIDERS.includes(value),
|
|
70
84
|
failure_stage: (value) => FAILURE_STAGES.includes(value),
|
|
71
85
|
};
|
|
86
|
+
const COVERAGE_FIELDS = {
|
|
87
|
+
host: (value) => HOSTS.includes(value),
|
|
88
|
+
routed_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
89
|
+
bypassed_bucket: (value) => COUNT_BUCKETS.includes(value),
|
|
90
|
+
coverage_ratio_bucket: (value) => COVERAGE_RATIO_BUCKETS.includes(value),
|
|
91
|
+
top_bypass_reason: (value) => COVERAGE_REASONS.includes(value),
|
|
92
|
+
};
|
|
93
|
+
|
|
72
94
|
const F4_FIELDS = {
|
|
73
95
|
f4_host: (value) => F4_HOSTS.includes(value),
|
|
74
96
|
f4_operation: (value) => F4_OPERATIONS.includes(value),
|
|
@@ -91,6 +113,7 @@ function fieldsForEvent(eventType) {
|
|
|
91
113
|
if (eventType === 'active_day') return ACTIVE_DAY_FIELDS;
|
|
92
114
|
if (eventType === 'hook_failure_summary') return HOOK_FAILURE_FIELDS;
|
|
93
115
|
if (eventType === 'f4_gateway') return F4_FIELDS;
|
|
116
|
+
if (eventType === 'coverage_summary') return COVERAGE_FIELDS;
|
|
94
117
|
return PROXY_FAILURE_FIELDS;
|
|
95
118
|
}
|
|
96
119
|
|
|
@@ -259,11 +282,11 @@ const LEASE_MS = 5 * 60 * 1000;
|
|
|
259
282
|
const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 21_600_000];
|
|
260
283
|
const CHILD_ENV_KEYS = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE'];
|
|
261
284
|
|
|
262
|
-
function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {} }; }
|
|
285
|
+
function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {}, coverage_days: {} }; }
|
|
263
286
|
function readCounters(countersPath) {
|
|
264
287
|
if (!fs.existsSync(countersPath)) return emptyCounters();
|
|
265
288
|
const state = JSON.parse(fs.readFileSync(countersPath, 'utf8'));
|
|
266
|
-
return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {} };
|
|
289
|
+
return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {}, coverage_days: state.coverage_days ?? {} };
|
|
267
290
|
}
|
|
268
291
|
|
|
269
292
|
function readQueueRows(queuePath) {
|
|
@@ -320,9 +343,10 @@ function publicRow(row) {
|
|
|
320
343
|
}
|
|
321
344
|
|
|
322
345
|
function bucketEntry(entry, pluginVersion) {
|
|
346
|
+
const recordedVersion = entry.pluginVersion ?? pluginVersion;
|
|
323
347
|
if (entry.event === 'hook_summary') {
|
|
324
348
|
return {
|
|
325
|
-
schema_version: SCHEMA_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version:
|
|
349
|
+
schema_version: SCHEMA_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: recordedVersion,
|
|
326
350
|
host: entry.host, mode: entry.mode,
|
|
327
351
|
tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
|
|
328
352
|
capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
|
|
@@ -331,41 +355,70 @@ function bucketEntry(entry, pluginVersion) {
|
|
|
331
355
|
};
|
|
332
356
|
}
|
|
333
357
|
if (entry.event === 'proxy_summary') return {
|
|
334
|
-
schema_version: SCHEMA_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version:
|
|
358
|
+
schema_version: SCHEMA_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: recordedVersion,
|
|
335
359
|
provider: entry.provider ?? 'unknown', mode: entry.mode ?? 'enforce',
|
|
336
360
|
rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
|
|
337
361
|
rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
|
|
338
362
|
input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
|
|
339
363
|
};
|
|
364
|
+
if (entry.event === 'coverage_summary') {
|
|
365
|
+
const reasons = Object.entries(entry)
|
|
366
|
+
.filter(([field, count]) => field.startsWith(REASON_PREFIX) && Number.isInteger(count) && count > 0)
|
|
367
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
368
|
+
const leading = reasons[0]?.[0].slice(REASON_PREFIX.length);
|
|
369
|
+
const routed = entry.routed ?? 0;
|
|
370
|
+
const bypassed = entry.bypassed ?? 0;
|
|
371
|
+
return {
|
|
372
|
+
schema_version: SCHEMA_VERSION, event: 'coverage_summary', day_utc: entry.day, plugin_version: recordedVersion,
|
|
373
|
+
host: entry.host,
|
|
374
|
+
routed_bucket: countBucket(routed),
|
|
375
|
+
bypassed_bucket: countBucket(bypassed),
|
|
376
|
+
coverage_ratio_bucket: coverageRatioBucket(routed, routed + bypassed),
|
|
377
|
+
// A reason this build does not know travels as `other`, never as free text.
|
|
378
|
+
top_bypass_reason: COVERAGE_REASONS.includes(leading) ? leading : 'other',
|
|
379
|
+
};
|
|
380
|
+
}
|
|
340
381
|
if (entry.event === 'hook_failure_summary') return {
|
|
341
|
-
schema_version: SCHEMA_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version:
|
|
382
|
+
schema_version: SCHEMA_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: recordedVersion,
|
|
342
383
|
host: entry.host, failure_stage: entry.failureStage,
|
|
343
384
|
};
|
|
344
385
|
return {
|
|
345
|
-
schema_version: SCHEMA_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version:
|
|
386
|
+
schema_version: SCHEMA_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: recordedVersion,
|
|
346
387
|
provider: entry.provider, failure_stage: entry.failureStage,
|
|
347
388
|
};
|
|
348
389
|
}
|
|
349
390
|
|
|
350
391
|
/** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
|
|
351
392
|
* only ever leave the machine) once `closeDay` closes a finished UTC day. */
|
|
352
|
-
export function incrementCounter({ statePaths, day, event, host, provider, mode, failureStage, deltas = {} }) {
|
|
353
|
-
if (!['hook_summary', 'proxy_summary', 'hook_failure_summary', 'proxy_failure_summary'].includes(event)) {
|
|
393
|
+
export function incrementCounter({ statePaths, day, pluginVersion = PLUGIN_VERSION, event, host, provider, mode, failureStage, deltas = {} }) {
|
|
394
|
+
if (!['hook_summary', 'proxy_summary', 'hook_failure_summary', 'proxy_failure_summary', 'coverage_summary'].includes(event)) {
|
|
354
395
|
throw new Error('incrementCounter: invalid event');
|
|
355
396
|
}
|
|
356
397
|
const isProxy = event.startsWith('proxy_');
|
|
357
398
|
const dimension = isProxy ? provider : host;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
399
|
+
if (!SHARED_FIELDS.plugin_version(pluginVersion)) throw new Error('incrementCounter: invalid plugin version');
|
|
400
|
+
const suffix = event.includes('failure') ? failureStage ?? '' : mode ?? '';
|
|
401
|
+
const key = [day, pluginVersion, event, dimension, suffix].join('|');
|
|
402
|
+
const legacyKey = [day, event, dimension, suffix].join('|');
|
|
361
403
|
ensureDirectory(path.dirname(statePaths.counters));
|
|
362
404
|
withLock(`${statePaths.counters}.lock`, () => {
|
|
363
405
|
const state = readCounters(statePaths.counters);
|
|
364
|
-
const
|
|
365
|
-
|
|
406
|
+
const legacy = state.counters[legacyKey];
|
|
407
|
+
let existing = state.counters[key];
|
|
408
|
+
if (legacy && !legacy.pluginVersion) {
|
|
409
|
+
if (existing) {
|
|
410
|
+
for (const [field, value] of Object.entries(legacy)) {
|
|
411
|
+
if (Number.isInteger(value) && value >= 0) existing[field] = (existing[field] ?? 0) + value;
|
|
412
|
+
}
|
|
413
|
+
} else existing = legacy;
|
|
414
|
+
delete state.counters[legacyKey];
|
|
415
|
+
}
|
|
416
|
+
existing ??= {
|
|
417
|
+
day, pluginVersion, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
|
|
366
418
|
...(event.endsWith('_summary') && !event.includes('failure') ? { mode: mode ?? null } : {}),
|
|
367
419
|
...(event.includes('failure') ? { failureStage } : {}),
|
|
368
420
|
};
|
|
421
|
+
existing.pluginVersion = pluginVersion;
|
|
369
422
|
for (const [field, value] of Object.entries(deltas)) {
|
|
370
423
|
if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
|
|
371
424
|
existing[field] = (existing[field] ?? 0) + value;
|
|
@@ -375,23 +428,52 @@ export function incrementCounter({ statePaths, day, event, host, provider, mode,
|
|
|
375
428
|
});
|
|
376
429
|
}
|
|
377
430
|
|
|
378
|
-
export function recordFailure({ statePaths, day, event, host, provider, failureStage }) {
|
|
431
|
+
export function recordFailure({ statePaths, day, pluginVersion = PLUGIN_VERSION, event, host, provider, failureStage }) {
|
|
379
432
|
incrementCounter({
|
|
380
|
-
statePaths, day, event, host, provider, failureStage, deltas: { count: 1 },
|
|
433
|
+
statePaths, day, pluginVersion, event, host, provider, failureStage, deltas: { count: 1 },
|
|
381
434
|
});
|
|
382
435
|
}
|
|
383
436
|
|
|
384
437
|
/** Queues a single non-aggregate activity marker for this UTC day and host. */
|
|
385
|
-
export
|
|
438
|
+
export const REASON_PREFIX = 'reason:';
|
|
439
|
+
|
|
440
|
+
export function coverageRatioBucket(routed, total) {
|
|
441
|
+
if (!Number.isInteger(routed) || !Number.isInteger(total) || routed < 0 || total < routed) {
|
|
442
|
+
throw new Error('coverageRatioBucket: invalid counts');
|
|
443
|
+
}
|
|
444
|
+
if (total === 0 || routed === 0) return 'zero';
|
|
445
|
+
const ratio = routed / total;
|
|
446
|
+
if (ratio < 0.01) return 'lt_1pct';
|
|
447
|
+
if (ratio < 0.1) return '1_to_10pct';
|
|
448
|
+
if (ratio < 0.5) return '10_to_50pct';
|
|
449
|
+
if (ratio < 0.9) return '50_to_90pct';
|
|
450
|
+
return 'gt_90pct';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** One call per classified shell command. Counters close into a single row per day, next to the
|
|
454
|
+
* reduction they qualify: a large saving on a small share of commands should not read the same as
|
|
455
|
+
* a large saving on all of them. */
|
|
456
|
+
export function recordCoverage({ statePaths, day, pluginVersion = PLUGIN_VERSION, host, routed, reason }) {
|
|
457
|
+
const deltas = routed ? { routed: 1 } : { bypassed: 1 };
|
|
458
|
+
if (!routed) {
|
|
459
|
+
const label = COVERAGE_REASONS.includes(reason) ? reason : 'other';
|
|
460
|
+
deltas[`${REASON_PREFIX}${label}`] = 1;
|
|
461
|
+
}
|
|
462
|
+
incrementCounter({ statePaths, day, pluginVersion, event: 'coverage_summary', host, deltas });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function recordActiveDay({ statePaths, day, pluginVersion = PLUGIN_VERSION, host }) {
|
|
386
466
|
const marker = {
|
|
387
|
-
schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version:
|
|
467
|
+
schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: pluginVersion, host,
|
|
388
468
|
};
|
|
389
469
|
const validatedMarker = validateEvent(marker);
|
|
390
|
-
const activeDayKey = `${day}|${host}`;
|
|
470
|
+
const activeDayKey = `${day}|${pluginVersion}|${host}`;
|
|
471
|
+
const legacyActiveDayKey = `${day}|${host}`;
|
|
391
472
|
ensureDirectory(path.dirname(statePaths.counters));
|
|
392
473
|
withLock(`${statePaths.counters}.lock`, () => {
|
|
393
474
|
const state = readCounters(statePaths.counters);
|
|
394
475
|
const activeDays = state.active_days;
|
|
476
|
+
if (Object.hasOwn(activeDays, legacyActiveDayKey)) delete activeDays[legacyActiveDayKey];
|
|
395
477
|
const cutoff = Date.parse(`${day}T00:00:00Z`) - (ACTIVE_DAY_RETENTION_DAYS - 1) * 86_400_000;
|
|
396
478
|
for (const [key, recordedDay] of Object.entries(activeDays)) {
|
|
397
479
|
if (Date.parse(`${recordedDay}T00:00:00Z`) < cutoff) delete activeDays[key];
|