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/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
  }
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: 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: 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: 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: 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
- const key = event.includes('failure')
359
- ? [day, event, dimension, failureStage ?? ''].join('|')
360
- : [day, event, dimension, mode ?? ''].join('|');
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 existing = state.counters[key] ?? {
365
- day, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
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 function recordActiveDay({ statePaths, day, pluginVersion, host }) {
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: PLUGIN_VERSION, host,
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];