@indigoai-us/hq-cloud 5.45.0 → 5.46.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.
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Skill-invocation telemetry collector.
3
+ *
4
+ * Sibling to `./telemetry.ts` (the token-usage collector). Where that one
5
+ * promotes token-accounting fields off each Claude Code session row, this one
6
+ * extracts *which skill / slash-command was invoked*, reading the SAME
7
+ * `~/.claude/projects/**\/*.jsonl` session logs but with an independent
8
+ * byte-offset cursor at `~/.hq/skill-telemetry-cursor.json` and shipping to
9
+ * `/v1/skill-invocations`.
10
+ *
11
+ * Why a separate collector rather than folding into `./telemetry.ts`: the
12
+ * token path is proven and its per-batch cursor mechanics are load-bearing.
13
+ * Skill events are sparse, so this collector uses a simpler all-or-nothing
14
+ * per-run cursor commit (re-delivery is idempotent server-side via the
15
+ * composite eventKey). Keeping it standalone means a bug here can never
16
+ * regress token telemetry.
17
+ *
18
+ * Two capture paths, both recoverable from the transcript (verified against
19
+ * real sessions):
20
+ * - User-typed slash command → a `user` row whose content carries
21
+ * `<command-name>/foo</command-name>` (+ optional `<command-args>`).
22
+ * - Model-invoked skill → an `assistant` row with a `tool_use` block whose
23
+ * `name === "Skill"` and `input.skill` names the skill.
24
+ * The two are mutually exclusive per invocation, so there is no double-count.
25
+ *
26
+ * Privacy: raw `<command-args>` / `input.args` content is NEVER sent to the
27
+ * cloud — only a `hasArgs` boolean. This matches the message-stripping posture
28
+ * of `./telemetry.ts::sanitizeRow`, which deliberately drops all prompt/tool
29
+ * content client-side. Flip `INCLUDE_ARGS_PREVIEW` only with a deliberate
30
+ * privacy review and a matching server-side allowlist change.
31
+ *
32
+ * Trust model + error handling are identical to `./telemetry.ts`: personUid is
33
+ * resolved server-side from the JWT (never the body), and all errors are
34
+ * swallowed so telemetry never aborts or delays a sync.
35
+ */
36
+
37
+ import { promises as fs } from "node:fs";
38
+ import * as os from "node:os";
39
+ import * as path from "node:path";
40
+
41
+ import type {
42
+ SkillInvocationBatch,
43
+ SkillInvocationIngestResult,
44
+ TelemetryOptInResponse,
45
+ } from "./vault-client.js";
46
+
47
+ // ── Public surface ────────────────────────────────────────────────────────────
48
+
49
+ export interface SkillTelemetryClientSurface {
50
+ getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
51
+ postSkillInvocations(
52
+ batch: SkillInvocationBatch,
53
+ ): Promise<SkillInvocationIngestResult>;
54
+ }
55
+
56
+ export interface CollectSkillTelemetryOptions {
57
+ client: SkillTelemetryClientSurface;
58
+ machineId: string;
59
+ installerVersion: string;
60
+ /**
61
+ * When set, only invocations whose recorded `cwd` equals this path are
62
+ * emitted — scoping capture to the HQ project and excluding skill usage in
63
+ * unrelated repos on the same machine. The walk still covers all of
64
+ * `~/.claude/projects` (so the cursor stays consistent and no session is
65
+ * silently missed by a project-dir-name encoding guess), but non-matching
66
+ * events are dropped before they are batched. Omit to capture every project.
67
+ */
68
+ hqRoot?: string;
69
+ /** Override `~/.claude/projects` for tests. */
70
+ claudeProjectsRoot?: string;
71
+ /** Override `~/.hq/skill-telemetry-cursor.json` for tests. */
72
+ cursorPath?: string;
73
+ /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
74
+ menubarPath?: string;
75
+ /** Diagnostic sink. No-op by default. */
76
+ log?: (msg: string) => void;
77
+ }
78
+
79
+ export interface CollectSkillTelemetryResult {
80
+ enabled: boolean;
81
+ optInSource: "server" | "menubar-fallback" | "skipped";
82
+ filesScanned: number;
83
+ eventsSent: number;
84
+ batchesSent: number;
85
+ }
86
+
87
+ /** A single extracted skill-invocation event. Mirrors the server allowlist in
88
+ * `apps/hq-pro/src/vault-service/handlers/skill-invocations.ts` (KEEP_FIELDS).
89
+ * Any drift surfaces as `unexpected-event-field` in the ingest result. */
90
+ export interface SkillEvent {
91
+ skill: string;
92
+ source: "typed" | "model";
93
+ sessionId?: string;
94
+ timestamp?: string;
95
+ uuid?: string;
96
+ cwd?: string;
97
+ hasArgs: boolean;
98
+ }
99
+
100
+ // Privacy switch — keep false (see file header). When false, raw argument text
101
+ // never leaves the machine; only the `hasArgs` boolean is emitted.
102
+ const INCLUDE_ARGS_PREVIEW = false;
103
+
104
+ // ── Cursor schema (independent from the token collector's) ──────────────────────
105
+
106
+ interface CursorEntry {
107
+ offset: number;
108
+ mtime: number;
109
+ }
110
+
111
+ interface SkillCursor {
112
+ version: string;
113
+ files: Record<string, CursorEntry>;
114
+ }
115
+
116
+ async function loadCursor(cursorPath: string): Promise<SkillCursor> {
117
+ try {
118
+ const raw = await fs.readFile(cursorPath, "utf-8");
119
+ const parsed = JSON.parse(raw) as Partial<SkillCursor>;
120
+ if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
121
+ return { version: parsed.version ?? "1", files: parsed.files as Record<string, CursorEntry> };
122
+ }
123
+ } catch {
124
+ // Missing / unparseable — start fresh.
125
+ }
126
+ return { version: "1", files: {} };
127
+ }
128
+
129
+ async function saveCursor(cursorPath: string, cursor: SkillCursor): Promise<void> {
130
+ await fs.mkdir(path.dirname(cursorPath), { recursive: true });
131
+ const tmp = `${cursorPath}.tmp`;
132
+ await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
133
+ await fs.rename(tmp, cursorPath);
134
+ }
135
+
136
+ async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
137
+ try {
138
+ const raw = await fs.readFile(menubarPath, "utf-8");
139
+ const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
140
+ return parsed.telemetryEnabled === true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ // ── Extractor ───────────────────────────────────────────────────────────────
147
+
148
+ const CMD_NAME = /<command-name>\s*\/?([^<]+?)\s*<\/command-name>/;
149
+ const CMD_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
150
+
151
+ function rowText(content: unknown): string {
152
+ if (typeof content === "string") return content;
153
+ if (Array.isArray(content)) {
154
+ return content
155
+ .map((b) => (b && typeof b === "object" && typeof (b as Record<string, unknown>).text === "string"
156
+ ? ((b as Record<string, unknown>).text as string)
157
+ : ""))
158
+ .join(" ");
159
+ }
160
+ return "";
161
+ }
162
+
163
+ /**
164
+ * Extract zero or more skill-invocation events from a single parsed session
165
+ * row. A `user` row yields at most one typed command; an `assistant` row can
166
+ * carry multiple `Skill` tool_use blocks (rare, but handled).
167
+ */
168
+ export function extractSkillEvents(row: unknown): SkillEvent[] {
169
+ if (!row || typeof row !== "object" || Array.isArray(row)) return [];
170
+ const obj = row as Record<string, unknown>;
171
+ const type = obj.type;
172
+ const msg =
173
+ obj.message && typeof obj.message === "object" && !Array.isArray(obj.message)
174
+ ? (obj.message as Record<string, unknown>)
175
+ : undefined;
176
+ if (!msg) return [];
177
+
178
+ const sessionId = typeof obj.sessionId === "string" ? obj.sessionId : undefined;
179
+ const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
180
+ const cwd = typeof obj.cwd === "string" ? obj.cwd : undefined;
181
+ const rowUuid = typeof obj.uuid === "string" ? obj.uuid : undefined;
182
+
183
+ // Path A — user-typed slash command.
184
+ if (type === "user") {
185
+ const text = rowText(msg.content);
186
+ const m = CMD_NAME.exec(text);
187
+ if (!m) return [];
188
+ const a = CMD_ARGS.exec(text);
189
+ return [
190
+ {
191
+ skill: m[1].trim(),
192
+ source: "typed",
193
+ sessionId,
194
+ timestamp,
195
+ cwd,
196
+ uuid: rowUuid,
197
+ hasArgs: Boolean(a && a[1].trim()),
198
+ },
199
+ ];
200
+ }
201
+
202
+ // Path B — model-invoked Skill tool_use.
203
+ if (type === "assistant" && Array.isArray(msg.content)) {
204
+ const out: SkillEvent[] = [];
205
+ for (const blk of msg.content as unknown[]) {
206
+ if (!blk || typeof blk !== "object") continue;
207
+ const b = blk as Record<string, unknown>;
208
+ if (b.type !== "tool_use" || b.name !== "Skill") continue;
209
+ const input =
210
+ b.input && typeof b.input === "object" && !Array.isArray(b.input)
211
+ ? (b.input as Record<string, unknown>)
212
+ : {};
213
+ const skill = typeof input.skill === "string" ? input.skill : "";
214
+ if (!skill) continue;
215
+ const args = input.args;
216
+ out.push({
217
+ skill,
218
+ source: "model",
219
+ sessionId,
220
+ timestamp,
221
+ cwd,
222
+ // Prefer the tool_use block id (stable, globally unique) for dedup.
223
+ uuid: typeof b.id === "string" ? b.id : rowUuid,
224
+ hasArgs: typeof args === "string" ? args.trim().length > 0 : Boolean(args),
225
+ });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ return [];
231
+ }
232
+
233
+ /** Shape the event for the wire. Drops raw args unless explicitly enabled. */
234
+ function toWireRow(ev: SkillEvent): Record<string, unknown> {
235
+ const row: Record<string, unknown> = {
236
+ skill: ev.skill,
237
+ source: ev.source,
238
+ hasArgs: ev.hasArgs,
239
+ };
240
+ if (ev.sessionId !== undefined) row.sessionId = ev.sessionId;
241
+ if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
242
+ if (ev.uuid !== undefined) row.uuid = ev.uuid;
243
+ if (ev.cwd !== undefined) row.cwd = ev.cwd;
244
+ // INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
245
+ // the guarded branch documents the (currently disabled) egress path.
246
+ if (INCLUDE_ARGS_PREVIEW) {
247
+ // Reserved: a server allowlist change must land before this is enabled.
248
+ }
249
+ return row;
250
+ }
251
+
252
+ // ── File walker ───────────────────────────────────────────────────────────────
253
+
254
+ async function listJsonlFiles(root: string): Promise<string[]> {
255
+ const out: string[] = [];
256
+ async function walk(dir: string): Promise<void> {
257
+ let entries;
258
+ try {
259
+ entries = await fs.readdir(dir, { withFileTypes: true });
260
+ } catch {
261
+ return;
262
+ }
263
+ for (const ent of entries) {
264
+ const full = path.join(dir, ent.name);
265
+ if (ent.isDirectory()) {
266
+ await walk(full);
267
+ } else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
268
+ out.push(full);
269
+ }
270
+ }
271
+ }
272
+ await walk(root);
273
+ return out;
274
+ }
275
+
276
+ const MAX_BATCH_BYTES = 1_000_000;
277
+
278
+ // ── Main entry point ──────────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * Scan, extract, and POST any new skill-invocation events.
282
+ *
283
+ * Cursor model (per-batch commit, matching the token collector for robustness):
284
+ * each file is scanned from its stored byte offset to EOF; extracted events
285
+ * carry the byte offset of the line they came from. Events are flushed in
286
+ * ≤1 MiB batches, and the cursor advances **per successful batch** — so if one
287
+ * batch in a large (e.g. first-run backfill) fails, the batches that already
288
+ * succeeded stay committed and only the rest re-send next sync.
289
+ *
290
+ * Per-file commit rule:
291
+ * - All of a file's events sent OK (including zero-event files) → commit EOF,
292
+ * so quiet/non-skill tails are never re-scanned.
293
+ * - Some of a file's events failed → commit the max byte offset whose batch
294
+ * succeeded (partial progress); the remainder re-sends next sync.
295
+ * Server-side dedup on the composite eventKey makes any re-send idempotent.
296
+ * Rotation/truncation resets the offset to 0 (re-read from the top).
297
+ */
298
+ export async function collectAndSendSkillTelemetry(
299
+ opts: CollectSkillTelemetryOptions,
300
+ ): Promise<CollectSkillTelemetryResult> {
301
+ const home = os.homedir();
302
+ const claudeProjectsRoot =
303
+ opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
304
+ const cursorPath =
305
+ opts.cursorPath ?? path.join(home, ".hq", "skill-telemetry-cursor.json");
306
+ const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
307
+ const log = opts.log ?? (() => {});
308
+
309
+ // Normalize the scope path once (drop a single trailing slash, keeping "/").
310
+ const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
311
+ const scopeCwd = opts.hqRoot !== undefined ? normalizePath(opts.hqRoot) : undefined;
312
+
313
+ // 1. Opt-in check — reuse the same gate as token telemetry.
314
+ let enabled: boolean;
315
+ let optInSource: CollectSkillTelemetryResult["optInSource"];
316
+ try {
317
+ const resp = await opts.client.getTelemetryOptIn();
318
+ enabled = resp.enabled === true;
319
+ optInSource = "server";
320
+ } catch (err) {
321
+ log(`[skill-telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
322
+ enabled = await readLocalTelemetryEnabled(menubarPath);
323
+ optInSource = "menubar-fallback";
324
+ }
325
+
326
+ if (!enabled) {
327
+ return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
328
+ }
329
+
330
+ // 2. Cursor + file enumeration.
331
+ const cursor = await loadCursor(cursorPath);
332
+ const files = await listJsonlFiles(claudeProjectsRoot);
333
+
334
+ // 3. Scan every file from its stored offset, collecting events tagged with
335
+ // the byte offset of the line they came from (for per-batch commit).
336
+ interface FileScan {
337
+ eof: number;
338
+ mtime: number;
339
+ eventCount: number; // events extracted from this file this run
340
+ }
341
+ interface Sourced {
342
+ row: Record<string, unknown>;
343
+ filePath: string;
344
+ endOffset: number; // absolute byte offset at the end of the source line
345
+ }
346
+
347
+ const fileScans: Record<string, FileScan> = {};
348
+ const rotationResets: Record<string, CursorEntry> = {};
349
+ const sourced: Sourced[] = [];
350
+
351
+ for (const filePath of files) {
352
+ let stat;
353
+ try {
354
+ stat = await fs.stat(filePath);
355
+ } catch {
356
+ continue;
357
+ }
358
+ const currentSize = stat.size;
359
+ const currentMtime = Math.floor(stat.mtimeMs / 1000);
360
+
361
+ const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
362
+ let offset = stored.offset;
363
+
364
+ // Rotation / truncation → re-read from the top.
365
+ const rotated =
366
+ currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
367
+ if (rotated) {
368
+ offset = 0;
369
+ rotationResets[filePath] = { offset: 0, mtime: currentMtime };
370
+ }
371
+
372
+ // Record the scan even when there are no new bytes — a fully-drained file
373
+ // (eventCount 0, offset already at EOF) should still settle at EOF below.
374
+ fileScans[filePath] = { eof: currentSize, mtime: currentMtime, eventCount: 0 };
375
+
376
+ if (offset >= currentSize && !rotated) continue;
377
+
378
+ let content: string;
379
+ try {
380
+ const fh = await fs.open(filePath, "r");
381
+ try {
382
+ const length = Math.max(0, currentSize - offset);
383
+ const buf = Buffer.alloc(length);
384
+ await fh.read(buf, 0, length, offset);
385
+ content = buf.toString("utf-8");
386
+ } finally {
387
+ await fh.close();
388
+ }
389
+ } catch {
390
+ // Could not read — drop the scan so we don't claim progress for it.
391
+ delete fileScans[filePath];
392
+ continue;
393
+ }
394
+
395
+ // Compute the absolute end-byte offset of each line in the read region.
396
+ const segments = content.split("\n");
397
+ let cumulative = offset;
398
+ for (let i = 0; i < segments.length; i++) {
399
+ cumulative += Buffer.byteLength(segments[i], "utf-8");
400
+ if (i < segments.length - 1) cumulative += 1; // the split newline byte
401
+ const endOffset = cumulative;
402
+
403
+ const trimmed = segments[i].trim();
404
+ if (trimmed.length === 0) continue;
405
+ let parsed: unknown;
406
+ try {
407
+ parsed = JSON.parse(trimmed);
408
+ } catch {
409
+ continue;
410
+ }
411
+ for (const ev of extractSkillEvents(parsed)) {
412
+ // Scope filter: only emit invocations made from the HQ project.
413
+ if (scopeCwd !== undefined && (ev.cwd === undefined || normalizePath(ev.cwd) !== scopeCwd)) {
414
+ continue;
415
+ }
416
+ sourced.push({ row: toWireRow(ev), filePath, endOffset });
417
+ fileScans[filePath].eventCount++;
418
+ }
419
+ }
420
+ }
421
+
422
+ // 4. Flush in ≤1 MiB batches, advancing per-file progress on each 2xx.
423
+ let eventsSent = 0;
424
+ let batchesSent = 0;
425
+
426
+ // Per file: count of events successfully sent + max committed byte offset.
427
+ const sentCount: Record<string, number> = {};
428
+ const committedOffset: Record<string, number> = {};
429
+
430
+ const envelopeBytes = Buffer.byteLength(
431
+ JSON.stringify({ machineId: opts.machineId, installerVersion: opts.installerVersion, events: [] }),
432
+ "utf-8",
433
+ );
434
+
435
+ let batch: Sourced[] = [];
436
+ let batchBytes = envelopeBytes;
437
+
438
+ const flush = async (): Promise<void> => {
439
+ if (batch.length === 0) return;
440
+ const toSend = batch;
441
+ batch = [];
442
+ batchBytes = envelopeBytes;
443
+ try {
444
+ await opts.client.postSkillInvocations({
445
+ machineId: opts.machineId,
446
+ installerVersion: opts.installerVersion,
447
+ events: toSend.map((s) => s.row),
448
+ });
449
+ batchesSent++;
450
+ eventsSent += toSend.length;
451
+ // Advance per-file progress for the events in this (successful) batch.
452
+ for (const s of toSend) {
453
+ sentCount[s.filePath] = (sentCount[s.filePath] ?? 0) + 1;
454
+ const prev = committedOffset[s.filePath] ?? 0;
455
+ if (s.endOffset > prev) committedOffset[s.filePath] = s.endOffset;
456
+ }
457
+ } catch (err) {
458
+ log(`[skill-telemetry] postSkillInvocations failed (${(err as Error).message ?? err}) — these rows re-send next sync`);
459
+ // Cursor not advanced for this batch; eventKey dedups the eventual re-send.
460
+ }
461
+ };
462
+
463
+ for (const s of sourced) {
464
+ const rowBytes = Buffer.byteLength(JSON.stringify(s.row), "utf-8");
465
+ const addCost = rowBytes + (batch.length > 0 ? 1 : 0);
466
+ if (batch.length > 0 && batchBytes + addCost > MAX_BATCH_BYTES) {
467
+ await flush();
468
+ batchBytes = envelopeBytes + rowBytes;
469
+ } else {
470
+ batchBytes += addCost;
471
+ }
472
+ batch.push(s);
473
+ }
474
+ await flush();
475
+
476
+ // 5. Build the new cursor: loaded < rotationResets < per-file commit.
477
+ // A file settles at EOF only when every event extracted from it this run
478
+ // was sent OK (zero-event files included); otherwise it settles at the
479
+ // highest byte offset whose batch succeeded, so the rest re-sends.
480
+ const finalFiles: Record<string, CursorEntry> = { ...cursor.files };
481
+ for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
482
+ for (const [fp, scan] of Object.entries(fileScans)) {
483
+ if ((sentCount[fp] ?? 0) >= scan.eventCount) {
484
+ finalFiles[fp] = { offset: scan.eof, mtime: scan.mtime };
485
+ } else if (fp in committedOffset) {
486
+ finalFiles[fp] = { offset: committedOffset[fp], mtime: scan.mtime };
487
+ }
488
+ // else: no progress for this file — leave loaded/rotation-reset offset.
489
+ }
490
+ await saveCursor(cursorPath, { version: "1", files: finalFiles });
491
+
492
+ return {
493
+ enabled: true,
494
+ optInSource,
495
+ filesScanned: files.length,
496
+ eventsSent,
497
+ batchesSent,
498
+ };
499
+ }
@@ -352,6 +352,27 @@ export interface UsageIngestResult {
352
352
  skipped: Array<{ index: number; code: string; error: string }>;
353
353
  }
354
354
 
355
+ // ---------------------------------------------------------------------------
356
+ // Skill-invocation telemetry (hq-pro `/v1/skill-invocations`)
357
+ // ---------------------------------------------------------------------------
358
+
359
+ export interface SkillInvocationBatch {
360
+ machineId: string;
361
+ installerVersion: string;
362
+ /**
363
+ * Skill-invocation event rows. Each row contains only the fields in the
364
+ * server's KEEP allowlist (skill, source, sessionId, timestamp, uuid, cwd,
365
+ * hasArgs). Raw argument text is never included — see the privacy note in
366
+ * `./skill-telemetry.ts`. Any extra field is rejected by hq-pro with
367
+ * `unexpected-event-field`, so the extractor in `./skill-telemetry.ts` is the
368
+ * only thing allowed to produce these.
369
+ */
370
+ events: Array<Record<string, unknown>>;
371
+ }
372
+
373
+ /** Same wire shape as `UsageIngestResult`; aliased for call-site clarity. */
374
+ export type SkillInvocationIngestResult = UsageIngestResult;
375
+
355
376
  // ---------------------------------------------------------------------------
356
377
  // Retry config
357
378
  // ---------------------------------------------------------------------------
@@ -773,6 +794,19 @@ export class VaultClient {
773
794
  return this.post<UsageIngestResult>("/v1/usage", batch);
774
795
  }
775
796
 
797
+ /**
798
+ * `POST /v1/skill-invocations` — upload a batch of skill-invocation events.
799
+ *
800
+ * Same trust + size model as `postUsage`: `personUid` MUST NOT appear in the
801
+ * batch (server resolves it from the JWT). Gated by the same telemetry
802
+ * opt-in as `/v1/usage`.
803
+ */
804
+ async postSkillInvocations(
805
+ batch: SkillInvocationBatch,
806
+ ): Promise<SkillInvocationIngestResult> {
807
+ return this.post<SkillInvocationIngestResult>("/v1/skill-invocations", batch);
808
+ }
809
+
776
810
  // -- HTTP primitives with retry -------------------------------------------
777
811
 
778
812
  private async get<T>(path: string): Promise<T> {