sandoichi 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Cuts what Claude Code and Codex charge you to re-read their own output.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
@@ -22,6 +22,7 @@
22
22
  "src"
23
23
  ],
24
24
  "scripts": {
25
- "test": "node --test tests/*.test.mjs"
25
+ "test": "node --test tests/*.test.mjs",
26
+ "postinstall": "node src/postinstall.mjs"
26
27
  }
27
28
  }
package/src/hook-cli.mjs CHANGED
@@ -4,6 +4,40 @@ import path from 'node:path';
4
4
 
5
5
  import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
6
6
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
7
+ import {
8
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
9
+ } from './telemetry.mjs';
10
+
11
+ const PLUGIN_VERSION = '0.1';
12
+
13
+ function todayUtc() { return new Date().toISOString().slice(0, 10); }
14
+
15
+ /** Only counts (never content, paths, or IDs) — see docs/plans/2026-08-25-sando-telemetry-design.md.
16
+ * `enforce` covers both `apply` and `dry-run`: both walk the real rewrite path, only
17
+ * `observe` collects without deciding anything. */
18
+ function recordHookTelemetry({ host, env, policy, optimization }) {
19
+ const configPath = defaultTelemetryConfigPath(env);
20
+ let config;
21
+ try { config = readTelemetryConfig(configPath); } catch { return; }
22
+ if (!config.enabled) return;
23
+ try {
24
+ const statePaths = defaultTelemetryStatePaths(env);
25
+ incrementCounter({
26
+ statePaths,
27
+ day: todayUtc(),
28
+ event: 'hook_summary',
29
+ host,
30
+ mode: policy.mode === 'observe' ? 'observe' : 'enforce',
31
+ deltas: {
32
+ toolCalls: 1,
33
+ redactions: optimization.stats.redactions,
34
+ cappedOutputs: optimization.artifact ? 1 : 0,
35
+ bytesSaved: Math.max(0, optimization.stats.inputBytes - optimization.stats.inlineBytes),
36
+ },
37
+ });
38
+ closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
39
+ } catch { /* telemetry is best-effort and must never affect hook output */ }
40
+ }
7
41
 
8
42
  function hookPolicy(env, host) {
9
43
  const policy = env.SANDO_POLICY
@@ -69,6 +103,7 @@ export function runHookCli({ host, env = process.env } = {}) {
69
103
  try {
70
104
  recordMetrics({ storagePath: defaultMetricsPath(env), host, event, optimization, receipt });
71
105
  } catch {}
106
+ recordHookTelemetry({ host, env, policy, optimization });
72
107
  if (host === 'codex' && policy.mode === 'apply' && env.SANDO_CODEX_FALLBACK === 'feedback') {
73
108
  process.stdout.write(`${JSON.stringify(buildCodexFallback({ optimization, cwd: event.cwd }))}\n`);
74
109
  return;
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // npm postinstall: one-time, local-only consent prompt. No network calls here —
3
+ // upload happens later, only if enabled, from the hook/proxy paths. Must never
4
+ // fail or hang an `npm install`: every path resolves, every error is swallowed.
5
+
6
+ import path from 'node:path';
7
+ import readline from 'node:readline/promises';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ import { CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, readTelemetryConfig } from './telemetry.mjs';
11
+
12
+ const CONSENT_PROMPT = 'Enable anonymous telemetry? Full details at: TELEMETRY.md [y/N] ';
13
+
14
+ export async function runPostinstall({
15
+ env = process.env, stdin = process.stdin, stdout = process.stdout,
16
+ readlineFactory = () => readline.createInterface({ input: stdin, output: stdout }),
17
+ } = {}) {
18
+ try {
19
+ if (env.SANDO_SKIP_TELEMETRY_PROMPT) return;
20
+ if (!stdin.isTTY || !stdout.isTTY) return; // CI, --ignore-scripts consumers, piped installs, etc.
21
+
22
+ const configPath = defaultTelemetryConfigPath(env);
23
+ const current = readTelemetryConfig(configPath);
24
+ if (current.prompted_consent_version >= CONSENT_VERSION) return; // never re-ask on reinstall/upgrade
25
+
26
+ const rl = readlineFactory();
27
+ let answer;
28
+ try {
29
+ answer = await rl.question(CONSENT_PROMPT);
30
+ } finally {
31
+ rl.close();
32
+ }
33
+ const result = enableTelemetry({ configPath, interactive: true, answer });
34
+ stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
35
+ } catch {
36
+ // A postinstall script must never fail `npm install`.
37
+ }
38
+ }
39
+
40
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
41
+ runPostinstall().finally(() => process.exit(0));
42
+ }
@@ -148,7 +148,7 @@ function validateState(value) {
148
148
  return value;
149
149
  }
150
150
 
151
- function ensureDirectory(directory) {
151
+ export function ensureDirectory(directory) {
152
152
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
153
153
  const stat = fs.lstatSync(directory);
154
154
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('provider usage directory is unsafe');
@@ -156,7 +156,7 @@ function ensureDirectory(directory) {
156
156
  }
157
157
 
158
158
  function waitForLock() { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS); }
159
- function withLock(lockPath, operation) {
159
+ export function withLock(lockPath, operation) {
160
160
  let handle;
161
161
  for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
162
162
  try { handle = fs.openSync(lockPath, 'wx', 0o600); break; }
@@ -171,7 +171,7 @@ function withLock(lockPath, operation) {
171
171
  try { return operation(); } finally { fs.closeSync(handle); fs.rmSync(lockPath, { force: true }); }
172
172
  }
173
173
 
174
- function atomicWrite(filePath, value) {
174
+ export function atomicWrite(filePath, value) {
175
175
  const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
176
176
  fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
177
177
  try { fs.renameSync(temporary, filePath); } catch (error) { fs.rmSync(temporary, { force: true }); throw error; }
package/src/proxy.mjs CHANGED
@@ -1,7 +1,45 @@
1
1
  import http from 'node:http';
2
2
 
3
+ import { estimateTokens } from './core.mjs';
3
4
  import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
4
5
  import { recordProxyRequest } from './proxy-metrics.mjs';
6
+ import {
7
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
8
+ } from './telemetry.mjs';
9
+
10
+ const PLUGIN_VERSION = '0.1';
11
+
12
+ function todayUtc() { return new Date().toISOString().slice(0, 10); }
13
+
14
+ /** Only counts (never request/response content) — see docs/plans/2026-08-25-sando-telemetry-design.md.
15
+ * `host` here is the provider request shape (`anthropic`/`openai`), which does not map cleanly to
16
+ * Sando's `claude`/`codex` telemetry host enum; the proxy fronts both hosts equally, so it reports
17
+ * under a fixed `claude` label — this is the one place the design doc's host/provider split is
18
+ * approximate, flagged for the design doc rather than silently assumed. */
19
+ function recordProxyTelemetry({ env, transformed, beforeText, afterText }) {
20
+ const configPath = defaultTelemetryConfigPath(env);
21
+ let config;
22
+ try { config = readTelemetryConfig(configPath); } catch { return; }
23
+ if (!config.enabled) return;
24
+ const cacheWarm = transformed.stats.cacheRewriteRatio !== null;
25
+ try {
26
+ const statePaths = defaultTelemetryStatePaths(env);
27
+ incrementCounter({
28
+ statePaths,
29
+ day: todayUtc(),
30
+ event: 'proxy_summary',
31
+ host: 'claude',
32
+ deltas: {
33
+ rewritesApplied: transformed.changed ? 1 : 0,
34
+ rewritesSkippedCache: transformed.stats.cacheProtectedSkips > 0 ? 1 : 0,
35
+ inputTokensSaved: Math.max(0, estimateTokens(beforeText) - estimateTokens(afterText)),
36
+ cacheHitYes: cacheWarm ? 1 : 0,
37
+ cacheHitNo: cacheWarm ? 0 : 1,
38
+ },
39
+ });
40
+ closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
41
+ } catch { /* telemetry is best-effort and must never affect the proxied response */ }
42
+ }
5
43
 
6
44
  const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
7
45
  const HOP_BY_HOP_HEADERS = new Set([
@@ -130,7 +168,7 @@ async function observeSemanticCandidates({ provider, candidates, semanticCompact
130
168
  }
131
169
  }
132
170
 
133
- export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath } = {}) {
171
+ export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath, env = process.env } = {}) {
134
172
  const upstreamUrl = assertUpstream(upstream);
135
173
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('port is invalid');
136
174
  if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1024) throw new TypeError('maxBodyBytes is invalid');
@@ -158,6 +196,10 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
158
196
  lastRequestAt = now;
159
197
  const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
160
198
  if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
199
+ recordProxyTelemetry({
200
+ env, transformed,
201
+ beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
202
+ });
161
203
  lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
162
204
  recordProvider = provider;
163
205
  recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import {
8
+ defaultTelemetryConfigPath, defaultTelemetryStatePaths,
9
+ disableTelemetry, enableTelemetry, flushQueue, previewNextUpload, statusTelemetry,
10
+ } from './telemetry.mjs';
11
+
12
+ const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
13
+ const CONSENT_PROMPT = 'Enable anonymous telemetry? Full details at: TELEMETRY.md [y/N] ';
14
+
15
+ async function defaultPrompt(message) {
16
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
17
+ try { return await rl.question(message); } finally { rl.close(); }
18
+ }
19
+
20
+ export async function runTelemetryCli({
21
+ argv = process.argv.slice(2), env = process.env, stdout = process.stdout, stderr = process.stderr,
22
+ configPath = defaultTelemetryConfigPath(env), statePaths = defaultTelemetryStatePaths(env),
23
+ interactive = Boolean(process.stdin.isTTY), prompt = defaultPrompt,
24
+ } = {}) {
25
+ const [command, ...rest] = argv;
26
+ try {
27
+ if (command === 'status') {
28
+ const config = statusTelemetry(configPath);
29
+ stdout.write(`telemetry: ${config.enabled ? 'enabled' : 'disabled'}\n`);
30
+ return config;
31
+ }
32
+ if (command === 'enable') {
33
+ if (!interactive) {
34
+ stderr.write('sando telemetry: enable requires an interactive session\n');
35
+ return enableTelemetry({ configPath, interactive: false });
36
+ }
37
+ const answer = await prompt(CONSENT_PROMPT);
38
+ const result = enableTelemetry({
39
+ configPath, interactive: true,
40
+ answer: /^(y|yes)$/i.test((answer ?? '').trim()) ? 'yes' : answer,
41
+ });
42
+ stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
43
+ return result;
44
+ }
45
+ if (command === 'disable') {
46
+ const result = disableTelemetry({ configPath, statePaths, purge: rest.includes('--purge') });
47
+ stdout.write('telemetry disabled.\n');
48
+ return result;
49
+ }
50
+ if (command === 'preview') {
51
+ const config = statusTelemetry(configPath);
52
+ const preview = previewNextUpload({ statePaths, endpoint: config.endpoint });
53
+ stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
54
+ return preview;
55
+ }
56
+ if (command === 'flush') {
57
+ const config = statusTelemetry(configPath);
58
+ if (!config.enabled) {
59
+ stdout.write('telemetry is disabled; nothing to flush.\n');
60
+ return { sent: 0 };
61
+ }
62
+ const result = await flushQueue({ statePaths, endpoint: config.endpoint });
63
+ stdout.write(`flushed ${result.sent} row(s).\n`);
64
+ return result;
65
+ }
66
+ stdout.write(USAGE);
67
+ return null;
68
+ } catch (error) {
69
+ stderr.write(`sando telemetry: ${error instanceof Error ? error.message : String(error)}\n`);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
75
+ await runTelemetryCli();
76
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Detached flush entrypoint: invoked with --queue and --config, nothing else.
3
+ // Never inherits provider credential env vars or contacts anything but the configured endpoint.
4
+
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ import { flushQueue, readTelemetryConfig } from './telemetry.mjs';
9
+
10
+ function option(argv, name) {
11
+ const index = argv.indexOf(`--${name}`);
12
+ return index === -1 ? undefined : argv[index + 1];
13
+ }
14
+
15
+ export async function runTelemetryFlushEntry({ argv = process.argv.slice(2) } = {}) {
16
+ const queuePath = option(argv, 'queue');
17
+ const configPath = option(argv, 'config');
18
+ if (!queuePath || !configPath) throw new Error('telemetry-flush-entry requires --queue and --config');
19
+ const config = readTelemetryConfig(configPath);
20
+ if (!config.enabled) return { sent: 0 };
21
+ return flushQueue({ statePaths: { queue: queuePath }, endpoint: config.endpoint });
22
+ }
23
+
24
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
25
+ await runTelemetryFlushEntry();
26
+ }
@@ -0,0 +1,357 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { atomicWrite, ensureDirectory, withLock } from './provider-usage.mjs';
8
+
9
+ const SCHEMA_VERSION = 1;
10
+ const MAX_STRING_LENGTH = 32;
11
+ const MAX_EVENT_BYTES = 2048;
12
+
13
+ const COUNT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', 'gt_20'];
14
+ const BYTE_BUCKETS = ['lt_4k', '4_to_16k', '16_to_64k', 'gte_64k'];
15
+ const HOSTS = ['claude', 'codex'];
16
+ const MODES = ['enforce', 'observe'];
17
+ const YES_NO_UNKNOWN = ['yes', 'no', 'unknown'];
18
+
19
+ const SHARED_FIELDS = {
20
+ schema_version: (value) => value === SCHEMA_VERSION,
21
+ event: (value) => value === 'hook_summary' || value === 'proxy_summary',
22
+ day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
23
+ plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+$/.test(value) && value.length <= MAX_STRING_LENGTH,
24
+ host: (value) => HOSTS.includes(value),
25
+ };
26
+
27
+ const HOOK_FIELDS = {
28
+ mode: (value) => MODES.includes(value),
29
+ tool_calls_bucket: (value) => COUNT_BUCKETS.includes(value),
30
+ redactions_bucket: (value) => COUNT_BUCKETS.includes(value),
31
+ capped_outputs_bucket: (value) => COUNT_BUCKETS.includes(value),
32
+ bytes_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
33
+ };
34
+
35
+ const PROXY_FIELDS = {
36
+ rewrites_applied_bucket: (value) => COUNT_BUCKETS.includes(value),
37
+ rewrites_skipped_cache_bucket: (value) => COUNT_BUCKETS.includes(value),
38
+ input_tokens_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
39
+ prompt_cache_hit: (value) => YES_NO_UNKNOWN.includes(value),
40
+ };
41
+
42
+ function fieldsForEvent(eventType) {
43
+ return eventType === 'hook_summary' ? HOOK_FIELDS : PROXY_FIELDS;
44
+ }
45
+
46
+ export function countBucket(count) {
47
+ if (!Number.isInteger(count) || count < 0) throw new Error('countBucket: invalid count');
48
+ if (count === 0) return 'zero';
49
+ if (count === 1) return 'one';
50
+ if (count <= 5) return '2_to_5';
51
+ if (count <= 20) return '6_to_20';
52
+ return 'gt_20';
53
+ }
54
+
55
+ export function byteBucket(bytes) {
56
+ if (!Number.isInteger(bytes) || bytes < 0) throw new Error('byteBucket: invalid byte count');
57
+ if (bytes < 4096) return 'lt_4k';
58
+ if (bytes < 16384) return '4_to_16k';
59
+ if (bytes < 65536) return '16_to_64k';
60
+ return 'gte_64k';
61
+ }
62
+
63
+ export function validateEvent(payload) {
64
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('event must be an object');
65
+ if (!SHARED_FIELDS.event(payload.event)) throw new Error('event: unknown event type');
66
+ const allowed = { ...SHARED_FIELDS, ...fieldsForEvent(payload.event) };
67
+ for (const key of Object.keys(payload)) {
68
+ if (!Object.hasOwn(allowed, key)) throw new Error(`unknown field: ${key}`);
69
+ }
70
+ for (const [key, check] of Object.entries(allowed)) {
71
+ if (!Object.hasOwn(payload, key)) throw new Error(`missing field: ${key}`);
72
+ if (typeof payload[key] === 'string' && payload[key].length > MAX_STRING_LENGTH) throw new Error(`${key}: string too long`);
73
+ if (!check(payload[key])) throw new Error(`${key}: invalid value`);
74
+ }
75
+ return payload;
76
+ }
77
+
78
+ export function serializeEvent(payload) {
79
+ validateEvent(payload);
80
+ const serialized = JSON.stringify(payload);
81
+ if (Buffer.byteLength(serialized) > MAX_EVENT_BYTES) throw new Error('event exceeds serialized size limit');
82
+ return serialized;
83
+ }
84
+
85
+ export const TELEMETRY_CONFIG_VERSION = 1;
86
+ export const CONSENT_VERSION = 1;
87
+ // Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
88
+ // reachable from any of the owner's machines (see
89
+ // session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
90
+ // Release/broader publication is still gated on the open items in
91
+ // session-handoff/docs/telemetry-canary-report.md.
92
+ export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
93
+
94
+ function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
95
+
96
+ function emptyTelemetryConfig() {
97
+ return { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0 };
98
+ }
99
+
100
+ function validateTelemetryConfig(value) {
101
+ if (!record(value) || value.schema_version !== TELEMETRY_CONFIG_VERSION || typeof value.enabled !== 'boolean'
102
+ || !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0) {
103
+ throw new Error('telemetry config is invalid');
104
+ }
105
+ if (value.enabled) {
106
+ if (!Number.isInteger(value.consent_version) || value.consent_version < 1
107
+ || typeof value.consented_at !== 'string' || Number.isNaN(Date.parse(value.consented_at))
108
+ || typeof value.endpoint !== 'string' || !value.endpoint) throw new Error('telemetry config is invalid');
109
+ }
110
+ return value;
111
+ }
112
+
113
+ export function defaultTelemetryConfigPath(env = process.env) {
114
+ const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
115
+ if (!path.isAbsolute(configHome)) throw new Error('config directory must be absolute');
116
+ return path.join(configHome, 'sando', 'telemetry.json');
117
+ }
118
+
119
+ export function defaultTelemetryStatePaths(env = process.env) {
120
+ const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
121
+ if (!path.isAbsolute(stateHome)) throw new Error('state directory must be absolute');
122
+ const directory = path.join(stateHome, 'sando');
123
+ return { counters: path.join(directory, 'telemetry-counters.json'), queue: path.join(directory, 'telemetry-queue.jsonl') };
124
+ }
125
+
126
+ export function readTelemetryConfig(configPath = defaultTelemetryConfigPath()) {
127
+ if (!fs.existsSync(configPath)) return emptyTelemetryConfig();
128
+ return validateTelemetryConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
129
+ }
130
+
131
+ function writeTelemetryConfig(configPath, config) {
132
+ validateTelemetryConfig(config);
133
+ ensureDirectory(path.dirname(configPath));
134
+ withLock(`${configPath}.lock`, () => atomicWrite(configPath, config));
135
+ return config;
136
+ }
137
+
138
+ export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
139
+ return readTelemetryConfig(configPath);
140
+ }
141
+
142
+ /** Only an explicit `yes` in an interactive session enables collection; anything else
143
+ * (blank, `no`, EOF, or a non-interactive caller) writes the disabled prompt marker so
144
+ * upgrades and reinstalls never re-prompt or silently opt a user in. */
145
+ export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
146
+ if (!interactive || typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
147
+ return writeTelemetryConfig(configPath, { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: CONSENT_VERSION });
148
+ }
149
+ return writeTelemetryConfig(configPath, {
150
+ schema_version: TELEMETRY_CONFIG_VERSION,
151
+ enabled: true,
152
+ prompted_consent_version: CONSENT_VERSION,
153
+ consent_version: CONSENT_VERSION,
154
+ consented_at: now().toISOString(),
155
+ endpoint: TELEMETRY_ENDPOINT,
156
+ });
157
+ }
158
+
159
+ const QUEUE_MAX_ROWS = 256;
160
+ const QUEUE_MAX_BYTES = 256 * 1024;
161
+ const DEFAULT_BATCH_MAX = 32;
162
+
163
+ function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {} }; }
164
+ function readCounters(countersPath) {
165
+ if (!fs.existsSync(countersPath)) return emptyCounters();
166
+ return JSON.parse(fs.readFileSync(countersPath, 'utf8'));
167
+ }
168
+
169
+ function readQueueRows(queuePath) {
170
+ if (!fs.existsSync(queuePath)) return [];
171
+ return fs.readFileSync(queuePath, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
172
+ }
173
+
174
+ function writeQueueRows(queuePath, rows) {
175
+ ensureDirectory(path.dirname(queuePath));
176
+ const temporary = path.join(path.dirname(queuePath), `.${path.basename(queuePath)}.${process.pid}.tmp`);
177
+ const content = rows.length ? `${rows.map((row) => JSON.stringify(row)).join('\n')}\n` : '';
178
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
179
+ try { fs.renameSync(temporary, queuePath); } catch (error) { fs.rmSync(temporary, { force: true }); throw error; }
180
+ fs.chmodSync(queuePath, 0o600);
181
+ }
182
+
183
+ /** Enforces the bounded queue (256 rows / 256 KiB), dropping the oldest rows first —
184
+ * a telemetry backlog must never grow without bound or block product behavior. */
185
+ function appendQueueRows(queuePath, newRows) {
186
+ withLock(`${queuePath}.lock`, () => {
187
+ let rows = [...readQueueRows(queuePath), ...newRows];
188
+ if (rows.length > QUEUE_MAX_ROWS) rows = rows.slice(rows.length - QUEUE_MAX_ROWS);
189
+ while (rows.length > 0 && Buffer.byteLength(rows.map((row) => JSON.stringify(row)).join('\n')) > QUEUE_MAX_BYTES) rows.shift();
190
+ writeQueueRows(queuePath, rows);
191
+ });
192
+ }
193
+
194
+ function majorityCacheHit(entry) {
195
+ const yes = entry.cacheHitYes ?? 0;
196
+ const no = entry.cacheHitNo ?? 0;
197
+ const unknown = entry.cacheHitUnknown ?? 0;
198
+ if (yes > no && yes >= unknown) return 'yes';
199
+ if (no > yes && no >= unknown) return 'no';
200
+ return 'unknown';
201
+ }
202
+
203
+ function bucketEntry(entry, pluginVersion) {
204
+ if (entry.event === 'hook_summary') {
205
+ return {
206
+ schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
207
+ host: entry.host, mode: entry.mode,
208
+ tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
209
+ redactions_bucket: countBucket(entry.redactions ?? 0),
210
+ capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
211
+ bytes_saved_bucket: byteBucket(entry.bytesSaved ?? 0),
212
+ };
213
+ }
214
+ return {
215
+ schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
216
+ host: entry.host,
217
+ rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
218
+ rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
219
+ input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
220
+ prompt_cache_hit: majorityCacheHit(entry),
221
+ };
222
+ }
223
+
224
+ /** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
225
+ * only ever leave the machine) once `closeDay` closes a finished UTC day. */
226
+ export function incrementCounter({ statePaths, day, event, host, mode, deltas = {} }) {
227
+ if (!['hook_summary', 'proxy_summary'].includes(event)) throw new Error('incrementCounter: invalid event');
228
+ const key = `${day}|${event}|${host}|${mode ?? ''}`;
229
+ ensureDirectory(path.dirname(statePaths.counters));
230
+ withLock(`${statePaths.counters}.lock`, () => {
231
+ const state = readCounters(statePaths.counters);
232
+ const existing = state.counters[key] ?? { day, event, host, mode: mode ?? null };
233
+ for (const [field, value] of Object.entries(deltas)) {
234
+ if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
235
+ existing[field] = (existing[field] ?? 0) + value;
236
+ }
237
+ state.counters[key] = existing;
238
+ atomicWrite(statePaths.counters, state);
239
+ });
240
+ }
241
+
242
+ /** Closes a finished UTC day: buckets its raw counters into daily_aggregate rows,
243
+ * appends them to the upload queue, and clears them from the raw counter file so a
244
+ * day is never counted twice. */
245
+ export function closeDay({ statePaths, day, pluginVersion }) {
246
+ const closedRows = [];
247
+ withLock(`${statePaths.counters}.lock`, () => {
248
+ const state = readCounters(statePaths.counters);
249
+ const remaining = {};
250
+ for (const [key, entry] of Object.entries(state.counters)) {
251
+ if (entry.day !== day) { remaining[key] = entry; continue; }
252
+ closedRows.push(validateEvent(bucketEntry(entry, pluginVersion)));
253
+ }
254
+ state.counters = remaining;
255
+ ensureDirectory(path.dirname(statePaths.counters));
256
+ atomicWrite(statePaths.counters, state);
257
+ });
258
+ if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
259
+ return closedRows;
260
+ }
261
+
262
+ function launchDetachedFlush({ statePaths, configPath, spawnImpl }) {
263
+ try {
264
+ const entryPath = fileURLToPath(new URL('./telemetry-flush-entry.mjs', import.meta.url));
265
+ const child = spawnImpl(process.execPath, [entryPath, '--queue', statePaths.queue, '--config', configPath], {
266
+ detached: true, env: {}, stdio: 'ignore', windowsHide: true,
267
+ });
268
+ child.unref();
269
+ } catch { /* telemetry must never affect the caller */ }
270
+ }
271
+
272
+ /** Closes every raw counter day before `day` and starts one detached uploader.
273
+ * The child receives only local state paths and an empty environment. */
274
+ export function closeFinishedDays({
275
+ statePaths, configPath = defaultTelemetryConfigPath(), day, pluginVersion,
276
+ spawnImpl = spawn,
277
+ } = {}) {
278
+ const days = new Set();
279
+ if (fs.existsSync(statePaths.counters)) {
280
+ const state = readCounters(statePaths.counters);
281
+ for (const entry of Object.values(state.counters)) {
282
+ if (typeof entry.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(entry.day) && entry.day < day) days.add(entry.day);
283
+ }
284
+ }
285
+ const closedRows = [];
286
+ for (const closedDay of [...days].sort()) {
287
+ closedRows.push(...closeDay({ statePaths, day: closedDay, pluginVersion }));
288
+ }
289
+ if (closedRows.length) launchDetachedFlush({ statePaths, configPath, spawnImpl });
290
+ return closedRows;
291
+ }
292
+
293
+ export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX } = {}) {
294
+ return readQueueRows(statePaths.queue).slice(0, max);
295
+ }
296
+
297
+ export function ackBatch({ statePaths, count }) {
298
+ withLock(`${statePaths.queue}.lock`, () => {
299
+ const rows = readQueueRows(statePaths.queue);
300
+ writeQueueRows(statePaths.queue, rows.slice(count));
301
+ });
302
+ }
303
+
304
+ export function toOtlpLogs(rows) {
305
+ return {
306
+ resourceLogs: [{
307
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'sando' } }] },
308
+ scopeLogs: [{
309
+ logRecords: rows.map((row) => ({
310
+ body: { stringValue: 'sando.daily_aggregate' },
311
+ attributes: Object.entries(row).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
312
+ })),
313
+ }],
314
+ }],
315
+ };
316
+ }
317
+
318
+ export function previewNextUpload({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX } = {}) {
319
+ const rows = loadBatch({ statePaths, max });
320
+ return { url: endpoint, headers: { 'content-type': 'application/json' }, body: toOtlpLogs(rows) };
321
+ }
322
+
323
+ /** Uploads at most one batch. Every failure mode (timeout, network error, non-2xx) is
324
+ * swallowed and reported as `sent: 0` — telemetry must never throw into, or change the
325
+ * outcome of, the hook or proxy call that triggered a day close. */
326
+ export async function flushQueue({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000 } = {}) {
327
+ const rows = loadBatch({ statePaths, max });
328
+ if (rows.length === 0) return { sent: 0 };
329
+ const controller = new AbortController();
330
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
331
+ try {
332
+ const response = await fetch(endpoint, {
333
+ method: 'POST', headers: { 'content-type': 'application/json' },
334
+ body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
335
+ });
336
+ if (!response.ok) return { sent: 0 };
337
+ ackBatch({ statePaths, count: rows.length });
338
+ return { sent: rows.length };
339
+ } catch {
340
+ return { sent: 0 };
341
+ } finally {
342
+ clearTimeout(timer);
343
+ }
344
+ }
345
+
346
+ export function disableTelemetry({ configPath = defaultTelemetryConfigPath(), purge = false, statePaths = defaultTelemetryStatePaths() } = {}) {
347
+ const previous = readTelemetryConfig(configPath);
348
+ const result = writeTelemetryConfig(configPath, {
349
+ schema_version: TELEMETRY_CONFIG_VERSION,
350
+ enabled: false,
351
+ prompted_consent_version: previous.prompted_consent_version || CONSENT_VERSION,
352
+ });
353
+ if (purge) {
354
+ for (const target of [statePaths.counters, statePaths.queue]) fs.rmSync(target, { force: true });
355
+ }
356
+ return result;
357
+ }