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.
Files changed (51) hide show
  1. package/README.md +44 -5
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +518 -141
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +190 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +221 -45
  33. package/dist/server.js +444 -620
  34. package/dist/session-namer.js +66 -17
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +153 -35
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +11 -1
  43. package/packages/protocol/openapi.yaml +6 -2
  44. package/packages/protocol/websocket.schema.json +58 -15
  45. package/web/activity-view.js +121 -0
  46. package/web/app.js +3098 -743
  47. package/web/backend-state.js +59 -27
  48. package/web/collab.js +668 -255
  49. package/web/hid-keyboard.js +173 -140
  50. package/web/index.html +4 -1
  51. 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/remote-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-mini`、无 API Key。可用参数:
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
- Remote Pi 的服务端元数据保存在 `~/.pi/remote-pi/remote-pi.db` SQLite 数据库中:
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
- 灯效恢复使用服务器活动快照:`state.activity.bashIds` 表示在途的独立 Shell,`state.activity.dialogIds` 表示待回答的扩展对话框。这些 ID 只保存在服务进程内存中,不写入数据库或 Pi Session;LLM 的 `idle` 不代表它们已结束。回放缺口通过 `agent_snapshot.state` 恢复,随后按序补发快照构建期间的事件。每次订阅在回放完成后、实时事件开始前发送 `agent_state` 当前状态;首次 `fromNow` 订阅或已有游标但无新事件时也会发送,因此刷新页面无需等下一段 Shell 输出才能恢复灯效。旧服务端未提供 `activity` 时仍按普通 Agent 状态降级显示。不支持 WebHID 或安全上下文的浏览器会静默跳过自动连接,配置页保留原因说明。
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` 支持 sequence、replay、snapshot、`fromNow` 和 Agent command;`subscribe_collab` 推送协作事件。
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
 
@@ -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
+ }