@bigknoxy/hashpilot 4.6.3

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +777 -0
  3. package/docs/ADAPTER-CONTRACT.md +1260 -0
  4. package/docs/ARCHITECTURE.md +846 -0
  5. package/docs/CLI-QUICKREF.md +827 -0
  6. package/docs/COMPETITIVE-ANALYSIS.md +307 -0
  7. package/docs/INSTALL.md +403 -0
  8. package/docs/INTEGRATION-CLAUDE.md +126 -0
  9. package/docs/INTEGRATION-MCP.md +196 -0
  10. package/docs/INTEGRATION-OPENCODE.md +136 -0
  11. package/docs/INTEGRATION-PI.md +195 -0
  12. package/package.json +77 -0
  13. package/scripts/build-site.sh +39 -0
  14. package/scripts/doctor.sh +218 -0
  15. package/scripts/gen-cli-quickref.ts +232 -0
  16. package/scripts/install-cli.sh +60 -0
  17. package/scripts/install.sh +466 -0
  18. package/scripts/roadmap-lint.ts +200 -0
  19. package/scripts/uninstall.sh +202 -0
  20. package/src/cli-node.cjs +51 -0
  21. package/src/cli.ts +209 -0
  22. package/src/commands/ast.ts +255 -0
  23. package/src/commands/diff.ts +98 -0
  24. package/src/commands/edit.ts +93 -0
  25. package/src/commands/hash.ts +64 -0
  26. package/src/commands/intent.ts +68 -0
  27. package/src/commands/maintenance.ts +191 -0
  28. package/src/commands/mcp.ts +28 -0
  29. package/src/commands/provenance.ts +111 -0
  30. package/src/commands/read.ts +117 -0
  31. package/src/commands/route.ts +42 -0
  32. package/src/commands/shared.ts +65 -0
  33. package/src/commands/telemetry.ts +126 -0
  34. package/src/commands/verify.ts +61 -0
  35. package/src/core/ast-edit.ts +2357 -0
  36. package/src/core/batch-edit.ts +185 -0
  37. package/src/core/config.ts +189 -0
  38. package/src/core/diff-engine.ts +474 -0
  39. package/src/core/doctor.ts +303 -0
  40. package/src/core/encoding.ts +116 -0
  41. package/src/core/envelope.ts +163 -0
  42. package/src/core/exit-codes.ts +198 -0
  43. package/src/core/format.ts +339 -0
  44. package/src/core/grep.ts +180 -0
  45. package/src/core/hash-edit.ts +416 -0
  46. package/src/core/index.ts +155 -0
  47. package/src/core/intent.ts +584 -0
  48. package/src/core/locking.ts +292 -0
  49. package/src/core/module-system.ts +142 -0
  50. package/src/core/operations.ts +557 -0
  51. package/src/core/output.ts +122 -0
  52. package/src/core/path-normalize.ts +61 -0
  53. package/src/core/paths.ts +326 -0
  54. package/src/core/plan-executor.ts +437 -0
  55. package/src/core/platform.ts +132 -0
  56. package/src/core/provenance.ts +214 -0
  57. package/src/core/read.ts +111 -0
  58. package/src/core/redact.ts +98 -0
  59. package/src/core/resolve-content.ts +12 -0
  60. package/src/core/router.ts +463 -0
  61. package/src/core/snapshot.ts +346 -0
  62. package/src/core/telemetry.ts +838 -0
  63. package/src/core/utils.ts +7 -0
  64. package/src/core/verify-baseline.ts +186 -0
  65. package/src/core/verify-scope.ts +282 -0
  66. package/src/core/verify.ts +753 -0
  67. package/src/mcp/server.ts +325 -0
  68. package/templates/claude-section.md +12 -0
  69. package/templates/opencode-agent.md +106 -0
  70. package/templates/opencode-skill.md +241 -0
  71. package/templates/pi-extension.ts +288 -0
  72. package/templates/pi-skill.md +123 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,838 @@
1
+ import { mkdirSync, appendFileSync, readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync, readdirSync, chmodSync } from "fs";
2
+ import { join } from "path";
3
+ import type { TelemetryConfig } from "./config";
4
+ import { redactEvent } from "./redact";
5
+ import { createHash } from "crypto";
6
+
7
+ const LOG_DIR = join(process.env.HOME || "/root", ".agentic-tools", "logs");
8
+ const LOG_FILE = join(LOG_DIR, "telemetry.jsonl");
9
+ const ROTATED_FILE_RE = /^telemetry-(\d{4}-\d{2}-\d{2})(?:-\d+)?\.jsonl$/;
10
+
11
+ // Configurable defaults
12
+ export let MAX_FILE_SIZE = 10 * 1024 * 1024;
13
+ export let MAX_ROTATED_FILES = 10;
14
+ export let RETENTION_DAYS = 30;
15
+ /**
16
+ * Cap on one serialized record. A captured diff is unbounded — one edit to a
17
+ * large file used to write megabytes into a line of the log, which made the
18
+ * log expensive to read, unbounded between rotation checks, and impossible to
19
+ * stream (#20). Oversized payloads move to a content-addressed store beside
20
+ * the log and the record keeps only the hash.
21
+ */
22
+ export let MAX_RECORD_BYTES = 4096;
23
+
24
+ export function configureTelemetry(cfg: TelemetryConfig | undefined): void {
25
+ if (!cfg) return;
26
+ if (cfg.maxFileSize !== undefined) MAX_FILE_SIZE = cfg.maxFileSize;
27
+ if (cfg.maxRotatedFiles !== undefined) MAX_ROTATED_FILES = cfg.maxRotatedFiles;
28
+ if (cfg.retentionDays !== undefined) RETENTION_DAYS = cfg.retentionDays;
29
+ if (cfg.maxRecordBytes !== undefined) MAX_RECORD_BYTES = cfg.maxRecordBytes;
30
+ // `enabled` used to be parsed and then ignored, so opting out via config did
31
+ // nothing. It is the lowest-priority switch: env and CLI still override it.
32
+ if (cfg.enabled !== undefined) sessionEnabled = cfg.enabled;
33
+ }
34
+
35
+ /**
36
+ * Resolve the telemetry kill switch. Precedence, highest first:
37
+ * CLI `--no-telemetry` > `HASHPILOT_TELEMETRY=0` > config `telemetry.enabled` > on.
38
+ */
39
+ export function resolveTelemetryEnabled(cfg: TelemetryConfig | undefined, cliDisabled: boolean): boolean {
40
+ if (cliDisabled) return false;
41
+ const env = process.env.HASHPILOT_TELEMETRY;
42
+ if (env !== undefined && ["0", "false", "off", "no"].includes(env.trim().toLowerCase())) return false;
43
+ if (cfg?.enabled !== undefined) return cfg.enabled;
44
+ return true;
45
+ }
46
+
47
+ export enum ErrorCode {
48
+ STALE_ANCHOR = "STALE_ANCHOR",
49
+ SYMBOL_NOT_FOUND = "SYMBOL_NOT_FOUND",
50
+ PARSE_ERROR = "PARSE_ERROR",
51
+ FILE_NOT_FOUND = "FILE_NOT_FOUND",
52
+ DUPLICATE_MATCH = "DUPLICATE_MATCH",
53
+ UNSUPPORTED_LANGUAGE = "UNSUPPORTED_LANGUAGE",
54
+ HASH_MISMATCH = "HASH_MISMATCH",
55
+ WRITE_FAILED = "WRITE_FAILED",
56
+ /** Write target is outside the project root or on the hard-deny list. */
57
+ PATH_DENIED = "PATH_DENIED",
58
+ /** A flag or argument was malformed (bad --range, non-numeric value). */
59
+ INVALID_ARGUMENT = "INVALID_ARGUMENT",
60
+ /** The requested operation exists in the CLI surface but is not implemented for this input. */
61
+ UNSUPPORTED_OPERATION = "UNSUPPORTED_OPERATION",
62
+ /** The edit applied but format/lint/test verification failed. */
63
+ VERIFY_FAILED = "VERIFY_FAILED",
64
+ /**
65
+ * A verification check was killed at its timeout. Distinct from
66
+ * VERIFY_FAILED: the check never reached a verdict, so it is evidence of
67
+ * nothing about the edit and must not trigger a revert on its own.
68
+ */
69
+ VERIFY_TIMEOUT = "VERIFY_TIMEOUT",
70
+ /** verify-changes ran no checks at all, so it verified nothing (#106). */
71
+ VERIFY_NO_CHECKS = "VERIFY_NO_CHECKS",
72
+ /**
73
+ * A rollback ran but could not restore every file, so the tree is left in a
74
+ * state that is neither the original nor the intended result. Strictly more
75
+ * serious than the failure that triggered the rollback — see
76
+ * `PlanResult.unrevertedFiles` for the files still holding edits.
77
+ */
78
+ ROLLBACK_INCOMPLETE = "ROLLBACK_INCOMPLETE",
79
+ /** The anchor could not be relocated unambiguously (multiple candidate matches). */
80
+ AMBIGUOUS_ANCHOR = "AMBIGUOUS_ANCHOR",
81
+ /**
82
+ * rename-symbol was asked to rename a name that binds **more than one**
83
+ * distinct symbol in the file — a shadowed local, a foreign import of the
84
+ * same name, or two top-level declarations. A file-wide textual rename would
85
+ * clobber a binding the caller did not mean to touch, so the operation
86
+ * refuses and names the contending binding sites.
87
+ */
88
+ AMBIGUOUS_SYMBOL = "AMBIGUOUS_SYMBOL",
89
+ /**
90
+ * An AST search hit the runaway depth guard before it finished, so "not
91
+ * found" would be a claim the search never earned. Distinct from
92
+ * SYMBOL_NOT_FOUND: the symbol may well exist below the cap (#39).
93
+ */
94
+ SEARCH_TRUNCATED = "SEARCH_TRUNCATED",
95
+ /** A file exists but could not be read (permissions, device error). Distinct from FILE_NOT_FOUND. */
96
+ READ_FAILED = "READ_FAILED",
97
+ /** Lock acquisition timed out — another process holds the advisory lock. */
98
+ LOCK_TIMEOUT = "LOCK_TIMEOUT",
99
+ /**
100
+ * The requested import cannot be expressed in the target file's module
101
+ * system, or the file's module system is indeterminate. Emitting ESM syntax
102
+ * into a CommonJS file parses cleanly and then fails to load at runtime, so
103
+ * refusing is the only safe answer (#139).
104
+ */
105
+ MODULE_SYSTEM_MISMATCH = "MODULE_SYSTEM_MISMATCH",
106
+ /** A failure that carried no code of its own. Better than an empty `error.code`. */
107
+ UNKNOWN = "UNKNOWN",
108
+ /** Uncaught internal error — a bug in HashPilot. */
109
+ INTERNAL_ERROR = "INTERNAL_ERROR",
110
+ }
111
+
112
+ export interface TelemetryEvent {
113
+ timestamp: string;
114
+ sessionId: string;
115
+ operation: string;
116
+ route: "ast" | "hash" | "diff" | "read" | "grep" | "verify" | "intent" | "other";
117
+ file?: string;
118
+ files_count?: number;
119
+ lines_read?: number;
120
+ language?: string;
121
+ success: boolean;
122
+ fallback_reason?: string;
123
+ retries?: number;
124
+ recovered?: boolean;
125
+ verification_result?: "pass" | "fail" | "skip";
126
+ failed_in?: string[];
127
+ elapsed_ms: number;
128
+ detail?: string;
129
+ errorCode?: ErrorCode;
130
+
131
+ // ── M6: Provenance fields (all optional) ──────────────────────────
132
+ /** Agent identity (e.g. "claude-opus-4.7@anthropic") */
133
+ actor?: string;
134
+ /** Task or issue reference (e.g. "ISSUE-142", "GH#123") */
135
+ taskId?: string;
136
+ /** UUID linking multi-step edits into one logical change */
137
+ changeSetId?: string;
138
+ /** Human-readable reason for the edit */
139
+ reason?: string;
140
+ /** SHA-256 hash of file content before edit (12-char truncated) */
141
+ beforeHash?: string;
142
+ /** SHA-256 hash of file content after edit (12-char truncated) */
143
+ afterHash?: string;
144
+ /** Unified diff of the change */
145
+ diff?: string;
146
+ /**
147
+ * Hash of a diff held in the payload store because inlining it would blow
148
+ * past `MAX_RECORD_BYTES` (#20). Readers rehydrate `diff` from it, so
149
+ * consumers never have to know which of the two a record was written with.
150
+ */
151
+ diffRef?: string;
152
+ /** Size of the spilled diff in bytes, so a reader can report it unresolved. */
153
+ diffBytes?: number;
154
+ /** 0-indexed position of this step within a changeSet */
155
+ stepIndex?: number;
156
+ /** Total number of steps in the changeSet */
157
+ stepTotal?: number;
158
+ /** Truncated agent prompt/context that produced this edit */
159
+ context?: string;
160
+ }
161
+
162
+ export interface SessionSummary {
163
+ sessionId: string;
164
+ eventCount: number;
165
+ errorRate: number;
166
+ firstTimestamp: string;
167
+ lastTimestamp: string;
168
+ durationMs: number;
169
+ }
170
+
171
+ // Generated once at module load. For the CLI that is exactly right — one
172
+ // process, one session. A library embedding or the MCP server, though, is a
173
+ // long-lived process serving many unrelated tasks, and a single permanent id
174
+ // collapses all of them into one session, making per-task analysis impossible.
175
+ // Hosts call `newSession()` (or `setSessionId`) at a task boundary (#51).
176
+ let sessionId: string = crypto.randomUUID();
177
+
178
+ let sessionEnabled = true;
179
+
180
+ // How many events this process has recorded. `finish()` consults it so a
181
+ // command that records nothing of its own still lands one event, closing the
182
+ // silent holes in the health report's operation coverage (#51).
183
+ let recordedEventCount = 0;
184
+
185
+ /** Events recorded by this process so far (successful writes only). */
186
+ export function getRecordedEventCount(): number {
187
+ return recordedEventCount;
188
+ }
189
+
190
+ export function enableTelemetry(on: boolean = true): void {
191
+ sessionEnabled = on;
192
+ }
193
+
194
+ export function getSessionId(): string {
195
+ return sessionId;
196
+ }
197
+
198
+ /** Start a new telemetry session and return its id. */
199
+ export function newSession(): string {
200
+ sessionId = crypto.randomUUID();
201
+ return sessionId;
202
+ }
203
+
204
+ /** Adopt a caller-supplied session id, e.g. one that matches the host's task id. */
205
+ export function setSessionId(id: string): void {
206
+ if (!id) throw new Error("setSessionId requires a non-empty id");
207
+ sessionId = id;
208
+ }
209
+
210
+ // --- File helpers ---
211
+
212
+ function ensureLogDir(): void {
213
+ // 0700/0600: the log can contain file paths, edit reasons, and (when
214
+ // provenance.captureDiffs is on) source lines. Other users on the box have
215
+ // no business reading it.
216
+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
217
+ }
218
+
219
+ /**
220
+ * Narrow permissions on a log dir/file created by an older version, which used
221
+ * the process umask. The `mode` options above only apply at creation time.
222
+ */
223
+ function tightenLogPermissions(): void {
224
+ try {
225
+ if ((statSync(LOG_DIR).mode & 0o077) !== 0) chmodSync(LOG_DIR, 0o700);
226
+ if (existsSync(LOG_FILE) && (statSync(LOG_FILE).mode & 0o077) !== 0) chmodSync(LOG_FILE, 0o600);
227
+ } catch {}
228
+ }
229
+
230
+ function rotatedFiles(): string[] {
231
+ if (!existsSync(LOG_DIR)) return [];
232
+ return readdirSync(LOG_DIR)
233
+ .filter((f) => ROTATED_FILE_RE.test(f))
234
+ .sort()
235
+ .map((f) => join(LOG_DIR, f));
236
+ }
237
+
238
+ function parseRotatedDate(filename: string): string | null {
239
+ const match = filename.match(ROTATED_FILE_RE);
240
+ return match ? match[1] : null;
241
+ }
242
+
243
+ function maybeRotate(): void {
244
+ if (!existsSync(LOG_FILE)) return;
245
+ const stat = statSync(LOG_FILE);
246
+ if (stat.size < MAX_FILE_SIZE) return;
247
+
248
+ const date = new Date().toISOString().split("T")[0];
249
+ let rotatedPath = join(LOG_DIR, `telemetry-${date}.jsonl`);
250
+ let counter = 1;
251
+ while (existsSync(rotatedPath)) {
252
+ counter++;
253
+ rotatedPath = join(LOG_DIR, `telemetry-${date}-${counter}.jsonl`);
254
+ }
255
+
256
+ renameSync(LOG_FILE, rotatedPath);
257
+
258
+ // Enforce max rotated files
259
+ const files = rotatedFiles();
260
+ while (files.length > MAX_ROTATED_FILES) {
261
+ const oldest = files.shift()!;
262
+ try { unlinkSync(oldest); } catch {}
263
+ }
264
+ }
265
+
266
+ /** Content-addressed store for payloads too large to inline in a record. */
267
+ function payloadsDir(): string {
268
+ return join(LOG_DIR, "payloads");
269
+ }
270
+
271
+ function payloadPath(ref: string): string {
272
+ return join(payloadsDir(), `${ref}.txt`);
273
+ }
274
+
275
+ /**
276
+ * Write a payload out-of-line and return its hash. Content-addressed, so the
277
+ * same diff recorded twice costs one object. Written temp-then-rename: a reader
278
+ * must never see a half-written payload behind a reference that already landed
279
+ * in the log.
280
+ */
281
+ function storePayload(content: string): string {
282
+ const ref = createHash("sha256").update(content).digest("hex").slice(0, 32);
283
+ const dest = payloadPath(ref);
284
+ if (existsSync(dest)) return ref;
285
+ mkdirSync(payloadsDir(), { recursive: true, mode: 0o700 });
286
+ const tmp = `${dest}.${process.pid}.tmp`;
287
+ writeFileSync(tmp, content, { mode: 0o600 });
288
+ renameSync(tmp, dest);
289
+ return ref;
290
+ }
291
+
292
+ function loadPayload(ref: string): string | undefined {
293
+ try {
294
+ return readFileSync(payloadPath(ref), "utf-8");
295
+ } catch {
296
+ return undefined;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Bring one record under `MAX_RECORD_BYTES`.
302
+ *
303
+ * The diff is the only genuinely unbounded field, so it spills to the payload
304
+ * store first and the record keeps `diffRef` plus the original byte count.
305
+ * `context` and `detail` are bounded by their own callers but can still be long
306
+ * enough to matter, so they are truncated as a backstop. A record that is still
307
+ * oversized after all that is written as-is: dropping telemetry to satisfy a
308
+ * size cap would lose the very events most worth having.
309
+ */
310
+ export function capRecord(entry: TelemetryEvent): TelemetryEvent {
311
+ if (Buffer.byteLength(JSON.stringify(entry)) <= MAX_RECORD_BYTES) return entry;
312
+
313
+ const capped: TelemetryEvent = { ...entry };
314
+ if (capped.diff !== undefined) {
315
+ const diff = capped.diff;
316
+ capped.diffBytes = Buffer.byteLength(diff);
317
+ capped.diffRef = storePayload(diff);
318
+ delete capped.diff;
319
+ }
320
+ if (Buffer.byteLength(JSON.stringify(capped)) <= MAX_RECORD_BYTES) return capped;
321
+
322
+ for (const field of ["context", "detail"] as const) {
323
+ const value = capped[field];
324
+ if (typeof value !== "string" || value.length <= 200) continue;
325
+ capped[field] = value.slice(0, 200) + "...";
326
+ if (Buffer.byteLength(JSON.stringify(capped)) <= MAX_RECORD_BYTES) return capped;
327
+ }
328
+ return capped;
329
+ }
330
+
331
+ /**
332
+ * Put a spilled diff back on the record. Readers (health, provenance) see the
333
+ * same shape they always did; the out-of-line store is a storage detail, not a
334
+ * change to the query contract. A payload pruned by retention leaves the
335
+ * reference in place so the record still says a diff existed.
336
+ */
337
+ function rehydrate(entry: TelemetryEvent): TelemetryEvent {
338
+ if (entry.diff !== undefined || entry.diffRef === undefined) return entry;
339
+ const diff = loadPayload(entry.diffRef);
340
+ return diff === undefined ? entry : { ...entry, diff };
341
+ }
342
+
343
+ /**
344
+ * Total bytes the telemetry store occupies: the active log, every rotated log,
345
+ * and the payload objects. Reported by `telemetry health` and `doctor` — an
346
+ * unbounded store nobody can see the size of is one nobody prunes (#50).
347
+ */
348
+ export function diskUsage(): number {
349
+ if (!existsSync(LOG_DIR)) return 0;
350
+ let total = 0;
351
+ const walk = (dir: string): void => {
352
+ for (const name of readdirSync(dir)) {
353
+ const full = join(dir, name);
354
+ try {
355
+ const st = statSync(full);
356
+ if (st.isDirectory()) walk(full);
357
+ else total += st.size;
358
+ } catch {}
359
+ }
360
+ };
361
+ try { walk(LOG_DIR); } catch {}
362
+ return total;
363
+ }
364
+
365
+ /** Warn past this many bytes on disk. */
366
+ export const DISK_WARN_BYTES = 100 * 1024 * 1024;
367
+
368
+ const PRUNE_MARKER = join(LOG_DIR, ".last-prune");
369
+ const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
370
+
371
+ /**
372
+ * Enforce `retentionDays` without anyone running `telemetry prune`. The prune
373
+ * itself reads every event (payload GC needs the full reference set), so it
374
+ * must not run per write: a marker file gates it to once a day, and the cost on
375
+ * every other invocation is a single `statSync` (#50).
376
+ */
377
+ function maybeAutoPrune(): void {
378
+ try {
379
+ if (existsSync(PRUNE_MARKER)) {
380
+ const age = Date.now() - statSync(PRUNE_MARKER).mtimeMs;
381
+ if (age < PRUNE_INTERVAL_MS) return;
382
+ }
383
+ // Touch first: a prune that throws must not re-run on every subsequent
384
+ // write, and losing one day of retention beats a hot loop over the log.
385
+ writeFileSync(PRUNE_MARKER, new Date().toISOString(), { mode: 0o600 });
386
+ pruneEvents();
387
+ } catch {}
388
+ }
389
+
390
+ // --- Core functions ---
391
+
392
+ export function recordEvent(event: Omit<TelemetryEvent, "timestamp" | "sessionId">): void {
393
+ if (!sessionEnabled) return;
394
+ try {
395
+ ensureLogDir();
396
+ tightenLogPermissions();
397
+ maybeRotate();
398
+ maybeAutoPrune();
399
+ const entry: TelemetryEvent = redactEvent({
400
+ ...event,
401
+ timestamp: new Date().toISOString(),
402
+ sessionId,
403
+ });
404
+ appendFileSync(LOG_FILE, JSON.stringify(capRecord(entry)) + "\n", { mode: 0o600 });
405
+ recordedEventCount++;
406
+ } catch {}
407
+ }
408
+
409
+ /**
410
+ * A log file exists but could not be read (permissions, a directory in its
411
+ * place, a device error). Distinct from "no telemetry has been recorded yet",
412
+ * which is an empty result, not an error.
413
+ */
414
+ export class TelemetryReadError extends Error {
415
+ readonly file: string;
416
+ constructor(file: string, cause: unknown) {
417
+ super(`cannot read telemetry log ${file}: ${cause instanceof Error ? cause.message : String(cause)}`);
418
+ this.name = "TelemetryReadError";
419
+ this.file = file;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Malformed lines skipped by the most recent read. Corruption must not be
425
+ * silently indistinguishable from a short log, but the query payloads are a
426
+ * published contract, so the count is reported alongside them rather than
427
+ * inside them (see `docs/ADAPTER-CONTRACT.md`).
428
+ */
429
+ let lastSkipped = 0;
430
+ export function lastReadSkipped(): number {
431
+ return lastSkipped;
432
+ }
433
+
434
+ /** Reads one JSONL file, counting unparseable lines instead of dropping them. */
435
+ function parseLog(file: string): { events: TelemetryEvent[]; skipped: number } {
436
+ let content: string;
437
+ try {
438
+ content = readFileSync(file, "utf-8");
439
+ } catch (err) {
440
+ throw new TelemetryReadError(file, err);
441
+ }
442
+ const events: TelemetryEvent[] = [];
443
+ let skipped = 0;
444
+ for (const line of content.trim().split("\n")) {
445
+ if (!line) continue;
446
+ try {
447
+ events.push(rehydrate(JSON.parse(line)));
448
+ } catch {
449
+ skipped++;
450
+ }
451
+ }
452
+ return { events, skipped };
453
+ }
454
+
455
+ /**
456
+ * Most recent `limit` events from the active log.
457
+ *
458
+ * Throws `TelemetryReadError` if the log exists but cannot be read — returning
459
+ * `[]` there reports a broken log as a clean one.
460
+ */
461
+ export function readEvents(limit: number = 100): TelemetryEvent[] {
462
+ lastSkipped = 0;
463
+ if (!existsSync(LOG_FILE)) return [];
464
+ // `slice(-0)` is `slice(0)` — a request for zero events would return the
465
+ // entire log. Asking for none means none.
466
+ if (limit <= 0) return [];
467
+ const { events, skipped } = parseLog(LOG_FILE);
468
+ lastSkipped = skipped;
469
+ return events.slice(-limit);
470
+ }
471
+
472
+ function readAllEvents(): TelemetryEvent[] {
473
+ lastSkipped = 0;
474
+ const events: TelemetryEvent[] = [];
475
+
476
+ // Current file first, then every rotated file.
477
+ const files = existsSync(LOG_FILE) ? [LOG_FILE, ...rotatedFiles()] : rotatedFiles();
478
+ for (const f of files) {
479
+ const parsed = parseLog(f);
480
+ events.push(...parsed.events);
481
+ lastSkipped += parsed.skipped;
482
+ }
483
+
484
+ return events;
485
+ }
486
+
487
+ export function exportEvents(options?: { from?: Date; to?: Date; sessionId?: string }): TelemetryEvent[] {
488
+ const all = readAllEvents();
489
+ return all.filter((e) => {
490
+ if (options?.from || options?.to) {
491
+ const ts = new Date(e.timestamp).getTime();
492
+ if (options.from && ts < options.from.getTime()) return false;
493
+ if (options.to && ts > options.to.getTime()) return false;
494
+ }
495
+ if (options?.sessionId && e.sessionId !== options.sessionId) return false;
496
+ return true;
497
+ });
498
+ }
499
+
500
+ export function listSessions(): SessionSummary[] {
501
+ const all = readAllEvents();
502
+ const groups: Record<string, TelemetryEvent[]> = {};
503
+ for (const e of all) {
504
+ if (!groups[e.sessionId]) groups[e.sessionId] = [];
505
+ groups[e.sessionId].push(e);
506
+ }
507
+
508
+ return Object.entries(groups)
509
+ .map(([sid, evts]) => {
510
+ const sorted = evts.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
511
+ const first = sorted[0];
512
+ const last = sorted[sorted.length - 1];
513
+ const firstTs = new Date(first.timestamp).getTime();
514
+ const lastTs = new Date(last.timestamp).getTime();
515
+ const errors = sorted.filter((e) => !e.success).length;
516
+ return {
517
+ sessionId: sid,
518
+ eventCount: sorted.length,
519
+ errorRate: Math.round((errors / sorted.length) * 1000) / 10,
520
+ firstTimestamp: first.timestamp,
521
+ lastTimestamp: last.timestamp,
522
+ durationMs: lastTs - firstTs,
523
+ };
524
+ })
525
+ .sort((a, b) => new Date(b.firstTimestamp).getTime() - new Date(a.firstTimestamp).getTime());
526
+ }
527
+
528
+ export function pruneEvents(olderThanDays: number = RETENTION_DAYS): number {
529
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
530
+ let deleted = 0;
531
+
532
+ for (const f of rotatedFiles()) {
533
+ const basename = f.split("/").pop() || "";
534
+ const dateStr = parseRotatedDate(basename);
535
+ if (!dateStr) continue;
536
+
537
+ const fileDate = new Date(dateStr + "T00:00:00Z").getTime();
538
+ if (fileDate < cutoff) {
539
+ try {
540
+ unlinkSync(f);
541
+ deleted++;
542
+ } catch {}
543
+ }
544
+ }
545
+
546
+ prunePayloads();
547
+ return deleted;
548
+ }
549
+
550
+ /**
551
+ * Delete payload objects no surviving record points at. Pruning events without
552
+ * this leaves the store growing forever — the object outlives the only line
553
+ * that could ever ask for it.
554
+ */
555
+ export function prunePayloads(): number {
556
+ if (!existsSync(payloadsDir())) return 0;
557
+ const referenced = new Set<string>();
558
+ for (const e of readAllEvents()) if (e.diffRef) referenced.add(e.diffRef);
559
+
560
+ let removed = 0;
561
+ for (const f of readdirSync(payloadsDir())) {
562
+ const ref = f.replace(/\.txt$/, "");
563
+ if (f === ref || referenced.has(ref)) continue;
564
+ try { unlinkSync(join(payloadsDir(), f)); removed++; } catch {}
565
+ }
566
+ return removed;
567
+ }
568
+
569
+ export function clearEvents(): void {
570
+ try {
571
+ if (existsSync(LOG_FILE)) {
572
+ writeFileSync(LOG_FILE, "");
573
+ }
574
+ // Also clean up rotated files
575
+ for (const f of rotatedFiles()) {
576
+ try { unlinkSync(f); } catch {}
577
+ }
578
+ // Payloads outlive the records that referenced them unless swept here, and
579
+ // a "cleared" log that still has megabytes of diffs under it is not clear.
580
+ if (existsSync(payloadsDir())) {
581
+ for (const f of readdirSync(payloadsDir())) {
582
+ try { unlinkSync(join(payloadsDir(), f)); } catch {}
583
+ }
584
+ }
585
+ } catch {}
586
+ }
587
+
588
+ export function summary(): Record<string, { count: number; success: number; avg_ms: number }> {
589
+ const events = readAllEvents().slice(-10000);
590
+ const buckets: Record<string, { count: number; success: number; total_ms: number }> = {};
591
+ for (const e of events) {
592
+ const key = `${e.route}:${e.operation}`;
593
+ if (!buckets[key]) buckets[key] = { count: 0, success: 0, total_ms: 0 };
594
+ buckets[key].count++;
595
+ if (e.success) buckets[key].success++;
596
+ buckets[key].total_ms += e.elapsed_ms;
597
+ }
598
+ const result: Record<string, { count: number; success: number; avg_ms: number }> = {};
599
+ for (const [k, v] of Object.entries(buckets)) {
600
+ result[k] = {
601
+ count: v.count,
602
+ success: v.success,
603
+ avg_ms: Math.round(v.total_ms / v.count),
604
+ };
605
+ }
606
+ return result;
607
+ }
608
+
609
+ export interface HealthReport {
610
+ totalEvents: number;
611
+ windowDays: number;
612
+ routeDistribution: Record<string, { count: number; success: number }>;
613
+ fallbackFrequency: Record<string, number>;
614
+ staleAnchors: { total: number; recovered: number; failed: number };
615
+ perLanguage: Record<string, { operations: number; failures: number }>;
616
+ verifyFailures: { total: number; byCheck: Record<string, number> };
617
+ topFallbackCauses: { reason: string; count: number }[];
618
+ /** Bytes on disk across the active log, rotated logs, and the payload store. */
619
+ diskBytes: number;
620
+ warnings: string[];
621
+ }
622
+
623
+ function computeHealthFromEvents(events: TelemetryEvent[], windowDays: number): Omit<HealthReport, "topFallbackCauses" | "diskBytes" | "warnings"> {
624
+ const routeDistribution: Record<string, { count: number; success: number }> = {};
625
+ for (const e of events) {
626
+ const r = routeDistribution[e.route] || (routeDistribution[e.route] = { count: 0, success: 0 });
627
+ r.count++;
628
+ if (e.success) r.success++;
629
+ }
630
+
631
+ const fallbackFrequency: Record<string, number> = {};
632
+ for (const e of events) {
633
+ if (e.fallback_reason) {
634
+ fallbackFrequency[e.fallback_reason] = (fallbackFrequency[e.fallback_reason] || 0) + 1;
635
+ }
636
+ }
637
+
638
+ const replaceHashEvents = events.filter((e) => e.operation === "replace-hash");
639
+ const staleAnchors = {
640
+ total: replaceHashEvents.filter((e) => (e.retries ?? 0) > 0 || e.fallback_reason === "stale-anchor").length,
641
+ recovered: replaceHashEvents.filter((e) => (e.retries ?? 0) > 0).length,
642
+ failed: replaceHashEvents.filter((e) => e.fallback_reason === "stale-anchor" && !e.success).length,
643
+ };
644
+
645
+ const perLanguage: Record<string, { operations: number; failures: number }> = {};
646
+ for (const e of events) {
647
+ if (e.language) {
648
+ const l = perLanguage[e.language] || (perLanguage[e.language] = { operations: 0, failures: 0 });
649
+ l.operations++;
650
+ if (!e.success) l.failures++;
651
+ }
652
+ }
653
+
654
+ const verifyEvents = events.filter((e) => e.operation === "verify-changes");
655
+ const verifyFailures = { total: 0, byCheck: {} as Record<string, number> };
656
+ for (const e of verifyEvents) {
657
+ if (!e.success) verifyFailures.total++;
658
+ if (e.failed_in) {
659
+ for (const check of e.failed_in) {
660
+ verifyFailures.byCheck[check] = (verifyFailures.byCheck[check] || 0) + 1;
661
+ }
662
+ }
663
+ }
664
+
665
+ return {
666
+ totalEvents: events.length,
667
+ windowDays,
668
+ routeDistribution,
669
+ fallbackFrequency,
670
+ staleAnchors,
671
+ perLanguage,
672
+ verifyFailures,
673
+ };
674
+ }
675
+
676
+ export function health(windowDays: number = 7): HealthReport {
677
+ const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000;
678
+ const events = readAllEvents().filter((e) => {
679
+ return new Date(e.timestamp).getTime() >= cutoff;
680
+ });
681
+
682
+ const base = computeHealthFromEvents(events, windowDays);
683
+ const { routeDistribution, staleAnchors, perLanguage, verifyFailures } = base;
684
+
685
+ const replaceHashCount = events.filter((e) => e.operation === "replace-hash").length;
686
+ const verifyEventCount = events.filter((e) => e.operation === "verify-changes").length;
687
+ const verifyFailCount = verifyFailures.total;
688
+
689
+ const fc: Record<string, number> = {};
690
+ for (const e of events) {
691
+ if (e.fallback_reason) fc[e.fallback_reason] = (fc[e.fallback_reason] || 0) + 1;
692
+ }
693
+ const topFallbackCauses = Object.entries(fc)
694
+ .sort((a, b) => b[1] - a[1])
695
+ .slice(0, 10)
696
+ .map(([reason, count]) => ({ reason, count }));
697
+
698
+ const warnings: string[] = [];
699
+
700
+ if (replaceHashCount > 0) {
701
+ const staleRate = staleAnchors.total / replaceHashCount;
702
+ if (staleRate > 0.1) {
703
+ warnings.push(
704
+ `Stale-anchor rate ${(staleRate * 100).toFixed(0)}% exceeds threshold of 10% (${staleAnchors.total}/${replaceHashCount} replace-hash calls)`
705
+ );
706
+ }
707
+ }
708
+
709
+ const diffCount = routeDistribution["diff"]?.count ?? 0;
710
+ if (events.length > 0 && diffCount / events.length > 0.1) {
711
+ warnings.push(
712
+ `Fallback-to-diff rate ${((diffCount / events.length) * 100).toFixed(0)}% exceeds threshold of 10%`
713
+ );
714
+ }
715
+
716
+ if (verifyEventCount > 0) {
717
+ const verifyFailRate = verifyFailCount / verifyEventCount;
718
+ if (verifyFailRate > 0.2) {
719
+ warnings.push(
720
+ `Verify-changes failure rate ${(verifyFailRate * 100).toFixed(0)}% exceeds threshold of 20% (${verifyFailCount}/${verifyEventCount})`
721
+ );
722
+ }
723
+ }
724
+
725
+ for (const [lang, stats] of Object.entries(perLanguage)) {
726
+ if (stats.operations >= 3 && stats.failures / stats.operations > 0.3) {
727
+ warnings.push(
728
+ `Language '${lang}' failure rate ${((stats.failures / stats.operations) * 100).toFixed(0)}% exceeds threshold of 30% (${stats.failures}/${stats.operations})`
729
+ );
730
+ }
731
+ }
732
+
733
+ const diskBytes = diskUsage();
734
+ if (diskBytes > DISK_WARN_BYTES) {
735
+ warnings.push(
736
+ `Telemetry store is ${(diskBytes / (1024 * 1024)).toFixed(1)} MB, past the ${(DISK_WARN_BYTES / (1024 * 1024)).toFixed(0)} MB threshold — run 'hashpilot telemetry prune' or lower telemetry.retentionDays`
737
+ );
738
+ }
739
+
740
+ return {
741
+ ...base,
742
+ topFallbackCauses,
743
+ diskBytes,
744
+ warnings,
745
+ };
746
+ }
747
+
748
+ export interface HealthTrend {
749
+ current: HealthReport;
750
+ previous: HealthReport;
751
+ changes: {
752
+ totalEventsDelta: number;
753
+ errorRateDelta: number; // percentage points
754
+ staleAnchorDelta: number;
755
+ verifyFailureDelta: number;
756
+ newWarnings: string[];
757
+ resolvedWarnings: string[];
758
+ languageRegressions: string[];
759
+ };
760
+ }
761
+
762
+ export function healthTrend(windowDays: number = 7): HealthTrend {
763
+ const current = health(windowDays);
764
+ const previous = healthFromWindow(windowDays * 2, windowDays);
765
+ const changes = compareHealth(current, previous);
766
+ return { current, previous, changes };
767
+ }
768
+
769
+ function healthFromWindow(pastDays: number, offsetDays: number): HealthReport {
770
+ const now = Date.now();
771
+ const windowEnd = now - offsetDays * 24 * 60 * 60 * 1000;
772
+ const windowStart = now - pastDays * 24 * 60 * 60 * 1000;
773
+
774
+ const events = readAllEvents().filter((e) => {
775
+ const ts = new Date(e.timestamp).getTime();
776
+ return ts >= windowStart && ts < windowEnd;
777
+ });
778
+
779
+ const base = computeHealthFromEvents(events, pastDays);
780
+ return {
781
+ ...base,
782
+ topFallbackCauses: [],
783
+ // The previous window is a historical slice; disk usage is a
784
+ // point-in-time property of the store, so it belongs only to `current`.
785
+ diskBytes: 0,
786
+ warnings: [],
787
+ };
788
+ }
789
+
790
+ function compareHealth(current: HealthReport, previous: HealthReport): HealthTrend["changes"] {
791
+ const newWarnings: string[] = [];
792
+ const resolvedWarnings: string[] = [];
793
+
794
+ const currentWarnSet = new Set(current.warnings);
795
+ const prevWarnSet = new Set(previous.warnings);
796
+ for (const w of current.warnings) {
797
+ if (!prevWarnSet.has(w)) newWarnings.push(w);
798
+ }
799
+ for (const w of previous.warnings) {
800
+ if (!currentWarnSet.has(w)) resolvedWarnings.push(w);
801
+ }
802
+
803
+ const curTotal = current.totalEvents || 1;
804
+ const prevTotal = previous.totalEvents || 1;
805
+ const curErrors = current.totalEvents - Object.values(current.routeDistribution).reduce((s, r) => s + r.success, 0);
806
+ const prevErrors = previous.totalEvents - Object.values(previous.routeDistribution).reduce((s, r) => s + r.success, 0);
807
+ const errorRateDelta = ((curErrors / curTotal) - (prevErrors / prevTotal)) * 100;
808
+
809
+ const staleAnchorDelta = current.staleAnchors.total - previous.staleAnchors.total;
810
+
811
+ const curVerifyOps = current.routeDistribution["verify"]?.count || 1;
812
+ const curVerifyRate = current.verifyFailures.total / curVerifyOps;
813
+ const prevVerifyOps = previous.routeDistribution["verify"]?.count || 1;
814
+ const prevVerifyRate = previous.verifyFailures.total / prevVerifyOps;
815
+ const verifyFailureDelta = (curVerifyRate - prevVerifyRate) * 100;
816
+
817
+ const languageRegressions: string[] = [];
818
+ for (const [lang, curStats] of Object.entries(current.perLanguage)) {
819
+ const prevStats = previous.perLanguage[lang];
820
+ if (prevStats) {
821
+ const curFailRate = curStats.failures / Math.max(1, curStats.operations);
822
+ const prevFailRate = prevStats.failures / Math.max(1, prevStats.operations);
823
+ if (curFailRate > prevFailRate && curFailRate > 0.1) {
824
+ languageRegressions.push(`${lang} (${(prevFailRate * 100).toFixed(0)}% → ${(curFailRate * 100).toFixed(0)}% failure rate)`);
825
+ }
826
+ }
827
+ }
828
+
829
+ return {
830
+ totalEventsDelta: current.totalEvents - previous.totalEvents,
831
+ errorRateDelta: Math.round(errorRateDelta * 10) / 10,
832
+ staleAnchorDelta,
833
+ verifyFailureDelta: Math.round(verifyFailureDelta * 10) / 10,
834
+ newWarnings,
835
+ resolvedWarnings,
836
+ languageRegressions,
837
+ };
838
+ }