/**
* 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