@chanlerdev/scorel 0.0.1
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 +110 -0
- package/dist/index.js +6675 -0
- package/dist/index.js.map +7 -0
- package/docs/CHANGELOG.md +12 -0
- package/docs/README.md +116 -0
- package/docs/ROADMAP.md +669 -0
- package/docs/SHIP.md +242 -0
- package/docs/spec/channels.md +156 -0
- package/docs/spec/client.md +326 -0
- package/docs/spec/daemon.md +408 -0
- package/docs/spec/events.md +423 -0
- package/docs/spec/extensions.md +255 -0
- package/docs/spec/relay.md +391 -0
- package/docs/spec/runtime.md +251 -0
- package/docs/spec/session.md +380 -0
- package/docs/spec/ship/S0001-docs-baseline.md +41 -0
- package/docs/spec/ship/S0002-package-skeleton.md +56 -0
- package/docs/spec/ship/S0003-protocol-contracts.md +49 -0
- package/docs/spec/ship/S0004-session-core.md +50 -0
- package/docs/spec/ship/S0005-runtime-loop.md +48 -0
- package/docs/spec/ship/S0006-embedded-daemon-client.md +51 -0
- package/docs/spec/ship/S0007-cli-alpha.md +49 -0
- package/docs/spec/ship/S0008-coding-tools.md +107 -0
- package/docs/spec/ship/S0009-code-discovery-tools.md +82 -0
- package/docs/spec/ship/S0010-todo-tool-and-cli.md +81 -0
- package/docs/spec/ship/S0011-coding-agent-alpha-smoke.md +110 -0
- package/docs/spec/ship/S0012-coding-tools-maturity.md +143 -0
- package/docs/spec/ship/S0013-local-daemon-protocol.md +57 -0
- package/docs/spec/ship/S0014-local-daemon-lifecycle.md +64 -0
- package/docs/spec/ship/S0015-local-attach-and-broadcast.md +58 -0
- package/docs/spec/ship/S0016-local-daemon-resync-smoke.md +60 -0
- package/docs/spec/ship/S0017-grep-files-output-mode.md +49 -0
- package/docs/spec/ship/S0018-daemon-entrypoint-smoke.md +48 -0
- package/docs/spec/ship/S0019-remote-transport-contract.md +59 -0
- package/docs/spec/ship/S0020-remote-websocket-server.md +56 -0
- package/docs/spec/ship/S0021-remote-websocket-client-transport.md +55 -0
- package/docs/spec/ship/S0022-remote-daemon-cli-lifecycle.md +60 -0
- package/docs/spec/ship/S0023-remote-control-e2e-validation.md +66 -0
- package/docs/spec/ship/S0024-remote-attach-interactive-stream.md +49 -0
- package/docs/spec/ship/S0025-remote-attach-session-event-view.md +57 -0
- package/docs/spec/ship/S0026-attach-project-cache-and-dual-seq-reconnect.md +87 -0
- package/docs/spec/ship/S0027-session-diagnostics-log.md +77 -0
- package/docs/spec/ship/S0028-client-attach-diagnostics-log.md +70 -0
- package/docs/spec/ship/S0029-project-index-for-session-lookup.md +119 -0
- package/docs/spec/ship/S0030-webui-product-intent.md +73 -0
- package/docs/spec/ship/S0031-daemon-projectslug-rule.md +72 -0
- package/docs/spec/ship/S0032-daemon-protocol-completion.md +123 -0
- package/docs/spec/ship/S0033-webui-skeleton-routing.md +92 -0
- package/docs/spec/ship/S0034-webui-device-settings.md +121 -0
- package/docs/spec/ship/S0035-webui-device-handshake.md +83 -0
- package/docs/spec/ship/S0036-webui-project-session-sync.md +70 -0
- package/docs/spec/ship/S0037-webui-chatbox-v1.md +97 -0
- package/docs/spec/ship/S0038-webui-cancel-multiclient.md +65 -0
- package/docs/spec/ship/S0039-webui-e2e-newchat.md +74 -0
- package/docs/spec/ship/S0040-webui-codex-visual-tokens.md +227 -0
- package/docs/spec/ship/S0041-webui-markdown-and-tool-block.md +248 -0
- package/docs/spec/ship/S0042-webui-streaming-ux-autoscroll.md +130 -0
- package/docs/spec/ship/S0043-startup-ergonomics.md +278 -0
- package/docs/spec/ship/S0044-webui-chatbox-rebuild.md +556 -0
- package/docs/spec/ship/S0045-webui-card-sidebar-and-session-fixes.md +469 -0
- package/docs/spec/ship/S0046-webui-empty-composer-and-lazy-session.md +428 -0
- package/docs/spec/ship/S0047-webui-project-hover-newchat-and-dynamic-greeting.md +176 -0
- package/docs/spec/ship/S0048-device-level-host-project-registry.md +253 -0
- package/docs/spec/ship/S0049-webui-add-project-directory-browser.md +217 -0
- package/docs/spec/ship/S0050-instruction-snapshot-and-agents-assembly.md +338 -0
- package/docs/spec/ship/S0051-harness-item-and-system-reminder.md +190 -0
- package/docs/spec/ship/S0052-follow-up-queue-and-dual-loop.md +195 -0
- package/docs/spec/ship/S0053-skill-index-and-skill-tool.md +252 -0
- package/docs/spec/ship/S0054-webui-running-message-behavior.md +72 -0
- package/docs/spec/ship/S0055-webui-composer-acceptance-and-queue-strip.md +68 -0
- package/docs/spec/ship/S0056-relay-and-hosted-webui-contract.md +106 -0
- package/docs/spec/ship/S0057-relay-service-protocol-skeleton.md +161 -0
- package/docs/spec/ship/S0058-host-outbound-relay-and-pair-command.md +138 -0
- package/docs/spec/ship/S0059-relay-transport-and-hosted-webui-connector.md +140 -0
- package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.md +132 -0
- package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.verification.md +90 -0
- package/docs/spec/ship/S0061-hosted-defaults-and-cli-command-surface.md +208 -0
- package/docs/spec/ship/S0062-npm-package-and-release-workflow.md +166 -0
- package/docs/spec/tools.md +173 -0
- package/package.json +51 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# 统一事件模型
|
|
2
|
+
|
|
3
|
+
> 上游:`architecture.md`
|
|
4
|
+
> 主题:统一事件模型——JSONL 持久化 = 远程同步 = 本地状态驱动,一套机制覆盖全部。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 设计目标
|
|
9
|
+
|
|
10
|
+
当前实现的根本问题:**事件和存储是两套东西**。
|
|
11
|
+
|
|
12
|
+
- Runtime 事件(10 种,纯瞬态)→ 用于 UI 流式渲染
|
|
13
|
+
- LogEntry(4 种,持久化)→ 用于 JSONL 存储
|
|
14
|
+
|
|
15
|
+
这导致:
|
|
16
|
+
- Rewind/Branch 不是事件,无法同步给远端 client
|
|
17
|
+
- 没有 deviceId(谁做的操作?)
|
|
18
|
+
- 本地持久化和远程同步是两条路径
|
|
19
|
+
|
|
20
|
+
**目标:统一它们。**
|
|
21
|
+
|
|
22
|
+
核心公式:`JSONL 中的一行 = 一个 PersistentEvent = 远程同步的一个单元`
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 2. 两层事件模型
|
|
27
|
+
|
|
28
|
+
| 层 | 持久化 | 广播 | 有 id+parentId | 有 seq |
|
|
29
|
+
|---|---|---|---|---|
|
|
30
|
+
| **PersistentEvent** | ✅ 写入 JSONL | ✅ 广播所有 client | ✅ 构成树 | ✅ |
|
|
31
|
+
| **TransientEvent** | ❌ 不存储 | ✅ 广播所有 client | ❌ | ✅ |
|
|
32
|
+
|
|
33
|
+
**为什么分两层而不是全部持久化?**
|
|
34
|
+
|
|
35
|
+
流式 delta 每秒产生几十上百条,全部写 JSONL 会导致:
|
|
36
|
+
1. 文件膨胀 10-100x
|
|
37
|
+
2. 重连 replay 时间暴增
|
|
38
|
+
3. 对 LLM context 无意义
|
|
39
|
+
|
|
40
|
+
所以:**改变状态的 → 持久化;传递过程信息的 → 仅广播**。
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 3. 公共字段
|
|
45
|
+
|
|
46
|
+
### 3.1 身份模型
|
|
47
|
+
|
|
48
|
+
| 概念 | 含义 | 粒度 | 示例 |
|
|
49
|
+
|------|------|------|------|
|
|
50
|
+
| `deviceId` | Daemon 运行在哪台机器,session 的物理归属 | Session 级(SessionHeader) | `"vps-tokyo-01"` |
|
|
51
|
+
| `clientId` | 哪个连接端发起的操作 | Event 级(每个 event) | `"gui-mac-chanler"` / `"tg-bot-001"` |
|
|
52
|
+
| `sessionId` | 会话唯一标识 | Session 级 | `"ses_x7k2m"` |
|
|
53
|
+
|
|
54
|
+
**核心原则**:Session 属于 device(daemon 宿主机)。多个 client 连接到同一个 session,共享同一份数据。如果想把 session 搬到另一台机器,用 fork。
|
|
55
|
+
|
|
56
|
+
### 3.1.1 ID 与序号规则
|
|
57
|
+
|
|
58
|
+
随机稳定 ID 和单调序号分工明确:
|
|
59
|
+
|
|
60
|
+
- `sessionId`:默认由 daemon 随机生成(UUID / ULID / nanoid 均可),用于协议和存储引用;用户可读名称放在 title / meta,不把手写名称当主 ID。
|
|
61
|
+
- `event.id`:persistent event 的随机稳定 ID,不使用自增;tree、去重、transient finalization 都依赖它。
|
|
62
|
+
- `deviceId`:每台 daemon 宿主持久生成一次,重启后保持稳定;remote mirror 用它判断 session 物理归属。
|
|
63
|
+
- `clientId`:client 连接端 ID,可按 profile 持久或按连接生成;它用于审计/展示,不决定 session 权威。
|
|
64
|
+
- `seq`:每个 session 内由 daemon 单调递增,persistent 和 transient 共享;它只表达顺序和 reconnect anchor,不作为全局身份。
|
|
65
|
+
- UI 短号 / 本机 index:可以自增,但只服务本机选择和展示,不能进入协议身份。
|
|
66
|
+
|
|
67
|
+
因此,`sessionId` 需要尽量全局不碰撞;`seq` 才是适合自增的 per-session 顺序号。
|
|
68
|
+
|
|
69
|
+
### 3.2 PersistentEvent 基础结构
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
interface PersistentEventBase {
|
|
73
|
+
id: EventId; // 唯一标识,nanoid
|
|
74
|
+
parentId: EventId | null; // 树结构(null = 第一个事件)
|
|
75
|
+
seq: Seq; // Daemon 分配的递增序号(用于同步)
|
|
76
|
+
sessionId: SessionId; // 所属 session
|
|
77
|
+
clientId: ClientId; // 哪个 client 发起的操作
|
|
78
|
+
ts: number; // epoch ms
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3.3 TransientEvent 基础结构
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface TransientEventBase {
|
|
86
|
+
seq: Seq; // 与 PersistentEvent 共享同一个递增序列
|
|
87
|
+
sessionId: SessionId;
|
|
88
|
+
clientId: ClientId; // 哪个 client 触发的
|
|
89
|
+
ts: number;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**关键**:seq 是 per-session 统一递增的,PersistentEvent 和 TransientEvent 共享同一个序列。这意味着 client 通过 `lastSeq` 可以精确知道自己漏了哪些事件(无论类型)。
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 4. PersistentEvent 类型(8 种)
|
|
98
|
+
|
|
99
|
+
### 4.1 `message` — 对话内容
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
interface MessageEvent extends PersistentEventBase {
|
|
103
|
+
type: "message";
|
|
104
|
+
message: ScorelMessage; // user | assistant | tool_result | internal
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
最核心的事件。用户输入、助手回复、工具结果都是 message 事件。
|
|
109
|
+
|
|
110
|
+
### 4.2 `rewind` — 回退到某个点
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
interface RewindEvent extends PersistentEventBase {
|
|
114
|
+
type: "rewind";
|
|
115
|
+
targetEventId: EventId; // 回退到哪个事件(新的 active leaf)
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Rewind 本身也是一个事件节点,记录在树上。回退后,新消息 attach 到 `targetEventId`(而不是 rewind 事件本身)。
|
|
120
|
+
|
|
121
|
+
### 4.3 `branch` — 切换到另一个分支叶子
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
interface BranchEvent extends PersistentEventBase {
|
|
125
|
+
type: "branch";
|
|
126
|
+
leafEventId: EventId; // 要导航到的叶子
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
与 rewind 区别:rewind 是"回到过去重新来",branch 是"切换到已有的另一条分支"。
|
|
131
|
+
|
|
132
|
+
### 4.4 `compact` — 压缩上下文
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
interface CompactEvent extends PersistentEventBase {
|
|
136
|
+
type: "compact";
|
|
137
|
+
summary: string; // 摘要文本
|
|
138
|
+
compactedThrough: EventId; // 到此事件为止的内容被压缩
|
|
139
|
+
tokensBefore: number; // 压缩前 token 数
|
|
140
|
+
tokensAfter: number; // 压缩后 token 数
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
构建 context 时:从 leaf 往 root 走,遇到 CompactEvent → 注入 summary,停止继续向上。旧事件仍在 JSONL 中(可查阅),但不进入 LLM context。
|
|
145
|
+
|
|
146
|
+
### 4.5 `channel_inject` — 外部来源元数据
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
interface ChannelInjectEvent extends PersistentEventBase {
|
|
150
|
+
type: "channel_inject";
|
|
151
|
+
channel: string; // "telegram" | "wechat" | "cron" | "webhook"
|
|
152
|
+
externalId: string; // 外部系统中的 ID
|
|
153
|
+
metadata?: Record<string, unknown>;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
紧跟一个 MessageEvent。标记"这条消息来自 Telegram 群"。不进入 LLM context,仅审计用。
|
|
158
|
+
|
|
159
|
+
### 4.6 `session_info` — 元数据变更
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
interface SessionInfoEvent extends PersistentEventBase {
|
|
163
|
+
type: "session_info";
|
|
164
|
+
changes: Partial<SessionMeta>; // 只记 delta
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
模型切换、thinking level 变更、session 重命名等。累积 fold 所有 session_info 事件得到当前配置。
|
|
169
|
+
|
|
170
|
+
### 4.7 `custom` — 扩展数据(不进入 LLM context)
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
interface CustomEvent extends PersistentEventBase {
|
|
174
|
+
type: "custom";
|
|
175
|
+
kind: string; // 扩展命名空间
|
|
176
|
+
data: unknown;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Extension 用于存储自己的状态(书签、标注等)。Session reload 时 extension 扫描 `kind` 重建状态。
|
|
181
|
+
|
|
182
|
+
### 4.8 `custom_message` — 扩展数据(进入 LLM context)
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
interface CustomMessageEvent extends PersistentEventBase {
|
|
186
|
+
type: "custom_message";
|
|
187
|
+
kind: string;
|
|
188
|
+
message: ScorelMessage; // LLM 能看到的内容
|
|
189
|
+
data?: unknown; // 不给 LLM 的元数据
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
用于 RAG 注入、动态指令、记忆召回等。构建 context 时和普通 message 一样被包含。
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 5. TransientEvent 类型(12 种)
|
|
198
|
+
|
|
199
|
+
### 5.1 `message_start` — 预分配事件 ID
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
interface MessageStartEvent extends TransientEventBase {
|
|
203
|
+
type: "message_start";
|
|
204
|
+
eventId: EventId; // 预分配 → 最终 MessageEvent 使用同一个 id
|
|
205
|
+
parentId: EventId | null; // 最终在树上的位置
|
|
206
|
+
role: "assistant" | "tool_result";
|
|
207
|
+
model?: string;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**核心机制**:生成开始时就分配 id,后续所有 delta 引用它。Client 收到最终的 PersistentEvent(MessageEvent) 时,用 id 匹配替换 transient buffer。
|
|
212
|
+
|
|
213
|
+
### 5.2 流式 delta(3 种)
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
interface TextDeltaEvent extends TransientEventBase {
|
|
217
|
+
type: "text_delta";
|
|
218
|
+
eventId: EventId; // 引用 message_start 的 eventId
|
|
219
|
+
delta: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface ThinkingDeltaEvent extends TransientEventBase {
|
|
223
|
+
type: "thinking_delta";
|
|
224
|
+
eventId: EventId;
|
|
225
|
+
delta: string;
|
|
226
|
+
blockIndex: number; // 多个 thinking block 时区分
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface ToolCallDeltaEvent extends TransientEventBase {
|
|
230
|
+
type: "tool_call_delta";
|
|
231
|
+
eventId: EventId; // 引用 assistant message 的 eventId
|
|
232
|
+
toolCallId: string; // 这个 tool call 的唯一 id
|
|
233
|
+
toolName?: string; // 第一个 delta 包含名称
|
|
234
|
+
delta: string; // JSON 参数片段
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 5.3 工具执行(3 种)
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
interface ToolExecutionStartEvent extends TransientEventBase {
|
|
242
|
+
type: "tool_execution_start";
|
|
243
|
+
eventId: EventId; // 预分配 → 工具结果 MessageEvent 使用同一个 id
|
|
244
|
+
parentId: EventId; // 工具结果将挂在哪里
|
|
245
|
+
toolCallId: string;
|
|
246
|
+
toolName: string;
|
|
247
|
+
args: unknown;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface ToolExecutionUpdateEvent extends TransientEventBase {
|
|
251
|
+
type: "tool_execution_update";
|
|
252
|
+
eventId: EventId;
|
|
253
|
+
toolCallId: string;
|
|
254
|
+
partial: unknown; // 工具特定的中间输出
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface ToolExecutionEndEvent extends TransientEventBase {
|
|
258
|
+
type: "tool_execution_end";
|
|
259
|
+
eventId: EventId;
|
|
260
|
+
toolCallId: string;
|
|
261
|
+
toolName: string;
|
|
262
|
+
durationMs: number;
|
|
263
|
+
isError: boolean;
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### 5.4 生命周期(4 种)
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
interface TurnStartEvent extends TransientEventBase {
|
|
271
|
+
type: "turn_start";
|
|
272
|
+
turnIndex: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface TurnEndEvent extends TransientEventBase {
|
|
276
|
+
type: "turn_end";
|
|
277
|
+
turnIndex: number;
|
|
278
|
+
usage?: Usage;
|
|
279
|
+
stopReason?: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface RuntimeStartEvent extends TransientEventBase {
|
|
283
|
+
type: "runtime_start";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface RuntimeEndEvent extends TransientEventBase {
|
|
287
|
+
type: "runtime_end";
|
|
288
|
+
error?: string;
|
|
289
|
+
reason: "completed" | "cancelled" | "error";
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### 5.5 `message_cancelled` — 取消清理
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
interface MessageCancelledEvent extends TransientEventBase {
|
|
297
|
+
type: "message_cancelled";
|
|
298
|
+
eventId: EventId; // 预分配了但不会被持久化的 id
|
|
299
|
+
reason: "user_cancel" | "error" | "max_tokens";
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
用户取消生成时,预分配的 id 永远不会出现在 PersistentEvent 中。此事件告诉 client 清理 transient buffer。
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## 6. EventTypeHandler:双 Converter 模式
|
|
308
|
+
|
|
309
|
+
每种 PersistentEvent 类型注册两个 converter,决定该事件在不同消费场景下的呈现方式。核心逻辑(buildContext / UI 渲染)不 hardcode 任何事件类型的特殊行为——全靠 handler 声明。
|
|
310
|
+
|
|
311
|
+
### 6.1 接口
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
interface EventTypeHandler<T extends PersistentEvent> {
|
|
315
|
+
/** 构建 LLM context 时:这条事件怎么变成(或合入)LLM 消息 */
|
|
316
|
+
convertToLlm(event: T, ctx: LlmConvertContext): LlmAction;
|
|
317
|
+
|
|
318
|
+
/** 展示给用户时:这条事件怎么渲染 */
|
|
319
|
+
convertToDisplay(event: T, ctx: DisplayContext): DisplayAction;
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### 6.2 LlmAction
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
type LlmAction =
|
|
327
|
+
| { action: "include"; message: ScorelMessage } // 正常包含为一条消息
|
|
328
|
+
| { action: "merge_prev"; content: string } // 合入前一条消息(<system-reminder> 包裹)
|
|
329
|
+
| { action: "skip" } // 不包含在 LLM context 中
|
|
330
|
+
| { action: "barrier"; summary: ScorelMessage } // 替换上方所有消息,注入 summary,停止遍历
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### 6.3 各事件类型的 Handler 行为
|
|
334
|
+
|
|
335
|
+
| Event 类型 | convertToLlm | convertToDisplay |
|
|
336
|
+
|---|---|---|
|
|
337
|
+
| `message`(user/assistant/tool_result) | `include` — 原样包含 | 正常气泡 |
|
|
338
|
+
| `message`(meta.source = "steer") | `merge_prev` — 合入前一条 tool_result 的 `<system-reminder>`;无 tool_result 则 `include` 作为独立 user msg | 内联小字提示 |
|
|
339
|
+
| `message`(meta.source = "followUp") | 同 steer | 内联 "追加任务" 标记 |
|
|
340
|
+
| `rewind` | `skip` | "回退到此处" 标记 |
|
|
341
|
+
| `branch` | `skip` | "切换分支" 标记 |
|
|
342
|
+
| `compact` | `barrier` — 注入 summary,停止向上遍历 | "已压缩" 折叠块 |
|
|
343
|
+
| `channel_inject` | `skip` | 来源 badge "from Telegram" |
|
|
344
|
+
| `session_info` | `skip` | "模型切换为 X" 通知 |
|
|
345
|
+
| `custom` | `skip` | Extension 自定义 |
|
|
346
|
+
| `custom_message` | `include` — 包含 message | Extension 自定义 |
|
|
347
|
+
|
|
348
|
+
### 6.4 buildContext 通用遍历
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
function buildContext(tree: SessionTree, leafId: EventId): ScorelMessage[] {
|
|
352
|
+
const path = tree.getPath(leafId); // root → leaf
|
|
353
|
+
const messages: ScorelMessage[] = [];
|
|
354
|
+
|
|
355
|
+
// 从 leaf 往 root 走
|
|
356
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
357
|
+
const event = tree.get(path[i])!.event;
|
|
358
|
+
const handler = getHandler(event.type);
|
|
359
|
+
const result = handler.convertToLlm(event, ctx);
|
|
360
|
+
|
|
361
|
+
switch (result.action) {
|
|
362
|
+
case "include":
|
|
363
|
+
messages.unshift(result.message);
|
|
364
|
+
break;
|
|
365
|
+
case "merge_prev":
|
|
366
|
+
// 合入 messages 中最后一条 tool_result 的 content 末尾
|
|
367
|
+
mergeIntoPrevToolResult(messages, result.content);
|
|
368
|
+
break;
|
|
369
|
+
case "skip":
|
|
370
|
+
break;
|
|
371
|
+
case "barrier":
|
|
372
|
+
messages.unshift(result.summary);
|
|
373
|
+
return messages; // 停止遍历
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return messages;
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**核心不 hardcode 任何事件类型**。新增事件类型只需注册 handler。
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## 7. `<system-reminder>` 通用 Harness 注入机制
|
|
385
|
+
|
|
386
|
+
### 7.1 用途
|
|
387
|
+
|
|
388
|
+
`<system-reminder>` 是 Scorel harness 向 LLM 传递旁路信息的统一格式。所有非用户直接输入、但需要 LLM 看到的系统级内容都用此标签包裹。
|
|
389
|
+
|
|
390
|
+
### 7.2 使用场景
|
|
391
|
+
|
|
392
|
+
| 场景 | 注入内容 | 注入位置 |
|
|
393
|
+
|------|---------|---------|
|
|
394
|
+
| Steer(用户中途插话) | 用户的引导文字 | merge 进前一条 tool_result |
|
|
395
|
+
| Hook 上下文(UserPromptSubmit 等) | hook 产出 | user message / tool_result 末尾 |
|
|
396
|
+
| Memory 召回 | 记忆内容 | tool_result 末尾 |
|
|
397
|
+
| 系统提醒(超时、配额等) | 通知文本 | tool_result 末尾 |
|
|
398
|
+
| Channel 来源标注 | 来自哪个群/频道 | user message 内 |
|
|
399
|
+
|
|
400
|
+
### 7.3 格式
|
|
401
|
+
|
|
402
|
+
```xml
|
|
403
|
+
<system-reminder>
|
|
404
|
+
内容
|
|
405
|
+
</system-reminder>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### 7.4 注入规则
|
|
409
|
+
|
|
410
|
+
- **工具循环中**:merge 进最近一条 tool_result 的 content 末尾
|
|
411
|
+
- **无 tool_result 时(idle / turn 结束后)**:作为独立 user message(或附加到 user message 内)
|
|
412
|
+
|
|
413
|
+
### 7.5 LLM System Prompt 声明
|
|
414
|
+
|
|
415
|
+
LLM 在 system prompt 中被告知:
|
|
416
|
+
|
|
417
|
+
> Tool results and user messages may include `<system-reminder>` tags. These contain information from the system and bear no direct relation to the specific tool results or user messages in which they appear.
|
|
418
|
+
|
|
419
|
+
这确保 LLM 不会把 `<system-reminder>` 内容误解为工具输出或用户直接发言。
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
*统一事件模型的核心洞察:JSONL 中的一行 = 一个可同步的状态变更。本地持久化和远程同步共享同一个机制,不再是两套系统。EventTypeHandler 双 converter 让每种事件自声明行为,核心遍历逻辑无需 hardcode。*
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# 扩展点:Hooks、Extensions、Prompt 与配置
|
|
2
|
+
|
|
3
|
+
> 上游:`architecture.md`
|
|
4
|
+
> 主题:核心做减法,变化都收拢到这一层——Hook、Extension、System Prompt 组装、TOML 配置。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 设计目标
|
|
9
|
+
|
|
10
|
+
Scorel 核心要保持小而稳定,所有"怎么用"的决定应当外置。这份文档覆盖的四个机制都是"向外开口":
|
|
11
|
+
|
|
12
|
+
| 机制 | 面向 | 变化频率 |
|
|
13
|
+
|------|------|---------|
|
|
14
|
+
| Hooks | 核心 + Extension 都用 | 低,接口要稳定 |
|
|
15
|
+
| Extensions | 第三方 / 用户 | 高,动态加载 |
|
|
16
|
+
| System Prompt 组装 | 用户 + 项目 | 中 |
|
|
17
|
+
| 配置 | 用户 + 运维 | 高 |
|
|
18
|
+
|
|
19
|
+
四个机制都围绕 **同一条原则**:核心只定义"在哪里可以接",不定义"接什么"。
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. Hooks 系统
|
|
24
|
+
|
|
25
|
+
Scorel 的 Hooks 分两类:**原生 Hook**(来自 pi-agent-core,串行/拦截)和 **广播 Hook**(来自 `agent.subscribe`,并行/通知)。
|
|
26
|
+
|
|
27
|
+
### 2.1 原生 Hook(4 个,可阻断/可修改)
|
|
28
|
+
|
|
29
|
+
| Hook | 能力 | 典型用途 |
|
|
30
|
+
|------|------|---------|
|
|
31
|
+
| `beforeToolCall` | 拦截 / 修改工具调用 | 参数归一化;未来的权限审批 |
|
|
32
|
+
| `afterToolCall` | 覆盖工具结果 | 结果修正、日志审计 |
|
|
33
|
+
| `transformContext` | 修改消息列表 | 压缩、rewind 解析;未来的记忆注入 |
|
|
34
|
+
| `convertToLlm` | 过滤消息送 LLM | 隔离应用层自定义消息 |
|
|
35
|
+
|
|
36
|
+
**组合规则**:同一个原生 hook 不允许多个 Extension 并行覆盖,必须**链式包装**——按 Extension 声明顺序串联,每层都能决定是否继续向下传。
|
|
37
|
+
|
|
38
|
+
### 2.2 广播 Hook(基于 `subscribe`)
|
|
39
|
+
|
|
40
|
+
在 pi-agent-core 的 11 种 `AgentEvent` 之上扩展 Scorel 语义事件:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
type ScorelEvent =
|
|
44
|
+
| AgentEvent // 透传 pi-agent-core
|
|
45
|
+
| { type: 'session_start'; sessionId: string }
|
|
46
|
+
| { type: 'session_end'; sessionId: string; reason: string }
|
|
47
|
+
| { type: 'prompt_submit'; sessionId: string; prompt: string }
|
|
48
|
+
| { type: 'turn_finish'; sessionId: string; usage: Usage; duration: number }
|
|
49
|
+
| { type: 'turn_error'; sessionId: string; error: Error }
|
|
50
|
+
| { type: 'rewind'; sessionId: string; targetId: string }
|
|
51
|
+
| { type: 'compact'; sessionId: string; tokensBefore: number; tokensAfter: number };
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
广播 Hook **并行执行、错误隔离**,单个订阅失败不影响其他。
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 3. Extensions 系统
|
|
59
|
+
|
|
60
|
+
### 3.1 接口
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
interface Extension {
|
|
64
|
+
id: string;
|
|
65
|
+
name: string;
|
|
66
|
+
version: string;
|
|
67
|
+
|
|
68
|
+
activate?(ctx: ExtensionContext): Promise<void>;
|
|
69
|
+
deactivate?(): Promise<void>;
|
|
70
|
+
|
|
71
|
+
tools?(): AgentTool[]; // 追加工具
|
|
72
|
+
commands?(): Record<string, SlashCommand>; // 追加斜杠命令
|
|
73
|
+
onEvent?(event: ScorelEvent, ctx: ExtensionContext): Promise<void>;
|
|
74
|
+
hooks?(): Partial<{
|
|
75
|
+
beforeToolCall: BeforeToolCallHook;
|
|
76
|
+
afterToolCall: AfterToolCallHook;
|
|
77
|
+
transformContext: TransformContextHook;
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ExtensionContext {
|
|
82
|
+
readonly agent: Agent;
|
|
83
|
+
readonly session: SessionStore;
|
|
84
|
+
readonly config: Config;
|
|
85
|
+
readonly logger: Logger;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Extension 可以注入四种能力:工具、斜杠命令、事件监听、原生 Hook。四种都不是必选,一个扩展可以只做最小的一件事。
|
|
90
|
+
|
|
91
|
+
### 3.2 加载路径与时机
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
~/.scorel/extensions/ ← 全局
|
|
95
|
+
.scorel/extensions/ ← 项目级
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
启动时扫描 `.ts` / `.js`,动态 `import()`,调用 `activate()`。项目级覆盖全局。
|
|
99
|
+
|
|
100
|
+
### 3.3 错误隔离
|
|
101
|
+
|
|
102
|
+
广播事件使用 `Promise.allSettled` + 单 Extension try/catch:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
class ExtensionRunner {
|
|
106
|
+
async emit(event: ScorelEvent): Promise<void> {
|
|
107
|
+
await Promise.allSettled(
|
|
108
|
+
this.extensions.map(async (ext) => {
|
|
109
|
+
try {
|
|
110
|
+
await ext.onEvent?.(event, this.ctx(ext));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger.error(`Extension ${ext.id} failed on ${event.type}:`, err);
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**单个 Extension 挂掉绝不影响核心和其他 Extension。** 原生 Hook 的链式包装里同样有 try/catch 兜底,但拦截失败会直接跳过该层。
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 4. System Prompt 组装
|
|
125
|
+
|
|
126
|
+
System Prompt 是若干内容块按优先级和预算拼出来的:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
class PromptBuilder {
|
|
130
|
+
build(ctx: { cwd: string; model: Model; tools: AgentTool[]; mcp: McpTool[] }): string {
|
|
131
|
+
const parts = [
|
|
132
|
+
{ priority: 100, content: SCOREL_BASE_PROMPT }, // 行为规范
|
|
133
|
+
{ priority: 90, content: renderContextVars(ctx) }, // cwd / date / git
|
|
134
|
+
{ priority: 80, content: this.config.userPrompt }, // 用户自定义
|
|
135
|
+
{ priority: 70, content: await loadProjectInstructions(ctx.cwd) }, // .scorel/instructions.md
|
|
136
|
+
{ priority: 60, content: renderToolDescriptions(ctx.tools) },
|
|
137
|
+
{ priority: 50, content: renderMcpDescriptions(ctx.mcp) },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const budget = ctx.model.contextWindow * 0.15;
|
|
141
|
+
return assembleWithBudget(parts.sort((a, b) => b.priority - a.priority), budget);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**预算策略**:按优先级从高到低拼,超预算时截断低优先级(通常是 MCP 描述和部分工具描述)。
|
|
147
|
+
|
|
148
|
+
初期的行为规范模板和用户自定义格式先定死一版。如果 Extension 想插入内容,应当通过 `transformContext` 注入到首条 user message 里,**不改 System Prompt**——保持 prompt cache 友好。
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 5. 配置系统
|
|
153
|
+
|
|
154
|
+
### 5.1 格式与优先级
|
|
155
|
+
|
|
156
|
+
**格式**:TOML
|
|
157
|
+
|
|
158
|
+
**读取顺序**(高 → 低):
|
|
159
|
+
1. `.scorel/config.toml`(项目级)
|
|
160
|
+
2. `~/.scorel/config.toml`(用户级)
|
|
161
|
+
|
|
162
|
+
`~/.scorel` 是固定用户级根目录;`~/.scorel/config.toml` 是固定用户级配置文件;JSONL session 默认固定写入 `~/.scorel/sessions`。这些路径是产品约定,不作为可配置字段暴露。测试和内部嵌入场景可以通过代码注入临时 session 目录,但这不是用户配置面。
|
|
163
|
+
|
|
164
|
+
环境变量不参与通用配置覆盖,只通过 `apiKeyEnv` 指向具体密钥。CLI 参数也不复制完整 config schema;只有明确属于交互控制的参数才进入 CLI。
|
|
165
|
+
|
|
166
|
+
### 5.2 初期配置示例
|
|
167
|
+
|
|
168
|
+
pi-ai 已支持的内置 provider 使用 `type = "builtin"`,Scorel 只把 provider/model/api key 交给 pi-ai,不在本仓库重写 provider 协议:
|
|
169
|
+
|
|
170
|
+
```toml
|
|
171
|
+
[model]
|
|
172
|
+
type = "builtin"
|
|
173
|
+
provider = "openai"
|
|
174
|
+
id = "gpt-5.4-mini"
|
|
175
|
+
apiKeyEnv = "SCOREL_API_KEY"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
自定义兼容 endpoint 使用 `type = "custom"`,并显式声明兼容协议。这个分支和 builtin provider 分开,避免把任意自定义 endpoint 混成 pi-ai 内置 provider:
|
|
179
|
+
|
|
180
|
+
```toml
|
|
181
|
+
[model]
|
|
182
|
+
type = "custom"
|
|
183
|
+
api = "openai-completions"
|
|
184
|
+
provider = "chanleramp"
|
|
185
|
+
id = "gpt-5.4-mini"
|
|
186
|
+
baseUrl = "https://amp.chanler.dev/v1"
|
|
187
|
+
apiKeyEnv = "SCOREL_API_KEY"
|
|
188
|
+
contextWindow = 400000
|
|
189
|
+
maxTokens = 128000
|
|
190
|
+
reasoning = true
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`api` 初期只接受以下四个值:
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
openai-completions
|
|
197
|
+
openai-responses
|
|
198
|
+
google-generative-ai
|
|
199
|
+
anthropic-messages
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
工具配置和扩展配置在对应模块稳定后进入同一个 schema:
|
|
203
|
+
|
|
204
|
+
```toml
|
|
205
|
+
[tools]
|
|
206
|
+
preset = "coding"
|
|
207
|
+
|
|
208
|
+
[channels]
|
|
209
|
+
enabled = ["cli"]
|
|
210
|
+
|
|
211
|
+
[mcp]
|
|
212
|
+
[[mcp.servers]]
|
|
213
|
+
name = "github"
|
|
214
|
+
transport = "stdio"
|
|
215
|
+
command = "mcp-server-github"
|
|
216
|
+
|
|
217
|
+
[extensions]
|
|
218
|
+
disabled = ["experimental-memory"]
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### 5.3 Schema 规则
|
|
222
|
+
|
|
223
|
+
配置必须通过 `SCOREL_CONFIG_SCHEMA` 校验。未知 section 和未知 key 都应由通用 schema 校验拒绝,例如根级 `sessionsDir = "/tmp/nope"` 必须报 `Unsupported config key: sessionsDir`,不能为单个字段写特殊判断。
|
|
224
|
+
|
|
225
|
+
当前已落地的稳定 section 只有 `[model]`。未来新增 `[tools]` / `[mcp]` / `[extensions]` 时,必须先把字段加入 schema,再接入加载逻辑和测试。
|
|
226
|
+
|
|
227
|
+
### 5.4 延后段落
|
|
228
|
+
|
|
229
|
+
- `[permissions]` — 权限策略。初期工具默认全允许,权限审批整体后补
|
|
230
|
+
- `[channels.telegram]` / `[channels.wechat]` — IM Channel 配置
|
|
231
|
+
- `[mcp.servers.*.tier]` / `keywords` — MCP 分级加载配置
|
|
232
|
+
|
|
233
|
+
这些段落的 schema 会在对应模块落地时补入,初期不预留任何半成品字段。
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 6. 初期范围与延后项
|
|
238
|
+
|
|
239
|
+
**初期落地**
|
|
240
|
+
- 4 个原生 Hook 暴露给 Extension(链式包装)
|
|
241
|
+
- 广播事件:`turn_finish` / `turn_error` / `rewind` / `compact`
|
|
242
|
+
- Extension 加载 + 错误隔离(`tools` / `commands` / `onEvent`)
|
|
243
|
+
- System Prompt 组装与预算
|
|
244
|
+
- TOML 配置的核心段:`[model]`,并通过 `SCOREL_CONFIG_SCHEMA` 统一拒绝未知 section/key
|
|
245
|
+
|
|
246
|
+
**延后**
|
|
247
|
+
- Extension 的沙箱与权限边界(现阶段信任本地扩展)
|
|
248
|
+
- Prompt 的模板化与多语种切换
|
|
249
|
+
- 热更新配置(初期启动时读一次即可)
|
|
250
|
+
- `[tools]` / `[channels]` / `[mcp]` / `[extensions]` 的完整配置 schema
|
|
251
|
+
- Skills 加载(`~/.scorel/skills/*/SKILL.md`)
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
*所有"用户可定制"的口子都收在这一层。核心 API 稳定后,新能力优先作为 Extension 而不是核心改动。*
|