@trim21/personal-pi-extensions 0.0.205 → 0.0.208
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 +28 -22
- package/package.json +1 -1
- package/src/gh-readonly.ts +213 -10
- package/src/talk/core.ts +158 -49
- package/src/talk/format.ts +1 -1
- package/src/talk/group.ts +94 -0
- package/src/talk/index.ts +60 -24
- package/src/talk/skills/multi-agent-dev/SKILL.md +12 -11
- package/src/talk/storage.ts +6 -0
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
|
|
|
@@ -250,8 +250,8 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
|
|
|
250
250
|
- **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `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
|
-
- **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50
|
|
254
|
-
- **
|
|
253
|
+
- **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递标注来源(来自另一个 pi session,非用户)。
|
|
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
|
);
|
package/src/talk/core.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Talk core: coordinates the registry, mailbox, and policy over a
|
|
3
|
-
* backend, and yields deliveries and notifications to an adapter
|
|
4
|
-
* events. Pi-free — the pi adapter (index.ts) owns the pi API surface.
|
|
2
|
+
* Talk core: coordinates the registry, mailbox, groups, and policy over a
|
|
3
|
+
* storage backend, and yields deliveries and notifications to an adapter
|
|
4
|
+
* through events. Pi-free — the pi adapter (index.ts) owns the pi API surface.
|
|
5
|
+
*
|
|
6
|
+
* Visibility model: groups are the only visibility boundary. A session in a
|
|
7
|
+
* group sees only its co-members; a session in no group sees only itself.
|
|
8
|
+
* Membership is read live from storage on every operation, so joining or
|
|
9
|
+
* leaving a group takes effect immediately for every session.
|
|
5
10
|
*
|
|
6
11
|
* Delivery model: a letter is removed from the inbox only AFTER the adapter
|
|
7
12
|
* reports it was handed to the session (`events.deliver` returns true). A
|
|
@@ -9,10 +14,16 @@
|
|
|
9
14
|
* poll — so a swallowed sendMessage error no longer destroys the letter.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
import { age, formatListing, refusalUnknown, shortAddr } from "./format.js";
|
|
18
|
+
import {
|
|
19
|
+
deleteGroup,
|
|
20
|
+
groupForSession,
|
|
21
|
+
isValidGroupName,
|
|
22
|
+
listGroups,
|
|
23
|
+
newGroupId,
|
|
24
|
+
readGroup,
|
|
25
|
+
writeGroup,
|
|
26
|
+
} from "./group.js";
|
|
16
27
|
import {
|
|
17
28
|
appendAudit,
|
|
18
29
|
awaitReceipt,
|
|
@@ -69,32 +80,6 @@ const DELIVERY_BACKOFF_MS = 5000;
|
|
|
69
80
|
const INITIAL_DRAIN_DELAY_MS = 1200;
|
|
70
81
|
const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
|
|
71
82
|
|
|
72
|
-
/** Normalize an allowed path: expand ~, resolve relative against baseCwd, strip trailing slashes. */
|
|
73
|
-
function normalizeAllowedPath(p: string, baseCwd: string): string {
|
|
74
|
-
const expanded = expandHome(p);
|
|
75
|
-
const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(baseCwd, expanded);
|
|
76
|
-
return abs.replace(/[\\/]+$/, "") || "/";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Build the workspace-visibility gate for one session. `allowed` is the
|
|
81
|
-
* `allowed` array from `<cwd>/.pi/talk.json` (undefined when the file or key
|
|
82
|
-
* is absent). A peer session is visible when its cwd equals an allowed prefix
|
|
83
|
-
* or sits below it (`prefix` or `prefix/*`); `company1` never matches
|
|
84
|
-
* `company12`. An undefined list shows everything; an explicit empty list
|
|
85
|
-
* shows nothing.
|
|
86
|
-
*/
|
|
87
|
-
export function buildVisibilityFilter(
|
|
88
|
-
allowed: string[] | undefined,
|
|
89
|
-
baseCwd: string,
|
|
90
|
-
): (peerCwd: string) => boolean {
|
|
91
|
-
if (allowed === undefined) return () => true;
|
|
92
|
-
if (allowed.length === 0) return () => false;
|
|
93
|
-
const prefixes = allowed.map((p) => normalizeAllowedPath(p, baseCwd));
|
|
94
|
-
return (peerCwd) =>
|
|
95
|
-
prefixes.some((prefix) => peerCwd === prefix || peerCwd.startsWith(`${prefix}/`));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
83
|
/**
|
|
99
84
|
* Mutual-ask arbitration: true when the peer asked first. The `ts` fields of
|
|
100
85
|
* the two ask letters are fixed values inside the letters, so both sides
|
|
@@ -121,8 +106,6 @@ export class TalkCore {
|
|
|
121
106
|
private readonly watched = new Map<string, Presence>();
|
|
122
107
|
/** Message ids already handed to the adapter but not yet removed from the inbox. */
|
|
123
108
|
private readonly deliveredIds = new Set<string>();
|
|
124
|
-
/** Visibility gate over peer working directories; defaults to everything visible. */
|
|
125
|
-
private isPeerVisible: (peerCwd: string) => boolean = () => true;
|
|
126
109
|
/** Manually marked dead: offline flag set, lastSeenAt pinned to 0. */
|
|
127
110
|
private dead = false;
|
|
128
111
|
|
|
@@ -141,11 +124,6 @@ export class TalkCore {
|
|
|
141
124
|
return this.self?.addr;
|
|
142
125
|
}
|
|
143
126
|
|
|
144
|
-
/** Replace the peer-visibility gate (from the session's `.pi/talk.json`). */
|
|
145
|
-
setPeerVisibility(filter: (peerCwd: string) => boolean): void {
|
|
146
|
-
this.isPeerVisible = filter;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
127
|
private requireSelf(): SessionRecord {
|
|
150
128
|
const self = this.self;
|
|
151
129
|
if (!self) throw new Error("Talk core is not started");
|
|
@@ -319,14 +297,25 @@ export class TalkCore {
|
|
|
319
297
|
// ── Outbound ───────────────────────────────────────────────────────────
|
|
320
298
|
|
|
321
299
|
/**
|
|
322
|
-
*
|
|
323
|
-
* from
|
|
324
|
-
*
|
|
300
|
+
* Session ids of the caller's group members, or null when the caller is in
|
|
301
|
+
* no group. Visibility is read live from storage on every operation, so a
|
|
302
|
+
* group change takes effect immediately for every session.
|
|
303
|
+
*/
|
|
304
|
+
private async myGroupMemberIds(): Promise<Set<string> | null> {
|
|
305
|
+
const self = this.requireSelf();
|
|
306
|
+
const group = await groupForSession(this.storage, self.sessionId);
|
|
307
|
+
return group ? new Set(group.members) : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve a target by its exact session id (uuid). Only co-members of the
|
|
312
|
+
* caller's group are reachable; a session in no group sees no peers at all.
|
|
325
313
|
*/
|
|
326
314
|
private async resolveTarget(to: string): Promise<TargetResult> {
|
|
327
315
|
const self = this.requireSelf();
|
|
328
316
|
const records = await listRecords(this.storage);
|
|
329
|
-
const
|
|
317
|
+
const memberIds = await this.myGroupMemberIds();
|
|
318
|
+
const others = records.filter((r) => r.addr !== self.addr && memberIds?.has(r.sessionId));
|
|
330
319
|
const target = others.find((r) => r.sessionId === to);
|
|
331
320
|
if (!target) return { ok: false, error: refusalUnknown(to) };
|
|
332
321
|
return { ok: true, record: target };
|
|
@@ -445,15 +434,17 @@ export class TalkCore {
|
|
|
445
434
|
|
|
446
435
|
/**
|
|
447
436
|
* JSON listing of visible sessions, including self (marked `self: true`).
|
|
448
|
-
*
|
|
449
|
-
*
|
|
437
|
+
* Grouped sessions see only their co-members; a session in no group sees
|
|
438
|
+
* only itself. Every visible record is listed, live or offline; presence
|
|
439
|
+
* decides the per-session status.
|
|
450
440
|
*/
|
|
451
441
|
async list(): Promise<string> {
|
|
452
442
|
const self = this.requireSelf();
|
|
453
443
|
const all = await listRecords(this.storage);
|
|
444
|
+
const memberIds = await this.myGroupMemberIds();
|
|
454
445
|
const records = all.filter((r) => {
|
|
455
446
|
if (r.addr === self.addr) return !this.dead;
|
|
456
|
-
return
|
|
447
|
+
return memberIds?.has(r.sessionId) ?? false;
|
|
457
448
|
});
|
|
458
449
|
return formatListing(records, self.addr, presenceOf);
|
|
459
450
|
}
|
|
@@ -462,10 +453,11 @@ export class TalkCore {
|
|
|
462
453
|
async listCwd(cwd: string): Promise<string> {
|
|
463
454
|
const self = this.requireSelf();
|
|
464
455
|
const records = await listRecords(this.storage);
|
|
456
|
+
const memberIds = await this.myGroupMemberIds();
|
|
465
457
|
const filtered = records.filter((r) => {
|
|
466
458
|
if (r.cwd !== cwd) return false;
|
|
467
459
|
if (r.addr === self.addr) return !this.dead;
|
|
468
|
-
return
|
|
460
|
+
return memberIds?.has(r.sessionId) ?? false;
|
|
469
461
|
});
|
|
470
462
|
return formatListing(filtered, self.addr, presenceOf);
|
|
471
463
|
}
|
|
@@ -474,7 +466,8 @@ export class TalkCore {
|
|
|
474
466
|
async listPeers(): Promise<SessionRecord[]> {
|
|
475
467
|
const self = this.requireSelf();
|
|
476
468
|
const records = await listRecords(this.storage);
|
|
477
|
-
|
|
469
|
+
const memberIds = await this.myGroupMemberIds();
|
|
470
|
+
return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.sessionId) ?? false));
|
|
478
471
|
}
|
|
479
472
|
|
|
480
473
|
/**
|
|
@@ -504,6 +497,122 @@ export class TalkCore {
|
|
|
504
497
|
return `Marked ${peers.length} session(s) as dead.`;
|
|
505
498
|
}
|
|
506
499
|
|
|
500
|
+
// ── Groups ─────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
/** Remove the caller from its current group, deleting the group when it empties. */
|
|
503
|
+
private async leaveCurrentGroup(): Promise<boolean> {
|
|
504
|
+
const self = this.requireSelf();
|
|
505
|
+
const group = await groupForSession(this.storage, self.sessionId);
|
|
506
|
+
if (!group) return false;
|
|
507
|
+
const others = group.members.filter((m) => m !== self.sessionId);
|
|
508
|
+
if (others.length === 0) {
|
|
509
|
+
await deleteGroup(this.storage, group.id);
|
|
510
|
+
} else {
|
|
511
|
+
await writeGroup(this.storage, { ...group, members: others, updatedAt: this.now() });
|
|
512
|
+
}
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Join or create a group and join it. With no name a fresh uuid is
|
|
518
|
+
* generated; with a name, the group is joined when it exists and created
|
|
519
|
+
* otherwise. Leaving any current group first keeps the single-group
|
|
520
|
+
* invariant.
|
|
521
|
+
*/
|
|
522
|
+
async groupJoin(groupName?: string): Promise<string> {
|
|
523
|
+
const self = this.requireSelf();
|
|
524
|
+
const name =
|
|
525
|
+
groupName === undefined || groupName.trim() === "" ? newGroupId() : groupName.trim();
|
|
526
|
+
if (!isValidGroupName(name)) {
|
|
527
|
+
return `Invalid group name '${name}'. Allowed: letters, digits, '-' and '_' (max 64 chars).`;
|
|
528
|
+
}
|
|
529
|
+
const existing = await readGroup(this.storage, name);
|
|
530
|
+
if (existing?.members.includes(self.sessionId)) {
|
|
531
|
+
return `Already in group ${name} (${existing.members.length} member(s)).`;
|
|
532
|
+
}
|
|
533
|
+
await this.leaveCurrentGroup();
|
|
534
|
+
if (existing) {
|
|
535
|
+
await writeGroup(this.storage, {
|
|
536
|
+
...existing,
|
|
537
|
+
members: [...existing.members, self.sessionId],
|
|
538
|
+
updatedAt: this.now(),
|
|
539
|
+
});
|
|
540
|
+
return `Joined group ${name} (${existing.members.length + 1} member(s)). You now see only co-members.`;
|
|
541
|
+
}
|
|
542
|
+
const now = this.now();
|
|
543
|
+
await writeGroup(this.storage, {
|
|
544
|
+
id: name,
|
|
545
|
+
members: [self.sessionId],
|
|
546
|
+
createdAt: now,
|
|
547
|
+
updatedAt: now,
|
|
548
|
+
});
|
|
549
|
+
return `Created group ${name}. Other sessions join it with /talk-group-join ${name}.`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Join the most recently created group; no-op when already in it. */
|
|
553
|
+
async groupJoinLast(): Promise<string> {
|
|
554
|
+
const groups = await listGroups(this.storage);
|
|
555
|
+
if (groups.length === 0) return "No groups. Create one with /talk-group-join.";
|
|
556
|
+
const latest = groups.reduce((a, b) => (b.createdAt > a.createdAt ? b : a));
|
|
557
|
+
return this.groupJoin(latest.id);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Leave the current group; an emptied group is deleted. */
|
|
561
|
+
async groupLeave(): Promise<string> {
|
|
562
|
+
const self = this.requireSelf();
|
|
563
|
+
const group = await groupForSession(this.storage, self.sessionId);
|
|
564
|
+
if (!group) return "Not in any group.";
|
|
565
|
+
const others = group.members.filter((m) => m !== self.sessionId);
|
|
566
|
+
if (others.length === 0) {
|
|
567
|
+
await deleteGroup(this.storage, group.id);
|
|
568
|
+
return `Left group ${group.id} (deleted — it was empty).`;
|
|
569
|
+
}
|
|
570
|
+
await writeGroup(this.storage, { ...group, members: others, updatedAt: this.now() });
|
|
571
|
+
return `Left group ${group.id} (${others.length} member(s) remain).`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Delete a group by name; its members become ungrouped. */
|
|
575
|
+
async groupDelete(groupName: string): Promise<string> {
|
|
576
|
+
const name = groupName.trim();
|
|
577
|
+
if (!isValidGroupName(name)) return `Invalid group name '${name}'.`;
|
|
578
|
+
const group = await readGroup(this.storage, name);
|
|
579
|
+
if (!group) return `Unknown group '${name}'. Run /talk-group-list to see groups.`;
|
|
580
|
+
await deleteGroup(this.storage, name);
|
|
581
|
+
return `Deleted group ${name} (${group.members.length} member(s)).`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Delete every group; all sessions become ungrouped. */
|
|
585
|
+
async groupClear(): Promise<string> {
|
|
586
|
+
const groups = await listGroups(this.storage);
|
|
587
|
+
for (const group of groups) await deleteGroup(this.storage, group.id);
|
|
588
|
+
return groups.length === 0 ? "No groups." : `Deleted ${groups.length} group(s).`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Human-readable listing of every group and its members (management view).
|
|
593
|
+
* Newest first — the oldest group is listed last.
|
|
594
|
+
*/
|
|
595
|
+
async groupList(): Promise<string> {
|
|
596
|
+
const self = this.requireSelf();
|
|
597
|
+
const groups = await listGroups(this.storage);
|
|
598
|
+
if (groups.length === 0) return "No groups.";
|
|
599
|
+
const records = await listRecords(this.storage);
|
|
600
|
+
const label = (sessionId: string): string => {
|
|
601
|
+
const rec = records.find((r) => r.sessionId === sessionId);
|
|
602
|
+
const id = sessionId.length > 8 ? `${sessionId.slice(0, 8)}…` : sessionId;
|
|
603
|
+
return rec ? `${rec.name} (${id})` : `unknown session (${id})`;
|
|
604
|
+
};
|
|
605
|
+
const lines = groups
|
|
606
|
+
.toSorted((a, b) => b.createdAt - a.createdAt)
|
|
607
|
+
.map((g) => {
|
|
608
|
+
const members = g.members.map((m) =>
|
|
609
|
+
m === self.sessionId ? `${label(m)} ← you` : label(m),
|
|
610
|
+
);
|
|
611
|
+
return `- ${g.id} (created ${age(g.createdAt)}): ${members.join(", ")}`;
|
|
612
|
+
});
|
|
613
|
+
return `Groups (${groups.length}):\n${lines.join("\n")}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
507
616
|
async send(to: string, body: string): Promise<string> {
|
|
508
617
|
if (!to) return 'send requires "to".';
|
|
509
618
|
if (!body) return 'send requires "message".';
|
package/src/talk/format.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Letter } from "./mailbox.js";
|
|
|
7
7
|
import type { Presence, SessionRecord } from "./registry.js";
|
|
8
8
|
|
|
9
9
|
export const BOUNDARY_PREAMBLE =
|
|
10
|
-
"This came from another pi session, not from the user.
|
|
10
|
+
"This came from another pi session (a peer agent), not from the user.";
|
|
11
11
|
|
|
12
12
|
export function shortAddr(addr: string): string {
|
|
13
13
|
return addr.slice(0, 6);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session groups for the talk mailbox: an explicit, uuid-addressed set of
|
|
3
|
+
* sessions that see only each other. Core layer — depends only on
|
|
4
|
+
* TalkStorage, never on pi.
|
|
5
|
+
*
|
|
6
|
+
* Rules:
|
|
7
|
+
* - A session belongs to at most one group (single-group invariant).
|
|
8
|
+
* - Groups are public: any session can join any group by its uuid, and a
|
|
9
|
+
* member can leave freely. There is no owner.
|
|
10
|
+
* - Visibility is fully group-driven: a grouped session sees only its
|
|
11
|
+
* co-members; a session in no group sees only itself.
|
|
12
|
+
* - A group that empties is deleted; a one-member group is a normal state
|
|
13
|
+
* (a creator waiting for peers to join).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
import { type Static, Type } from "typebox";
|
|
19
|
+
import { Value } from "typebox/value";
|
|
20
|
+
|
|
21
|
+
import type { TalkStorage } from "./storage.js";
|
|
22
|
+
|
|
23
|
+
export const GroupSchema = Type.Object({
|
|
24
|
+
id: Type.String(),
|
|
25
|
+
/** pi session uuids of the members. */
|
|
26
|
+
members: Type.Array(Type.String()),
|
|
27
|
+
createdAt: Type.Number(),
|
|
28
|
+
updatedAt: Type.Number(),
|
|
29
|
+
});
|
|
30
|
+
export type Group = Static<typeof GroupSchema>;
|
|
31
|
+
|
|
32
|
+
export const GROUPS_NS = "groups";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Group names are user-facing and become storage keys, so they are
|
|
36
|
+
* constrained: start with a letter/digit, then letters, digits, '-' or '_'.
|
|
37
|
+
* A generated uuid fits this pattern too.
|
|
38
|
+
*/
|
|
39
|
+
const GROUP_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
40
|
+
|
|
41
|
+
export function assertGroupId(id: string): void {
|
|
42
|
+
if (!GROUP_ID_PATTERN.test(id)) throw new TypeError(`Invalid group name: ${id}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isValidGroupName(id: string): boolean {
|
|
46
|
+
return GROUP_ID_PATTERN.test(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function newGroupId(): string {
|
|
50
|
+
return randomUUID();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function groupKey(id: string): string {
|
|
54
|
+
assertGroupId(id);
|
|
55
|
+
return `${id}.json`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** All groups, oldest first. Never mutates anything. */
|
|
59
|
+
export async function listGroups(storage: TalkStorage): Promise<Group[]> {
|
|
60
|
+
const out: Group[] = [];
|
|
61
|
+
for (const key of await storage.listKeys(GROUPS_NS)) {
|
|
62
|
+
const raw = await storage.readJson(GROUPS_NS, key);
|
|
63
|
+
if (Value.Check(GroupSchema, raw)) out.push(raw);
|
|
64
|
+
}
|
|
65
|
+
return out.toSorted((a, b) => a.createdAt - b.createdAt);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function readGroup(storage: TalkStorage, id: string): Promise<Group | null> {
|
|
69
|
+
const raw = await storage.readJson(GROUPS_NS, groupKey(id));
|
|
70
|
+
return Value.Check(GroupSchema, raw) ? raw : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeGroup(storage: TalkStorage, group: Group): Promise<void> {
|
|
74
|
+
await storage.writeJson(GROUPS_NS, groupKey(group.id), group);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deleteGroup(storage: TalkStorage, id: string): Promise<boolean> {
|
|
78
|
+
return storage.removeKey(GROUPS_NS, groupKey(id));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The group a session currently belongs to. Under the single-group invariant
|
|
83
|
+
* this is at most one; corrupted data that lists the session in several
|
|
84
|
+
* groups resolves to the first match.
|
|
85
|
+
*/
|
|
86
|
+
export async function groupForSession(
|
|
87
|
+
storage: TalkStorage,
|
|
88
|
+
sessionId: string,
|
|
89
|
+
): Promise<Group | null> {
|
|
90
|
+
for (const group of await listGroups(storage)) {
|
|
91
|
+
if (group.members.includes(sessionId)) return group;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
package/src/talk/index.ts
CHANGED
|
@@ -18,9 +18,8 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
18
18
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
19
19
|
import { Type } from "typebox";
|
|
20
20
|
|
|
21
|
-
import { jsoncToJson } from "../lib/jsonc.js";
|
|
22
21
|
import { resolveHomePath } from "../lib/path.js";
|
|
23
|
-
import {
|
|
22
|
+
import { TalkCore } from "./core.js";
|
|
24
23
|
import { formatDelivery } from "./format.js";
|
|
25
24
|
import type { Letter } from "./mailbox.js";
|
|
26
25
|
import { deriveAddr, type SessionRecord } from "./registry.js";
|
|
@@ -103,27 +102,6 @@ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
|
|
|
103
102
|
}
|
|
104
103
|
}
|
|
105
104
|
|
|
106
|
-
/**
|
|
107
|
-
* Read the workspace visibility config from `<cwd>/.pi/talk.json`:
|
|
108
|
-
* `{ "allowed": ["~/projects/company1/"] }`. Missing file/key → undefined
|
|
109
|
-
* (everything visible); an explicit `"allowed": []` hides every peer.
|
|
110
|
-
*/
|
|
111
|
-
function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
|
|
112
|
-
const configPath = path.join(cwd, ".pi", "talk.json");
|
|
113
|
-
try {
|
|
114
|
-
const raw = fs.readFileSync(configPath, "utf8");
|
|
115
|
-
const parsed = JSON.parse(jsoncToJson(raw)) as { allowed?: unknown };
|
|
116
|
-
if (Array.isArray(parsed.allowed)) {
|
|
117
|
-
return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
|
|
118
|
-
}
|
|
119
|
-
return {};
|
|
120
|
-
} catch (error) {
|
|
121
|
-
// eslint-disable-next-line no-console -- config errors must be visible, not silent
|
|
122
|
-
console.error(`Warning: could not parse ${configPath}: ${String(error)}`);
|
|
123
|
-
return {};
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
105
|
export default function talk(pi: ExtensionAPI) {
|
|
128
106
|
const settings = readTalkSettings();
|
|
129
107
|
const configured = process.env.PI_TALK_DB ?? settings.dbPath;
|
|
@@ -197,7 +175,6 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
197
175
|
lastSeenAt: now,
|
|
198
176
|
status: "idle",
|
|
199
177
|
};
|
|
200
|
-
core.setPeerVisibility(buildVisibilityFilter(readWorkspaceTalkConfig(cwd).allowed, cwd));
|
|
201
178
|
void core.start(self);
|
|
202
179
|
});
|
|
203
180
|
|
|
@@ -324,6 +301,65 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
324
301
|
},
|
|
325
302
|
});
|
|
326
303
|
|
|
304
|
+
pi.registerCommand("talk-group-join", {
|
|
305
|
+
description:
|
|
306
|
+
"Join or create a private session group (members see only each other; a session in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist",
|
|
307
|
+
async handler(args) {
|
|
308
|
+
const initError = requireInit();
|
|
309
|
+
const token = args.trim();
|
|
310
|
+
const text = initError ?? (await core.groupJoin(token || undefined));
|
|
311
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
pi.registerCommand("talk-group-join-last", {
|
|
316
|
+
description: "Join the most recently created session group (no-op when already in it).",
|
|
317
|
+
async handler() {
|
|
318
|
+
const initError = requireInit();
|
|
319
|
+
const text = initError ?? (await core.groupJoinLast());
|
|
320
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
pi.registerCommand("talk-group-leave", {
|
|
325
|
+
description: "Leave the current session group (an emptied group is deleted).",
|
|
326
|
+
async handler() {
|
|
327
|
+
const initError = requireInit();
|
|
328
|
+
const text = initError ?? (await core.groupLeave());
|
|
329
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
pi.registerCommand("talk-group-list", {
|
|
334
|
+
description: "List all session groups and their members, newest first.",
|
|
335
|
+
async handler() {
|
|
336
|
+
const initError = requireInit();
|
|
337
|
+
const text = initError ?? (await core.groupList());
|
|
338
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
pi.registerCommand("talk-group-del", {
|
|
343
|
+
description:
|
|
344
|
+
"Delete a session group by name; its members become ungrouped (see only themselves).",
|
|
345
|
+
async handler(args) {
|
|
346
|
+
const initError = requireInit();
|
|
347
|
+
const name = args.trim();
|
|
348
|
+
const text =
|
|
349
|
+
initError ?? (name ? await core.groupDelete(name) : "Usage: /talk-group-del <group name>");
|
|
350
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
pi.registerCommand("talk-group-clear", {
|
|
355
|
+
description: "Delete every session group; all sessions become ungrouped.",
|
|
356
|
+
async handler() {
|
|
357
|
+
const initError = requireInit();
|
|
358
|
+
const text = initError ?? (await core.groupClear());
|
|
359
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
327
363
|
// ── Delivery card ──────────────────────────────────────────────────────
|
|
328
364
|
|
|
329
365
|
pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
|
|
@@ -40,25 +40,26 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
|
|
|
40
40
|
|
|
41
41
|
### Visibility
|
|
42
42
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
43
|
+
- Visibility is fully group-driven: a session in a group sees only its co-members; a session in no group sees only itself. Ungrouped sessions are invisible to everyone.
|
|
44
|
+
- Groups are managed by the user from the TUI (`/talk-group-*` commands) — you cannot create, join, or leave a group yourself.
|
|
45
|
+
- If a session you need to collaborate with is missing from `talk-list-sessions`, ask the user to pair the sessions into the same group.
|
|
45
46
|
|
|
46
47
|
## Tools
|
|
47
48
|
|
|
48
|
-
| Tool | Purpose
|
|
49
|
-
| -------------------- |
|
|
50
|
-
| `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`)
|
|
51
|
-
| `talk-send` | Send a plain message to a single session id (async — the main collaboration primitive)
|
|
52
|
-
| `talk-ask` | Ask a question and block for the reply (default 30 min timeout)
|
|
53
|
-
| `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message
|
|
49
|
+
| Tool | Purpose |
|
|
50
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`); only group co-members (or only yourself when ungrouped) |
|
|
52
|
+
| `talk-send` | Send a plain message to a single session id (async — the main collaboration primitive) |
|
|
53
|
+
| `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
|
|
54
|
+
| `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message |
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
Pairing into groups is a user action (`/talk-group-*` in the TUI); you only observe its effect through `talk-list-sessions`.
|
|
56
57
|
|
|
57
58
|
## Collaboration workflows
|
|
58
59
|
|
|
59
60
|
### Split work between sessions
|
|
60
61
|
|
|
61
|
-
1. `talk-list-sessions` first: see which
|
|
62
|
+
1. `talk-list-sessions` first: see which co-members exist, their `work_dir`, and status. If only yourself shows up, the peer sessions are not in your group yet — ask the user to pair them.
|
|
62
63
|
2. Assign work by module/files with `talk-send` — state the scope, boundaries, and expected output.
|
|
63
64
|
3. Each session completes its slice, then sends the result or a review request.
|
|
64
65
|
4. Sync progress periodically to avoid overlapping edits.
|
|
@@ -92,4 +93,4 @@ In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (shown
|
|
|
92
93
|
- **Avoid message loops**: if the peer sent you something or is asking you, answer it before sending new ones. Two agents pinging each other deadlock.
|
|
93
94
|
- **Address from known ids**: only run `talk-list-sessions` to discover sessions or verify an id. If you already hold a valid id (e.g. from an incoming message or a previous listing), send directly — an unknown or invisible id is refused with `Unknown session id`.
|
|
94
95
|
- **Respect status**: asking an offline session blocks until the 30 min timeout. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
|
|
95
|
-
- **Visibility boundary**: you can only collaborate with sessions
|
|
96
|
+
- **Visibility boundary**: you can only collaborate with sessions that share your group; ungrouped sessions and other groups' members are unreachable by design. Ask the user to pair sessions before collaborating.
|
package/src/talk/storage.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* blindly cast.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { mkdirSync } from "node:fs";
|
|
20
|
+
import { dirname } from "node:path";
|
|
19
21
|
import { DatabaseSync } from "node:sqlite";
|
|
20
22
|
|
|
21
23
|
export interface TalkStorage {
|
|
@@ -53,6 +55,10 @@ export class SqliteTalkStorage implements TalkStorage {
|
|
|
53
55
|
private readonly db: DatabaseSync;
|
|
54
56
|
|
|
55
57
|
constructor(dbPath: string) {
|
|
58
|
+
// A custom db_path may point into a directory that does not exist yet
|
|
59
|
+
// (e.g. "~/data/talk.db"); sqlite refuses to open it, so create the
|
|
60
|
+
// parent directory first.
|
|
61
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
56
62
|
this.db = new DatabaseSync(dbPath);
|
|
57
63
|
this.db.exec("PRAGMA journal_mode = WAL");
|
|
58
64
|
this.db.exec("PRAGMA busy_timeout = 5000");
|