sandoichi 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ CONTEXT_CAPTURE_SCHEMA,
8
+ buildContextFootprintReport,
9
+ serializeContextFootprint,
10
+ } from './context-footprint.mjs';
11
+ import { classifyContextRequest } from './context-classifier.mjs';
12
+
13
+ export const CONTEXT_CAPTURE_RECORD_SCHEMA = 'sando-context-capture-record/v1';
14
+ export const CONTEXT_CAPTURE_RECORD_VERSION = 1;
15
+
16
+ const PROVIDER_FORMATS = Object.freeze({
17
+ anthropic: { host: 'claude', requestFormat: 'anthropic' },
18
+ 'openai-responses': { host: 'codex', requestFormat: 'openai-responses' },
19
+ });
20
+
21
+ function object(value) {
22
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+
25
+ function counter(value) {
26
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
27
+ }
28
+
29
+ function add(left, right) {
30
+ const result = left + right;
31
+ return Number.isSafeInteger(result) ? result : null;
32
+ }
33
+
34
+ function optionalCounter(value) {
35
+ return value === undefined || value === null ? 0 : counter(value);
36
+ }
37
+
38
+ function sha256(value) {
39
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
40
+ }
41
+
42
+ function isoDate(value) {
43
+ const date = value instanceof Date ? value : new Date(value ?? Date.now());
44
+ if (Number.isNaN(date.getTime())) throw new TypeError('capture timestamp is invalid');
45
+ return date.toISOString();
46
+ }
47
+
48
+ function safeModel(value) {
49
+ if (value === undefined || value === null) return null;
50
+ if (typeof value !== 'string' || value.length > 256) throw new TypeError('capture model is invalid');
51
+ return value;
52
+ }
53
+
54
+ function rawText(value) {
55
+ if (typeof value === 'string') return value;
56
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value).toString('utf8');
57
+ throw new TypeError('raw request body is invalid');
58
+ }
59
+
60
+ function anthropicUsage(value) {
61
+ if (!object(value)) return null;
62
+ const input = counter(value.input_tokens);
63
+ const cacheRead = optionalCounter(value.cache_read_input_tokens);
64
+ const cacheWrite = optionalCounter(value.cache_creation_input_tokens);
65
+ const output = counter(value.output_tokens);
66
+ if (input === null || cacheRead === null || cacheWrite === null || output === null) return null;
67
+ const inputTokens = add(add(input, cacheRead), cacheWrite);
68
+ const totalTokens = inputTokens === null ? null : add(inputTokens, output);
69
+ if (totalTokens === null) return null;
70
+ const result = {
71
+ inputTokens,
72
+ cachedInputTokens: cacheRead,
73
+ cacheWriteInputTokens: cacheWrite,
74
+ cacheReadInputTokens: cacheRead,
75
+ outputTokens: output,
76
+ totalTokens,
77
+ };
78
+ if (counter(value.reasoning_output_tokens) !== null) result.reasoningOutputTokens = value.reasoning_output_tokens;
79
+ if (typeof value.total_cost_usd === 'number' && Number.isFinite(value.total_cost_usd) && value.total_cost_usd >= 0) {
80
+ result.totalCostUsd = value.total_cost_usd;
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function responsesUsage(value) {
86
+ if (!object(value)) return null;
87
+ const input = counter(value.input_tokens);
88
+ const output = counter(value.output_tokens);
89
+ const cached = optionalCounter(value.cached_input_tokens
90
+ ?? value.cache_read_input_tokens
91
+ ?? value.input_tokens_details?.cached_tokens);
92
+ const cacheWrite = optionalCounter(value.cache_write_input_tokens);
93
+ const reasoning = optionalCounter(value.reasoning_output_tokens
94
+ ?? value.output_tokens_details?.reasoning_tokens);
95
+ if ([input, output, cached, cacheWrite, reasoning].some((item) => item === null)
96
+ || reasoning > output) return null;
97
+ const totalTokens = value.total_tokens === undefined ? add(input, output) : counter(value.total_tokens);
98
+ if (totalTokens === null || totalTokens !== input + output) return null;
99
+ const result = {
100
+ inputTokens: input,
101
+ cachedInputTokens: cached,
102
+ cacheWriteInputTokens: cacheWrite,
103
+ cacheReadInputTokens: cached,
104
+ outputTokens: output,
105
+ reasoningOutputTokens: reasoning,
106
+ totalTokens,
107
+ };
108
+ if (typeof value.total_cost_usd === 'number' && Number.isFinite(value.total_cost_usd) && value.total_cost_usd >= 0) {
109
+ result.totalCostUsd = value.total_cost_usd;
110
+ }
111
+ return result;
112
+ }
113
+
114
+ export function normalizeProviderUsage(provider, usage) {
115
+ if (usage === undefined || usage === null) return null;
116
+ if (provider === 'anthropic') return anthropicUsage(usage);
117
+ if (provider === 'openai-responses') return responsesUsage(usage);
118
+ return null;
119
+ }
120
+
121
+ export function defaultContextCapturePath(env = process.env) {
122
+ const configured = env.SANDO_CONTEXT_FOOTPRINT_PATH;
123
+ if (configured !== undefined) {
124
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) throw new Error('context capture path must be absolute');
125
+ return configured;
126
+ }
127
+ const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
128
+ if (!path.isAbsolute(stateHome)) throw new Error('state directory must be absolute');
129
+ return path.join(stateHome, 'sando', 'context-footprints.jsonl');
130
+ }
131
+
132
+ export function buildContextCaptureRecord({
133
+ host, provider, rawBody, requestBody, sessionKey, model, providerUsage, now = new Date(), toolSearch,
134
+ } = {}) {
135
+ const format = PROVIDER_FORMATS[provider];
136
+ if (!format || host !== format.host) throw new TypeError('provider and capture host do not match');
137
+ if (typeof sessionKey !== 'string' || sessionKey.length === 0) return null;
138
+ const content = rawText(rawBody);
139
+ let body = requestBody;
140
+ if (body === undefined) {
141
+ try { body = JSON.parse(content); } catch { body = null; }
142
+ }
143
+ const classification = classifyContextRequest({ provider, body });
144
+ const report = buildContextFootprintReport({
145
+ schema: CONTEXT_CAPTURE_SCHEMA,
146
+ host,
147
+ requestFormat: format.requestFormat,
148
+ body: { state: 'observed', content },
149
+ segments: classification.segments,
150
+ providerUsage: normalizeProviderUsage(provider, providerUsage),
151
+ toolSearch: toolSearch ?? { state: 'indeterminate' },
152
+ });
153
+ return {
154
+ schema: CONTEXT_CAPTURE_RECORD_SCHEMA,
155
+ version: CONTEXT_CAPTURE_RECORD_VERSION,
156
+ at: isoDate(now),
157
+ host,
158
+ provider,
159
+ requestFormat: format.requestFormat,
160
+ model: safeModel(model),
161
+ sessionKeyDigest: sha256(sessionKey),
162
+ report,
163
+ };
164
+ }
165
+
166
+ function ensureDirectory(directory) {
167
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
168
+ const stat = fs.lstatSync(directory);
169
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('context capture directory is unsafe');
170
+ fs.chmodSync(directory, 0o700);
171
+ }
172
+
173
+ function validateRecord(record) {
174
+ if (!object(record)
175
+ || record.schema !== CONTEXT_CAPTURE_RECORD_SCHEMA
176
+ || record.version !== CONTEXT_CAPTURE_RECORD_VERSION
177
+ || typeof record.at !== 'string'
178
+ || typeof record.host !== 'string'
179
+ || typeof record.provider !== 'string'
180
+ || typeof record.requestFormat !== 'string'
181
+ || !/^sha256:[0-9a-f]{64}$/.test(record.sessionKeyDigest)
182
+ || !object(record.report)
183
+ || record.report.schema !== 'sando-context-footprint/v1') {
184
+ throw new TypeError('context capture record is invalid');
185
+ }
186
+ serializeContextFootprint(record.report);
187
+ }
188
+
189
+ export function recordContextCapture({ storagePath, record } = {}) {
190
+ if (typeof storagePath !== 'string' || !path.isAbsolute(storagePath)) throw new Error('context capture path must be absolute');
191
+ validateRecord(record);
192
+ ensureDirectory(path.dirname(storagePath));
193
+ if (fs.existsSync(storagePath)) {
194
+ const stat = fs.lstatSync(storagePath);
195
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('context capture file is unsafe');
196
+ }
197
+ fs.appendFileSync(storagePath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
198
+ fs.chmodSync(storagePath, 0o600);
199
+ return record;
200
+ }
@@ -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
+ }