@oyasmi/pipiclaw 0.2.1 → 0.3.0
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/CHANGELOG.md +11 -0
- package/README.md +48 -16
- package/dist/agent.d.ts +2 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +119 -106
- package/dist/agent.js.map +1 -1
- package/dist/command-extension.d.ts +14 -0
- package/dist/command-extension.d.ts.map +1 -0
- package/dist/command-extension.js +112 -0
- package/dist/command-extension.js.map +1 -0
- package/dist/commands.d.ts +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +22 -25
- package/dist/commands.js.map +1 -1
- package/dist/config-loader.d.ts +2 -6
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/config-loader.js +4 -60
- package/dist/config-loader.js.map +1 -1
- package/dist/context.d.ts +4 -23
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +7 -122
- package/dist/context.js.map +1 -1
- package/dist/dingtalk.d.ts.map +1 -1
- package/dist/dingtalk.js +5 -0
- package/dist/dingtalk.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +27 -15
- package/dist/main.js.map +1 -1
- package/dist/memory-consolidation.d.ts +22 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +258 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-files.d.ts +24 -0
- package/dist/memory-files.d.ts.map +1 -0
- package/dist/memory-files.js +131 -0
- package/dist/memory-files.js.map +1 -0
- package/dist/memory-lifecycle.d.ts +27 -0
- package/dist/memory-lifecycle.d.ts.map +1 -0
- package/dist/memory-lifecycle.js +85 -0
- package/dist/memory-lifecycle.js.map +1 -0
- package/dist/prompt-builder.d.ts +1 -2
- package/dist/prompt-builder.d.ts.map +1 -1
- package/dist/prompt-builder.js +35 -88
- package/dist/prompt-builder.js.map +1 -1
- package/dist/store.d.ts +2 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -1
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
- Periodic memory consolidation event template in README
|
|
12
12
|
- DingTalk channel now intercepts `/help`, `/new`, `/compact`, `/session`, and `/model` as built-in slash commands instead of sending them to the LLM
|
|
13
13
|
- DingTalk channel now supports busy-time steering controls: plain messages default to steer, and `/steer`, `/followup`, and `/stop` are handled directly while a task is running
|
|
14
|
+
- Channel-level `HISTORY.md` for runtime-managed summarized history
|
|
15
|
+
- Runtime memory consolidation pipeline for channel `MEMORY.md` and `HISTORY.md`
|
|
14
16
|
|
|
15
17
|
### Changed
|
|
16
18
|
|
|
@@ -31,3 +33,12 @@
|
|
|
31
33
|
- Auto-generated `models.json` now starts as an empty valid config, and `SOUL.md` / `AGENTS.md` are guidance templates instead of prefilled behavior
|
|
32
34
|
- Global pipiclaw settings now live in `~/.pi/pipiclaw/settings.json`, and saved default models are restored on restart
|
|
33
35
|
- DingTalk channel configuration is now read from `~/.pi/pipiclaw/channel.json`
|
|
36
|
+
- Workspace `SOUL.md` and `AGENTS.md` now load only at session start instead of every turn
|
|
37
|
+
- Workspace and channel memory files are no longer injected into the system prompt by default
|
|
38
|
+
- `log.jsonl` and `context.jsonl` are now treated as cold raw storage and are no longer proactively scanned for memory/context loading
|
|
39
|
+
- Channel memory consolidation now hooks into AgentSession compaction and session-switch lifecycle without replacing AgentSession auto-compaction
|
|
40
|
+
- Session commands and prompt loading now run through pi extension/resource hooks instead of parallel pipiclaw-specific prompt plumbing
|
|
41
|
+
- Pipiclaw now reloads AgentSession resources before the first turn so `SOUL.md`, `AGENTS.md`, and skill summaries apply immediately
|
|
42
|
+
- Workspace `SOUL.md` now appends to the default pi system prompt instead of replacing the pi base prompt
|
|
43
|
+
- Channel directories now create `MEMORY.md` and `HISTORY.md` immediately when the channel state is initialized
|
|
44
|
+
- Workspace and channel memory templates are now structured around stable shared context instead of placeholder comments only
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pipiclaw
|
|
2
2
|
|
|
3
|
-
Pipiclaw
|
|
3
|
+
Pipiclaw 是一个 AI 智能体,把 [pi-coding-agent](../coding-agent) 带到钉钉对话里,支持过程性 AI 卡片、最终 Markdown 回复、内置 Slash 命令、技能扩展和定时事件。
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
@@ -9,8 +9,9 @@ Pipiclaw 是一个接入钉钉的 AI Card 机器人,把 [pi-coding-agent](../c
|
|
|
9
9
|
- 内置 Slash 命令:`/help`、`/new`、`/compact`、`/session`、`/model`
|
|
10
10
|
- 忙碌时默认将普通新消息作为 steer 送入当前任务,也支持显式 `/steer`、`/followup`、`/stop`
|
|
11
11
|
- 每个 DM / 群聊独立工作空间
|
|
12
|
-
-
|
|
12
|
+
- 支持 workspace 级 `SOUL.md`、`AGENTS.md`、`MEMORY.md`
|
|
13
13
|
- 支持全局和频道级技能目录
|
|
14
|
+
- 支持 runtime-managed 的频道级 `MEMORY.md` / `HISTORY.md`
|
|
14
15
|
- 支持 immediate / one-shot / periodic 定时事件
|
|
15
16
|
- 支持自定义模型配置和模型切换
|
|
16
17
|
|
|
@@ -145,7 +146,10 @@ pipiclaw --sandbox=docker:your-container
|
|
|
145
146
|
|
|
146
147
|
## 内置 Slash 命令
|
|
147
148
|
|
|
148
|
-
|
|
149
|
+
Pipiclaw 暴露两层命令:
|
|
150
|
+
|
|
151
|
+
- transport 层命令:由 DingTalk runtime 直接处理
|
|
152
|
+
- session 层命令:由 `AgentSession` extension command 立即执行,不作为普通 prompt 发给模型
|
|
149
153
|
|
|
150
154
|
### 空闲时可用
|
|
151
155
|
|
|
@@ -183,7 +187,7 @@ pipiclaw --sandbox=docker:your-container
|
|
|
183
187
|
- busy 时普通消息默认等价于 `/steer <message>`
|
|
184
188
|
- `/steer` 更适合纠偏、补充限制条件、修改当前任务方向
|
|
185
189
|
- `/followup` 更适合“等这件事做完,再继续做下一件事”
|
|
186
|
-
-
|
|
190
|
+
- busy 时,其他 slash 输入不会执行;只允许 `/help`、`/stop`、`/steer`、`/followup`
|
|
187
191
|
|
|
188
192
|
## Workspace Files
|
|
189
193
|
|
|
@@ -208,23 +212,26 @@ Pipiclaw 同时支持:
|
|
|
208
212
|
|
|
209
213
|
| 名称 | 全局位置 | 渠道级位置 | 生效方式 |
|
|
210
214
|
|------|----------|------------|----------|
|
|
211
|
-
| `SOUL.md` | `workspace/SOUL.md` | 不支持 |
|
|
212
|
-
| `AGENTS.md` | `workspace/AGENTS.md` |
|
|
213
|
-
| `MEMORY.md` | `workspace/MEMORY.md` | `<channel>/MEMORY.md` |
|
|
214
|
-
| `
|
|
215
|
+
| `SOUL.md` | `workspace/SOUL.md` | 不支持 | 仅在 session 开始时加载全局文件。渠道级 `SOUL.md` 不会被读取。 |
|
|
216
|
+
| `AGENTS.md` | `workspace/AGENTS.md` | 不支持 | 仅在 session 开始时加载全局文件。渠道级 `AGENTS.md` 不会被读取。 |
|
|
217
|
+
| `MEMORY.md` | `workspace/MEMORY.md` | `<channel>/MEMORY.md` | 默认都不会直接加载进上下文。workspace 文件稳定且由管理员维护;channel 文件由 runtime consolidation 自动更新,也允许 agent 主动读写。 |
|
|
218
|
+
| `HISTORY.md` | 不支持 | `<channel>/HISTORY.md` | 默认不会直接加载进上下文。由 runtime consolidation 自动维护,用于按需读取旧摘要。 |
|
|
219
|
+
| `skills/` | `workspace/skills/` | `<channel>/skills/` | 两边的 skill 摘要会在 session 开始时进入上下文;如果同名,渠道级覆盖全局。具体 skill 内容仍由 agent 按需读取。 |
|
|
215
220
|
| `events/` | `workspace/events/` | 不支持 | 仅支持全局事件目录。 |
|
|
216
221
|
| `.channel-meta.json` | 不支持 | `<channel>/.channel-meta.json` | 运行时自动维护,用于主动发送和重启恢复,不建议手工编辑。 |
|
|
217
|
-
| `context.jsonl` | 不支持 | `<channel>/context.jsonl` |
|
|
218
|
-
| `log.jsonl` | 不支持 | `<channel>/log.jsonl` |
|
|
222
|
+
| `context.jsonl` | 不支持 | `<channel>/context.jsonl` | 原始 session 存储,冷文件,不主动加载或扫描。 |
|
|
223
|
+
| `log.jsonl` | 不支持 | `<channel>/log.jsonl` | 原始消息存储,冷文件,不主动加载或扫描。 |
|
|
219
224
|
|
|
220
225
|
### File Intent
|
|
221
226
|
|
|
222
227
|
- `SOUL.md`
|
|
223
|
-
定义 Pipiclaw
|
|
228
|
+
定义 Pipiclaw 的身份、语气、默认语言和回复风格。它会追加到 pi 默认底座 prompt 之后。首次运行生成的只是说明模板,你需要替换成真实内容。
|
|
224
229
|
- `AGENTS.md`
|
|
225
|
-
|
|
230
|
+
定义行为规则、工具使用策略、安全约束和项目工作流。只读取 workspace 级文件。不要把 runtime 内建的记忆系统细节完整复制到这里。
|
|
226
231
|
- `MEMORY.md`
|
|
227
|
-
|
|
232
|
+
定义持久记忆。workspace 级文件适合存稳定共享背景,由管理员维护;channel 级文件适合存 durable facts、ongoing work、decisions、open loops,并由 runtime consolidation 自动维护。
|
|
233
|
+
- `HISTORY.md`
|
|
234
|
+
仅存在于 channel 目录。保存旧上下文的摘要历史,由 runtime consolidation 自动维护。
|
|
228
235
|
- `skills/`
|
|
229
236
|
存放自定义技能。适合放可复用的 CLI 工具、脚本和 skill 说明。
|
|
230
237
|
- `events/`
|
|
@@ -245,14 +252,40 @@ Pipiclaw 同时支持:
|
|
|
245
252
|
├── skills/
|
|
246
253
|
├── events/
|
|
247
254
|
└── dm_{userId}/
|
|
248
|
-
├── AGENTS.md
|
|
249
255
|
├── MEMORY.md
|
|
256
|
+
├── HISTORY.md
|
|
250
257
|
├── .channel-meta.json
|
|
251
258
|
├── context.jsonl
|
|
252
259
|
├── log.jsonl
|
|
253
260
|
└── skills/
|
|
254
261
|
```
|
|
255
262
|
|
|
263
|
+
## 记忆模型
|
|
264
|
+
|
|
265
|
+
Pipiclaw 的默认 session 上下文只直接加载这些内容:
|
|
266
|
+
|
|
267
|
+
- pi 默认底座 system prompt
|
|
268
|
+
- workspace 级 `SOUL.md`
|
|
269
|
+
- workspace 级 `AGENTS.md`
|
|
270
|
+
- 内置工具说明
|
|
271
|
+
- workspace 和 channel 两层 skills 的摘要
|
|
272
|
+
|
|
273
|
+
这些文件不会默认直接进入上下文:
|
|
274
|
+
|
|
275
|
+
- `workspace/MEMORY.md`
|
|
276
|
+
- `<channel>/MEMORY.md`
|
|
277
|
+
- `<channel>/HISTORY.md`
|
|
278
|
+
- `<channel>/log.jsonl`
|
|
279
|
+
- `<channel>/context.jsonl`
|
|
280
|
+
|
|
281
|
+
说明:
|
|
282
|
+
|
|
283
|
+
- `workspace/MEMORY.md` 是稳定共享背景,由管理员维护,runtime 不会自动改写。
|
|
284
|
+
- `<channel>/MEMORY.md` 和 `<channel>/HISTORY.md` 由 runtime 在 compaction 或 session trimming 前自动 consolidation。
|
|
285
|
+
- agent 被鼓励在需要时主动读取 channel memory/history。
|
|
286
|
+
- `log.jsonl` 和 `context.jsonl` 是冷存储,只做原始归档,不承担记忆角色。
|
|
287
|
+
- channel 目录首次初始化时会立即创建 `MEMORY.md` 和 `HISTORY.md`,而不是等到首次 consolidation 再懒创建。
|
|
288
|
+
|
|
256
289
|
## 定时事件
|
|
257
290
|
|
|
258
291
|
在 `~/.pi/pipiclaw/workspace/events/` 中创建 JSON 文件来触发定时任务:
|
|
@@ -285,6 +318,5 @@ Pipiclaw 同时支持:
|
|
|
285
318
|
|
|
286
319
|
```bash
|
|
287
320
|
npm install
|
|
288
|
-
npm run
|
|
289
|
-
npm run dev
|
|
321
|
+
npm run check
|
|
290
322
|
```
|
package/dist/agent.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ export interface AgentRunner {
|
|
|
8
8
|
errorMessage?: string;
|
|
9
9
|
}>;
|
|
10
10
|
handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;
|
|
11
|
-
queueSteer(text: string): Promise<void>;
|
|
12
|
-
queueFollowUp(text: string): Promise<void>;
|
|
11
|
+
queueSteer(text: string, userName?: string): Promise<void>;
|
|
12
|
+
queueFollowUp(text: string, userName?: string): Promise<void>;
|
|
13
13
|
abort(): void;
|
|
14
14
|
}
|
|
15
15
|
export declare function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner;
|
package/dist/agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AAGvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAUrD,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAO/C,MAAM,WAAW,WAAW;IAC3B,GAAG,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvG,oBAAoB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnF,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,KAAK,IAAI,IAAI,CAAC;CACd;AAitBD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAOlH","sourcesContent":["import { Agent } from \"@mariozechner/pi-agent-core\";\nimport type { Api, Model } from \"@mariozechner/pi-ai\";\nimport {\n\tAgentSession,\n\tAuthStorage,\n\tconvertToLlm,\n\tDefaultResourceLoader,\n\tModelRegistry,\n\tSessionManager,\n\ttype Skill,\n} from \"@mariozechner/pi-coding-agent\";\nimport { mkdir, writeFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport { type BuiltInCommand, renderBuiltInHelp } from \"./commands.js\";\nimport { getAgentConfig, getApiKeyForModel, getMemory, getSoul, loadPipiclawSkills } from \"./config-loader.js\";\nimport { PipiclawSettingsManager, syncLogToSessionManager } from \"./context.js\";\nimport type { DingTalkContext } from \"./dingtalk.js\";\nimport * as log from \"./log.js\";\nimport {\n\tfindExactModelReferenceMatch,\n\tformatModelList,\n\tformatModelReference,\n\tresolveInitialModel,\n} from \"./model-utils.js\";\nimport { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from \"./paths.js\";\nimport { buildSystemPrompt } from \"./prompt-builder.js\";\nimport { createExecutor, type SandboxConfig } from \"./sandbox.js\";\nimport type { ChannelStore } from \"./store.js\";\nimport { createPipiclawTools } from \"./tools/index.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface AgentRunner {\n\trun(ctx: DingTalkContext, store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }>;\n\thandleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;\n\tqueueSteer(text: string): Promise<void>;\n\tqueueFollowUp(text: string): Promise<void>;\n\tabort(): void;\n}\n\ntype FinalOutcome = { kind: \"none\" } | { kind: \"silent\" } | { kind: \"final\"; text: string };\n\nfunction isSilentOutcome(outcome: FinalOutcome): outcome is { kind: \"silent\" } {\n\treturn outcome.kind === \"silent\";\n}\n\nfunction isFinalOutcome(outcome: FinalOutcome): outcome is { kind: \"final\"; text: string } {\n\treturn outcome.kind === \"final\";\n}\n\nfunction getFinalOutcomeText(outcome: FinalOutcome): string | null {\n\treturn isFinalOutcome(outcome) ? outcome.text : null;\n}\n\n// ============================================================================\n// Text helpers\n// ============================================================================\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn `${text.substring(0, maxLen - 3)}...`;\n}\n\nfunction sanitizeProgressText(text: string): string {\n\treturn text\n\t\t.replace(/\\uFFFC/g, \"\")\n\t\t.replace(/\\r/g, \"\")\n\t\t.trim();\n}\n\nfunction formatProgressEntry(kind: \"tool\" | \"thinking\" | \"error\" | \"assistant\", text: string): string {\n\tconst cleaned = sanitizeProgressText(text);\n\tif (!cleaned) return \"\";\n\n\tconst normalized = cleaned.replace(/\\n+/g, \" \").trim();\n\tswitch (kind) {\n\t\tcase \"tool\":\n\t\t\treturn `Running: ${normalized}`;\n\t\tcase \"thinking\":\n\t\t\treturn `Thinking: ${normalized}`;\n\t\tcase \"error\":\n\t\t\treturn `Error: ${normalized}`;\n\t\tcase \"assistant\":\n\t\t\treturn normalized;\n\t}\n}\n\nfunction extractToolResultText(result: unknown): string {\n\tif (typeof result === \"string\") {\n\t\treturn result;\n\t}\n\n\tif (\n\t\tresult &&\n\t\ttypeof result === \"object\" &&\n\t\t\"content\" in result &&\n\t\tArray.isArray((result as { content: unknown }).content)\n\t) {\n\t\tconst content = (result as { content: Array<{ type: string; text?: string }> }).content;\n\t\tconst textParts: string[] = [];\n\t\tfor (const part of content) {\n\t\t\tif (part.type === \"text\" && part.text) {\n\t\t\t\ttextParts.push(part.text);\n\t\t\t}\n\t\t}\n\t\tif (textParts.length > 0) {\n\t\t\treturn textParts.join(\"\\n\");\n\t\t}\n\t}\n\n\treturn JSON.stringify(result);\n}\n\n// ============================================================================\n// Run State\n// ============================================================================\n\ninterface PendingTool {\n\ttoolName: string;\n\targs: unknown;\n\tstartTime: number;\n}\n\ninterface UsageTotals {\n\tinput: number;\n\toutput: number;\n\tcacheRead: number;\n\tcacheWrite: number;\n\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n}\n\ninterface RunQueue {\n\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n}\n\ninterface RunState {\n\tctx: DingTalkContext | null;\n\tlogCtx: { channelId: string; userName?: string; channelName?: string } | null;\n\tqueue: RunQueue | null;\n\tpendingTools: Map<string, PendingTool>;\n\ttotalUsage: UsageTotals;\n\tstopReason: string;\n\terrorMessage: string | undefined;\n\tfinalOutcome: FinalOutcome;\n\tfinalResponseDelivered: boolean;\n}\n\nfunction createEmptyRunState(): RunState {\n\treturn {\n\t\tctx: null,\n\t\tlogCtx: null,\n\t\tqueue: null,\n\t\tpendingTools: new Map(),\n\t\ttotalUsage: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t},\n\t\tstopReason: \"stop\",\n\t\terrorMessage: undefined,\n\t\tfinalOutcome: { kind: \"none\" },\n\t\tfinalResponseDelivered: false,\n\t};\n}\n\n// ============================================================================\n// ChannelRunner\n// ============================================================================\n\nclass ChannelRunner implements AgentRunner {\n\t// --- Constructed once ---\n\tprivate readonly sandboxConfig: SandboxConfig;\n\tprivate readonly channelId: string;\n\tprivate readonly channelDir: string;\n\tprivate readonly workspacePath: string;\n\tprivate readonly workspaceDir: string;\n\tprivate readonly session: AgentSession;\n\tprivate readonly agent: Agent;\n\tprivate readonly sessionManager: SessionManager;\n\tprivate readonly settingsManager: PipiclawSettingsManager;\n\tprivate readonly modelRegistry: ModelRegistry;\n\n\t// --- Mutable across runs ---\n\tprivate activeModel: Model<Api>;\n\tprivate currentSkills: Skill[];\n\n\t// --- Per run ---\n\tprivate runState: RunState = createEmptyRunState();\n\n\tconstructor(sandboxConfig: SandboxConfig, channelId: string, channelDir: string) {\n\t\tthis.sandboxConfig = sandboxConfig;\n\t\tthis.channelId = channelId;\n\t\tthis.channelDir = channelDir;\n\n\t\tconst executor = createExecutor(sandboxConfig);\n\t\tthis.workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, \"\"));\n\t\tthis.workspaceDir = join(channelDir, \"..\");\n\n\t\t// Create tools\n\t\tconst tools = createPipiclawTools(executor);\n\n\t\t// Initial system prompt\n\t\tconst soul = getSoul(this.workspaceDir);\n\t\tconst agentConfig = getAgentConfig(channelDir);\n\t\tconst memory = getMemory(channelDir);\n\t\tconst initialSkills = loadPipiclawSkills(channelDir, this.workspacePath);\n\t\tthis.currentSkills = initialSkills;\n\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\tthis.workspacePath,\n\t\t\tchannelId,\n\t\t\tsoul,\n\t\t\tagentConfig,\n\t\t\tmemory,\n\t\t\tsandboxConfig,\n\t\t\tinitialSkills,\n\t\t);\n\n\t\t// Create session manager\n\t\tconst contextFile = join(channelDir, \"context.jsonl\");\n\t\tthis.sessionManager = SessionManager.open(contextFile, channelDir);\n\t\tthis.settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);\n\n\t\t// Create AuthStorage and ModelRegistry\n\t\tconst authStorage = AuthStorage.create(AUTH_CONFIG_PATH);\n\t\tthis.modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);\n\n\t\t// Resolve model: prefer saved global default, fall back to first available model\n\t\tthis.activeModel = resolveInitialModel(this.modelRegistry, this.settingsManager);\n\t\tlog.logInfo(`Using model: ${this.activeModel.provider}/${this.activeModel.id} (${this.activeModel.name})`);\n\n\t\t// Create agent\n\t\tthis.agent = new Agent({\n\t\t\tinitialState: {\n\t\t\t\tsystemPrompt,\n\t\t\t\tmodel: this.activeModel,\n\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\ttools,\n\t\t\t},\n\t\t\tconvertToLlm,\n\t\t\tgetApiKey: async () => getApiKeyForModel(this.modelRegistry, this.activeModel),\n\t\t});\n\n\t\t// Load existing messages\n\t\tconst loadedSession = this.sessionManager.buildSessionContext();\n\t\tif (loadedSession.messages.length > 0) {\n\t\t\tthis.agent.replaceMessages(loadedSession.messages);\n\t\t\tlog.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);\n\t\t}\n\n\t\tconst resourceLoader = new DefaultResourceLoader({\n\t\t\tcwd: process.cwd(),\n\t\t\tagentDir: APP_HOME_DIR,\n\t\t\tsettingsManager: this.settingsManager as any,\n\t\t\tskillsOverride: (base) => ({\n\t\t\t\tskills: [...base.skills, ...this.currentSkills],\n\t\t\t\tdiagnostics: base.diagnostics,\n\t\t\t}),\n\t\t});\n\n\t\tconst baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n\n\t\t// Create AgentSession\n\t\tthis.session = new AgentSession({\n\t\t\tagent: this.agent,\n\t\t\tsessionManager: this.sessionManager,\n\t\t\tsettingsManager: this.settingsManager as any,\n\t\t\tcwd: process.cwd(),\n\t\t\tmodelRegistry: this.modelRegistry,\n\t\t\tresourceLoader,\n\t\t\tbaseToolsOverride,\n\t\t});\n\n\t\t// Subscribe to session events\n\t\tthis.subscribeToSessionEvents();\n\t}\n\n\t// === Public API ===\n\n\tasync run(ctx: DingTalkContext, _store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }> {\n\t\tthis.resetRunState(ctx);\n\n\t\t// Create queue for this run\n\t\tlet queueChain = Promise.resolve();\n\t\tthis.runState.queue = {\n\t\t\tenqueue: (fn: () => Promise<void>, errorContext: string): void => {\n\t\t\t\tqueueChain = queueChain.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait fn();\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(`DingTalk API error (${errorContext})`, errMsg);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t},\n\t\t\tenqueueMessage: function (text: string, target: \"main\" | \"thread\", errorContext: string, doLog = true): void {\n\t\t\t\tthis.enqueue(\n\t\t\t\t\t() => (target === \"main\" ? ctx.respond(text, doLog) : ctx.respondInThread(text)),\n\t\t\t\t\terrorContext,\n\t\t\t\t);\n\t\t\t},\n\t\t};\n\n\t\ttry {\n\t\t\t// Ensure channel directory exists\n\t\t\tawait mkdir(this.channelDir, { recursive: true });\n\n\t\t\t// Update system prompt and runtime resources with fresh config\n\t\t\tconst soul = getSoul(this.workspaceDir);\n\t\t\tconst agentConfig = getAgentConfig(this.channelDir);\n\t\t\tconst memory = getMemory(this.channelDir);\n\t\t\tconst skills = loadPipiclawSkills(this.channelDir, this.workspacePath);\n\t\t\tthis.currentSkills = skills;\n\t\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\t\tthis.workspacePath,\n\t\t\t\tthis.channelId,\n\t\t\t\tsoul,\n\t\t\t\tagentConfig,\n\t\t\t\tmemory,\n\t\t\t\tthis.sandboxConfig,\n\t\t\t\tskills,\n\t\t\t);\n\t\t\tthis.session.agent.setSystemPrompt(systemPrompt);\n\t\t\tawait this.session.reload();\n\n\t\t\t// Sync messages from log.jsonl\n\t\t\tconst syncedCount = syncLogToSessionManager(this.sessionManager, this.channelDir, ctx.message.ts);\n\t\t\tif (syncedCount > 0) {\n\t\t\t\tlog.logInfo(`[${this.channelId}] Synced ${syncedCount} messages from log.jsonl`);\n\t\t\t}\n\n\t\t\t// Reload messages from context.jsonl\n\t\t\tconst reloadedSession = this.sessionManager.buildSessionContext();\n\t\t\tif (reloadedSession.messages.length > 0) {\n\t\t\t\tthis.agent.replaceMessages(reloadedSession.messages);\n\t\t\t\tlog.logInfo(`[${this.channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);\n\t\t\t}\n\n\t\t\t// Log context info\n\t\t\tlog.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);\n\n\t\t\t// Build user message with timestamp and username prefix\n\t\t\tconst now = new Date();\n\t\t\tconst pad = (n: number) => n.toString().padStart(2, \"0\");\n\t\t\tconst offset = -now.getTimezoneOffset();\n\t\t\tconst offsetSign = offset >= 0 ? \"+\" : \"-\";\n\t\t\tconst offsetHours = pad(Math.floor(Math.abs(offset) / 60));\n\t\t\tconst offsetMins = pad(Math.abs(offset) % 60);\n\t\t\tconst timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;\n\t\t\tconst userMessage = `[${timestamp}] [${ctx.message.userName || \"unknown\"}]: ${ctx.message.text}`;\n\n\t\t\t// Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)\n\t\t\tif (process.env.PIPICLAW_DEBUG) {\n\t\t\t\tconst debugContext = {\n\t\t\t\t\tsystemPrompt,\n\t\t\t\t\tmessages: this.session.messages,\n\t\t\t\t\tnewUserMessage: userMessage,\n\t\t\t\t};\n\t\t\t\tawait writeFile(join(this.channelDir, \"last_prompt.json\"), JSON.stringify(debugContext, null, 2));\n\t\t\t}\n\n\t\t\tawait this.session.prompt(userMessage);\n\t\t} catch (err) {\n\t\t\tthis.runState.stopReason = \"error\";\n\t\t\tthis.runState.errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${this.channelId}] Runner failed`, this.runState.errorMessage);\n\t\t} finally {\n\t\t\tawait queueChain;\n\t\t\tconst finalOutcome = this.runState.finalOutcome;\n\t\t\tconst finalOutcomeText = getFinalOutcomeText(finalOutcome);\n\n\t\t\ttry {\n\t\t\t\tif (\n\t\t\t\t\tthis.runState.stopReason === \"error\" &&\n\t\t\t\t\tthis.runState.errorMessage &&\n\t\t\t\t\t!this.runState.finalResponseDelivered\n\t\t\t\t) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.replaceMessage(\"_Sorry, something went wrong_\");\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to post error message\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t} else if (isSilentOutcome(finalOutcome)) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.deleteMessage();\n\t\t\t\t\t\tlog.logInfo(\"Silent response - deleted message\");\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to delete message for silent response\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t} else if (finalOutcomeText && !this.runState.finalResponseDelivered) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.replaceMessage(finalOutcomeText);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to replace message with final text\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tawait ctx.flush();\n\t\t\t} finally {\n\t\t\t\tawait ctx.close();\n\t\t\t}\n\n\t\t\t// Log usage summary\n\t\t\tif (this.runState.totalUsage.cost.total > 0) {\n\t\t\t\tconst messages = this.session.messages;\n\t\t\t\tconst lastAssistantMessage = messages\n\t\t\t\t\t.slice()\n\t\t\t\t\t.reverse()\n\t\t\t\t\t.find((m: any) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as any;\n\n\t\t\t\tconst contextTokens = lastAssistantMessage\n\t\t\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t\t\t: 0;\n\t\t\t\tconst currentRunModel = this.session.model ?? this.activeModel;\n\t\t\t\tconst contextWindow = currentRunModel.contextWindow || 200000;\n\n\t\t\t\tlog.logUsageSummary(this.runState.logCtx!, this.runState.totalUsage, contextTokens, contextWindow);\n\t\t\t}\n\n\t\t\t// Clear run state\n\t\t\tthis.runState.ctx = null;\n\t\t\tthis.runState.logCtx = null;\n\t\t\tthis.runState.queue = null;\n\t\t}\n\n\t\treturn { stopReason: this.runState.stopReason, errorMessage: this.runState.errorMessage };\n\t}\n\n\tasync handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void> {\n\t\ttry {\n\t\t\tswitch (command.name) {\n\t\t\t\tcase \"help\":\n\t\t\t\t\tawait this.sendCommandReply(ctx, renderBuiltInHelp());\n\t\t\t\t\treturn;\n\t\t\t\tcase \"new\": {\n\t\t\t\t\tconst completed = await this.session.newSession();\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\tcompleted ? `已开启新会话。\\n\\nSession ID: \\`${this.session.sessionId}\\`` : \"新会话已取消。\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"compact\": {\n\t\t\t\t\tconst result = await this.session.compact(command.args || undefined);\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t`已压缩当前会话上下文。\\n\\n- Tokens before compaction: \\`${result.tokensBefore}\\`\\n- Summary:\\n\\n\\`\\`\\`text\\n${result.summary}\\n\\`\\`\\``,\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"session\": {\n\t\t\t\t\tconst stats = this.session.getSessionStats();\n\t\t\t\t\tconst currentModel = this.session.model ? `\\`${formatModelReference(this.session.model)}\\`` : \"(none)\";\n\t\t\t\t\tconst sessionFile = stats.sessionFile ? `\\`${basename(stats.sessionFile)}\\`` : \"(none)\";\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t`# Session\\n\\n- Session ID: \\`${stats.sessionId}\\`\\n- Session file: ${sessionFile}\\n- Model: ${currentModel}\\n- Thinking level: \\`${this.session.thinkingLevel}\\`\\n- User messages: \\`${stats.userMessages}\\`\\n- Assistant messages: \\`${stats.assistantMessages}\\`\\n- Tool calls: \\`${stats.toolCalls}\\`\\n- Tool results: \\`${stats.toolResults}\\`\\n- Total messages: \\`${stats.totalMessages}\\`\\n- Tokens: \\`${stats.tokens.total}\\` (input \\`${stats.tokens.input}\\`, output \\`${stats.tokens.output}\\`, cache read \\`${stats.tokens.cacheRead}\\`, cache write \\`${stats.tokens.cacheWrite}\\`)\\n- Cost: \\`$${stats.cost.toFixed(4)}\\``,\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"model\":\n\t\t\t\t\tawait this.handleModelCommand(ctx, command.args);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"stop\":\n\t\t\t\t\tawait this.sendCommandReply(ctx, \"No task is running. Use `/stop` only while a task is running.\");\n\t\t\t\t\treturn;\n\t\t\t\tcase \"steer\":\n\t\t\t\t\tthis.requireQueuedMessage(command.args, \"steer\");\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly instead of using `/steer`.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"followup\":\n\t\t\t\t\tthis.requireQueuedMessage(command.args, \"followup\");\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly now, or use `/followup` while a task is running.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${this.channelId}] Built-in command failed`, errMsg);\n\t\t\tawait this.sendCommandReply(ctx, `命令执行失败:${errMsg}`);\n\t\t}\n\t}\n\n\tasync queueSteer(text: string): Promise<void> {\n\t\tawait this.queueBusyMessage(\"steer\", this.requireQueuedMessage(text, \"steer\"));\n\t}\n\n\tasync queueFollowUp(text: string): Promise<void> {\n\t\tawait this.queueBusyMessage(\"followUp\", this.requireQueuedMessage(text, \"followup\"));\n\t}\n\n\tabort(): void {\n\t\tthis.session.abort();\n\t}\n\n\t// === Private helpers ===\n\n\tprivate async sendCommandReply(ctx: DingTalkContext, text: string): Promise<void> {\n\t\tconst delivered = await ctx.respondPlain(text);\n\t\tif (!delivered) {\n\t\t\tawait ctx.replaceMessage(text);\n\t\t\tawait ctx.flush();\n\t\t}\n\t}\n\n\tprivate requireQueuedMessage(text: string, commandName: \"steer\" | \"followup\"): string {\n\t\tconst trimmedText = text.trim();\n\t\tif (!trimmedText) {\n\t\t\tthrow new Error(`/${commandName} requires a message.`);\n\t\t}\n\t\treturn trimmedText;\n\t}\n\n\tprivate async queueBusyMessage(delivery: \"steer\" | \"followUp\", text: string): Promise<void> {\n\t\tif (!this.session.isStreaming) {\n\t\t\tthrow new Error(\"No task is currently running.\");\n\t\t}\n\n\t\tif (delivery === \"followUp\") {\n\t\t\tawait this.session.followUp(text);\n\t\t} else {\n\t\t\tawait this.session.steer(text);\n\t\t}\n\t}\n\n\tprivate async handleModelCommand(ctx: DingTalkContext, args: string): Promise<void> {\n\t\tthis.modelRegistry.refresh();\n\t\tconst availableModels = await this.modelRegistry.getAvailable();\n\t\tconst currentModel = this.session.model;\n\n\t\tif (!args.trim()) {\n\t\t\tconst current = currentModel ? `\\`${formatModelReference(currentModel)}\\`` : \"(none)\";\n\t\t\tconst available = availableModels.length > 0 ? formatModelList(availableModels, currentModel) : \"- (none)\";\n\t\t\tawait this.sendCommandReply(\n\t\t\t\tctx,\n\t\t\t\t`# Model\\n\\nCurrent model: ${current}\\n\\nUse \\`/model <provider/modelId>\\` or \\`/model <modelId>\\` to switch. Bare model IDs must resolve uniquely.\\n\\nAvailable models:\\n${available}`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tconst match = findExactModelReferenceMatch(args, availableModels);\n\t\tif (match.match) {\n\t\t\tawait this.session.setModel(match.match);\n\t\t\tthis.activeModel = match.match;\n\t\t\tawait this.sendCommandReply(ctx, `已切换模型到 \\`${formatModelReference(match.match)}\\`。`);\n\t\t\treturn;\n\t\t}\n\n\t\tconst available = availableModels.length > 0 ? formatModelList(availableModels, currentModel, 10) : \"- (none)\";\n\t\tif (match.ambiguous) {\n\t\t\tawait this.sendCommandReply(\n\t\t\t\tctx,\n\t\t\t\t`未切换模型:\\`${args.trim()}\\` 匹配到多个模型。请改用精确的 \\`provider/modelId\\` 形式。\\n\\nAvailable models:\\n${available}`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait this.sendCommandReply(\n\t\t\tctx,\n\t\t\t`未找到模型 \\`${args.trim()}\\`。请使用精确的 \\`provider/modelId\\` 或唯一的 \\`modelId\\`。\\n\\nAvailable models:\\n${available}`,\n\t\t);\n\t}\n\n\tprivate resetRunState(ctx: DingTalkContext): void {\n\t\tthis.runState = createEmptyRunState();\n\t\tthis.runState.ctx = ctx;\n\t\tthis.runState.logCtx = {\n\t\t\tchannelId: ctx.message.channel,\n\t\t\tuserName: ctx.message.userName,\n\t\t\tchannelName: ctx.channelName,\n\t\t};\n\t}\n\n\t// === Session event subscription ===\n\n\tprivate subscribeToSessionEvents(): void {\n\t\tthis.session.subscribe(async (event: any) => {\n\t\t\tif (!this.runState.ctx || !this.runState.logCtx || !this.runState.queue) return;\n\n\t\t\tconst { ctx, logCtx, queue, pendingTools } = this.runState;\n\n\t\t\tif (event.type === \"tool_execution_start\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"tool_execution_start\" };\n\t\t\t\tconst args = agentEvent.args as { label?: string };\n\t\t\t\tconst label = args.label || agentEvent.toolName;\n\n\t\t\t\tpendingTools.set(agentEvent.toolCallId, {\n\t\t\t\t\ttoolName: agentEvent.toolName,\n\t\t\t\t\targs: agentEvent.args,\n\t\t\t\t\tstartTime: Date.now(),\n\t\t\t\t});\n\n\t\t\t\tlog.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args as Record<string, unknown>);\n\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"tool\", label), false), \"tool label\");\n\t\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"tool_execution_end\" };\n\t\t\t\tconst resultStr = extractToolResultText(agentEvent.result);\n\t\t\t\tconst pending = pendingTools.get(agentEvent.toolCallId);\n\t\t\t\tpendingTools.delete(agentEvent.toolCallId);\n\n\t\t\t\tconst durationMs = pending ? Date.now() - pending.startTime : 0;\n\n\t\t\t\tif (agentEvent.isError) {\n\t\t\t\t\tlog.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t\t} else {\n\t\t\t\t\tlog.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t\t}\n\n\t\t\t\tif (agentEvent.isError) {\n\t\t\t\t\tqueue.enqueue(\n\t\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"error\", truncate(resultStr, 200)), false),\n\t\t\t\t\t\t\"tool error\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else if (event.type === \"message_start\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"message_start\" };\n\t\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\t\tlog.logResponseStart(logCtx);\n\t\t\t\t}\n\t\t\t} else if (event.type === \"message_end\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"message_end\" };\n\t\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = agentEvent.message as any;\n\n\t\t\t\t\tif (assistantMsg.stopReason) {\n\t\t\t\t\t\tthis.runState.stopReason = assistantMsg.stopReason;\n\t\t\t\t\t}\n\t\t\t\t\tif (assistantMsg.errorMessage) {\n\t\t\t\t\t\tthis.runState.errorMessage = assistantMsg.errorMessage;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\t\tthis.runState.totalUsage.input += assistantMsg.usage.input;\n\t\t\t\t\t\tthis.runState.totalUsage.output += assistantMsg.usage.output;\n\t\t\t\t\t\tthis.runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\t\t\tthis.runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst content = agentEvent.message.content;\n\t\t\t\t\tconst thinkingParts: string[] = [];\n\t\t\t\t\tconst textParts: string[] = [];\n\t\t\t\t\tlet hasToolCalls = false;\n\t\t\t\t\tfor (const part of content) {\n\t\t\t\t\t\tif (part.type === \"thinking\") {\n\t\t\t\t\t\t\tthinkingParts.push((part as any).thinking);\n\t\t\t\t\t\t} else if (part.type === \"text\") {\n\t\t\t\t\t\t\ttextParts.push((part as any).text);\n\t\t\t\t\t\t} else if (part.type === \"toolCall\") {\n\t\t\t\t\t\t\thasToolCalls = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tconst text = textParts.join(\"\\n\");\n\n\t\t\t\t\tfor (const thinking of thinkingParts) {\n\t\t\t\t\t\tlog.logThinking(logCtx, thinking);\n\t\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"thinking\", thinking), false), \"thinking\");\n\t\t\t\t\t}\n\n\t\t\t\t\tif (hasToolCalls && text.trim()) {\n\t\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"assistant\", text), false), \"assistant progress\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (event.type === \"turn_end\") {\n\t\t\t\tconst turnEvent = event as any & {\n\t\t\t\t\ttype: \"turn_end\";\n\t\t\t\t\tmessage: { role: string; stopReason?: string; content: Array<{ type: string; text?: string }> };\n\t\t\t\t\ttoolResults: unknown[];\n\t\t\t\t};\n\t\t\t\tif (turnEvent.message.role === \"assistant\" && turnEvent.toolResults.length === 0) {\n\t\t\t\t\tif (turnEvent.message.stopReason === \"error\" || turnEvent.message.stopReason === \"aborted\") {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst finalContent = turnEvent.message.content as Array<{ type: string; text?: string }>;\n\t\t\t\t\tconst finalText = finalContent\n\t\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\" && !!part.text)\n\t\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\t\tconst trimmedFinalText = finalText.trim();\n\t\t\t\t\tif (!trimmedFinalText) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (trimmedFinalText === \"[SILENT]\" || trimmedFinalText.startsWith(\"[SILENT]\")) {\n\t\t\t\t\t\tthis.runState.finalOutcome = { kind: \"silent\" };\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (\n\t\t\t\t\t\tthis.runState.finalOutcome.kind === \"final\" &&\n\t\t\t\t\t\tthis.runState.finalOutcome.text.trim() === trimmedFinalText\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.runState.finalOutcome = { kind: \"final\", text: finalText };\n\t\t\t\t\tlog.logResponse(logCtx, finalText);\n\t\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\t\tconst delivered = await ctx.respondPlain(finalText);\n\t\t\t\t\t\tif (delivered) {\n\t\t\t\t\t\t\tthis.runState.finalResponseDelivered = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}, \"final response\");\n\t\t\t\t}\n\t\t\t} else if (event.type === \"auto_compaction_start\") {\n\t\t\t\tlog.logInfo(`Auto-compaction started (reason: ${(event as any).reason})`);\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"assistant\", \"Compacting context...\"), false),\n\t\t\t\t\t\"compaction start\",\n\t\t\t\t);\n\t\t\t} else if (event.type === \"auto_compaction_end\") {\n\t\t\t\tconst compEvent = event as any;\n\t\t\t\tif (compEvent.result) {\n\t\t\t\t\tlog.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);\n\t\t\t\t} else if (compEvent.aborted) {\n\t\t\t\t\tlog.logInfo(\"Auto-compaction aborted\");\n\t\t\t\t}\n\t\t\t} else if (event.type === \"auto_retry_start\") {\n\t\t\t\tconst retryEvent = event as any;\n\t\t\t\tlog.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() =>\n\t\t\t\t\t\tctx.respond(\n\t\t\t\t\t\t\tformatProgressEntry(\"assistant\", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`),\n\t\t\t\t\t\t\tfalse,\n\t\t\t\t\t\t),\n\t\t\t\t\t\"retry\",\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\t}\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nconst channelRunners = new Map<string, AgentRunner>();\n\nexport function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst existing = channelRunners.get(channelId);\n\tif (existing) return existing;\n\n\tconst runner = new ChannelRunner(sandboxConfig, channelId, channelDir);\n\tchannelRunners.set(channelId, runner);\n\treturn runner;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AAGvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAMrD,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAO/C,MAAM,WAAW,WAAW;IAC3B,GAAG,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvG,oBAAoB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnF,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,KAAK,IAAI,IAAI,CAAC;CACd;AA6rBD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAOlH","sourcesContent":["import { Agent } from \"@mariozechner/pi-agent-core\";\nimport type { Api, Model } from \"@mariozechner/pi-ai\";\nimport {\n\tAgentSession,\n\tAuthStorage,\n\tconvertToLlm,\n\tDefaultResourceLoader,\n\tModelRegistry,\n\tSessionManager,\n\ttype Skill,\n} from \"@mariozechner/pi-coding-agent\";\nimport { mkdir, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { COMMAND_RESULT_CUSTOM_TYPE, createCommandExtension } from \"./command-extension.js\";\nimport { type BuiltInCommand, renderBuiltInHelp } from \"./commands.js\";\nimport { getAgentConfig, getApiKeyForModel, getSoul, loadPipiclawSkills } from \"./config-loader.js\";\nimport { PipiclawSettingsManager } from \"./context.js\";\nimport type { DingTalkContext } from \"./dingtalk.js\";\nimport * as log from \"./log.js\";\nimport { MemoryLifecycle } from \"./memory-lifecycle.js\";\nimport { resolveInitialModel } from \"./model-utils.js\";\nimport { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from \"./paths.js\";\nimport { buildAppendSystemPrompt } from \"./prompt-builder.js\";\nimport { createExecutor, type SandboxConfig } from \"./sandbox.js\";\nimport type { ChannelStore } from \"./store.js\";\nimport { createPipiclawTools } from \"./tools/index.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface AgentRunner {\n\trun(ctx: DingTalkContext, store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }>;\n\thandleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;\n\tqueueSteer(text: string, userName?: string): Promise<void>;\n\tqueueFollowUp(text: string, userName?: string): Promise<void>;\n\tabort(): void;\n}\n\ntype FinalOutcome = { kind: \"none\" } | { kind: \"silent\" } | { kind: \"final\"; text: string };\n\nfunction isSilentOutcome(outcome: FinalOutcome): outcome is { kind: \"silent\" } {\n\treturn outcome.kind === \"silent\";\n}\n\nfunction isFinalOutcome(outcome: FinalOutcome): outcome is { kind: \"final\"; text: string } {\n\treturn outcome.kind === \"final\";\n}\n\nfunction getFinalOutcomeText(outcome: FinalOutcome): string | null {\n\treturn isFinalOutcome(outcome) ? outcome.text : null;\n}\n\n// ============================================================================\n// Text helpers\n// ============================================================================\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn `${text.substring(0, maxLen - 3)}...`;\n}\n\nfunction sanitizeProgressText(text: string): string {\n\treturn text\n\t\t.replace(/\\uFFFC/g, \"\")\n\t\t.replace(/\\r/g, \"\")\n\t\t.trim();\n}\n\nfunction formatProgressEntry(kind: \"tool\" | \"thinking\" | \"error\" | \"assistant\", text: string): string {\n\tconst cleaned = sanitizeProgressText(text);\n\tif (!cleaned) return \"\";\n\n\tconst normalized = cleaned.replace(/\\n+/g, \" \").trim();\n\tswitch (kind) {\n\t\tcase \"tool\":\n\t\t\treturn `Running: ${normalized}`;\n\t\tcase \"thinking\":\n\t\t\treturn `Thinking: ${normalized}`;\n\t\tcase \"error\":\n\t\t\treturn `Error: ${normalized}`;\n\t\tcase \"assistant\":\n\t\t\treturn normalized;\n\t}\n}\n\nfunction extractToolResultText(result: unknown): string {\n\tif (typeof result === \"string\") {\n\t\treturn result;\n\t}\n\n\tif (\n\t\tresult &&\n\t\ttypeof result === \"object\" &&\n\t\t\"content\" in result &&\n\t\tArray.isArray((result as { content: unknown }).content)\n\t) {\n\t\tconst content = (result as { content: Array<{ type: string; text?: string }> }).content;\n\t\tconst textParts: string[] = [];\n\t\tfor (const part of content) {\n\t\t\tif (part.type === \"text\" && part.text) {\n\t\t\t\ttextParts.push(part.text);\n\t\t\t}\n\t\t}\n\t\tif (textParts.length > 0) {\n\t\t\treturn textParts.join(\"\\n\");\n\t\t}\n\t}\n\n\treturn JSON.stringify(result);\n}\n\nfunction extractCustomCommandResultText(message: unknown): string | null {\n\tif (\n\t\t!message ||\n\t\ttypeof message !== \"object\" ||\n\t\t!(\"role\" in message) ||\n\t\t!(\"customType\" in message) ||\n\t\t(message as { role?: unknown }).role !== \"custom\" ||\n\t\t(message as { customType?: unknown }).customType !== COMMAND_RESULT_CUSTOM_TYPE\n\t) {\n\t\treturn null;\n\t}\n\n\tconst content = (message as { content?: unknown }).content;\n\treturn typeof content === \"string\" && content.trim() ? content : null;\n}\n\n// ============================================================================\n// Run State\n// ============================================================================\n\ninterface PendingTool {\n\ttoolName: string;\n\targs: unknown;\n\tstartTime: number;\n}\n\ninterface UsageTotals {\n\tinput: number;\n\toutput: number;\n\tcacheRead: number;\n\tcacheWrite: number;\n\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n}\n\ninterface RunQueue {\n\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n}\n\ninterface RunState {\n\tctx: DingTalkContext | null;\n\tlogCtx: { channelId: string; userName?: string; channelName?: string } | null;\n\tqueue: RunQueue | null;\n\tpendingTools: Map<string, PendingTool>;\n\ttotalUsage: UsageTotals;\n\tstopReason: string;\n\terrorMessage: string | undefined;\n\tfinalOutcome: FinalOutcome;\n\tfinalResponseDelivered: boolean;\n}\n\nfunction createEmptyRunState(): RunState {\n\treturn {\n\t\tctx: null,\n\t\tlogCtx: null,\n\t\tqueue: null,\n\t\tpendingTools: new Map(),\n\t\ttotalUsage: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t},\n\t\tstopReason: \"stop\",\n\t\terrorMessage: undefined,\n\t\tfinalOutcome: { kind: \"none\" },\n\t\tfinalResponseDelivered: false,\n\t};\n}\n\n// ============================================================================\n// ChannelRunner\n// ============================================================================\n\nclass ChannelRunner implements AgentRunner {\n\t// --- Constructed once ---\n\tprivate readonly sandboxConfig: SandboxConfig;\n\tprivate readonly channelId: string;\n\tprivate readonly channelDir: string;\n\tprivate readonly workspacePath: string;\n\tprivate readonly workspaceDir: string;\n\tprivate readonly session: AgentSession;\n\tprivate readonly agent: Agent;\n\tprivate readonly sessionManager: SessionManager;\n\tprivate readonly settingsManager: PipiclawSettingsManager;\n\tprivate readonly modelRegistry: ModelRegistry;\n\tprivate readonly memoryLifecycle: MemoryLifecycle;\n\tprivate readonly sessionReady: Promise<void>;\n\n\t// --- Mutable across runs ---\n\tprivate activeModel: Model<Api>;\n\tprivate currentSkills: Skill[];\n\n\t// --- Per run ---\n\tprivate runState: RunState = createEmptyRunState();\n\n\tconstructor(sandboxConfig: SandboxConfig, channelId: string, channelDir: string) {\n\t\tthis.sandboxConfig = sandboxConfig;\n\t\tthis.channelId = channelId;\n\t\tthis.channelDir = channelDir;\n\n\t\tconst executor = createExecutor(sandboxConfig);\n\t\tthis.workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, \"\"));\n\t\tthis.workspaceDir = join(channelDir, \"..\");\n\n\t\t// Create tools\n\t\tconst tools = createPipiclawTools(executor);\n\n\t\t// Initial skill summaries\n\t\tconst initialSkills = loadPipiclawSkills(channelDir, this.workspacePath);\n\t\tthis.currentSkills = initialSkills;\n\n\t\t// Create session manager\n\t\tconst contextFile = join(channelDir, \"context.jsonl\");\n\t\tthis.sessionManager = SessionManager.open(contextFile, channelDir);\n\t\tthis.settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);\n\n\t\t// Create AuthStorage and ModelRegistry\n\t\tconst authStorage = AuthStorage.create(AUTH_CONFIG_PATH);\n\t\tthis.modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);\n\n\t\t// Resolve model: prefer saved global default, fall back to first available model\n\t\tthis.activeModel = resolveInitialModel(this.modelRegistry, this.settingsManager);\n\t\tlog.logInfo(`Using model: ${this.activeModel.provider}/${this.activeModel.id} (${this.activeModel.name})`);\n\n\t\t// Create agent\n\t\tthis.agent = new Agent({\n\t\t\tinitialState: {\n\t\t\t\tsystemPrompt: \"\",\n\t\t\t\tmodel: this.activeModel,\n\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\ttools,\n\t\t\t},\n\t\t\tconvertToLlm,\n\t\t\tgetApiKey: async () => getApiKeyForModel(this.modelRegistry, this.activeModel),\n\t\t});\n\n\t\tthis.memoryLifecycle = new MemoryLifecycle({\n\t\t\tchannelId: this.channelId,\n\t\t\tchannelDir: this.channelDir,\n\t\t\tgetMessages: () => this.session.messages,\n\t\t\tgetSessionEntries: () => this.sessionManager.getBranch(),\n\t\t\tgetModel: () => this.session.model ?? this.activeModel,\n\t\t\tresolveApiKey: async (model) => getApiKeyForModel(this.modelRegistry, model),\n\t\t});\n\n\t\tconst resourceLoader = new DefaultResourceLoader({\n\t\t\tcwd: process.cwd(),\n\t\t\tagentDir: APP_HOME_DIR,\n\t\t\tsettingsManager: this.settingsManager as any,\n\t\t\textensionFactories: [\n\t\t\t\tthis.memoryLifecycle.createExtensionFactory(),\n\t\t\t\tcreateCommandExtension({\n\t\t\t\t\tgetCurrentModel: () => this.session.model ?? this.activeModel,\n\t\t\t\t\tgetAvailableModels: async () => {\n\t\t\t\t\t\tthis.modelRegistry.refresh();\n\t\t\t\t\t\treturn await this.modelRegistry.getAvailable();\n\t\t\t\t\t},\n\t\t\t\t\tgetSessionStats: () => this.session.getSessionStats(),\n\t\t\t\t\tgetThinkingLevel: () => this.session.thinkingLevel,\n\t\t\t\t\tswitchModel: async (model) => {\n\t\t\t\t\t\tawait this.session.setModel(model);\n\t\t\t\t\t\tthis.activeModel = model;\n\t\t\t\t\t},\n\t\t\t\t\trefreshSessionResources: async () => {\n\t\t\t\t\t\tawait this.refreshSessionResources();\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t],\n\t\t\tappendSystemPromptOverride: (base) => {\n\t\t\t\tconst soul = getSoul(this.workspaceDir);\n\t\t\t\tconst sections = [...base];\n\t\t\t\tif (soul) {\n\t\t\t\t\tsections.unshift(soul);\n\t\t\t\t}\n\t\t\t\tsections.push(buildAppendSystemPrompt(this.workspacePath, this.channelId, this.sandboxConfig));\n\t\t\t\treturn sections;\n\t\t\t},\n\t\t\tagentsFilesOverride: () => {\n\t\t\t\tconst agentConfig = getAgentConfig(this.channelDir);\n\t\t\t\treturn {\n\t\t\t\t\tagentsFiles: agentConfig ? [{ path: `${this.workspacePath}/AGENTS.md`, content: agentConfig }] : [],\n\t\t\t\t};\n\t\t\t},\n\t\t\tskillsOverride: (base) => ({\n\t\t\t\tskills: [...base.skills, ...this.currentSkills],\n\t\t\t\tdiagnostics: base.diagnostics,\n\t\t\t}),\n\t\t});\n\n\t\tconst baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n\n\t\t// Create AgentSession\n\t\tthis.session = new AgentSession({\n\t\t\tagent: this.agent,\n\t\t\tsessionManager: this.sessionManager,\n\t\t\tsettingsManager: this.settingsManager as any,\n\t\t\tcwd: process.cwd(),\n\t\t\tmodelRegistry: this.modelRegistry,\n\t\t\tresourceLoader,\n\t\t\tbaseToolsOverride,\n\t\t});\n\n\t\t// Subscribe to session events\n\t\tthis.subscribeToSessionEvents();\n\t\tthis.sessionReady = this.initializeSession();\n\t}\n\n\t// === Public API ===\n\n\tasync run(ctx: DingTalkContext, _store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }> {\n\t\tthis.resetRunState(ctx);\n\n\t\t// Create queue for this run\n\t\tlet queueChain = Promise.resolve();\n\t\tthis.runState.queue = {\n\t\t\tenqueue: (fn: () => Promise<void>, errorContext: string): void => {\n\t\t\t\tqueueChain = queueChain.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait fn();\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(`DingTalk API error (${errorContext})`, errMsg);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t},\n\t\t\tenqueueMessage: function (text: string, target: \"main\" | \"thread\", errorContext: string, doLog = true): void {\n\t\t\t\tthis.enqueue(\n\t\t\t\t\t() => (target === \"main\" ? ctx.respond(text, doLog) : ctx.respondInThread(text)),\n\t\t\t\t\terrorContext,\n\t\t\t\t);\n\t\t\t},\n\t\t};\n\n\t\ttry {\n\t\t\tawait this.ensureSessionReady();\n\n\t\t\t// Ensure channel directory exists\n\t\t\tawait mkdir(this.channelDir, { recursive: true });\n\n\t\t\tconst userMessage = this.formatUserMessage(ctx.message.text, ctx.message.userName);\n\t\t\tconst promptText = this.shouldPreserveRawInput(ctx.message.text) ? ctx.message.text.trim() : userMessage;\n\n\t\t\t// Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)\n\t\t\tif (process.env.PIPICLAW_DEBUG) {\n\t\t\t\tconst debugContext = {\n\t\t\t\t\tsystemPrompt: this.agent.state.systemPrompt,\n\t\t\t\t\tmessages: this.session.messages,\n\t\t\t\t\tnewUserMessage: promptText,\n\t\t\t\t};\n\t\t\t\tawait writeFile(join(this.channelDir, \"last_prompt.json\"), JSON.stringify(debugContext, null, 2));\n\t\t\t}\n\n\t\t\tawait this.session.prompt(promptText);\n\t\t} catch (err) {\n\t\t\tthis.runState.stopReason = \"error\";\n\t\t\tthis.runState.errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${this.channelId}] Runner failed`, this.runState.errorMessage);\n\t\t} finally {\n\t\t\tawait queueChain;\n\t\t\tconst finalOutcome = this.runState.finalOutcome;\n\t\t\tconst finalOutcomeText = getFinalOutcomeText(finalOutcome);\n\n\t\t\ttry {\n\t\t\t\tif (\n\t\t\t\t\tthis.runState.stopReason === \"error\" &&\n\t\t\t\t\tthis.runState.errorMessage &&\n\t\t\t\t\t!this.runState.finalResponseDelivered\n\t\t\t\t) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.replaceMessage(\"_Sorry, something went wrong_\");\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to post error message\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t} else if (isSilentOutcome(finalOutcome)) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.deleteMessage();\n\t\t\t\t\t\tlog.logInfo(\"Silent response - deleted message\");\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to delete message for silent response\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t} else if (finalOutcomeText && !this.runState.finalResponseDelivered) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.replaceMessage(finalOutcomeText);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to replace message with final text\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tawait ctx.flush();\n\t\t\t} finally {\n\t\t\t\tawait ctx.close();\n\t\t\t}\n\n\t\t\t// Log usage summary\n\t\t\tif (this.runState.totalUsage.cost.total > 0) {\n\t\t\t\tconst messages = this.session.messages;\n\t\t\t\tconst lastAssistantMessage = messages\n\t\t\t\t\t.slice()\n\t\t\t\t\t.reverse()\n\t\t\t\t\t.find((m: any) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as any;\n\n\t\t\t\tconst contextTokens = lastAssistantMessage\n\t\t\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t\t\t: 0;\n\t\t\t\tconst currentRunModel = this.session.model ?? this.activeModel;\n\t\t\t\tconst contextWindow = currentRunModel.contextWindow || 200000;\n\n\t\t\t\tlog.logUsageSummary(this.runState.logCtx!, this.runState.totalUsage, contextTokens, contextWindow);\n\t\t\t}\n\n\t\t\t// Clear run state\n\t\t\tthis.runState.ctx = null;\n\t\t\tthis.runState.logCtx = null;\n\t\t\tthis.runState.queue = null;\n\t\t}\n\n\t\treturn { stopReason: this.runState.stopReason, errorMessage: this.runState.errorMessage };\n\t}\n\n\tasync handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void> {\n\t\ttry {\n\t\t\tswitch (command.name) {\n\t\t\t\tcase \"help\":\n\t\t\t\t\tawait this.sendCommandReply(ctx, renderBuiltInHelp());\n\t\t\t\t\treturn;\n\t\t\t\tcase \"stop\":\n\t\t\t\t\tawait this.sendCommandReply(ctx, \"No task is running. Use `/stop` only while a task is running.\");\n\t\t\t\t\treturn;\n\t\t\t\tcase \"steer\":\n\t\t\t\t\tthis.requireQueuedMessage(command.args, \"steer\");\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly instead of using `/steer`.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"followup\":\n\t\t\t\t\tthis.requireQueuedMessage(command.args, \"followup\");\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly now, or use `/followup` while a task is running.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${this.channelId}] Built-in command failed`, errMsg);\n\t\t\tawait this.sendCommandReply(ctx, `命令执行失败:${errMsg}`);\n\t\t}\n\t}\n\n\tasync queueSteer(text: string, userName?: string): Promise<void> {\n\t\tawait this.queueBusyMessage(\"steer\", this.requireQueuedMessage(text, \"steer\"), userName);\n\t}\n\n\tasync queueFollowUp(text: string, userName?: string): Promise<void> {\n\t\tawait this.queueBusyMessage(\"followUp\", this.requireQueuedMessage(text, \"followup\"), userName);\n\t}\n\n\tabort(): void {\n\t\tthis.session.abort();\n\t}\n\n\t// === Private helpers ===\n\n\tprivate async sendCommandReply(ctx: DingTalkContext, text: string): Promise<void> {\n\t\tconst delivered = await ctx.respondPlain(text);\n\t\tif (!delivered) {\n\t\t\tawait ctx.replaceMessage(text);\n\t\t\tawait ctx.flush();\n\t\t}\n\t}\n\n\tprivate requireQueuedMessage(text: string, commandName: \"steer\" | \"followup\"): string {\n\t\tconst trimmedText = text.trim();\n\t\tif (!trimmedText) {\n\t\t\tthrow new Error(`/${commandName} requires a message.`);\n\t\t}\n\t\treturn trimmedText;\n\t}\n\n\tprivate shouldPreserveRawInput(text: string): boolean {\n\t\treturn text.trim().startsWith(\"/\");\n\t}\n\n\tprivate formatUserMessage(text: string, userName?: string, now: Date = new Date()): string {\n\t\tconst pad = (n: number) => n.toString().padStart(2, \"0\");\n\t\tconst offset = -now.getTimezoneOffset();\n\t\tconst offsetSign = offset >= 0 ? \"+\" : \"-\";\n\t\tconst offsetHours = pad(Math.floor(Math.abs(offset) / 60));\n\t\tconst offsetMins = pad(Math.abs(offset) % 60);\n\t\tconst timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;\n\t\treturn `[${timestamp}] [${userName || \"unknown\"}]: ${text}`;\n\t}\n\n\tprivate async queueBusyMessage(delivery: \"steer\" | \"followUp\", text: string, userName?: string): Promise<void> {\n\t\tif (!this.session.isStreaming) {\n\t\t\tthrow new Error(\"No task is currently running.\");\n\t\t}\n\n\t\tawait this.session.prompt(this.formatUserMessage(text, userName), {\n\t\t\tstreamingBehavior: delivery,\n\t\t});\n\t}\n\n\tprivate resetRunState(ctx: DingTalkContext): void {\n\t\tthis.runState = createEmptyRunState();\n\t\tthis.runState.ctx = ctx;\n\t\tthis.runState.logCtx = {\n\t\t\tchannelId: ctx.message.channel,\n\t\t\tuserName: ctx.message.userName,\n\t\t\tchannelName: ctx.channelName,\n\t\t};\n\t}\n\n\tprivate async refreshSessionResources(): Promise<void> {\n\t\tawait this.ensureSessionReady();\n\t\tconst skills = loadPipiclawSkills(this.channelDir, this.workspacePath);\n\t\tthis.currentSkills = skills;\n\t\tawait this.session.reload();\n\t}\n\n\tprivate async initializeSession(): Promise<void> {\n\t\tconst skills = loadPipiclawSkills(this.channelDir, this.workspacePath);\n\t\tthis.currentSkills = skills;\n\t\tawait this.session.reload();\n\t}\n\n\tprivate async ensureSessionReady(): Promise<void> {\n\t\tawait this.sessionReady;\n\t}\n\n\t// === Session event subscription ===\n\n\tprivate subscribeToSessionEvents(): void {\n\t\tthis.session.subscribe(async (event: any) => {\n\t\t\tif (!this.runState.ctx || !this.runState.logCtx || !this.runState.queue) return;\n\n\t\t\tconst { ctx, logCtx, queue, pendingTools } = this.runState;\n\n\t\t\tif (event.type === \"tool_execution_start\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"tool_execution_start\" };\n\t\t\t\tconst args = agentEvent.args as { label?: string };\n\t\t\t\tconst label = args.label || agentEvent.toolName;\n\n\t\t\t\tpendingTools.set(agentEvent.toolCallId, {\n\t\t\t\t\ttoolName: agentEvent.toolName,\n\t\t\t\t\targs: agentEvent.args,\n\t\t\t\t\tstartTime: Date.now(),\n\t\t\t\t});\n\n\t\t\t\tlog.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args as Record<string, unknown>);\n\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"tool\", label), false), \"tool label\");\n\t\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"tool_execution_end\" };\n\t\t\t\tconst resultStr = extractToolResultText(agentEvent.result);\n\t\t\t\tconst pending = pendingTools.get(agentEvent.toolCallId);\n\t\t\t\tpendingTools.delete(agentEvent.toolCallId);\n\n\t\t\t\tconst durationMs = pending ? Date.now() - pending.startTime : 0;\n\n\t\t\t\tif (agentEvent.isError) {\n\t\t\t\t\tlog.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t\t} else {\n\t\t\t\t\tlog.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t\t}\n\n\t\t\t\tif (agentEvent.isError) {\n\t\t\t\t\tqueue.enqueue(\n\t\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"error\", truncate(resultStr, 200)), false),\n\t\t\t\t\t\t\"tool error\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else if (event.type === \"message_start\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"message_start\" };\n\t\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\t\tlog.logResponseStart(logCtx);\n\t\t\t\t}\n\t\t\t} else if (event.type === \"message_end\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"message_end\" };\n\t\t\t\tconst commandResultText = extractCustomCommandResultText(agentEvent.message);\n\t\t\t\tif (commandResultText) {\n\t\t\t\t\tthis.runState.finalOutcome = { kind: \"final\", text: commandResultText };\n\t\t\t\t\tlog.logResponse(logCtx, commandResultText);\n\t\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\t\tconst delivered = await ctx.respondPlain(commandResultText);\n\t\t\t\t\t\tif (!delivered) {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(commandResultText);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.runState.finalResponseDelivered = true;\n\t\t\t\t\t}, \"command result\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = agentEvent.message as any;\n\n\t\t\t\t\tif (assistantMsg.stopReason) {\n\t\t\t\t\t\tthis.runState.stopReason = assistantMsg.stopReason;\n\t\t\t\t\t}\n\t\t\t\t\tif (assistantMsg.errorMessage) {\n\t\t\t\t\t\tthis.runState.errorMessage = assistantMsg.errorMessage;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\t\tthis.runState.totalUsage.input += assistantMsg.usage.input;\n\t\t\t\t\t\tthis.runState.totalUsage.output += assistantMsg.usage.output;\n\t\t\t\t\t\tthis.runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\t\t\tthis.runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst content = agentEvent.message.content;\n\t\t\t\t\tconst thinkingParts: string[] = [];\n\t\t\t\t\tconst textParts: string[] = [];\n\t\t\t\t\tlet hasToolCalls = false;\n\t\t\t\t\tfor (const part of content) {\n\t\t\t\t\t\tif (part.type === \"thinking\") {\n\t\t\t\t\t\t\tthinkingParts.push((part as any).thinking);\n\t\t\t\t\t\t} else if (part.type === \"text\") {\n\t\t\t\t\t\t\ttextParts.push((part as any).text);\n\t\t\t\t\t\t} else if (part.type === \"toolCall\") {\n\t\t\t\t\t\t\thasToolCalls = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tconst text = textParts.join(\"\\n\");\n\n\t\t\t\t\tfor (const thinking of thinkingParts) {\n\t\t\t\t\t\tlog.logThinking(logCtx, thinking);\n\t\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"thinking\", thinking), false), \"thinking\");\n\t\t\t\t\t}\n\n\t\t\t\t\tif (hasToolCalls && text.trim()) {\n\t\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"assistant\", text), false), \"assistant progress\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (event.type === \"turn_end\") {\n\t\t\t\tconst turnEvent = event as any & {\n\t\t\t\t\ttype: \"turn_end\";\n\t\t\t\t\tmessage: { role: string; stopReason?: string; content: Array<{ type: string; text?: string }> };\n\t\t\t\t\ttoolResults: unknown[];\n\t\t\t\t};\n\t\t\t\tif (turnEvent.message.role === \"assistant\" && turnEvent.toolResults.length === 0) {\n\t\t\t\t\tif (turnEvent.message.stopReason === \"error\" || turnEvent.message.stopReason === \"aborted\") {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst finalContent = turnEvent.message.content as Array<{ type: string; text?: string }>;\n\t\t\t\t\tconst finalText = finalContent\n\t\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\" && !!part.text)\n\t\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\t\tconst trimmedFinalText = finalText.trim();\n\t\t\t\t\tif (!trimmedFinalText) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (trimmedFinalText === \"[SILENT]\" || trimmedFinalText.startsWith(\"[SILENT]\")) {\n\t\t\t\t\t\tthis.runState.finalOutcome = { kind: \"silent\" };\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (\n\t\t\t\t\t\tthis.runState.finalOutcome.kind === \"final\" &&\n\t\t\t\t\t\tthis.runState.finalOutcome.text.trim() === trimmedFinalText\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.runState.finalOutcome = { kind: \"final\", text: finalText };\n\t\t\t\t\tlog.logResponse(logCtx, finalText);\n\t\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\t\tconst delivered = await ctx.respondPlain(finalText);\n\t\t\t\t\t\tif (delivered) {\n\t\t\t\t\t\t\tthis.runState.finalResponseDelivered = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}, \"final response\");\n\t\t\t\t}\n\t\t\t} else if (event.type === \"auto_compaction_start\") {\n\t\t\t\tlog.logInfo(`Auto-compaction started (reason: ${(event as any).reason})`);\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"assistant\", \"Compacting context...\"), false),\n\t\t\t\t\t\"compaction start\",\n\t\t\t\t);\n\t\t\t} else if (event.type === \"auto_compaction_end\") {\n\t\t\t\tconst compEvent = event as any;\n\t\t\t\tif (compEvent.result) {\n\t\t\t\t\tlog.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);\n\t\t\t\t} else if (compEvent.aborted) {\n\t\t\t\t\tlog.logInfo(\"Auto-compaction aborted\");\n\t\t\t\t}\n\t\t\t} else if (event.type === \"auto_retry_start\") {\n\t\t\t\tconst retryEvent = event as any;\n\t\t\t\tlog.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() =>\n\t\t\t\t\t\tctx.respond(\n\t\t\t\t\t\t\tformatProgressEntry(\"assistant\", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`),\n\t\t\t\t\t\t\tfalse,\n\t\t\t\t\t\t),\n\t\t\t\t\t\"retry\",\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\t}\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nconst channelRunners = new Map<string, AgentRunner>();\n\nexport function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst existing = channelRunners.get(channelId);\n\tif (existing) return existing;\n\n\tconst runner = new ChannelRunner(sandboxConfig, channelId, channelDir);\n\tchannelRunners.set(channelId, runner);\n\treturn runner;\n}\n"]}
|