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.
- package/README.md +4 -4
- package/index.mjs +70 -0
- package/package.json +1 -1
- package/src/artifact-cli.mjs +67 -0
- package/src/artifact-recovery.mjs +133 -0
- package/src/artifact-store.mjs +43 -0
- package/src/context-audit-cli.mjs +104 -0
- package/src/context-capture.mjs +200 -0
- package/src/context-classifier.mjs +142 -0
- package/src/context-footprint.mjs +299 -0
- package/src/context-transform.mjs +18 -1
- package/src/core.mjs +18 -2
- package/src/f1-telemetry.mjs +80 -0
- package/src/f4-telemetry.mjs +183 -0
- package/src/gateway-gate-cli.mjs +88 -0
- package/src/gateway-gate.mjs +412 -0
- package/src/history-disclosure.mjs +70 -0
- package/src/lazy-mcp-gateway-stdio.mjs +59 -0
- package/src/lazy-mcp-gateway.mjs +291 -0
- package/src/mcp-server.mjs +31 -5
- package/src/proxy.mjs +424 -15
- package/src/result-disclosure.mjs +109 -0
- package/src/statusline.mjs +8 -19
- package/src/telemetry.mjs +61 -17
|
@@ -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:
|
|
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'
|
|
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
|
+
}
|