pi2web 0.2.1 → 0.2.8
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 +53 -6
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +671 -152
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +222 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +47 -0
- package/dist/http/web-assets.js +64 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +203 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +249 -45
- package/dist/server.js +488 -614
- package/dist/session-namer.js +66 -17
- package/dist/session-performance.js +137 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +24 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3647 -756
- package/web/backend-state.js +59 -27
- package/web/collab.js +684 -257
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +7 -2
- package/web/styles.css +21 -2
- package/web/voice-pcm.js +36 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ npm 包名为 `pi2web`,全局命令同名(`pi2web`)。安装时会准备 `
|
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
pi2web --host 127.0.0.1 --port 11318
|
|
22
|
-
pi2web --data-dir ~/.pi/
|
|
22
|
+
pi2web --data-dir ~/.pi/pi2web # 默认值;也可用环境变量 PI2WEB_DIR
|
|
23
23
|
pi2web --rotate-access-token # 旧配对码立即失效,输出新配对码
|
|
24
24
|
pi2web --set-access-token <token> # 手动设置配对码(16–128 位 URL-safe 字符)
|
|
25
25
|
pi2web --set-access-token-env MY_TOKEN_ENV # 从环境变量读取配对码,推荐方式
|
|
@@ -110,11 +110,19 @@ pi2web \
|
|
|
110
110
|
- 摘要 LLM 使用流式 Chat Completions;Remote Pi 在完整句子出现后立即调用流式 TTS,因此无需等待整段摘要完成。
|
|
111
111
|
- Web UI 中点击“启用语音”后,会播放服务端推送的 PCM16 或 MP3 音频;新的 Agent 运行会取消该 Agent 尚未完成的播报。
|
|
112
112
|
- 多个 Session 接近同时完成时,语音摘要会进入全局队列依次播放,不会重叠;有 Session 名称时以“会话{name}已完成:”开头。
|
|
113
|
-
- 只有配置 `--voice-stt-model
|
|
113
|
+
- 只有配置 `--voice-stt-model`,或在 Web UI「配置 → Sotto 语音输入」开启 Sotto 时,才显示麦克风按钮;识别文字只插入输入框,不会自动发送。
|
|
114
114
|
- API key 通过 `--voice-api-key-env` 和 `--voice-summary-api-key-env` 指定环境变量名,避免把密钥放入命令行参数。
|
|
115
115
|
|
|
116
116
|
语音流目前通过已认证的 Agent WebSocket 发送,只会实时投递,不写入 Session 或事件 replay。浏览器麦克风通常要求 HTTPS 安全上下文(`localhost` 例外)。
|
|
117
117
|
|
|
118
|
+
### 使用本机 Sotto 进行语音输入(无需另一份 STT 模型)
|
|
119
|
+
|
|
120
|
+
先在**与 pi2web Backend 同一台机器**启动支持 `/api/capabilities` 和 `/api/transcribe` 的 Sotto Web 版本,再在 pi2web 网页「配置 → Sotto 语音输入」勾选启用并保存。默认 endpoint 为 `http://127.0.0.1:8766`,指的是 **Backend 所在机器**,不是访问网页的手机或电脑;服务端只允许配置 IPv4 回环 HTTP 地址,不允许指向公网。启用时会探测 endpoint,不可用则不会保存开启状态。设置保存在 pi2web 的数据目录,所有使用该 Backend 的网页共享;默认关闭,不配语音识别时麦克风按钮保持隐藏。
|
|
121
|
+
|
|
122
|
+
浏览器点击麦克风后持续采集单声道 float32 PCM,经 pi2web 已认证的 Agent WebSocket 转发至本机 Sotto;Sotto 持续返回 **partial/final**,网页边说边更新输入框;再次点击停止并定稿,**不会自动发送给 Agent**。切到 Terminal 等其他视图时录音继续;连续 1 分钟没有新的识别文字会自动停止并保留已识别内容。Sotto 复用已加载的 ASR(首次请求才加载一次),使用独立本机 WS 端口(默认 `8768`,即 Web 端口 +2),不对外开放。浏览器需支持安全上下文、Web Audio、麦克风权限;`Alt+Shift+V` 可启停。Sotto 的 ASR 实时测试运行时不能并用实时输入,普通监听可并存但共享同一个识别器。原有 `--voice-*` 语音摘要和 STT 功能保持独立;不要同时配置另一套 STT 模型,除非确实需要第二个识别服务。
|
|
123
|
+
|
|
124
|
+
实时听写采用**点击开始、再次点击停止**:停顿只用于分句定稿,不会结束录音,也没有固定一分钟的录音上限。停止时先发送 Worklet 中尚未发送的尾部音频,再等待最终结果。切换 Agent 或归档录音所属 Agent 会取消录音,切到 Terminal 则继续;关闭 Sotto 或修改其服务地址会结束该 Backend 所有网页已经建立的实时语音连接,当前网页尚在启动的采集也会取消。停止已有录音不受“能否开始新录音”的条件限制;连接、麦克风或音频积压异常会停止并提示原因。代理仍限制单帧大小及发送积压,连续 30 秒没有收到音频帧会报错关闭连接(静音也会产生音频帧,因此不是“静音超时”)。启动中再次点击可取消;后台运行时请留意浏览器的麦克风指示并主动停止录音。
|
|
125
|
+
|
|
118
126
|
### Edge TTS(仅语音合成)
|
|
119
127
|
|
|
120
128
|
项目提供了一个基于 Python `edge-tts` 和 `aiohttp` 的轻量本地服务。它直接返回 MP3,不需要 Docker 或 FFmpeg:
|
|
@@ -145,7 +153,7 @@ pi2web \
|
|
|
145
153
|
|
|
146
154
|
自动命名是独立于语音的功能,**默认开启**:Agent 每次任务结束(`agent_settled`)后,如果 Session 还没有名字,就用一个 OpenAI 兼容的接口生成简短标题并写入 Session。不开语音时只生成标题,不会产生任何语音;开了语音播报时仍然只由命名器负责命名,语音负责朗读摘要。
|
|
147
155
|
|
|
148
|
-
默认值:`--session-name-base-url http://localhost:8313/`、`--session-name-model gpt-5-
|
|
156
|
+
默认值:`--session-name-base-url http://localhost:8313/`、`--session-name-model gpt-5.4-nano`、无 API Key。可用参数:
|
|
149
157
|
|
|
150
158
|
```
|
|
151
159
|
--session-naming <on|off> 关闭或开启自动命名(默认 on)
|
|
@@ -170,13 +178,26 @@ npm test
|
|
|
170
178
|
npm run dev -- --port 11318
|
|
171
179
|
```
|
|
172
180
|
|
|
181
|
+
源码使用 Prettier 统一格式:`npm run format` 和 `npm run format:check` 覆盖 `src/**/*.ts` 与 `web/*.js`;不要格式化 `dist/` 或网站生成文件 `website/public/`,也不要把纯格式改动与功能修改混在同一次提交。
|
|
182
|
+
|
|
173
183
|
## 数据持久化
|
|
174
184
|
|
|
175
|
-
|
|
185
|
+
pi2web 的服务端数据目录是 `~/.pi/pi2web`(`--data-dir` 或 `PI2WEB_DIR` 可覆盖)。旧版本使用的 `~/.pi/remote-pi` 不再读取,请在停止服务后运行一次迁移脚本:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python scripts/migrate_data_dir.py --dry-run # 查看计划
|
|
189
|
+
python scripts/migrate_data_dir.py # 复制 ~/.pi/remote-pi -> ~/.pi/pi2web,remote-pi.db 改名为 pi2web.db
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
脚本使用 SQLite backup API(包含 WAL 中尚未落盘的数据),不修改源目录;目标目录已有同名文件时会中止,`--force` 会先把它们移到 `pre-migration-<时间>/`。
|
|
193
|
+
|
|
194
|
+
元数据保存在 `~/.pi/pi2web/pi2web.db` SQLite 数据库中:
|
|
176
195
|
|
|
177
196
|
- `workspaces`:Workspace ID、名称、根目录和创建时间。
|
|
178
197
|
- `agents`:Agent 与 Workspace/Session 的映射、cwd、状态、创建/最后活动时间以及 Archive 状态。
|
|
179
198
|
- `sessions`:用于 Session 列表、排序和分页的轻量索引,包括名称、文件路径、消息数量、文件 mtime/size 和最后活动时间。
|
|
199
|
+
- `agents.template`:Agent 的 System Prompt 模板快照(JSON)。仅在第一条会话消息前可修改,保存成功后才生效;对话开始后固定,卸载或重启不会改变。
|
|
200
|
+
- `agent-templates/*.md`:可分享的 Agent 模板文件,见下文「System Prompt 与 Agent 模板」。
|
|
180
201
|
- `auth.json`:配对码的 SHA-256 hash、创建和轮换时间;认证数据仍单独保存,不保存配对码明文,文件权限为 `0600`。
|
|
181
202
|
|
|
182
203
|
数据库使用 WAL、短事务、部分索引和批量延迟更新。Session 活动时间不会随每个 streaming token 写入,而是在完整消息、Agent 完成和 Session 信息变化时合并持久化。旧版 `workspaces.json`、`agents.json` 和 `agents_archive.json` 不会被读取、迁移或继续写入。
|
|
@@ -233,7 +254,7 @@ Terminal 是以 Remote Pi 进程用户身份运行的完整宿主机 Shell。Wor
|
|
|
233
254
|
|
|
234
255
|
这些约定针对上述 Nozzala 固件,不是所有相同 VID/PID 设备的通用规范。连接前请退出其他控制该键盘的 ChatGPT/Codex App,避免设备占用或灯光互相覆盖。
|
|
235
256
|
|
|
236
|
-
|
|
257
|
+
灯效直接映射服务器维护的 Agent 活动状态(`state.activity`:当前阶段、在途的独立 Shell、待回答的扩展对话框),连接时随 `agent_state` 下发、变化时以 `activity_changed` 推送;这些状态只保存在服务进程内存中,不写入数据库或 Pi Session,LLM 的 `idle` 不代表独立 Shell 或对话框已结束。不支持 WebHID 或安全上下文的浏览器会静默跳过自动连接,配置页保留原因说明。
|
|
237
258
|
|
|
238
259
|
活动快照中的 `state.activity.dialogRequests` 还包含待回答对话框的原始请求(不含用户答案),刷新或回放缺口后可恢复回答控件。服务端复用同一份内存请求记录,前端按 request ID 避免重复创建对话框;已结束的请求不会再出现在快照中。
|
|
239
260
|
|
|
@@ -327,6 +348,10 @@ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与
|
|
|
327
348
|
- `GET /api/v1/sessions`
|
|
328
349
|
- `GET/POST /api/v1/agents`(创建时可带 `profile`:`default`(默认)或 `collab`;`collab` 会加载协作工具,供协作看板占座使用)
|
|
329
350
|
- `GET/DELETE /api/v1/agents/:id`
|
|
351
|
+
- `POST /api/v1/agents` 可带 `templateId` 选择 Agent 模板;`GET /api/v1/agents/:id/system-prompt` 返回该 Agent 当前生效的 System Prompt、模板快照及来源文件。
|
|
352
|
+
- `GET/PUT /api/v1/system-prompt`:读取 / 写入 Pi 全局 `~/.pi/agent/SYSTEM.md`、`APPEND_SYSTEM.md`(`{kind: "system"|"append", content}`,空内容即删除文件)。
|
|
353
|
+
- `GET /api/v1/system-prompt/preview?workspaceId=&relativeCwd=&templateId=&source=pi-default`:生成指定 cwd 下的有效 System Prompt;`source=pi-default` 忽略 SYSTEM.md / APPEND_SYSTEM.md,显示 Pi 内置 Prompt。
|
|
354
|
+
- `GET /api/v1/agent-templates`、`GET/PUT/DELETE /api/v1/agent-templates/:id`:管理 Agent 模板文件。
|
|
330
355
|
- `GET/POST /api/v1/collab/sessions`、`/sessions/:id`、`/participants`(含 `/participants/:pid/binding`、`/participants/:pid/budget`)、`/advance`、`/ready`、`/recheck`(**仅人**,重开时轮换每个座位的 token 并在响应 `participantTokens` 里返回一次)、`/policy`、`/events`(`?since=` 游标或 `?tail=` 取最新若干条)、`/digest`、`/issues`、`/findings`、`/issue-votes`、`/merge-votes`、`/issue-discussions`、`/review-consensus`、`/review-rounds`(**仅人**)、`/retry-waiting`、`/escalations`、`/report`(**仅人**),以及 scoring 场景的 `/nominations`、`/votes`、`/criteria`、`/scores`、`/analysis`、`/debates`、`/finalize`(**仅人**,且只在 `awaiting_human` 阶段接受,必须为每个争议维度给一个合刻度的分数)
|
|
331
356
|
- `GET /api/v1/collab/escalations`、`POST /api/v1/collab/escalations/:id/resolve`
|
|
332
357
|
- `GET/POST /api/v1/terminals`
|
|
@@ -337,7 +362,29 @@ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与
|
|
|
337
362
|
- `GET /api/v1/agents/:id/mcp` 返回该 Agent cwd 下的 MCP 服务器配置(全局 `mcp.json` 与受信任项目的 `.pi/mcp.json`),`POST` 用 `{name, enabled}` 启用或停用。只有项目被 Pi 显式信任时才会写入项目级覆盖,否则写全局文件。修改在 Agent 下次加载时生效。
|
|
338
363
|
- `GET /api/v1/agents/:id/commands` 返回该 Agent cwd 下可用的 prompt template 和 skill(`{name, description, argumentHint, source}`)。`prompt` 接口本身会展开 `/命令`、skill 和 prompt template。
|
|
339
364
|
- `POST /api/v1/agents/:id/bash` 在 Agent Session 内执行命令(`{command, excludeFromContext}`),输出通过 `bash_execution_update` 事件流式推送并记录到 Session;`POST /api/v1/agents/:id/bash-abort` 取消。
|
|
340
|
-
- WebSocket `/api/v1/ws`
|
|
365
|
+
- WebSocket `/api/v1/ws`(协议 v2,不做事件回放):`subscribe_all` 先下发每个 Agent 的当前状态,再推送所有 Agent 的概览事件(状态、活动、队列、对话框、通知);`focus` 选定正在查看的 Agent,只为它推送流式文本、时间线条目变化和用户 Shell 输出;`watch` 订阅已展开的运行中工具的输出;`subscribe_collab` 推送协作事件。
|
|
366
|
+
- 会话内容通过 HTTP 时间线读取:`GET /api/v1/agents/:id/timeline?limit&before` 返回条目摘要(正文较小或需要阅读的条目内联),`GET /api/v1/agents/:id/sessions/:sessionId/items/:itemId` 按需返回完整正文;已完成条目永不改变,响应带 `immutable` 缓存头,浏览器可永久缓存。
|
|
367
|
+
|
|
368
|
+
## System Prompt 与 Agent 模板
|
|
369
|
+
|
|
370
|
+
Web UI「配置 → 管理 System Prompt…」可以:
|
|
371
|
+
|
|
372
|
+
- 预览当前 Workspace / 工作路径下 Agent 实际会使用的 System Prompt,或忽略全局文件的 Pi 内置 Prompt;
|
|
373
|
+
- 编辑 Pi 自己的全局 `~/.pi/agent/SYSTEM.md`(替换 Pi 基础 Prompt)和 `APPEND_SYSTEM.md`(追加),这两个文件同样作用于 Pi CLI;受信任项目的 `.pi/SYSTEM.md`、`.pi/APPEND_SYSTEM.md` 优先;
|
|
374
|
+
- 管理 Agent 模板。
|
|
375
|
+
|
|
376
|
+
Agent 模板是 `~/.pi/pi2web/agent-templates/<id>.md` 文件,直接复制即可分享,手动放进目录的文件会立即出现:
|
|
377
|
+
|
|
378
|
+
```markdown
|
|
379
|
+
---
|
|
380
|
+
name: Code reviewer
|
|
381
|
+
description: 只审查,不修改文件
|
|
382
|
+
mode: append # append:追加在 Pi Prompt 之后;replace:替换 Pi 基础 Prompt(等同 SYSTEM.md)
|
|
383
|
+
---
|
|
384
|
+
You are a meticulous code reviewer. ...
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
新建 Agent 时可选择模板(默认是 Pi 自己的 Prompt)。模板内容复制为 Agent 自己的快照;**第一条会话消息前可以修改,开始对话后不能更改**。模板替换与会话写入共用准入检查,写入期间不能替换,替换期间不能写入。修改操作先保存到数据库再启用,保存失败则保留原模板;Stop 或归档会使未完成的替换失效,迟到的候选会话会被释放,不会重新启用 Agent。后端重启或 Agent 重新加载仍使用最后保存的快照,不会退回创建时的选择。修改或删除模板文件不影响已有 Agent,Fork 出的 Agent 沿用同一模板。两种模式下 Pi 仍会加上 AGENTS.md 等项目上下文、skills 和 cwd;`replace` 会去掉 Pi 内置的工具说明、规则和文档段落,`APPEND_SYSTEM.md` 仍然生效。Agent 工具栏的「Prompt」按钮显示该 Agent 当前生效的完整 System Prompt。
|
|
341
388
|
|
|
342
389
|
运行时使用真实 `@earendil-works/pi-coding-agent` SDK,并复用 Pi CLI 的 `~/.pi/agent` 模型、认证和设置。`MockBackend` 只用于自动化测试。
|
|
343
390
|
|
package/dist/activity.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const SUMMARY_LIMIT = 80;
|
|
2
|
+
const SUMMARY_KEYS = ['command', 'path', 'file_path', 'pattern', 'query', 'url'];
|
|
3
|
+
/** A one-line hint of what a tool call works on: its command, path, pattern... */
|
|
4
|
+
export function toolSummary(args) {
|
|
5
|
+
if (!args || typeof args !== 'object')
|
|
6
|
+
return undefined;
|
|
7
|
+
const record = args;
|
|
8
|
+
const value = SUMMARY_KEYS.map(key => record[key]).find(v => typeof v === 'string' && v.trim()) ??
|
|
9
|
+
Object.values(record).find(v => typeof v === 'string' && v.trim());
|
|
10
|
+
if (typeof value !== 'string')
|
|
11
|
+
return undefined;
|
|
12
|
+
const line = value.trim().replace(/\s+/g, ' ');
|
|
13
|
+
return line.length > SUMMARY_LIMIT ? `${line.slice(0, SUMMARY_LIMIT - 1)}…` : line;
|
|
14
|
+
}
|
|
15
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
16
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
|
17
|
+
/**
|
|
18
|
+
* Server-side state machine over raw Pi events. It is the single source of truth for
|
|
19
|
+
* "what is this Agent doing and since when", so any client can show it at any time,
|
|
20
|
+
* including after switching sessions, reloading or reconnecting.
|
|
21
|
+
*/
|
|
22
|
+
export class ActivityTracker {
|
|
23
|
+
now;
|
|
24
|
+
state;
|
|
25
|
+
constructor(now = Date.now) {
|
|
26
|
+
this.now = now;
|
|
27
|
+
this.state = { run: null, phase: { kind: 'idle', since: now() }, bash: [], dialogs: [] };
|
|
28
|
+
}
|
|
29
|
+
snapshot() {
|
|
30
|
+
return structuredClone(this.state);
|
|
31
|
+
}
|
|
32
|
+
/** Forget everything, e.g. when the backend is unloaded. Returns whether anything changed. */
|
|
33
|
+
reset() {
|
|
34
|
+
const s = this.state;
|
|
35
|
+
if (!s.run && s.phase.kind === 'idle' && !s.bash.length && !s.dialogs.length)
|
|
36
|
+
return false;
|
|
37
|
+
this.state = { run: null, phase: { kind: 'idle', since: this.now() }, bash: [], dialogs: [] };
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
/** The user asked to abort the current run. */
|
|
41
|
+
stopping() {
|
|
42
|
+
if (!this.state.run || this.state.phase.kind === 'stopping')
|
|
43
|
+
return false;
|
|
44
|
+
return this.setPhase({ kind: 'stopping' });
|
|
45
|
+
}
|
|
46
|
+
/** Apply one raw Pi event. Returns whether the activity changed, so callers publish only real changes. */
|
|
47
|
+
apply(event) {
|
|
48
|
+
const e = event, s = this.state;
|
|
49
|
+
switch (event.type) {
|
|
50
|
+
case 'agent_start': {
|
|
51
|
+
const startedRun = !s.run;
|
|
52
|
+
if (startedRun)
|
|
53
|
+
s.run = { startedAt: this.now() };
|
|
54
|
+
return this.setPhase({ kind: 'waiting_llm' }) || startedRun;
|
|
55
|
+
}
|
|
56
|
+
case 'turn_start':
|
|
57
|
+
return this.setPhase({ kind: 'waiting_llm' });
|
|
58
|
+
case 'message_update': {
|
|
59
|
+
const update = e.assistantMessageEvent, type = str(update?.type);
|
|
60
|
+
if (type === 'thinking_start' || type === 'thinking_delta')
|
|
61
|
+
return this.setPhase({ kind: 'thinking' });
|
|
62
|
+
if (type === 'text_start' || type === 'text_delta')
|
|
63
|
+
return this.setPhase({ kind: 'generating' });
|
|
64
|
+
if (type === 'toolcall_start' || type === 'toolcall_delta') {
|
|
65
|
+
const toolName = str(update.partial?.content?.[update.contentIndex]?.name);
|
|
66
|
+
// The name may only arrive with a later delta: fill it in without restarting the timer.
|
|
67
|
+
if (s.phase.kind === 'preparing_tool' && !s.phase.toolName && toolName) {
|
|
68
|
+
s.phase.toolName = toolName;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return this.setPhase({ kind: 'preparing_tool', ...(toolName ? { toolName } : {}) });
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
case 'tool_execution_start': {
|
|
76
|
+
// Tools called by other tools run inside their caller's time; show only top-level calls.
|
|
77
|
+
if (e.parentToolCallId)
|
|
78
|
+
return false;
|
|
79
|
+
const tool = {
|
|
80
|
+
toolCallId: String(e.toolCallId ?? ''),
|
|
81
|
+
toolName: str(e.toolName) ?? 'tool',
|
|
82
|
+
summary: toolSummary(e.args),
|
|
83
|
+
since: this.now()
|
|
84
|
+
};
|
|
85
|
+
if (s.phase.kind === 'running_tools') {
|
|
86
|
+
s.phase.tools.push(tool);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return this.setPhase({ kind: 'running_tools', tools: [tool] });
|
|
90
|
+
}
|
|
91
|
+
case 'tool_execution_end': {
|
|
92
|
+
if (e.parentToolCallId || s.phase.kind !== 'running_tools')
|
|
93
|
+
return false;
|
|
94
|
+
const tools = s.phase.tools.filter(tool => tool.toolCallId !== e.toolCallId);
|
|
95
|
+
if (tools.length === s.phase.tools.length)
|
|
96
|
+
return false;
|
|
97
|
+
if (tools.length) {
|
|
98
|
+
s.phase.tools = tools;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return this.setPhase({ kind: 'waiting_llm' });
|
|
102
|
+
}
|
|
103
|
+
case 'auto_retry_start': {
|
|
104
|
+
if (!s.run)
|
|
105
|
+
s.run = { startedAt: this.now() };
|
|
106
|
+
const delay = num(e.delayMs);
|
|
107
|
+
return this.setPhase({
|
|
108
|
+
kind: 'retrying',
|
|
109
|
+
attempt: num(e.attempt),
|
|
110
|
+
maxAttempts: num(e.maxAttempts),
|
|
111
|
+
retryAt: delay === undefined ? undefined : this.now() + delay,
|
|
112
|
+
error: str(e.errorMessage)
|
|
113
|
+
}, true);
|
|
114
|
+
}
|
|
115
|
+
case 'compaction_start':
|
|
116
|
+
return this.setPhase({ kind: 'compacting', reason: str(e.reason) });
|
|
117
|
+
case 'compaction_end':
|
|
118
|
+
return this.setPhase({ kind: s.run ? 'waiting_llm' : 'idle' });
|
|
119
|
+
case 'agent_end':
|
|
120
|
+
if (e.willRetry === true)
|
|
121
|
+
return false;
|
|
122
|
+
return this.endRun();
|
|
123
|
+
case 'agent_settled':
|
|
124
|
+
// Settled: nothing of the run is left, compaction included. Shell commands and dialogs stay.
|
|
125
|
+
return this.endRun(true);
|
|
126
|
+
case 'bash_execution_start':
|
|
127
|
+
case 'bash_execution_update': {
|
|
128
|
+
const id = str(e.id);
|
|
129
|
+
if (!id || s.bash.some(b => b.id === id))
|
|
130
|
+
return false;
|
|
131
|
+
s.bash.push({ id, command: str(e.command), since: this.now() });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
case 'bash_execution_end': {
|
|
135
|
+
const before = s.bash.length;
|
|
136
|
+
s.bash = s.bash.filter(b => b.id !== e.id);
|
|
137
|
+
return s.bash.length !== before;
|
|
138
|
+
}
|
|
139
|
+
case 'extension_ui_request': {
|
|
140
|
+
const requestId = str(e.requestId);
|
|
141
|
+
if (!requestId || s.dialogs.some(d => d.requestId === requestId))
|
|
142
|
+
return false;
|
|
143
|
+
s.dialogs.push({ requestId, title: str(e.title) ?? str(e.message), since: this.now() });
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
case 'extension_ui_response': {
|
|
147
|
+
const before = s.dialogs.length;
|
|
148
|
+
s.dialogs = s.dialogs.filter(d => d.requestId !== e.requestId);
|
|
149
|
+
return s.dialogs.length !== before;
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
endRun(settled = false) {
|
|
156
|
+
const hadRun = !!this.state.run;
|
|
157
|
+
this.state.run = null;
|
|
158
|
+
// Compaction may continue after the run has ended; agent_settled closes it.
|
|
159
|
+
if (this.state.phase.kind === 'compacting' && !settled)
|
|
160
|
+
return hadRun;
|
|
161
|
+
return this.setPhase({ kind: 'idle' }) || hadRun;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Enter a phase. Staying in the same phase keeps its start time, so a stream of deltas
|
|
165
|
+
* neither resets the timer nor counts as a change. `force` restarts it (e.g. each retry).
|
|
166
|
+
*/
|
|
167
|
+
setPhase(next, force = false) {
|
|
168
|
+
const current = this.state.phase;
|
|
169
|
+
if (!force && current.kind === next.kind && Object.keys(next).every(key => current[key] === next[key]))
|
|
170
|
+
return false;
|
|
171
|
+
const phase = { ...next, since: this.now() };
|
|
172
|
+
for (const key of Object.keys(phase))
|
|
173
|
+
if (phase[key] === undefined)
|
|
174
|
+
delete phase[key];
|
|
175
|
+
this.state.phase = phase;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { buildTimeline, contentText, pageTimeline } from './timeline.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns one backend's raw events into the detail stream. After any event, if the session's
|
|
4
|
+
* leaf moved, the branch is projected again and the items after the published ones are sent.
|
|
5
|
+
*/
|
|
6
|
+
export class AgentFeed {
|
|
7
|
+
source;
|
|
8
|
+
emit;
|
|
9
|
+
published;
|
|
10
|
+
live = { active: false, text: '', thinking: '' };
|
|
11
|
+
clearLive = false;
|
|
12
|
+
outputs = new Map();
|
|
13
|
+
timer;
|
|
14
|
+
closed = false;
|
|
15
|
+
constructor(source, emit) {
|
|
16
|
+
this.source = source;
|
|
17
|
+
this.emit = emit;
|
|
18
|
+
this.published = this.project().published;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Everything a client needs to start viewing: the newest page and the text streamed so far.
|
|
22
|
+
* Publishes pending changes first, so the next message a subscriber gets is newer than this.
|
|
23
|
+
*/
|
|
24
|
+
snapshot(limit) {
|
|
25
|
+
this.sync();
|
|
26
|
+
const source = this.source(), { active, text, thinking } = this.live;
|
|
27
|
+
return {
|
|
28
|
+
sessionId: source.sessionId,
|
|
29
|
+
page: pageTimeline(buildTimeline(source.entries()), limit),
|
|
30
|
+
live: active || text || thinking ? { text, thinking } : null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
output(toolCallId) {
|
|
34
|
+
return this.outputs.get(toolCallId);
|
|
35
|
+
}
|
|
36
|
+
handle(event) {
|
|
37
|
+
const e = event;
|
|
38
|
+
switch (event.type) {
|
|
39
|
+
case 'message_start':
|
|
40
|
+
if (e.message?.role === 'assistant' || e.role === 'assistant') {
|
|
41
|
+
// The previous message is persisted by now; publish it before streaming the next.
|
|
42
|
+
this.sync();
|
|
43
|
+
this.live = { active: true, text: '', thinking: '' };
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
case 'message_update': {
|
|
47
|
+
const update = e.assistantMessageEvent;
|
|
48
|
+
if ((update?.type === 'text_delta' || update?.type === 'thinking_delta') &&
|
|
49
|
+
typeof update.delta === 'string') {
|
|
50
|
+
const kind = update.type === 'text_delta' ? 'text' : 'thinking';
|
|
51
|
+
this.live[kind] += update.delta;
|
|
52
|
+
this.emit({ type: 'delta', kind, delta: update.delta });
|
|
53
|
+
}
|
|
54
|
+
return; // Deltas never change the persisted timeline.
|
|
55
|
+
}
|
|
56
|
+
case 'message_end':
|
|
57
|
+
if (this.live.active) {
|
|
58
|
+
this.live.active = false;
|
|
59
|
+
this.clearLive = true;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case 'tool_execution_update':
|
|
63
|
+
if (!e.parentToolCallId && typeof e.toolCallId === 'string') {
|
|
64
|
+
const result = e.partialResult, output = contentText(result?.content ?? result) || (result ? JSON.stringify(result) : '');
|
|
65
|
+
this.outputs.set(e.toolCallId, output);
|
|
66
|
+
this.emit({ type: 'tool_output', toolCallId: e.toolCallId, output });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
case 'tool_execution_end':
|
|
70
|
+
this.outputs.delete(String(e.toolCallId));
|
|
71
|
+
break;
|
|
72
|
+
case 'bash_execution_start':
|
|
73
|
+
case 'bash_execution_update':
|
|
74
|
+
case 'bash_execution_end':
|
|
75
|
+
this.emit({ type: 'event', event });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
this.schedule();
|
|
79
|
+
}
|
|
80
|
+
/** Pi persists a message right after notifying listeners of it, so look once the event has finished. */
|
|
81
|
+
schedule() {
|
|
82
|
+
if (this.timer || this.closed)
|
|
83
|
+
return;
|
|
84
|
+
this.timer = setTimeout(() => {
|
|
85
|
+
this.timer = undefined;
|
|
86
|
+
this.sync();
|
|
87
|
+
}, 0);
|
|
88
|
+
}
|
|
89
|
+
/** Publish what changed on the timeline since the last call. Cheap when the leaf did not move. */
|
|
90
|
+
sync() {
|
|
91
|
+
if (this.closed)
|
|
92
|
+
return;
|
|
93
|
+
const source = this.source(), previous = this.published;
|
|
94
|
+
if (source.sessionId === previous.sessionId && source.leafId === previous.leafId && !this.clearLive)
|
|
95
|
+
return;
|
|
96
|
+
const { published, records, entries } = this.project(source);
|
|
97
|
+
this.published = published;
|
|
98
|
+
// An append keeps the old leaf on the branch and every item published so far.
|
|
99
|
+
const appended = source.sessionId === previous.sessionId &&
|
|
100
|
+
(previous.leafId === null || entries.some(entry => entry.id === previous.leafId)) &&
|
|
101
|
+
previous.ids.every((id, i) => published.ids[i] === id);
|
|
102
|
+
const clearLive = this.clearLive;
|
|
103
|
+
this.clearLive = false;
|
|
104
|
+
if (clearLive)
|
|
105
|
+
this.live = { active: false, text: '', thinking: '' };
|
|
106
|
+
if (!appended) {
|
|
107
|
+
this.emit({ type: 'timeline_reset', sessionId: source.sessionId });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const items = records.slice(previous.ids.length).map(r => r.item);
|
|
111
|
+
if (!items.length && !clearLive)
|
|
112
|
+
return;
|
|
113
|
+
this.emit({
|
|
114
|
+
type: 'items',
|
|
115
|
+
sessionId: source.sessionId,
|
|
116
|
+
items,
|
|
117
|
+
...(clearLive ? { clearLive: true } : {})
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
close() {
|
|
121
|
+
this.closed = true;
|
|
122
|
+
if (this.timer)
|
|
123
|
+
clearTimeout(this.timer);
|
|
124
|
+
}
|
|
125
|
+
project(source = this.source()) {
|
|
126
|
+
const entries = source.entries(), records = buildTimeline(entries);
|
|
127
|
+
return {
|
|
128
|
+
published: { sessionId: source.sessionId, leafId: source.leafId, ids: records.map(r => r.item.id) },
|
|
129
|
+
records,
|
|
130
|
+
entries
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { getAgentDir, parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
5
|
+
const MAX_PROMPT = 200_000;
|
|
6
|
+
const invalid = (message) => Object.assign(new Error(message), { code: 'INVALID_REQUEST' });
|
|
7
|
+
export const validTemplateId = (id) => typeof id === 'string' && ID.test(id) && !id.endsWith('.') && id !== 'default';
|
|
8
|
+
const asMode = (value) => value === 'replace' || value === 'append' ? value : undefined;
|
|
9
|
+
/** Serialize a template as Markdown with YAML front matter; JSON strings are valid YAML scalars. */
|
|
10
|
+
export const formatTemplateFile = (template) => [
|
|
11
|
+
'---',
|
|
12
|
+
`name: ${JSON.stringify(template.name)}`,
|
|
13
|
+
...(template.description ? [`description: ${JSON.stringify(template.description)}`] : []),
|
|
14
|
+
`mode: ${template.mode}`,
|
|
15
|
+
'---',
|
|
16
|
+
'',
|
|
17
|
+
template.systemPrompt.trim(),
|
|
18
|
+
''
|
|
19
|
+
].join('\n');
|
|
20
|
+
/**
|
|
21
|
+
* Agent templates are plain files under `<data-dir>/agent-templates/<id>.md` so they can be shared by copying:
|
|
22
|
+
*
|
|
23
|
+
* ```markdown
|
|
24
|
+
* ---
|
|
25
|
+
* name: Code reviewer
|
|
26
|
+
* description: Reviews diffs, never edits files
|
|
27
|
+
* mode: replace # or append
|
|
28
|
+
* ---
|
|
29
|
+
* You are a meticulous code reviewer...
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Files are re-read on every call, so templates dropped into the directory by hand show up immediately.
|
|
33
|
+
*/
|
|
34
|
+
export class AgentTemplateStore {
|
|
35
|
+
dir;
|
|
36
|
+
constructor(dir) {
|
|
37
|
+
this.dir = dir;
|
|
38
|
+
}
|
|
39
|
+
fileFor(id) {
|
|
40
|
+
if (!validTemplateId(id))
|
|
41
|
+
throw invalid('Template id must be 1-64 letters, digits, dot, underscore or hyphen');
|
|
42
|
+
return path.join(this.dir, `${id}.md`);
|
|
43
|
+
}
|
|
44
|
+
read(id, file) {
|
|
45
|
+
const raw = readFileSync(file, 'utf8');
|
|
46
|
+
let frontmatter = {}, body = raw;
|
|
47
|
+
try {
|
|
48
|
+
({ frontmatter, body } = parseFrontmatter(raw));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw Object.assign(new Error(`Invalid front matter in ${file}: ${error.message}`), {
|
|
52
|
+
code: 'INVALID_TEMPLATE'
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const name = typeof frontmatter.name === 'string' && frontmatter.name.trim() ? frontmatter.name.trim() : id;
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
name,
|
|
59
|
+
description: typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '',
|
|
60
|
+
mode: asMode(frontmatter.mode) ?? 'append',
|
|
61
|
+
systemPrompt: body.trim(),
|
|
62
|
+
file,
|
|
63
|
+
updatedAt: statSync(file).mtime.toISOString()
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
list() {
|
|
67
|
+
if (!existsSync(this.dir))
|
|
68
|
+
return { templates: [], errors: [] };
|
|
69
|
+
const templates = [], errors = [];
|
|
70
|
+
for (const entry of readdirSync(this.dir, { withFileTypes: true })) {
|
|
71
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
72
|
+
continue;
|
|
73
|
+
const id = entry.name.slice(0, -3);
|
|
74
|
+
if (!validTemplateId(id)) {
|
|
75
|
+
errors.push(`Ignored ${entry.name}: invalid template id`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
templates.push(this.read(id, path.join(this.dir, entry.name)));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
errors.push(error.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
templates.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
86
|
+
return { templates, errors };
|
|
87
|
+
}
|
|
88
|
+
get(id) {
|
|
89
|
+
const file = this.fileFor(id);
|
|
90
|
+
if (!existsSync(file))
|
|
91
|
+
throw Object.assign(new Error(`Agent template not found: ${id}`), { code: 'TEMPLATE_NOT_FOUND' });
|
|
92
|
+
return this.read(id, file);
|
|
93
|
+
}
|
|
94
|
+
save(id, input) {
|
|
95
|
+
const file = this.fileFor(id);
|
|
96
|
+
const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim() : id;
|
|
97
|
+
if (name.length > 100 || /[\r\n]/.test(name))
|
|
98
|
+
throw invalid('name must be a single line of at most 100 characters');
|
|
99
|
+
const description = typeof input.description === 'string' ? input.description.trim() : '';
|
|
100
|
+
if (description.length > 500 || /[\r\n]/.test(description))
|
|
101
|
+
throw invalid('description must be a single line of at most 500 characters');
|
|
102
|
+
const mode = asMode(input.mode);
|
|
103
|
+
if (!mode)
|
|
104
|
+
throw invalid('mode must be replace or append');
|
|
105
|
+
if (typeof input.systemPrompt !== 'string' || !input.systemPrompt.trim())
|
|
106
|
+
throw invalid('systemPrompt is required');
|
|
107
|
+
if (input.systemPrompt.length > MAX_PROMPT)
|
|
108
|
+
throw invalid('systemPrompt is too long');
|
|
109
|
+
mkdirSync(this.dir, { recursive: true });
|
|
110
|
+
writeFileSync(file, formatTemplateFile({ name, description, mode, systemPrompt: input.systemPrompt }), 'utf8');
|
|
111
|
+
return this.read(id, file);
|
|
112
|
+
}
|
|
113
|
+
remove(id) {
|
|
114
|
+
const file = this.fileFor(id);
|
|
115
|
+
if (!existsSync(file))
|
|
116
|
+
throw Object.assign(new Error(`Agent template not found: ${id}`), { code: 'TEMPLATE_NOT_FOUND' });
|
|
117
|
+
rmSync(file);
|
|
118
|
+
}
|
|
119
|
+
snapshot(id) {
|
|
120
|
+
const { name, mode, systemPrompt } = this.get(id);
|
|
121
|
+
return { id, name, mode, systemPrompt };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const PI_PROMPT_FILES = { system: 'SYSTEM.md', append: 'APPEND_SYSTEM.md' };
|
|
125
|
+
export const piPromptFile = (kind, agentDir = getAgentDir()) => path.join(agentDir, PI_PROMPT_FILES[kind]);
|
|
126
|
+
export function readPiPromptFiles(agentDir = getAgentDir()) {
|
|
127
|
+
const read = (kind) => {
|
|
128
|
+
const file = piPromptFile(kind, agentDir), exists = existsSync(file);
|
|
129
|
+
return { path: file, exists, content: exists ? readFileSync(file, 'utf8') : '' };
|
|
130
|
+
};
|
|
131
|
+
return { agentDir, system: read('system'), append: read('append') };
|
|
132
|
+
}
|
|
133
|
+
/** Write (or, for an empty value, delete) a global Pi prompt file. Deleting SYSTEM.md restores Pi's built-in prompt. */
|
|
134
|
+
export function writePiPromptFile(kind, content, agentDir = getAgentDir()) {
|
|
135
|
+
if (content.length > MAX_PROMPT)
|
|
136
|
+
throw invalid('content is too long');
|
|
137
|
+
const file = piPromptFile(kind, agentDir);
|
|
138
|
+
if (!content.trim())
|
|
139
|
+
rmSync(file, { force: true });
|
|
140
|
+
else {
|
|
141
|
+
mkdirSync(agentDir, { recursive: true });
|
|
142
|
+
writeFileSync(file, content, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
}
|