@indigoai-us/hq-cloud 6.15.0 → 6.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/skill-telemetry.ts
DELETED
|
@@ -1,1439 +0,0 @@
|
|
|
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
|
-
* Codex CLI is captured too, from its own rollout logs at
|
|
27
|
-
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. Codex records cwd +
|
|
28
|
-
* sessionId ONCE in a leading `session_meta` line (not on every row). Two Codex
|
|
29
|
-
* paths feed the same wire shape, scope filter, batcher, and per-file cursor:
|
|
30
|
-
* - Typed (`source: "typed"`) — a slash command, including an HQ skill, e.g.
|
|
31
|
-
* `/indigo:hello-world`, is logged verbatim as a later `event_msg`
|
|
32
|
-
* `user_message` (Codex does not expand it). Parallels Claude's typed path.
|
|
33
|
-
* - Model-driven (`source: "model"`) — Codex has no discrete "Skill tool_use"
|
|
34
|
-
* event like Claude. Instead it *runs* a skill by reading its instruction
|
|
35
|
-
* file: the model issues a shell command that reads `…/skills/<name>/
|
|
36
|
-
* SKILL.md`. Codex logs that exec in one of two shapes depending on CLI
|
|
37
|
-
* version — an `event_msg` `exec_command_end` (with `turn_id`, `cwd`, and a
|
|
38
|
-
* `parsed_cmd` it tags `type: "read"`) or a `response_item` `function_call`
|
|
39
|
-
* named `exec_command` (command + `workdir` in its `arguments`). Both are
|
|
40
|
-
* handled. We treat the read as one invocation of `<name>`. A single use
|
|
41
|
-
* re-reads the file several times (line ranges, greps) and a version may log
|
|
42
|
-
* both shapes for one exec, so events are deduped per (sessionId, turn_id,
|
|
43
|
-
* skill) — at most one per Codex turn. Edits to a SKILL.md travel via
|
|
44
|
-
* `apply_patch` (authoring, not using) and never reach this path, so skill
|
|
45
|
-
* development is not miscounted as usage.
|
|
46
|
-
*
|
|
47
|
-
* Privacy: raw `<command-args>` / `input.args` content is NEVER sent to the
|
|
48
|
-
* cloud — only a `hasArgs` boolean. This matches the message-stripping posture
|
|
49
|
-
* of `./telemetry.ts::sanitizeRow`, which deliberately drops all prompt/tool
|
|
50
|
-
* content client-side. Flip `INCLUDE_ARGS_PREVIEW` only with a deliberate
|
|
51
|
-
* privacy review and a matching server-side allowlist change.
|
|
52
|
-
*
|
|
53
|
-
* Trust model + error handling are identical to `./telemetry.ts`: personUid is
|
|
54
|
-
* resolved server-side from the JWT (never the body), and all errors are
|
|
55
|
-
* swallowed so telemetry never aborts or delays a sync.
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
import { createHash } from "node:crypto";
|
|
59
|
-
import { promises as fs } from "node:fs";
|
|
60
|
-
import * as os from "node:os";
|
|
61
|
-
import * as path from "node:path";
|
|
62
|
-
|
|
63
|
-
import {
|
|
64
|
-
buildRepoCompanyMap,
|
|
65
|
-
resolveCompanyForCwd,
|
|
66
|
-
resolveCompanyForSkill,
|
|
67
|
-
type RepoCompanyMap,
|
|
68
|
-
} from "./company-resolver.js";
|
|
69
|
-
import {
|
|
70
|
-
canonicalVaultSegmentSpelling,
|
|
71
|
-
encodeLocalVaultSegment,
|
|
72
|
-
} from "./local-path-codec.js";
|
|
73
|
-
import type {
|
|
74
|
-
SkillInvocationBatch,
|
|
75
|
-
SkillInvocationIngestResult,
|
|
76
|
-
TelemetryOptInResponse,
|
|
77
|
-
} from "./vault-client.js";
|
|
78
|
-
|
|
79
|
-
// ── Public surface ────────────────────────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
export interface SkillTelemetryClientSurface {
|
|
82
|
-
getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
|
|
83
|
-
postSkillInvocations(
|
|
84
|
-
batch: SkillInvocationBatch,
|
|
85
|
-
): Promise<SkillInvocationIngestResult>;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface CollectSkillTelemetryOptions {
|
|
89
|
-
client: SkillTelemetryClientSurface;
|
|
90
|
-
machineId: string;
|
|
91
|
-
installerVersion: string;
|
|
92
|
-
/**
|
|
93
|
-
* When set, only invocations whose recorded `cwd` equals this path are
|
|
94
|
-
* emitted — scoping capture to the HQ project and excluding skill usage in
|
|
95
|
-
* unrelated repos on the same machine. The walk still covers all of
|
|
96
|
-
* `~/.claude/projects` (so the cursor stays consistent and no session is
|
|
97
|
-
* silently missed by a project-dir-name encoding guess), but non-matching
|
|
98
|
-
* events are dropped before they are batched. Omit to capture every project.
|
|
99
|
-
*/
|
|
100
|
-
hqRoot?: string;
|
|
101
|
-
/** Override `~/.claude/projects` for tests. */
|
|
102
|
-
claudeProjectsRoot?: string;
|
|
103
|
-
/** Override `~/.codex/sessions` (the Codex CLI rollout root) for tests. */
|
|
104
|
-
codexSessionsRoot?: string;
|
|
105
|
-
/** Override `~/.hq/skill-telemetry-cursor.json` for tests. */
|
|
106
|
-
cursorPath?: string;
|
|
107
|
-
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
108
|
-
menubarPath?: string;
|
|
109
|
-
/**
|
|
110
|
-
* Maximum transcript bytes to inspect per source (Claude or Codex) in one
|
|
111
|
-
* collection pass. The next pass resumes from the last complete line.
|
|
112
|
-
* Override for deterministic bounded-scan tests.
|
|
113
|
-
*/
|
|
114
|
-
maxScanBytesPerSource?: number;
|
|
115
|
-
/**
|
|
116
|
-
* Override skillVersion resolution (skills-first-class US-015). Given a skill
|
|
117
|
-
* name, return its content-hash version marker (`sha256:<hex>`) or undefined.
|
|
118
|
-
* Defaults to hashing `<hqRoot>/.claude/skills/<skill>/SKILL.md` via
|
|
119
|
-
* {@link computeSkillVersion}. Injected in tests to decouple emission from the
|
|
120
|
-
* on-disk skills tree.
|
|
121
|
-
*/
|
|
122
|
-
resolveSkillVersion?: (
|
|
123
|
-
skill: string,
|
|
124
|
-
) => Promise<string | undefined> | string | undefined;
|
|
125
|
-
/** Diagnostic sink. No-op by default. */
|
|
126
|
-
log?: (msg: string) => void;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export interface CollectSkillTelemetryResult {
|
|
130
|
-
enabled: boolean;
|
|
131
|
-
optInSource: "server" | "menubar-fallback" | "skipped";
|
|
132
|
-
filesScanned: number;
|
|
133
|
-
eventsSent: number;
|
|
134
|
-
batchesSent: number;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** A single extracted skill-invocation event. Mirrors the server allowlist in
|
|
138
|
-
* `apps/hq-pro/src/vault-service/handlers/skill-invocations.ts` (KEEP_FIELDS).
|
|
139
|
-
* Any drift surfaces as `unexpected-event-field` in the ingest result. */
|
|
140
|
-
export interface SkillEvent {
|
|
141
|
-
skill: string;
|
|
142
|
-
source: "typed" | "model";
|
|
143
|
-
sessionId?: string;
|
|
144
|
-
timestamp?: string;
|
|
145
|
-
uuid?: string;
|
|
146
|
-
cwd?: string;
|
|
147
|
-
hasArgs: boolean;
|
|
148
|
-
/**
|
|
149
|
-
* Content-version marker (skills-first-class US-015) — the sha256 of the
|
|
150
|
-
* skill's SKILL.md, `sha256:<hex>`. The version half of the analytics-v2 usage
|
|
151
|
-
* join key (`skill_uid` + `skillVersion`), so a run maps to the version that
|
|
152
|
-
* produced it. Resolved + stamped at capture (see `computeSkillVersion` +
|
|
153
|
-
* `collectAndSendSkillTelemetry`); OPTIONAL — absent when the SKILL.md can't
|
|
154
|
-
* be located, and additive on the wire (old clients/rows simply omit it).
|
|
155
|
-
*/
|
|
156
|
-
skillVersion?: string;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Privacy switch — keep false (see file header). When false, raw argument text
|
|
160
|
-
// never leaves the machine; only the `hasArgs` boolean is emitted.
|
|
161
|
-
const INCLUDE_ARGS_PREVIEW = false;
|
|
162
|
-
|
|
163
|
-
// ── Cursor schema (independent from the token collector's) ──────────────────────
|
|
164
|
-
|
|
165
|
-
type SkillTelemetrySource = "claude" | "codex";
|
|
166
|
-
|
|
167
|
-
interface PendingLineCursor {
|
|
168
|
-
/** Offset of the unterminated JSONL record's first byte. */
|
|
169
|
-
start: number;
|
|
170
|
-
/** Furthest byte inspected for that record; no transcript bytes are stored. */
|
|
171
|
-
scannedOffset: number;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
interface CodexCursorState {
|
|
175
|
-
/** Latest turn context, needed by function_call rows after a bounded resume. */
|
|
176
|
-
turnId?: string;
|
|
177
|
-
/** Dedupe keys from the active turn, so repeated skill reads stay collapsed. */
|
|
178
|
-
seen?: string[];
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
interface CursorEntry {
|
|
182
|
-
offset: number;
|
|
183
|
-
mtime: number;
|
|
184
|
-
pendingLine?: PendingLineCursor;
|
|
185
|
-
codex?: CodexCursorState;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
interface SkillCursor {
|
|
189
|
-
version: string;
|
|
190
|
-
files: Record<string, CursorEntry>;
|
|
191
|
-
/** Rotate each runtime's file order so one growing backlog cannot starve peers. */
|
|
192
|
-
lastScannedBySource?: Partial<Record<SkillTelemetrySource, string>>;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function clonePendingLine(value: unknown): PendingLineCursor | undefined {
|
|
196
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
197
|
-
const pending = value as Partial<PendingLineCursor>;
|
|
198
|
-
const { start, scannedOffset } = pending;
|
|
199
|
-
if (
|
|
200
|
-
typeof start !== "number" ||
|
|
201
|
-
typeof scannedOffset !== "number" ||
|
|
202
|
-
!Number.isSafeInteger(start) ||
|
|
203
|
-
!Number.isSafeInteger(scannedOffset) ||
|
|
204
|
-
start < 0 ||
|
|
205
|
-
scannedOffset < start
|
|
206
|
-
) {
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
return { start, scannedOffset };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function cloneCodexCursorState(value: unknown): CodexCursorState | undefined {
|
|
213
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
214
|
-
const state = value as Partial<CodexCursorState>;
|
|
215
|
-
const turnId = typeof state.turnId === "string" ? state.turnId : undefined;
|
|
216
|
-
const seen = Array.isArray(state.seen)
|
|
217
|
-
? state.seen.filter((entry): entry is string => typeof entry === "string")
|
|
218
|
-
: [];
|
|
219
|
-
return turnId === undefined && seen.length === 0 ? undefined : { turnId, seen };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function snapshotCodexCursorState(
|
|
223
|
-
turnId: string | undefined,
|
|
224
|
-
seen: Set<string> | undefined,
|
|
225
|
-
): CodexCursorState | undefined {
|
|
226
|
-
if (turnId === undefined && (seen === undefined || seen.size === 0)) return undefined;
|
|
227
|
-
return { turnId, ...(seen && seen.size > 0 ? { seen: [...seen] } : {}) };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function rotateFilesFromLast(
|
|
231
|
-
files: string[],
|
|
232
|
-
lastScanned: string | undefined,
|
|
233
|
-
): string[] {
|
|
234
|
-
if (!lastScanned) return files;
|
|
235
|
-
const index = files.indexOf(lastScanned);
|
|
236
|
-
return index < 0 ? files : [...files.slice(index + 1), ...files.slice(0, index + 1)];
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function loadCursor(cursorPath: string): Promise<SkillCursor> {
|
|
240
|
-
try {
|
|
241
|
-
const raw = await fs.readFile(cursorPath, "utf-8");
|
|
242
|
-
const parsed = JSON.parse(raw) as Partial<SkillCursor>;
|
|
243
|
-
if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
|
|
244
|
-
return {
|
|
245
|
-
version: parsed.version ?? "1",
|
|
246
|
-
files: parsed.files as Record<string, CursorEntry>,
|
|
247
|
-
...(parsed.lastScannedBySource && typeof parsed.lastScannedBySource === "object"
|
|
248
|
-
? {
|
|
249
|
-
lastScannedBySource: parsed.lastScannedBySource as Partial<
|
|
250
|
-
Record<SkillTelemetrySource, string>
|
|
251
|
-
>,
|
|
252
|
-
}
|
|
253
|
-
: {}),
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
} catch {
|
|
257
|
-
// Missing / unparseable — start fresh.
|
|
258
|
-
}
|
|
259
|
-
return { version: "1", files: {} };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
async function saveCursor(cursorPath: string, cursor: SkillCursor): Promise<void> {
|
|
263
|
-
await fs.mkdir(path.dirname(cursorPath), { recursive: true });
|
|
264
|
-
const tmp = `${cursorPath}.tmp`;
|
|
265
|
-
await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
|
|
266
|
-
await fs.rename(tmp, cursorPath);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
|
|
270
|
-
try {
|
|
271
|
-
const raw = await fs.readFile(menubarPath, "utf-8");
|
|
272
|
-
const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
|
|
273
|
-
return parsed.telemetryEnabled === true;
|
|
274
|
-
} catch {
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// ── Extractor ───────────────────────────────────────────────────────────────
|
|
280
|
-
|
|
281
|
-
const CMD_NAME = /<command-name>\s*\/?([^<]+?)\s*<\/command-name>/;
|
|
282
|
-
const CMD_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
|
|
283
|
-
|
|
284
|
-
function rowText(content: unknown): string {
|
|
285
|
-
if (typeof content === "string") return content;
|
|
286
|
-
if (Array.isArray(content)) {
|
|
287
|
-
return content
|
|
288
|
-
.map((b) => (b && typeof b === "object" && typeof (b as Record<string, unknown>).text === "string"
|
|
289
|
-
? ((b as Record<string, unknown>).text as string)
|
|
290
|
-
: ""))
|
|
291
|
-
.join(" ");
|
|
292
|
-
}
|
|
293
|
-
return "";
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Extract zero or more skill-invocation events from a single parsed session
|
|
298
|
-
* row. A `user` row yields at most one typed command; an `assistant` row can
|
|
299
|
-
* carry multiple `Skill` tool_use blocks (rare, but handled).
|
|
300
|
-
*/
|
|
301
|
-
export function extractSkillEvents(row: unknown): SkillEvent[] {
|
|
302
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
303
|
-
const obj = row as Record<string, unknown>;
|
|
304
|
-
const type = obj.type;
|
|
305
|
-
const msg =
|
|
306
|
-
obj.message && typeof obj.message === "object" && !Array.isArray(obj.message)
|
|
307
|
-
? (obj.message as Record<string, unknown>)
|
|
308
|
-
: undefined;
|
|
309
|
-
if (!msg) return [];
|
|
310
|
-
|
|
311
|
-
const sessionId = typeof obj.sessionId === "string" ? obj.sessionId : undefined;
|
|
312
|
-
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
313
|
-
const cwd = typeof obj.cwd === "string" ? obj.cwd : undefined;
|
|
314
|
-
const rowUuid = typeof obj.uuid === "string" ? obj.uuid : undefined;
|
|
315
|
-
|
|
316
|
-
// Path A — user-typed slash command.
|
|
317
|
-
if (type === "user") {
|
|
318
|
-
const text = rowText(msg.content);
|
|
319
|
-
const m = CMD_NAME.exec(text);
|
|
320
|
-
if (!m) return [];
|
|
321
|
-
const a = CMD_ARGS.exec(text);
|
|
322
|
-
return [
|
|
323
|
-
{
|
|
324
|
-
skill: m[1].trim(),
|
|
325
|
-
source: "typed",
|
|
326
|
-
sessionId,
|
|
327
|
-
timestamp,
|
|
328
|
-
cwd,
|
|
329
|
-
uuid: rowUuid,
|
|
330
|
-
hasArgs: Boolean(a && a[1].trim()),
|
|
331
|
-
},
|
|
332
|
-
];
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Path B — model-invoked Skill tool_use.
|
|
336
|
-
if (type === "assistant" && Array.isArray(msg.content)) {
|
|
337
|
-
const out: SkillEvent[] = [];
|
|
338
|
-
for (const blk of msg.content as unknown[]) {
|
|
339
|
-
if (!blk || typeof blk !== "object") continue;
|
|
340
|
-
const b = blk as Record<string, unknown>;
|
|
341
|
-
if (b.type !== "tool_use" || b.name !== "Skill") continue;
|
|
342
|
-
const input =
|
|
343
|
-
b.input && typeof b.input === "object" && !Array.isArray(b.input)
|
|
344
|
-
? (b.input as Record<string, unknown>)
|
|
345
|
-
: {};
|
|
346
|
-
const skill = typeof input.skill === "string" ? input.skill : "";
|
|
347
|
-
if (!skill) continue;
|
|
348
|
-
const args = input.args;
|
|
349
|
-
out.push({
|
|
350
|
-
skill,
|
|
351
|
-
source: "model",
|
|
352
|
-
sessionId,
|
|
353
|
-
timestamp,
|
|
354
|
-
cwd,
|
|
355
|
-
// Prefer the tool_use block id (stable, globally unique) for dedup.
|
|
356
|
-
uuid: typeof b.id === "string" ? b.id : rowUuid,
|
|
357
|
-
hasArgs: typeof args === "string" ? args.trim().length > 0 : Boolean(args),
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
return out;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return [];
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ── Codex rollout extractor ───────────────────────────────────────────────────
|
|
367
|
-
|
|
368
|
-
/** Match a leading slash command in a Codex `user_message`: a single token
|
|
369
|
-
* (no internal "/", so file paths like "/etc/hosts" are rejected) optionally
|
|
370
|
-
* followed by whitespace + args. Group 1 = skill/command, group 2 = args. */
|
|
371
|
-
const CODEX_CMD = /^\/([^/\s]+)(?:\s+([\s\S]*))?$/;
|
|
372
|
-
|
|
373
|
-
/** Parse cwd + sessionId from a Codex `session_meta` rollout line. Returns null
|
|
374
|
-
* for any other line type. */
|
|
375
|
-
export function parseCodexSessionMeta(
|
|
376
|
-
row: unknown,
|
|
377
|
-
): { sessionId?: string; cwd?: string } | null {
|
|
378
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
379
|
-
const obj = row as Record<string, unknown>;
|
|
380
|
-
if (obj.type !== "session_meta") return null;
|
|
381
|
-
const payload =
|
|
382
|
-
obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
|
|
383
|
-
? (obj.payload as Record<string, unknown>)
|
|
384
|
-
: {};
|
|
385
|
-
const sessionId = typeof payload.id === "string" ? payload.id : undefined;
|
|
386
|
-
const cwd = typeof payload.cwd === "string" ? payload.cwd : undefined;
|
|
387
|
-
return { sessionId, cwd };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/** The `turn_id` a Codex rollout row belongs to, when it carries one
|
|
391
|
-
* (`turn_context` and `exec_command_end` do; bare `function_call` execs do
|
|
392
|
-
* not). Used to track the running turn so the function_call exec shape can be
|
|
393
|
-
* attributed to the turn that preceded it. */
|
|
394
|
-
export function codexRowTurnId(row: unknown): string | undefined {
|
|
395
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
|
|
396
|
-
const payload = (row as Record<string, unknown>).payload;
|
|
397
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
398
|
-
const t = (payload as Record<string, unknown>).turn_id;
|
|
399
|
-
return typeof t === "string" ? t : undefined;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Extract a typed skill/slash-command invocation from a Codex `event_msg`
|
|
404
|
-
* `user_message` row. Session context (cwd, sessionId) lives in the file's
|
|
405
|
-
* leading `session_meta` line and is threaded in via `ctx`. Returns 0 or 1
|
|
406
|
-
* event (a Codex user_message carries at most one command).
|
|
407
|
-
*/
|
|
408
|
-
export function extractCodexSkillEvents(
|
|
409
|
-
row: unknown,
|
|
410
|
-
ctx: { sessionId?: string; cwd?: string },
|
|
411
|
-
): SkillEvent[] {
|
|
412
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
413
|
-
const obj = row as Record<string, unknown>;
|
|
414
|
-
if (obj.type !== "event_msg") return [];
|
|
415
|
-
const payload =
|
|
416
|
-
obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
|
|
417
|
-
? (obj.payload as Record<string, unknown>)
|
|
418
|
-
: undefined;
|
|
419
|
-
if (!payload || payload.type !== "user_message") return [];
|
|
420
|
-
const message = typeof payload.message === "string" ? payload.message : "";
|
|
421
|
-
const m = CODEX_CMD.exec(message.trim());
|
|
422
|
-
if (!m) return [];
|
|
423
|
-
const skill = m[1].trim();
|
|
424
|
-
if (!skill) return [];
|
|
425
|
-
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
426
|
-
// Codex events carry no per-event id, so synthesize a stable, content-derived
|
|
427
|
-
// uuid (sessionId + timestamp + skill are unique per invocation) for
|
|
428
|
-
// idempotent re-delivery — the server dedups on the composite eventKey.
|
|
429
|
-
const uuid =
|
|
430
|
-
ctx.sessionId !== undefined && timestamp !== undefined
|
|
431
|
-
? `codex:${ctx.sessionId}:${timestamp}:${skill}`
|
|
432
|
-
: undefined;
|
|
433
|
-
return [
|
|
434
|
-
{
|
|
435
|
-
skill,
|
|
436
|
-
source: "typed",
|
|
437
|
-
sessionId: ctx.sessionId,
|
|
438
|
-
timestamp,
|
|
439
|
-
cwd: ctx.cwd,
|
|
440
|
-
uuid,
|
|
441
|
-
hasArgs: Boolean(m[2] && m[2].trim()),
|
|
442
|
-
},
|
|
443
|
-
];
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** Match a `…/skills/<name>/SKILL.md` path inside a shell command. `skills/`
|
|
447
|
-
* may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
|
|
448
|
-
* and `<name>` is always the directory immediately above SKILL.md — captured
|
|
449
|
-
* as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
|
|
450
|
-
* `<name>`, not `hq`. The segment classes deliberately exclude `/` so segment
|
|
451
|
-
* boundaries are unambiguous and non-matching `skills/...` paths cannot
|
|
452
|
-
* catastrophically backtrack. */
|
|
453
|
-
const CODEX_SKILL_FILE =
|
|
454
|
-
/(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
|
|
455
|
-
|
|
456
|
-
/** A company skill read via its REAL path `companies/<slug>/skills/<name>/
|
|
457
|
-
* SKILL.md`. Codex loads a skill by *reading* its file, and a company skill's
|
|
458
|
-
* real file lives under `companies/<slug>/skills/` — so, unlike the namespaced
|
|
459
|
-
* `.claude/skills/<slug>:<name>/` symlink (whose captured directory already
|
|
460
|
-
* carries the `<slug>:` prefix), this path names the owning company in a
|
|
461
|
-
* SEPARATE `<slug>` segment that {@link CODEX_SKILL_FILE} discards, leaving a
|
|
462
|
-
* bare `<name>` that no prefix-based attribution can place (the company view
|
|
463
|
-
* then misses the run). Group 1 = `<slug>`, group 2 = `<name>`: we rebuild the
|
|
464
|
-
* canonical `<slug>:<name>` invocation token from them so the event flows
|
|
465
|
-
* through the SAME attribution as every other runtime — the client
|
|
466
|
-
* `resolveCompanyForSkill` and the server rollup both key off that `<slug>:`
|
|
467
|
-
* prefix — and shares one `skill_uid` with the Claude/typed forms. The slug
|
|
468
|
-
* segment excludes `:` (a real company slug has none); skill discovery is one
|
|
469
|
-
* level deep, so `<name>` is a single segment. The leading boundary admits a
|
|
470
|
-
* RELATIVE `companies/…` path too (preceded by whitespace/quote/start, not just
|
|
471
|
-
* `/`), since Codex often reads it relative to the run cwd. */
|
|
472
|
-
const CODEX_COMPANY_SKILL_FILE =
|
|
473
|
-
/(?:^|[\s/'"])companies\/([^/\s'":]+)\/skills\/([^/\s'"]+)\/SKILL\.md\b/;
|
|
474
|
-
|
|
475
|
-
/** Pull the shell command string out of a Codex `exec_command_end` `command`,
|
|
476
|
-
* which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
|
|
477
|
-
* tolerate a bare string too. */
|
|
478
|
-
function codexCommandString(command: unknown): string {
|
|
479
|
-
if (typeof command === "string") return command;
|
|
480
|
-
if (Array.isArray(command)) {
|
|
481
|
-
// The interpreter + flags lead; the actual command is the trailing string.
|
|
482
|
-
for (let i = command.length - 1; i >= 0; i--) {
|
|
483
|
-
if (typeof command[i] === "string") return command[i] as string;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
return "";
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** Classify a Codex exec from its own `parsed_cmd`:
|
|
490
|
-
* - `"read"` — every classified sub-command is a read; authoritative.
|
|
491
|
-
* - `"nonread"` — at least one write/apply_patch-style entry; authoritative,
|
|
492
|
-
* the command text is NOT consulted (Codex's call wins).
|
|
493
|
-
* - `"unknown"` — no usable classification (missing/empty); the caller then
|
|
494
|
-
* falls back to a read-verb check on the command text.
|
|
495
|
-
*/
|
|
496
|
-
function classifyCodexExec(parsedCmd: unknown): "read" | "nonread" | "unknown" {
|
|
497
|
-
if (!Array.isArray(parsedCmd)) return "unknown";
|
|
498
|
-
let sawRead = false;
|
|
499
|
-
for (const entry of parsedCmd) {
|
|
500
|
-
if (!entry || typeof entry !== "object") continue;
|
|
501
|
-
const t = (entry as Record<string, unknown>).type;
|
|
502
|
-
if (typeof t !== "string") continue;
|
|
503
|
-
if (t !== "read") return "nonread";
|
|
504
|
-
sawRead = true;
|
|
505
|
-
}
|
|
506
|
-
return sawRead ? "read" : "unknown";
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// A leading read verb in the command — the positive signal that an exec is
|
|
510
|
-
// inspecting SKILL.md rather than rewriting it. Anchored after an optional
|
|
511
|
-
// `cd …;`/env-var prefix so `sed`, `rg`, `cat`, … are recognized at the head of
|
|
512
|
-
// the real command.
|
|
513
|
-
const CODEX_READ_VERB =
|
|
514
|
-
/(?:^|[;&|]\s*|\bcd\s+[^\s;]+\s*;\s*)(sed|cat|head|tail|nl|rg|grep|less|bat|wc|awk|cut|fold|view|print)\b/;
|
|
515
|
-
// Writing into the skill file disqualifies regardless of a read verb elsewhere.
|
|
516
|
-
const CODEX_WRITE_TO_SKILL = /(?:>>?|\btee\b)[^\n]*\/SKILL\.md\b/;
|
|
517
|
-
|
|
518
|
-
/** Codex tool-call names that run a shell command (the `response_item`
|
|
519
|
-
* `function_call` form). Excludes `apply_patch` — that is an edit, not a read. */
|
|
520
|
-
const CODEX_EXEC_TOOLS = new Set([
|
|
521
|
-
"exec_command",
|
|
522
|
-
"shell",
|
|
523
|
-
"local_shell",
|
|
524
|
-
"local_shell_call",
|
|
525
|
-
"bash",
|
|
526
|
-
"container.exec",
|
|
527
|
-
]);
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Normalize a completed Codex exec from either shape the CLI emits (it varies
|
|
531
|
-
* by version), returning the command string, the turn it belongs to, the run
|
|
532
|
-
* `cwd`, and any `parsed_cmd` classification — or null when the row is neither.
|
|
533
|
-
* - `event_msg` / `exec_command_end`: `command` array, own `turn_id` + `cwd`,
|
|
534
|
-
* and a `parsed_cmd` Codex tags `type: "read"`.
|
|
535
|
-
* - `response_item` / `function_call` (name `exec_command`/`shell`/…): the
|
|
536
|
-
* command lives in `arguments` (a JSON string) as `cmd`/`command`, the run
|
|
537
|
-
* dir as `workdir`. No `turn_id`/`parsed_cmd` on the row, so the turn comes
|
|
538
|
-
* from the scan's running `ctx.turnId` (tracked from `turn_context`) and
|
|
539
|
-
* read-intent is decided by the command text.
|
|
540
|
-
*/
|
|
541
|
-
function codexExecParams(
|
|
542
|
-
obj: Record<string, unknown>,
|
|
543
|
-
payload: Record<string, unknown>,
|
|
544
|
-
ctx: { cwd?: string; turnId?: string },
|
|
545
|
-
): { cmd: string; turnId?: string; cwd?: string; parsedCmd: unknown } | null {
|
|
546
|
-
if (obj.type === "event_msg" && payload.type === "exec_command_end") {
|
|
547
|
-
const cmd = codexCommandString(payload.command);
|
|
548
|
-
if (!cmd) return null;
|
|
549
|
-
return {
|
|
550
|
-
cmd,
|
|
551
|
-
turnId: typeof payload.turn_id === "string" ? payload.turn_id : ctx.turnId,
|
|
552
|
-
cwd: typeof payload.cwd === "string" ? payload.cwd : ctx.cwd,
|
|
553
|
-
parsedCmd: payload.parsed_cmd,
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
if (obj.type === "response_item" && payload.type === "function_call") {
|
|
557
|
-
const name = typeof payload.name === "string" ? payload.name : "";
|
|
558
|
-
if (!CODEX_EXEC_TOOLS.has(name)) return null;
|
|
559
|
-
let args: Record<string, unknown> = {};
|
|
560
|
-
const raw = payload.arguments;
|
|
561
|
-
if (typeof raw === "string") {
|
|
562
|
-
try {
|
|
563
|
-
const parsed = JSON.parse(raw);
|
|
564
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
565
|
-
args = parsed as Record<string, unknown>;
|
|
566
|
-
}
|
|
567
|
-
} catch {
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
571
|
-
args = raw as Record<string, unknown>;
|
|
572
|
-
}
|
|
573
|
-
const cmdRaw = args.cmd ?? args.command;
|
|
574
|
-
const cmd = typeof cmdRaw === "string" ? cmdRaw : codexCommandString(cmdRaw);
|
|
575
|
-
if (!cmd) return null;
|
|
576
|
-
return {
|
|
577
|
-
cmd,
|
|
578
|
-
turnId: ctx.turnId,
|
|
579
|
-
cwd: typeof args.workdir === "string" ? args.workdir : ctx.cwd,
|
|
580
|
-
parsedCmd: undefined,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* Extract a model-driven skill invocation from a completed Codex exec — the
|
|
588
|
-
* model ran a shell command that *reads* a skill's `SKILL.md`, which is how
|
|
589
|
-
* Codex loads and runs a skill (it has no discrete Skill tool_use). Handles both
|
|
590
|
-
* Codex exec shapes (see `codexExecParams`). Returns 0 or 1 event tagged
|
|
591
|
-
* `source: "model"`.
|
|
592
|
-
*
|
|
593
|
-
* Dedup is per (sessionId, turn_id, skill): a single skill use re-reads the file
|
|
594
|
-
* several times (line ranges, greps) and some Codex versions log both exec
|
|
595
|
-
* shapes for one exec, so the caller threads a `seen` Set to collapse them to
|
|
596
|
-
* one event per Codex turn. When `seen` is omitted (unit tests), no dedup is
|
|
597
|
-
* applied. Session context (sessionId, cwd, and the running turnId) comes via
|
|
598
|
-
* `ctx`; the row's own `cwd` is preferred when present.
|
|
599
|
-
*/
|
|
600
|
-
export function extractCodexSkillToolEvents(
|
|
601
|
-
row: unknown,
|
|
602
|
-
ctx: { sessionId?: string; cwd?: string; turnId?: string },
|
|
603
|
-
seen?: Set<string>,
|
|
604
|
-
): SkillEvent[] {
|
|
605
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
606
|
-
const obj = row as Record<string, unknown>;
|
|
607
|
-
const payload =
|
|
608
|
-
obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
|
|
609
|
-
? (obj.payload as Record<string, unknown>)
|
|
610
|
-
: undefined;
|
|
611
|
-
if (!payload) return [];
|
|
612
|
-
|
|
613
|
-
const exec = codexExecParams(obj, payload, ctx);
|
|
614
|
-
if (!exec) return [];
|
|
615
|
-
const { cmd } = exec;
|
|
616
|
-
// Real exec commands are tiny; avoid running regexes over pathological rows.
|
|
617
|
-
if (cmd.length > 100_000) return [];
|
|
618
|
-
const m = CODEX_SKILL_FILE.exec(cmd);
|
|
619
|
-
if (!m) return [];
|
|
620
|
-
// Confirm the exec is a read of the skill file, not a write to it. Codex's own
|
|
621
|
-
// classification leads; only when it is absent does the command text decide.
|
|
622
|
-
if (CODEX_WRITE_TO_SKILL.test(cmd)) return [];
|
|
623
|
-
const cls = classifyCodexExec(exec.parsedCmd);
|
|
624
|
-
if (cls === "nonread") return [];
|
|
625
|
-
if (cls === "unknown" && !CODEX_READ_VERB.test(cmd)) return [];
|
|
626
|
-
|
|
627
|
-
// Prefer the canonical `<slug>:<name>` token when the read is of a company
|
|
628
|
-
// skill's real file (`companies/<slug>/skills/<name>/SKILL.md`) — the slug is
|
|
629
|
-
// in the path but dropped by CODEX_SKILL_FILE, so reconstruct it here to keep
|
|
630
|
-
// the run attributable. Otherwise keep the last-segment name (core/personal/
|
|
631
|
-
// namespaced skills already carry their prefix in the captured directory).
|
|
632
|
-
const co = CODEX_COMPANY_SKILL_FILE.exec(cmd);
|
|
633
|
-
const skill = co ? `${co[1]}:${co[2]}` : m[1].trim();
|
|
634
|
-
if (!skill) return [];
|
|
635
|
-
|
|
636
|
-
const sessionId = ctx.sessionId;
|
|
637
|
-
const turnId = exec.turnId;
|
|
638
|
-
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
639
|
-
const cwd = exec.cwd;
|
|
640
|
-
|
|
641
|
-
// Dedup key: one invocation per (session, turn, skill). Fall back to the
|
|
642
|
-
// session when no turn_id is present (still collapses a turn's repeat reads
|
|
643
|
-
// for the common single-turn case, since they share a timestamp-free key).
|
|
644
|
-
const dedupKey = `${sessionId ?? ""}:${turnId ?? ""}:${skill}`;
|
|
645
|
-
if (seen) {
|
|
646
|
-
if (seen.has(dedupKey)) return [];
|
|
647
|
-
seen.add(dedupKey);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Synthesize a stable uuid so re-delivery across syncs is idempotent
|
|
651
|
-
// server-side. Prefer (session, turn) — globally unique per invocation; fall
|
|
652
|
-
// back to (session, timestamp) so distinct reads aren't all collapsed when a
|
|
653
|
-
// turn_id is somehow absent.
|
|
654
|
-
const uuid =
|
|
655
|
-
sessionId !== undefined && turnId !== undefined
|
|
656
|
-
? `codex:skill:${sessionId}:${turnId}:${skill}`
|
|
657
|
-
: sessionId !== undefined && timestamp !== undefined
|
|
658
|
-
? `codex:skill:${sessionId}:${timestamp}:${skill}`
|
|
659
|
-
: undefined;
|
|
660
|
-
|
|
661
|
-
return [
|
|
662
|
-
{
|
|
663
|
-
skill,
|
|
664
|
-
source: "model",
|
|
665
|
-
sessionId,
|
|
666
|
-
timestamp,
|
|
667
|
-
cwd,
|
|
668
|
-
uuid,
|
|
669
|
-
hasArgs: false,
|
|
670
|
-
},
|
|
671
|
-
];
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/**
|
|
675
|
-
* Resolve a skill's content-version marker ("skillVersion", skills-first-class
|
|
676
|
-
* US-015) — the sha256 of its SKILL.md as `sha256:<hex>` (the canonical
|
|
677
|
-
* content-hash shape used across hq-cloud, e.g. `watcher.ts`). This is the
|
|
678
|
-
* version half of the analytics-v2 usage join key (`skill_uid` + `skillVersion`),
|
|
679
|
-
* so a captured run maps to the exact version that produced it.
|
|
680
|
-
*
|
|
681
|
-
* Resolution is best-effort against `<hqRoot>/.claude/skills/<skill>/SKILL.md` —
|
|
682
|
-
* the canonical single-source skills location, which holds an entry (a real dir
|
|
683
|
-
* or a symlink) for every skill by its FULL invocation name incl. namespace
|
|
684
|
-
* (`indigo:hello-world`, `personal:worktree`, `deploy`, …). A `.agents/skills/`
|
|
685
|
-
* fallback covers the Codex bridge layout. `readFile` follows symlinks, so a
|
|
686
|
-
* company/personal skill surfaced into `.claude/skills/` resolves transparently.
|
|
687
|
-
* Returns undefined when `hqRoot` is absent or the file can't be located/read,
|
|
688
|
-
* so the wire field is simply OMITTED — additive + backwards-compatible, exactly
|
|
689
|
-
* like the optional `companyUid` attribution.
|
|
690
|
-
*
|
|
691
|
-
* "At invocation time" is approximated by the SKILL.md's content AT CAPTURE TIME:
|
|
692
|
-
* the collector scans historical session logs during sync, so the file's current
|
|
693
|
-
* bytes are the closest available proxy for the version that ran. A skill edited
|
|
694
|
-
* between run and sync can differ — acceptable for a usage-rollup join key (the
|
|
695
|
-
* overwhelming majority of runs sync before any edit to the skill).
|
|
696
|
-
*/
|
|
697
|
-
export async function computeSkillVersion(
|
|
698
|
-
hqRoot: string | undefined,
|
|
699
|
-
skill: string,
|
|
700
|
-
): Promise<string | undefined> {
|
|
701
|
-
if (!hqRoot || !skill) return undefined;
|
|
702
|
-
// A skill token maps to exactly one directory name here; reject anything that
|
|
703
|
-
// could escape the skills dir (path traversal / separators). Namespaced names
|
|
704
|
-
// use `:` (not a separator) and pass.
|
|
705
|
-
if (skill.includes("/") || skill.includes("\\") || skill.includes("..")) {
|
|
706
|
-
return undefined;
|
|
707
|
-
}
|
|
708
|
-
// Codec boundary: callers hand us EITHER the canonical invocation name
|
|
709
|
-
// (`indigo:hello-world`) or an already-encoded local dir segment captured
|
|
710
|
-
// from a win32 path (`indigo%3Ahello-world`, Codex telemetry extraction).
|
|
711
|
-
// Normalize to canonical first — encoding an already-encoded name would
|
|
712
|
-
// look under `indigo%253A...` and miss the real wrapper — then encode for
|
|
713
|
-
// THIS host. Re-run the traversal guard on the decoded form: a crafted
|
|
714
|
-
// `%2F`/`%5C` escape must not smuggle a separator past the raw check.
|
|
715
|
-
const canonicalSkill = canonicalVaultSegmentSpelling(skill);
|
|
716
|
-
if (
|
|
717
|
-
canonicalSkill.includes("/") ||
|
|
718
|
-
canonicalSkill.includes("\\") ||
|
|
719
|
-
canonicalSkill.includes("..")
|
|
720
|
-
) {
|
|
721
|
-
return undefined;
|
|
722
|
-
}
|
|
723
|
-
const localSkillDirName = encodeLocalVaultSegment(canonicalSkill);
|
|
724
|
-
const candidates = [
|
|
725
|
-
path.join(hqRoot, ".claude", "skills", localSkillDirName, "SKILL.md"),
|
|
726
|
-
path.join(hqRoot, ".agents", "skills", localSkillDirName, "SKILL.md"),
|
|
727
|
-
];
|
|
728
|
-
// Colon-free names do not normalize (amplifier families are namespaced),
|
|
729
|
-
// so a win32 capture of a space-named skill's dir (`foo%20bar`) would
|
|
730
|
-
// re-encode to `foo%2520bar` and miss — also try the raw segment as-is.
|
|
731
|
-
if (skill !== localSkillDirName) {
|
|
732
|
-
candidates.push(
|
|
733
|
-
path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
|
|
734
|
-
path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
|
|
735
|
-
);
|
|
736
|
-
}
|
|
737
|
-
for (const candidate of candidates) {
|
|
738
|
-
try {
|
|
739
|
-
const bytes = await fs.readFile(candidate);
|
|
740
|
-
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
741
|
-
} catch {
|
|
742
|
-
// Not at this candidate — try the next.
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return undefined;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/** Shape the event for the wire. Drops raw args unless explicitly enabled.
|
|
749
|
-
*
|
|
750
|
-
* `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
|
|
751
|
-
* (`resolveCompanyForCwd`) and passes that `cmp_*` uid here. It is on the
|
|
752
|
-
* server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
|
|
753
|
-
* skill-invocations.ts`). When undefined (cwd maps to no company repo) the
|
|
754
|
-
* field is OMITTED — the server treats absence as unattributed/personal. The
|
|
755
|
-
* reserved value `unattributed` is never produced.
|
|
756
|
-
*
|
|
757
|
-
* `skillVersion` (US-015): the caller stamps `ev.skillVersion` from
|
|
758
|
-
* `computeSkillVersion` before shaping; a `sha256:<hex>` marker (or omitted when
|
|
759
|
-
* unresolved). Also on the server KEEP allowlist; additive + backwards-compatible. */
|
|
760
|
-
function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
|
|
761
|
-
const row: Record<string, unknown> = {
|
|
762
|
-
skill: ev.skill,
|
|
763
|
-
source: ev.source,
|
|
764
|
-
hasArgs: ev.hasArgs,
|
|
765
|
-
};
|
|
766
|
-
if (ev.sessionId !== undefined) row.sessionId = ev.sessionId;
|
|
767
|
-
if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
|
|
768
|
-
if (ev.uuid !== undefined) row.uuid = ev.uuid;
|
|
769
|
-
if (ev.cwd !== undefined) row.cwd = ev.cwd;
|
|
770
|
-
if (ev.skillVersion !== undefined) row.skillVersion = ev.skillVersion;
|
|
771
|
-
if (companyUid !== undefined) row.companyUid = companyUid;
|
|
772
|
-
// INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
|
|
773
|
-
// the guarded branch documents the (currently disabled) egress path.
|
|
774
|
-
if (INCLUDE_ARGS_PREVIEW) {
|
|
775
|
-
// Reserved: a server allowlist change must land before this is enabled.
|
|
776
|
-
}
|
|
777
|
-
return row;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// ── File walker ───────────────────────────────────────────────────────────────
|
|
781
|
-
|
|
782
|
-
async function listJsonlFiles(root: string): Promise<string[]> {
|
|
783
|
-
const out: string[] = [];
|
|
784
|
-
async function walk(dir: string): Promise<void> {
|
|
785
|
-
let entries;
|
|
786
|
-
try {
|
|
787
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
788
|
-
} catch {
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
for (const ent of entries) {
|
|
792
|
-
const full = path.join(dir, ent.name);
|
|
793
|
-
if (ent.isDirectory()) {
|
|
794
|
-
await walk(full);
|
|
795
|
-
} else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
|
|
796
|
-
out.push(full);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
await walk(root);
|
|
801
|
-
return out.sort((a, b) => a.localeCompare(b));
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Codex's `session_meta` is always the first, small line of a rollout. Read a
|
|
805
|
-
// bounded prefix from the top so a mid-file (cursor-resumed) scan still has
|
|
806
|
-
// session context without slurping a multi-MiB transcript.
|
|
807
|
-
const CODEX_META_PREFIX_BYTES = 64 * 1024;
|
|
808
|
-
|
|
809
|
-
/**
|
|
810
|
-
* Largest length passed to a single `FileHandle.read()`. Node's native fs.read
|
|
811
|
-
* asserts `args[3]->IsInt32()` on the length argument, so a single read whose
|
|
812
|
-
* length exceeds 2**31-1 (≈2 GiB) does NOT throw a catchable error — it aborts
|
|
813
|
-
* the whole process with SIGABRT. A skill/session log whose unread tail had
|
|
814
|
-
* grown past ~2 GiB crashed the entire `hq sync` runner this way
|
|
815
|
-
* (Sentry HQ-SYNC-WEB-15). 256 MiB stays comfortably within Int32 while keeping
|
|
816
|
-
* the read-loop iteration count small.
|
|
817
|
-
*/
|
|
818
|
-
const MAX_FS_READ_CHUNK_BYTES = 256 * 1024 * 1024;
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* A sync pass must never turn an unbounded transcript tail into one V8 string.
|
|
822
|
-
* Node aborts the process once Buffer#toString receives a length >= 2**31; this
|
|
823
|
-
* conservative cap is also below the smaller practical string ceilings across
|
|
824
|
-
* supported Node versions. Keep this assertion close to the call site so a
|
|
825
|
-
* future increase cannot reintroduce the native SIGTRAP failure.
|
|
826
|
-
*/
|
|
827
|
-
export const MAX_DECODE_BYTES = 256 * 1024 * 1024;
|
|
828
|
-
if (MAX_DECODE_BYTES >= 2 ** 31) {
|
|
829
|
-
throw new Error("MAX_DECODE_BYTES must remain below the V8 string length limit");
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/** Minimal structural view of the `FileHandle.read` we depend on. */
|
|
833
|
-
type ReadableFileHandle = {
|
|
834
|
-
read(
|
|
835
|
-
buffer: Buffer,
|
|
836
|
-
offset: number,
|
|
837
|
-
length: number,
|
|
838
|
-
position: number,
|
|
839
|
-
): Promise<{ bytesRead: number }>;
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* Read `length` bytes from `fh` starting at byte `position` into `buf`, issuing
|
|
844
|
-
* native reads no larger than `chunkBytes` so the length argument never exceeds
|
|
845
|
-
* Int32 and trips the SIGABRT-on-assert path (HQ-SYNC-WEB-15). Returns the total
|
|
846
|
-
* bytes actually read — which may be < `length` if the file was truncated
|
|
847
|
-
* between `stat` and this read (a 0-byte read means EOF, so we stop).
|
|
848
|
-
*/
|
|
849
|
-
export async function readFileRegion(
|
|
850
|
-
fh: ReadableFileHandle,
|
|
851
|
-
buf: Buffer,
|
|
852
|
-
position: number,
|
|
853
|
-
length: number,
|
|
854
|
-
chunkBytes: number = MAX_FS_READ_CHUNK_BYTES,
|
|
855
|
-
): Promise<number> {
|
|
856
|
-
let filled = 0;
|
|
857
|
-
while (filled < length) {
|
|
858
|
-
const chunk = Math.min(chunkBytes, length - filled);
|
|
859
|
-
const { bytesRead } = await fh.read(buf, filled, chunk, position + filled);
|
|
860
|
-
if (bytesRead === 0) break; // EOF / truncated since stat — stop cleanly.
|
|
861
|
-
filled += bytesRead;
|
|
862
|
-
}
|
|
863
|
-
return filled;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
/** Read cwd + sessionId from a Codex rollout's leading `session_meta` line.
|
|
867
|
-
* Always reads from offset 0 (independent of the byte cursor). Best-effort:
|
|
868
|
-
* any error → empty context (events then fail the hqRoot scope filter, which
|
|
869
|
-
* is the safe default). */
|
|
870
|
-
async function readCodexSessionContext(
|
|
871
|
-
filePath: string,
|
|
872
|
-
): Promise<{ sessionId?: string; cwd?: string }> {
|
|
873
|
-
try {
|
|
874
|
-
const fh = await fs.open(filePath, "r");
|
|
875
|
-
try {
|
|
876
|
-
const buf = Buffer.alloc(CODEX_META_PREFIX_BYTES);
|
|
877
|
-
const bytesRead = await readFileRegion(fh, buf, 0, CODEX_META_PREFIX_BYTES);
|
|
878
|
-
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n", 1)[0]?.trim();
|
|
879
|
-
if (!firstLine) return {};
|
|
880
|
-
return parseCodexSessionMeta(JSON.parse(firstLine)) ?? {};
|
|
881
|
-
} finally {
|
|
882
|
-
await fh.close();
|
|
883
|
-
}
|
|
884
|
-
} catch {
|
|
885
|
-
return {};
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
const MAX_BATCH_EVENTS = 100;
|
|
890
|
-
const MAX_BATCH_BYTES = 240 * 1024;
|
|
891
|
-
const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
|
|
892
|
-
const ROW_TRUNCATION_SUFFIX = "...[truncated]";
|
|
893
|
-
|
|
894
|
-
function jsonBytes(value: unknown): number {
|
|
895
|
-
return Buffer.byteLength(JSON.stringify(value), "utf-8");
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function truncateLongestStringField(row: Record<string, unknown>): boolean {
|
|
899
|
-
let longestKey: string | undefined;
|
|
900
|
-
let longestBytes = 0;
|
|
901
|
-
for (const [key, value] of Object.entries(row)) {
|
|
902
|
-
if (typeof value !== "string" || value.length === 0) continue;
|
|
903
|
-
const bytes = Buffer.byteLength(value, "utf-8");
|
|
904
|
-
if (bytes > longestBytes) {
|
|
905
|
-
longestBytes = bytes;
|
|
906
|
-
longestKey = key;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
if (longestKey === undefined) return false;
|
|
910
|
-
|
|
911
|
-
const value = row[longestKey] as string;
|
|
912
|
-
const keepChars =
|
|
913
|
-
value.length > ROW_TRUNCATION_SUFFIX.length
|
|
914
|
-
? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
|
|
915
|
-
: 0;
|
|
916
|
-
const next =
|
|
917
|
-
keepChars > 0
|
|
918
|
-
? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
|
|
919
|
-
: "";
|
|
920
|
-
if (next === value) return false;
|
|
921
|
-
row[longestKey] = next;
|
|
922
|
-
return true;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
function boundRowForPost(
|
|
926
|
-
row: Record<string, unknown>,
|
|
927
|
-
maxRowBytes: number,
|
|
928
|
-
): Record<string, unknown> | null {
|
|
929
|
-
if (maxRowBytes < 0) return null;
|
|
930
|
-
const bounded = { ...row };
|
|
931
|
-
while (jsonBytes(bounded) > maxRowBytes) {
|
|
932
|
-
if (!truncateLongestStringField(bounded)) return null;
|
|
933
|
-
}
|
|
934
|
-
return bounded;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
938
|
-
|
|
939
|
-
/**
|
|
940
|
-
* Scan, extract, and POST any new skill-invocation events.
|
|
941
|
-
*
|
|
942
|
-
* Cursor model (per-batch commit, matching the token collector for robustness):
|
|
943
|
-
* each file is scanned from its stored byte offset through a bounded region;
|
|
944
|
-
* extracted events carry the byte offset of the line they came from. Events are
|
|
945
|
-
* flushed in server-sized batches, and the cursor advances **per successful
|
|
946
|
-
* batch** — so if one batch in a large (e.g. first-run backfill) fails, the
|
|
947
|
-
* batches that already succeeded stay committed and only the rest re-send next
|
|
948
|
-
* sync. A bounded region settles only at a complete-line boundary, preventing
|
|
949
|
-
* a cursor from skipping an unread tail or resuming mid-line.
|
|
950
|
-
*
|
|
951
|
-
* Per-file commit rule:
|
|
952
|
-
* - All of a file's events sent OK (including zero-event files) → commit EOF,
|
|
953
|
-
* so quiet/non-skill tails are never re-scanned.
|
|
954
|
-
* - Some of a file's events failed → commit the max byte offset whose batch
|
|
955
|
-
* succeeded (partial progress); the remainder re-sends next sync.
|
|
956
|
-
* Server-side dedup on the composite eventKey makes any re-send idempotent.
|
|
957
|
-
* Rotation/truncation resets the offset to 0 (re-read from the top).
|
|
958
|
-
*/
|
|
959
|
-
export async function collectAndSendSkillTelemetry(
|
|
960
|
-
opts: CollectSkillTelemetryOptions,
|
|
961
|
-
): Promise<CollectSkillTelemetryResult> {
|
|
962
|
-
const home = os.homedir();
|
|
963
|
-
const claudeProjectsRoot =
|
|
964
|
-
opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
|
|
965
|
-
const codexSessionsRoot =
|
|
966
|
-
opts.codexSessionsRoot ?? path.join(home, ".codex", "sessions");
|
|
967
|
-
const cursorPath =
|
|
968
|
-
opts.cursorPath ?? path.join(home, ".hq", "skill-telemetry-cursor.json");
|
|
969
|
-
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
970
|
-
const log = opts.log ?? (() => {});
|
|
971
|
-
// Match the usage collector's per-runtime budget, with an independent hard
|
|
972
|
-
// decode ceiling as defence in depth for future callers/configuration.
|
|
973
|
-
const maxScanBytesPerSource = Math.min(
|
|
974
|
-
opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE,
|
|
975
|
-
MAX_DECODE_BYTES,
|
|
976
|
-
);
|
|
977
|
-
|
|
978
|
-
// Normalize the scope path once (drop a single trailing slash, keeping "/").
|
|
979
|
-
const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
980
|
-
const scopeCwd = opts.hqRoot !== undefined ? normalizePath(opts.hqRoot) : undefined;
|
|
981
|
-
|
|
982
|
-
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
983
|
-
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
984
|
-
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
985
|
-
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
986
|
-
? await buildRepoCompanyMap(opts.hqRoot)
|
|
987
|
-
: { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
|
|
988
|
-
|
|
989
|
-
// skillVersion resolution (US-015): resolve each skill's SKILL.md content hash
|
|
990
|
-
// ONCE per run (skills repeat across a session) and stamp it onto every event.
|
|
991
|
-
// Injectable for tests; defaults to hashing under hqRoot. Errors degrade to
|
|
992
|
-
// undefined so a resolver failure never blocks telemetry (additive field).
|
|
993
|
-
const resolveSkillVersion =
|
|
994
|
-
opts.resolveSkillVersion ??
|
|
995
|
-
((skill: string) => computeSkillVersion(opts.hqRoot, skill));
|
|
996
|
-
const skillVersionCache = new Map<string, string | undefined>();
|
|
997
|
-
const skillVersionFor = async (skill: string): Promise<string | undefined> => {
|
|
998
|
-
if (skillVersionCache.has(skill)) return skillVersionCache.get(skill);
|
|
999
|
-
let version: string | undefined;
|
|
1000
|
-
try {
|
|
1001
|
-
version = await resolveSkillVersion(skill);
|
|
1002
|
-
} catch {
|
|
1003
|
-
version = undefined;
|
|
1004
|
-
}
|
|
1005
|
-
skillVersionCache.set(skill, version);
|
|
1006
|
-
return version;
|
|
1007
|
-
};
|
|
1008
|
-
|
|
1009
|
-
// 1. Opt-in check — reuse the same gate as token telemetry.
|
|
1010
|
-
let enabled: boolean;
|
|
1011
|
-
let optInSource: CollectSkillTelemetryResult["optInSource"];
|
|
1012
|
-
try {
|
|
1013
|
-
const resp = await opts.client.getTelemetryOptIn();
|
|
1014
|
-
enabled = resp.enabled === true;
|
|
1015
|
-
optInSource = "server";
|
|
1016
|
-
} catch (err) {
|
|
1017
|
-
log(`[skill-telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
|
|
1018
|
-
enabled = await readLocalTelemetryEnabled(menubarPath);
|
|
1019
|
-
optInSource = "menubar-fallback";
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
if (!enabled) {
|
|
1023
|
-
return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// 2. Cursor + file enumeration. Both runtimes share one cursor (keyed by
|
|
1027
|
-
// absolute path — the roots never collide) and one batcher. Each file is
|
|
1028
|
-
// tagged with its runtime so the scan picks the right line extractor.
|
|
1029
|
-
const cursor = await loadCursor(cursorPath);
|
|
1030
|
-
const claudeFiles = await listJsonlFiles(claudeProjectsRoot);
|
|
1031
|
-
const codexFiles = await listJsonlFiles(codexSessionsRoot);
|
|
1032
|
-
const files: { filePath: string; kind: SkillTelemetrySource }[] = [
|
|
1033
|
-
...rotateFilesFromLast(
|
|
1034
|
-
claudeFiles,
|
|
1035
|
-
cursor.lastScannedBySource?.claude,
|
|
1036
|
-
).map((f) => ({ filePath: f, kind: "claude" as const })),
|
|
1037
|
-
...rotateFilesFromLast(
|
|
1038
|
-
codexFiles,
|
|
1039
|
-
cursor.lastScannedBySource?.codex,
|
|
1040
|
-
).map((f) => ({ filePath: f, kind: "codex" as const })),
|
|
1041
|
-
];
|
|
1042
|
-
|
|
1043
|
-
// 3. Scan every file from its stored offset, collecting events tagged with
|
|
1044
|
-
// the byte offset of the line they came from (for per-batch commit).
|
|
1045
|
-
interface FileScan {
|
|
1046
|
-
eof: number;
|
|
1047
|
-
mtime: number;
|
|
1048
|
-
eventCount: number; // events extracted from this file this run
|
|
1049
|
-
pendingLine?: PendingLineCursor;
|
|
1050
|
-
codex?: CodexCursorState;
|
|
1051
|
-
}
|
|
1052
|
-
interface Sourced {
|
|
1053
|
-
row: Record<string, unknown>;
|
|
1054
|
-
filePath: string;
|
|
1055
|
-
endOffset: number; // absolute byte offset at the end of the source line
|
|
1056
|
-
codex?: CodexCursorState;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
const fileScans: Record<string, FileScan> = {};
|
|
1060
|
-
const rotationResets: Record<string, CursorEntry> = {};
|
|
1061
|
-
const scannedBytes: Record<SkillTelemetrySource, number> = { claude: 0, codex: 0 };
|
|
1062
|
-
const lastScannedBySource: Partial<Record<SkillTelemetrySource, string>> = {
|
|
1063
|
-
...cursor.lastScannedBySource,
|
|
1064
|
-
};
|
|
1065
|
-
const sourced: Sourced[] = [];
|
|
1066
|
-
const envelopeBytes = Buffer.byteLength(
|
|
1067
|
-
JSON.stringify({ machineId: opts.machineId, installerVersion: opts.installerVersion, events: [] }),
|
|
1068
|
-
"utf-8",
|
|
1069
|
-
);
|
|
1070
|
-
const maxRowBytes = MAX_BATCH_BYTES - envelopeBytes;
|
|
1071
|
-
|
|
1072
|
-
for (const { filePath, kind } of files) {
|
|
1073
|
-
const sourceBudgetRemaining = maxScanBytesPerSource - scannedBytes[kind];
|
|
1074
|
-
if (sourceBudgetRemaining <= 0) continue;
|
|
1075
|
-
|
|
1076
|
-
let stat;
|
|
1077
|
-
try {
|
|
1078
|
-
stat = await fs.stat(filePath);
|
|
1079
|
-
} catch {
|
|
1080
|
-
continue;
|
|
1081
|
-
}
|
|
1082
|
-
const currentSize = stat.size;
|
|
1083
|
-
const currentMtime = Math.floor(stat.mtimeMs / 1000);
|
|
1084
|
-
|
|
1085
|
-
const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
|
|
1086
|
-
let offset = stored.offset;
|
|
1087
|
-
let pendingLine = clonePendingLine(stored.pendingLine);
|
|
1088
|
-
let codexCursor = kind === "codex" ? cloneCodexCursorState(stored.codex) : undefined;
|
|
1089
|
-
|
|
1090
|
-
// Rotation / truncation → re-read from the top.
|
|
1091
|
-
const rotated =
|
|
1092
|
-
currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
|
|
1093
|
-
if (rotated) {
|
|
1094
|
-
offset = 0;
|
|
1095
|
-
pendingLine = undefined;
|
|
1096
|
-
codexCursor = undefined;
|
|
1097
|
-
rotationResets[filePath] = { offset: 0, mtime: currentMtime };
|
|
1098
|
-
}
|
|
1099
|
-
// A malformed/stale pending continuation is never allowed to move the
|
|
1100
|
-
// durable complete-line cursor. Start scanning fresh from that cursor.
|
|
1101
|
-
if (
|
|
1102
|
-
pendingLine &&
|
|
1103
|
-
(pendingLine.start !== offset || pendingLine.scannedOffset > currentSize)
|
|
1104
|
-
) {
|
|
1105
|
-
pendingLine = undefined;
|
|
1106
|
-
}
|
|
1107
|
-
const scanOffset = pendingLine?.scannedOffset ?? offset;
|
|
1108
|
-
|
|
1109
|
-
// Record the scan even when there are no new bytes — a fully-drained file
|
|
1110
|
-
// (eventCount 0, offset already at EOF) should still settle at EOF below.
|
|
1111
|
-
fileScans[filePath] = {
|
|
1112
|
-
eof: currentSize,
|
|
1113
|
-
mtime: currentMtime,
|
|
1114
|
-
eventCount: 0,
|
|
1115
|
-
...(pendingLine ? { pendingLine } : {}),
|
|
1116
|
-
...(codexCursor ? { codex: codexCursor } : {}),
|
|
1117
|
-
};
|
|
1118
|
-
|
|
1119
|
-
if (offset >= currentSize && !rotated) continue;
|
|
1120
|
-
|
|
1121
|
-
let content: string;
|
|
1122
|
-
let bytesRead = 0;
|
|
1123
|
-
let readBuffer = Buffer.alloc(0);
|
|
1124
|
-
try {
|
|
1125
|
-
const fh = await fs.open(filePath, "r");
|
|
1126
|
-
try {
|
|
1127
|
-
const length = Math.max(
|
|
1128
|
-
0,
|
|
1129
|
-
Math.min(currentSize - scanOffset, sourceBudgetRemaining, MAX_DECODE_BYTES),
|
|
1130
|
-
);
|
|
1131
|
-
readBuffer = Buffer.alloc(length);
|
|
1132
|
-
// Chunked: a single fh.read() with length > Int32 aborts the process
|
|
1133
|
-
// with SIGABRT (HQ-SYNC-WEB-15). `MAX_DECODE_BYTES` separately keeps
|
|
1134
|
-
// the following toString below V8's fatal >= 2**31 boundary; an
|
|
1135
|
-
// oversized configured budget cannot bypass this guard.
|
|
1136
|
-
bytesRead = await readFileRegion(fh, readBuffer, scanOffset, length);
|
|
1137
|
-
content = readBuffer.toString("utf-8", 0, Math.min(bytesRead, MAX_DECODE_BYTES));
|
|
1138
|
-
} finally {
|
|
1139
|
-
await fh.close();
|
|
1140
|
-
}
|
|
1141
|
-
} catch {
|
|
1142
|
-
// Could not read — drop the scan so we don't claim progress for it.
|
|
1143
|
-
delete fileScans[filePath];
|
|
1144
|
-
continue;
|
|
1145
|
-
}
|
|
1146
|
-
scannedBytes[kind] += bytesRead;
|
|
1147
|
-
if (bytesRead > 0) lastScannedBySource[kind] = filePath;
|
|
1148
|
-
|
|
1149
|
-
let reachedPhysicalEof = scanOffset + bytesRead >= currentSize;
|
|
1150
|
-
if (!reachedPhysicalEof) {
|
|
1151
|
-
log(`[skill-telemetry] scan budget reached; deferring unread transcript bytes (${filePath})`);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
let completedPendingLine = false;
|
|
1155
|
-
if (pendingLine) {
|
|
1156
|
-
const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
|
|
1157
|
-
if (newlineIndex < 0 && !reachedPhysicalEof) {
|
|
1158
|
-
// The record itself is larger than the normal per-pass budget. Persist
|
|
1159
|
-
// only byte positions and continue searching from this new suffix next
|
|
1160
|
-
// pass — never the transcript's contents, and never the same prefix.
|
|
1161
|
-
fileScans[filePath].eof = offset;
|
|
1162
|
-
fileScans[filePath].pendingLine = {
|
|
1163
|
-
start: offset,
|
|
1164
|
-
scannedOffset: scanOffset + bytesRead,
|
|
1165
|
-
};
|
|
1166
|
-
log(`[skill-telemetry] continuing oversized JSONL record on a later pass (${filePath})`);
|
|
1167
|
-
continue;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
const lineEnd =
|
|
1171
|
-
newlineIndex >= 0 ? scanOffset + newlineIndex + 1 : scanOffset + bytesRead;
|
|
1172
|
-
const lineLength = lineEnd - offset;
|
|
1173
|
-
if (lineLength > MAX_DECODE_BYTES) {
|
|
1174
|
-
// This guard still makes the failure recoverable rather than allowing a
|
|
1175
|
-
// native V8 abort. Keep the byte-only continuation so a later file
|
|
1176
|
-
// rotation/truncation can reset it safely; diagnostics reveal the
|
|
1177
|
-
// exceptional transcript without leaking its contents.
|
|
1178
|
-
fileScans[filePath].eof = offset;
|
|
1179
|
-
fileScans[filePath].pendingLine = { start: offset, scannedOffset: lineEnd };
|
|
1180
|
-
log(`[skill-telemetry] JSONL record exceeds safe decode limit (${filePath})`);
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
try {
|
|
1185
|
-
const fh = await fs.open(filePath, "r");
|
|
1186
|
-
try {
|
|
1187
|
-
readBuffer = Buffer.alloc(lineLength);
|
|
1188
|
-
bytesRead = await readFileRegion(fh, readBuffer, offset, lineLength);
|
|
1189
|
-
if (bytesRead !== lineLength) {
|
|
1190
|
-
fileScans[filePath].eof = offset;
|
|
1191
|
-
fileScans[filePath].pendingLine = {
|
|
1192
|
-
start: offset,
|
|
1193
|
-
scannedOffset: offset + bytesRead,
|
|
1194
|
-
};
|
|
1195
|
-
continue;
|
|
1196
|
-
}
|
|
1197
|
-
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
1198
|
-
} finally {
|
|
1199
|
-
await fh.close();
|
|
1200
|
-
}
|
|
1201
|
-
} catch {
|
|
1202
|
-
delete fileScans[filePath];
|
|
1203
|
-
continue;
|
|
1204
|
-
}
|
|
1205
|
-
reachedPhysicalEof = lineEnd >= currentSize;
|
|
1206
|
-
completedPendingLine = true;
|
|
1207
|
-
pendingLine = undefined;
|
|
1208
|
-
delete fileScans[filePath].pendingLine;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
// Codex events lack per-row cwd/sessionId — they live in the file's leading
|
|
1212
|
-
// `session_meta` line, which we read from the top regardless of the cursor.
|
|
1213
|
-
const codexCtx =
|
|
1214
|
-
kind === "codex" ? await readCodexSessionContext(filePath) : undefined;
|
|
1215
|
-
// Per-file dedup of model-driven Codex skill loads: a single skill use
|
|
1216
|
-
// re-reads SKILL.md several times within one turn, so collapse them to one
|
|
1217
|
-
// event per (session, turn, skill). Scoped per file = per Codex session.
|
|
1218
|
-
const codexSeen =
|
|
1219
|
-
kind === "codex" ? new Set(codexCursor?.seen ?? []) : undefined;
|
|
1220
|
-
// Running turn id for Codex: the `function_call` exec shape carries no
|
|
1221
|
-
// turn_id of its own, so we track the latest one seen (from `turn_context`,
|
|
1222
|
-
// which precedes a turn's execs) and attribute those execs to it.
|
|
1223
|
-
let codexTurnId: string | undefined = codexCursor?.turnId;
|
|
1224
|
-
|
|
1225
|
-
// A region capped before EOF can end part-way through a JSONL row (and even
|
|
1226
|
-
// part-way through a multibyte UTF-8 character). Only process its newline-
|
|
1227
|
-
// terminated segments; the next pass resumes at the last settled boundary.
|
|
1228
|
-
// At physical EOF retain today's behaviour of handling a final newline-less
|
|
1229
|
-
// record in the same pass.
|
|
1230
|
-
const segments = content.split("\n");
|
|
1231
|
-
const completeSegmentCount = reachedPhysicalEof
|
|
1232
|
-
? segments.length
|
|
1233
|
-
: Math.max(0, segments.length - 1);
|
|
1234
|
-
let cumulative = offset;
|
|
1235
|
-
let settledOffset = offset;
|
|
1236
|
-
for (let i = 0; i < completeSegmentCount; i++) {
|
|
1237
|
-
cumulative += Buffer.byteLength(segments[i], "utf-8");
|
|
1238
|
-
if (i < segments.length - 1) cumulative += 1; // the split newline byte
|
|
1239
|
-
// For the final physical-EOF segment, use the bytes actually returned by
|
|
1240
|
-
// fs rather than re-encoding it. This preserves byte-exact cursor
|
|
1241
|
-
// offsets even if a writer left an invalid/incomplete UTF-8 tail.
|
|
1242
|
-
const endOffset =
|
|
1243
|
-
i === segments.length - 1 ? offset + bytesRead : cumulative;
|
|
1244
|
-
settledOffset = endOffset;
|
|
1245
|
-
|
|
1246
|
-
const trimmed = segments[i].trim();
|
|
1247
|
-
if (trimmed.length === 0) continue;
|
|
1248
|
-
let parsed: unknown;
|
|
1249
|
-
try {
|
|
1250
|
-
parsed = JSON.parse(trimmed);
|
|
1251
|
-
} catch {
|
|
1252
|
-
continue;
|
|
1253
|
-
}
|
|
1254
|
-
if (kind === "codex") {
|
|
1255
|
-
const t = codexRowTurnId(parsed);
|
|
1256
|
-
if (t !== undefined) {
|
|
1257
|
-
if (t !== codexTurnId) codexSeen?.clear();
|
|
1258
|
-
codexTurnId = t;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
const events =
|
|
1262
|
-
kind === "codex"
|
|
1263
|
-
? [
|
|
1264
|
-
...extractCodexSkillEvents(parsed, codexCtx ?? {}),
|
|
1265
|
-
...extractCodexSkillToolEvents(
|
|
1266
|
-
parsed,
|
|
1267
|
-
{ ...(codexCtx ?? {}), turnId: codexTurnId },
|
|
1268
|
-
codexSeen,
|
|
1269
|
-
),
|
|
1270
|
-
]
|
|
1271
|
-
: extractSkillEvents(parsed);
|
|
1272
|
-
for (const ev of events) {
|
|
1273
|
-
// Scope filter: only emit invocations made from the HQ project — its
|
|
1274
|
-
// root or any path beneath it (worktrees, nested apps), so a session run
|
|
1275
|
-
// from `<hqRoot>/.claude/worktrees/…` still counts. Sibling repos that
|
|
1276
|
-
// merely share a path prefix (`<hqRoot>-other`) are excluded by the
|
|
1277
|
-
// trailing-slash boundary.
|
|
1278
|
-
if (scopeCwd !== undefined) {
|
|
1279
|
-
const c = ev.cwd === undefined ? undefined : normalizePath(ev.cwd);
|
|
1280
|
-
if (c === undefined || (c !== scopeCwd && !c.startsWith(`${scopeCwd}/`))) {
|
|
1281
|
-
continue;
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
// Attribute the event to a company (cmp_* uid) from the per-run map.
|
|
1285
|
-
// A company SKILL (`<slug>:<skill>`) belongs to ITS company regardless of
|
|
1286
|
-
// the cwd it ran in, so skill-based resolution takes precedence; a core
|
|
1287
|
-
// skill falls back to the cwd's owning company. Unresolved by both →
|
|
1288
|
-
// undefined → companyUid omitted (unattributed/personal).
|
|
1289
|
-
const companyUid =
|
|
1290
|
-
resolveCompanyForSkill(ev.skill, repoCompanyMap) ??
|
|
1291
|
-
resolveCompanyForCwd(ev.cwd, repoCompanyMap);
|
|
1292
|
-
// Stamp the skill's content-version marker (US-015). Unresolved → the
|
|
1293
|
-
// field is omitted from the wire row (additive).
|
|
1294
|
-
ev.skillVersion = await skillVersionFor(ev.skill);
|
|
1295
|
-
const wireRow = toWireRow(ev, companyUid);
|
|
1296
|
-
const wasOversized = jsonBytes(wireRow) > maxRowBytes;
|
|
1297
|
-
const bounded = boundRowForPost(wireRow, maxRowBytes);
|
|
1298
|
-
if (!bounded) {
|
|
1299
|
-
log(
|
|
1300
|
-
`[skill-telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
|
|
1301
|
-
);
|
|
1302
|
-
continue;
|
|
1303
|
-
}
|
|
1304
|
-
if (wasOversized) {
|
|
1305
|
-
log(
|
|
1306
|
-
`[skill-telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
|
|
1307
|
-
);
|
|
1308
|
-
}
|
|
1309
|
-
sourced.push({
|
|
1310
|
-
row: bounded,
|
|
1311
|
-
filePath,
|
|
1312
|
-
endOffset,
|
|
1313
|
-
...(kind === "codex"
|
|
1314
|
-
? { codex: snapshotCodexCursorState(codexTurnId, codexSeen) }
|
|
1315
|
-
: {}),
|
|
1316
|
-
});
|
|
1317
|
-
fileScans[filePath].eventCount++;
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
// Commit only bytes that reached a complete record boundary. In particular,
|
|
1321
|
-
// never claim currentSize merely because the first budgeted region was read.
|
|
1322
|
-
fileScans[filePath].eof = settledOffset;
|
|
1323
|
-
if (!completedPendingLine && !reachedPhysicalEof) {
|
|
1324
|
-
const lastNewline = readBuffer.subarray(0, bytesRead).lastIndexOf(0x0a);
|
|
1325
|
-
const partialStart = offset + lastNewline + 1;
|
|
1326
|
-
const inspectedThrough = offset + bytesRead;
|
|
1327
|
-
if (partialStart < inspectedThrough) {
|
|
1328
|
-
fileScans[filePath].pendingLine = {
|
|
1329
|
-
start: partialStart,
|
|
1330
|
-
scannedOffset: inspectedThrough,
|
|
1331
|
-
};
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
if (kind === "codex") {
|
|
1335
|
-
fileScans[filePath].codex = snapshotCodexCursorState(codexTurnId, codexSeen);
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// 4. Flush in server-sized batches, advancing per-file progress on each 2xx.
|
|
1340
|
-
let eventsSent = 0;
|
|
1341
|
-
let batchesSent = 0;
|
|
1342
|
-
|
|
1343
|
-
// Per file: count of events successfully sent + max committed byte offset.
|
|
1344
|
-
const sentCount: Record<string, number> = {};
|
|
1345
|
-
const committed: Record<string, { offset: number; codex?: CodexCursorState }> = {};
|
|
1346
|
-
|
|
1347
|
-
let batch: Sourced[] = [];
|
|
1348
|
-
let batchBytes = envelopeBytes;
|
|
1349
|
-
|
|
1350
|
-
const flush = async (): Promise<boolean> => {
|
|
1351
|
-
if (batch.length === 0) return true;
|
|
1352
|
-
const toSend = batch;
|
|
1353
|
-
batch = [];
|
|
1354
|
-
batchBytes = envelopeBytes;
|
|
1355
|
-
try {
|
|
1356
|
-
await opts.client.postSkillInvocations({
|
|
1357
|
-
machineId: opts.machineId,
|
|
1358
|
-
installerVersion: opts.installerVersion,
|
|
1359
|
-
events: toSend.map((s) => s.row),
|
|
1360
|
-
});
|
|
1361
|
-
batchesSent++;
|
|
1362
|
-
eventsSent += toSend.length;
|
|
1363
|
-
// Advance per-file progress for the events in this (successful) batch.
|
|
1364
|
-
for (const s of toSend) {
|
|
1365
|
-
sentCount[s.filePath] = (sentCount[s.filePath] ?? 0) + 1;
|
|
1366
|
-
const prev = committed[s.filePath];
|
|
1367
|
-
if (!prev || s.endOffset > prev.offset) {
|
|
1368
|
-
committed[s.filePath] = {
|
|
1369
|
-
offset: s.endOffset,
|
|
1370
|
-
...(s.codex ? { codex: s.codex } : {}),
|
|
1371
|
-
};
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
return true;
|
|
1375
|
-
} catch (err) {
|
|
1376
|
-
log(`[skill-telemetry] postSkillInvocations failed (${(err as Error).message ?? err}) — these rows re-send next sync`);
|
|
1377
|
-
// Cursor not advanced for this batch; eventKey dedups the eventual re-send.
|
|
1378
|
-
return false;
|
|
1379
|
-
}
|
|
1380
|
-
};
|
|
1381
|
-
|
|
1382
|
-
let stoppedAfterFailure = false;
|
|
1383
|
-
for (const s of sourced) {
|
|
1384
|
-
const rowBytes = Buffer.byteLength(JSON.stringify(s.row), "utf-8");
|
|
1385
|
-
const addCost = rowBytes + (batch.length > 0 ? 1 : 0);
|
|
1386
|
-
if (
|
|
1387
|
-
batch.length > 0 &&
|
|
1388
|
-
(batch.length >= MAX_BATCH_EVENTS || batchBytes + addCost > MAX_BATCH_BYTES)
|
|
1389
|
-
) {
|
|
1390
|
-
if (!(await flush())) {
|
|
1391
|
-
stoppedAfterFailure = true;
|
|
1392
|
-
break;
|
|
1393
|
-
}
|
|
1394
|
-
batchBytes = envelopeBytes + rowBytes;
|
|
1395
|
-
} else {
|
|
1396
|
-
batchBytes += addCost;
|
|
1397
|
-
}
|
|
1398
|
-
batch.push(s);
|
|
1399
|
-
}
|
|
1400
|
-
if (!stoppedAfterFailure) await flush();
|
|
1401
|
-
|
|
1402
|
-
// 5. Build the new cursor: loaded < rotationResets < per-file commit.
|
|
1403
|
-
// A file settles at EOF only when every event extracted from it this run
|
|
1404
|
-
// was sent OK (zero-event files included); otherwise it settles at the
|
|
1405
|
-
// highest byte offset whose batch succeeded, so the rest re-sends.
|
|
1406
|
-
const finalFiles: Record<string, CursorEntry> = { ...cursor.files };
|
|
1407
|
-
for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
|
|
1408
|
-
for (const [fp, scan] of Object.entries(fileScans)) {
|
|
1409
|
-
if ((sentCount[fp] ?? 0) >= scan.eventCount) {
|
|
1410
|
-
finalFiles[fp] = {
|
|
1411
|
-
offset: scan.eof,
|
|
1412
|
-
mtime: scan.mtime,
|
|
1413
|
-
...(scan.pendingLine ? { pendingLine: scan.pendingLine } : {}),
|
|
1414
|
-
...(scan.codex ? { codex: scan.codex } : {}),
|
|
1415
|
-
};
|
|
1416
|
-
} else if (fp in committed) {
|
|
1417
|
-
const entry = committed[fp];
|
|
1418
|
-
finalFiles[fp] = {
|
|
1419
|
-
offset: entry.offset,
|
|
1420
|
-
mtime: scan.mtime,
|
|
1421
|
-
...(entry.codex ? { codex: entry.codex } : {}),
|
|
1422
|
-
};
|
|
1423
|
-
}
|
|
1424
|
-
// else: no progress for this file — leave loaded/rotation-reset offset.
|
|
1425
|
-
}
|
|
1426
|
-
await saveCursor(cursorPath, {
|
|
1427
|
-
version: "1",
|
|
1428
|
-
files: finalFiles,
|
|
1429
|
-
...(Object.keys(lastScannedBySource).length > 0 ? { lastScannedBySource } : {}),
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
return {
|
|
1433
|
-
enabled: true,
|
|
1434
|
-
optInSource,
|
|
1435
|
-
filesScanned: files.length,
|
|
1436
|
-
eventsSent,
|
|
1437
|
-
batchesSent,
|
|
1438
|
-
};
|
|
1439
|
-
}
|