@trim21/personal-pi-extensions 0.1.541 → 0.1.544

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.541",
3
+ "version": "0.1.544",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -14,7 +14,7 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { Type } from "typebox";
16
16
 
17
- import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
17
+ import { appendLspDiagnosticText, type DiagnosticReport } from "../lib/lsp/diagnostic.js";
18
18
  import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
19
19
  import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
20
20
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
@@ -333,24 +333,25 @@ export function registerFileTools(
333
333
  const snapshot = snapshotOf(buffer);
334
334
  state.reads.set(key, snapshot);
335
335
  // 与 Edit / Write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
336
- const {
337
- text: diagnosticText,
338
- errorCount,
339
- warningCount,
340
- } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
336
+ const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
341
337
  notify: (message, level) => ctx.ui.notify(message, level),
342
338
  });
343
339
  return {
344
340
  content: [
345
341
  {
346
342
  type: "text",
347
- text: appendLspDiagnosticText(formatted.text, diagnosticText, errorCount),
343
+ text: appendLspDiagnosticText(formatted.text, diagnostics.text),
348
344
  },
349
345
  ],
350
346
  details: {
351
347
  reads: { [key]: snapshot },
352
348
  pendant: {
353
- subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount),
349
+ subtitle: formatSubtitlePath(
350
+ ctx.cwd,
351
+ filePath,
352
+ diagnostics.errorCount,
353
+ diagnostics.warningCount,
354
+ ),
354
355
  title: "Read",
355
356
  } satisfies ToolPendant,
356
357
  },
@@ -394,178 +395,167 @@ export function registerFileTools(
394
395
  replaceAll: params.replace_all,
395
396
  },
396
397
  });
397
- const [message, details, diagnosticText, errorCount, warningCount] =
398
- await withFileMutationQueue<[string, FileToolDetails, string, number, number]>(
399
- filePath,
400
- async () => {
401
- const oldString = params.old_string;
402
- const newString = params.new_string;
403
- if (oldString === newString) {
404
- throw new Error(
405
- "No changes to make: old_string and new_string are exactly the same.",
406
- );
407
- }
408
- // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
409
- if (oldString === "") {
410
- let exists = true;
411
- try {
412
- const value = await stat(filePath);
413
- if (value.isFile()) {
414
- const content = await readFile(filePath, "utf8");
415
- if (content.trim() !== "") {
416
- throw new Error("Cannot create new file - file already exists.");
417
- }
418
- }
419
- } catch (error) {
420
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
421
- exists = false;
422
- } else {
423
- throw error;
424
- }
425
- }
426
- if (!exists) await mkdir(dirname(filePath), { recursive: true });
427
- await writeFile(filePath, newString, "utf8");
428
- const snapshot = snapshotOf(newString);
429
- const key = await readStateKey(filePath);
430
- state.reads.set(key, snapshot);
431
- const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
432
- signal?.throwIfAborted();
433
- const {
434
- text: diagnosticText,
435
- errorCount,
436
- warningCount,
437
- } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
438
- notify: (message, level) => ctx.ui.notify(message, level),
439
- signal,
440
- });
441
- return [
442
- `The file ${filePath} has been updated successfully.`,
443
- {
444
- diff: diff.diff,
445
- patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
446
- firstChangedLine: diff.firstChangedLine,
447
- reads: { [key]: snapshot },
448
- },
449
- diagnosticText,
450
- errorCount,
451
- warningCount,
452
- ];
453
- }
454
- const replaceAll = params.replace_all ?? false;
455
- // 防止 OOM 的大文件检查(对齐 Claude Code)
456
- try {
457
- const { size } = await stat(filePath);
458
- if (size > MAX_EDIT_FILE_SIZE) {
459
- throw new Error(
460
- `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
461
- );
462
- }
463
- } catch (error) {
464
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
465
- throw error;
398
+ const [message, details, diagnostics] = await withFileMutationQueue<
399
+ [string, FileToolDetails, DiagnosticReport]
400
+ >(filePath, async () => {
401
+ const oldString = params.old_string;
402
+ const newString = params.new_string;
403
+ if (oldString === newString) {
404
+ throw new Error("No changes to make: old_string and new_string are exactly the same.");
405
+ }
406
+ // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
407
+ if (oldString === "") {
408
+ let exists = true;
409
+ try {
410
+ const value = await stat(filePath);
411
+ if (value.isFile()) {
412
+ const content = await readFile(filePath, "utf8");
413
+ if (content.trim() !== "") {
414
+ throw new Error("Cannot create new file - file already exists.");
466
415
  }
467
416
  }
468
- let content: Buffer;
469
- try {
470
- content = await readFile(filePath);
471
- } catch (error) {
472
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
473
- const suggestion = await didYouMean(filePath, ctx.cwd);
474
- throw new Error(
475
- `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
476
- { cause: error },
477
- );
478
- }
417
+ } catch (error) {
418
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
419
+ exists = false;
420
+ } else {
479
421
  throw error;
480
422
  }
481
- if (extname(filePath).toLowerCase() === ".ipynb") {
482
- throw new Error(
483
- "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
484
- );
485
- }
486
- const key = await readStateKey(filePath);
487
- requireCurrentRead(state, key, filePath, content);
488
- await access(filePath, constants.R_OK | constants.W_OK);
489
- signal?.throwIfAborted();
490
- const original = content.toString("utf8");
491
-
492
- // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
493
- const crlfCount = (original.match(/\r\n/g) ?? []).length;
494
- const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
495
- const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
496
- const normalized = original.replaceAll("\r\n", "\n");
497
- const actualOldString = findActualString(normalized, oldString) ?? oldString;
498
- const matches = normalized.split(actualOldString).length - 1;
499
- if (matches === 0) {
500
- throw new Error(`String to replace not found in file.\nString: ${oldString}`);
501
- }
502
- if (!replaceAll && matches > 1) {
503
- throw new Error(
504
- `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
505
- );
506
- }
507
- const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
508
- // 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
509
- // "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
510
- // applyEditToFile 的 stripTrailingNewline 语义)
511
- let searchString = actualOldString;
512
- if (
513
- actualNewString === "" &&
514
- !actualOldString.endsWith("\n") &&
515
- normalized.includes(actualOldString + "\n")
516
- ) {
517
- searchString = actualOldString + "\n";
518
- }
519
- // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
520
- const updated = replaceAll
521
- ? normalized.split(searchString).join(actualNewString)
522
- : normalized.replace(searchString, () => actualNewString);
523
- const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
524
- await writeFile(filePath, restored, "utf8");
525
- const snapshot = snapshotOf(restored);
526
- state.reads.set(key, snapshot);
527
- // patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
528
- const diff = generateDiffString(
423
+ }
424
+ if (!exists) await mkdir(dirname(filePath), { recursive: true });
425
+ await writeFile(filePath, newString, "utf8");
426
+ const snapshot = snapshotOf(newString);
427
+ const key = await readStateKey(filePath);
428
+ state.reads.set(key, snapshot);
429
+ const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
430
+ signal?.throwIfAborted();
431
+ const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
432
+ notify: (message, level) => ctx.ui.notify(message, level),
433
+ signal,
434
+ });
435
+ return [
436
+ `The file ${filePath} has been updated successfully.`,
437
+ {
438
+ diff: diff.diff,
439
+ patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
440
+ firstChangedLine: diff.firstChangedLine,
441
+ reads: { [key]: snapshot },
442
+ },
443
+ diagnostics,
444
+ ];
445
+ }
446
+ const replaceAll = params.replace_all ?? false;
447
+ // 防止 OOM 的大文件检查(对齐 Claude Code)
448
+ try {
449
+ const { size } = await stat(filePath);
450
+ if (size > MAX_EDIT_FILE_SIZE) {
451
+ throw new Error(
452
+ `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
453
+ );
454
+ }
455
+ } catch (error) {
456
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
457
+ throw error;
458
+ }
459
+ }
460
+ let content: Buffer;
461
+ try {
462
+ content = await readFile(filePath);
463
+ } catch (error) {
464
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
465
+ const suggestion = await didYouMean(filePath, ctx.cwd);
466
+ throw new Error(
467
+ `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
468
+ { cause: error },
469
+ );
470
+ }
471
+ throw error;
472
+ }
473
+ if (extname(filePath).toLowerCase() === ".ipynb") {
474
+ throw new Error(
475
+ "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
476
+ );
477
+ }
478
+ const key = await readStateKey(filePath);
479
+ requireCurrentRead(state, key, filePath, content);
480
+ await access(filePath, constants.R_OK | constants.W_OK);
481
+ signal?.throwIfAborted();
482
+ const original = content.toString("utf8");
483
+
484
+ // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
485
+ const crlfCount = (original.match(/\r\n/g) ?? []).length;
486
+ const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
487
+ const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
488
+ const normalized = original.replaceAll("\r\n", "\n");
489
+ const actualOldString = findActualString(normalized, oldString) ?? oldString;
490
+ const matches = normalized.split(actualOldString).length - 1;
491
+ if (matches === 0) {
492
+ throw new Error(`String to replace not found in file.\nString: ${oldString}`);
493
+ }
494
+ if (!replaceAll && matches > 1) {
495
+ throw new Error(
496
+ `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
497
+ );
498
+ }
499
+ const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
500
+ // 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
501
+ // "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
502
+ // applyEditToFile 的 stripTrailingNewline 语义)
503
+ let searchString = actualOldString;
504
+ if (
505
+ actualNewString === "" &&
506
+ !actualOldString.endsWith("\n") &&
507
+ normalized.includes(actualOldString + "\n")
508
+ ) {
509
+ searchString = actualOldString + "\n";
510
+ }
511
+ // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
512
+ const updated = replaceAll
513
+ ? normalized.split(searchString).join(actualNewString)
514
+ : normalized.replace(searchString, () => actualNewString);
515
+ const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
516
+ await writeFile(filePath, restored, "utf8");
517
+ const snapshot = snapshotOf(restored);
518
+ state.reads.set(key, snapshot);
519
+ // patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
520
+ const diff = generateDiffString(
521
+ convertLeadingTabsToSpaces(original),
522
+ convertLeadingTabsToSpaces(restored),
523
+ );
524
+ const text = replaceAll
525
+ ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
526
+ : `The file ${filePath} has been updated successfully.`;
527
+ signal?.throwIfAborted();
528
+ const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
529
+ notify: (message, level) => ctx.ui.notify(message, level),
530
+ });
531
+ return [
532
+ text,
533
+ {
534
+ diff: diff.diff,
535
+ patch: generateUnifiedPatch(
536
+ filePath,
529
537
  convertLeadingTabsToSpaces(original),
530
538
  convertLeadingTabsToSpaces(restored),
531
- );
532
- const text = replaceAll
533
- ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
534
- : `The file ${filePath} has been updated successfully.`;
535
- signal?.throwIfAborted();
536
- const {
537
- text: diagnosticText,
538
- errorCount,
539
- warningCount,
540
- } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
541
- notify: (message, level) => ctx.ui.notify(message, level),
542
- });
543
- return [
544
- text,
545
- {
546
- diff: diff.diff,
547
- patch: generateUnifiedPatch(
548
- filePath,
549
- convertLeadingTabsToSpaces(original),
550
- convertLeadingTabsToSpaces(restored),
551
- ),
552
- firstChangedLine: diff.firstChangedLine,
553
- reads: { [key]: snapshot },
554
- },
555
- diagnosticText,
556
- errorCount,
557
- warningCount,
558
- ];
539
+ ),
540
+ firstChangedLine: diff.firstChangedLine,
541
+ reads: { [key]: snapshot },
559
542
  },
560
- );
543
+ diagnostics,
544
+ ];
545
+ });
561
546
 
562
- const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
547
+ const text = appendLspDiagnosticText(message, diagnostics.text);
563
548
  return {
564
549
  content: [{ type: "text" as const, text }],
565
550
  details: {
566
551
  ...details,
567
552
  pendant: {
568
- subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount),
553
+ subtitle: formatSubtitlePath(
554
+ ctx.cwd,
555
+ filePath,
556
+ diagnostics.errorCount,
557
+ diagnostics.warningCount,
558
+ ),
569
559
  title: "Edit",
570
560
  } satisfies ToolPendant,
571
561
  },
@@ -600,66 +590,65 @@ export function registerFileTools(
600
590
  absolutePath: filePath,
601
591
  change: { oldText: "", newText: params.content },
602
592
  });
603
- const [message, details, diagnosticText, errorCount, warningCount] =
604
- await withFileMutationQueue<[string, FileToolDetails, string, number, number]>(
605
- filePath,
606
- async () => {
607
- let original: string | undefined;
608
- let key: string | undefined;
609
- try {
610
- const value = await stat(filePath);
611
- if (value.isFile()) {
612
- const content = await readFile(filePath);
613
- key = await readStateKey(filePath);
614
- requireCurrentRead(state, key, filePath, content);
615
- original = content.toString("utf8");
616
- }
617
- } catch (error) {
618
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
619
- throw error;
620
- }
621
- }
622
- signal?.throwIfAborted();
623
- await mkdir(dirname(filePath), { recursive: true });
624
- await writeFile(filePath, params.content, "utf8");
625
- const snapshot = snapshotOf(params.content);
626
- // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
627
- const resolvedKey = key ?? (await readStateKey(filePath));
628
- state.reads.set(resolvedKey, snapshot);
629
- const diff = generateDiffString(original ?? "", params.content);
630
- const text =
631
- original === undefined
632
- ? `File created successfully at: ${filePath}`
633
- : `The file ${filePath} has been updated successfully.`;
634
- signal?.throwIfAborted();
635
- const {
636
- text: diagnosticText,
637
- errorCount,
638
- warningCount,
639
- } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
640
- notify: (message, level) => ctx.ui.notify(message, level),
641
- });
642
- return [
643
- text,
644
- {
645
- diff: diff.diff,
646
- patch: generateUnifiedPatch(filePath, original ?? "", params.content),
647
- firstChangedLine: diff.firstChangedLine,
648
- reads: { [resolvedKey]: snapshot },
649
- },
650
- diagnosticText,
651
- errorCount,
652
- warningCount,
653
- ];
593
+ const [message, details, diagnostics] = await withFileMutationQueue<
594
+ [string, FileToolDetails, DiagnosticReport]
595
+ >(filePath, async () => {
596
+ let original: string | undefined;
597
+ let key: string | undefined;
598
+ try {
599
+ const value = await stat(filePath);
600
+ if (value.isFile()) {
601
+ const content = await readFile(filePath);
602
+ key = await readStateKey(filePath);
603
+ requireCurrentRead(state, key, filePath, content);
604
+ original = content.toString("utf8");
605
+ }
606
+ } catch (error) {
607
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
608
+ throw error;
609
+ }
610
+ }
611
+ signal?.throwIfAborted();
612
+ await mkdir(dirname(filePath), { recursive: true });
613
+ await writeFile(filePath, params.content, "utf8");
614
+ const snapshot = snapshotOf(params.content);
615
+ // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
616
+ const resolvedKey = key ?? (await readStateKey(filePath));
617
+ state.reads.set(resolvedKey, snapshot);
618
+ const diff = generateDiffString(original ?? "", params.content);
619
+ const text =
620
+ original === undefined
621
+ ? `File created successfully at: ${filePath}`
622
+ : `The file ${filePath} has been updated successfully.`;
623
+ signal?.throwIfAborted();
624
+ const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
625
+ notify: (message, level) => ctx.ui.notify(message, level),
626
+ });
627
+ return [
628
+ text,
629
+ {
630
+ diff: diff.diff,
631
+ patch: generateUnifiedPatch(filePath, original ?? "", params.content),
632
+ firstChangedLine: diff.firstChangedLine,
633
+ reads: { [resolvedKey]: snapshot },
654
634
  },
655
- );
635
+ diagnostics,
636
+ ];
637
+ });
656
638
 
657
- const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
639
+ const text = appendLspDiagnosticText(message, diagnostics.text);
658
640
  return {
659
641
  content: [{ type: "text" as const, text }],
660
642
  details: {
661
643
  ...details,
662
- pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount, warningCount) },
644
+ pendant: {
645
+ subtitle: formatSubtitlePath(
646
+ ctx.cwd,
647
+ filePath,
648
+ diagnostics.errorCount,
649
+ diagnostics.warningCount,
650
+ ),
651
+ },
663
652
  },
664
653
  };
665
654
  },
@@ -425,6 +425,11 @@ export function stepsDetail(
425
425
  // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
426
426
  const seq = createSeqState();
427
427
 
428
+ /** 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`);
431
+ }
432
+
428
433
  async function getJobLog(
429
434
  runId: string,
430
435
  jobId: number,
@@ -433,8 +438,8 @@ async function getJobLog(
433
438
  cwd: string | undefined,
434
439
  input?: unknown,
435
440
  ): Promise<string> {
436
- const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
437
- const cacheFile = join(cacheDir, `${jobId}.log`);
441
+ const cacheFile = jobLogPath(runId, jobId);
442
+ const cacheDir = dirname(cacheFile);
438
443
 
439
444
  // 同一 runId:jobId 的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
440
445
  // 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
@@ -682,6 +687,15 @@ export function stripAnsi(text: string): string {
682
687
  return text.replace(/^\uFEFF/, "").replaceAll(ANSI_RE, "");
683
688
  }
684
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
+
685
699
  export interface StepLogParams {
686
700
  runId: string;
687
701
  job?: string;
@@ -772,6 +786,7 @@ export async function renderStepLog(
772
786
  });
773
787
 
774
788
  const rawLog = await fetchJobLog(targetJob.id);
789
+ const rawLogFile = jobLogPath(params.runId, targetJob.id);
775
790
 
776
791
  const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
777
792
  if (stepLog === null) {
@@ -779,10 +794,10 @@ export async function renderStepLog(
779
794
  content: [
780
795
  {
781
796
  type: "text",
782
- text: `Could not extract step ${stepNum} from job "${targetJob.name}" logs. The log may be malformed or empty. Try fetching without \`step\` to see the full job log.`,
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)}`,
783
798
  },
784
799
  ],
785
- details: {},
800
+ details: { rawLogFile },
786
801
  };
787
802
  }
788
803
 
@@ -792,11 +807,12 @@ export async function renderStepLog(
792
807
  if (full) {
793
808
  const fullLines = clean.split("\n").length;
794
809
  return {
795
- content: [{ type: "text", text: clean }],
810
+ content: [{ type: "text", text: `${clean}\n\n${rawLogNotice(rawLogFile)}` }],
796
811
  details: {
797
812
  summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: complete output (${fullLines} lines)`,
798
813
  truncated: false,
799
814
  full: true,
815
+ rawLogFile,
800
816
  job: {
801
817
  name: targetJob.name,
802
818
  conclusion: targetJob.conclusion,
@@ -818,10 +834,10 @@ export async function renderStepLog(
818
834
  content: [
819
835
  {
820
836
  type: "text",
821
- text: `Offset ${offset} exceeds step log length (${totalLines} lines).`,
837
+ text: `Offset ${offset} exceeds step log length (${totalLines} lines).\n\n${rawLogNotice(rawLogFile)}`,
822
838
  },
823
839
  ],
824
- details: {},
840
+ details: { rawLogFile },
825
841
  };
826
842
  }
827
843
  logToShow = clean
@@ -837,10 +853,11 @@ export async function renderStepLog(
837
853
  const shownLines = text.split("\n").length;
838
854
 
839
855
  return {
840
- content: [{ type: "text", text }],
856
+ content: [{ type: "text", text: `${text}\n\n${rawLogNotice(rawLogFile)}` }],
841
857
  details: {
842
858
  summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: ${shownLines} of ${totalLines} lines${tr ? " (truncated)" : ""}`,
843
859
  truncated: tr,
860
+ rawLogFile,
844
861
  job: {
845
862
  name: targetJob.name,
846
863
  conclusion: targetJob.conclusion,
@@ -869,15 +886,18 @@ export interface JobLogsStep {
869
886
 
870
887
  export interface JobLogsOutput {
871
888
  name: string;
889
+ /** Absolute path of this job's raw log on disk, set when its log was fetched. */
890
+ log_file?: string;
872
891
  steps: JobLogsStep[];
873
892
  }
874
893
 
875
894
  /**
876
895
  * Render the result of `read-github-ci-logs` without a `step`: a JSON array of
877
- * jobs `[{ name, steps: [{ name, output? }] }]`. Every step is listed by name;
878
- * only failed steps carry an `output` (their log as plain text). `job` is an
879
- * optional filter; `offset`/`limit` control the size of each `output` text.
880
- * Pure — no network, no `gh`.
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`.
881
901
  */
882
902
  export async function renderJobLogs(
883
903
  params: JobLogsParams,
@@ -961,7 +981,11 @@ export async function renderJobLogs(
961
981
  }
962
982
  }
963
983
 
964
- output.push({ name: j.name, steps });
984
+ output.push({
985
+ name: j.name,
986
+ ...(rawLog !== null && { log_file: jobLogPath(params.runId, j.id) }),
987
+ steps,
988
+ });
965
989
  }
966
990
 
967
991
  const totalJobs = output.length;
@@ -1856,7 +1880,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1856
1880
  name: "read-github-ci-logs",
1857
1881
  label: "GitHub CI Logs",
1858
1882
  description:
1859
- "Get CI logs from a GitHub Actions workflow run. Without step: returns a JSON array of jobs [{name, 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. 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 log to a file instead of returning it (requires job when the run has multiple jobs); the tool returns the file path to read.",
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.",
1860
1884
  promptSnippet: "Read GitHub CI logs",
1861
1885
  parameters: Type.Object({
1862
1886
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
@@ -17,6 +17,20 @@ function severityOf(diagnostic: Diagnostic): number {
17
17
  return diagnostic.severity ?? 1;
18
18
  }
19
19
 
20
+ /** 单个文件的诊断报告;计数覆盖该文件全部 ERROR / WARN,不受每文件上限影响。 */
21
+ export interface DiagnosticReport {
22
+ /** 空字符串表示没有 ERROR / WARN。 */
23
+ text: string;
24
+ errorCount: number;
25
+ warningCount: number;
26
+ }
27
+
28
+ export const EMPTY_DIAGNOSTIC_REPORT: DiagnosticReport = {
29
+ text: "",
30
+ errorCount: 0,
31
+ warningCount: 0,
32
+ };
33
+
20
34
  export function prettyDiagnostic(diagnostic: Diagnostic): string {
21
35
  const severity = SEVERITY_LABELS[severityOf(diagnostic)] ?? "ERROR";
22
36
  const line = diagnostic.range.start.line + 1;
@@ -27,25 +41,38 @@ export function prettyDiagnostic(diagnostic: Diagnostic): string {
27
41
  return `${severity} [${line}:${col}] ${message}`;
28
42
  }
29
43
 
30
- /** 返回空字符串表示没有 ERROR / WARN。ERROR 在前,WARN 在后。 */
31
- export function report(file: string, issues: Diagnostic[]): string {
44
+ function pluralize(count: number, noun: string): string {
45
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
46
+ }
47
+
48
+ /** 未列出部分的构成:`3 errors, 4 warnings`(为 0 的一类省略;单复数按数量)。 */
49
+ function hiddenSummary(hidden: Diagnostic[]): string {
50
+ const errors = hidden.filter((item) => severityOf(item) === 1).length;
51
+ const warnings = hidden.length - errors;
52
+ return [
53
+ ...(errors > 0 ? [pluralize(errors, "error")] : []),
54
+ ...(warnings > 0 ? [pluralize(warnings, "warning")] : []),
55
+ ].join(", ");
56
+ }
57
+
58
+ /** 返回空 text 表示没有 ERROR / WARN。ERROR 在前,WARN 在后,超出上限的部分只报数量。 */
59
+ export function report(file: string, issues: Diagnostic[]): DiagnosticReport {
32
60
  const errors = issues.filter((item) => severityOf(item) === 1);
33
61
  const warnings = issues.filter((item) => item.severity === 2);
34
62
  const relevant = [...errors, ...warnings];
35
- if (relevant.length === 0) return "";
63
+ if (relevant.length === 0) return EMPTY_DIAGNOSTIC_REPORT;
36
64
  const limited = relevant.slice(0, MAX_PER_FILE);
37
- const more = relevant.length - MAX_PER_FILE;
38
- const suffix = more > 0 ? `\n... and ${more} more` : "";
39
- return `<diagnostics file="${file}">\n${limited.map((d) => prettyDiagnostic(d)).join("\n")}${suffix}\n</diagnostics>`;
65
+ const hidden = relevant.slice(MAX_PER_FILE);
66
+ const suffix = hidden.length > 0 ? `\n... and ${hiddenSummary(hidden)}` : "";
67
+ return {
68
+ text: `<diagnostics file="${file}">\n${limited.map((d) => prettyDiagnostic(d)).join("\n")}${suffix}\n</diagnostics>`,
69
+ errorCount: errors.length,
70
+ warningCount: warnings.length,
71
+ };
40
72
  }
41
73
 
42
74
  /** 把 LSP 诊断块接到工具成功文案后面;无诊断时原样返回。 */
43
- export function appendLspDiagnosticText(
44
- message: string,
45
- diagnosticText: string,
46
- errorCount: number,
47
- ): string {
48
- if (!diagnosticText) return message;
49
- const kind = errorCount > 0 ? "errors" : "warnings";
50
- return `${message}\n\nLSP ${kind} detected in this file:\n${diagnosticText}`;
75
+ export function appendLspDiagnosticText(message: string, diagnosticText: string): string {
76
+ if (diagnosticText === "") return message;
77
+ return `${message}\n\nLSP diagnostics detected in this file\n${diagnosticText}`;
51
78
  }
@@ -43,7 +43,7 @@ import {
43
43
  WATCH_KIND_CREATE,
44
44
  WATCH_KIND_DELETE,
45
45
  } from "./client.js";
46
- import { report } from "./diagnostic.js";
46
+ import { type DiagnosticReport, EMPTY_DIAGNOSTIC_REPORT, report } from "./diagnostic.js";
47
47
  import {
48
48
  createAdapters,
49
49
  matchesInclude,
@@ -398,12 +398,12 @@ export interface LspService {
398
398
  options?: LspRequestOptions,
399
399
  ): Promise<void>;
400
400
  diagnostics(): Promise<Record<string, Diagnostic[]>>;
401
- /** read / edit / write 用:等待文档诊断并返回该文件的 ERROR / WARN 报告与数量。 */
401
+ /** read / edit / write 用:等待文档诊断并返回该文件的 ERROR / WARN 报告与计数。 */
402
402
  lspDiagnosticsForFile(
403
403
  file: string,
404
404
  cwd: string,
405
405
  options?: LspRequestOptions,
406
- ): Promise<{ text: string; errorCount: number; warningCount: number }>;
406
+ ): Promise<DiagnosticReport>;
407
407
  /**
408
408
  * 符号重命名:只面向 kind 为 "language" 的服务器(linter 不参与符号级
409
409
  * 功能);多 client 按配置顺序取第一个成功结果,全部失败时抛聚合错误。
@@ -929,15 +929,12 @@ export function createLspService(
929
929
  file: string,
930
930
  cwd: string,
931
931
  options?: LspRequestOptions,
932
- ): Promise<{ text: string; errorCount: number; warningCount: number }> {
933
- if (options?.signal?.aborted) return { text: "", errorCount: 0, warningCount: 0 };
932
+ ): Promise<DiagnosticReport> {
933
+ if (options?.signal?.aborted) return EMPTY_DIAGNOSTIC_REPORT;
934
934
  await touchFile(file, cwd, "document", options);
935
935
  const all = await diagnostics();
936
936
  const normalized = normalize(file);
937
- const issues = all[normalized] ?? [];
938
- const errorCount = issues.filter((item) => (item.severity ?? 1) === 1).length;
939
- const warningCount = issues.filter((item) => item.severity === 2).length;
940
- return { text: report(normalized, issues), errorCount, warningCount };
937
+ return report(normalized, all[normalized] ?? []);
941
938
  }
942
939
 
943
940
  /** 符号重命名:只面向 language 类服务器;按配置顺序取第一个成功结果。 */
@@ -432,15 +432,13 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
432
432
  }
433
433
 
434
434
  // opencode: 与 edit / write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
435
- const {
436
- text: diagnosticText,
437
- errorCount,
438
- warningCount,
439
- } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
435
+ const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
436
+ signal,
437
+ });
440
438
  content = [
441
439
  {
442
440
  type: "text",
443
- text: appendLspDiagnosticText(outputText, diagnosticText, errorCount),
441
+ text: appendLspDiagnosticText(outputText, diagnostics.text),
444
442
  },
445
443
  ];
446
444
 
@@ -449,7 +447,12 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
449
447
  details: {
450
448
  ...details,
451
449
  pendant: {
452
- subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
450
+ subtitle: formatSubtitlePath(
451
+ ctx.cwd,
452
+ absolutePath,
453
+ diagnostics.errorCount,
454
+ diagnostics.warningCount,
455
+ ),
453
456
  },
454
457
  },
455
458
  };
@@ -503,8 +506,9 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
503
506
  change: { oldText: oldString, newText: newString, replaceAll },
504
507
  });
505
508
 
506
- const [message, details, diagnosticText, errorCount, warningCount] =
507
- await withFileMutationQueue(absolutePath, async () => {
509
+ const [message, details, diagnostics] = await withFileMutationQueue(
510
+ absolutePath,
511
+ async () => {
508
512
  signal?.throwIfAborted();
509
513
 
510
514
  // opencode: 前置校验,先于空 oldString 分支
@@ -529,17 +533,13 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
529
533
  await mkdir(dirname(absolutePath), { recursive: true });
530
534
  signal?.throwIfAborted();
531
535
  await writeFile(absolutePath, newString, "utf8");
532
- const {
533
- text: diagnosticText,
534
- errorCount,
535
- warningCount,
536
- } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
536
+ const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
537
+ signal,
538
+ });
537
539
  return [
538
540
  "Edit applied successfully.",
539
541
  { diff: "", patch: "", firstChangedLine: 0 },
540
- diagnosticText,
541
- errorCount,
542
- warningCount,
542
+ diagnostics,
543
543
  ] as const;
544
544
  }
545
545
 
@@ -571,27 +571,29 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
571
571
  const diffNew = normalizeToLF(applied.contentNew);
572
572
  const diffResult = generateDiffString(diffOld, diffNew);
573
573
  const patch = generateUnifiedPatch(filePath, diffOld, diffNew);
574
- const {
575
- text: diagnosticText,
576
- errorCount,
577
- warningCount,
578
- } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
574
+ const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
575
+ signal,
576
+ });
579
577
  return [
580
578
  "Edit applied successfully.",
581
579
  { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
582
- diagnosticText,
583
- errorCount,
584
- warningCount,
580
+ diagnostics,
585
581
  ] as const;
586
- });
582
+ },
583
+ );
587
584
 
588
- const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
585
+ const text = appendLspDiagnosticText(message, diagnostics.text);
589
586
  return {
590
587
  content: [{ type: "text" as const, text }],
591
588
  details: {
592
589
  ...details,
593
590
  pendant: {
594
- subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
591
+ subtitle: formatSubtitlePath(
592
+ ctx.cwd,
593
+ absolutePath,
594
+ diagnostics.errorCount,
595
+ diagnostics.warningCount,
596
+ ),
595
597
  },
596
598
  },
597
599
  };
@@ -647,8 +649,9 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
647
649
  });
648
650
  const dir = dirname(absolutePath);
649
651
 
650
- const [message, details, diagnosticText, errorCount, warningCount] =
651
- await withFileMutationQueue(absolutePath, async () => {
652
+ const [message, details, diagnostics] = await withFileMutationQueue(
653
+ absolutePath,
654
+ async () => {
652
655
  signal?.throwIfAborted();
653
656
 
654
657
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
@@ -671,28 +674,26 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
671
674
  await mkdir(dir, { recursive: true });
672
675
  signal?.throwIfAborted();
673
676
  await writeFile(absolutePath, desiredBom + nextText, "utf8");
674
- const {
675
- text: diagnosticText,
676
- errorCount,
677
- warningCount,
678
- } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
677
+ const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
678
+ signal,
679
+ });
679
680
 
680
- return [
681
- "Wrote file successfully.",
682
- {},
683
- diagnosticText,
684
- errorCount,
685
- warningCount,
686
- ] as const;
687
- });
681
+ return ["Wrote file successfully.", {}, diagnostics] as const;
682
+ },
683
+ );
688
684
 
689
- const text = appendLspDiagnosticText(message, diagnosticText, errorCount);
685
+ const text = appendLspDiagnosticText(message, diagnostics.text);
690
686
  return {
691
687
  content: [{ type: "text" as const, text }],
692
688
  details: {
693
689
  ...details,
694
690
  pendant: {
695
- subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount, warningCount),
691
+ subtitle: formatSubtitlePath(
692
+ ctx.cwd,
693
+ absolutePath,
694
+ diagnostics.errorCount,
695
+ diagnostics.warningCount,
696
+ ),
696
697
  },
697
698
  },
698
699
  };