@trim21/personal-pi-extensions 0.0.206 → 0.0.209
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/README.md +27 -21
- package/package.json +1 -1
- package/src/gh-readonly.ts +213 -10
- package/src/lib/cli-args.ts +116 -0
- package/src/talk/core.ts +195 -84
- package/src/talk/format.ts +14 -15
- package/src/talk/group.ts +91 -0
- package/src/talk/index.ts +95 -50
- package/src/talk/mailbox.ts +27 -6
- package/src/talk/policy.ts +1 -1
- package/src/talk/registry.ts +50 -27
- package/src/talk/skills/multi-agent-dev/SKILL.md +28 -26
- package/src/talk/storage.ts +7 -1
package/README.md
CHANGED
|
@@ -222,7 +222,7 @@ session 间消息传递:不同 pi session(同一台机器)通过一个共
|
|
|
222
222
|
|
|
223
223
|
```
|
|
224
224
|
storage.ts —— 存储层:TalkStorage 接口 + SqliteTalkStorage 实现(node:sqlite,零 npm 依赖)
|
|
225
|
-
core.ts —— talk 核心:registry/mailbox/policy/format + TalkCore 协调器,只依赖存储层,通过回调 yield 投递/通知
|
|
225
|
+
core.ts —— talk 核心:registry/mailbox/group/policy/format + TalkCore 协调器,只依赖存储层,通过回调 yield 投递/通知
|
|
226
226
|
index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周期事件 / 工具注册
|
|
227
227
|
```
|
|
228
228
|
|
|
@@ -230,18 +230,18 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
|
|
|
230
230
|
|
|
231
231
|
### 工具(LLM 可见)
|
|
232
232
|
|
|
233
|
-
| 工具 | 作用
|
|
234
|
-
| -------------------- |
|
|
235
|
-
| `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true
|
|
236
|
-
| `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时)
|
|
237
|
-
| `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播)
|
|
238
|
-
| `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断)
|
|
233
|
+
| 工具 | 作用 |
|
|
234
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);只列出同组成员(未入组时只有自己),`status` 区分 live(`idle` / `working` / `waiting-talk-message`)与 `offline` |
|
|
236
|
+
| `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
|
|
237
|
+
| `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播) |
|
|
238
|
+
| `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
|
|
239
239
|
|
|
240
240
|
对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
|
|
241
241
|
|
|
242
242
|
**定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
|
|
243
243
|
|
|
244
|
-
**标记废弃 session**:`/talk-dead` 给 session 打 `offline` 标志并把 `lastSeenAt` 置 0(列表显示为 offline,下次 sweep 无 mail 即回收):无参标记当前 session,`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session
|
|
244
|
+
**标记废弃 session**:`/talk-dead` 给 session 打 `offline` 标志并把 `lastSeenAt` 置 0(列表显示为 offline,下次 sweep 无 mail 即回收):无参标记当前 session,`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session(同组成员)。
|
|
245
245
|
|
|
246
246
|
### 关键设计
|
|
247
247
|
|
|
@@ -251,7 +251,7 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
|
|
|
251
251
|
- **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
|
|
252
252
|
- **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
|
|
253
253
|
- **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递标注来源(来自另一个 pi session,非用户)。
|
|
254
|
-
- **
|
|
254
|
+
- **group 可见性**:可见性完全由 group 决定——组内 session 只能看到同组成员,不在任何 group 的 session 只能看到自己。用 `/talk-group-*` 命令建组/入组,见下方「group 可见性」。
|
|
255
255
|
|
|
256
256
|
### 配置
|
|
257
257
|
|
|
@@ -277,22 +277,28 @@ sqlite 文件路径按优先级取第一个可用值:
|
|
|
277
277
|
|
|
278
278
|
默认 `"queue"`。
|
|
279
279
|
|
|
280
|
-
###
|
|
280
|
+
### group 可见性
|
|
281
281
|
|
|
282
|
-
|
|
282
|
+
可见性完全由 group 决定,不再有路径/workspace 配置:
|
|
283
283
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
- 在某个 group 里的 session **只能看到同组成员**;不在任何 group 的 session **只能看到自己**。
|
|
285
|
+
- group 是带 uuid 的私有房间:任何 session 都可以凭 uuid 加入任意 group,也可以自由离开,没有 owner。
|
|
286
|
+
- 一个 session 只能属于一个 group:加入新 group 自动离开旧 group。
|
|
287
|
+
- group 成员关系存在共享的 talk DB 里,每次 list/发送实时读取,加入/离开立即对所有 session 生效(无需重启)。
|
|
288
|
+
|
|
289
|
+
通过 `/talk-group-*` 命令操作(TUI):
|
|
290
|
+
|
|
291
|
+
```
|
|
292
|
+
/talk-group-join # 无参:自动创建一个新 group(uuid 作为组名)并加入
|
|
293
|
+
/talk-group-join <name> # 加入名为 name 的 group;不存在则创建(名字允许字母/数字/-/_)
|
|
294
|
+
/talk-group-join-last # 加入最近创建的 group(方便新开 session 快速归队)
|
|
295
|
+
/talk-group-leave # 离开当前 group(组空了自动删除)
|
|
296
|
+
/talk-group-list # 列出所有 group 及其成员,最新创建的在前
|
|
297
|
+
/talk-group-del <name> # 删除指定 group(成员随之变为未入组)
|
|
298
|
+
/talk-group-clear # 删除所有 group
|
|
289
299
|
```
|
|
290
300
|
|
|
291
|
-
-
|
|
292
|
-
- 路径支持 `~` 展开,相对路径相对该 workspace 的 cwd 解析
|
|
293
|
-
- 无 `allowed` 字段 → 全部可见;`"allowed": []` → 谁都看不到
|
|
294
|
-
- 可见性单向生效:A 的配置只决定 A 能看到谁,不影响 B
|
|
295
|
-
- 不可见的 session 不仅 list 不到,也无法 `talk-send` / `talk-ask` 寻址(即使知道 id 也会被拒绝)
|
|
301
|
+
典型用法:在 A session 里 `/talk-group-join`(或 `/talk-group-join mytask`)建组,把组名复制到 B、C session 里 `/talk-group-join <组名>`,此后 A/B/C 互相可见且只见彼此。
|
|
296
302
|
|
|
297
303
|
| 变量 | 默认 | 含义 |
|
|
298
304
|
| ----------------- | --------------------------------- | ------------------------------- |
|
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { spawn } from "node:child_process";
|
|
32
32
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
33
33
|
import { homedir } from "node:os";
|
|
34
|
-
import { join } from "node:path";
|
|
34
|
+
import { dirname, join, resolve } from "node:path";
|
|
35
35
|
|
|
36
36
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
37
37
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
@@ -603,12 +603,24 @@ export function cleanStepOutput(stepLog: string): string {
|
|
|
603
603
|
.trim();
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Strip terminal escape sequences and a leading UTF-8 BOM from a raw job log,
|
|
608
|
+
* keeping everything else — timestamps, `##[group]` markers, blank lines —
|
|
609
|
+
* intact. Used when writing a job's complete log to a file: complete, but
|
|
610
|
+
* readable without ANSI garbage.
|
|
611
|
+
*/
|
|
612
|
+
export function stripAnsi(text: string): string {
|
|
613
|
+
return text.replace(/^\uFEFF/, "").replaceAll(ANSI_RE, "");
|
|
614
|
+
}
|
|
615
|
+
|
|
606
616
|
export interface StepLogParams {
|
|
607
617
|
runId: string;
|
|
608
618
|
job?: string;
|
|
609
619
|
step: string;
|
|
610
620
|
offset?: number;
|
|
611
621
|
limit?: number;
|
|
622
|
+
/** Return the complete, untruncated step output (ignores `offset`/`limit`). */
|
|
623
|
+
full?: boolean;
|
|
612
624
|
}
|
|
613
625
|
|
|
614
626
|
/**
|
|
@@ -623,7 +635,7 @@ export async function renderStepLog(
|
|
|
623
635
|
fetchJobLog: (jobId: number) => Promise<string>,
|
|
624
636
|
onUpdate?: (msg: CiLogsResult) => void,
|
|
625
637
|
): Promise<CiLogsResult> {
|
|
626
|
-
const { job, step, offset, limit } = params;
|
|
638
|
+
const { job, step, offset, limit, full } = params;
|
|
627
639
|
|
|
628
640
|
if (!job) {
|
|
629
641
|
return {
|
|
@@ -707,6 +719,26 @@ export async function renderStepLog(
|
|
|
707
719
|
|
|
708
720
|
const clean = cleanStepOutput(stepLog);
|
|
709
721
|
|
|
722
|
+
// `full`: return the complete output, no truncation and no offset.
|
|
723
|
+
if (full) {
|
|
724
|
+
const fullLines = clean.split("\n").length;
|
|
725
|
+
return {
|
|
726
|
+
content: [{ type: "text", text: clean }],
|
|
727
|
+
details: {
|
|
728
|
+
summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: complete output (${fullLines} lines)`,
|
|
729
|
+
truncated: false,
|
|
730
|
+
full: true,
|
|
731
|
+
job: {
|
|
732
|
+
name: targetJob.name,
|
|
733
|
+
conclusion: targetJob.conclusion,
|
|
734
|
+
steps: stepsDetail(targetJob, new Set([stepNum])),
|
|
735
|
+
},
|
|
736
|
+
totalLines: fullLines,
|
|
737
|
+
shownLines: fullLines,
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
710
742
|
// Apply offset on the cleaned text, then truncate.
|
|
711
743
|
const totalLines = clean.split("\n").length;
|
|
712
744
|
let logToShow = clean;
|
|
@@ -757,6 +789,8 @@ export interface JobLogsParams {
|
|
|
757
789
|
job?: string;
|
|
758
790
|
offset?: number;
|
|
759
791
|
limit?: number;
|
|
792
|
+
/** Expand every step's complete output (default: only failed steps, truncated). */
|
|
793
|
+
full?: boolean;
|
|
760
794
|
}
|
|
761
795
|
|
|
762
796
|
export interface JobLogsStep {
|
|
@@ -781,7 +815,7 @@ export async function renderJobLogs(
|
|
|
781
815
|
jobs: CiLogsJob[],
|
|
782
816
|
fetchJobLog: (jobId: number) => Promise<string>,
|
|
783
817
|
): Promise<CiLogsResult> {
|
|
784
|
-
const { job, offset, limit } = params;
|
|
818
|
+
const { job, offset, limit, full } = params;
|
|
785
819
|
|
|
786
820
|
if (!jobs || jobs.length === 0) {
|
|
787
821
|
return {
|
|
@@ -814,7 +848,7 @@ export async function renderJobLogs(
|
|
|
814
848
|
let rawLog: string | null = null;
|
|
815
849
|
|
|
816
850
|
for (const s of j.steps) {
|
|
817
|
-
if (s.conclusion !== "failure") {
|
|
851
|
+
if (!full && s.conclusion !== "failure") {
|
|
818
852
|
steps.push({ name: s.name });
|
|
819
853
|
continue;
|
|
820
854
|
}
|
|
@@ -828,6 +862,13 @@ export async function renderJobLogs(
|
|
|
828
862
|
}
|
|
829
863
|
|
|
830
864
|
const clean = cleanStepOutput(stepLog);
|
|
865
|
+
|
|
866
|
+
// `full`: every step carries its complete, untruncated output.
|
|
867
|
+
if (full) {
|
|
868
|
+
steps.push({ name: s.name, output: clean });
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
831
872
|
const totalLines = clean.split("\n").length;
|
|
832
873
|
|
|
833
874
|
// Apply offset on the cleaned text, then truncate.
|
|
@@ -856,7 +897,7 @@ export async function renderJobLogs(
|
|
|
856
897
|
|
|
857
898
|
const totalJobs = output.length;
|
|
858
899
|
const failedJobs = output.filter((j) => j.steps.some((s) => s.output !== undefined)).length;
|
|
859
|
-
const
|
|
900
|
+
const expandedSteps = output.reduce(
|
|
860
901
|
(acc, j) => acc + j.steps.filter((s) => s.output !== undefined).length,
|
|
861
902
|
0,
|
|
862
903
|
);
|
|
@@ -864,8 +905,11 @@ export async function renderJobLogs(
|
|
|
864
905
|
return {
|
|
865
906
|
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
866
907
|
details: {
|
|
867
|
-
summary:
|
|
908
|
+
summary: full
|
|
909
|
+
? `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${expandedSteps} step output${expandedSteps === 1 ? "" : "s"} expanded (full, untruncated)`
|
|
910
|
+
: `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${expandedSteps} failed step${expandedSteps > 1 ? "s" : ""}`,
|
|
868
911
|
truncated: undefined,
|
|
912
|
+
...(full && { full: true }),
|
|
869
913
|
jobs: targetJobs.map((j) => ({
|
|
870
914
|
name: j.name,
|
|
871
915
|
conclusion: j.conclusion,
|
|
@@ -875,6 +919,142 @@ export async function renderJobLogs(
|
|
|
875
919
|
};
|
|
876
920
|
}
|
|
877
921
|
|
|
922
|
+
// ── writing complete logs to a file ─────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
export interface WriteLogFileParams {
|
|
925
|
+
runId: string;
|
|
926
|
+
job?: string;
|
|
927
|
+
step?: string;
|
|
928
|
+
outputFile: string;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Write the complete log to a file and return metadata (path, line/byte
|
|
933
|
+
* counts) instead of the log content itself. With `step`: the step's cleaned
|
|
934
|
+
* output. Without `step`: the whole job's log, timestamps and `##[group]`
|
|
935
|
+
* markers kept but ANSI escapes stripped. `job` is required when the run has
|
|
936
|
+
* more than one job (a single-job run is used implicitly). Relative
|
|
937
|
+
* `outputFile` paths resolve against `cwd`.
|
|
938
|
+
*/
|
|
939
|
+
export async function writeLogFile(
|
|
940
|
+
params: WriteLogFileParams,
|
|
941
|
+
jobs: CiLogsJob[],
|
|
942
|
+
fetchJobLog: (jobId: number) => Promise<string>,
|
|
943
|
+
cwd: string | undefined,
|
|
944
|
+
input: unknown,
|
|
945
|
+
): Promise<CiLogsResult> {
|
|
946
|
+
const { job, step, outputFile } = params;
|
|
947
|
+
|
|
948
|
+
const isNumeric = /^\d+$/.test(job ?? "");
|
|
949
|
+
const targetJobs = job ? jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job) : jobs;
|
|
950
|
+
if (targetJobs.length === 0) {
|
|
951
|
+
return {
|
|
952
|
+
content: [
|
|
953
|
+
{
|
|
954
|
+
type: "text",
|
|
955
|
+
text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
|
|
956
|
+
},
|
|
957
|
+
],
|
|
958
|
+
details: { input },
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
if (targetJobs.length > 1) {
|
|
962
|
+
return {
|
|
963
|
+
content: [
|
|
964
|
+
{
|
|
965
|
+
type: "text",
|
|
966
|
+
text: `Job "${job}" matches ${targetJobs.length} jobs. Specify a unique job name or id. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
|
|
967
|
+
},
|
|
968
|
+
],
|
|
969
|
+
details: { input },
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
const targetJob = targetJobs[0];
|
|
973
|
+
|
|
974
|
+
if (targetJob.status === "queued") {
|
|
975
|
+
return {
|
|
976
|
+
content: [
|
|
977
|
+
{
|
|
978
|
+
type: "text",
|
|
979
|
+
text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
|
|
980
|
+
},
|
|
981
|
+
],
|
|
982
|
+
details: { input },
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const rawLog = await fetchJobLog(targetJob.id);
|
|
987
|
+
|
|
988
|
+
let content: string;
|
|
989
|
+
let what: string;
|
|
990
|
+
if (step !== undefined && step !== null) {
|
|
991
|
+
const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
|
|
992
|
+
if (!found) {
|
|
993
|
+
return {
|
|
994
|
+
content: [
|
|
995
|
+
{
|
|
996
|
+
type: "text",
|
|
997
|
+
text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
|
|
998
|
+
},
|
|
999
|
+
],
|
|
1000
|
+
details: { input },
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
const stepLog = extractStepFromLog(rawLog, found.number, targetJob.steps);
|
|
1004
|
+
if (stepLog === null) {
|
|
1005
|
+
return {
|
|
1006
|
+
content: [
|
|
1007
|
+
{
|
|
1008
|
+
type: "text",
|
|
1009
|
+
text: `Could not extract step ${found.number} from job "${targetJob.name}" logs. The log may be malformed or empty.`,
|
|
1010
|
+
},
|
|
1011
|
+
],
|
|
1012
|
+
details: { input },
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
content = cleanStepOutput(stepLog);
|
|
1016
|
+
what = `step ${found.number} ("${found.name}") of job "${targetJob.name}"`;
|
|
1017
|
+
} else {
|
|
1018
|
+
content = stripAnsi(rawLog);
|
|
1019
|
+
what = `job "${targetJob.name}" (id: ${targetJob.id})`;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const target = resolve(cwd ?? process.cwd(), outputFile);
|
|
1023
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1024
|
+
await withFileMutationQueue(target, async () => {
|
|
1025
|
+
await writeFile(target, content);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
const lines = content.split("\n").length;
|
|
1029
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
content: [
|
|
1033
|
+
{
|
|
1034
|
+
type: "text",
|
|
1035
|
+
text:
|
|
1036
|
+
`## CI log written to \`${target}\`\n\n` +
|
|
1037
|
+
`- content: ${what}\n` +
|
|
1038
|
+
`- ${lines} lines, ${bytes} bytes\n` +
|
|
1039
|
+
`- run: ${params.runId}\n\n` +
|
|
1040
|
+
`Read it with the \`read\` tool (use \`offset\`/\`limit\` for large files).`,
|
|
1041
|
+
},
|
|
1042
|
+
],
|
|
1043
|
+
details: {
|
|
1044
|
+
outputFile: target,
|
|
1045
|
+
lines,
|
|
1046
|
+
bytes,
|
|
1047
|
+
runId: params.runId,
|
|
1048
|
+
job: {
|
|
1049
|
+
name: targetJob.name,
|
|
1050
|
+
id: targetJob.id,
|
|
1051
|
+
conclusion: targetJob.conclusion,
|
|
1052
|
+
},
|
|
1053
|
+
input,
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
878
1058
|
// ── tools ────────────────────────────────────────────────────────────────────
|
|
879
1059
|
|
|
880
1060
|
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
@@ -1150,7 +1330,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1150
1330
|
name: "read-github-ci-logs",
|
|
1151
1331
|
label: "GitHub CI Logs",
|
|
1152
1332
|
description:
|
|
1153
|
-
"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.",
|
|
1333
|
+
"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.",
|
|
1154
1334
|
promptSnippet: "Read GitHub CI logs",
|
|
1155
1335
|
parameters: Type.Object({
|
|
1156
1336
|
run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
|
|
@@ -1178,9 +1358,21 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1178
1358
|
description: "Maximum number of lines per output text (default 500).",
|
|
1179
1359
|
}),
|
|
1180
1360
|
),
|
|
1361
|
+
full: Type.Optional(
|
|
1362
|
+
Type.Boolean({
|
|
1363
|
+
description:
|
|
1364
|
+
"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.",
|
|
1365
|
+
}),
|
|
1366
|
+
),
|
|
1367
|
+
output_file: Type.Optional(
|
|
1368
|
+
Type.String({
|
|
1369
|
+
description:
|
|
1370
|
+
"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.",
|
|
1371
|
+
}),
|
|
1372
|
+
),
|
|
1181
1373
|
}),
|
|
1182
1374
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1183
|
-
const { run_id, repo, job, step, offset, limit } = params;
|
|
1375
|
+
const { run_id, repo, job, step, offset, limit, full, output_file } = params;
|
|
1184
1376
|
|
|
1185
1377
|
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1186
1378
|
const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
|
|
@@ -1193,6 +1385,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1193
1385
|
const fetchJobLog = (jobId: number): Promise<string> =>
|
|
1194
1386
|
getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
|
|
1195
1387
|
|
|
1388
|
+
// ── Write the complete log to a file ───────────────────────────────
|
|
1389
|
+
if (output_file !== undefined && output_file !== null && output_file !== "") {
|
|
1390
|
+
return writeLogFile(
|
|
1391
|
+
{ runId: String(run_id), job, step, outputFile: output_file },
|
|
1392
|
+
jobs,
|
|
1393
|
+
fetchJobLog,
|
|
1394
|
+
ctx.cwd,
|
|
1395
|
+
params,
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1196
1399
|
// ── Fetch a specific step's logs (requires `job`) ─────────────────
|
|
1197
1400
|
if (step !== undefined && step !== null) {
|
|
1198
1401
|
onUpdate?.({
|
|
@@ -1200,7 +1403,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1200
1403
|
details: {},
|
|
1201
1404
|
});
|
|
1202
1405
|
const stepResult = await renderStepLog(
|
|
1203
|
-
{ runId: String(run_id), job, step, offset, limit },
|
|
1406
|
+
{ runId: String(run_id), job, step, offset, limit, full },
|
|
1204
1407
|
jobs,
|
|
1205
1408
|
fetchJobLog,
|
|
1206
1409
|
onUpdate,
|
|
@@ -1214,7 +1417,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1214
1417
|
details: {},
|
|
1215
1418
|
});
|
|
1216
1419
|
const jobsResult = await renderJobLogs(
|
|
1217
|
-
{ runId: String(run_id), job, offset, limit },
|
|
1420
|
+
{ runId: String(run_id), job, offset, limit, full },
|
|
1218
1421
|
jobs,
|
|
1219
1422
|
fetchJobLog,
|
|
1220
1423
|
);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal command-line argument parsing for `/command` handlers.
|
|
3
|
+
*
|
|
4
|
+
* The pi command API passes handlers a raw string (current versions) or a
|
|
5
|
+
* token array (newer ones); this helper accepts either and yields
|
|
6
|
+
* positionals plus flags, so handlers never care about the input shape.
|
|
7
|
+
*
|
|
8
|
+
* A raw string is split with shell-like rules first: whitespace separates
|
|
9
|
+
* tokens, single quotes preserve everything literally, double quotes allow
|
|
10
|
+
* `\"` / `\\` escapes, and a backslash outside quotes escapes the next
|
|
11
|
+
* character. Unlike bash, empty tokens are dropped and an unterminated
|
|
12
|
+
* quote raises a SyntaxError.
|
|
13
|
+
*
|
|
14
|
+
* Flags (only long form, `--name`):
|
|
15
|
+
* --name value flag "name" = "value" (value may not start with `--`)
|
|
16
|
+
* --name=value same
|
|
17
|
+
* --flag boolean flag = true
|
|
18
|
+
* -- everything after is a positional
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ParsedArgs {
|
|
22
|
+
/** Non-flag arguments, in order. */
|
|
23
|
+
positionals: string[];
|
|
24
|
+
/** `--name value` / `--name=value` → string; `--flag` → true. */
|
|
25
|
+
flags: Record<string, string | boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Does this token look like a long flag (`--x`, but not the bare `--`)? */
|
|
29
|
+
function isFlagToken(token: string): boolean {
|
|
30
|
+
return token.startsWith("--") && token !== "--";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shell-like tokenizer for a raw command line. Empty tokens are dropped. */
|
|
34
|
+
export function shlexSplit(raw: string): string[] {
|
|
35
|
+
const tokens: string[] = [];
|
|
36
|
+
let cur = "";
|
|
37
|
+
let quote: "'" | '"' | undefined;
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < raw.length) {
|
|
40
|
+
const c = raw[i];
|
|
41
|
+
if (quote) {
|
|
42
|
+
if (c === quote) {
|
|
43
|
+
quote = undefined;
|
|
44
|
+
} else if (c === "\\" && quote === '"' && (raw[i + 1] === '"' || raw[i + 1] === "\\")) {
|
|
45
|
+
cur += raw[i + 1];
|
|
46
|
+
i++;
|
|
47
|
+
} else {
|
|
48
|
+
cur += c;
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === "'" || c === '"') {
|
|
54
|
+
quote = c;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === "\\") {
|
|
59
|
+
if (i + 1 < raw.length) cur += raw[i + 1];
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (/\s/.test(c)) {
|
|
64
|
+
if (cur) {
|
|
65
|
+
tokens.push(cur);
|
|
66
|
+
cur = "";
|
|
67
|
+
}
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
cur += c;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
if (quote !== undefined) {
|
|
75
|
+
throw new SyntaxError(`unterminated quote in command arguments: ${raw}`);
|
|
76
|
+
}
|
|
77
|
+
if (cur) tokens.push(cur);
|
|
78
|
+
return tokens;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseArgs(input: string | string[]): ParsedArgs {
|
|
82
|
+
const tokens = Array.isArray(input) ? input : shlexSplit(input);
|
|
83
|
+
const positionals: string[] = [];
|
|
84
|
+
const flags: Record<string, string | boolean> = {};
|
|
85
|
+
let positionalOnly = false;
|
|
86
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
87
|
+
const token = tokens[i];
|
|
88
|
+
if (positionalOnly) {
|
|
89
|
+
positionals.push(token);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (token === "--") {
|
|
93
|
+
positionalOnly = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const m = /^--([a-zA-Z0-9][a-zA-Z0-9-]*)(?:=(.*))?$/.exec(token);
|
|
97
|
+
if (!m) {
|
|
98
|
+
positionals.push(token);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const name = m[1];
|
|
102
|
+
if (m[2] !== undefined) {
|
|
103
|
+
flags[name] = m[2];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// `--name value` form: consume the next token unless it is itself a flag.
|
|
107
|
+
const next = tokens[i + 1];
|
|
108
|
+
if (next !== undefined && !isFlagToken(next)) {
|
|
109
|
+
flags[name] = next;
|
|
110
|
+
i++;
|
|
111
|
+
} else {
|
|
112
|
+
flags[name] = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { positionals, flags };
|
|
116
|
+
}
|