sandoichi 0.4.0 → 0.4.2

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,142 @@
1
+ import { CONTEXT_CATEGORIES } from './context-footprint.mjs';
2
+
3
+ const CATEGORY_SET = new Set(CONTEXT_CATEGORIES);
4
+
5
+ function object(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+
9
+ function jsonBytes(value) {
10
+ try {
11
+ const text = JSON.stringify(value);
12
+ return text === undefined ? 0 : Buffer.byteLength(text, 'utf8');
13
+ } catch {
14
+ return 0;
15
+ }
16
+ }
17
+
18
+ function nameOf(value) {
19
+ return typeof value?.name === 'string' ? value.name : '';
20
+ }
21
+
22
+ function deferred(value) {
23
+ return value?.defer_loading === true
24
+ || value?.deferred === true
25
+ || value?.deferLoading === true;
26
+ }
27
+
28
+ function toolCategory(tool) {
29
+ const name = nameOf(tool).toLowerCase();
30
+ if (name.includes('sando') || name.startsWith('sando_')) return 'sando';
31
+ if (deferred(tool)) return 'mcp-deferred';
32
+ if (name.startsWith('mcp__') || name.startsWith('mcp_')) return 'mcp-direct';
33
+ return 'builtin-tools';
34
+ }
35
+
36
+ function without(value, keys) {
37
+ if (!object(value)) return value;
38
+ const result = { ...value };
39
+ for (const key of keys) delete result[key];
40
+ return result;
41
+ }
42
+
43
+ function hasToolOutput(item) {
44
+ const type = typeof item?.type === 'string' ? item.type.toLowerCase() : '';
45
+ return item?.role === 'tool'
46
+ || type === 'tool_result'
47
+ || type.endsWith('_tool_call_output')
48
+ || type.endsWith('function_call_output')
49
+ || Object.hasOwn(item ?? {}, 'output');
50
+ }
51
+
52
+ function addSegment(segments, budget, category, value) {
53
+ if (!CATEGORY_SET.has(category)) return budget;
54
+ const bytes = Math.min(jsonBytes(value), budget);
55
+ if (bytes > 0) segments.push({ category, bytes });
56
+ return budget - bytes;
57
+ }
58
+
59
+ function classifyAnthropic(body, segments, budget) {
60
+ const handled = new Set(['system', 'tools', 'messages']);
61
+ if (Object.hasOwn(body, 'system')) budget = addSegment(segments, budget, 'host-instructions', body.system);
62
+
63
+ if (Array.isArray(body.tools)) {
64
+ for (const tool of body.tools) {
65
+ budget = addSegment(segments, budget, toolCategory(tool), tool);
66
+ }
67
+ }
68
+
69
+ if (Array.isArray(body.messages)) {
70
+ let currentUser = -1;
71
+ body.messages.forEach((message, index) => {
72
+ if (message?.role === 'user' && !hasToolOutput(message)) currentUser = index;
73
+ });
74
+ body.messages.forEach((message, index) => {
75
+ const category = index === currentUser ? 'user-prompt' : 'history';
76
+ budget = addSegment(segments, budget, category, message);
77
+ });
78
+ }
79
+
80
+ for (const [key, value] of Object.entries(body)) {
81
+ if (!handled.has(key)) budget = addSegment(segments, budget, 'provider-overhead', value);
82
+ }
83
+ return budget;
84
+ }
85
+
86
+ function codexMessageCategory(item, currentUser) {
87
+ if (item?.role === 'developer' || item?.role === 'system') return 'host-instructions';
88
+ if (currentUser && item === currentUser) return 'user-prompt';
89
+ return 'history';
90
+ }
91
+
92
+ function classifyCodex(body, segments, budget) {
93
+ const handled = new Set(['instructions', 'input', 'tools']);
94
+ if (Object.hasOwn(body, 'instructions')) budget = addSegment(segments, budget, 'host-instructions', body.instructions);
95
+
96
+ if (Array.isArray(body.tools)) {
97
+ for (const tool of body.tools) budget = addSegment(segments, budget, toolCategory(tool), tool);
98
+ }
99
+
100
+ if (Array.isArray(body.input)) {
101
+ let currentUser = null;
102
+ for (const item of body.input) {
103
+ if (object(item) && item.role === 'user' && !hasToolOutput(item)) currentUser = item;
104
+ }
105
+ for (const item of body.input) {
106
+ if (!object(item)) {
107
+ budget = addSegment(segments, budget, 'provider-overhead', item);
108
+ continue;
109
+ }
110
+ if (Array.isArray(item.tools)) {
111
+ for (const tool of item.tools) budget = addSegment(segments, budget, toolCategory(tool), tool);
112
+ }
113
+ const message = Object.hasOwn(item, 'content')
114
+ ? without(item, ['content', 'tools'])
115
+ : without(item, ['tools']);
116
+ const messageCategory = hasToolOutput(item)
117
+ ? 'history'
118
+ : codexMessageCategory(item, currentUser);
119
+ if (Object.hasOwn(item, 'content')) {
120
+ budget = addSegment(segments, budget, messageCategory, item.content);
121
+ budget = addSegment(segments, budget, 'provider-overhead', message);
122
+ } else {
123
+ budget = addSegment(segments, budget, 'provider-overhead', message);
124
+ }
125
+ }
126
+ }
127
+
128
+ for (const [key, value] of Object.entries(body)) {
129
+ if (!handled.has(key)) budget = addSegment(segments, budget, 'provider-overhead', value);
130
+ }
131
+ return budget;
132
+ }
133
+
134
+ export function classifyContextRequest({ provider, body } = {}) {
135
+ const segments = [];
136
+ if (!object(body)) return { segments };
137
+ const bodyBytes = jsonBytes(body);
138
+ let budget = bodyBytes;
139
+ if (provider === 'anthropic') budget = classifyAnthropic(body, segments, budget);
140
+ else if (provider === 'openai-responses') budget = classifyCodex(body, segments, budget);
141
+ return { segments, unclassifiedBytes: budget };
142
+ }
@@ -0,0 +1,299 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { createRedactionProfile } from './redaction-profile.mjs';
4
+
5
+ export const CONTEXT_CAPTURE_SCHEMA = 'sando-context-capture/v1';
6
+ export const CONTEXT_FOOTPRINT_SCHEMA = 'sando-context-footprint/v1';
7
+ export const CONTEXT_FOOTPRINT_VERSION = 1;
8
+
9
+ export const CONTEXT_CATEGORIES = Object.freeze([
10
+ 'host-instructions',
11
+ 'project-instructions',
12
+ 'skills',
13
+ 'builtin-tools',
14
+ 'mcp-direct',
15
+ 'mcp-deferred',
16
+ 'sando',
17
+ 'user-prompt',
18
+ 'history',
19
+ 'provider-overhead',
20
+ 'unknown',
21
+ ]);
22
+
23
+ const HOSTS = new Set(['claude', 'codex']);
24
+ const BODY_STATES = new Set(['observed', 'partial', 'unavailable']);
25
+ const TOOL_SEARCH_STATES = new Set(['enabled', 'disabled', 'unavailable', 'indeterminate']);
26
+ const FORMATS = Object.freeze({
27
+ claude: new Set(['anthropic']),
28
+ codex: new Set(['openai-responses', 'codex-cli']),
29
+ });
30
+ const PROVIDER_FIELDS = Object.freeze([
31
+ 'inputTokens',
32
+ 'cachedInputTokens',
33
+ 'cacheWriteInputTokens',
34
+ 'cacheReadInputTokens',
35
+ 'outputTokens',
36
+ 'reasoningOutputTokens',
37
+ 'totalTokens',
38
+ 'totalCostUsd',
39
+ ]);
40
+ const DEFAULT_PROFILE = createRedactionProfile();
41
+
42
+ function object(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+
46
+ function counter(value, name) {
47
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
48
+ return value;
49
+ }
50
+
51
+ function add(left, right, name) {
52
+ const total = left + right;
53
+ if (!Number.isSafeInteger(total)) throw new RangeError(`${name} exceeds safe integer range`);
54
+ return total;
55
+ }
56
+
57
+ function digest(value) {
58
+ if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) throw new TypeError('digest is invalid');
59
+ return value;
60
+ }
61
+
62
+ function sha256(value) {
63
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
64
+ }
65
+
66
+ function stableJson(value, seen = new Set()) {
67
+ if (value === null || typeof value !== 'object') {
68
+ const result = JSON.stringify(value);
69
+ if (result === undefined) throw new TypeError('value is not JSON serializable');
70
+ return result;
71
+ }
72
+ if (seen.has(value)) throw new TypeError('value must not be cyclic');
73
+ seen.add(value);
74
+ const result = Array.isArray(value)
75
+ ? `[${value.map((item) => stableJson(item, seen)).join(',')}]`
76
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
77
+ seen.delete(value);
78
+ return result;
79
+ }
80
+
81
+ function estimateBytes(bytes) {
82
+ return bytes === 0 ? 0 : Math.ceil(bytes / 4);
83
+ }
84
+
85
+ function profileFor(candidate) {
86
+ if (candidate === undefined) return DEFAULT_PROFILE;
87
+ if (!object(candidate) || typeof candidate.redact !== 'function') throw new TypeError('redactionProfile is invalid');
88
+ return candidate;
89
+ }
90
+
91
+ function redactedDigest(text, profile) {
92
+ return sha256(profile.redact(text).text);
93
+ }
94
+
95
+ function contentEvidence(value, name, profile) {
96
+ if (typeof value !== 'string') throw new TypeError(`${name}.content must be a string`);
97
+ return { bytes: Buffer.byteLength(value, 'utf8'), digest: redactedDigest(value, profile) };
98
+ }
99
+
100
+ function bodyEvidence(body, profile) {
101
+ if (!object(body)) throw new TypeError('body is invalid');
102
+ const state = body.state ?? 'unavailable';
103
+ if (!BODY_STATES.has(state)) throw new TypeError('body state is invalid');
104
+ if (state === 'unavailable') return { state, bytes: null, digest: null };
105
+
106
+ const content = Object.hasOwn(body, 'content') ? contentEvidence(body.content, 'body', profile) : null;
107
+ const bytes = content ? content.bytes : counter(body.bytes, 'body.bytes');
108
+ if (Object.hasOwn(body, 'bytes') && counter(body.bytes, 'body.bytes') !== bytes) {
109
+ throw new TypeError('body bytes contradict content');
110
+ }
111
+ const bodyDigest = content ? content.digest : (body.digest === undefined ? null : digest(body.digest));
112
+ if (content && body.digest !== undefined && digest(body.digest) !== bodyDigest) {
113
+ throw new TypeError('body digest contradicts content');
114
+ }
115
+ return { state, bytes, digest: bodyDigest };
116
+ }
117
+
118
+ function categoryOf(value) {
119
+ if (typeof value !== 'string' || !CONTEXT_CATEGORIES.includes(value)) throw new TypeError('segment category is invalid');
120
+ return value;
121
+ }
122
+
123
+ function normalizeSegments(segments, profile) {
124
+ if (segments === undefined) return [];
125
+ if (!Array.isArray(segments) || segments.length > 10_000) throw new TypeError('segments are invalid');
126
+ return segments.map((segment) => {
127
+ if (!object(segment)) throw new TypeError('segment is invalid');
128
+ const category = categoryOf(segment.category);
129
+ const content = Object.hasOwn(segment, 'content') ? contentEvidence(segment.content, 'segment', profile) : null;
130
+ const bytes = content ? content.bytes : counter(segment.bytes, 'segment.bytes');
131
+ if (Object.hasOwn(segment, 'bytes') && counter(segment.bytes, 'segment.bytes') !== bytes) {
132
+ throw new TypeError('segment bytes contradict content');
133
+ }
134
+ return { category, bytes, digest: content?.digest ?? null };
135
+ });
136
+ }
137
+
138
+ function validateSegmentMembership(body, segments) {
139
+ if (typeof body?.content !== 'string' || !Array.isArray(segments)) return;
140
+ let cursor = 0;
141
+ for (const segment of segments) {
142
+ if (!object(segment) || typeof segment.content !== 'string') continue;
143
+ const position = body.content.indexOf(segment.content, cursor);
144
+ if (position < 0) throw new TypeError('segment content is not a non-overlapping part of the observed body');
145
+ cursor = position + segment.content.length;
146
+ }
147
+ }
148
+
149
+ function providerUsage(value) {
150
+ if (value === undefined || value === null) return null;
151
+ if (!object(value)) throw new TypeError('providerUsage is invalid');
152
+ const result = { source: 'provider-reported' };
153
+ for (const field of PROVIDER_FIELDS) {
154
+ if (!Object.hasOwn(value, field)) continue;
155
+ if (field === 'totalCostUsd') {
156
+ if (typeof value[field] !== 'number' || !Number.isFinite(value[field]) || value[field] < 0) {
157
+ throw new TypeError('provider cost is invalid');
158
+ }
159
+ result[field] = value[field];
160
+ } else result[field] = counter(value[field], `providerUsage.${field}`);
161
+ }
162
+ if (!Object.hasOwn(result, 'inputTokens')) throw new TypeError('providerUsage.inputTokens is required');
163
+ if (Object.hasOwn(result, 'cachedInputTokens') && Object.hasOwn(result, 'cacheReadInputTokens')
164
+ && result.cachedInputTokens !== result.cacheReadInputTokens) {
165
+ throw new TypeError('provider cache-read counters contradict each other');
166
+ }
167
+ const cacheRead = result.cacheReadInputTokens ?? result.cachedInputTokens;
168
+ if ([cacheRead, result.cacheWriteInputTokens].some((value) => value !== undefined && value > result.inputTokens)
169
+ || (cacheRead !== undefined && result.cacheWriteInputTokens !== undefined
170
+ && add(cacheRead, result.cacheWriteInputTokens, 'provider cache counters') > result.inputTokens)) {
171
+ throw new TypeError('provider cache counters exceed input tokens');
172
+ }
173
+ if (Object.hasOwn(result, 'reasoningOutputTokens') && Object.hasOwn(result, 'outputTokens')
174
+ && result.reasoningOutputTokens > result.outputTokens) {
175
+ throw new TypeError('provider reasoning tokens exceed output tokens');
176
+ }
177
+ if (Object.hasOwn(result, 'outputTokens') && Object.hasOwn(result, 'totalTokens')
178
+ && add(result.inputTokens, result.outputTokens, 'provider token counters') !== result.totalTokens) {
179
+ throw new TypeError('provider total tokens are invalid');
180
+ }
181
+ return result;
182
+ }
183
+
184
+ export function detectToolSearchState(value) {
185
+ if (!object(value)) return 'indeterminate';
186
+ if (TOOL_SEARCH_STATES.has(value.state)) return value.state;
187
+ if (typeof value.enabled === 'boolean') return value.enabled ? 'enabled' : 'disabled';
188
+ if (value.available === false) return 'unavailable';
189
+ return 'indeterminate';
190
+ }
191
+
192
+ function emptyCategories() {
193
+ return Object.fromEntries(CONTEXT_CATEGORIES.map((category) => [category, {
194
+ bytes: 0, estimatedTokens: 0, segmentCount: 0,
195
+ }]));
196
+ }
197
+
198
+ function categorySummary(segments) {
199
+ const categories = emptyCategories();
200
+ for (const segment of segments) {
201
+ const current = categories[segment.category];
202
+ current.bytes = add(current.bytes, segment.bytes, 'category bytes');
203
+ current.estimatedTokens = estimateBytes(current.bytes);
204
+ current.segmentCount += 1;
205
+ }
206
+ return categories;
207
+ }
208
+
209
+ function safeReport(report) {
210
+ const result = { ...report };
211
+ delete result.provenanceDigest;
212
+ return result;
213
+ }
214
+
215
+ export function serializeContextFootprint(report) {
216
+ if (!object(report) || report.schema !== CONTEXT_FOOTPRINT_SCHEMA) throw new TypeError('context footprint report is invalid');
217
+ return stableJson(report);
218
+ }
219
+
220
+ export function buildContextFootprintReport(capture, { redactionProfile } = {}) {
221
+ if (!object(capture) || capture.schema !== CONTEXT_CAPTURE_SCHEMA) throw new TypeError('context capture schema is invalid');
222
+ if (!HOSTS.has(capture.host)) throw new TypeError('context capture host is invalid');
223
+ const profile = profileFor(redactionProfile);
224
+ validateSegmentMembership(capture.body, capture.segments);
225
+ const body = bodyEvidence(capture.body ?? { state: 'unavailable' }, profile);
226
+ const requestFormat = capture.requestFormat ?? null;
227
+ if (requestFormat !== null && (typeof requestFormat !== 'string' || !FORMATS[capture.host].has(requestFormat))) {
228
+ throw new TypeError('context capture request format is invalid');
229
+ }
230
+ if (requestFormat === null && body.state !== 'unavailable') {
231
+ throw new TypeError('context capture request format is required for an observed body');
232
+ }
233
+ const segments = normalizeSegments(capture.segments, profile);
234
+ const providerReported = providerUsage(capture.providerUsage);
235
+ const toolSearch = { state: detectToolSearchState(capture.toolSearch) };
236
+ const base = {
237
+ schema: CONTEXT_FOOTPRINT_SCHEMA,
238
+ version: CONTEXT_FOOTPRINT_VERSION,
239
+ host: capture.host,
240
+ requestFormat,
241
+ toolSearch,
242
+ observation: { status: body.state, bodyDigest: body.digest },
243
+ attribution: null,
244
+ categories: null,
245
+ tokenAccounting: {
246
+ estimated: {
247
+ source: 'mechanical-estimate',
248
+ formula: 'ceil(UTF-8 bytes / 4)',
249
+ totalTokens: null,
250
+ attributedTokens: null,
251
+ unknownTokens: null,
252
+ categories: null,
253
+ },
254
+ providerReported,
255
+ },
256
+ };
257
+
258
+ if (body.state !== 'unavailable') {
259
+ const categories = categorySummary(segments);
260
+ const observedBytes = segments.reduce((total, segment) => add(total, segment.bytes, 'observed bytes'), 0);
261
+ if (observedBytes > body.bytes) throw new RangeError('segments exceed body bytes');
262
+ const unclassifiedBytes = body.bytes - observedBytes;
263
+ categories.unknown.bytes = add(categories.unknown.bytes, unclassifiedBytes, 'unknown bytes');
264
+ categories.unknown.estimatedTokens = estimateBytes(categories.unknown.bytes);
265
+ const unknownBytes = categories.unknown.bytes;
266
+ const attributedBytes = body.bytes - unknownBytes;
267
+ const categoryTokens = Object.fromEntries(
268
+ CONTEXT_CATEGORIES.map((category) => [category, categories[category].estimatedTokens]),
269
+ );
270
+ base.attribution = {
271
+ status: body.state === 'partial' || unknownBytes > 0 ? 'partial' : 'complete',
272
+ bodyBytes: body.bytes,
273
+ observedBytes,
274
+ attributedBytes,
275
+ unknownBytes,
276
+ unknownRatio: body.bytes === 0 ? 0 : unknownBytes / body.bytes,
277
+ };
278
+ base.categories = categories;
279
+ base.tokenAccounting.estimated = {
280
+ ...base.tokenAccounting.estimated,
281
+ totalTokens: estimateBytes(body.bytes),
282
+ attributedTokens: estimateBytes(attributedBytes),
283
+ unknownTokens: estimateBytes(unknownBytes),
284
+ categories: categoryTokens,
285
+ };
286
+ } else {
287
+ base.attribution = {
288
+ status: 'unavailable',
289
+ bodyBytes: null,
290
+ observedBytes: null,
291
+ attributedBytes: null,
292
+ unknownBytes: null,
293
+ unknownRatio: null,
294
+ };
295
+ }
296
+
297
+ base.provenanceDigest = sha256(serializeContextFootprint(safeReport(base)));
298
+ return base;
299
+ }
@@ -3,6 +3,7 @@ import { dedupeHistory } from './history-dedupe.mjs';
3
3
  import { selectHistoryCandidates, validateMaxHistoryTokens } from './history-budget.mjs';
4
4
  import { shakeHistoricalResult } from './history-shake.mjs';
5
5
  import { compactHistoricalStructure } from './history-structure.mjs';
6
+ import { buildHistoryDisclosure } from './history-disclosure.mjs';
6
7
 
7
8
  const SUPERSEDED = '[sando superseded by newer read]';
8
9
  const USELESS = '[sando elided useless success]';
@@ -321,7 +322,7 @@ function suffixTokensByPosition(body) {
321
322
  return suffix;
322
323
  }
323
324
 
324
- export function transformProviderRequest({ provider, body, policy, idleMs } = {}) {
325
+ export function transformProviderRequest({ provider, body, policy, idleMs, redactionProfile } = {}) {
325
326
  const clone = structuredClone(body);
326
327
  const estimatedInputTokens = estimate(body);
327
328
  const selectedProvider = provider ?? detectProviderBody(body);
@@ -331,6 +332,13 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
331
332
  let deduplicatedResults = 0;
332
333
  let compactedStructures = 0;
333
334
  let shakenResults = 0;
335
+ const disclosures = [];
336
+ const disclose = (record, reason, originalText, visibleText, recovery = 'rerun-tool') => {
337
+ if (typeof originalText !== 'string' || typeof visibleText !== 'string') return;
338
+ disclosures.push(buildHistoryDisclosure({
339
+ toolName: record.toolName, reason, originalText, visibleText, recovery, redactionProfile,
340
+ }));
341
+ };
334
342
  const maxHistoryTokens = object(policy) && Object.hasOwn(policy, 'maxHistoryTokens')
335
343
  ? validateMaxHistoryTokens(policy.maxHistoryTokens)
336
344
  : null;
@@ -400,6 +408,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
400
408
  if (!newer) continue;
401
409
  if (cacheProtected(old.result, reclaimedTokens(old.text, SUPERSEDED))) { cacheProtectedSkips += 1; continue; }
402
410
  replaceResult(old.result.item, old.result.key, SUPERSEDED);
411
+ disclose({ toolName: old.call.name }, 'superseded-read', old.text, SUPERSEDED);
403
412
  supersededReads += 1;
404
413
  }
405
414
 
@@ -410,6 +419,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
410
419
  if (text === SUPERSEDED || resultError(result.item, text) || !useless(text)) continue;
411
420
  if (cacheProtected(result, reclaimedTokens(text, USELESS))) { cacheProtectedSkips += 1; continue; }
412
421
  replaceResult(result.item, result.key, USELESS);
422
+ disclose({ toolName: calls.get(id).name }, 'useless-success', text, USELESS);
413
423
  elidedUselessSuccesses += 1;
414
424
  }
415
425
 
@@ -427,6 +437,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
427
437
  if (cacheProtected(original.entry, reclaimedTokens(
428
438
  resultText(original.output) ?? '', resultText(reduced.output) ?? ''))) { cacheProtectedSkips += 1; continue; }
429
439
  replaceResult(original.entry.item, original.entry.key, reduced.output);
440
+ disclose(original, 'duplicate-history', resultText(original.output) ?? '', resultText(reduced.output) ?? '', 'newer-result');
430
441
  deduplicatedResults += 1;
431
442
  }
432
443
 
@@ -443,6 +454,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
443
454
  if (compacted === text) continue;
444
455
  if (cacheProtected(record.entry, reclaimedTokens(text, compacted))) { cacheProtectedSkips += 1; continue; }
445
456
  replaceResult(record.entry.item, record.entry.key, compacted);
457
+ disclose(record, 'repeated-lines', text, compacted);
446
458
  compactedStructures += 1;
447
459
  }
448
460
 
@@ -460,6 +472,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
460
472
  if (!shaken.changed) continue;
461
473
  if (cacheProtected(record.entry, reclaimedTokens(text, shaken.text))) { cacheProtectedSkips += 1; continue; }
462
474
  replaceResult(record.entry.item, record.entry.key, shaken.text);
475
+ disclose(record, 'history-shake', text, shaken.text);
463
476
  shakenResults += 1;
464
477
  }
465
478
  }
@@ -475,6 +488,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
475
488
  body: clone,
476
489
  changed: reasons.length > 0,
477
490
  reasons,
491
+ disclosures,
478
492
  stats: {
479
493
  estimatedInputTokens,
480
494
  estimatedOutputTokens: estimate(clone),
@@ -483,6 +497,9 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
483
497
  deduplicatedResults,
484
498
  compactedStructures,
485
499
  shakenResults,
500
+ historyDisclosureCount: disclosures.length,
501
+ historyDisclosureOriginalBytes: disclosures.reduce((total, item) => total + item.bytes.original, 0),
502
+ historyDisclosureVisibleBytes: disclosures.reduce((total, item) => total + item.bytes.visible, 0),
486
503
  budgetTriggered,
487
504
  cacheProtectedSkips,
488
505
  cacheRewriteRatio: cacheWarm ? cacheRewriteRatio : null,
package/src/core.mjs CHANGED
@@ -2,9 +2,10 @@ import { createHash } from 'node:crypto';
2
2
 
3
3
  import { planToolRoute, ROUTING_POLICY_VERSION } from './routing.mjs';
4
4
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
5
+ import { buildResultDisclosure } from './result-disclosure.mjs';
5
6
 
6
7
  const DEFAULT_POLICY = Object.freeze({
7
- mode: 'apply', maxInlineBytes: 4096, maxArtifactBytes: 65536, headBytes: undefined, tailBytes: undefined,
8
+ mode: 'apply', maxInlineBytes: 4096, maxArtifactBytes: 1_048_576, headBytes: undefined, tailBytes: undefined,
8
9
  maxColumns: 768, redact: true,
9
10
  });
10
11
  const POLICY_FIELDS = new Set(Object.keys(DEFAULT_POLICY));
@@ -186,9 +187,20 @@ export function optimizeToolOutput({
186
187
  const sourceBytes = Buffer.byteLength(redacted.text);
187
188
  let inline = modelText;
188
189
  let artifact;
190
+ const artifactAdmitted = sourceBytes <= normalizedPolicy.maxArtifactBytes;
189
191
  const hasLongLine = routePolicy.maxColumns > 0
190
192
  && modelText.split('\n').some((line) => Buffer.byteLength(line) > routePolicy.maxColumns);
191
- if (route.route === 'summary' || route.route === 'artifact' || sourceBytes > routePolicy.maxInlineBytes || hasLongLine) {
193
+ if (!artifactAdmitted && (route.route === 'summary' || route.route === 'artifact'
194
+ || sourceBytes > routePolicy.maxInlineBytes || hasLongLine)) {
195
+ route = { route: 'passthrough', modelVisible: 'bounded-output', source: 'artifact-admission-limit' };
196
+ inline = truncateUtf8(inlineView(
197
+ modelText,
198
+ routePolicy.maxInlineBytes,
199
+ routePolicy.headBytes,
200
+ routePolicy.tailBytes,
201
+ routePolicy.maxColumns,
202
+ ), normalizedPolicy.maxInlineBytes);
203
+ } else if (route.route === 'summary' || route.route === 'artifact' || sourceBytes > routePolicy.maxInlineBytes || hasLongLine) {
192
204
  const sourceDigest = sha256(redacted.text);
193
205
  artifact = {
194
206
  schema: 'sando-artifact/v1',
@@ -226,6 +238,10 @@ export function optimizeToolOutput({
226
238
  const result = {
227
239
  inline, route: route.route, reason: route.source, policyVersion: ROUTING_POLICY_VERSION,
228
240
  redactionProfileDigest: profile?.digest ?? null, stats,
241
+ disclosure: buildResultDisclosure({
242
+ toolName, route: route.route, reason: route.source, inline,
243
+ redactedText: redacted.text, inputBytes: Buffer.byteLength(input), redactedBytes: sourceBytes, artifact,
244
+ }),
229
245
  };
230
246
  if (artifact) result.artifact = artifact;
231
247
  return result;
@@ -0,0 +1,80 @@
1
+ import { PLUGIN_VERSION } from './version.mjs';
2
+ import { SCHEMA_VERSION, byteBucket, countBucket, serializeEvent, toOtlpLogs } from './telemetry.mjs';
3
+
4
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:4319/v1/logs';
5
+ const HOSTS = ['claude', 'codex'];
6
+ const STATUSES = ['complete', 'partial', 'unavailable'];
7
+ const RATIO_BUCKETS = ['zero', 'lt_1pct', '1_to_10pct', 'gt_10pct', 'unavailable'];
8
+
9
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
10
+
11
+ function optionalCounter(value, name) {
12
+ if (value === null || value === undefined) return null;
13
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
14
+ return value;
15
+ }
16
+
17
+ function optionalBytes(value) { return optionalCounter(value, 'F1 body bytes'); }
18
+
19
+ function ratioBucket(value) {
20
+ if (value === null || value === undefined) return 'unavailable';
21
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
22
+ throw new TypeError('F1 unknown ratio is invalid');
23
+ }
24
+ if (value === 0) return 'zero';
25
+ if (value <= 0.01) return 'lt_1pct';
26
+ if (value <= 0.1) return '1_to_10pct';
27
+ return 'gt_10pct';
28
+ }
29
+
30
+ function bucket(value, build, name) {
31
+ if (value === null || value === undefined) return 'unavailable';
32
+ try { return build(value); } catch { throw new TypeError(`${name} is invalid`); }
33
+ }
34
+
35
+ export function buildF1TelemetryEvent(record) {
36
+ if (!object(record) || !HOSTS.includes(record.host) || typeof record.at !== 'string') {
37
+ throw new TypeError('F1 capture record is invalid');
38
+ }
39
+ const at = new Date(record.at);
40
+ if (Number.isNaN(at.getTime()) || !object(record.report) || !object(record.report.attribution)) {
41
+ throw new TypeError('F1 capture report is invalid');
42
+ }
43
+ const status = record.report.attribution.status;
44
+ if (!STATUSES.includes(status)) throw new TypeError('F1 attribution status is invalid');
45
+ const bodyBytes = optionalBytes(record.report.attribution.bodyBytes);
46
+ const providerReported = record.report.tokenAccounting?.providerReported;
47
+ const inputTokens = optionalCounter(providerReported?.inputTokens, 'F1 provider input tokens');
48
+ const event = {
49
+ schema_version: SCHEMA_VERSION,
50
+ event: 'f1_footprint',
51
+ day_utc: at.toISOString().slice(0, 10),
52
+ plugin_version: PLUGIN_VERSION,
53
+ f1_host: record.host,
54
+ f1_status: status,
55
+ f1_unknown_ratio_bucket: ratioBucket(record.report.attribution.unknownRatio),
56
+ f1_body_size_bucket: bucket(bodyBytes, byteBucket, 'F1 body bytes'),
57
+ f1_input_tokens_bucket: bucket(inputTokens, countBucket, 'F1 provider input tokens'),
58
+ };
59
+ serializeEvent(event);
60
+ return event;
61
+ }
62
+
63
+ export async function publishF1Telemetry({ record, endpoint = process.env.SANDO_F1_TELEMETRY_ENDPOINT || DEFAULT_ENDPOINT,
64
+ fetchImpl = fetch, timeoutMs = 2500 } = {}) {
65
+ const event = buildF1TelemetryEvent(record);
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
68
+ try {
69
+ const response = await fetchImpl(endpoint, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json' },
72
+ body: JSON.stringify(toOtlpLogs([{ ...event, _timeUnixNano: (BigInt(Date.now()) * 1000000n).toString() }])),
73
+ signal: controller.signal,
74
+ });
75
+ if (!response.ok) throw new Error(`F1 telemetry endpoint returned ${response.status}`);
76
+ return { events: 1, status: response.status };
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }