pi2web 0.2.1 → 0.2.6
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 +44 -5
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +518 -141
- 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 +196 -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 +43 -0
- package/dist/http/web-assets.js +63 -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 +190 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +221 -45
- package/dist/server.js +444 -620
- package/dist/session-namer.js +66 -17
- 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 +6 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3098 -743
- package/web/backend-state.js +59 -27
- package/web/collab.js +668 -255
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +4 -1
- package/web/styles.css +14 -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 # 从环境变量读取配对码,推荐方式
|
|
@@ -145,7 +145,7 @@ pi2web \
|
|
|
145
145
|
|
|
146
146
|
自动命名是独立于语音的功能,**默认开启**:Agent 每次任务结束(`agent_settled`)后,如果 Session 还没有名字,就用一个 OpenAI 兼容的接口生成简短标题并写入 Session。不开语音时只生成标题,不会产生任何语音;开了语音播报时仍然只由命名器负责命名,语音负责朗读摘要。
|
|
147
147
|
|
|
148
|
-
默认值:`--session-name-base-url http://localhost:8313/`、`--session-name-model gpt-5-
|
|
148
|
+
默认值:`--session-name-base-url http://localhost:8313/`、`--session-name-model gpt-5.4-nano`、无 API Key。可用参数:
|
|
149
149
|
|
|
150
150
|
```
|
|
151
151
|
--session-naming <on|off> 关闭或开启自动命名(默认 on)
|
|
@@ -170,13 +170,26 @@ npm test
|
|
|
170
170
|
npm run dev -- --port 11318
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
+
源码使用 Prettier 统一格式:`npm run format` 和 `npm run format:check` 覆盖 `src/**/*.ts` 与 `web/*.js`;不要格式化 `dist/` 或网站生成文件 `website/public/`,也不要把纯格式改动与功能修改混在同一次提交。
|
|
174
|
+
|
|
173
175
|
## 数据持久化
|
|
174
176
|
|
|
175
|
-
|
|
177
|
+
pi2web 的服务端数据目录是 `~/.pi/pi2web`(`--data-dir` 或 `PI2WEB_DIR` 可覆盖)。旧版本使用的 `~/.pi/remote-pi` 不再读取,请在停止服务后运行一次迁移脚本:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python scripts/migrate_data_dir.py --dry-run # 查看计划
|
|
181
|
+
python scripts/migrate_data_dir.py # 复制 ~/.pi/remote-pi -> ~/.pi/pi2web,remote-pi.db 改名为 pi2web.db
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
脚本使用 SQLite backup API(包含 WAL 中尚未落盘的数据),不修改源目录;目标目录已有同名文件时会中止,`--force` 会先把它们移到 `pre-migration-<时间>/`。
|
|
185
|
+
|
|
186
|
+
元数据保存在 `~/.pi/pi2web/pi2web.db` SQLite 数据库中:
|
|
176
187
|
|
|
177
188
|
- `workspaces`:Workspace ID、名称、根目录和创建时间。
|
|
178
189
|
- `agents`:Agent 与 Workspace/Session 的映射、cwd、状态、创建/最后活动时间以及 Archive 状态。
|
|
179
190
|
- `sessions`:用于 Session 列表、排序和分页的轻量索引,包括名称、文件路径、消息数量、文件 mtime/size 和最后活动时间。
|
|
191
|
+
- `agents.template`:Agent 创建时选定的 System Prompt 模板快照(JSON),之后不可修改。
|
|
192
|
+
- `agent-templates/*.md`:可分享的 Agent 模板文件,见下文「System Prompt 与 Agent 模板」。
|
|
180
193
|
- `auth.json`:配对码的 SHA-256 hash、创建和轮换时间;认证数据仍单独保存,不保存配对码明文,文件权限为 `0600`。
|
|
181
194
|
|
|
182
195
|
数据库使用 WAL、短事务、部分索引和批量延迟更新。Session 活动时间不会随每个 streaming token 写入,而是在完整消息、Agent 完成和 Session 信息变化时合并持久化。旧版 `workspaces.json`、`agents.json` 和 `agents_archive.json` 不会被读取、迁移或继续写入。
|
|
@@ -233,7 +246,7 @@ Terminal 是以 Remote Pi 进程用户身份运行的完整宿主机 Shell。Wor
|
|
|
233
246
|
|
|
234
247
|
这些约定针对上述 Nozzala 固件,不是所有相同 VID/PID 设备的通用规范。连接前请退出其他控制该键盘的 ChatGPT/Codex App,避免设备占用或灯光互相覆盖。
|
|
235
248
|
|
|
236
|
-
|
|
249
|
+
灯效直接映射服务器维护的 Agent 活动状态(`state.activity`:当前阶段、在途的独立 Shell、待回答的扩展对话框),连接时随 `agent_state` 下发、变化时以 `activity_changed` 推送;这些状态只保存在服务进程内存中,不写入数据库或 Pi Session,LLM 的 `idle` 不代表独立 Shell 或对话框已结束。不支持 WebHID 或安全上下文的浏览器会静默跳过自动连接,配置页保留原因说明。
|
|
237
250
|
|
|
238
251
|
活动快照中的 `state.activity.dialogRequests` 还包含待回答对话框的原始请求(不含用户答案),刷新或回放缺口后可恢复回答控件。服务端复用同一份内存请求记录,前端按 request ID 避免重复创建对话框;已结束的请求不会再出现在快照中。
|
|
239
252
|
|
|
@@ -327,6 +340,10 @@ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与
|
|
|
327
340
|
- `GET /api/v1/sessions`
|
|
328
341
|
- `GET/POST /api/v1/agents`(创建时可带 `profile`:`default`(默认)或 `collab`;`collab` 会加载协作工具,供协作看板占座使用)
|
|
329
342
|
- `GET/DELETE /api/v1/agents/:id`
|
|
343
|
+
- `POST /api/v1/agents` 可带 `templateId` 选择 Agent 模板;`GET /api/v1/agents/:id/system-prompt` 返回该 Agent 当前生效的 System Prompt、模板快照及来源文件。
|
|
344
|
+
- `GET/PUT /api/v1/system-prompt`:读取 / 写入 Pi 全局 `~/.pi/agent/SYSTEM.md`、`APPEND_SYSTEM.md`(`{kind: "system"|"append", content}`,空内容即删除文件)。
|
|
345
|
+
- `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。
|
|
346
|
+
- `GET /api/v1/agent-templates`、`GET/PUT/DELETE /api/v1/agent-templates/:id`:管理 Agent 模板文件。
|
|
330
347
|
- `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
348
|
- `GET /api/v1/collab/escalations`、`POST /api/v1/collab/escalations/:id/resolve`
|
|
332
349
|
- `GET/POST /api/v1/terminals`
|
|
@@ -337,7 +354,29 @@ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与
|
|
|
337
354
|
- `GET /api/v1/agents/:id/mcp` 返回该 Agent cwd 下的 MCP 服务器配置(全局 `mcp.json` 与受信任项目的 `.pi/mcp.json`),`POST` 用 `{name, enabled}` 启用或停用。只有项目被 Pi 显式信任时才会写入项目级覆盖,否则写全局文件。修改在 Agent 下次加载时生效。
|
|
338
355
|
- `GET /api/v1/agents/:id/commands` 返回该 Agent cwd 下可用的 prompt template 和 skill(`{name, description, argumentHint, source}`)。`prompt` 接口本身会展开 `/命令`、skill 和 prompt template。
|
|
339
356
|
- `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`
|
|
357
|
+
- WebSocket `/api/v1/ws`(协议 v2,不做事件回放):`subscribe_all` 先下发每个 Agent 的当前状态,再推送所有 Agent 的概览事件(状态、活动、队列、对话框、通知);`focus` 选定正在查看的 Agent,只为它推送流式文本、时间线条目变化和用户 Shell 输出;`watch` 订阅已展开的运行中工具的输出;`subscribe_collab` 推送协作事件。
|
|
358
|
+
- 会话内容通过 HTTP 时间线读取:`GET /api/v1/agents/:id/timeline?limit&before` 返回条目摘要(正文较小或需要阅读的条目内联),`GET /api/v1/agents/:id/sessions/:sessionId/items/:itemId` 按需返回完整正文;已完成条目永不改变,响应带 `immutable` 缓存头,浏览器可永久缓存。
|
|
359
|
+
|
|
360
|
+
## System Prompt 与 Agent 模板
|
|
361
|
+
|
|
362
|
+
Web UI「配置 → 管理 System Prompt…」可以:
|
|
363
|
+
|
|
364
|
+
- 预览当前 Workspace / 工作路径下 Agent 实际会使用的 System Prompt,或忽略全局文件的 Pi 内置 Prompt;
|
|
365
|
+
- 编辑 Pi 自己的全局 `~/.pi/agent/SYSTEM.md`(替换 Pi 基础 Prompt)和 `APPEND_SYSTEM.md`(追加),这两个文件同样作用于 Pi CLI;受信任项目的 `.pi/SYSTEM.md`、`.pi/APPEND_SYSTEM.md` 优先;
|
|
366
|
+
- 管理 Agent 模板。
|
|
367
|
+
|
|
368
|
+
Agent 模板是 `~/.pi/pi2web/agent-templates/<id>.md` 文件,直接复制即可分享,手动放进目录的文件会立即出现:
|
|
369
|
+
|
|
370
|
+
```markdown
|
|
371
|
+
---
|
|
372
|
+
name: Code reviewer
|
|
373
|
+
description: 只审查,不修改文件
|
|
374
|
+
mode: append # append:追加在 Pi Prompt 之后;replace:替换 Pi 基础 Prompt(等同 SYSTEM.md)
|
|
375
|
+
---
|
|
376
|
+
You are a meticulous code reviewer. ...
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
新建 Agent 时可选择模板(默认是 Pi 自己的 Prompt)。模板内容在创建时复制到 Agent 记录中,**之后不能更改**:修改或删除模板文件不影响已有 Agent,Fork 出的 Agent 沿用同一模板。两种模式下 Pi 仍会加上 AGENTS.md 等项目上下文、skills 和 cwd;`replace` 会去掉 Pi 内置的工具说明、规则和文档段落,`APPEND_SYSTEM.md` 仍然生效。Agent 工具栏的「Prompt」按钮显示该 Agent 当前生效的完整 System Prompt。
|
|
341
380
|
|
|
342
381
|
运行时使用真实 `@earendil-works/pi-coding-agent` SDK,并复用 Pi CLI 的 `~/.pi/agent` 模型、认证和设置。`MockBackend` 只用于自动化测试。
|
|
343
382
|
|
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
|
+
}
|