@indigoai-us/hq-cloud 5.19.0 → 5.20.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.
Files changed (145) hide show
  1. package/.github/workflows/ci.yml +8 -4
  2. package/.github/workflows/publish.yml +9 -3
  3. package/dist/bin/sync-runner.d.ts +9 -0
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +58 -0
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/client-info.d.ts +44 -0
  8. package/dist/client-info.d.ts.map +1 -0
  9. package/dist/client-info.js +112 -0
  10. package/dist/client-info.js.map +1 -0
  11. package/dist/client-info.test.d.ts +11 -0
  12. package/dist/client-info.test.d.ts.map +1 -0
  13. package/dist/client-info.test.js +168 -0
  14. package/dist/client-info.test.js.map +1 -0
  15. package/dist/context.d.ts.map +1 -1
  16. package/dist/context.js +10 -2
  17. package/dist/context.js.map +1 -1
  18. package/dist/entity-resolver.d.ts +48 -0
  19. package/dist/entity-resolver.d.ts.map +1 -0
  20. package/dist/entity-resolver.js +122 -0
  21. package/dist/entity-resolver.js.map +1 -0
  22. package/dist/entity-resolver.test.d.ts +10 -0
  23. package/dist/entity-resolver.test.d.ts.map +1 -0
  24. package/dist/entity-resolver.test.js +236 -0
  25. package/dist/entity-resolver.test.js.map +1 -0
  26. package/dist/index.d.ts +20 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +24 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/schemas/signal-types.d.ts +16 -0
  31. package/dist/schemas/signal-types.d.ts.map +1 -0
  32. package/dist/schemas/signal-types.js +30 -0
  33. package/dist/schemas/signal-types.js.map +1 -0
  34. package/dist/schemas/signal-types.test.d.ts +2 -0
  35. package/dist/schemas/signal-types.test.d.ts.map +1 -0
  36. package/dist/schemas/signal-types.test.js +65 -0
  37. package/dist/schemas/signal-types.test.js.map +1 -0
  38. package/dist/schemas/source-channels.d.ts +15 -0
  39. package/dist/schemas/source-channels.d.ts.map +1 -0
  40. package/dist/schemas/source-channels.js +28 -0
  41. package/dist/schemas/source-channels.js.map +1 -0
  42. package/dist/schemas/source-channels.test.d.ts +2 -0
  43. package/dist/schemas/source-channels.test.d.ts.map +1 -0
  44. package/dist/schemas/source-channels.test.js +65 -0
  45. package/dist/schemas/source-channels.test.js.map +1 -0
  46. package/dist/signals/get.d.ts +13 -0
  47. package/dist/signals/get.d.ts.map +1 -0
  48. package/dist/signals/get.js +74 -0
  49. package/dist/signals/get.js.map +1 -0
  50. package/dist/signals/get.test.d.ts +5 -0
  51. package/dist/signals/get.test.d.ts.map +1 -0
  52. package/dist/signals/get.test.js +170 -0
  53. package/dist/signals/get.test.js.map +1 -0
  54. package/dist/signals/internals.d.ts +16 -0
  55. package/dist/signals/internals.d.ts.map +1 -0
  56. package/dist/signals/internals.js +39 -0
  57. package/dist/signals/internals.js.map +1 -0
  58. package/dist/signals/list.d.ts +10 -0
  59. package/dist/signals/list.d.ts.map +1 -0
  60. package/dist/signals/list.js +76 -0
  61. package/dist/signals/list.js.map +1 -0
  62. package/dist/signals/list.test.d.ts +9 -0
  63. package/dist/signals/list.test.d.ts.map +1 -0
  64. package/dist/signals/list.test.js +227 -0
  65. package/dist/signals/list.test.js.map +1 -0
  66. package/dist/signals/parse.d.ts +8 -0
  67. package/dist/signals/parse.d.ts.map +1 -0
  68. package/dist/signals/parse.js +8 -0
  69. package/dist/signals/parse.js.map +1 -0
  70. package/dist/signals/types.d.ts +69 -0
  71. package/dist/signals/types.d.ts.map +1 -0
  72. package/dist/signals/types.js +10 -0
  73. package/dist/signals/types.js.map +1 -0
  74. package/dist/sources/get.d.ts +11 -0
  75. package/dist/sources/get.d.ts.map +1 -0
  76. package/dist/sources/get.js +67 -0
  77. package/dist/sources/get.js.map +1 -0
  78. package/dist/sources/get.test.d.ts +5 -0
  79. package/dist/sources/get.test.d.ts.map +1 -0
  80. package/dist/sources/get.test.js +132 -0
  81. package/dist/sources/get.test.js.map +1 -0
  82. package/dist/sources/internals.d.ts +16 -0
  83. package/dist/sources/internals.d.ts.map +1 -0
  84. package/dist/sources/internals.js +39 -0
  85. package/dist/sources/internals.js.map +1 -0
  86. package/dist/sources/list.d.ts +10 -0
  87. package/dist/sources/list.d.ts.map +1 -0
  88. package/dist/sources/list.js +76 -0
  89. package/dist/sources/list.js.map +1 -0
  90. package/dist/sources/list.test.d.ts +8 -0
  91. package/dist/sources/list.test.d.ts.map +1 -0
  92. package/dist/sources/list.test.js +198 -0
  93. package/dist/sources/list.test.js.map +1 -0
  94. package/dist/sources/parse.d.ts +18 -0
  95. package/dist/sources/parse.d.ts.map +1 -0
  96. package/dist/sources/parse.js +35 -0
  97. package/dist/sources/parse.js.map +1 -0
  98. package/dist/sources/types.d.ts +62 -0
  99. package/dist/sources/types.d.ts.map +1 -0
  100. package/dist/sources/types.js +8 -0
  101. package/dist/sources/types.js.map +1 -0
  102. package/dist/telemetry.d.ts +87 -0
  103. package/dist/telemetry.d.ts.map +1 -0
  104. package/dist/telemetry.js +349 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/telemetry.test.d.ts +11 -0
  107. package/dist/telemetry.test.d.ts.map +1 -0
  108. package/dist/telemetry.test.js +309 -0
  109. package/dist/telemetry.test.js.map +1 -0
  110. package/dist/types.d.ts +22 -0
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/vault-client.d.ts +60 -0
  113. package/dist/vault-client.d.ts.map +1 -1
  114. package/dist/vault-client.js +41 -0
  115. package/dist/vault-client.js.map +1 -1
  116. package/package.json +5 -3
  117. package/src/bin/sync-runner.ts +73 -0
  118. package/src/client-info.test.ts +214 -0
  119. package/src/client-info.ts +121 -0
  120. package/src/context.ts +10 -2
  121. package/src/entity-resolver.test.ts +307 -0
  122. package/src/entity-resolver.ts +173 -0
  123. package/src/index.ts +91 -0
  124. package/src/schemas/signal-types.test.ts +82 -0
  125. package/src/schemas/signal-types.ts +38 -0
  126. package/src/schemas/source-channels.test.ts +82 -0
  127. package/src/schemas/source-channels.ts +36 -0
  128. package/src/signals/get.test.ts +204 -0
  129. package/src/signals/get.ts +79 -0
  130. package/src/signals/internals.ts +46 -0
  131. package/src/signals/list.test.ts +283 -0
  132. package/src/signals/list.ts +92 -0
  133. package/src/signals/parse.ts +8 -0
  134. package/src/signals/types.ts +74 -0
  135. package/src/sources/get.test.ts +166 -0
  136. package/src/sources/get.ts +75 -0
  137. package/src/sources/internals.ts +46 -0
  138. package/src/sources/list.test.ts +247 -0
  139. package/src/sources/list.ts +95 -0
  140. package/src/sources/parse.ts +43 -0
  141. package/src/sources/types.ts +67 -0
  142. package/src/telemetry.test.ts +394 -0
  143. package/src/telemetry.ts +436 -0
  144. package/src/types.ts +23 -0
  145. package/src/vault-client.ts +91 -1
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Usage telemetry collector — TypeScript port of the Tauri Rust collector that
3
+ * used to live at `hq-workspace/apps/hq-sync/src-tauri/src/commands/telemetry.rs`.
4
+ *
5
+ * Why it moved: the Rust copy only ran inside the macOS menubar app. By moving
6
+ * the logic into `@indigoai-us/hq-cloud`, every consumer of the package
7
+ * (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
8
+ *
9
+ * What it does: after each successful sync (`all-complete` arm of
10
+ * `bin/sync-runner.ts`), walks `~/.claude/projects/**\/*.jsonl`, diffs each
11
+ * file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
12
+ * sanitizes new rows through a tight allowlist that matches the server's
13
+ * KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
14
+ * batches into ≤1 MiB POST bodies, and ships them to `/v1/usage`.
15
+ *
16
+ * Trust model: the caller's `personUid` is resolved on the server from the
17
+ * Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
18
+ * thinking content, tool inputs/outputs, and any nested `message` object so
19
+ * the wire payload contains only token-accounting fields.
20
+ *
21
+ * Errors are swallowed by design — telemetry must never abort or delay a
22
+ * sync. The cursor is only advanced for batches the server 2xx'd, so a
23
+ * transient outage retries automatically on the next sync.
24
+ */
25
+
26
+ import { promises as fs } from "node:fs";
27
+ import * as os from "node:os";
28
+ import * as path from "node:path";
29
+
30
+ import type {
31
+ TelemetryOptInResponse,
32
+ UsageBatch,
33
+ UsageIngestResult,
34
+ } from "./vault-client.js";
35
+
36
+ // ── Public surface ────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Minimal subset of `VaultClient` the collector needs. Declared as an
40
+ * interface so tests can inject a stub without spinning up a fetch mock.
41
+ * The real `VaultClient` from `./vault-client.js` satisfies this structurally.
42
+ */
43
+ export interface TelemetryClientSurface {
44
+ getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
45
+ postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
46
+ }
47
+
48
+ export interface CollectTelemetryOptions {
49
+ client: TelemetryClientSurface;
50
+ /** Stable per-machine id. The Tauri menubar reads this from `~/.hq/menubar.json`; the runner can pass it through or generate one once and cache. */
51
+ machineId: string;
52
+ /** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */
53
+ installerVersion: string;
54
+ /** Override `~/.claude/projects` for tests. */
55
+ claudeProjectsRoot?: string;
56
+ /** Override `~/.hq/telemetry-cursor.json` for tests. */
57
+ cursorPath?: string;
58
+ /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
59
+ menubarPath?: string;
60
+ /** Diagnostic sink. No-op by default. */
61
+ log?: (msg: string) => void;
62
+ }
63
+
64
+ export interface CollectTelemetryResult {
65
+ /** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
66
+ enabled: boolean;
67
+ /** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
68
+ optInSource: "server" | "menubar-fallback" | "skipped";
69
+ /** How many `.jsonl` files we considered (before the cursor diff). */
70
+ filesScanned: number;
71
+ /** Total events successfully POSTed across all batches. */
72
+ eventsSent: number;
73
+ /** Number of `POST /v1/usage` requests made. */
74
+ batchesSent: number;
75
+ }
76
+
77
+ // ── Cursor schema ─────────────────────────────────────────────────────────────
78
+
79
+ interface CursorEntry {
80
+ offset: number;
81
+ mtime: number;
82
+ }
83
+
84
+ interface TelemetryCursor {
85
+ version: string;
86
+ files: Record<string, CursorEntry>;
87
+ }
88
+
89
+ function emptyCursor(): TelemetryCursor {
90
+ return { version: "1", files: {} };
91
+ }
92
+
93
+ async function loadCursor(cursorPath: string): Promise<TelemetryCursor> {
94
+ try {
95
+ const raw = await fs.readFile(cursorPath, "utf-8");
96
+ const parsed = JSON.parse(raw) as Partial<TelemetryCursor>;
97
+ if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
98
+ return { version: parsed.version ?? "1", files: parsed.files as Record<string, CursorEntry> };
99
+ }
100
+ } catch {
101
+ // Missing / unparseable — start fresh.
102
+ }
103
+ return emptyCursor();
104
+ }
105
+
106
+ async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<void> {
107
+ // Atomic write: tmp + rename. The Rust impl uses the same .tmp suffix; we
108
+ // keep it for cross-implementation grep-ability.
109
+ await fs.mkdir(path.dirname(cursorPath), { recursive: true });
110
+ const tmp = `${cursorPath}.tmp`;
111
+ await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
112
+ await fs.rename(tmp, cursorPath);
113
+ }
114
+
115
+ // ── Local opt-in fallback ─────────────────────────────────────────────────────
116
+
117
+ async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
118
+ try {
119
+ const raw = await fs.readFile(menubarPath, "utf-8");
120
+ const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
121
+ return parsed.telemetryEnabled === true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ // ── Sanitizer ─────────────────────────────────────────────────────────────────
128
+
129
+ /** Top-level fields the server accepts. Keep aligned with `KEEP_FIELDS` in
130
+ * `apps/hq-pro/src/vault-service/handlers/usage.ts` — any drift will surface
131
+ * as an `unexpected-event-field` rejection in `UsageIngestResult.skipped`. */
132
+ const KEEP_TOP_LEVEL = [
133
+ "sessionId",
134
+ "timestamp",
135
+ "uuid",
136
+ "cwd",
137
+ "gitBranch",
138
+ "userType",
139
+ ] as const;
140
+
141
+ /**
142
+ * Build an outgoing event row matching the server's KEEP allowlist.
143
+ *
144
+ * Two transforms:
145
+ * 1. Top-level fields are copied straight through (string identity).
146
+ * 2. `message.model` and `message.usage.{input_tokens, output_tokens,
147
+ * cache_creation_input_tokens, cache_read_input_tokens}` are promoted to
148
+ * camelCase top-level fields. The original `message` object — which
149
+ * carries prompt/response text, thinking, and tool data — is dropped.
150
+ *
151
+ * Returns `null` when the input isn't an object. Empty results (e.g. a row
152
+ * with no recognised fields) are still returned as `{}` and emitted; the
153
+ * server accepts empty rows and they're useful as a "Claude Code was run at
154
+ * this time" heartbeat.
155
+ */
156
+ export function sanitizeRow(row: unknown): Record<string, unknown> | null {
157
+ if (!row || typeof row !== "object" || Array.isArray(row)) return null;
158
+ const obj = row as Record<string, unknown>;
159
+ const out: Record<string, unknown> = {};
160
+
161
+ for (const key of KEEP_TOP_LEVEL) {
162
+ if (key in obj) {
163
+ out[key] = obj[key];
164
+ }
165
+ }
166
+
167
+ const message = obj.message;
168
+ if (message && typeof message === "object" && !Array.isArray(message)) {
169
+ const m = message as Record<string, unknown>;
170
+ if ("model" in m) out.model = m.model;
171
+ const usage = m.usage;
172
+ if (usage && typeof usage === "object" && !Array.isArray(usage)) {
173
+ const u = usage as Record<string, unknown>;
174
+ if ("input_tokens" in u) out.inputTokens = u.input_tokens;
175
+ if ("output_tokens" in u) out.outputTokens = u.output_tokens;
176
+ if ("cache_creation_input_tokens" in u) {
177
+ out.cacheCreationInputTokens = u.cache_creation_input_tokens;
178
+ }
179
+ if ("cache_read_input_tokens" in u) {
180
+ out.cacheReadInputTokens = u.cache_read_input_tokens;
181
+ }
182
+ }
183
+ }
184
+
185
+ return out;
186
+ }
187
+
188
+ // ── File walker ───────────────────────────────────────────────────────────────
189
+
190
+ /** Recursively collect every `.jsonl` file under `root`. Skips errors silently
191
+ * (missing dir, EACCES on a stray subdir) — anything we can't enter is
192
+ * treated as absent rather than fatal, matching the Rust glob behavior. */
193
+ async function listJsonlFiles(root: string): Promise<string[]> {
194
+ const out: string[] = [];
195
+ async function walk(dir: string): Promise<void> {
196
+ let entries;
197
+ try {
198
+ entries = await fs.readdir(dir, { withFileTypes: true });
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const ent of entries) {
203
+ const full = path.join(dir, ent.name);
204
+ if (ent.isDirectory()) {
205
+ await walk(full);
206
+ } else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
207
+ out.push(full);
208
+ }
209
+ }
210
+ }
211
+ await walk(root);
212
+ return out;
213
+ }
214
+
215
+ // ── Batching primitives ───────────────────────────────────────────────────────
216
+
217
+ const MAX_BATCH_BYTES = 1_000_000;
218
+
219
+ interface RowSource {
220
+ filePath: string;
221
+ endOffset: number;
222
+ mtime: number;
223
+ }
224
+
225
+ /**
226
+ * Byte cost of the fixed wire-payload skeleton:
227
+ * {"machineId":"…","installerVersion":"…","events":[]}
228
+ *
229
+ * The Rust implementation re-serializes the entire growing batch on every
230
+ * row to check size — O(n²) bytes of JSON.stringify work per batch, which
231
+ * for 60K events takes ~4 minutes wall-clock in V8. This computes the same
232
+ * payload size incrementally instead: skeleton + Σ(per-row JSON length) +
233
+ * commas between rows. Same threshold semantics, O(n) total cost.
234
+ */
235
+ function envelopeBytes(machineId: string, installerVersion: string): number {
236
+ // Serialize the empty-events envelope once and measure it. Captures the
237
+ // exact JSON whitespace / escaping V8 produces so we match what would
238
+ // actually go over the wire.
239
+ return Buffer.byteLength(
240
+ JSON.stringify({ machineId, installerVersion, events: [] }),
241
+ "utf-8",
242
+ );
243
+ }
244
+
245
+ // ── Main entry point ──────────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * Scan, sanitize, and POST any new Claude Code session rows.
249
+ *
250
+ * Fire-and-forget from the caller's perspective: errors are caught internally
251
+ * and surfaced only via `log`. The returned summary lets observers (e.g.
252
+ * sync-runner) decide whether to record a "telemetry attempted" breadcrumb,
253
+ * but no consumer is expected to react to it.
254
+ */
255
+ export async function collectAndSendTelemetry(
256
+ opts: CollectTelemetryOptions,
257
+ ): Promise<CollectTelemetryResult> {
258
+ const home = os.homedir();
259
+ const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
260
+ const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
261
+ const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
262
+ const log = opts.log ?? (() => {});
263
+
264
+ // 1. Opt-in check (server-authoritative, with local fallback).
265
+ let enabled: boolean;
266
+ let optInSource: CollectTelemetryResult["optInSource"];
267
+ try {
268
+ const resp = await opts.client.getTelemetryOptIn();
269
+ enabled = resp.enabled === true;
270
+ optInSource = "server";
271
+ } catch (err) {
272
+ log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
273
+ enabled = await readLocalTelemetryEnabled(menubarPath);
274
+ optInSource = "menubar-fallback";
275
+ }
276
+
277
+ if (!enabled) {
278
+ return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
279
+ }
280
+
281
+ // 2. Cursor + file enumeration.
282
+ const cursor = await loadCursor(cursorPath);
283
+ const loadedFiles = { ...cursor.files };
284
+ const rotationResets: Record<string, CursorEntry> = {};
285
+ const newlyCommitted: Record<string, CursorEntry> = {};
286
+
287
+ const files = await listJsonlFiles(claudeProjectsRoot);
288
+
289
+ // 3. Walk each file, sanitize new rows, batch, flush at 1 MiB.
290
+ //
291
+ // Byte accounting is incremental: we track `batchBytes` as the projected
292
+ // serialized size of the current batch (envelope + per-row JSON + commas).
293
+ // Each row contributes its own JSON.stringify length once; we never
294
+ // re-serialize the growing batch. This is the O(n) replacement for the
295
+ // O(n²) projected-payload check the Rust impl uses (which spent ~4 min
296
+ // on a 60K-event first-run in the E2E smoke against hq-prod).
297
+ const ENVELOPE_BYTES = envelopeBytes(opts.machineId, opts.installerVersion);
298
+ let batchEvents: Array<Record<string, unknown>> = [];
299
+ let batchSources: RowSource[] = [];
300
+ let batchBytes = ENVELOPE_BYTES;
301
+ let eventsSent = 0;
302
+ let batchesSent = 0;
303
+
304
+ const flush = async (): Promise<void> => {
305
+ if (batchEvents.length === 0) return;
306
+ const events = batchEvents;
307
+ const sources = batchSources;
308
+ batchEvents = [];
309
+ batchSources = [];
310
+ batchBytes = ENVELOPE_BYTES;
311
+
312
+ try {
313
+ await opts.client.postUsage({
314
+ machineId: opts.machineId,
315
+ installerVersion: opts.installerVersion,
316
+ events,
317
+ });
318
+ batchesSent++;
319
+ eventsSent += events.length;
320
+ // Advance cursor to max(endOffset) per file in this batch.
321
+ const maxPerFile = new Map<string, { mtime: number; offset: number }>();
322
+ for (const src of sources) {
323
+ const cur = maxPerFile.get(src.filePath);
324
+ if (!cur || src.endOffset > cur.offset) {
325
+ maxPerFile.set(src.filePath, { mtime: src.mtime, offset: src.endOffset });
326
+ }
327
+ }
328
+ for (const [fp, entry] of maxPerFile) {
329
+ newlyCommitted[fp] = { offset: entry.offset, mtime: entry.mtime };
330
+ }
331
+ } catch (err) {
332
+ log(`[telemetry] postUsage failed (${(err as Error).message ?? err}) — cursor not advanced for ${sources.length} rows`);
333
+ // Cursor intentionally left un-advanced — next sync retries.
334
+ }
335
+ };
336
+
337
+ for (const filePath of files) {
338
+ let stat;
339
+ try {
340
+ stat = await fs.stat(filePath);
341
+ } catch {
342
+ continue;
343
+ }
344
+ const currentSize = stat.size;
345
+ const currentMtime = Math.floor(stat.mtimeMs / 1000);
346
+
347
+ const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
348
+ let offset = stored.offset;
349
+
350
+ const rotated =
351
+ currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
352
+ if (rotated) {
353
+ offset = 0;
354
+ rotationResets[filePath] = { offset: 0, mtime: currentMtime };
355
+ }
356
+
357
+ if (offset >= currentSize && !rotated) continue;
358
+
359
+ let content: string;
360
+ try {
361
+ const fh = await fs.open(filePath, "r");
362
+ try {
363
+ const length = Math.max(0, currentSize - offset);
364
+ const buf = Buffer.alloc(length);
365
+ await fh.read(buf, 0, length, offset);
366
+ content = buf.toString("utf-8");
367
+ } finally {
368
+ await fh.close();
369
+ }
370
+ } catch {
371
+ continue;
372
+ }
373
+ if (content.length === 0) continue;
374
+
375
+ const segments = content.split("\n");
376
+ const lineEndOffsets: number[] = [];
377
+ let cumulative = 0;
378
+ for (let i = 0; i < segments.length; i++) {
379
+ cumulative += Buffer.byteLength(segments[i], "utf-8");
380
+ if (i < segments.length - 1) cumulative += 1; // newline byte
381
+ lineEndOffsets.push(offset + cumulative);
382
+ }
383
+
384
+ for (let i = 0; i < segments.length; i++) {
385
+ const trimmed = segments[i].trim();
386
+ if (trimmed.length === 0) continue;
387
+ let parsed: unknown;
388
+ try {
389
+ parsed = JSON.parse(trimmed);
390
+ } catch {
391
+ continue;
392
+ }
393
+ const sanitized = sanitizeRow(parsed);
394
+ if (!sanitized) continue;
395
+
396
+ // Cost of appending this row to the current batch: the row's JSON
397
+ // length plus 1 byte for the leading comma when there's already at
398
+ // least one row. (No comma when the batch is empty — the row sits
399
+ // alone inside the events array.)
400
+ const rowJsonBytes = Buffer.byteLength(JSON.stringify(sanitized), "utf-8");
401
+ const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
402
+
403
+ if (batchEvents.length > 0 && batchBytes + addCost > MAX_BATCH_BYTES) {
404
+ await flush();
405
+ // After flush, batchEvents is empty → no comma needed for the first row.
406
+ batchBytes = ENVELOPE_BYTES + rowJsonBytes;
407
+ } else {
408
+ batchBytes += addCost;
409
+ }
410
+
411
+ batchEvents.push(sanitized);
412
+ batchSources.push({
413
+ filePath,
414
+ endOffset: lineEndOffsets[i],
415
+ mtime: currentMtime,
416
+ });
417
+ }
418
+ }
419
+
420
+ await flush();
421
+
422
+ // 4. Persist cursor: loaded < rotation_resets < newly_committed.
423
+ const finalFiles: Record<string, CursorEntry> = { ...loadedFiles };
424
+ for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
425
+ for (const [fp, entry] of Object.entries(newlyCommitted)) finalFiles[fp] = entry;
426
+
427
+ await saveCursor(cursorPath, { version: "1", files: finalFiles });
428
+
429
+ return {
430
+ enabled: true,
431
+ optInSource,
432
+ filesScanned: files.length,
433
+ eventsSent,
434
+ batchesSent,
435
+ };
436
+ }
package/src/types.ts CHANGED
@@ -91,6 +91,24 @@ export interface VaultCredentials {
91
91
  sessionToken: string;
92
92
  }
93
93
 
94
+ /**
95
+ * Caller identification stamped on every request to hq-cloud-api so the server
96
+ * can attribute traffic and gate on minimum versions.
97
+ */
98
+ export interface ClientInfo {
99
+ /** Package name, e.g. "@indigoai-us/hq-cli" */
100
+ name: string;
101
+ /** Package version, e.g. "5.15.0" */
102
+ version: string;
103
+ /**
104
+ * `hqVersion` from `core/core.yaml` — set when the caller is running inside
105
+ * an hq-core checkout. Lets the server see scaffold-generation skew.
106
+ */
107
+ hqCoreVersion?: string;
108
+ /** Arbitrary extra key/value pairs forwarded as `x-hq-client-<key>` headers. */
109
+ extra?: Record<string, string>;
110
+ }
111
+
94
112
  /**
95
113
  * Configuration for connecting to the vault-service API.
96
114
  */
@@ -110,6 +128,11 @@ export interface VaultServiceConfig {
110
128
  authToken: string | (() => string | Promise<string>);
111
129
  /** AWS region for S3 client (defaults to entity region or us-east-1) */
112
130
  region?: string;
131
+ /**
132
+ * Identifies the calling package + version on every outbound request.
133
+ * Optional for back-compat, but all first-party clients should pass it.
134
+ */
135
+ clientInfo?: ClientInfo;
113
136
  }
114
137
 
115
138
  // ── Conflict index (consumed by /resolve-conflicts) ─────────────────────────
@@ -6,7 +6,8 @@
6
6
  * share one client instead of each rolling its own HTTP layer.
7
7
  */
8
8
 
9
- import type { VaultServiceConfig } from "./types.js";
9
+ import type { ClientInfo, VaultServiceConfig } from "./types.js";
10
+ import { buildClientHeaders } from "./client-info.js";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Error classes
@@ -150,6 +151,19 @@ export interface CreateEntityResult {
150
151
  entity: EntityInfo;
151
152
  }
152
153
 
154
+ // -- Entity STS vending (sources/signals entity resolver) ------------------
155
+
156
+ export interface EntityStsResult {
157
+ credentials: {
158
+ accessKeyId: string;
159
+ secretAccessKey: string;
160
+ sessionToken: string;
161
+ };
162
+ expiresAt: string;
163
+ bucketName: string;
164
+ region: string;
165
+ }
166
+
153
167
  // -- STS child vending (VLT-8) --------------------------------------------
154
168
 
155
169
  export type TaskAction = "read" | "write";
@@ -191,6 +205,35 @@ export interface VendChildResult {
191
205
  expiresAt: string;
192
206
  }
193
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // Usage telemetry (hq-pro `/v1/usage` + `/v1/usage/opt-in`)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export interface TelemetryOptInResponse {
213
+ enabled: boolean;
214
+ updatedAt: string | null;
215
+ }
216
+
217
+ export interface UsageBatch {
218
+ machineId: string;
219
+ installerVersion: string;
220
+ /**
221
+ * Sanitized event rows. Each row is a plain object containing only the
222
+ * fields in the server's KEEP allowlist (sessionId, timestamp, uuid, cwd,
223
+ * gitBranch, userType, model, inputTokens, outputTokens,
224
+ * cacheCreationInputTokens, cacheReadInputTokens). Any extra field is
225
+ * rejected by hq-pro with `unexpected-event-field`, so the sanitizer in
226
+ * `./telemetry.ts` is the only thing allowed to produce these.
227
+ */
228
+ events: Array<Record<string, unknown>>;
229
+ }
230
+
231
+ export interface UsageIngestResult {
232
+ ok: boolean;
233
+ written: number;
234
+ skipped: Array<{ index: number; code: string; error: string }>;
235
+ }
236
+
194
237
  // ---------------------------------------------------------------------------
195
238
  // Retry config
196
239
  // ---------------------------------------------------------------------------
@@ -213,6 +256,7 @@ async function sleep(ms: number): Promise<void> {
213
256
  export class VaultClient {
214
257
  private readonly apiUrl: string;
215
258
  private readonly getAuthToken: () => Promise<string>;
259
+ private readonly clientInfo: ClientInfo | undefined;
216
260
 
217
261
  constructor(config: VaultServiceConfig) {
218
262
  this.apiUrl = config.apiUrl.replace(/\/+$/, "");
@@ -227,6 +271,7 @@ export class VaultClient {
227
271
  typeof tok === "function"
228
272
  ? async () => tok()
229
273
  : async () => tok;
274
+ this.clientInfo = config.clientInfo;
230
275
  }
231
276
 
232
277
  // -- Membership operations ------------------------------------------------
@@ -461,8 +506,52 @@ export class VaultClient {
461
506
  }> => {
462
507
  return this.post("/sts/vend-self", input);
463
508
  },
509
+
510
+ /**
511
+ * Vend STS-scoped credentials for an entity's S3 bucket.
512
+ * Backed by `POST /entities/{uid}/sts` — simpler than vend-child,
513
+ * returns full-bucket read credentials for the entity.
514
+ */
515
+ vendForEntity: async (entityUid: string): Promise<EntityStsResult> => {
516
+ const data = await this.post<EntityStsResult>(
517
+ `/entities/${encodeURIComponent(entityUid)}/sts`,
518
+ {},
519
+ );
520
+ return data;
521
+ },
464
522
  };
465
523
 
524
+ // -- Usage telemetry ------------------------------------------------------
525
+ //
526
+ // The server resolves `personUid` from the JWT (`extractCallerSub` →
527
+ // `resolveCallerPersonUid` in hq-pro `src/vault-service/handlers/_shared.ts`)
528
+ // and explicitly rejects any request body that carries a top-level
529
+ // `personUid`. So clients only send `{ machineId, installerVersion, events }`
530
+ // — tenant isolation is preserved no matter how this client is wrapped.
531
+
532
+ /**
533
+ * `GET /v1/usage/opt-in` — read whether the authenticated caller has opted
534
+ * in to per-event usage telemetry. Defaults to `false` server-side when the
535
+ * person row carries no `telemetryOptIn` field. Callers should treat any
536
+ * thrown error as "unknown — fall back to the local gate" rather than
537
+ * either yes or no; see `./telemetry.ts::collectAndSendTelemetry`.
538
+ */
539
+ async getTelemetryOptIn(): Promise<TelemetryOptInResponse> {
540
+ return this.get<TelemetryOptInResponse>("/v1/usage/opt-in");
541
+ }
542
+
543
+ /**
544
+ * `POST /v1/usage` — upload a batch of sanitized telemetry events.
545
+ *
546
+ * `personUid` MUST NOT appear in the batch — server-side resolution from
547
+ * the JWT is the only path. The server caps the body at 256 KiB and the
548
+ * event list at 100 rows; the collector in `./telemetry.ts` enforces a
549
+ * 1 MiB pre-flush cap which is the binding limit in practice.
550
+ */
551
+ async postUsage(batch: UsageBatch): Promise<UsageIngestResult> {
552
+ return this.post<UsageIngestResult>("/v1/usage", batch);
553
+ }
554
+
466
555
  // -- HTTP primitives with retry -------------------------------------------
467
556
 
468
557
  private async get<T>(path: string): Promise<T> {
@@ -485,6 +574,7 @@ export class VaultClient {
485
574
  const headers: Record<string, string> = {
486
575
  Authorization: `Bearer ${await this.getAuthToken()}`,
487
576
  Accept: "application/json",
577
+ ...buildClientHeaders(this.clientInfo),
488
578
  };
489
579
 
490
580
  const init: RequestInit = { method, headers };