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 +6 -2
- package/index.mjs +75 -0
- package/package.json +1 -1
- package/src/accounting-cli.mjs +1 -1
- package/src/artifact-cli.mjs +67 -0
- package/src/artifact-lifecycle.mjs +67 -0
- package/src/artifact-recovery.mjs +138 -0
- package/src/artifact-store.mjs +44 -0
- package/src/cache-attribution.mjs +13 -3
- 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 +146 -17
- package/src/core.mjs +300 -35
- 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-archive.mjs +80 -0
- package/src/history-disclosure.mjs +70 -0
- package/src/hook-cli.mjs +17 -1
- package/src/lazy-mcp-gateway-stdio.mjs +59 -0
- package/src/lazy-mcp-gateway.mjs +295 -0
- package/src/mcp-server.mjs +72 -11
- package/src/metrics.mjs +4 -3
- package/src/provider-usage.mjs +103 -23
- package/src/proxy.mjs +435 -19
- package/src/result-disclosure.mjs +115 -0
- package/src/slice.mjs +419 -0
- package/src/statusline.mjs +5 -9
- package/src/telemetry.mjs +155 -29
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
|
|
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
|
-
|
|
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
package/src/accounting-cli.mjs
CHANGED
|
@@ -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
|
-
`
|
|
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. `
|
|
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
|
|
105
|
-
const totalPromptTokens =
|
|
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();
|