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.
package/README.md CHANGED
@@ -24,9 +24,13 @@ Project-specific detectors can be declared in `.sando/redaction.json`:
24
24
 
25
25
  Built-ins stay enabled. Profiles are declarative and local to the current project; invalid profiles fail visibly.
26
26
 
27
- The library requires Node.js `>=22.22.0 <23` and has no runtime dependencies. Installing it does not install or enable the plugin. The plugin remains the supported host surface; this package exports the context/history runtime, provider usage report, paired accounting, and explicit proxy API only.
27
+ The library requires Node.js `>=22.22.0 <23` and has no runtime dependencies. Installing it does not install or enable the plugin. The plugin remains the supported host surface; this package exports the bounded output/disclosure runtime, context footprint audit, F1/F3/F4 evidence APIs, provider usage report, paired accounting, and explicit proxy API. Host hooks and MCP registration remain outside the package API.
28
28
 
29
- `computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted estimates, provider-reported cost, and paired-session evidence separate. The benchmark report adds explicit replay counterfactuals. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
29
+ The provider proxy is pass-through unless request transformation is explicitly enabled.
30
+
31
+ The recoverable-history strategy is opt-in and keeps eligible results inline when they are below 3,072 bytes by default. Provider-boundary replay measurements are diagnostic paired evidence; they do not predict end-to-end agent behavior or provider billing across workloads.
32
+
33
+ `computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted cost units, and paired-session evidence separate from the provider-usage report's reported cost provenance and coverage. Host-reported list estimates are not billed-cost records. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
30
34
 
31
35
  For plugin installation, see the [main project README](https://github.com/yuzushi-dev/Sando#readme).
32
36
 
package/index.mjs CHANGED
@@ -56,7 +56,82 @@ export {
56
56
  PROVIDER_USAGE_VERSION,
57
57
  } from './src/provider-usage.mjs';
58
58
  export { planToolRoute, ROUTING_POLICY_VERSION } from './src/routing.mjs';
59
+ export {
60
+ CONTEXT_CAPTURE_SCHEMA,
61
+ CONTEXT_CATEGORIES,
62
+ CONTEXT_FOOTPRINT_SCHEMA,
63
+ CONTEXT_FOOTPRINT_VERSION,
64
+ buildContextFootprintReport,
65
+ detectToolSearchState,
66
+ serializeContextFootprint,
67
+ } from './src/context-footprint.mjs';
68
+ export { classifyContextRequest } from './src/context-classifier.mjs';
69
+ export { formatContextFootprintReport, runContextAuditCli } from './src/context-audit-cli.mjs';
70
+ export {
71
+ CONTEXT_CAPTURE_RECORD_SCHEMA,
72
+ CONTEXT_CAPTURE_RECORD_VERSION,
73
+ buildContextCaptureRecord,
74
+ defaultContextCapturePath,
75
+ normalizeProviderUsage,
76
+ recordContextCapture,
77
+ } from './src/context-capture.mjs';
78
+ export {
79
+ ARTIFACT_TOOL_NAME,
80
+ RESULT_DISCLOSURE_SCHEMA,
81
+ RESULT_DISCLOSURE_VERSION,
82
+ buildResultDisclosure,
83
+ serializeResultDisclosure,
84
+ } from './src/result-disclosure.mjs';
85
+ export {
86
+ ARTIFACT_RECOVERY_SCHEMA,
87
+ ARTIFACT_RECOVERY_VERSION,
88
+ MAX_RECOVERY_BYTES,
89
+ recoverArtifactContent,
90
+ recoverArtifactFromWorkspace,
91
+ } from './src/artifact-recovery.mjs';
92
+ export { runArtifactCli } from './src/artifact-cli.mjs';
93
+ export {
94
+ DEFAULT_ARTIFACT_MAX_BYTES,
95
+ DEFAULT_ARTIFACT_TTL_MS,
96
+ cleanupArtifacts,
97
+ } from './src/artifact-lifecycle.mjs';
98
+ export { buildF1TelemetryEvent, publishF1Telemetry } from './src/f1-telemetry.mjs';
99
+ export {
100
+ F4_EVENT_SCHEMA,
101
+ F4_EVENT_VERSION,
102
+ F4_HOSTS,
103
+ F4_LATENCY_BUCKETS,
104
+ F4_OPERATIONS,
105
+ F4_OUTCOMES,
106
+ F4_RESULT_BUCKETS,
107
+ buildF4Event,
108
+ buildF4TelemetryEvent,
109
+ defaultF4EventsPath,
110
+ DEFAULT_F4_TELEMETRY_ENDPOINT,
111
+ digestCapability,
112
+ latencyBucket,
113
+ publishF4Telemetry,
114
+ recordF4Event,
115
+ resultBucket,
116
+ serializeF4Event,
117
+ } from './src/f4-telemetry.mjs';
118
+ export {
119
+ GATE_EVIDENCE_SCHEMA,
120
+ GATE_SCHEMA,
121
+ GATE_THRESHOLDS,
122
+ GATE_VERSION,
123
+ evaluateGatewayGate,
124
+ serializeGatewayGate,
125
+ } from './src/gateway-gate.mjs';
126
+ export {
127
+ HISTORY_DISCLOSURE_SCHEMA,
128
+ HISTORY_DISCLOSURE_VERSION,
129
+ buildHistoryDisclosure,
130
+ serializeHistoryDisclosure,
131
+ } from './src/history-disclosure.mjs';
59
132
  export { readStatusSnapshot, renderStatusLine, STATUSLINE_MAX_AGE_MS } from './src/statusline.mjs';
133
+ export { GATEWAY_CATALOG_TOOL, LAZY_MCP_GATEWAY_SCHEMA, createLazyMcpGateway, validateJsonSchema } from './src/lazy-mcp-gateway.mjs';
134
+ export { createConfiguredMcpServers, spawnMcpTransport, startLazyMcpGatewayStdio } from './src/lazy-mcp-gateway-stdio.mjs';
60
135
  export {
61
136
  activeSessionForPane,
62
137
  currentTmuxPanePid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Bound repeated tool-output context in Claude Code and Codex with deterministic local routing and provider accounting.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
@@ -21,7 +21,7 @@ export function formatAccountingReport(report) {
21
21
  `reasoning: ${report.reasoningOutputTokens}`,
22
22
  `turns: ${report.turnCount}`,
23
23
  `weighted estimate: ${report.weightedCost.costUnits} cost units`,
24
- `provider cost: ${report.cost.status === 'provider-reported' ? `$${report.cost.totalCostUsd.toFixed(6)}` : report.cost.status}`,
24
+ `reported cost: ${report.cost.totalCostUsd === null ? report.cost.status : `$${report.cost.totalCostUsd.toFixed(6)} (${report.cost.status})`}`,
25
25
  ];
26
26
  if (report.cost.effectiveRateUsdPerMillionTokens !== null) {
27
27
  lines.push(`blended effective rate: $${report.cost.effectiveRateUsdPerMillionTokens.toFixed(2)}/M tokens`);
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import { recoverArtifactFromWorkspace } from './artifact-recovery.mjs';
7
+
8
+ function usage() {
9
+ return 'Usage: sando artifact get --ref HANDLE [--root DIR] [--start-byte N --end-byte N | --start-line N --end-line N] [--max-bytes N] [--json]\n';
10
+ }
11
+
12
+ function number(value, name) {
13
+ if (!/^\d+$/.test(value ?? '')) throw new Error(`${name} must be a non-negative integer`);
14
+ const result = Number(value);
15
+ if (!Number.isSafeInteger(result)) throw new Error(`${name} is too large`);
16
+ return result;
17
+ }
18
+
19
+ function parseArgs(argv) {
20
+ let args = [...argv];
21
+ if (args[0] === 'artifact') args = args.slice(1);
22
+ if (args[0] === 'get') args = args.slice(1);
23
+ const result = { root: process.cwd(), json: false };
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const argument = args[index];
26
+ if (argument === '--json') result.json = true;
27
+ else if (argument === '--help' || argument === '-h') result.help = true;
28
+ else if (['--root', '--ref', '--start-byte', '--end-byte', '--start-line', '--end-line', '--max-bytes'].includes(argument)) {
29
+ const value = args[index + 1];
30
+ if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`);
31
+ const key = argument.slice(2).replaceAll('-', '');
32
+ result[key] = ['startbyte', 'endbyte', 'startline', 'endline', 'maxbytes'].includes(key)
33
+ ? number(value, argument)
34
+ : value;
35
+ index += 1;
36
+ } else throw new Error('unknown artifact option');
37
+ }
38
+ if (!result.help && !result.ref) throw new Error('--ref is required');
39
+ if (!result.help && ((result.startbyte !== undefined) !== (result.endbyte !== undefined)
40
+ || (result.startline !== undefined) !== (result.endline !== undefined))) throw new Error('artifact ranges require start and end');
41
+ return result;
42
+ }
43
+
44
+ function terminal(report) {
45
+ return `Sando artifact ${report.handle}: ${report.bytes}B${report.truncated ? ' (bounded)' : ''}\n${report.content}\n`;
46
+ }
47
+
48
+ export function runArtifactCli({ argv = process.argv.slice(2), stdout = process.stdout, stderr = process.stderr } = {}) {
49
+ try {
50
+ const options = parseArgs(argv);
51
+ if (options.help) { stdout.write(usage()); return null; }
52
+ const report = recoverArtifactFromWorkspace({
53
+ cwd: path.resolve(options.root), ref: options.ref,
54
+ ...(options.startbyte !== undefined ? { startByte: options.startbyte, endByte: options.endbyte } : {}),
55
+ ...(options.startline !== undefined ? { startLine: options.startline, endLine: options.endline } : {}),
56
+ ...(options.maxbytes !== undefined ? { maxBytes: options.maxbytes } : {}),
57
+ });
58
+ stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : terminal(report));
59
+ return report;
60
+ } catch (error) {
61
+ stderr.write(`sando artifact get: ${error instanceof Error ? error.message : String(error)}\n${usage()}`);
62
+ process.exitCode = 2;
63
+ return null;
64
+ }
65
+ }
66
+
67
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) runArtifactCli();
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_ARTIFACT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5
+ export const DEFAULT_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024;
6
+
7
+ function validNumber(value, name) {
8
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
9
+ return value;
10
+ }
11
+
12
+ function safeArtifact(directory, name) {
13
+ if (!/^[a-f0-9]{64}\.txt$/.test(name)) return null;
14
+ const target = path.join(directory, name);
15
+ let link;
16
+ try { link = fs.lstatSync(target); } catch { return null; }
17
+ if (!link.isFile() || link.isSymbolicLink()) return null;
18
+ let resolved;
19
+ try { resolved = fs.realpathSync(target); } catch { return null; }
20
+ if (resolved !== target) return null;
21
+ let stat;
22
+ try { stat = fs.statSync(target); } catch { return null; }
23
+ return { target, name, bytes: stat.size, mtimeMs: stat.mtimeMs };
24
+ }
25
+
26
+ export function cleanupArtifacts(
27
+ directory,
28
+ {
29
+ now = Date.now(), ttlMs = DEFAULT_ARTIFACT_TTL_MS,
30
+ maxBytes = DEFAULT_ARTIFACT_MAX_BYTES, preserveName = null,
31
+ } = {},
32
+ ) {
33
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw new TypeError('artifact directory is invalid');
34
+ validNumber(now, 'now');
35
+ validNumber(ttlMs, 'ttlMs');
36
+ validNumber(maxBytes, 'maxBytes');
37
+ if (preserveName !== null && !/^[a-f0-9]{64}\.txt$/.test(preserveName)) {
38
+ throw new TypeError('preserveName is invalid');
39
+ }
40
+ const directoryStat = fs.lstatSync(directory, { throwIfNoEntry: false });
41
+ if (!directoryStat?.isDirectory() || directoryStat.isSymbolicLink()) throw new Error('artifact directory is unsafe');
42
+
43
+ const entries = fs.readdirSync(directory).map((name) => safeArtifact(directory, name)).filter(Boolean);
44
+ const expired = entries.filter((entry) => now - entry.mtimeMs >= ttlMs);
45
+ const keep = entries.filter((entry) => !expired.includes(entry));
46
+ let totalBytes = keep.reduce((total, entry) => total + entry.bytes, 0);
47
+ const removals = [...expired, ...keep.sort((left, right) => {
48
+ if (left.name === preserveName) return 1;
49
+ if (right.name === preserveName) return -1;
50
+ return left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name);
51
+ })];
52
+ let removed = 0;
53
+ let removedBytes = 0;
54
+ for (const entry of removals) {
55
+ if (expired.includes(entry) || totalBytes > maxBytes) {
56
+ try {
57
+ const current = safeArtifact(directory, entry.name);
58
+ if (!current || current.target !== entry.target) continue;
59
+ fs.rmSync(current.target);
60
+ removed += 1;
61
+ removedBytes += current.bytes;
62
+ if (!expired.includes(entry)) totalBytes -= current.bytes;
63
+ } catch { /* cleanup is best-effort and never follows unresolved targets */ }
64
+ }
65
+ }
66
+ return { removed, removedBytes, retainedBytes: Math.max(0, totalBytes) };
67
+ }
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ export const ARTIFACT_RECOVERY_SCHEMA = 'sando-artifact-recovery/v1';
6
+ export const ARTIFACT_RECOVERY_VERSION = 1;
7
+ export const MAX_RECOVERY_BYTES = 1_048_576;
8
+ const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
9
+
10
+ function digest(text) {
11
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
12
+ }
13
+
14
+ function handleDigest(ref) {
15
+ const match = typeof ref === 'string' && ref.match(/^sando:(sha256:[a-f0-9]{16,64})$/);
16
+ if (!match) throw new TypeError('artifact handle is invalid');
17
+ return match[1];
18
+ }
19
+
20
+ export function validateArtifactHandle(ref) {
21
+ handleDigest(ref);
22
+ return ref;
23
+ }
24
+
25
+ function integer(value, name, { positive = false } = {}) {
26
+ if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) throw new TypeError(`${name} is invalid`);
27
+ return value;
28
+ }
29
+
30
+ function continuation(byte) { return (byte & 0xc0) === 0x80; }
31
+
32
+ function safeBufferText(buffer, start, end) {
33
+ if (start > 0 && continuation(buffer[start])) throw new RangeError('byte range splits UTF-8');
34
+ if (end < buffer.length && continuation(buffer[end])) throw new RangeError('byte range splits UTF-8');
35
+ return buffer.subarray(start, end).toString('utf8');
36
+ }
37
+
38
+ function prefix(buffer, limit) {
39
+ let end = Math.min(buffer.length, limit);
40
+ while (end > 0 && end < buffer.length && continuation(buffer[end])) end -= 1;
41
+ return { text: buffer.subarray(0, end).toString('utf8'), truncated: end < buffer.length };
42
+ }
43
+
44
+ export function recoverArtifactContent({
45
+ ref, content, digest: expectedDigest, sourceBytes, startByte, endByte, startLine, endLine, maxBytes,
46
+ } = {}) {
47
+ const handlePrefix = handleDigest(ref).slice('sha256:'.length);
48
+ const handle = ref;
49
+ if (typeof content !== 'string') throw new TypeError('artifact content is invalid');
50
+ const actualDigest = digest(content);
51
+ if (expectedDigest !== undefined && expectedDigest !== actualDigest) throw new Error('artifact digest integrity check failed');
52
+ const full = Buffer.from(content, 'utf8');
53
+ if (!actualDigest.slice('sha256:'.length).startsWith(handlePrefix)) throw new Error('artifact handle does not match content');
54
+ const limit = maxBytesValue(maxBytes);
55
+ const byteMode = startByte !== undefined || endByte !== undefined;
56
+ const lineMode = startLine !== undefined || endLine !== undefined;
57
+ if (byteMode && lineMode) throw new TypeError('artifact range is ambiguous');
58
+ let selected;
59
+ let range;
60
+ if (byteMode) {
61
+ const start = integer(startByte ?? 0, 'startByte');
62
+ const end = integer(endByte ?? full.length, 'endByte');
63
+ if (start > end || end > full.length) throw new RangeError('artifact byte range is invalid');
64
+ selected = safeBufferText(full, start, end);
65
+ range = { type: 'bytes', start, end };
66
+ } else if (lineMode) {
67
+ const start = integer(startLine ?? 1, 'startLine', { positive: true });
68
+ const end = integer(endLine ?? start, 'endLine', { positive: true });
69
+ const lines = content.split('\n');
70
+ if (start > end || start > lines.length || end > lines.length) throw new RangeError('artifact line range is invalid');
71
+ selected = lines.slice(start - 1, end).join('\n');
72
+ range = { type: 'lines', start, end };
73
+ } else {
74
+ selected = content;
75
+ range = { type: 'all' };
76
+ }
77
+ const selectedBuffer = Buffer.from(selected, 'utf8');
78
+ const bounded = prefix(selectedBuffer, limit);
79
+ const totalSourceBytes = integer(sourceBytes ?? full.length, 'sourceBytes');
80
+ if (totalSourceBytes !== full.length) throw new Error('artifact source byte metadata is inconsistent');
81
+ return {
82
+ schema: ARTIFACT_RECOVERY_SCHEMA,
83
+ version: ARTIFACT_RECOVERY_VERSION,
84
+ handle,
85
+ digest: actualDigest,
86
+ content: bounded.text,
87
+ bytes: Buffer.byteLength(bounded.text),
88
+ sourceBytes: totalSourceBytes,
89
+ range,
90
+ truncated: bounded.truncated,
91
+ };
92
+ }
93
+
94
+ function maxBytesValue(value) {
95
+ const result = value ?? 65_536;
96
+ integer(result, 'maxBytes', { positive: true });
97
+ if (result > MAX_RECOVERY_BYTES) throw new RangeError('maxBytes exceeds recovery limit');
98
+ return result;
99
+ }
100
+
101
+ function safeDirectory(target, name) {
102
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
103
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${name} is unavailable or unsafe`);
104
+ }
105
+
106
+ export function recoverArtifactFromWorkspace({ cwd, ref, ...range } = {}) {
107
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) throw new TypeError('artifact cwd is invalid');
108
+ const root = fs.realpathSync(cwd);
109
+ if (!fs.statSync(root).isDirectory()) throw new TypeError('artifact cwd is not a directory');
110
+ const stateRoot = path.join(root, '.sando');
111
+ const privateRoot = path.join(stateRoot, 'sando');
112
+ const directory = path.join(privateRoot, 'artifacts');
113
+ safeDirectory(stateRoot, 'artifact state');
114
+ safeDirectory(privateRoot, 'artifact private state');
115
+ safeDirectory(directory, 'artifact directory');
116
+ const digestPrefix = handleDigest(ref).slice('sha256:'.length);
117
+ const candidates = fs.readdirSync(directory)
118
+ .filter((entry) => /^[a-f0-9]{64}\.txt$/.test(entry) && entry.startsWith(digestPrefix));
119
+ if (candidates.length !== 1) throw new Error(candidates.length ? 'artifact handle is ambiguous' : 'artifact handle is unavailable');
120
+ const digestValue = `sha256:${candidates[0].slice(0, -'.txt'.length)}`;
121
+ const target = path.join(directory, candidates[0]);
122
+ const relative = path.relative(directory, target);
123
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('artifact path escapes directory');
124
+ let descriptor;
125
+ try {
126
+ descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
127
+ const stat = fs.fstatSync(descriptor);
128
+ if (!stat.isFile()) throw new Error('artifact handle is unavailable');
129
+ if (stat.size > MAX_ARTIFACT_BYTES) throw new RangeError('artifact exceeds recovery limit');
130
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(descriptor));
131
+ return recoverArtifactContent({ ref, content, digest: digestValue, ...range });
132
+ } catch (error) {
133
+ if (['ELOOP', 'ENOENT'].includes(error?.code)) throw new Error('artifact handle is unavailable', { cause: error });
134
+ throw error;
135
+ } finally {
136
+ if (descriptor !== undefined) fs.closeSync(descriptor);
137
+ }
138
+ }
@@ -0,0 +1,44 @@
1
+ import { recoverArtifactContent, validateArtifactHandle } from './artifact-recovery.mjs';
2
+
3
+ const MAX_ARTIFACTS = 128;
4
+ const MAX_STORED_BYTES = 64 * 1024 * 1024;
5
+ const store = new Map();
6
+ let storedBytes = 0;
7
+
8
+ export function rememberArtifact(artifact) {
9
+ if (!artifact || typeof artifact.ref !== 'string' || typeof artifact.content !== 'string') throw new TypeError('artifact is invalid');
10
+ const bytes = Buffer.byteLength(artifact.content);
11
+ if (bytes > MAX_STORED_BYTES) throw new RangeError('artifact exceeds in-process recovery limit');
12
+ const previous = store.get(artifact.ref);
13
+ if (previous) storedBytes -= previous.bytes;
14
+ store.delete(artifact.ref);
15
+ while (store.size >= MAX_ARTIFACTS || storedBytes + bytes > MAX_STORED_BYTES) {
16
+ const oldest = store.keys().next().value;
17
+ if (oldest === undefined) break;
18
+ storedBytes -= store.get(oldest).bytes;
19
+ store.delete(oldest);
20
+ }
21
+ store.set(artifact.ref, {
22
+ content: artifact.content,
23
+ digest: artifact.sourceDigest,
24
+ sourceBytes: artifact.sourceBytes ?? artifact.bytes,
25
+ bytes,
26
+ });
27
+ storedBytes += bytes;
28
+ }
29
+
30
+ export function recoverStoredArtifact(options = {}) {
31
+ validateArtifactHandle(options.ref);
32
+ const entry = store.get(options.ref);
33
+ if (!entry) throw new Error('artifact handle is unavailable in this MCP session');
34
+ store.delete(options.ref);
35
+ store.set(options.ref, entry);
36
+ return recoverArtifactContent({ ...options, ...entry });
37
+ }
38
+
39
+ export function exposeMcpResult(result) {
40
+ if (!result?.artifact) return result;
41
+ rememberArtifact(result.artifact);
42
+ const { content: _content, ...artifact } = result.artifact;
43
+ return { ...result, artifact };
44
+ }
@@ -45,6 +45,10 @@ function counter(value) {
45
45
  return Number.isSafeInteger(value) && value >= 0 ? value : 0;
46
46
  }
47
47
 
48
+ function validCounter(value) {
49
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
50
+ }
51
+
48
52
  /** Stable digest of a JSON-serializable value. Order-sensitive by design: a reordered
49
53
  * tool array is a different byte prefix to the provider even if the set is equal. */
50
54
  export function shapeDigest(value) {
@@ -88,7 +92,9 @@ export function hasBreakpoint(body) {
88
92
  *
89
93
  * `current` / `previous` are `{ at, usage, tools, system, messages, body }`, where
90
94
  * `usage` is `{ cachedInputTokens, cacheWriteInputTokens, inputTokens }` as recorded
91
- * in the provider ledger. `previous` is null on the first turn of a session.
95
+ * in the provider ledger. `inputTokens` is the complete prompt, including cache
96
+ * reads and writes; `promptTokens` is accepted as its clearer alias. `previous` is
97
+ * null on the first turn of a session.
92
98
  */
93
99
  export function attributeTurn({
94
100
  current,
@@ -101,8 +107,10 @@ export function attributeTurn({
101
107
  const usage = object(current.usage) ? current.usage : {};
102
108
  const cacheReadTokens = counter(usage.cachedInputTokens);
103
109
  const cacheWriteTokens = counter(usage.cacheWriteInputTokens);
104
- const freshInputTokens = counter(usage.inputTokens);
105
- const totalPromptTokens = cacheReadTokens + cacheWriteTokens + freshInputTokens;
110
+ const promptTokens = validCounter(usage.promptTokens) ?? counter(usage.inputTokens);
111
+ const totalPromptTokens = promptTokens;
112
+ const effectiveInputTokens = Math.max(0, promptTokens - cacheReadTokens);
113
+ const freshInputTokens = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);
106
114
  const hit = cacheReadTokens > 0;
107
115
 
108
116
  const currentDigests = messageDigests(current.messages);
@@ -112,6 +120,8 @@ export function attributeTurn({
112
120
  const detail = {
113
121
  cacheReadTokens,
114
122
  cacheWriteTokens,
123
+ promptTokens,
124
+ effectiveInputTokens,
115
125
  freshInputTokens,
116
126
  totalPromptTokens,
117
127
  divergedAtMessage: divergedAt,
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import {
8
+ CONTEXT_CAPTURE_SCHEMA,
9
+ buildContextFootprintReport,
10
+ } from './context-footprint.mjs';
11
+
12
+ const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
13
+
14
+ function usage() {
15
+ return 'Usage: sando context audit --host claude|codex [--input CAPTURE.json] [--json]\n';
16
+ }
17
+
18
+ function parseArgs(argv) {
19
+ let args = [...argv];
20
+ if (args[0] === 'context') args = args.slice(1);
21
+ if (args[0] === 'audit') args = args.slice(1);
22
+ const result = { host: undefined, input: undefined, json: false, help: false };
23
+ for (let index = 0; index < args.length; index += 1) {
24
+ const argument = args[index];
25
+ if (argument === '--help' || argument === '-h') result.help = true;
26
+ else if (argument === '--json') result.json = true;
27
+ else if (argument === '--host' || argument === '--input') {
28
+ const value = args[index + 1];
29
+ if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`);
30
+ result[argument.slice(2)] = value;
31
+ index += 1;
32
+ } else throw new Error('unknown context audit option');
33
+ }
34
+ if (!result.help && !result.host) throw new Error('--host is required');
35
+ return result;
36
+ }
37
+
38
+ function readCapture(inputPath) {
39
+ let descriptor;
40
+ try {
41
+ descriptor = fs.openSync(inputPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
42
+ const stat = fs.fstatSync(descriptor);
43
+ if (!stat.isFile() || stat.size > MAX_CAPTURE_BYTES) throw new Error('capture input is too large or not a file');
44
+ const source = fs.readFileSync(descriptor, 'utf8');
45
+ if (Buffer.byteLength(source, 'utf8') > MAX_CAPTURE_BYTES) throw new Error('capture input is too large');
46
+ try {
47
+ return JSON.parse(source);
48
+ } catch {
49
+ throw new Error('capture JSON is invalid');
50
+ }
51
+ } catch (error) {
52
+ if (error?.message === 'capture JSON is invalid' || error?.message === 'capture input is too large or not a file'
53
+ || error?.message === 'capture input is too large') throw error;
54
+ throw new Error('capture input cannot be read');
55
+ } finally {
56
+ if (descriptor !== undefined) fs.closeSync(descriptor);
57
+ }
58
+ }
59
+
60
+ function unavailableCapture(host) {
61
+ return { schema: CONTEXT_CAPTURE_SCHEMA, host, body: { state: 'unavailable' } };
62
+ }
63
+
64
+ export function formatContextFootprintReport(report) {
65
+ const attribution = report.attribution.status === 'unavailable'
66
+ ? 'unavailable'
67
+ : `${report.attribution.status} (${report.attribution.bodyBytes}B, unknown ${report.attribution.unknownBytes}B)`;
68
+ const estimated = report.tokenAccounting.estimated.totalTokens === null
69
+ ? 'unavailable'
70
+ : String(report.tokenAccounting.estimated.totalTokens);
71
+ const provider = report.tokenAccounting.providerReported?.inputTokens === undefined
72
+ ? 'unavailable'
73
+ : String(report.tokenAccounting.providerReported.inputTokens);
74
+ return [
75
+ `Sando context audit: ${report.host}/${report.requestFormat ?? 'format unavailable'}`,
76
+ `body: ${report.observation.status}`,
77
+ `attribution: ${attribution}`,
78
+ `tool search: ${report.toolSearch.state}`,
79
+ `estimated input tokens: ${estimated}`,
80
+ `provider input tokens: ${provider}`,
81
+ `provenance: ${report.provenanceDigest}`,
82
+ ].join('\n') + '\n';
83
+ }
84
+
85
+ export function runContextAuditCli({ argv = process.argv.slice(2), stdout = process.stdout, stderr = process.stderr } = {}) {
86
+ try {
87
+ const options = parseArgs(argv);
88
+ if (options.help) {
89
+ stdout.write(usage());
90
+ return null;
91
+ }
92
+ const capture = options.input ? readCapture(options.input) : unavailableCapture(options.host);
93
+ if (capture?.host !== options.host) throw new Error('capture host does not match --host');
94
+ const report = buildContextFootprintReport(capture);
95
+ stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : formatContextFootprintReport(report));
96
+ return report;
97
+ } catch (error) {
98
+ stderr.write(`sando context audit: ${error instanceof Error ? error.message : String(error)}\n${usage()}`);
99
+ process.exitCode = 2;
100
+ return null;
101
+ }
102
+ }
103
+
104
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) runContextAuditCli();