@trim21/personal-pi-extensions 0.1.544 → 0.1.546

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/gh-readonly.ts +155 -631
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.544",
3
+ "version": "0.1.546",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -33,7 +33,7 @@ import { spawn } from "node:child_process";
33
33
  import { existsSync } from "node:fs";
34
34
  import { mkdir, readFile, writeFile } from "node:fs/promises";
35
35
  import { homedir } from "node:os";
36
- import { delimiter, dirname, join, resolve } from "node:path";
36
+ import { delimiter, dirname, join } from "node:path";
37
37
 
38
38
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
39
  import { Type } from "typebox";
@@ -266,6 +266,8 @@ const stepSchema = Type.Object({
266
266
 
267
267
  const jobRunSchema = Type.Object({
268
268
  id: Type.Number(),
269
+ run_id: Type.Number(),
270
+ run_url: Type.String(),
269
271
  name: Type.String(),
270
272
  status: Type.String(),
271
273
  conclusion: Type.Union([Type.String(), Type.Null()]),
@@ -401,49 +403,48 @@ export interface StepInfo {
401
403
  conclusion: string | null;
402
404
  }
403
405
 
404
- export interface JobInfo {
405
- id: number;
406
- name: string;
407
- conclusion: string | null;
408
- steps: StepInfo[];
409
- }
410
-
411
- /** Build GitHub-UI-style step list for details, marking expanded steps. */
412
- export function stepsDetail(
413
- job: JobInfo,
414
- expandedSteps?: Set<number>,
415
- ): { number: number; name: string; conclusion: string | null; expanded?: boolean }[] {
416
- return job.steps.map((s) => ({
417
- number: s.number,
418
- name: s.name,
419
- conclusion: s.conclusion,
420
- ...(expandedSteps?.has(s.number) && { expanded: true }),
421
- }));
422
- }
423
-
424
406
  // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
425
407
  // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
426
408
  const seq = createSeqState();
427
409
 
410
+ /**
411
+ * `OWNER/REPO` from a job's `run_url`
412
+ * (`https://api.github.com/repos/OWNER/REPO/actions/runs/123`). The path is
413
+ * parsed as a URL rather than pattern-matched, and GitHub canonicalizes the
414
+ * owner/repo casing in these fields — so this is the spelling to key the log
415
+ * cache on, independent of whatever `repo` the caller passed.
416
+ */
417
+ export function repoFromRunUrl(runUrl: string): string {
418
+ const segments = new URL(runUrl).pathname.split("/").filter(Boolean);
419
+ const reposAt = segments.indexOf("repos");
420
+ const ownerAndRepo = reposAt === -1 ? [] : segments.slice(reposAt + 1, reposAt + 3);
421
+ if (ownerAndRepo.length !== 2) {
422
+ throw new Error(`unexpected run_url (expected /repos/<owner>/<repo>/...): ${runUrl}`);
423
+ }
424
+ return ownerAndRepo.join("/");
425
+ }
426
+
428
427
  /** Absolute path of the raw job log cache file written by `getJobLog`. */
429
- export function jobLogPath(runId: string, jobId: number): string {
430
- return join(homedir(), ".cache", "pi", "ci-logs", runId, `${jobId}.log`);
428
+ export function jobLogPath(repo: string, runId: string, jobId: number): string {
429
+ const { owner, repo: name } = splitRepo(repo);
430
+ return join(homedir(), ".cache", "pi", "github", "ci-logs", owner, name, runId, `${jobId}.log`);
431
431
  }
432
432
 
433
433
  async function getJobLog(
434
- runId: string,
435
- jobId: number,
436
- effectiveRepo: string,
434
+ job: CiLogsJob,
437
435
  signal: AbortSignal | undefined,
438
436
  cwd: string | undefined,
439
437
  input?: unknown,
440
438
  ): Promise<string> {
441
- const cacheFile = jobLogPath(runId, jobId);
439
+ // The log download only accepts a job id, and the cache is keyed on the
440
+ // canonical repo/run from the job itself, not on the caller's `repo` string.
441
+ const repo = repoFromRunUrl(job.run_url);
442
+ const cacheFile = jobLogPath(repo, String(job.run_id), job.id);
442
443
  const cacheDir = dirname(cacheFile);
443
444
 
444
- // 同一 runId:jobId 的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
445
+ // 同一 cache 文件的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
445
446
  // 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
446
- return seq.execute(`${runId}:${jobId}`, async () => {
447
+ return seq.execute(cacheFile, async () => {
447
448
  // Check file cache
448
449
  try {
449
450
  return await readFile(cacheFile, "utf8");
@@ -452,13 +453,14 @@ async function getJobLog(
452
453
  }
453
454
 
454
455
  // `gh api` refuses to print responses that contain terminal escape
455
- // sequences unless `--allow-escape-sequences` is passed. Job logs are a
456
- // binary zip, so without this flag the download always fails with
456
+ // sequences unless `--allow-escape-sequences` is passed. Job logs carry
457
+ // ANSI color codes, so without this flag the download always fails with
457
458
  // "the response contains terminal escape sequences; pass
458
- // --allow-escape-sequences to output it anyway". The ANSI escapes are
459
- // stripped later by `cleanStepOutput`, so there is no injection surface.
459
+ // --allow-escape-sequences to output it anyway". The raw bytes are kept
460
+ // as-is (the file is the log exactly as GitHub delivers it); the tool never
461
+ // echoes them, and the TUI strips ANSI when rendering tool results.
460
462
  const log = await ghExec(
461
- ["api", "--allow-escape-sequences", `/repos/${effectiveRepo}/actions/jobs/${jobId}/logs`],
463
+ ["api", "--allow-escape-sequences", `/repos/${repo}/actions/jobs/${job.id}/logs`],
462
464
  {
463
465
  cwd,
464
466
  signal,
@@ -521,8 +523,14 @@ export function statusIcon(conclusion: string | null): string {
521
523
  }
522
524
  }
523
525
 
526
+ /** A step's line span in the raw job log: 0-based `start`, exclusive `end`. */
527
+ export interface StepSpan {
528
+ start: number;
529
+ end: number;
530
+ }
531
+
524
532
  /**
525
- * Extract step content from raw job log by matching step names to "Run " groups.
533
+ * Locate a job's steps in its raw log by matching step names to "Run " groups.
526
534
  *
527
535
  * Each top-level step emits a `##[group]Run <name>` / `##[group]Post Run <name>`
528
536
  * marker at depth 1. Composite actions emit their internal steps as *additional*
@@ -540,18 +548,14 @@ export function statusIcon(conclusion: string | null): string {
540
548
  * Explicitly named steps that lack a "Run " prefix (e.g. a step named
541
549
  * "Setup node" running actions/setup-node) are located between the previous
542
550
  * and next anchor's groups.
543
- * Steps that were skipped and never executed return null.
544
- *
545
- * Returns null if no matching group is found.
551
+ * Steps that were skipped and never executed get no span.
546
552
  */
547
- export function extractStepFromLog(
553
+ export function stepLineSpans(
548
554
  log: string,
549
- stepNumber: number,
550
555
  apiSteps: { number: number; name: string }[],
551
- ): string | null {
552
- if (apiSteps.every((s) => s.number !== stepNumber)) return null;
553
-
556
+ ): Map<number, StepSpan> {
554
557
  const lines = log.split("\n");
558
+ const spans = new Map<number, StepSpan>();
555
559
 
556
560
  // Collect depth-1 "Run "/"Post Run " groups in log order.
557
561
  const groups: { line: number; action: string }[] = [];
@@ -573,14 +577,6 @@ export function extractStepFromLog(
573
577
  }
574
578
  }
575
579
 
576
- // Step 1 ("Set up job"): everything before the first "Run "/"Post Run " group.
577
- if (stepNumber === 1) {
578
- return lines
579
- .slice(0, groups[0]?.line ?? lines.length)
580
- .join("\n")
581
- .trimEnd();
582
- }
583
-
584
580
  // API steps that produce a "Run "/"Post Run " log group, in step order.
585
581
  const runSteps = apiSteps
586
582
  .filter((s) => /^(Run |Post Run )/.test(s.name))
@@ -604,40 +600,67 @@ export function extractStepFromLog(
604
600
  .map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
605
601
  .toSorted((a, b) => a.line - b.line);
606
602
 
607
- // Direct anchor hit: span from this anchor to the next one.
608
- const anchorIdx = anchors.findIndex((a) => a.stepNum === stepNumber);
609
- if (anchorIdx !== -1) {
610
- const start = anchors[anchorIdx].line;
611
- const end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
612
- return lines.slice(start, end).join("\n").trimEnd();
613
- }
614
-
615
- // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits in
616
- // the gap between the previous and next anchors' groups. Take the first
617
- // unclaimed group in that span.
618
- const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
619
- (acc, a) => (a.stepNum < stepNumber ? a : acc),
620
- undefined,
621
- );
622
- const nextAnchor = anchors.find((a) => a.stepNum > stepNumber);
603
+ for (const s of apiSteps) {
604
+ let start: number;
605
+ let end: number;
623
606
 
624
- const spanStart = prevAnchor ? prevAnchor.line + 1 : 0;
625
- const spanEnd = nextAnchor ? nextAnchor.line : lines.length;
607
+ if (s.number === 1) {
608
+ // Step 1 ("Set up job"): everything before the first "Run " group.
609
+ start = 0;
610
+ end = groups[0]?.line ?? lines.length;
611
+ } else {
612
+ const anchorIdx = anchors.findIndex((a) => a.stepNum === s.number);
613
+ if (anchorIdx === -1) {
614
+ // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits
615
+ // in the gap between the previous and next anchors' groups. Take the
616
+ // first unclaimed group in that span.
617
+ const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
618
+ (acc, a) => (a.stepNum < s.number ? a : acc),
619
+ undefined,
620
+ );
621
+ const nextAnchor = anchors.find((a) => a.stepNum > s.number);
622
+ const gapStart = prevAnchor ? prevAnchor.line + 1 : 0;
623
+ const gapEnd = nextAnchor ? nextAnchor.line : lines.length;
626
624
 
627
- for (const [gi, g] of groups.entries()) {
628
- if (used.has(gi)) continue;
629
- if (g.line >= spanStart && g.line < spanEnd) {
630
- return lines.slice(g.line, spanEnd).join("\n").trimEnd();
625
+ const gi = groups.findIndex(
626
+ (g, idx) => !used.has(idx) && g.line >= gapStart && g.line < gapEnd,
627
+ );
628
+ if (gi === -1) continue; // step never ran → no block in the log
629
+ start = groups[gi].line;
630
+ end = gapEnd;
631
+ } else {
632
+ // Direct anchor hit: span from this anchor to the next one.
633
+ start = anchors[anchorIdx].line;
634
+ end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
635
+ }
631
636
  }
637
+
638
+ // Drop trailing blank lines so the span slices to the step's own text.
639
+ while (end > start && lines[end - 1].trim() === "") end--;
640
+ spans.set(s.number, { start, end });
632
641
  }
633
642
 
634
- return null;
643
+ return spans;
644
+ }
645
+
646
+ /** Text of `stepNumber` in its raw log, or null when the step never ran. */
647
+ export function extractStepFromLog(
648
+ log: string,
649
+ stepNumber: number,
650
+ apiSteps: { number: number; name: string }[],
651
+ ): string | null {
652
+ const span = stepLineSpans(log, apiSteps).get(stepNumber);
653
+ if (span === undefined) return null;
654
+ return log.split("\n").slice(span.start, span.end).join("\n").trimEnd();
635
655
  }
636
656
 
637
657
  // ── ci-logs rendering (pure, testable) ──────────────────────────────────────
638
658
 
639
659
  export interface CiLogsJob {
640
660
  id: number;
661
+ run_id: number;
662
+ /** Canonical `api.github.com/repos/<owner>/<repo>/actions/runs/<id>`. */
663
+ run_url: string;
641
664
  name: string;
642
665
  status: string;
643
666
  conclusion: string | null;
@@ -649,500 +672,50 @@ export interface CiLogsResult {
649
672
  details: Record<string, unknown>;
650
673
  }
651
674
 
652
- /** GitHub Actions runner line prefix: `2026-08-05T16:35:50.8358826Z `. */
653
- const RUNNER_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z /;
654
- /** ANSI color escape sequences. */
655
- // eslint-disable-next-line no-control-regex -- intentional: matching raw ESC sequences in runner logs
656
- const ANSI_RE = /\u001B\[[0-9;]*m/g;
657
-
658
- /**
659
- * Strip the runner framing from a step's raw log, leaving the command's own
660
- * output as plain text: removes the per-line timestamp prefix, ANSI color
661
- * escapes and `##[group]` / `##[endgroup]` marker lines. `##[error]` /
662
- * `##[warning]` lines are kept — their message is part of the output.
663
- */
664
- export function cleanStepOutput(stepLog: string): string {
665
- return stepLog
666
- .split("\n")
667
- .map((line) =>
668
- line
669
- .replace(/^\uFEFF/, "") // UTF-8 BOM on the first line
670
- .replace(RUNNER_TIMESTAMP_RE, "")
671
- .replaceAll(ANSI_RE, "")
672
- .replace(/\r$/, "")
673
- .trimEnd(),
674
- )
675
- .filter((line) => !line.startsWith("##[group]") && !line.startsWith("##[endgroup]"))
676
- .join("\n")
677
- .trim();
678
- }
679
-
680
- /**
681
- * Strip terminal escape sequences and a leading UTF-8 BOM from a raw job log,
682
- * keeping everything else — timestamps, `##[group]` markers, blank lines —
683
- * intact. Used when writing a job's complete log to a file: complete, but
684
- * readable without ANSI garbage.
685
- */
686
- export function stripAnsi(text: string): string {
687
- return text.replace(/^\uFEFF/, "").replaceAll(ANSI_RE, "");
688
- }
689
-
690
- /**
691
- * Appended to `read-github-ci-logs` content so the raw log file path reaches
692
- * the model: tool `details` is not part of the LLM context, so the path has to
693
- * live in the returned text.
694
- */
695
- function rawLogNotice(path: string): string {
696
- return `[Raw job log (whole job): ${path}]`;
697
- }
698
-
699
- export interface StepLogParams {
700
- runId: string;
701
- job?: string;
702
- step: string;
703
- offset?: number;
704
- limit?: number;
705
- /** Return the complete, untruncated step output (ignores `offset`/`limit`). */
706
- full?: boolean;
707
- }
708
-
709
- /**
710
- * Render the result of `read-github-ci-logs` for a single step: the step's
711
- * complete log as plain text (no runner framing). `job` is required — a step
712
- * only exists inside a specific job. `offset`/`limit` control the returned
713
- * text. Pure — no network, no `gh`.
714
- */
715
- export async function renderStepLog(
716
- params: StepLogParams,
717
- jobs: CiLogsJob[],
718
- fetchJobLog: (jobId: number) => Promise<string>,
719
- onUpdate?: (msg: CiLogsResult) => void,
720
- ): Promise<CiLogsResult> {
721
- const { job, step, offset, limit, full } = params;
722
-
723
- if (!job) {
724
- return {
725
- content: [{ type: "text", text: "`job` is required when fetching a step's logs." }],
726
- details: {},
727
- };
728
- }
729
-
730
- const isNumeric = /^\d+$/.test(job);
731
- const targetJob = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
732
- if (!targetJob) {
733
- return {
734
- content: [
735
- {
736
- type: "text",
737
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
738
- },
739
- ],
740
- details: {},
741
- };
742
- }
743
-
744
- if (targetJob.status === "queued") {
745
- return {
746
- content: [
747
- {
748
- type: "text",
749
- text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
750
- },
751
- ],
752
- details: {},
753
- };
754
- }
755
-
756
- // Resolve step name → number
757
- const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
758
- if (!found) {
759
- return {
760
- content: [
761
- {
762
- type: "text",
763
- text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
764
- },
765
- ],
766
- details: {},
767
- };
768
- }
769
- const stepNum = found.number;
770
-
771
- if (stepNum < 1 || stepNum > targetJob.steps.length) {
772
- return {
773
- content: [
774
- {
775
- type: "text",
776
- text: `Step ${stepNum} out of range. Job "${targetJob.name}" has ${targetJob.steps.length} steps (1-${targetJob.steps.length}).`,
777
- },
778
- ],
779
- details: {},
780
- };
781
- }
782
-
783
- onUpdate?.({
784
- content: [{ type: "text", text: `Fetching logs for step ${stepNum}...` }],
785
- details: {},
786
- });
787
-
788
- const rawLog = await fetchJobLog(targetJob.id);
789
- const rawLogFile = jobLogPath(params.runId, targetJob.id);
790
-
791
- const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
792
- if (stepLog === null) {
793
- return {
794
- content: [
795
- {
796
- type: "text",
797
- text: `Could not extract step ${stepNum} from job "${targetJob.name}" logs. The log may be malformed or empty — read the raw file instead.\n\n${rawLogNotice(rawLogFile)}`,
798
- },
799
- ],
800
- details: { rawLogFile },
801
- };
802
- }
803
-
804
- const clean = cleanStepOutput(stepLog);
805
-
806
- // `full`: return the complete output, no truncation and no offset.
807
- if (full) {
808
- const fullLines = clean.split("\n").length;
809
- return {
810
- content: [{ type: "text", text: `${clean}\n\n${rawLogNotice(rawLogFile)}` }],
811
- details: {
812
- summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: complete output (${fullLines} lines)`,
813
- truncated: false,
814
- full: true,
815
- rawLogFile,
816
- job: {
817
- name: targetJob.name,
818
- conclusion: targetJob.conclusion,
819
- steps: stepsDetail(targetJob, new Set([stepNum])),
820
- },
821
- totalLines: fullLines,
822
- shownLines: fullLines,
823
- },
824
- };
825
- }
826
-
827
- // Apply offset on the cleaned text, then truncate.
828
- const totalLines = clean.split("\n").length;
829
- let logToShow = clean;
830
- let appliedOffset = false;
831
- if (offset !== undefined && offset > 1) {
832
- if (offset > totalLines) {
833
- return {
834
- content: [
835
- {
836
- type: "text",
837
- text: `Offset ${offset} exceeds step log length (${totalLines} lines).\n\n${rawLogNotice(rawLogFile)}`,
838
- },
839
- ],
840
- details: { rawLogFile },
841
- };
842
- }
843
- logToShow = clean
844
- .split("\n")
845
- .slice(offset - 1)
846
- .join("\n");
847
- appliedOffset = true;
848
- }
849
-
850
- const maxLines = limit ?? 500;
851
- const maxBytes = 60 * 1024;
852
- const { text, truncated: tr } = truncate(logToShow, maxLines, maxBytes);
853
- const shownLines = text.split("\n").length;
854
-
855
- return {
856
- content: [{ type: "text", text: `${text}\n\n${rawLogNotice(rawLogFile)}` }],
857
- details: {
858
- summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: ${shownLines} of ${totalLines} lines${tr ? " (truncated)" : ""}`,
859
- truncated: tr,
860
- rawLogFile,
861
- job: {
862
- name: targetJob.name,
863
- conclusion: targetJob.conclusion,
864
- steps: stepsDetail(targetJob, new Set([stepNum])),
865
- },
866
- totalLines,
867
- shownLines,
868
- offset: appliedOffset ? offset : undefined,
869
- },
870
- };
871
- }
872
-
873
- export interface JobLogsParams {
874
- runId: string;
875
- job?: string;
876
- offset?: number;
877
- limit?: number;
878
- /** Expand every step's complete output (default: only failed steps, truncated). */
879
- full?: boolean;
880
- }
881
-
882
- export interface JobLogsStep {
675
+ /** One step of a job, indexed into the job's raw log file. */
676
+ export interface CiLogsStepIndex {
677
+ number: number;
883
678
  name: string;
884
- output?: string;
679
+ conclusion: string | null;
680
+ /** 1-based inclusive line range of this step's block in `log_file`. */
681
+ start_line?: number;
682
+ end_line?: number;
885
683
  }
886
684
 
887
- export interface JobLogsOutput {
685
+ /** A job's steps plus the raw log file holding their output. */
686
+ export interface CiLogsJobIndex {
888
687
  name: string;
889
- /** Absolute path of this job's raw log on disk, set when its log was fetched. */
890
- log_file?: string;
891
- steps: JobLogsStep[];
688
+ id: number;
689
+ status: string;
690
+ conclusion: string | null;
691
+ log_file: string;
692
+ steps: CiLogsStepIndex[];
892
693
  }
893
694
 
894
695
  /**
895
- * Render the result of `read-github-ci-logs` without a `step`: a JSON array of
896
- * jobs `[{ name, log_file?, steps: [{ name, output? }] }]`. Every step is listed
897
- * by name; only failed steps carry an `output` (their log as plain text).
898
- * `log_file` is the raw on-disk log path for jobs whose log was fetched. `job`
899
- * is an optional filter; `offset`/`limit` control the size of each `output`
900
- * text. Pure — no network, no `gh`.
696
+ * Index a job's steps into its raw log: every step that produced a log block
697
+ * gets the `[start_line, end_line]` range (1-based, inclusive) of that block in
698
+ * `log_file`; steps that never ran (skipped, or absent from the log) carry no
699
+ * range. The step content itself is not returned — the model reads it out of
700
+ * the file.
901
701
  */
902
- export async function renderJobLogs(
903
- params: JobLogsParams,
904
- jobs: CiLogsJob[],
905
- fetchJobLog: (jobId: number) => Promise<string>,
906
- ): Promise<CiLogsResult> {
907
- const { job, offset, limit, full } = params;
908
-
909
- if (jobs.length === 0) {
910
- return {
911
- content: [{ type: "text", text: `No jobs found for run ${params.runId}` }],
912
- details: {},
913
- };
914
- }
915
-
916
- let targetJobs = jobs;
917
- if (job) {
918
- const isNumeric = /^\d+$/.test(job);
919
- targetJobs = jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job);
920
- if (targetJobs.length === 0) {
921
- return {
922
- content: [
923
- {
924
- type: "text",
925
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
926
- },
927
- ],
928
- details: {},
929
- };
930
- }
931
- }
932
-
933
- const output: JobLogsOutput[] = [];
934
-
935
- for (const j of targetJobs) {
936
- const steps: JobLogsStep[] = [];
937
- let rawLog: string | null = null;
938
-
939
- for (const s of j.steps) {
940
- if (!full && s.conclusion !== "failure") {
941
- steps.push({ name: s.name });
942
- continue;
943
- }
944
-
945
- try {
946
- rawLog ??= await fetchJobLog(j.id);
947
- const stepLog = extractStepFromLog(rawLog, s.number, j.steps);
948
- if (!stepLog) {
949
- steps.push({ name: s.name });
950
- continue;
951
- }
952
-
953
- const clean = cleanStepOutput(stepLog);
954
-
955
- // `full`: every step carries its complete, untruncated output.
956
- if (full) {
957
- steps.push({ name: s.name, output: clean });
958
- continue;
959
- }
960
-
961
- const totalLines = clean.split("\n").length;
962
-
963
- // Apply offset on the cleaned text, then truncate.
964
- let logToShow = clean;
965
- if (offset !== undefined && offset > 1) {
966
- if (offset > totalLines) {
967
- steps.push({ name: s.name });
968
- continue;
969
- }
970
- logToShow = clean
971
- .split("\n")
972
- .slice(offset - 1)
973
- .join("\n");
974
- }
975
-
976
- const { text } = truncate(logToShow, limit ?? 500, 60 * 1024);
977
- steps.push({ name: s.name, ...(text && { output: text }) });
978
- } catch {
979
- // Log fetch failed — list the step without an output.
980
- steps.push({ name: s.name });
981
- }
982
- }
983
-
984
- output.push({
985
- name: j.name,
986
- ...(rawLog !== null && { log_file: jobLogPath(params.runId, j.id) }),
987
- steps,
988
- });
989
- }
990
-
991
- const totalJobs = output.length;
992
- const failedJobs = output.filter((j) => j.steps.some((s) => s.output !== undefined)).length;
993
- const expandedSteps = output.reduce(
994
- (acc, j) => acc + j.steps.filter((s) => s.output !== undefined).length,
995
- 0,
996
- );
997
-
702
+ export function jobLogIndex(job: CiLogsJob, rawLog: string): CiLogsJobIndex {
703
+ const spans = stepLineSpans(rawLog, job.steps);
998
704
  return {
999
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
1000
- details: {
1001
- summary: full
1002
- ? `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${expandedSteps} step output${expandedSteps === 1 ? "" : "s"} expanded (full, untruncated)`
1003
- : `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${expandedSteps} failed step${expandedSteps > 1 ? "s" : ""}`,
1004
- truncated: undefined,
1005
- ...(full && { full: true }),
1006
- jobs: targetJobs.map((j) => ({
1007
- name: j.name,
1008
- conclusion: j.conclusion,
1009
- steps: stepsDetail(j, undefined),
1010
- })),
1011
- },
1012
- };
1013
- }
1014
-
1015
- // ── writing complete logs to a file ─────────────────────────────────────────
1016
-
1017
- export interface WriteLogFileParams {
1018
- runId: string;
1019
- job?: string;
1020
- step?: string;
1021
- outputFile: string;
1022
- }
1023
-
1024
- /**
1025
- * Write the complete log to a file and return metadata (path, line/byte
1026
- * counts) instead of the log content itself. With `step`: the step's cleaned
1027
- * output. Without `step`: the whole job's log, timestamps and `##[group]`
1028
- * markers kept but ANSI escapes stripped. `job` is required when the run has
1029
- * more than one job (a single-job run is used implicitly). Relative
1030
- * `outputFile` paths resolve against `cwd`.
1031
- */
1032
- export async function writeLogFile(
1033
- params: WriteLogFileParams,
1034
- jobs: CiLogsJob[],
1035
- fetchJobLog: (jobId: number) => Promise<string>,
1036
- cwd: string | undefined,
1037
- input: unknown,
1038
- ): Promise<CiLogsResult> {
1039
- const { job, step, outputFile } = params;
1040
-
1041
- const isNumeric = /^\d+$/.test(job ?? "");
1042
- const targetJobs = job ? jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job) : jobs;
1043
- if (targetJobs.length === 0) {
1044
- return {
1045
- content: [
1046
- {
1047
- type: "text",
1048
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1049
- },
1050
- ],
1051
- details: { input },
1052
- };
1053
- }
1054
- if (targetJobs.length > 1) {
1055
- return {
1056
- content: [
1057
- {
1058
- type: "text",
1059
- text: `Job "${job}" matches ${targetJobs.length} jobs. Specify a unique job name or id. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1060
- },
1061
- ],
1062
- details: { input },
1063
- };
1064
- }
1065
- const targetJob = targetJobs[0];
1066
-
1067
- if (targetJob.status === "queued") {
1068
- return {
1069
- content: [
1070
- {
1071
- type: "text",
1072
- text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1073
- },
1074
- ],
1075
- details: { input },
1076
- };
1077
- }
1078
-
1079
- const rawLog = await fetchJobLog(targetJob.id);
1080
-
1081
- let content: string;
1082
- let what: string;
1083
- if (step === undefined) {
1084
- content = stripAnsi(rawLog);
1085
- what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1086
- } else {
1087
- const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
1088
- if (found === undefined) {
1089
- return {
1090
- content: [
1091
- {
1092
- type: "text",
1093
- text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
1094
- },
1095
- ],
1096
- details: { input },
1097
- };
1098
- }
1099
- const stepLog = extractStepFromLog(rawLog, found.number, targetJob.steps);
1100
- if (stepLog === null) {
705
+ name: job.name,
706
+ id: job.id,
707
+ status: job.status,
708
+ conclusion: job.conclusion,
709
+ log_file: jobLogPath(repoFromRunUrl(job.run_url), String(job.run_id), job.id),
710
+ steps: job.steps.map((s) => {
711
+ const span = spans.get(s.number);
1101
712
  return {
1102
- content: [
1103
- {
1104
- type: "text",
1105
- text: `Could not extract step ${found.number} from job "${targetJob.name}" logs. The log may be malformed or empty.`,
1106
- },
1107
- ],
1108
- details: { input },
713
+ number: s.number,
714
+ name: s.name,
715
+ conclusion: s.conclusion,
716
+ ...(span && span.end > span.start && { start_line: span.start + 1, end_line: span.end }),
1109
717
  };
1110
- }
1111
- content = cleanStepOutput(stepLog);
1112
- what = `step ${found.number} ("${found.name}") of job "${targetJob.name}"`;
1113
- }
1114
-
1115
- const target = resolve(cwd ?? process.cwd(), outputFile);
1116
- await mkdir(dirname(target), { recursive: true });
1117
- await writeFile(target, content);
1118
-
1119
- const lines = content.split("\n").length;
1120
- const bytes = Buffer.byteLength(content, "utf8");
1121
-
1122
- return {
1123
- content: [
1124
- {
1125
- type: "text",
1126
- text:
1127
- `## CI log written to \`${target}\`\n\n` +
1128
- `- content: ${what}\n` +
1129
- `- ${lines} lines, ${bytes} bytes\n` +
1130
- `- run: ${params.runId}\n\n` +
1131
- `Read it with the \`read\` tool (use \`offset\`/\`limit\` for large files).`,
1132
- },
1133
- ],
1134
- details: {
1135
- outputFile: target,
1136
- lines,
1137
- bytes,
1138
- runId: params.runId,
1139
- job: {
1140
- name: targetJob.name,
1141
- id: targetJob.id,
1142
- conclusion: targetJob.conclusion,
1143
- },
1144
- input,
1145
- },
718
+ }),
1146
719
  };
1147
720
  }
1148
721
 
@@ -1880,49 +1453,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1880
1453
  name: "read-github-ci-logs",
1881
1454
  label: "GitHub CI Logs",
1882
1455
  description:
1883
- "Get CI logs from a GitHub Actions workflow run. Without step: returns a JSON array of jobs [{name, log_file?, steps:[{name, output?}]}] where every step is listed by name and failed steps carry their log as plain text in `output`. With step (requires job): returns that step's complete log as plain text. Every fetched job log is also saved raw (timestamps and ANSI kept) to a local file whose path is reported — `log_file` on the job, or a trailing `[Raw job log (whole job): <path>]` line with step — so read/grep that file for the complete, untruncated log. offset/limit control the size of every expanded output. Use run_id from list-github-workflow-runs. Note: queued jobs have no logs yet; use watch-github-run to wait for completion. Set full=true for complete untruncated outputs (every step when step is omitted; caution: very large outputs consume a lot of LLM context). Set output_file=/path to write the complete cleaned log to this file instead of returning it (requires job when the run has multiple jobs); the tool returns the file path to read.",
1456
+ "Download one GitHub Actions job's CI log and index its steps. Returns JSON {name, id, status, conclusion, log_file, steps:[{number, name, conclusion, start_line?, end_line?}]}: `log_file` is the job's complete raw log on disk (runner timestamps and ANSI kept, exactly as GitHub delivers it) and each step carries the 1-based inclusive line range of its block inside that file. Read the content out of the file yourself (read/grep with offset/limit) — it is not echoed back. Get `job` names/ids from read-github-workflow-jobs." +
1457
+ " Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
1884
1458
  promptSnippet: "Read GitHub CI logs",
1885
1459
  parameters: Type.Object({
1886
1460
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1887
1461
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1888
- job: Type.Optional(
1889
- Type.String({
1890
- description:
1891
- "Job name or ID. Optional filter when listing jobs; required when fetching a specific step's logs.",
1892
- }),
1893
- ),
1894
- step: Type.Optional(
1895
- Type.String({
1896
- description:
1897
- "Step name to fetch the complete log for. Requires `job`. Omit to list jobs/steps with failed step logs expanded.",
1898
- }),
1899
- ),
1900
- offset: Type.Optional(
1901
- Type.Number({
1902
- description:
1903
- "Line number to start each output text from (1-indexed). Useful for long outputs where the error is at the end.",
1904
- }),
1905
- ),
1906
- limit: Type.Optional(
1907
- Type.Number({
1908
- description: "Maximum number of lines per output text (default 500).",
1909
- }),
1910
- ),
1911
- full: Type.Optional(
1912
- Type.Boolean({
1913
- description:
1914
- "Return complete, untruncated output instead of the default 500-line/60KB cap. With `step`: that step's full output. Without `step`: every step's full output (not just failed ones). Ignored when `output_file` is set. Caution: very large outputs consume a lot of LLM context — prefer `output_file` for big logs.",
1915
- }),
1916
- ),
1917
- output_file: Type.Optional(
1918
- Type.String({
1919
- description:
1920
- "Write the complete log to this file instead of returning it (relative paths resolve against the working directory). With `step` (requires `job`): the step's cleaned output. Without `step`: requires `job` (or a run with a single job) and writes that job's full log — timestamps and group markers kept, ANSI escapes stripped. Returns the file path; read it with the `read` tool.",
1921
- }),
1922
- ),
1462
+ job: Type.String({ description: "Job name or job ID, from read-github-workflow-jobs." }),
1923
1463
  }),
1924
1464
  async execute(_id, params, signal, onUpdate, ctx) {
1925
- const { run_id, repo, job, step, offset, limit, full, output_file } = params;
1465
+ const { run_id, repo, job } = params;
1466
+ const runId = String(run_id);
1926
1467
 
1927
1468
  const pendant = subtitlePendant(params, "run_id");
1928
1469
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
@@ -1933,52 +1474,35 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1933
1474
  });
1934
1475
  const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
1935
1476
 
1936
- const fetchJobLog = (jobId: number): Promise<string> =>
1937
- getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
1938
-
1939
- // ── Write the complete log to a file ───────────────────────────────
1940
- if (output_file !== undefined && output_file !== "") {
1941
- const result = await writeLogFile(
1942
- { runId: String(run_id), job, step, outputFile: output_file },
1943
- jobs,
1944
- fetchJobLog,
1945
- ctx.cwd,
1946
- params,
1477
+ const failure = (text: string): CiLogsResult => ({
1478
+ content: [{ type: "text", text }],
1479
+ details: { input: params, ...(pendant && { pendant }) },
1480
+ });
1481
+
1482
+ const isNumeric = /^\d+$/.test(job);
1483
+ const target = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
1484
+ if (target === undefined) {
1485
+ return failure(
1486
+ `Job "${job}" not found in run ${runId}. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1947
1487
  );
1948
- return { ...result, details: { ...result.details, ...(pendant && { pendant }) } };
1949
1488
  }
1950
-
1951
- // ── Fetch a specific step's logs (requires `job`) ─────────────────
1952
- if (step !== undefined) {
1953
- onUpdate?.({
1954
- content: [{ type: "text", text: `Fetching job list...` }],
1955
- details: {},
1956
- });
1957
- const stepResult = await renderStepLog(
1958
- { runId: String(run_id), job, step, offset, limit, full },
1959
- jobs,
1960
- fetchJobLog,
1961
- onUpdate,
1489
+ if (target.status === "queued") {
1490
+ return failure(
1491
+ `Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1962
1492
  );
1963
- return {
1964
- ...stepResult,
1965
- details: { ...stepResult.details, input: params, ...(pendant && { pendant }) },
1966
- };
1967
1493
  }
1968
1494
 
1969
- // ── List jobs/steps, with failed step logs expanded ────────────────
1970
1495
  onUpdate?.({
1971
- content: [{ type: "text", text: `Fetching job list...` }],
1496
+ content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
1972
1497
  details: {},
1973
1498
  });
1974
- const jobsResult = await renderJobLogs(
1975
- { runId: String(run_id), job, offset, limit, full },
1976
- jobs,
1977
- fetchJobLog,
1978
- );
1499
+
1500
+ const rawLog = await getJobLog(target, signal, ctx.cwd, params);
1501
+ const index = jobLogIndex(target, rawLog);
1502
+
1979
1503
  return {
1980
- ...jobsResult,
1981
- details: { ...jobsResult.details, input: params, ...(pendant && { pendant }) },
1504
+ content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
1505
+ details: { ...index, input: params, ...(pendant && { pendant }) },
1982
1506
  };
1983
1507
  },
1984
1508
  });