sandoichi 0.2.0 → 0.4.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 +3 -1
- package/index.mjs +10 -0
- package/package.json +2 -2
- package/src/accounting-cli.mjs +49 -0
- package/src/adaptive-control.mjs +10 -0
- package/src/exec-capture.mjs +94 -0
- package/src/metrics.mjs +8 -8
- package/src/paired-accounting.mjs +142 -0
- package/src/postinstall.mjs +3 -3
- package/src/provider-usage.mjs +134 -18
- package/src/proxy.mjs +3 -2
- package/src/session-start.mjs +17 -9
- package/src/statusline.mjs +16 -14
- package/src/telemetry-cli.mjs +6 -3
- package/src/telemetry.mjs +51 -10
- package/src/user-prompt-submit.mjs +52 -0
- package/src/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ 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.
|
|
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 is the supported host surface; this package exports the context/history runtime, provider usage report, paired accounting, and explicit proxy API only.
|
|
28
|
+
|
|
29
|
+
`computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted estimates, provider-reported cost, and paired-session evidence distinct. The benchmark report adds explicit replay counterfactuals. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
|
|
28
30
|
|
|
29
31
|
For plugin installation, see the [main project README](https://github.com/yuzushi-dev/Sando#readme).
|
|
30
32
|
|
package/index.mjs
CHANGED
|
@@ -8,6 +8,16 @@ export {
|
|
|
8
8
|
export { createRedactionProfile } from './src/redaction-profile.mjs';
|
|
9
9
|
export { loadProjectRedactionProfile } from './src/redaction-config.mjs';
|
|
10
10
|
export { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './src/context-transform.mjs';
|
|
11
|
+
export {
|
|
12
|
+
DEFAULT_ACCOUNTING_WEIGHTS,
|
|
13
|
+
PAIRED_ARMS,
|
|
14
|
+
computeWeightedUsage,
|
|
15
|
+
pairedArmFromEnv,
|
|
16
|
+
pairedExperimentFromEnv,
|
|
17
|
+
pairedWorkloadFromEnv,
|
|
18
|
+
summarizePairedSessions,
|
|
19
|
+
} from './src/paired-accounting.mjs';
|
|
20
|
+
export { formatAccountingReport, runAccountingCli } from './src/accounting-cli.mjs';
|
|
11
21
|
export {
|
|
12
22
|
buildSemanticPrompt,
|
|
13
23
|
createSemanticCompactor,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sandoichi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { buildProviderUsageReport, defaultProviderUsagePath, readProviderUsage } from './provider-usage.mjs';
|
|
7
|
+
|
|
8
|
+
function option(argv, name) {
|
|
9
|
+
const index = argv.indexOf(`--${name}`);
|
|
10
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatAccountingReport(report) {
|
|
14
|
+
const lines = [
|
|
15
|
+
'provider accounting',
|
|
16
|
+
`input: ${report.inputTokens}`,
|
|
17
|
+
`fresh input: ${report.freshInputTokens}`,
|
|
18
|
+
`cache read: ${report.cachedInputTokens}`,
|
|
19
|
+
`cache write: ${report.cacheWriteInputTokens}`,
|
|
20
|
+
`output: ${report.outputTokens}`,
|
|
21
|
+
`reasoning: ${report.reasoningOutputTokens}`,
|
|
22
|
+
`turns: ${report.turnCount}`,
|
|
23
|
+
`weighted estimate: ${report.weightedCost.costUnits} cost units`,
|
|
24
|
+
`provider cost: ${report.cost.status === 'provider-reported' ? `$${report.cost.totalCostUsd.toFixed(6)}` : report.cost.status}`,
|
|
25
|
+
];
|
|
26
|
+
if (report.cost.effectiveRateUsdPerMillionTokens !== null) {
|
|
27
|
+
lines.push(`blended effective rate: $${report.cost.effectiveRateUsdPerMillionTokens.toFixed(2)}/M tokens`);
|
|
28
|
+
}
|
|
29
|
+
return `${lines.join('\n')}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runAccountingCli({ argv = process.argv.slice(2), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
33
|
+
if (argv.includes('--help')) {
|
|
34
|
+
stdout.write('Usage: node accounting-cli.mjs [--json] [--path ABSOLUTE_PATH] [--session SESSION_ID]\n');
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const storagePath = option(argv, 'path') || defaultProviderUsagePath(env);
|
|
39
|
+
const report = buildProviderUsageReport(readProviderUsage(storagePath), { sessionId: option(argv, 'session') });
|
|
40
|
+
stdout.write(argv.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatAccountingReport(report));
|
|
41
|
+
return report;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
stderr.write(`sando accounting: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) runAccountingCli();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Compatibility aliases for older direct imports. Routing no longer reads this module.
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_ACCOUNTING_WEIGHTS as DEFAULT_ADAPTIVE_WEIGHTS,
|
|
4
|
+
PAIRED_ARMS as ADAPTIVE_ARMS,
|
|
5
|
+
computeWeightedUsage as computeUsageCost,
|
|
6
|
+
pairedArmFromEnv as adaptiveArmFromEnv,
|
|
7
|
+
pairedExperimentFromEnv as adaptiveExperimentFromEnv,
|
|
8
|
+
pairedWorkloadFromEnv as adaptiveWorkloadFromEnv,
|
|
9
|
+
summarizePairedSessions as summarizeAdaptiveSessions,
|
|
10
|
+
} from './paired-accounting.mjs';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const MAX_EXEC_CAPTURE_BYTES = 16_777_216;
|
|
2
|
+
|
|
3
|
+
function terminate(child, signal) {
|
|
4
|
+
try {
|
|
5
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
6
|
+
else child.kill(signal);
|
|
7
|
+
} catch {}
|
|
8
|
+
try { child.kill(signal); } catch {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function incompleteUtf8Suffix(buffer) {
|
|
12
|
+
let index = buffer.length - 1;
|
|
13
|
+
let continuation = 0;
|
|
14
|
+
while (index >= 0 && (buffer[index] & 0xc0) === 0x80) {
|
|
15
|
+
continuation += 1;
|
|
16
|
+
index -= 1;
|
|
17
|
+
}
|
|
18
|
+
if (index < 0) return 0;
|
|
19
|
+
const lead = buffer[index];
|
|
20
|
+
const expected = lead >= 0xc2 && lead <= 0xdf ? 2
|
|
21
|
+
: lead >= 0xe0 && lead <= 0xef ? 3
|
|
22
|
+
: lead >= 0xf0 && lead <= 0xf4 ? 4 : 0;
|
|
23
|
+
return expected > continuation + 1 ? continuation + 1 : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function textOrBinary(buffer, { truncated = false } = {}) {
|
|
27
|
+
if (!Buffer.isBuffer(buffer)) throw new TypeError('output must be a Buffer');
|
|
28
|
+
if (buffer.includes(0)) return { binary: true, text: '', utf8Truncated: false };
|
|
29
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
30
|
+
try { return { binary: false, text: decoder.decode(buffer), utf8Truncated: false }; }
|
|
31
|
+
catch {
|
|
32
|
+
if (!truncated) return { binary: true, text: '', utf8Truncated: false };
|
|
33
|
+
const suffix = incompleteUtf8Suffix(buffer);
|
|
34
|
+
if (!suffix) return { binary: true, text: '', utf8Truncated: false };
|
|
35
|
+
try {
|
|
36
|
+
return { binary: false, text: decoder.decode(buffer.subarray(0, -suffix)), utf8Truncated: true };
|
|
37
|
+
} catch {
|
|
38
|
+
return { binary: true, text: '', utf8Truncated: false };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function captureProcess(child, { maxBytes, timeoutMs, signal } = {}) {
|
|
44
|
+
if (!child || typeof child.once !== 'function' || !Number.isSafeInteger(maxBytes) || maxBytes < 1
|
|
45
|
+
|| !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('capture options are invalid');
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const buffers = { stdout: [], stderr: [] };
|
|
48
|
+
const captured = { stdout: 0, stderr: 0 };
|
|
49
|
+
const truncated = { stdout: false, stderr: false };
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
let settled = false;
|
|
53
|
+
let forceTimer;
|
|
54
|
+
let timer;
|
|
55
|
+
const collect = (name, chunk) => {
|
|
56
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
57
|
+
const remaining = Math.max(0, maxBytes - captured[name]);
|
|
58
|
+
if (remaining) {
|
|
59
|
+
const part = buffer.subarray(0, remaining);
|
|
60
|
+
buffers[name].push(part);
|
|
61
|
+
captured[name] += part.length;
|
|
62
|
+
}
|
|
63
|
+
if (buffer.length > remaining) truncated[name] = true;
|
|
64
|
+
};
|
|
65
|
+
const stop = (signalName) => {
|
|
66
|
+
terminate(child, signalName);
|
|
67
|
+
if (signalName === 'SIGTERM' && forceTimer === undefined) {
|
|
68
|
+
forceTimer = setTimeout(() => terminate(child, 'SIGKILL'), 250);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const finish = (result, error) => {
|
|
72
|
+
if (settled) return;
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
clearTimeout(forceTimer);
|
|
76
|
+
signal?.removeEventListener('abort', onAbort);
|
|
77
|
+
if (error) reject(error);
|
|
78
|
+
else resolve({
|
|
79
|
+
stdout: Buffer.concat(buffers.stdout), stderr: Buffer.concat(buffers.stderr),
|
|
80
|
+
stdoutBytes: captured.stdout, stderrBytes: captured.stderr,
|
|
81
|
+
stdoutTruncated: truncated.stdout, stderrTruncated: truncated.stderr,
|
|
82
|
+
truncated: truncated.stdout || truncated.stderr, timedOut, cancelled, ...result,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const onAbort = () => { cancelled = true; stop('SIGTERM'); };
|
|
86
|
+
timer = setTimeout(() => { timedOut = true; stop('SIGTERM'); }, timeoutMs);
|
|
87
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
88
|
+
child.stdout?.on('data', (chunk) => collect('stdout', chunk));
|
|
89
|
+
child.stderr?.on('data', (chunk) => collect('stderr', chunk));
|
|
90
|
+
child.once('error', (error) => finish(null, error));
|
|
91
|
+
child.once('close', (exitCode, exitSignal) => finish({ exitCode, exitSignal }));
|
|
92
|
+
if (signal?.aborted) onAbort();
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/metrics.mjs
CHANGED
|
@@ -375,15 +375,15 @@ function tokenLine(value) {
|
|
|
375
375
|
|
|
376
376
|
export function formatMetricsReport(report) {
|
|
377
377
|
const lines = [
|
|
378
|
-
`Sando
|
|
378
|
+
`Sando mechanical measurement (timezone: ${report.timezone})`,
|
|
379
379
|
`Current session: ${report.currentSession ? report.currentSession.id ?? 'unknown' : 'none'}`,
|
|
380
|
-
`Current session
|
|
381
|
-
`Average
|
|
382
|
-
`Cumulative
|
|
383
|
-
`
|
|
384
|
-
`Daily (${report.periods.daily.current.period})
|
|
385
|
-
`ISO week (${report.periods.weekly.current.period})
|
|
386
|
-
`Monthly (${report.periods.monthly.current.period})
|
|
380
|
+
`Current session mechanical reduction: ${tokenLine(report.currentSession?.estimatedTransformSavingsTokens ?? 0)}`,
|
|
381
|
+
`Average mechanical reduction per session: ${tokenLine(report.averagePerSession.estimatedTransformSavingsTokens)} (${report.averagePerSession.sessionCount} sessions)`,
|
|
382
|
+
`Cumulative mechanical reduction: ${tokenLine(report.cumulative.estimatedTransformSavingsTokens)}`,
|
|
383
|
+
`Provider cost: unavailable here; use paired control/apply ledger evidence`,
|
|
384
|
+
`Daily (${report.periods.daily.current.period}) mechanical reduction: ${tokenLine(report.periods.daily.current.estimatedTransformSavingsTokens)}`,
|
|
385
|
+
`ISO week (${report.periods.weekly.current.period}) mechanical reduction: ${tokenLine(report.periods.weekly.current.estimatedTransformSavingsTokens)}`,
|
|
386
|
+
`Monthly (${report.periods.monthly.current.period}) mechanical reduction: ${tokenLine(report.periods.monthly.current.estimatedTransformSavingsTokens)}`,
|
|
387
387
|
];
|
|
388
388
|
if (!report.cumulative.eventCount) lines.push('No Sando events recorded.');
|
|
389
389
|
return `${lines.join('\n')}\n`;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const ARMS = new Set(['apply', 'control']);
|
|
2
|
+
|
|
3
|
+
export const PAIRED_ARMS = Object.freeze(['apply', 'control']);
|
|
4
|
+
export const DEFAULT_ACCOUNTING_WEIGHTS = Object.freeze({
|
|
5
|
+
freshInput: 1,
|
|
6
|
+
cacheRead: 0.1,
|
|
7
|
+
cacheWrite: 1.25,
|
|
8
|
+
output: 1,
|
|
9
|
+
reasoningOutput: 1,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function text(value) {
|
|
13
|
+
return typeof value === 'string' && value.length > 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function counter(value) {
|
|
17
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function weights(value) {
|
|
21
|
+
const result = { ...DEFAULT_ACCOUNTING_WEIGHTS, ...(value ?? {}) };
|
|
22
|
+
if (Object.keys(result).some((key) => !Object.hasOwn(DEFAULT_ACCOUNTING_WEIGHTS, key))
|
|
23
|
+
|| Object.values(result).some((item) => typeof item !== 'number' || !Number.isFinite(item) || item < 0)) {
|
|
24
|
+
throw new TypeError('accounting weights are invalid');
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function add(left, right, message = 'accounting aggregate overflow') {
|
|
30
|
+
const total = left + right;
|
|
31
|
+
if (!Number.isSafeInteger(total)) throw new RangeError(message);
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function addCostUnits(left, right) {
|
|
36
|
+
const total = left + right;
|
|
37
|
+
if (!Number.isFinite(total)) throw new RangeError('accounting cost overflow');
|
|
38
|
+
return total;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function armValue(env) {
|
|
42
|
+
return env.SANDO_EXPERIMENT_ARM ?? env.SANDO_ADAPTIVE_ARM ?? 'apply';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function pairedArmFromEnv(env = process.env) {
|
|
46
|
+
const value = armValue(env);
|
|
47
|
+
return ARMS.has(value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function pairedExperimentFromEnv(env = process.env) {
|
|
51
|
+
const value = env.SANDO_EXPERIMENT ?? env.SANDO_ADAPTIVE_EXPERIMENT ?? 'default';
|
|
52
|
+
return text(value) ? value : 'default';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pairedWorkloadFromEnv(env = process.env) {
|
|
56
|
+
const value = env.SANDO_EXPERIMENT_WORKLOAD ?? env.SANDO_ADAPTIVE_WORKLOAD;
|
|
57
|
+
return text(value) ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function computeWeightedUsage(record, pricing) {
|
|
61
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) throw new TypeError('usage record is invalid');
|
|
62
|
+
const inputTokens = record.inputTokens;
|
|
63
|
+
const cachedInputTokens = record.cachedInputTokens ?? 0;
|
|
64
|
+
const cacheWriteInputTokens = record.cacheWriteInputTokens ?? 0;
|
|
65
|
+
const outputTokens = record.outputTokens;
|
|
66
|
+
const reasoningOutputTokens = record.reasoningOutputTokens ?? 0;
|
|
67
|
+
if (![inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens, reasoningOutputTokens].every(counter)
|
|
68
|
+
|| cachedInputTokens + cacheWriteInputTokens > inputTokens
|
|
69
|
+
|| reasoningOutputTokens > outputTokens) throw new TypeError('usage counters are invalid');
|
|
70
|
+
const nonReasoningOutputTokens = outputTokens - reasoningOutputTokens;
|
|
71
|
+
const parts = {
|
|
72
|
+
inputTokens,
|
|
73
|
+
freshInputTokens: inputTokens - cachedInputTokens - cacheWriteInputTokens,
|
|
74
|
+
cachedInputTokens,
|
|
75
|
+
cacheWriteInputTokens,
|
|
76
|
+
outputTokens,
|
|
77
|
+
nonReasoningOutputTokens,
|
|
78
|
+
reasoningOutputTokens,
|
|
79
|
+
};
|
|
80
|
+
const prices = weights(pricing);
|
|
81
|
+
const costUnits = parts.freshInputTokens * prices.freshInput
|
|
82
|
+
+ parts.cachedInputTokens * prices.cacheRead
|
|
83
|
+
+ parts.cacheWriteInputTokens * prices.cacheWrite
|
|
84
|
+
+ parts.nonReasoningOutputTokens * prices.output
|
|
85
|
+
+ parts.reasoningOutputTokens * prices.reasoningOutput;
|
|
86
|
+
if (!Number.isFinite(costUnits)) throw new RangeError('accounting cost overflow');
|
|
87
|
+
return { ...parts, costUnits };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sessionKey(item) {
|
|
91
|
+
return `${item.arm}\0${item.sessionId}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function optionalCounter(item, field) {
|
|
95
|
+
return item[field] === undefined ? undefined : counter(item[field]) ? item[field] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function summarizePairedSessions(records, { host, experimentId, workloadId, pricing } = {}) {
|
|
99
|
+
if (!Array.isArray(records)) throw new TypeError('usage records must be an array');
|
|
100
|
+
const groups = new Map();
|
|
101
|
+
records.forEach((item, index) => {
|
|
102
|
+
if (!item || typeof item !== 'object' || item.host !== host || !ARMS.has(item.arm)
|
|
103
|
+
|| !text(item.sessionId) || (experimentId !== undefined && item.experimentId !== experimentId)
|
|
104
|
+
|| (workloadId !== undefined && item.workloadId !== workloadId)) return;
|
|
105
|
+
const usage = computeWeightedUsage(item, pricing);
|
|
106
|
+
const fields = {
|
|
107
|
+
inputTokens: usage.inputTokens,
|
|
108
|
+
freshInputTokens: usage.freshInputTokens,
|
|
109
|
+
cachedInputTokens: usage.cachedInputTokens,
|
|
110
|
+
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
|
111
|
+
outputTokens: usage.outputTokens,
|
|
112
|
+
reasoningOutputTokens: usage.reasoningOutputTokens,
|
|
113
|
+
totalTokens: add(item.inputTokens, item.outputTokens),
|
|
114
|
+
costUnits: usage.costUnits,
|
|
115
|
+
};
|
|
116
|
+
const current = groups.get(sessionKey(item));
|
|
117
|
+
if (!current) {
|
|
118
|
+
groups.set(sessionKey(item), {
|
|
119
|
+
sessionId: item.sessionId, arm: item.arm, ...fields,
|
|
120
|
+
turnIds: new Set([text(item.turnId) ? item.turnId : `record:${index}`]),
|
|
121
|
+
totalToolCalls: optionalCounter(item, 'totalToolCalls') ?? null,
|
|
122
|
+
nativeToolCalls: optionalCounter(item, 'nativeToolCalls') ?? null,
|
|
123
|
+
sandoMcpCalls: optionalCounter(item, 'sandoMcpCalls') ?? null,
|
|
124
|
+
mechanicalContextTrimmedBytes: optionalCounter(item, 'mechanicalContextTrimmedBytes') ?? null,
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const field of Object.keys(fields)) current[field] = field === 'costUnits'
|
|
129
|
+
? addCostUnits(current[field], fields[field]) : add(current[field], fields[field]);
|
|
130
|
+
current.turnIds.add(text(item.turnId) ? item.turnId : `record:${index}`);
|
|
131
|
+
for (const field of ['totalToolCalls', 'nativeToolCalls', 'sandoMcpCalls', 'mechanicalContextTrimmedBytes']) {
|
|
132
|
+
if (current[field] !== null) {
|
|
133
|
+
const value = optionalCounter(item, field);
|
|
134
|
+
current[field] = value === undefined || value === null || current[field] === null
|
|
135
|
+
? null : add(current[field], value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return [...groups.values()]
|
|
140
|
+
.map(({ turnIds, ...group }) => ({ ...group, turns: turnIds.size }))
|
|
141
|
+
.sort((left, right) => left.arm.localeCompare(right.arm) || left.sessionId.localeCompare(right.sessionId));
|
|
142
|
+
}
|
package/src/postinstall.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import readline from 'node:readline/promises';
|
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
|
|
12
12
|
} from './telemetry.mjs';
|
|
13
13
|
|
|
14
|
-
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
|
|
14
|
+
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
|
|
15
15
|
|
|
16
16
|
export async function runPostinstall({
|
|
17
17
|
env = process.env, stdin = process.stdin, stdout = process.stdout,
|
|
@@ -24,7 +24,7 @@ export async function runPostinstall({
|
|
|
24
24
|
|
|
25
25
|
const configPath = defaultTelemetryConfigPath(env);
|
|
26
26
|
const current = readTelemetryConfig(configPath);
|
|
27
|
-
if (current.
|
|
27
|
+
if (current.consent_state !== 'unasked') return; // never re-ask on reinstall/upgrade
|
|
28
28
|
|
|
29
29
|
const rl = readlineFactory();
|
|
30
30
|
let answer;
|
package/src/provider-usage.mjs
CHANGED
|
@@ -3,11 +3,14 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
|
+
import { computeWeightedUsage } from './paired-accounting.mjs';
|
|
7
|
+
|
|
6
8
|
const SCHEMA = 'sando-provider-usage/v1';
|
|
7
9
|
const VERSION = 1;
|
|
8
10
|
const LOCK_WAIT_MS = 10;
|
|
9
11
|
const LOCK_ATTEMPTS = 250;
|
|
10
12
|
const STALE_LOCK_MS = 30_000;
|
|
13
|
+
const COST_SCOPES = new Set(['session', 'event']);
|
|
11
14
|
|
|
12
15
|
export const PROVIDER_USAGE_SCHEMA = SCHEMA;
|
|
13
16
|
export const PROVIDER_USAGE_VERSION = VERSION;
|
|
@@ -15,10 +18,19 @@ export const PROVIDER_USAGE_VERSION = VERSION;
|
|
|
15
18
|
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
16
19
|
function text(value) { return typeof value === 'string' && value.length > 0; }
|
|
17
20
|
function counter(value) { return Number.isSafeInteger(value) && value >= 0; }
|
|
21
|
+
function usd(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0; }
|
|
22
|
+
function cacheFits(inputTokens, cachedInputTokens, cacheWriteInputTokens) {
|
|
23
|
+
return cacheWriteInputTokens <= inputTokens
|
|
24
|
+
&& cachedInputTokens <= inputTokens - cacheWriteInputTokens;
|
|
25
|
+
}
|
|
18
26
|
function safeSum(...values) {
|
|
19
27
|
const total = values.reduce((sum, value) => sum + value, 0);
|
|
20
28
|
return Number.isSafeInteger(total) ? total : null;
|
|
21
29
|
}
|
|
30
|
+
function sumUsd(values) {
|
|
31
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
32
|
+
return Number.isFinite(total) ? Number(total.toFixed(12)) : null;
|
|
33
|
+
}
|
|
22
34
|
function sha256(value) { return `sha256:${createHash('sha256').update(value).digest('hex')}`; }
|
|
23
35
|
function isoDate(value, fallback = new Date()) {
|
|
24
36
|
const date = value === undefined ? new Date(fallback) : new Date(value);
|
|
@@ -39,26 +51,55 @@ function jsonLines(textValue) {
|
|
|
39
51
|
});
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
function reportedCost(value) {
|
|
55
|
+
const candidates = [
|
|
56
|
+
value?.total_cost_usd,
|
|
57
|
+
value?.totalCostUsd,
|
|
58
|
+
value?.cost_usd,
|
|
59
|
+
value?.costUsd,
|
|
60
|
+
value?.usage?.total_cost_usd,
|
|
61
|
+
value?.usage?.totalCostUsd,
|
|
62
|
+
value?.message?.usage?.total_cost_usd,
|
|
63
|
+
value?.message?.usage?.totalCostUsd,
|
|
64
|
+
value?.cost?.total_cost_usd,
|
|
65
|
+
value?.cost?.totalCostUsd,
|
|
66
|
+
value?.cost?.usd,
|
|
67
|
+
];
|
|
68
|
+
return candidates.find(usd);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function attachReportedCost(records, totalCostUsd) {
|
|
72
|
+
if (!records.length || !usd(totalCostUsd)) return records;
|
|
73
|
+
return records.map((item, index) => index === records.length - 1
|
|
74
|
+
? { ...item, totalCostUsd, costScope: 'session' } : item);
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
function usageRecord({ host, source, sourceKey, sessionId, turnId, at, inputTokens, cachedInputTokens = 0,
|
|
43
|
-
cacheWriteInputTokens = 0, outputTokens, reasoningOutputTokens = 0 }) {
|
|
78
|
+
cacheWriteInputTokens = 0, outputTokens, reasoningOutputTokens = 0, totalCostUsd, arm, experimentId, workloadId }) {
|
|
44
79
|
if (!text(host) || !text(source) || !text(sourceKey)
|
|
45
80
|
|| (sessionId !== null && !text(sessionId)) || (turnId !== null && !text(turnId))
|
|
46
81
|
|| !text(at) || !counter(inputTokens) || !counter(cachedInputTokens)
|
|
47
|
-
|| !counter(cacheWriteInputTokens) || !
|
|
82
|
+
|| !counter(cacheWriteInputTokens) || !cacheFits(inputTokens, cachedInputTokens, cacheWriteInputTokens)
|
|
83
|
+
|| !counter(outputTokens) || !counter(reasoningOutputTokens) || reasoningOutputTokens > outputTokens) return null;
|
|
48
84
|
const totalTokens = safeSum(inputTokens, outputTokens);
|
|
49
85
|
if (totalTokens === null) return null;
|
|
50
86
|
const identity = JSON.stringify({ host, source, sourceKey, at, inputTokens, cachedInputTokens,
|
|
51
|
-
cacheWriteInputTokens, outputTokens, reasoningOutputTokens, totalTokens });
|
|
52
|
-
|
|
87
|
+
cacheWriteInputTokens, outputTokens, reasoningOutputTokens, totalTokens, arm, experimentId, workloadId });
|
|
88
|
+
const result = {
|
|
53
89
|
eventKey: `usage:${host}:${sha256(identity)}`,
|
|
54
90
|
schema: SCHEMA, version: VERSION, host, source,
|
|
55
91
|
sessionId: sessionId ?? null, turnId: turnId ?? null, at,
|
|
56
92
|
inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens,
|
|
57
93
|
reasoningOutputTokens, totalTokens,
|
|
58
94
|
};
|
|
95
|
+
if (totalCostUsd !== undefined && usd(totalCostUsd)) result.totalCostUsd = totalCostUsd;
|
|
96
|
+
if (arm !== undefined) result.arm = arm;
|
|
97
|
+
if (experimentId !== undefined) result.experimentId = experimentId;
|
|
98
|
+
if (workloadId !== undefined) result.workloadId = workloadId;
|
|
99
|
+
return result;
|
|
59
100
|
}
|
|
60
101
|
|
|
61
|
-
function claudeRecord(value, index, { sessionId = null, turnId = null, now } = {}) {
|
|
102
|
+
function claudeRecord(value, index, { sessionId = null, turnId = null, now, arm, experimentId, workloadId } = {}) {
|
|
62
103
|
if (value.type !== 'assistant' || !record(value.message?.usage)) return null;
|
|
63
104
|
const usage = value.message.usage;
|
|
64
105
|
const inputTokens = usage.input_tokens;
|
|
@@ -74,10 +115,11 @@ function claudeRecord(value, index, { sessionId = null, turnId = null, now } = {
|
|
|
74
115
|
host: 'claude', source: 'claude-transcript', sourceKey: value.uuid ?? value.request_id ?? value.timestamp ?? String(index),
|
|
75
116
|
sessionId, turnId: value.turn_id ?? turnId, at: isoDate(value.timestamp, now),
|
|
76
117
|
inputTokens: totalInputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens,
|
|
118
|
+
arm, experimentId, workloadId,
|
|
77
119
|
});
|
|
78
120
|
}
|
|
79
121
|
|
|
80
|
-
function codexRecord(value, index, { sessionId = null, turnId = null, now } = {}) {
|
|
122
|
+
function codexRecord(value, index, { sessionId = null, turnId = null, now, arm, experimentId, workloadId } = {}) {
|
|
81
123
|
const usage = value.type === 'turn.completed'
|
|
82
124
|
? value.usage
|
|
83
125
|
: value.type === 'event_msg' && value.payload?.type === 'token_count'
|
|
@@ -95,17 +137,24 @@ function codexRecord(value, index, { sessionId = null, turnId = null, now } = {}
|
|
|
95
137
|
if (!counter(totalTokens) || totalTokens !== inputTokens + outputTokens) return null;
|
|
96
138
|
return usageRecord({
|
|
97
139
|
host: 'codex', source: 'codex-transcript', sourceKey: value.turn_id ?? value.id ?? value.timestamp ?? String(index),
|
|
98
|
-
sessionId, turnId: value.turn_id ?? turnId, at: isoDate(value.timestamp, now),
|
|
140
|
+
sessionId, turnId: value.turn_id ?? value.id ?? (value.timestamp ? `at:${value.timestamp}` : turnId), at: isoDate(value.timestamp, now),
|
|
99
141
|
inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens, reasoningOutputTokens,
|
|
142
|
+
arm, experimentId, workloadId,
|
|
100
143
|
});
|
|
101
144
|
}
|
|
102
145
|
|
|
103
146
|
export function parseClaudeTranscript(textValue, options = {}) {
|
|
104
|
-
|
|
147
|
+
const entries = jsonLines(textValue);
|
|
148
|
+
const records = entries.map(({ value }, index) => claudeRecord(value, index, options)).filter(Boolean);
|
|
149
|
+
const totalCostUsd = options.totalCostUsd ?? entries.slice().reverse().map(({ value }) => reportedCost(value)).find(usd);
|
|
150
|
+
return attachReportedCost(records, totalCostUsd);
|
|
105
151
|
}
|
|
106
152
|
|
|
107
153
|
export function parseCodexTranscript(textValue, options = {}) {
|
|
108
|
-
|
|
154
|
+
const entries = jsonLines(textValue);
|
|
155
|
+
const records = entries.map(({ value }, index) => codexRecord(value, index, options)).filter(Boolean);
|
|
156
|
+
const totalCostUsd = options.totalCostUsd ?? entries.slice().reverse().map(({ value }) => reportedCost(value)).find(usd);
|
|
157
|
+
return attachReportedCost(records, totalCostUsd);
|
|
109
158
|
}
|
|
110
159
|
|
|
111
160
|
export function defaultProviderUsagePath(env = process.env) {
|
|
@@ -131,8 +180,17 @@ function validateUsage(value) {
|
|
|
131
180
|
|| !text(value.host) || !text(value.source) || !text(value.at)
|
|
132
181
|
|| (value.sessionId !== null && !text(value.sessionId)) || (value.turnId !== null && !text(value.turnId))
|
|
133
182
|
|| !counter(value.inputTokens) || !counter(value.cachedInputTokens) || !counter(value.cacheWriteInputTokens)
|
|
134
|
-
|| !
|
|
135
|
-
|| value.
|
|
183
|
+
|| !cacheFits(value.inputTokens, value.cachedInputTokens, value.cacheWriteInputTokens)
|
|
184
|
+
|| !counter(value.outputTokens) || !counter(value.reasoningOutputTokens) || value.reasoningOutputTokens > value.outputTokens
|
|
185
|
+
|| !counter(value.totalTokens)
|
|
186
|
+
|| value.totalTokens !== value.inputTokens + value.outputTokens
|
|
187
|
+
|| (value.arm !== undefined && !['apply', 'control'].includes(value.arm))
|
|
188
|
+
|| (value.experimentId !== undefined && !text(value.experimentId))
|
|
189
|
+
|| (value.workloadId !== undefined && !text(value.workloadId))
|
|
190
|
+
|| (value.totalCostUsd !== undefined && !usd(value.totalCostUsd))
|
|
191
|
+
|| (value.costScope !== undefined && (!COST_SCOPES.has(value.costScope) || value.totalCostUsd === undefined))) {
|
|
192
|
+
throw new Error('provider usage record is invalid');
|
|
193
|
+
}
|
|
136
194
|
}
|
|
137
195
|
|
|
138
196
|
function validateState(value) {
|
|
@@ -191,34 +249,92 @@ export function appendProviderUsage({ storagePath = defaultProviderUsagePath(),
|
|
|
191
249
|
ensureDirectory(path.dirname(filePath));
|
|
192
250
|
return withLock(`${filePath}.lock`, () => {
|
|
193
251
|
const state = readProviderUsage(filePath);
|
|
194
|
-
const existing = new
|
|
195
|
-
for (const item of records)
|
|
252
|
+
const existing = new Map(state.records.map((item, index) => [item.eventKey, index]));
|
|
253
|
+
for (const item of records) {
|
|
254
|
+
const index = existing.get(item.eventKey);
|
|
255
|
+
if (index === undefined) {
|
|
256
|
+
existing.set(item.eventKey, state.records.length);
|
|
257
|
+
state.records.push(item);
|
|
258
|
+
} else if (item.costScope === 'session') {
|
|
259
|
+
state.records[index] = item;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
196
262
|
atomicWrite(filePath, state);
|
|
197
263
|
return state;
|
|
198
264
|
});
|
|
199
265
|
}
|
|
200
266
|
|
|
201
267
|
export function collectProviderUsage({ host, transcriptPath, sessionId = null, turnId = null,
|
|
202
|
-
storagePath = defaultProviderUsagePath(), now } = {}) {
|
|
268
|
+
storagePath = defaultProviderUsagePath(), now, totalCostUsd, arm, experimentId, workloadId } = {}) {
|
|
203
269
|
if (!['claude', 'codex'].includes(host) || typeof transcriptPath !== 'string' || !transcriptPath) return { records: [], state: readProviderUsage(storagePath) };
|
|
204
270
|
try {
|
|
205
271
|
const textValue = fs.readFileSync(transcriptPath, 'utf8');
|
|
206
272
|
const parse = host === 'claude' ? parseClaudeTranscript : parseCodexTranscript;
|
|
207
|
-
const records = parse(textValue, { sessionId, turnId, now });
|
|
273
|
+
const records = parse(textValue, { sessionId, turnId, now, totalCostUsd, arm, experimentId, workloadId });
|
|
208
274
|
return { records, state: appendProviderUsage({ storagePath, records }) };
|
|
209
275
|
} catch {
|
|
210
276
|
return { records: [], state: readProviderUsage(storagePath) };
|
|
211
277
|
}
|
|
212
278
|
}
|
|
213
279
|
|
|
214
|
-
export function buildProviderUsageReport(state, { sessionId } = {}) {
|
|
280
|
+
export function buildProviderUsageReport(state, { sessionId, pricing } = {}) {
|
|
215
281
|
const records = validateState(state).records.filter((item) => sessionId === undefined || item.sessionId === sessionId);
|
|
216
282
|
const sessions = new Set(records.map((item) => `${item.host}\0${item.sessionId ?? '<unknown>'}`));
|
|
283
|
+
const turns = new Set(records.map((item, index) => `${item.host}\0${item.sessionId ?? '<unknown>'}\0${item.turnId ?? `record:${index}`}`));
|
|
217
284
|
const sum = (field) => records.reduce((total, item) => total + item[field], 0);
|
|
285
|
+
const weightedCostUnits = records.reduce((total, item) => total + computeWeightedUsage(item, pricing).costUnits, 0);
|
|
286
|
+
const freshInputTokens = records.reduce((total, item) => total + item.inputTokens - item.cachedInputTokens - item.cacheWriteInputTokens, 0);
|
|
287
|
+
const billing = reportedCostSummary(records);
|
|
288
|
+
const totalCostUsd = billing.complete ? billing.totalCostUsd : null;
|
|
289
|
+
const effectiveRate = totalCostUsd !== null && totalTokens(records) > 0
|
|
290
|
+
? totalCostUsd / totalTokens(records) * 1_000_000 : null;
|
|
291
|
+
const cost = {
|
|
292
|
+
status: billing.complete ? 'provider-reported' : 'unavailable',
|
|
293
|
+
coverage: billing.complete ? 'complete' : billing.partial ? 'partial' : 'none',
|
|
294
|
+
totalCostUsd,
|
|
295
|
+
effectiveRateUsdPerMillionTokens: effectiveRate,
|
|
296
|
+
};
|
|
218
297
|
return {
|
|
219
298
|
eventCount: records.length, sessionCount: sessions.size,
|
|
220
299
|
inputTokens: sum('inputTokens'), cachedInputTokens: sum('cachedInputTokens'),
|
|
221
|
-
cacheWriteInputTokens: sum('cacheWriteInputTokens'),
|
|
222
|
-
reasoningOutputTokens: sum('reasoningOutputTokens'), totalTokens: sum('totalTokens'),
|
|
300
|
+
cacheWriteInputTokens: sum('cacheWriteInputTokens'), freshInputTokens,
|
|
301
|
+
outputTokens: sum('outputTokens'), reasoningOutputTokens: sum('reasoningOutputTokens'), totalTokens: sum('totalTokens'),
|
|
302
|
+
turnCount: turns.size, weightedCostUnits,
|
|
303
|
+
weightedCost: { source: 'weighted-estimate', costUnits: weightedCostUnits },
|
|
304
|
+
cost,
|
|
305
|
+
totalCostUsd,
|
|
306
|
+
providerReportedCostUsd: totalCostUsd,
|
|
307
|
+
sessionBlendedEffectiveRateUsdPerMillionTokens: effectiveRate,
|
|
308
|
+
costSource: cost.status,
|
|
223
309
|
};
|
|
224
310
|
}
|
|
311
|
+
|
|
312
|
+
function billingKey(item) {
|
|
313
|
+
return `${item.host}\0${item.sessionId ?? '<unknown>'}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function reportedCostSummary(records) {
|
|
317
|
+
const groups = new Map();
|
|
318
|
+
records.forEach((item) => {
|
|
319
|
+
const entries = groups.get(billingKey(item)) ?? [];
|
|
320
|
+
entries.push(item);
|
|
321
|
+
groups.set(billingKey(item), entries);
|
|
322
|
+
});
|
|
323
|
+
const totals = [];
|
|
324
|
+
for (const entries of groups.values()) {
|
|
325
|
+
const sessionCosts = entries.filter((item) => item.costScope === 'session' && usd(item.totalCostUsd));
|
|
326
|
+
if (sessionCosts.length) {
|
|
327
|
+
const latest = sessionCosts.reduce((left, right) => right.at >= left.at ? right : left);
|
|
328
|
+
totals.push(latest.totalCostUsd);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (entries.every((item) => usd(item.totalCostUsd))) totals.push(sumUsd(entries.map((item) => item.totalCostUsd)));
|
|
332
|
+
}
|
|
333
|
+
const complete = groups.size > 0 && totals.length === groups.size && !totals.includes(null);
|
|
334
|
+
const totalCostUsd = complete ? sumUsd(totals) : null;
|
|
335
|
+
return { complete: complete && totalCostUsd !== null, partial: totals.length > 0, totalCostUsd };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function totalTokens(records) {
|
|
339
|
+
return records.reduce((total, item) => total + item.totalTokens, 0);
|
|
340
|
+
}
|
package/src/proxy.mjs
CHANGED
|
@@ -225,14 +225,15 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
|
|
|
225
225
|
lastRequestAt = now;
|
|
226
226
|
const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
|
|
227
227
|
if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
|
|
228
|
+
const mechanicalContextTrimmedBytes = Math.max(0, rawBody.length - body.length);
|
|
228
229
|
recordProxyTelemetry({
|
|
229
230
|
env, provider, transformed,
|
|
230
231
|
beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
|
|
231
232
|
});
|
|
232
|
-
lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
|
|
233
|
+
lastStats = { provider, ...transformed.stats, mechanicalContextTrimmedBytes, changed: transformed.changed, reasons: transformed.reasons };
|
|
233
234
|
recordProvider = provider;
|
|
234
235
|
recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
|
|
235
|
-
recordStats = transformed.stats;
|
|
236
|
+
recordStats = { ...transformed.stats, mechanicalContextTrimmedBytes };
|
|
236
237
|
if (typeof semanticCompactor === 'function') {
|
|
237
238
|
const candidates = listSemanticCandidates({ provider, body: transformed.body });
|
|
238
239
|
const stats = createSemanticStats(candidates);
|
package/src/session-start.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack,
|
|
4
|
+
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, markTelemetryAsked,
|
|
5
|
+
readTelemetryConfig, TELEMETRY_DETAILS_URL,
|
|
5
6
|
} from './telemetry.mjs';
|
|
6
7
|
import { PLUGIN_VERSION } from './version.mjs';
|
|
7
8
|
|
|
@@ -10,28 +11,35 @@ export function runSessionStart({
|
|
|
10
11
|
stdout = process.stdout,
|
|
11
12
|
rootEnv = 'PLUGIN_ROOT',
|
|
12
13
|
spawnImpl,
|
|
13
|
-
configPath
|
|
14
|
-
statePaths
|
|
14
|
+
configPath,
|
|
15
|
+
statePaths,
|
|
15
16
|
} = {}) {
|
|
16
17
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
if (isDoNotTrack(env)) {
|
|
19
|
+
stdout.write('{}\n');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
|
|
23
|
+
const telemetryStatePaths = statePaths ?? defaultTelemetryStatePaths(env);
|
|
24
|
+
const config = readTelemetryConfig(telemetryConfigPath);
|
|
25
|
+
if (config.enabled) {
|
|
19
26
|
closeFinishedDays({
|
|
20
|
-
statePaths, configPath, day: new Date().toISOString().slice(0, 10),
|
|
27
|
+
statePaths: telemetryStatePaths, configPath: telemetryConfigPath, day: new Date().toISOString().slice(0, 10),
|
|
21
28
|
pluginVersion: PLUGIN_VERSION, ...(spawnImpl ? { spawnImpl } : {}),
|
|
22
29
|
});
|
|
23
30
|
}
|
|
24
|
-
if (
|
|
31
|
+
if (config.consent_state !== 'unasked' || !markTelemetryAsked(telemetryConfigPath)) {
|
|
25
32
|
stdout.write('{}\n');
|
|
26
33
|
return;
|
|
27
34
|
}
|
|
28
35
|
const pluginRoot = env[rootEnv] || path.resolve(import.meta.dirname, '..');
|
|
29
36
|
const cli = path.join(pluginRoot, 'lib', 'telemetry-cli.mjs');
|
|
30
37
|
stdout.write(`${JSON.stringify({
|
|
38
|
+
systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
|
|
39
|
+
+ 'Reply with exactly `sando telemetry yes` or `sando telemetry no`, '
|
|
40
|
+
+ `or run \`node "${cli}" enable\`. Details: ${TELEMETRY_DETAILS_URL}`,
|
|
31
41
|
hookSpecificOutput: {
|
|
32
42
|
hookEventName: 'SessionStart',
|
|
33
|
-
systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
|
|
34
|
-
+ `Run \`node "${cli}" enable\` to turn it on. Details: ${TELEMETRY_DETAILS_URL}`,
|
|
35
43
|
},
|
|
36
44
|
})}\n`);
|
|
37
45
|
} catch {
|
package/src/statusline.mjs
CHANGED
|
@@ -80,21 +80,23 @@ function compactTokens(value) {
|
|
|
80
80
|
return `${Number((value / 1_000_000).toFixed(2))}M`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// it's the harness's real billed rate, not a reconstructed list price.
|
|
86
|
-
function compactCost(tokens, effectiveRate) {
|
|
87
|
-
return `$${(tokens * effectiveRate).toFixed(2)}`;
|
|
83
|
+
function compactTurns(value) {
|
|
84
|
+
return `${value} ${value === 1 ? 'turn' : 'turns'}`;
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
export function renderStatusLine({ metrics, providerUsage, totalCostUsd } = {}, _now = Date.now()) {
|
|
91
|
-
if (!
|
|
92
|
-
|| !Number.isSafeInteger(
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
if (!Number.isSafeInteger(providerUsage?.totalTokens) || providerUsage.totalTokens <= 0
|
|
89
|
+
|| !Number.isSafeInteger(providerUsage?.turnCount) || providerUsage.turnCount <= 0) return '🥪 —';
|
|
90
|
+
const parts = [
|
|
91
|
+
`${compactTokens(providerUsage.totalTokens)} provider tokens`,
|
|
92
|
+
compactTurns(providerUsage.turnCount),
|
|
93
|
+
];
|
|
94
|
+
if (Number.isFinite(providerUsage.weightedCostUnits) && providerUsage.weightedCostUnits >= 0) {
|
|
95
|
+
parts.push(`${compactTokens(Math.round(providerUsage.weightedCostUnits))} cost units`);
|
|
96
|
+
}
|
|
97
|
+
if (Number.isFinite(totalCostUsd) && totalCostUsd >= 0) {
|
|
98
|
+
const effectiveRate = totalCostUsd / providerUsage.totalTokens;
|
|
99
|
+
parts.push(`$${totalCostUsd.toFixed(2)}`, `$${(effectiveRate * 1_000_000).toFixed(2)}/M`);
|
|
100
|
+
}
|
|
101
|
+
return `🥪 ${parts.join(' · ')}`;
|
|
100
102
|
}
|
package/src/telemetry-cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from './telemetry.mjs';
|
|
11
11
|
|
|
12
12
|
const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
|
|
13
|
-
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
|
|
13
|
+
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
|
|
14
14
|
|
|
15
15
|
async function defaultPrompt(message) {
|
|
16
16
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -47,10 +47,13 @@ export async function runTelemetryCli({
|
|
|
47
47
|
stdout.write('telemetry already enabled.\n');
|
|
48
48
|
return current;
|
|
49
49
|
}
|
|
50
|
+
if (current.consent_state === 'declined') {
|
|
51
|
+
stdout.write('telemetry not enabled.\n');
|
|
52
|
+
return current;
|
|
53
|
+
}
|
|
50
54
|
const answer = await prompt(CONSENT_PROMPT);
|
|
51
55
|
const result = enableTelemetry({
|
|
52
|
-
configPath, interactive: true,
|
|
53
|
-
answer: /^(y|yes)$/i.test((answer ?? '').trim()) ? 'yes' : answer,
|
|
56
|
+
configPath, interactive: true, answer,
|
|
54
57
|
});
|
|
55
58
|
stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
|
|
56
59
|
return result;
|
package/src/telemetry.mjs
CHANGED
|
@@ -105,6 +105,7 @@ export function serializeEvent(payload) {
|
|
|
105
105
|
export const TELEMETRY_CONFIG_VERSION = 1;
|
|
106
106
|
export const CONSENT_VERSION = 1;
|
|
107
107
|
export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md';
|
|
108
|
+
export const CONSENT_STATES = ['unasked', 'asked', 'enabled', 'declined'];
|
|
108
109
|
// Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
|
|
109
110
|
// reachable from any of the owner's machines (see
|
|
110
111
|
// session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
|
|
@@ -119,12 +120,15 @@ export function isDoNotTrack(env = process.env) {
|
|
|
119
120
|
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
120
121
|
|
|
121
122
|
function emptyTelemetryConfig() {
|
|
122
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0, consent_state: 'unasked',
|
|
125
|
+
};
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
function validateTelemetryConfig(value) {
|
|
126
129
|
if (!record(value) || value.schema_version !== TELEMETRY_CONFIG_VERSION || typeof value.enabled !== 'boolean'
|
|
127
|
-
|| !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0
|
|
130
|
+
|| !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0
|
|
131
|
+
|| !CONSENT_STATES.includes(value.consent_state)) {
|
|
128
132
|
throw new Error('telemetry config is invalid');
|
|
129
133
|
}
|
|
130
134
|
if (value.enabled) {
|
|
@@ -150,7 +154,14 @@ export function defaultTelemetryStatePaths(env = process.env) {
|
|
|
150
154
|
|
|
151
155
|
export function readTelemetryConfig(configPath = defaultTelemetryConfigPath()) {
|
|
152
156
|
if (!fs.existsSync(configPath)) return emptyTelemetryConfig();
|
|
153
|
-
|
|
157
|
+
const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
158
|
+
// Legacy files cannot distinguish an explicit no from blank input or the old
|
|
159
|
+
// y-as-no bug. Keep that ambiguous decision as asked, not as a decline.
|
|
160
|
+
const migrated = Object.hasOwn(value, 'consent_state') ? value : {
|
|
161
|
+
...value,
|
|
162
|
+
consent_state: value.enabled ? 'enabled' : value.prompted_consent_version > 0 ? 'asked' : 'unasked',
|
|
163
|
+
};
|
|
164
|
+
return validateTelemetryConfig(migrated);
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
function writeTelemetryConfig(configPath, config) {
|
|
@@ -164,19 +175,48 @@ export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
|
|
|
164
175
|
return readTelemetryConfig(configPath);
|
|
165
176
|
}
|
|
166
177
|
|
|
167
|
-
|
|
178
|
+
export function normalizeConsentAnswer(answer) {
|
|
179
|
+
if (typeof answer !== 'string') return undefined;
|
|
180
|
+
const normalized = answer.trim().toLowerCase();
|
|
181
|
+
if (normalized === 'y' || normalized === 'yes') return 'yes';
|
|
182
|
+
if (normalized === 'n' || normalized === 'no') return 'no';
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function markTelemetryAsked(configPath = defaultTelemetryConfigPath()) {
|
|
187
|
+
ensureDirectory(path.dirname(configPath));
|
|
188
|
+
return withLock(`${configPath}.lock`, () => {
|
|
189
|
+
const current = readTelemetryConfig(configPath);
|
|
190
|
+
if (current.consent_state !== 'unasked') return false;
|
|
191
|
+
const next = {
|
|
192
|
+
...current, enabled: false, prompted_consent_version: CONSENT_VERSION, consent_state: 'asked',
|
|
193
|
+
};
|
|
194
|
+
validateTelemetryConfig(next);
|
|
195
|
+
atomicWrite(configPath, next);
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Only an explicit yes enables collection; explicit no declines, other input stays asked/off. */
|
|
168
201
|
export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
|
|
169
202
|
if (!interactive) return { ...readTelemetryConfig(configPath), exitCode: 1 };
|
|
170
|
-
|
|
171
|
-
|
|
203
|
+
const normalized = normalizeConsentAnswer(answer);
|
|
204
|
+
if (normalized === 'yes') {
|
|
205
|
+
return writeTelemetryConfig(configPath, {
|
|
206
|
+
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
207
|
+
enabled: true,
|
|
208
|
+
prompted_consent_version: CONSENT_VERSION,
|
|
209
|
+
consent_state: 'enabled',
|
|
210
|
+
consent_version: CONSENT_VERSION,
|
|
211
|
+
consented_at: now().toISOString(),
|
|
212
|
+
endpoint: TELEMETRY_ENDPOINT,
|
|
213
|
+
});
|
|
172
214
|
}
|
|
173
215
|
return writeTelemetryConfig(configPath, {
|
|
174
216
|
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
175
|
-
enabled:
|
|
217
|
+
enabled: false,
|
|
176
218
|
prompted_consent_version: CONSENT_VERSION,
|
|
177
|
-
|
|
178
|
-
consented_at: now().toISOString(),
|
|
179
|
-
endpoint: TELEMETRY_ENDPOINT,
|
|
219
|
+
consent_state: normalized === 'no' ? 'declined' : 'asked',
|
|
180
220
|
});
|
|
181
221
|
}
|
|
182
222
|
|
|
@@ -509,6 +549,7 @@ export function disableTelemetry({ configPath = defaultTelemetryConfigPath(), pu
|
|
|
509
549
|
schema_version: TELEMETRY_CONFIG_VERSION,
|
|
510
550
|
enabled: false,
|
|
511
551
|
prompted_consent_version: previous.prompted_consent_version || CONSENT_VERSION,
|
|
552
|
+
consent_state: 'declined',
|
|
512
553
|
});
|
|
513
554
|
if (purge) {
|
|
514
555
|
for (const target of [statePaths.counters, statePaths.queue]) fs.rmSync(target, { force: true });
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig,
|
|
9
|
+
} from './telemetry.mjs';
|
|
10
|
+
|
|
11
|
+
const CONSENT_COMMANDS = new Map([
|
|
12
|
+
['sando telemetry yes', 'yes'],
|
|
13
|
+
['sando telemetry no', 'no'],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function pass(stdout) { stdout.write('{}\n'); }
|
|
17
|
+
|
|
18
|
+
export function runUserPromptSubmit({
|
|
19
|
+
env = process.env, input, stdout = process.stdout, configPath,
|
|
20
|
+
} = {}) {
|
|
21
|
+
try {
|
|
22
|
+
if (isDoNotTrack(env)) {
|
|
23
|
+
pass(stdout);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
|
|
27
|
+
const rawInput = input === undefined ? fs.readFileSync(0, 'utf8') : input;
|
|
28
|
+
const prompt = JSON.parse(rawInput || '{}').prompt;
|
|
29
|
+
// Normalizza solo gli spazi ai bordi: non introduce ambiguita' (la stringa
|
|
30
|
+
// resta esatta) ed evita di perdere risposte genuine incollate con spazi.
|
|
31
|
+
const answer = typeof prompt === 'string' ? CONSENT_COMMANDS.get(prompt.trim()) : undefined;
|
|
32
|
+
if (!answer) {
|
|
33
|
+
pass(stdout);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const current = readTelemetryConfig(telemetryConfigPath);
|
|
37
|
+
if (current.consent_state === 'declined' && answer === 'yes') {
|
|
38
|
+
pass(stdout);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const result = enableTelemetry({ configPath: telemetryConfigPath, interactive: true, answer });
|
|
42
|
+
stdout.write(`${JSON.stringify({
|
|
43
|
+
systemMessage: result.enabled ? 'Sando telemetry enabled.' : 'Sando telemetry disabled.',
|
|
44
|
+
})}\n`);
|
|
45
|
+
} catch {
|
|
46
|
+
pass(stdout);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
51
|
+
runUserPromptSubmit();
|
|
52
|
+
}
|
package/src/version.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
const VERSION_PATTERN = /^\d+\.\d+(?:\.\d+)?$/;
|
|
5
|
-
const STANDALONE_VERSION = '0.
|
|
5
|
+
const STANDALONE_VERSION = '0.3.0';
|
|
6
6
|
const METADATA_FILES = ['package.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json'];
|
|
7
7
|
|
|
8
8
|
function findMetadataFile() {
|