/** * Skill-invocation telemetry collector. * * Sibling to `./telemetry.ts` (the token-usage collector). Where that one * promotes token-accounting fields off each Claude Code session row, this one * extracts *which skill / slash-command was invoked*, reading the SAME * `~/.claude/projects/**\/*.jsonl` session logs but with an independent * byte-offset cursor at `~/.hq/skill-telemetry-cursor.json` and shipping to * `/v1/skill-invocations`. * * Why a separate collector rather than folding into `./telemetry.ts`: the * token path is proven and its per-batch cursor mechanics are load-bearing. * Skill events are sparse, so this collector uses a simpler all-or-nothing * per-run cursor commit (re-delivery is idempotent server-side via the * composite eventKey). Keeping it standalone means a bug here can never * regress token telemetry. * * Two capture paths, both recoverable from the transcript (verified against * real sessions): * - User-typed slash command → a `user` row whose content carries * `/foo` (+ optional ``). * - Model-invoked skill → an `assistant` row with a `tool_use` block whose * `name === "Skill"` and `input.skill` names the skill. * The two are mutually exclusive per invocation, so there is no double-count. * * Codex CLI is captured too, from its own rollout logs at * `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`. Codex records cwd + * sessionId ONCE in a leading `session_meta` line (not on every row). Two Codex * paths feed the same wire shape, scope filter, batcher, and per-file cursor: * - Typed (`source: "typed"`) — a slash command, including an HQ skill, e.g. * `/indigo:hello-world`, is logged verbatim as a later `event_msg` * `user_message` (Codex does not expand it). Parallels Claude's typed path. * - Model-driven (`source: "model"`) — Codex has no discrete "Skill tool_use" * event like Claude. Instead it *runs* a skill by reading its instruction * file: the model issues a shell command that reads `…/skills// * SKILL.md`. Codex logs that exec in one of two shapes depending on CLI * version — an `event_msg` `exec_command_end` (with `turn_id`, `cwd`, and a * `parsed_cmd` it tags `type: "read"`) or a `response_item` `function_call` * named `exec_command` (command + `workdir` in its `arguments`). Both are * handled. We treat the read as one invocation of ``. A single use * re-reads the file several times (line ranges, greps) and a version may log * both shapes for one exec, so events are deduped per (sessionId, turn_id, * skill) — at most one per Codex turn. Edits to a SKILL.md travel via * `apply_patch` (authoring, not using) and never reach this path, so skill * development is not miscounted as usage. * * Privacy: raw `` / `input.args` content is NEVER sent to the * cloud — only a `hasArgs` boolean. This matches the message-stripping posture * of `./telemetry.ts::sanitizeRow`, which deliberately drops all prompt/tool * content client-side. * * Trust model + error handling are identical to `./telemetry.ts`: personUid is * resolved server-side from the JWT (never the body), and all errors are * swallowed so telemetry never aborts or delays a sync. */ import type { SkillInvocationBatch, SkillInvocationIngestResult, TelemetryOptInResponse } from "./vault-client.js"; export interface SkillTelemetryClientSurface { getTelemetryOptIn(): Promise; postSkillInvocations(batch: SkillInvocationBatch): Promise; } export interface CollectSkillTelemetryOptions { client: SkillTelemetryClientSurface; machineId: string; installerVersion: string; /** * When set, only invocations whose recorded `cwd` equals this path are * emitted — scoping capture to the HQ project and excluding skill usage in * unrelated repos on the same machine. The walk still covers all of * `~/.claude/projects` (so the cursor stays consistent and no session is * silently missed by a project-dir-name encoding guess), but non-matching * events are dropped before they are batched. Omit to capture every project. */ hqRoot?: string; /** Override `~/.claude/projects` for tests. */ claudeProjectsRoot?: string; /** Override `~/.codex/sessions` (the Codex CLI rollout root) for tests. */ codexSessionsRoot?: string; /** Override `~/.hq/skill-telemetry-cursor.json` for tests. */ cursorPath?: string; /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */ menubarPath?: string; /** * Maximum transcript bytes to inspect per source (Claude or Codex) in one * collection pass. The next pass resumes from the last complete line. * Override for deterministic bounded-scan tests. */ maxScanBytesPerSource?: number; /** * Override skillVersion resolution (skills-first-class US-015). Given a skill * name, return its content-hash version marker (`sha256:`) or undefined. * Defaults to hashing `/.claude/skills//SKILL.md` via * {@link computeSkillVersion}. Injected in tests to decouple emission from the * on-disk skills tree. */ resolveSkillVersion?: (skill: string) => Promise | string | undefined; /** * Fleet agents have no consent dialog. When true, collection is on for * company-attributed AND unattributed skill runs. */ forceCollect?: boolean; /** Diagnostic sink. No-op by default. */ log?: (msg: string) => void; } export interface CollectSkillTelemetryResult { enabled: boolean; /** * True when the personal opt-in resolved FALSE but company-attributable * skill invocations still shipped. Personal consent governs personal / * unattributed work only — see the matching flag on `CollectTelemetryResult`. */ companyScopeOnly?: boolean; optInSource: "server" | "menubar-fallback" | "agent-required" | "skipped"; filesScanned: number; eventsSent: number; batchesSent: number; } /** A single extracted skill-invocation event. Mirrors the server allowlist in * `apps/hq-pro/src/vault-service/handlers/skill-invocations.ts` (KEEP_FIELDS). * Any drift surfaces as `unexpected-event-field` in the ingest result. */ export interface SkillEvent { skill: string; source: "typed" | "model"; sessionId?: string; timestamp?: string; uuid?: string; cwd?: string; hasArgs: boolean; /** * Content-version marker (skills-first-class US-015) — the sha256 of the * skill's SKILL.md, `sha256:`. The version half of the analytics-v2 usage * join key (`skill_uid` + `skillVersion`), so a run maps to the version that * produced it. Resolved + stamped at capture (see `computeSkillVersion` + * `collectAndSendSkillTelemetry`); OPTIONAL — absent when the SKILL.md can't * be located, and additive on the wire (old clients/rows simply omit it). */ skillVersion?: string; } /** * Extract zero or more skill-invocation events from a single parsed session * row. A `user` row yields at most one typed command; an `assistant` row can * carry multiple `Skill` tool_use blocks (rare, but handled). */ export declare function extractSkillEvents(row: unknown): SkillEvent[]; /** Parse cwd + sessionId from a Codex `session_meta` rollout line. Returns null * for any other line type. */ export declare function parseCodexSessionMeta(row: unknown): { sessionId?: string; cwd?: string; } | null; /** The `turn_id` a Codex rollout row belongs to, when it carries one * (`turn_context` and `exec_command_end` do; bare `function_call` execs do * not). Used to track the running turn so the function_call exec shape can be * attributed to the turn that preceded it. */ export declare function codexRowTurnId(row: unknown): string | undefined; /** * Extract a typed skill/slash-command invocation from a Codex `event_msg` * `user_message` row. Session context (cwd, sessionId) lives in the file's * leading `session_meta` line and is threaded in via `ctx`. Returns 0 or 1 * event (a Codex user_message carries at most one command). */ export declare function extractCodexSkillEvents(row: unknown, ctx: { sessionId?: string; cwd?: string; }): SkillEvent[]; /** * Extract a model-driven skill invocation from a completed Codex exec — the * model ran a shell command that *reads* a skill's `SKILL.md`, which is how * Codex loads and runs a skill (it has no discrete Skill tool_use). Handles both * Codex exec shapes (see `codexExecParams`). Returns 0 or 1 event tagged * `source: "model"`. * * Dedup is per (sessionId, turn_id, skill): a single skill use re-reads the file * several times (line ranges, greps) and some Codex versions log both exec * shapes for one exec, so the caller threads a `seen` Set to collapse them to * one event per Codex turn. When `seen` is omitted (unit tests), no dedup is * applied. Session context (sessionId, cwd, and the running turnId) comes via * `ctx`; the row's own `cwd` is preferred when present. */ export declare function extractCodexSkillToolEvents(row: unknown, ctx: { sessionId?: string; cwd?: string; turnId?: string; }, seen?: Set): SkillEvent[]; /** * Resolve a skill's content-version marker ("skillVersion", skills-first-class * US-015) — the sha256 of its SKILL.md as `sha256:` (the canonical * content-hash shape used across hq-cloud, e.g. `watcher.ts`). This is the * version half of the analytics-v2 usage join key (`skill_uid` + `skillVersion`), * so a captured run maps to the exact version that produced it. * * Resolution is best-effort against `/.claude/skills//SKILL.md` — * the canonical single-source skills location, which holds an entry (a real dir * or a symlink) for every skill by its FULL invocation name incl. namespace * (`indigo:hello-world`, `personal:worktree`, `deploy`, …). A `.agents/skills/` * fallback covers the Codex bridge layout. `readFile` follows symlinks, so a * company/personal skill surfaced into `.claude/skills/` resolves transparently. * Returns undefined when `hqRoot` is absent or the file can't be located/read, * so the wire field is simply OMITTED — additive + backwards-compatible, exactly * like the optional `companyUid` attribution. * * "At invocation time" is approximated by the SKILL.md's content AT CAPTURE TIME: * the collector scans historical session logs during sync, so the file's current * bytes are the closest available proxy for the version that ran. A skill edited * between run and sync can differ — acceptable for a usage-rollup join key (the * overwhelming majority of runs sync before any edit to the skill). */ export declare function computeSkillVersion(hqRoot: string | undefined, skill: string): Promise; /** * A sync pass must never turn an unbounded transcript tail into one V8 string. * Node aborts the process once Buffer#toString receives a length >= 2**31; this * conservative cap is also below the smaller practical string ceilings across * supported Node versions. Keep this assertion close to the call site so a * future increase cannot reintroduce the native SIGTRAP failure. */ export declare const MAX_DECODE_BYTES: number; /** Minimal structural view of the `FileHandle.read` we depend on. */ type ReadableFileHandle = { read(buffer: Buffer, offset: number, length: number, position: number): Promise<{ bytesRead: number; }>; }; /** * Read `length` bytes from `fh` starting at byte `position` into `buf`, issuing * native reads no larger than `chunkBytes` so the length argument never exceeds * Int32 and trips the SIGABRT-on-assert path (HQ-SYNC-WEB-15). Returns the total * bytes actually read — which may be < `length` if the file was truncated * between `stat` and this read (a 0-byte read means EOF, so we stop). */ export declare function readFileRegion(fh: ReadableFileHandle, buf: Buffer, position: number, length: number, chunkBytes?: number): Promise; /** * Scan, extract, and POST any new skill-invocation events. * * Cursor model (per-batch commit, matching the token collector for robustness): * each file is scanned from its stored byte offset through a bounded region; * extracted events carry the byte offset of the line they came from. Events are * flushed in server-sized batches, and the cursor advances **per successful * batch** — so if one batch in a large (e.g. first-run backfill) fails, the * batches that already succeeded stay committed and only the rest re-send next * sync. A bounded region settles only at a complete-line boundary, preventing * a cursor from skipping an unread tail or resuming mid-line. * * Per-file commit rule: * - All of a file's events sent OK (including zero-event files) → commit EOF, * so quiet/non-skill tails are never re-scanned. * - Some of a file's events failed → commit the max byte offset whose batch * succeeded (partial progress); the remainder re-sends next sync. * Server-side dedup on the composite eventKey makes any re-send idempotent. * Rotation/truncation resets the offset to 0 (re-read from the top). */ export declare function collectAndSendSkillTelemetry(opts: CollectSkillTelemetryOptions): Promise; export {}; //# sourceMappingURL=skill-telemetry.d.ts.map