@indigoai-us/hq-cloud 6.3.1 → 6.3.3
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/cli/sync.js +43 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +98 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +42 -6
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +253 -10
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +287 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/sync.test.ts +114 -0
- package/src/cli/sync.ts +43 -1
- package/src/skill-telemetry.test.ts +433 -0
- package/src/skill-telemetry.ts +260 -10
package/src/skill-telemetry.ts
CHANGED
|
@@ -25,12 +25,24 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Codex CLI is captured too, from its own rollout logs at
|
|
27
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)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
46
|
*
|
|
35
47
|
* Privacy: raw `<command-args>` / `input.args` content is NEVER sent to the
|
|
36
48
|
* cloud — only a `hasArgs` boolean. This matches the message-stripping posture
|
|
@@ -265,6 +277,18 @@ export function parseCodexSessionMeta(
|
|
|
265
277
|
return { sessionId, cwd };
|
|
266
278
|
}
|
|
267
279
|
|
|
280
|
+
/** The `turn_id` a Codex rollout row belongs to, when it carries one
|
|
281
|
+
* (`turn_context` and `exec_command_end` do; bare `function_call` execs do
|
|
282
|
+
* not). Used to track the running turn so the function_call exec shape can be
|
|
283
|
+
* attributed to the turn that preceded it. */
|
|
284
|
+
export function codexRowTurnId(row: unknown): string | undefined {
|
|
285
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
|
|
286
|
+
const payload = (row as Record<string, unknown>).payload;
|
|
287
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
288
|
+
const t = (payload as Record<string, unknown>).turn_id;
|
|
289
|
+
return typeof t === "string" ? t : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
268
292
|
/**
|
|
269
293
|
* Extract a typed skill/slash-command invocation from a Codex `event_msg`
|
|
270
294
|
* `user_message` row. Session context (cwd, sessionId) lives in the file's
|
|
@@ -309,6 +333,206 @@ export function extractCodexSkillEvents(
|
|
|
309
333
|
];
|
|
310
334
|
}
|
|
311
335
|
|
|
336
|
+
/** Match a `…/skills/<name>/SKILL.md` path inside a shell command. `skills/`
|
|
337
|
+
* may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
|
|
338
|
+
* and `<name>` is always the directory immediately above SKILL.md — captured
|
|
339
|
+
* as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
|
|
340
|
+
* `<name>`, not `hq`. The leading `(?:…/)*` is non-greedy via the segment class
|
|
341
|
+
* so it stops at the final directory boundary. */
|
|
342
|
+
const CODEX_SKILL_FILE =
|
|
343
|
+
/(?:^|\/)skills\/(?:[^\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
|
|
344
|
+
|
|
345
|
+
/** Pull the shell command string out of a Codex `exec_command_end` `command`,
|
|
346
|
+
* which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
|
|
347
|
+
* tolerate a bare string too. */
|
|
348
|
+
function codexCommandString(command: unknown): string {
|
|
349
|
+
if (typeof command === "string") return command;
|
|
350
|
+
if (Array.isArray(command)) {
|
|
351
|
+
// The interpreter + flags lead; the actual command is the trailing string.
|
|
352
|
+
for (let i = command.length - 1; i >= 0; i--) {
|
|
353
|
+
if (typeof command[i] === "string") return command[i] as string;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return "";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Classify a Codex exec from its own `parsed_cmd`:
|
|
360
|
+
* - `"read"` — every classified sub-command is a read; authoritative.
|
|
361
|
+
* - `"nonread"` — at least one write/apply_patch-style entry; authoritative,
|
|
362
|
+
* the command text is NOT consulted (Codex's call wins).
|
|
363
|
+
* - `"unknown"` — no usable classification (missing/empty); the caller then
|
|
364
|
+
* falls back to a read-verb check on the command text.
|
|
365
|
+
*/
|
|
366
|
+
function classifyCodexExec(parsedCmd: unknown): "read" | "nonread" | "unknown" {
|
|
367
|
+
if (!Array.isArray(parsedCmd)) return "unknown";
|
|
368
|
+
let sawRead = false;
|
|
369
|
+
for (const entry of parsedCmd) {
|
|
370
|
+
if (!entry || typeof entry !== "object") continue;
|
|
371
|
+
const t = (entry as Record<string, unknown>).type;
|
|
372
|
+
if (typeof t !== "string") continue;
|
|
373
|
+
if (t !== "read") return "nonread";
|
|
374
|
+
sawRead = true;
|
|
375
|
+
}
|
|
376
|
+
return sawRead ? "read" : "unknown";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A leading read verb in the command — the positive signal that an exec is
|
|
380
|
+
// inspecting SKILL.md rather than rewriting it. Anchored after an optional
|
|
381
|
+
// `cd …;`/env-var prefix so `sed`, `rg`, `cat`, … are recognized at the head of
|
|
382
|
+
// the real command.
|
|
383
|
+
const CODEX_READ_VERB =
|
|
384
|
+
/(?:^|[;&|]\s*|\bcd\s+[^\s;]+\s*;\s*)(sed|cat|head|tail|nl|rg|grep|less|bat|wc|awk|cut|fold|view|print)\b/;
|
|
385
|
+
// Writing into the skill file disqualifies regardless of a read verb elsewhere.
|
|
386
|
+
const CODEX_WRITE_TO_SKILL = /(?:>>?|\btee\b)[^\n]*\/SKILL\.md\b/;
|
|
387
|
+
|
|
388
|
+
/** Codex tool-call names that run a shell command (the `response_item`
|
|
389
|
+
* `function_call` form). Excludes `apply_patch` — that is an edit, not a read. */
|
|
390
|
+
const CODEX_EXEC_TOOLS = new Set([
|
|
391
|
+
"exec_command",
|
|
392
|
+
"shell",
|
|
393
|
+
"local_shell",
|
|
394
|
+
"local_shell_call",
|
|
395
|
+
"bash",
|
|
396
|
+
"container.exec",
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Normalize a completed Codex exec from either shape the CLI emits (it varies
|
|
401
|
+
* by version), returning the command string, the turn it belongs to, the run
|
|
402
|
+
* `cwd`, and any `parsed_cmd` classification — or null when the row is neither.
|
|
403
|
+
* - `event_msg` / `exec_command_end`: `command` array, own `turn_id` + `cwd`,
|
|
404
|
+
* and a `parsed_cmd` Codex tags `type: "read"`.
|
|
405
|
+
* - `response_item` / `function_call` (name `exec_command`/`shell`/…): the
|
|
406
|
+
* command lives in `arguments` (a JSON string) as `cmd`/`command`, the run
|
|
407
|
+
* dir as `workdir`. No `turn_id`/`parsed_cmd` on the row, so the turn comes
|
|
408
|
+
* from the scan's running `ctx.turnId` (tracked from `turn_context`) and
|
|
409
|
+
* read-intent is decided by the command text.
|
|
410
|
+
*/
|
|
411
|
+
function codexExecParams(
|
|
412
|
+
obj: Record<string, unknown>,
|
|
413
|
+
payload: Record<string, unknown>,
|
|
414
|
+
ctx: { cwd?: string; turnId?: string },
|
|
415
|
+
): { cmd: string; turnId?: string; cwd?: string; parsedCmd: unknown } | null {
|
|
416
|
+
if (obj.type === "event_msg" && payload.type === "exec_command_end") {
|
|
417
|
+
const cmd = codexCommandString(payload.command);
|
|
418
|
+
if (!cmd) return null;
|
|
419
|
+
return {
|
|
420
|
+
cmd,
|
|
421
|
+
turnId: typeof payload.turn_id === "string" ? payload.turn_id : ctx.turnId,
|
|
422
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : ctx.cwd,
|
|
423
|
+
parsedCmd: payload.parsed_cmd,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (obj.type === "response_item" && payload.type === "function_call") {
|
|
427
|
+
const name = typeof payload.name === "string" ? payload.name : "";
|
|
428
|
+
if (!CODEX_EXEC_TOOLS.has(name)) return null;
|
|
429
|
+
let args: Record<string, unknown> = {};
|
|
430
|
+
const raw = payload.arguments;
|
|
431
|
+
if (typeof raw === "string") {
|
|
432
|
+
try {
|
|
433
|
+
const parsed = JSON.parse(raw);
|
|
434
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
435
|
+
args = parsed as Record<string, unknown>;
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
441
|
+
args = raw as Record<string, unknown>;
|
|
442
|
+
}
|
|
443
|
+
const cmdRaw = args.cmd ?? args.command;
|
|
444
|
+
const cmd = typeof cmdRaw === "string" ? cmdRaw : codexCommandString(cmdRaw);
|
|
445
|
+
if (!cmd) return null;
|
|
446
|
+
return {
|
|
447
|
+
cmd,
|
|
448
|
+
turnId: ctx.turnId,
|
|
449
|
+
cwd: typeof args.workdir === "string" ? args.workdir : ctx.cwd,
|
|
450
|
+
parsedCmd: undefined,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Extract a model-driven skill invocation from a completed Codex exec — the
|
|
458
|
+
* model ran a shell command that *reads* a skill's `SKILL.md`, which is how
|
|
459
|
+
* Codex loads and runs a skill (it has no discrete Skill tool_use). Handles both
|
|
460
|
+
* Codex exec shapes (see `codexExecParams`). Returns 0 or 1 event tagged
|
|
461
|
+
* `source: "model"`.
|
|
462
|
+
*
|
|
463
|
+
* Dedup is per (sessionId, turn_id, skill): a single skill use re-reads the file
|
|
464
|
+
* several times (line ranges, greps) and some Codex versions log both exec
|
|
465
|
+
* shapes for one exec, so the caller threads a `seen` Set to collapse them to
|
|
466
|
+
* one event per Codex turn. When `seen` is omitted (unit tests), no dedup is
|
|
467
|
+
* applied. Session context (sessionId, cwd, and the running turnId) comes via
|
|
468
|
+
* `ctx`; the row's own `cwd` is preferred when present.
|
|
469
|
+
*/
|
|
470
|
+
export function extractCodexSkillToolEvents(
|
|
471
|
+
row: unknown,
|
|
472
|
+
ctx: { sessionId?: string; cwd?: string; turnId?: string },
|
|
473
|
+
seen?: Set<string>,
|
|
474
|
+
): SkillEvent[] {
|
|
475
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
476
|
+
const obj = row as Record<string, unknown>;
|
|
477
|
+
const payload =
|
|
478
|
+
obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
|
|
479
|
+
? (obj.payload as Record<string, unknown>)
|
|
480
|
+
: undefined;
|
|
481
|
+
if (!payload) return [];
|
|
482
|
+
|
|
483
|
+
const exec = codexExecParams(obj, payload, ctx);
|
|
484
|
+
if (!exec) return [];
|
|
485
|
+
const { cmd } = exec;
|
|
486
|
+
const m = CODEX_SKILL_FILE.exec(cmd);
|
|
487
|
+
if (!m) return [];
|
|
488
|
+
// Confirm the exec is a read of the skill file, not a write to it. Codex's own
|
|
489
|
+
// classification leads; only when it is absent does the command text decide.
|
|
490
|
+
if (CODEX_WRITE_TO_SKILL.test(cmd)) return [];
|
|
491
|
+
const cls = classifyCodexExec(exec.parsedCmd);
|
|
492
|
+
if (cls === "nonread") return [];
|
|
493
|
+
if (cls === "unknown" && !CODEX_READ_VERB.test(cmd)) return [];
|
|
494
|
+
|
|
495
|
+
const skill = m[1].trim();
|
|
496
|
+
if (!skill) return [];
|
|
497
|
+
|
|
498
|
+
const sessionId = ctx.sessionId;
|
|
499
|
+
const turnId = exec.turnId;
|
|
500
|
+
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
501
|
+
const cwd = exec.cwd;
|
|
502
|
+
|
|
503
|
+
// Dedup key: one invocation per (session, turn, skill). Fall back to the
|
|
504
|
+
// session when no turn_id is present (still collapses a turn's repeat reads
|
|
505
|
+
// for the common single-turn case, since they share a timestamp-free key).
|
|
506
|
+
const dedupKey = `${sessionId ?? ""}:${turnId ?? ""}:${skill}`;
|
|
507
|
+
if (seen) {
|
|
508
|
+
if (seen.has(dedupKey)) return [];
|
|
509
|
+
seen.add(dedupKey);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Synthesize a stable uuid so re-delivery across syncs is idempotent
|
|
513
|
+
// server-side. Prefer (session, turn) — globally unique per invocation; fall
|
|
514
|
+
// back to (session, timestamp) so distinct reads aren't all collapsed when a
|
|
515
|
+
// turn_id is somehow absent.
|
|
516
|
+
const uuid =
|
|
517
|
+
sessionId !== undefined && turnId !== undefined
|
|
518
|
+
? `codex:skill:${sessionId}:${turnId}:${skill}`
|
|
519
|
+
: sessionId !== undefined && timestamp !== undefined
|
|
520
|
+
? `codex:skill:${sessionId}:${timestamp}:${skill}`
|
|
521
|
+
: undefined;
|
|
522
|
+
|
|
523
|
+
return [
|
|
524
|
+
{
|
|
525
|
+
skill,
|
|
526
|
+
source: "model",
|
|
527
|
+
sessionId,
|
|
528
|
+
timestamp,
|
|
529
|
+
cwd,
|
|
530
|
+
uuid,
|
|
531
|
+
hasArgs: false,
|
|
532
|
+
},
|
|
533
|
+
];
|
|
534
|
+
}
|
|
535
|
+
|
|
312
536
|
/** Shape the event for the wire. Drops raw args unless explicitly enabled. */
|
|
313
537
|
function toWireRow(ev: SkillEvent): Record<string, unknown> {
|
|
314
538
|
const row: Record<string, unknown> = {
|
|
@@ -512,6 +736,14 @@ export async function collectAndSendSkillTelemetry(
|
|
|
512
736
|
// `session_meta` line, which we read from the top regardless of the cursor.
|
|
513
737
|
const codexCtx =
|
|
514
738
|
kind === "codex" ? await readCodexSessionContext(filePath) : undefined;
|
|
739
|
+
// Per-file dedup of model-driven Codex skill loads: a single skill use
|
|
740
|
+
// re-reads SKILL.md several times within one turn, so collapse them to one
|
|
741
|
+
// event per (session, turn, skill). Scoped per file = per Codex session.
|
|
742
|
+
const codexSeen = kind === "codex" ? new Set<string>() : undefined;
|
|
743
|
+
// Running turn id for Codex: the `function_call` exec shape carries no
|
|
744
|
+
// turn_id of its own, so we track the latest one seen (from `turn_context`,
|
|
745
|
+
// which precedes a turn's execs) and attribute those execs to it.
|
|
746
|
+
let codexTurnId: string | undefined;
|
|
515
747
|
|
|
516
748
|
// Compute the absolute end-byte offset of each line in the read region.
|
|
517
749
|
const segments = content.split("\n");
|
|
@@ -529,14 +761,32 @@ export async function collectAndSendSkillTelemetry(
|
|
|
529
761
|
} catch {
|
|
530
762
|
continue;
|
|
531
763
|
}
|
|
764
|
+
if (kind === "codex") {
|
|
765
|
+
const t = codexRowTurnId(parsed);
|
|
766
|
+
if (t !== undefined) codexTurnId = t;
|
|
767
|
+
}
|
|
532
768
|
const events =
|
|
533
769
|
kind === "codex"
|
|
534
|
-
?
|
|
770
|
+
? [
|
|
771
|
+
...extractCodexSkillEvents(parsed, codexCtx ?? {}),
|
|
772
|
+
...extractCodexSkillToolEvents(
|
|
773
|
+
parsed,
|
|
774
|
+
{ ...(codexCtx ?? {}), turnId: codexTurnId },
|
|
775
|
+
codexSeen,
|
|
776
|
+
),
|
|
777
|
+
]
|
|
535
778
|
: extractSkillEvents(parsed);
|
|
536
779
|
for (const ev of events) {
|
|
537
|
-
// Scope filter: only emit invocations made from the HQ project
|
|
538
|
-
|
|
539
|
-
|
|
780
|
+
// Scope filter: only emit invocations made from the HQ project — its
|
|
781
|
+
// root or any path beneath it (worktrees, nested apps), so a session run
|
|
782
|
+
// from `<hqRoot>/.claude/worktrees/…` still counts. Sibling repos that
|
|
783
|
+
// merely share a path prefix (`<hqRoot>-other`) are excluded by the
|
|
784
|
+
// trailing-slash boundary.
|
|
785
|
+
if (scopeCwd !== undefined) {
|
|
786
|
+
const c = ev.cwd === undefined ? undefined : normalizePath(ev.cwd);
|
|
787
|
+
if (c === undefined || (c !== scopeCwd && !c.startsWith(`${scopeCwd}/`))) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
540
790
|
}
|
|
541
791
|
sourced.push({ row: toWireRow(ev), filePath, endOffset });
|
|
542
792
|
fileScans[filePath].eventCount++;
|