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 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.2.0",
4
- "description": "Reduce repeated tool-output context in Claude Code and Codex.",
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 savings (timezone: ${report.timezone})`,
378
+ `Sando mechanical measurement (timezone: ${report.timezone})`,
379
379
  `Current session: ${report.currentSession ? report.currentSession.id ?? 'unknown' : 'none'}`,
380
- `Current session estimated transform savings: ${tokenLine(report.currentSession?.estimatedTransformSavingsTokens ?? 0)}`,
381
- `Average estimated transform savings per session: ${tokenLine(report.averagePerSession.estimatedTransformSavingsTokens)} (${report.averagePerSession.sessionCount} sessions)`,
382
- `Cumulative estimated transform savings: ${tokenLine(report.cumulative.estimatedTransformSavingsTokens)}`,
383
- `Cumulative provider-reported savings: ${tokenLine(report.cumulative.providerReportedSavingsTokens)}`,
384
- `Daily (${report.periods.daily.current.period}) estimated transform savings: ${tokenLine(report.periods.daily.current.estimatedTransformSavingsTokens)}`,
385
- `ISO week (${report.periods.weekly.current.period}) estimated transform savings: ${tokenLine(report.periods.weekly.current.estimatedTransformSavingsTokens)}`,
386
- `Monthly (${report.periods.monthly.current.period}) estimated transform savings: ${tokenLine(report.periods.monthly.current.estimatedTransformSavingsTokens)}`,
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
+ }
@@ -8,10 +8,10 @@ import readline from 'node:readline/promises';
8
8
  import { pathToFileURL } from 'node:url';
9
9
 
10
10
  import {
11
- CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
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.prompted_consent_version >= CONSENT_VERSION) return; // never re-ask on reinstall/upgrade
27
+ if (current.consent_state !== 'unasked') return; // never re-ask on reinstall/upgrade
28
28
 
29
29
  const rl = readlineFactory();
30
30
  let answer;
@@ -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) || !counter(outputTokens) || !counter(reasoningOutputTokens)) return null;
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
- return {
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
- return jsonLines(textValue).map(({ value }, index) => claudeRecord(value, index, options)).filter(Boolean);
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
- return jsonLines(textValue).map(({ value }, index) => codexRecord(value, index, options)).filter(Boolean);
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
- || !counter(value.outputTokens) || !counter(value.reasoningOutputTokens) || !counter(value.totalTokens)
135
- || value.totalTokens !== value.inputTokens + value.outputTokens) throw new Error('provider usage record is invalid');
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 Set(state.records.map((item) => item.eventKey));
195
- for (const item of records) if (!existing.has(item.eventKey)) { state.records.push(item); existing.add(item.eventKey); }
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'), outputTokens: sum('outputTokens'),
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);
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  import {
4
- closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
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 = defaultTelemetryConfigPath(env),
14
- statePaths = defaultTelemetryStatePaths(env),
14
+ configPath,
15
+ statePaths,
15
16
  } = {}) {
16
17
  try {
17
- const config = readTelemetryConfig(configPath);
18
- if (config.enabled && !isDoNotTrack(env)) {
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 (isDoNotTrack(env) || config.prompted_consent_version > 0) {
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 {
@@ -80,21 +80,23 @@ function compactTokens(value) {
80
80
  return `${Number((value / 1_000_000).toFixed(2))}M`;
81
81
  }
82
82
 
83
- // The rate is the session's own blended $/token (totalCostUsd / totalTokens),
84
- // so cache reads (0.1x) and cache writes (1.25x/2x) are already folded in:
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 (!metrics || !['estimate', 'provider-reported'].includes(metrics.source)
92
- || !Number.isSafeInteger(metrics.savedTokens) || metrics.savedTokens <= 0) return '🥪 —';
93
- const estimated = metrics.source === 'estimate';
94
- const savings = `${estimated ? '~' : ''}${compactTokens(metrics.savedTokens)} token saved`;
95
- const effectiveRate = Number.isFinite(totalCostUsd) && totalCostUsd > 0
96
- && Number.isSafeInteger(providerUsage?.totalTokens) && providerUsage.totalTokens > 0
97
- ? totalCostUsd / providerUsage.totalTokens : undefined;
98
- void effectiveRate; // computed for downstream cost tracking, intentionally not shown in the status bar
99
- return `🥪 ${savings}`;
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
  }
@@ -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 { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0 };
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
- return validateTelemetryConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
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
- /** Only an explicit `yes` in an interactive session enables collection. */
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
- if (typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
171
- return writeTelemetryConfig(configPath, { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: CONSENT_VERSION });
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: true,
217
+ enabled: false,
176
218
  prompted_consent_version: CONSENT_VERSION,
177
- consent_version: CONSENT_VERSION,
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.2.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() {