@thispointon/kondi-chat 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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Audit Ledger — append-only log of every LLM call and verification.
3
+ *
4
+ * Records what was sent, what came back, which model handled it,
5
+ * how much it cost, and what task it was for. Persisted to disk
6
+ * alongside the session for full auditability.
7
+ */
8
+
9
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import type { LedgerEntry, LedgerPhase, LLMResponse, ProviderId } from '../types.ts';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Pricing table (USD per 1M tokens)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Pricing per 1M tokens.
19
+ * `cachedInput` is the discounted rate for prompt-cached bytes — typically
20
+ * 10% of standard (Anthropic) or 50% (OpenAI/Z.AI). If omitted, we use the
21
+ * standard input rate as a conservative fallback.
22
+ */
23
+ const PRICING: Record<string, { input: number; output: number; cachedInput?: number }> = {
24
+ // Anthropic (cache-read priced at ~10% of input on current plans)
25
+ // Opus 4.6 dropped to $5/$25 (was $15/$75). Sonnet 4.6 stays $3/$15.
26
+ 'claude-opus-4-20250514': { input: 5, output: 25, cachedInput: 0.50 },
27
+ 'claude-sonnet-4-5-20250929': { input: 3, output: 15, cachedInput: 0.30 },
28
+ 'claude-haiku-4-5-20251001': { input: 1, output: 5, cachedInput: 0.10 },
29
+ // OpenAI (cached reads at 50% of input)
30
+ 'gpt-5.4': { input: 2.5, output: 15, cachedInput: 1.25 },
31
+ 'gpt-5.4-mini': { input: 0.75, output: 4.5, cachedInput: 0.375 },
32
+ 'gpt-5.4-nano': { input: 0.20, output: 1.25, cachedInput: 0.10 },
33
+ 'gpt-4o': { input: 2.5, output: 10, cachedInput: 1.25 },
34
+ 'gpt-4o-mini': { input: 0.15, output: 0.6, cachedInput: 0.075 },
35
+ // DeepSeek — https://api-docs.deepseek.com/quick_start/pricing
36
+ // V4 Pro at 75% discount through May 31 2026.
37
+ // Cache hit reduced to 1/10 of launch price as of April 26 2026.
38
+ 'deepseek-v4-pro': { input: 0.435, output: 0.87, cachedInput: 0.003625 },
39
+ 'deepseek-v4-flash': { input: 0.14, output: 0.28, cachedInput: 0.0028 },
40
+ // Legacy names (being deprecated, map to v4-flash / v4-flash-reasoning)
41
+ 'deepseek-chat': { input: 0.14, output: 0.28, cachedInput: 0.0028 },
42
+ 'deepseek-reasoner': { input: 0.14, output: 0.28, cachedInput: 0.0028 },
43
+ // Google — https://ai.google.dev/gemini-api/docs/pricing
44
+ 'models/gemini-2.5-flash': { input: 0.30, output: 2.50, cachedInput: 0.03 },
45
+ 'models/gemini-2.5-pro': { input: 1.25, output: 10.0, cachedInput: 0.125 },
46
+ // xAI
47
+ 'grok-3': { input: 3, output: 15 },
48
+ // Z.AI GLM — https://docs.z.ai/guides/overview/pricing
49
+ 'glm-5.1': { input: 1.4, output: 4.4, cachedInput: 0.26 },
50
+ 'glm-5': { input: 1.0, output: 3.2, cachedInput: 0.20 },
51
+ 'glm-5-turbo': { input: 1.2, output: 4.0, cachedInput: 0.24 },
52
+ 'glm-4.7': { input: 0.6, output: 2.2, cachedInput: 0.11 },
53
+ 'glm-4.6': { input: 0.6, output: 2.2, cachedInput: 0.11 },
54
+ 'glm-4.5': { input: 0.6, output: 2.2, cachedInput: 0.11 },
55
+ 'glm-4.5-air': { input: 0.2, output: 1.1, cachedInput: 0.03 },
56
+ 'glm-4.5-flash': { input: 0, output: 0 },
57
+ 'glm-4.7-flash': { input: 0, output: 0 },
58
+ };
59
+
60
+ /**
61
+ * Estimate cost in USD.
62
+ * @param cachedInputTokens Portion of inputTokens served from prompt cache.
63
+ * Billed at the model's `cachedInput` rate (typically
64
+ * 10–50% of standard input) instead of `input`.
65
+ */
66
+ export function estimateCost(
67
+ model: string,
68
+ inputTokens: number,
69
+ outputTokens: number,
70
+ cachedInputTokens = 0,
71
+ ): number {
72
+ const p = PRICING[model] || { input: 3, output: 15 };
73
+ const cached = Math.min(cachedInputTokens, inputTokens);
74
+ const uncachedInput = inputTokens - cached;
75
+ const cachedRate = p.cachedInput ?? p.input;
76
+ return (uncachedInput * p.input + cached * cachedRate + outputTokens * p.output) / 1_000_000;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Ledger
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export class Ledger {
84
+ private entries: LedgerEntry[] = [];
85
+ private sessionId: string;
86
+ private storageDir?: string;
87
+
88
+ constructor(sessionId: string, storageDir?: string) {
89
+ this.sessionId = sessionId;
90
+ this.storageDir = storageDir;
91
+
92
+ if (storageDir) {
93
+ mkdirSync(storageDir, { recursive: true });
94
+ const existing = this.loadFromDisk();
95
+ if (existing) this.entries = existing;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Record an LLM call in the ledger.
101
+ */
102
+ record(
103
+ phase: LedgerPhase,
104
+ response: LLMResponse,
105
+ promptSummary: string,
106
+ opts?: { taskId?: string; promoted?: boolean },
107
+ ): LedgerEntry {
108
+ const cached = response.cachedInputTokens ?? 0;
109
+ const cost = estimateCost(response.model, response.inputTokens, response.outputTokens, cached);
110
+
111
+ const entry: LedgerEntry = {
112
+ id: `${this.sessionId}-${this.entries.length.toString().padStart(4, '0')}`,
113
+ timestamp: new Date().toISOString(),
114
+ phase,
115
+ provider: response.provider,
116
+ model: response.model,
117
+ inputTokens: response.inputTokens,
118
+ outputTokens: response.outputTokens,
119
+ latencyMs: response.latencyMs,
120
+ costUsd: cost,
121
+ cached: response.cached ?? cached > 0,
122
+ ...(cached > 0 ? { cachedInputTokens: cached } : {}),
123
+ promptSummary: truncate(promptSummary, 500),
124
+ responseSummary: truncate(response.content, 500),
125
+ taskId: opts?.taskId,
126
+ promoted: opts?.promoted,
127
+ };
128
+
129
+ this.entries.push(entry);
130
+ this.persistToDisk();
131
+
132
+ return entry;
133
+ }
134
+
135
+ /**
136
+ * Record a local verification step (no LLM call).
137
+ */
138
+ recordVerification(
139
+ taskId: string,
140
+ passed: boolean,
141
+ output: string,
142
+ ): LedgerEntry {
143
+ const entry: LedgerEntry = {
144
+ id: `${this.sessionId}-${this.entries.length.toString().padStart(4, '0')}`,
145
+ timestamp: new Date().toISOString(),
146
+ phase: 'verify',
147
+ provider: 'ollama' as ProviderId, // placeholder — no actual provider
148
+ model: 'local',
149
+ inputTokens: 0,
150
+ outputTokens: 0,
151
+ latencyMs: 0,
152
+ costUsd: 0,
153
+ cached: false,
154
+ promptSummary: `Verification for task ${taskId}`,
155
+ responseSummary: truncate(output, 500),
156
+ taskId,
157
+ };
158
+
159
+ this.entries.push(entry);
160
+ this.persistToDisk();
161
+
162
+ return entry;
163
+ }
164
+
165
+ // -------------------------------------------------------------------------
166
+ // Queries
167
+ // -------------------------------------------------------------------------
168
+
169
+ getAll(): LedgerEntry[] {
170
+ return [...this.entries];
171
+ }
172
+
173
+ getByPhase(phase: LedgerPhase): LedgerEntry[] {
174
+ return this.entries.filter(e => e.phase === phase);
175
+ }
176
+
177
+ getByTask(taskId: string): LedgerEntry[] {
178
+ return this.entries.filter(e => e.taskId === taskId);
179
+ }
180
+
181
+ getTotals(): {
182
+ calls: number;
183
+ inputTokens: number;
184
+ outputTokens: number;
185
+ costUsd: number;
186
+ byPhase: Record<string, { calls: number; inputTokens: number; outputTokens: number; costUsd: number }>;
187
+ byModel: Record<string, { calls: number; inputTokens: number; outputTokens: number; costUsd: number }>;
188
+ } {
189
+ const byPhase: Record<string, { calls: number; inputTokens: number; outputTokens: number; costUsd: number }> = {};
190
+ const byModel: Record<string, { calls: number; inputTokens: number; outputTokens: number; costUsd: number }> = {};
191
+
192
+ let totalInput = 0;
193
+ let totalOutput = 0;
194
+ let totalCost = 0;
195
+
196
+ for (const e of this.entries) {
197
+ totalInput += e.inputTokens;
198
+ totalOutput += e.outputTokens;
199
+ totalCost += e.costUsd;
200
+
201
+ if (!byPhase[e.phase]) byPhase[e.phase] = { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 };
202
+ byPhase[e.phase].calls++;
203
+ byPhase[e.phase].inputTokens += e.inputTokens;
204
+ byPhase[e.phase].outputTokens += e.outputTokens;
205
+ byPhase[e.phase].costUsd += e.costUsd;
206
+
207
+ if (!byModel[e.model]) byModel[e.model] = { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 };
208
+ byModel[e.model].calls++;
209
+ byModel[e.model].inputTokens += e.inputTokens;
210
+ byModel[e.model].outputTokens += e.outputTokens;
211
+ byModel[e.model].costUsd += e.costUsd;
212
+ }
213
+
214
+ return {
215
+ calls: this.entries.length,
216
+ inputTokens: totalInput,
217
+ outputTokens: totalOutput,
218
+ costUsd: totalCost,
219
+ byPhase,
220
+ byModel,
221
+ };
222
+ }
223
+
224
+ // -------------------------------------------------------------------------
225
+ // Persistence
226
+ // -------------------------------------------------------------------------
227
+
228
+ private persistToDisk(): void {
229
+ if (!this.storageDir) return;
230
+ const path = join(this.storageDir, `${this.sessionId}-ledger.json`);
231
+ writeFileSync(path, JSON.stringify(this.entries, null, 2));
232
+ }
233
+
234
+ private loadFromDisk(): LedgerEntry[] | null {
235
+ if (!this.storageDir) return null;
236
+ const path = join(this.storageDir, `${this.sessionId}-ledger.json`);
237
+ if (!existsSync(path)) return null;
238
+ try {
239
+ return JSON.parse(readFileSync(path, 'utf-8'));
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Helpers
248
+ // ---------------------------------------------------------------------------
249
+
250
+ function truncate(s: string, maxLen: number): string {
251
+ if (s.length <= maxLen) return s;
252
+ return s.slice(0, maxLen) + '...';
253
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Telemetry — opt-in, local-only, schema-enforced usage metrics.
3
+ *
4
+ * v1 does not ship any network code. Events accumulate in
5
+ * `.kondi-chat/telemetry.json` (separate from analytics.json). Users must
6
+ * explicitly run `/telemetry enable` before any event is recorded.
7
+ *
8
+ * The schema is a closed union; unknown fields are rejected at emit time to
9
+ * uphold the privacy claim. No prompts, paths, URLs, or free text allowed.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ export type FeatureId =
16
+ | 'session_started' | 'session_resumed' | 'undo_invoked'
17
+ | 'checkpoint_created' | 'checkpoint_restored'
18
+ | 'sub_agent_spawned' | 'hook_executed' | 'web_search'
19
+ | 'image_uploaded' | 'memory_loaded' | 'memory_updated'
20
+ | 'council_invoked' | 'non_interactive_run' | 'profile_changed';
21
+
22
+ export type ToolCategoryId =
23
+ | 'filesystem_read' | 'filesystem_write' | 'filesystem_edit'
24
+ | 'search_code' | 'run_command' | 'create_task' | 'update_plan'
25
+ | 'git' | 'web' | 'mcp' | 'council' | 'update_memory' | 'spawn_agent';
26
+
27
+ export type ErrorCategoryId =
28
+ | 'llm_timeout' | 'llm_rate_limit' | 'llm_auth'
29
+ | 'network' | 'permission_denied' | 'tool_error' | 'config_error'
30
+ | 'provider_fallback' | 'backend_crash';
31
+
32
+ export type TelemetryEvent =
33
+ | { kind: 'feature_used'; feature: FeatureId; timestamp: string }
34
+ | { kind: 'tool_called'; tool: ToolCategoryId; succeeded: boolean; timestamp: string }
35
+ | { kind: 'error_occurred'; category: ErrorCategoryId; recoverable: boolean; timestamp: string };
36
+
37
+ export type TelemetryState = 'disabled' | 'local-only' | 'remote-enabled';
38
+
39
+ interface PersistedTelemetry {
40
+ state: TelemetryState;
41
+ installationId?: string;
42
+ events: TelemetryEvent[];
43
+ }
44
+
45
+ const FEATURE_SET = new Set<FeatureId>([
46
+ 'session_started', 'session_resumed', 'undo_invoked',
47
+ 'checkpoint_created', 'checkpoint_restored',
48
+ 'sub_agent_spawned', 'hook_executed', 'web_search',
49
+ 'image_uploaded', 'memory_loaded', 'memory_updated',
50
+ 'council_invoked', 'non_interactive_run', 'profile_changed',
51
+ ]);
52
+
53
+ const TOOL_SET = new Set<ToolCategoryId>([
54
+ 'filesystem_read', 'filesystem_write', 'filesystem_edit',
55
+ 'search_code', 'run_command', 'create_task', 'update_plan',
56
+ 'git', 'web', 'mcp', 'council', 'update_memory', 'spawn_agent',
57
+ ]);
58
+
59
+ const ERROR_SET = new Set<ErrorCategoryId>([
60
+ 'llm_timeout', 'llm_rate_limit', 'llm_auth',
61
+ 'network', 'permission_denied', 'tool_error', 'config_error',
62
+ 'provider_fallback', 'backend_crash',
63
+ ]);
64
+
65
+ const MAX_EVENTS = 10_000;
66
+
67
+ export class TelemetryEmitter {
68
+ private path: string;
69
+ private data: PersistedTelemetry;
70
+
71
+ constructor(storageDir: string) {
72
+ this.path = join(storageDir, 'telemetry.json');
73
+ this.data = this.load();
74
+ // Environment override (Spec 15 clarifications): force disabled + delete.
75
+ if (process.env.KONDI_CHAT_NO_TELEMETRY === '1') {
76
+ this.data.state = 'disabled';
77
+ this.data.events = [];
78
+ this.save();
79
+ }
80
+ }
81
+
82
+ getState(): TelemetryState { return this.data.state; }
83
+
84
+ setState(state: TelemetryState): void {
85
+ this.data.state = state;
86
+ if (state === 'disabled') this.data.events = [];
87
+ this.save();
88
+ }
89
+
90
+ enable(): void { this.setState('local-only'); }
91
+ disable(): void { this.setState('disabled'); }
92
+
93
+ /** Record an event if telemetry is not disabled. Unknown fields are rejected. */
94
+ record(event: TelemetryEvent): void {
95
+ if (this.data.state === 'disabled') return;
96
+ if (!this.validate(event)) {
97
+ process.stderr.write(`[telemetry] rejected malformed event: ${JSON.stringify(event)}\n`);
98
+ return;
99
+ }
100
+ this.data.events.push(event);
101
+ if (this.data.events.length > MAX_EVENTS) {
102
+ this.data.events = this.data.events.slice(-MAX_EVENTS);
103
+ }
104
+ this.save();
105
+ }
106
+
107
+ export(): string {
108
+ return JSON.stringify(this.data, null, 2);
109
+ }
110
+
111
+ deleteAll(): void {
112
+ this.data.events = [];
113
+ this.save();
114
+ }
115
+
116
+ format(): string {
117
+ const lines = [`Telemetry state: ${this.data.state}`];
118
+ if (this.data.state === 'disabled') {
119
+ lines.push(' (no events recorded)');
120
+ lines.push(' enable with: /telemetry enable');
121
+ return lines.join('\n');
122
+ }
123
+ const counts: Record<string, number> = {};
124
+ for (const e of this.data.events) {
125
+ const key = e.kind === 'feature_used' ? `feature:${e.feature}`
126
+ : e.kind === 'tool_called' ? `tool:${e.tool}`
127
+ : `error:${e.category}`;
128
+ counts[key] = (counts[key] || 0) + 1;
129
+ }
130
+ lines.push(`Total events: ${this.data.events.length}`);
131
+ for (const [k, v] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
132
+ lines.push(` ${k.padEnd(40)} ${v}`);
133
+ }
134
+ return lines.join('\n');
135
+ }
136
+
137
+ private validate(event: TelemetryEvent): boolean {
138
+ switch (event.kind) {
139
+ case 'feature_used': return FEATURE_SET.has(event.feature);
140
+ case 'tool_called': return TOOL_SET.has(event.tool) && typeof event.succeeded === 'boolean';
141
+ case 'error_occurred': return ERROR_SET.has(event.category) && typeof event.recoverable === 'boolean';
142
+ }
143
+ return false;
144
+ }
145
+
146
+ private load(): PersistedTelemetry {
147
+ if (!existsSync(this.path)) {
148
+ return { state: 'disabled', events: [] };
149
+ }
150
+ try {
151
+ const raw = JSON.parse(readFileSync(this.path, 'utf-8'));
152
+ if (raw && typeof raw === 'object' && raw.state) return raw as PersistedTelemetry;
153
+ } catch { /* fall through */ }
154
+ return { state: 'disabled', events: [] };
155
+ }
156
+
157
+ private save(): void {
158
+ try {
159
+ mkdirSync(dirname(this.path), { recursive: true });
160
+ writeFileSync(this.path, JSON.stringify(this.data, null, 2));
161
+ } catch (e) {
162
+ process.stderr.write(`[telemetry] save failed: ${(e as Error).message}\n`);
163
+ }
164
+ }
165
+ }