sandoichi 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const RESULT_DISCLOSURE_SCHEMA = 'sando-result-disclosure/v1';
4
+ export const RESULT_DISCLOSURE_VERSION = 1;
5
+ export const ARTIFACT_TOOL_NAME = 'sando_artifact_get';
6
+
7
+ function sha256(text) {
8
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
9
+ }
10
+
11
+ function stableJson(value, seen = new Set()) {
12
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
13
+ if (seen.has(value)) throw new TypeError('result disclosure must not be cyclic');
14
+ seen.add(value);
15
+ const result = Array.isArray(value)
16
+ ? `[${value.map((item) => stableJson(item, seen)).join(',')}]`
17
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
18
+ seen.delete(value);
19
+ return result;
20
+ }
21
+
22
+ function resultType(toolName) {
23
+ const name = typeof toolName === 'string' ? toolName.toLowerCase() : '';
24
+ if (name === 'read') return 'read';
25
+ if (name === 'grep') return 'grep';
26
+ if (name === 'bash' || name === 'exec') return 'bash';
27
+ if (name === 'log') return 'log';
28
+ return 'mcp';
29
+ }
30
+
31
+ function policyName(type, route) {
32
+ if (type === 'read') return route === 'summary' ? 'read-structure' : 'read-bounded';
33
+ if (type === 'grep') return 'grep-matches';
34
+ if (type === 'bash') return 'bash-head-tail';
35
+ if (type === 'log') return 'log-head-tail';
36
+ return 'mcp-bounded';
37
+ }
38
+
39
+ function markers(inline, artifact) {
40
+ const result = [];
41
+ if (artifact) result.push('artifact-handle');
42
+ if (inline.includes('[middle elided]')) result.push('middle-elision');
43
+ if (inline.includes('[sando read structure:')) result.push('structure-preview');
44
+ if (inline.includes('[sando repeated x')) result.push('repetition-elision');
45
+ return result;
46
+ }
47
+
48
+ function bytes(value, name) {
49
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
50
+ return value;
51
+ }
52
+
53
+ export function buildResultDisclosure({
54
+ toolName, route, reason, inline, redactedText, inputBytes, redactedBytes, artifact, elidedRange,
55
+ } = {}) {
56
+ if (typeof toolName !== 'string' || !toolName || typeof route !== 'string' || !route
57
+ || typeof reason !== 'string' || !reason || typeof inline !== 'string' || typeof redactedText !== 'string') {
58
+ throw new TypeError('result disclosure input is invalid');
59
+ }
60
+ const original = bytes(inputBytes ?? Buffer.byteLength(redactedText), 'inputBytes');
61
+ const redacted = bytes(redactedBytes ?? Buffer.byteLength(redactedText), 'redactedBytes');
62
+ const visible = Buffer.byteLength(inline);
63
+ const provenanceDigest = sha256(redactedText);
64
+ if (artifact !== undefined && artifact !== null) {
65
+ const validRef = typeof artifact.ref === 'string' && /^sando:sha256:[a-f0-9]{16,64}$/.test(artifact.ref);
66
+ const refDigest = validRef ? artifact.ref.slice('sando:'.length) : null;
67
+ const contentValid = artifact.content === undefined
68
+ || (typeof artifact.content === 'string' && sha256(artifact.content) === provenanceDigest
69
+ && Buffer.byteLength(artifact.content) === redacted);
70
+ if (!validRef || typeof artifact.sourceDigest !== 'string'
71
+ || artifact.sourceDigest !== provenanceDigest
72
+ || !refDigest || !provenanceDigest.startsWith(refDigest)
73
+ || !Number.isSafeInteger(artifact.bytes) || artifact.bytes < 0 || artifact.bytes !== redacted
74
+ || !contentValid) {
75
+ throw new TypeError('result artifact is invalid');
76
+ }
77
+ }
78
+ const type = resultType(toolName);
79
+ const recovery = !artifact && reason === 'artifact-admission-limit'
80
+ ? { mode: 'unavailable', bounded: true }
81
+ : undefined;
82
+ const recoveryCommand = artifact
83
+ ? (elidedRange && Number.isInteger(elidedRange.startLine) && Number.isInteger(elidedRange.endLine)
84
+ ? `sando artifact get --ref ${artifact.ref} --start-line ${elidedRange.startLine} --end-line ${elidedRange.endLine}`
85
+ : `sando artifact get --ref ${artifact.ref} --max-bytes 65536`)
86
+ : undefined;
87
+ return {
88
+ schema: RESULT_DISCLOSURE_SCHEMA,
89
+ version: RESULT_DISCLOSURE_VERSION,
90
+ type,
91
+ policy: policyName(type, route),
92
+ route,
93
+ reason,
94
+ provenanceDigest,
95
+ bytes: { original, redacted, visible },
96
+ markers: markers(inline, artifact),
97
+ ...(recovery ? { recovery } : {}),
98
+ artifact: artifact ? {
99
+ handle: artifact.ref,
100
+ digest: artifact.sourceDigest,
101
+ bytes: artifact.bytes,
102
+ recovery: {
103
+ tool: ARTIFACT_TOOL_NAME,
104
+ command: recoveryCommand,
105
+ bounded: true,
106
+ },
107
+ ...(elidedRange ? { elidedRange } : {}),
108
+ } : null,
109
+ };
110
+ }
111
+
112
+ export function serializeResultDisclosure(report) {
113
+ if (!report || report.schema !== RESULT_DISCLOSURE_SCHEMA) throw new TypeError('result disclosure is invalid');
114
+ return stableJson(report);
115
+ }
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
+ }
@@ -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 hasProvider = records.some((item) => item.providerReportedSavingsTokens !== null);
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: source === 'provider-reported'
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
- return `🥪 saved ${estimate}${compactTokens(metrics.savedTokens)} ctx tok (${percentage}%)`;
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
  }