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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sandoichi
|
|
2
2
|
|
|
3
|
-
`sandoichi` is Sando's optional JavaScript library. The
|
|
3
|
+
`sandoichi` is Sando's optional JavaScript library. The marketplace plugin is the main product. Install the plugin through the Claude Code or Codex marketplace.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install sandoichi
|
|
@@ -24,12 +24,12 @@ 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
|
|
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
|
|
29
|
+
`computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted estimates, provider-reported cost, and paired-session evidence separate. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
|
|
30
30
|
|
|
31
31
|
For plugin installation, see the [main project README](https://github.com/yuzushi-dev/Sando#readme).
|
|
32
32
|
|
|
33
33
|
Telemetry is off by default. An interactive npm install asks once for consent; see the [full disclosure](https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md).
|
|
34
34
|
|
|
35
|
-
MIT.
|
|
35
|
+
License: MIT.
|
package/index.mjs
CHANGED
|
@@ -56,7 +56,77 @@ 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 { buildF1TelemetryEvent, publishF1Telemetry } from './src/f1-telemetry.mjs';
|
|
94
|
+
export {
|
|
95
|
+
F4_EVENT_SCHEMA,
|
|
96
|
+
F4_EVENT_VERSION,
|
|
97
|
+
F4_HOSTS,
|
|
98
|
+
F4_LATENCY_BUCKETS,
|
|
99
|
+
F4_OPERATIONS,
|
|
100
|
+
F4_OUTCOMES,
|
|
101
|
+
F4_RESULT_BUCKETS,
|
|
102
|
+
buildF4Event,
|
|
103
|
+
buildF4TelemetryEvent,
|
|
104
|
+
defaultF4EventsPath,
|
|
105
|
+
DEFAULT_F4_TELEMETRY_ENDPOINT,
|
|
106
|
+
digestCapability,
|
|
107
|
+
latencyBucket,
|
|
108
|
+
publishF4Telemetry,
|
|
109
|
+
recordF4Event,
|
|
110
|
+
resultBucket,
|
|
111
|
+
serializeF4Event,
|
|
112
|
+
} from './src/f4-telemetry.mjs';
|
|
113
|
+
export {
|
|
114
|
+
GATE_EVIDENCE_SCHEMA,
|
|
115
|
+
GATE_SCHEMA,
|
|
116
|
+
GATE_THRESHOLDS,
|
|
117
|
+
GATE_VERSION,
|
|
118
|
+
evaluateGatewayGate,
|
|
119
|
+
serializeGatewayGate,
|
|
120
|
+
} from './src/gateway-gate.mjs';
|
|
121
|
+
export {
|
|
122
|
+
HISTORY_DISCLOSURE_SCHEMA,
|
|
123
|
+
HISTORY_DISCLOSURE_VERSION,
|
|
124
|
+
buildHistoryDisclosure,
|
|
125
|
+
serializeHistoryDisclosure,
|
|
126
|
+
} from './src/history-disclosure.mjs';
|
|
59
127
|
export { readStatusSnapshot, renderStatusLine, STATUSLINE_MAX_AGE_MS } from './src/statusline.mjs';
|
|
128
|
+
export { GATEWAY_CATALOG_TOOL, LAZY_MCP_GATEWAY_SCHEMA, createLazyMcpGateway, validateJsonSchema } from './src/lazy-mcp-gateway.mjs';
|
|
129
|
+
export { createConfiguredMcpServers, spawnMcpTransport, startLazyMcpGatewayStdio } from './src/lazy-mcp-gateway-stdio.mjs';
|
|
60
130
|
export {
|
|
61
131
|
activeSessionForPane,
|
|
62
132
|
currentTmuxPanePid,
|
package/package.json
CHANGED
|
@@ -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,133 @@
|
|
|
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
|
+
function integer(value, name, { positive = false } = {}) {
|
|
21
|
+
if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) throw new TypeError(`${name} is invalid`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function continuation(byte) { return (byte & 0xc0) === 0x80; }
|
|
26
|
+
|
|
27
|
+
function safeBufferText(buffer, start, end) {
|
|
28
|
+
if (start > 0 && continuation(buffer[start])) throw new RangeError('byte range splits UTF-8');
|
|
29
|
+
if (end < buffer.length && continuation(buffer[end])) throw new RangeError('byte range splits UTF-8');
|
|
30
|
+
return buffer.subarray(start, end).toString('utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function prefix(buffer, limit) {
|
|
34
|
+
let end = Math.min(buffer.length, limit);
|
|
35
|
+
while (end > 0 && end < buffer.length && continuation(buffer[end])) end -= 1;
|
|
36
|
+
return { text: buffer.subarray(0, end).toString('utf8'), truncated: end < buffer.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function recoverArtifactContent({
|
|
40
|
+
ref, content, digest: expectedDigest, sourceBytes, startByte, endByte, startLine, endLine, maxBytes,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const handlePrefix = handleDigest(ref).slice('sha256:'.length);
|
|
43
|
+
const handle = ref;
|
|
44
|
+
if (typeof content !== 'string') throw new TypeError('artifact content is invalid');
|
|
45
|
+
const actualDigest = digest(content);
|
|
46
|
+
if (expectedDigest !== undefined && expectedDigest !== actualDigest) throw new Error('artifact digest integrity check failed');
|
|
47
|
+
const full = Buffer.from(content, 'utf8');
|
|
48
|
+
if (!actualDigest.slice('sha256:'.length).startsWith(handlePrefix)) throw new Error('artifact handle does not match content');
|
|
49
|
+
const limit = maxBytesValue(maxBytes);
|
|
50
|
+
const byteMode = startByte !== undefined || endByte !== undefined;
|
|
51
|
+
const lineMode = startLine !== undefined || endLine !== undefined;
|
|
52
|
+
if (byteMode && lineMode) throw new TypeError('artifact range is ambiguous');
|
|
53
|
+
let selected;
|
|
54
|
+
let range;
|
|
55
|
+
if (byteMode) {
|
|
56
|
+
const start = integer(startByte ?? 0, 'startByte');
|
|
57
|
+
const end = integer(endByte ?? full.length, 'endByte');
|
|
58
|
+
if (start > end || end > full.length) throw new RangeError('artifact byte range is invalid');
|
|
59
|
+
selected = safeBufferText(full, start, end);
|
|
60
|
+
range = { type: 'bytes', start, end };
|
|
61
|
+
} else if (lineMode) {
|
|
62
|
+
const start = integer(startLine ?? 1, 'startLine', { positive: true });
|
|
63
|
+
const end = integer(endLine ?? start, 'endLine', { positive: true });
|
|
64
|
+
const lines = content.split('\n');
|
|
65
|
+
if (start > end || start > lines.length || end > lines.length) throw new RangeError('artifact line range is invalid');
|
|
66
|
+
selected = lines.slice(start - 1, end).join('\n');
|
|
67
|
+
range = { type: 'lines', start, end };
|
|
68
|
+
} else {
|
|
69
|
+
selected = content;
|
|
70
|
+
range = { type: 'all' };
|
|
71
|
+
}
|
|
72
|
+
const selectedBuffer = Buffer.from(selected, 'utf8');
|
|
73
|
+
const bounded = prefix(selectedBuffer, limit);
|
|
74
|
+
const totalSourceBytes = integer(sourceBytes ?? full.length, 'sourceBytes');
|
|
75
|
+
if (totalSourceBytes !== full.length) throw new Error('artifact source byte metadata is inconsistent');
|
|
76
|
+
return {
|
|
77
|
+
schema: ARTIFACT_RECOVERY_SCHEMA,
|
|
78
|
+
version: ARTIFACT_RECOVERY_VERSION,
|
|
79
|
+
handle,
|
|
80
|
+
digest: actualDigest,
|
|
81
|
+
content: bounded.text,
|
|
82
|
+
bytes: Buffer.byteLength(bounded.text),
|
|
83
|
+
sourceBytes: totalSourceBytes,
|
|
84
|
+
range,
|
|
85
|
+
truncated: bounded.truncated,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function maxBytesValue(value) {
|
|
90
|
+
const result = value ?? 65_536;
|
|
91
|
+
integer(result, 'maxBytes', { positive: true });
|
|
92
|
+
if (result > MAX_RECOVERY_BYTES) throw new RangeError('maxBytes exceeds recovery limit');
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeDirectory(target, name) {
|
|
97
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
98
|
+
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${name} is unavailable or unsafe`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function recoverArtifactFromWorkspace({ cwd, ref, ...range } = {}) {
|
|
102
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) throw new TypeError('artifact cwd is invalid');
|
|
103
|
+
const root = fs.realpathSync(cwd);
|
|
104
|
+
if (!fs.statSync(root).isDirectory()) throw new TypeError('artifact cwd is not a directory');
|
|
105
|
+
const stateRoot = path.join(root, '.sando');
|
|
106
|
+
const privateRoot = path.join(stateRoot, 'sando');
|
|
107
|
+
const directory = path.join(privateRoot, 'artifacts');
|
|
108
|
+
safeDirectory(stateRoot, 'artifact state');
|
|
109
|
+
safeDirectory(privateRoot, 'artifact private state');
|
|
110
|
+
safeDirectory(directory, 'artifact directory');
|
|
111
|
+
const digestPrefix = handleDigest(ref).slice('sha256:'.length);
|
|
112
|
+
const candidates = fs.readdirSync(directory)
|
|
113
|
+
.filter((entry) => /^[a-f0-9]{64}\.txt$/.test(entry) && entry.startsWith(digestPrefix));
|
|
114
|
+
if (candidates.length !== 1) throw new Error(candidates.length ? 'artifact handle is ambiguous' : 'artifact handle is unavailable');
|
|
115
|
+
const digestValue = `sha256:${candidates[0].slice(0, -'.txt'.length)}`;
|
|
116
|
+
const target = path.join(directory, candidates[0]);
|
|
117
|
+
const relative = path.relative(directory, target);
|
|
118
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('artifact path escapes directory');
|
|
119
|
+
let descriptor;
|
|
120
|
+
try {
|
|
121
|
+
descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
122
|
+
const stat = fs.fstatSync(descriptor);
|
|
123
|
+
if (!stat.isFile()) throw new Error('artifact handle is unavailable');
|
|
124
|
+
if (stat.size > MAX_ARTIFACT_BYTES) throw new RangeError('artifact exceeds recovery limit');
|
|
125
|
+
const content = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(descriptor));
|
|
126
|
+
return recoverArtifactContent({ ref, content, digest: digestValue, ...range });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (['ELOOP', 'ENOENT'].includes(error?.code)) throw new Error('artifact handle is unavailable', { cause: error });
|
|
129
|
+
throw error;
|
|
130
|
+
} finally {
|
|
131
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { recoverArtifactContent } 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
|
+
const entry = store.get(options.ref);
|
|
32
|
+
if (!entry) throw new Error('artifact handle is unavailable in this MCP session');
|
|
33
|
+
store.delete(options.ref);
|
|
34
|
+
store.set(options.ref, entry);
|
|
35
|
+
return recoverArtifactContent({ ...options, ...entry });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function exposeMcpResult(result) {
|
|
39
|
+
if (!result?.artifact) return result;
|
|
40
|
+
rememberArtifact(result.artifact);
|
|
41
|
+
const { content: _content, ...artifact } = result.artifact;
|
|
42
|
+
return { ...result, artifact };
|
|
43
|
+
}
|
|
@@ -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();
|
|
@@ -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
|
+
}
|