@oyasmi/pipiclaw 0.1.1 → 0.2.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 +2 -1
- package/README.md +73 -11
- package/dist/agent.d.ts +2 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +47 -12
- package/dist/agent.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +22 -0
- package/dist/commands.js.map +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +4 -4
- package/dist/context.js.map +1 -1
- package/dist/dingtalk.d.ts +2 -0
- package/dist/dingtalk.d.ts.map +1 -1
- package/dist/dingtalk.js +24 -9
- package/dist/dingtalk.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +37 -3
- package/dist/main.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- log.jsonl rotation (> 1MB archived to .1)
|
|
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
|
+
- 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
|
|
13
14
|
|
|
14
15
|
### Changed
|
|
15
16
|
|
|
@@ -27,6 +28,6 @@
|
|
|
27
28
|
- Conversation metadata is persisted per channel so scheduled events and proactive sends continue to work after process restarts
|
|
28
29
|
- Package, CLI, and data directory renamed to `pipiclaw`, `@oyasmi/pipiclaw`, and `~/.pi/pipiclaw/`
|
|
29
30
|
- Pipiclaw now bootstraps `channel.json`, `auth.json`, `models.json`, `settings.json`, and the workspace skeleton automatically on first start
|
|
30
|
-
- Auto-generated `models.json` now starts as an empty valid config, and `SOUL.md` / `
|
|
31
|
+
- Auto-generated `models.json` now starts as an empty valid config, and `SOUL.md` / `AGENTS.md` are guidance templates instead of prefilled behavior
|
|
31
32
|
- Global pipiclaw settings now live in `~/.pi/pipiclaw/settings.json`, and saved default models are restored on restart
|
|
32
33
|
- DingTalk channel configuration is now read from `~/.pi/pipiclaw/channel.json`
|
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@ Pipiclaw 是一个接入钉钉的 AI Card 机器人,把 [pi-coding-agent](../c
|
|
|
7
7
|
- 钉钉 Stream 模式接收消息,自动重连
|
|
8
8
|
- 过程性思考和执行信息通过 AI Card 展示,最终答复独立快速返回
|
|
9
9
|
- 内置 Slash 命令:`/help`、`/new`、`/compact`、`/session`、`/model`
|
|
10
|
+
- 忙碌时默认将普通新消息作为 steer 送入当前任务,也支持显式 `/steer`、`/followup`、`/stop`
|
|
10
11
|
- 每个 DM / 群聊独立工作空间
|
|
11
|
-
- 支持全局和频道级 `SOUL.md`、`
|
|
12
|
+
- 支持全局和频道级 `SOUL.md`、`AGENTS.md`、`MEMORY.md`
|
|
12
13
|
- 支持全局和频道级技能目录
|
|
13
14
|
- 支持 immediate / one-shot / periodic 定时事件
|
|
14
15
|
- 支持自定义模型配置和模型切换
|
|
@@ -35,7 +36,7 @@ pipiclaw
|
|
|
35
36
|
- `workspace/events/`
|
|
36
37
|
- `workspace/skills/`
|
|
37
38
|
- `workspace/SOUL.md`
|
|
38
|
-
- `workspace/
|
|
39
|
+
- `workspace/AGENTS.md`
|
|
39
40
|
- `workspace/MEMORY.md`
|
|
40
41
|
|
|
41
42
|
如果 `channel.json` 还是示例占位符,程序会提示你先填写真实配置,然后退出。
|
|
@@ -144,29 +145,90 @@ pipiclaw --sandbox=docker:your-container
|
|
|
144
145
|
|
|
145
146
|
## 内置 Slash 命令
|
|
146
147
|
|
|
147
|
-
以下命令由 Pipiclaw 直接处理,不会作为普通 prompt
|
|
148
|
+
以下命令由 Pipiclaw 直接处理,不会作为普通 prompt 发送给模型。
|
|
149
|
+
|
|
150
|
+
### 空闲时可用
|
|
148
151
|
|
|
149
152
|
- `/help`
|
|
153
|
+
显示帮助
|
|
150
154
|
- `/new`
|
|
155
|
+
开启新会话
|
|
151
156
|
- `/compact [instructions]`
|
|
157
|
+
手动压缩当前会话上下文
|
|
152
158
|
- `/session`
|
|
159
|
+
查看当前会话状态、消息统计、token 使用和模型信息
|
|
153
160
|
- `/model [provider/modelId|modelId]`
|
|
161
|
+
查看当前模型,或用精确匹配切换模型
|
|
154
162
|
|
|
155
163
|
说明:
|
|
156
164
|
|
|
157
165
|
- `/model` 无参数时返回当前模型和可用模型列表
|
|
158
166
|
- `/model <ref>` 只支持精确匹配
|
|
167
|
+
|
|
168
|
+
### 忙碌时可用
|
|
169
|
+
|
|
170
|
+
- 普通消息
|
|
171
|
+
默认按 `steer` 处理,在当前工具步骤结束后尽快转向
|
|
172
|
+
- `/help`
|
|
173
|
+
显示帮助
|
|
174
|
+
- `/stop`
|
|
175
|
+
停止当前任务
|
|
176
|
+
- `/steer <message>`
|
|
177
|
+
显式指定一次 steer,改变当前任务方向
|
|
178
|
+
- `/followup <message>`
|
|
179
|
+
将一条新请求排到当前任务完成之后再执行
|
|
180
|
+
|
|
181
|
+
说明:
|
|
182
|
+
|
|
183
|
+
- busy 时普通消息默认等价于 `/steer <message>`
|
|
184
|
+
- `/steer` 更适合纠偏、补充限制条件、修改当前任务方向
|
|
185
|
+
- `/followup` 更适合“等这件事做完,再继续做下一件事”
|
|
159
186
|
- 未被 Pipiclaw 拦截的其他 slash 输入,仍会按 `AgentSession.prompt()` 的原有逻辑处理
|
|
160
187
|
|
|
161
|
-
|
|
188
|
+
## Workspace Files
|
|
189
|
+
|
|
190
|
+
Pipiclaw 只会自动识别并使用下面这些 workspace 文件或目录:
|
|
191
|
+
|
|
192
|
+
- `SOUL.md`
|
|
193
|
+
- `AGENTS.md`
|
|
194
|
+
- `MEMORY.md`
|
|
195
|
+
- `skills/`
|
|
196
|
+
- `events/`
|
|
197
|
+
|
|
198
|
+
`TOOLS.md` 当前不受支持。即使你手工创建了它,也不会被自动加载或生效。
|
|
199
|
+
|
|
200
|
+
### Global And Channel Scope
|
|
201
|
+
|
|
202
|
+
Pipiclaw 同时支持:
|
|
203
|
+
|
|
204
|
+
- 全局 workspace 文件:`~/.pi/pipiclaw/workspace/`
|
|
205
|
+
- 渠道级文件:`~/.pi/pipiclaw/workspace/dm_xxxx/` 或 `group_xxxx/`
|
|
206
|
+
|
|
207
|
+
它们的关系如下:
|
|
162
208
|
|
|
163
|
-
|
|
209
|
+
| 名称 | 全局位置 | 渠道级位置 | 生效方式 |
|
|
210
|
+
|------|----------|------------|----------|
|
|
211
|
+
| `SOUL.md` | `workspace/SOUL.md` | 不支持 | 仅使用全局文件。渠道级 `SOUL.md` 不会被读取。 |
|
|
212
|
+
| `AGENTS.md` | `workspace/AGENTS.md` | `<channel>/AGENTS.md` | 叠加。先读取全局,再追加渠道级,不是替换。 |
|
|
213
|
+
| `MEMORY.md` | `workspace/MEMORY.md` | `<channel>/MEMORY.md` | 合并。全局记忆和渠道记忆都会一起进入上下文。 |
|
|
214
|
+
| `skills/` | `workspace/skills/` | `<channel>/skills/` | 合并。两边的技能都会加载;如果同名,渠道级覆盖全局。 |
|
|
215
|
+
| `events/` | `workspace/events/` | 不支持 | 仅支持全局事件目录。 |
|
|
216
|
+
| `.channel-meta.json` | 不支持 | `<channel>/.channel-meta.json` | 运行时自动维护,用于主动发送和重启恢复,不建议手工编辑。 |
|
|
217
|
+
| `context.jsonl` | 不支持 | `<channel>/context.jsonl` | 运行时自动维护,保存结构化上下文。 |
|
|
218
|
+
| `log.jsonl` | 不支持 | `<channel>/log.jsonl` | 运行时自动维护,保存消息历史。 |
|
|
164
219
|
|
|
165
|
-
|
|
166
|
-
- `AGENT.md` 用来定义行为规则、工具使用策略和安全约束
|
|
167
|
-
- `MEMORY.md` 用来记录长期记忆
|
|
220
|
+
### File Intent
|
|
168
221
|
|
|
169
|
-
|
|
222
|
+
- `SOUL.md`
|
|
223
|
+
定义 Pipiclaw 的身份、语气、默认语言和回复风格。首次运行生成的只是说明模板,你需要替换成真实内容。
|
|
224
|
+
- `AGENTS.md`
|
|
225
|
+
定义行为规则、工具使用策略、安全约束和项目工作流。全局文件定义通用规则,渠道级文件补充该渠道的特定规则。
|
|
226
|
+
- `MEMORY.md`
|
|
227
|
+
定义持久记忆。适合存长期偏好、项目背景、联系人信息、长期约束等。
|
|
228
|
+
- `skills/`
|
|
229
|
+
存放自定义技能。适合放可复用的 CLI 工具、脚本和 skill 说明。
|
|
230
|
+
- `events/`
|
|
231
|
+
存放定时事件定义。只支持全局目录,不支持放到单个 channel 目录里。
|
|
170
232
|
|
|
171
233
|
## 工作空间布局
|
|
172
234
|
|
|
@@ -178,12 +240,12 @@ pipiclaw --sandbox=docker:your-container
|
|
|
178
240
|
├── settings.json
|
|
179
241
|
└── workspace/
|
|
180
242
|
├── SOUL.md
|
|
181
|
-
├──
|
|
243
|
+
├── AGENTS.md
|
|
182
244
|
├── MEMORY.md
|
|
183
245
|
├── skills/
|
|
184
246
|
├── events/
|
|
185
247
|
└── dm_{userId}/
|
|
186
|
-
├──
|
|
248
|
+
├── AGENTS.md
|
|
187
249
|
├── MEMORY.md
|
|
188
250
|
├── .channel-meta.json
|
|
189
251
|
├── context.jsonl
|
package/dist/agent.d.ts
CHANGED
|
@@ -8,6 +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
13
|
abort(): void;
|
|
12
14
|
}
|
|
13
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":"AAgBA,OAAO,EAAE,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AAEvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAM/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,KAAK,IAAI,IAAI,CAAC;CACd;AAqfD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAOlH;AA2kBD;;GAEG;AACH,wBAAgB,mBAAmB,CAClC,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,GACf,MAAM,CAWR","sourcesContent":["import { Agent } from \"@mariozechner/pi-agent-core\";\nimport { type Api, getModel, type Model } from \"@mariozechner/pi-ai\";\nimport {\n\tAgentSession,\n\tAuthStorage,\n\tconvertToLlm,\n\tDefaultResourceLoader,\n\tformatSkillsForPrompt,\n\tloadSkillsFromDir,\n\tModelRegistry,\n\tSessionManager,\n\ttype Skill,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { mkdir, writeFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport { type BuiltInCommand, renderBuiltInHelp } from \"./commands.js\";\nimport { PipiclawSettingsManager, syncLogToSessionManager } from \"./context.js\";\nimport type { DingTalkContext } from \"./dingtalk.js\";\nimport * as log from \"./log.js\";\nimport { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from \"./paths.js\";\nimport { createExecutor, type SandboxConfig } from \"./sandbox.js\";\nimport type { ChannelStore } from \"./store.js\";\nimport { createPipiclawTools } from \"./tools/index.js\";\n\n// Default model - will be overridden by ModelRegistry if custom models are configured\nconst defaultModel = getModel(\"anthropic\", \"claude-sonnet-4-5\");\n\nexport interface AgentRunner {\n\trun(ctx: DingTalkContext, store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }>;\n\thandleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): 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\nfunction formatModelReference(model: Model<Api>): string {\n\treturn `${model.provider}/${model.id}`;\n}\n\nfunction findExactModelReferenceMatch(\n\tmodelReference: string,\n\tavailableModels: Model<Api>[],\n): { match?: Model<Api>; ambiguous: boolean } {\n\tconst trimmedReference = modelReference.trim();\n\tif (!trimmedReference) {\n\t\treturn { ambiguous: false };\n\t}\n\n\tconst normalizedReference = trimmedReference.toLowerCase();\n\n\tconst canonicalMatches = availableModels.filter(\n\t\t(model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,\n\t);\n\tif (canonicalMatches.length === 1) {\n\t\treturn { match: canonicalMatches[0], ambiguous: false };\n\t}\n\tif (canonicalMatches.length > 1) {\n\t\treturn { ambiguous: true };\n\t}\n\n\tconst slashIndex = trimmedReference.indexOf(\"/\");\n\tif (slashIndex !== -1) {\n\t\tconst provider = trimmedReference.substring(0, slashIndex).trim();\n\t\tconst modelId = trimmedReference.substring(slashIndex + 1).trim();\n\t\tif (provider && modelId) {\n\t\t\tconst providerMatches = availableModels.filter(\n\t\t\t\t(model) =>\n\t\t\t\t\tmodel.provider.toLowerCase() === provider.toLowerCase() &&\n\t\t\t\t\tmodel.id.toLowerCase() === modelId.toLowerCase(),\n\t\t\t);\n\t\t\tif (providerMatches.length === 1) {\n\t\t\t\treturn { match: providerMatches[0], ambiguous: false };\n\t\t\t}\n\t\t\tif (providerMatches.length > 1) {\n\t\t\t\treturn { ambiguous: true };\n\t\t\t}\n\t\t}\n\t}\n\n\tconst idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);\n\tif (idMatches.length === 1) {\n\t\treturn { match: idMatches[0], ambiguous: false };\n\t}\n\n\treturn { ambiguous: idMatches.length > 1 };\n}\n\nfunction formatModelList(models: Model<Api>[], currentModel: Model<Api> | undefined, limit: number = 20): string {\n\tconst refs = models\n\t\t.slice()\n\t\t.sort((a, b) => formatModelReference(a).localeCompare(formatModelReference(b)))\n\t\t.map((model) => {\n\t\t\tconst ref = formatModelReference(model);\n\t\t\tconst marker =\n\t\t\t\tcurrentModel && currentModel.provider === model.provider && currentModel.id === model.id\n\t\t\t\t\t? \" (current)\"\n\t\t\t\t\t: \"\";\n\t\t\treturn `- \\`${ref}\\`${marker}`;\n\t\t});\n\n\tif (refs.length <= limit) {\n\t\treturn refs.join(\"\\n\");\n\t}\n\n\treturn `${refs.slice(0, limit).join(\"\\n\")}\\n- ... and ${refs.length - limit} more`;\n}\n\nfunction resolveInitialModel(modelRegistry: ModelRegistry, settingsManager: PipiclawSettingsManager): Model<Api> {\n\tconst savedProvider = settingsManager.getDefaultProvider();\n\tconst savedModelId = settingsManager.getDefaultModel();\n\tconst availableModels = modelRegistry.getAvailable();\n\tif (savedProvider && savedModelId) {\n\t\tconst savedModel = modelRegistry.find(savedProvider, savedModelId);\n\t\tif (\n\t\t\tsavedModel &&\n\t\t\tavailableModels.some((model) => model.provider === savedModel.provider && model.id === savedModel.id)\n\t\t) {\n\t\t\treturn savedModel;\n\t\t}\n\t}\n\n\tif (availableModels.length > 0) {\n\t\treturn availableModels[0];\n\t}\n\n\treturn defaultModel;\n}\n\nasync function getApiKeyForModel(modelRegistry: ModelRegistry, model: any): Promise<string> {\n\tconst key = await modelRegistry.getApiKeyForProvider(model.provider);\n\tif (key) return key;\n\t// Fallback: try anthropic env var\n\tconst envKey = process.env.ANTHROPIC_API_KEY;\n\tif (envKey) return envKey;\n\tthrow new Error(\n\t\t`No API key found for provider: ${model.provider}.\\n\\n` +\n\t\t\t\"Configure API key in ~/.pi/agent/models.json or set ANTHROPIC_API_KEY environment variable.\",\n\t);\n}\n\n// ============================================================================\n// Configuration file loaders: SOUL.md, AGENT.md, MEMORY.md\n// ============================================================================\n\n/**\n * Load SOUL.md — defines the agent's identity, personality, and communication style.\n * Only loaded from workspace root (global).\n */\nfunction getSoul(workspaceDir: string): string {\n\tconst soulPath = join(workspaceDir, \"SOUL.md\");\n\tif (existsSync(soulPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(soulPath, \"utf-8\").trim();\n\t\t\tif (content) return content;\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read SOUL.md\", `${soulPath}: ${error}`);\n\t\t}\n\t}\n\treturn \"\";\n}\n\n/**\n * Load AGENT.md — defines the agent's behavior instructions, capabilities, and constraints.\n * Supports both global (workspace root) and channel-level override.\n */\nfunction getAgentConfig(channelDir: string): string {\n\tconst parts: string[] = [];\n\n\t// Read workspace-level AGENT.md (global)\n\tconst workspaceAgentPath = join(channelDir, \"..\", \"AGENT.md\");\n\tif (existsSync(workspaceAgentPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(workspaceAgentPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(content);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read workspace AGENT.md\", `${workspaceAgentPath}: ${error}`);\n\t\t}\n\t}\n\n\t// Read channel-specific AGENT.md (overrides/extends global)\n\tconst channelAgentPath = join(channelDir, \"AGENT.md\");\n\tif (existsSync(channelAgentPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(channelAgentPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(content);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read channel AGENT.md\", `${channelAgentPath}: ${error}`);\n\t\t}\n\t}\n\n\treturn parts.join(\"\\n\\n\");\n}\n\nfunction getMemory(channelDir: string): string {\n\tconst parts: string[] = [];\n\n\t// Read workspace-level memory (shared across all channels)\n\tconst workspaceMemoryPath = join(channelDir, \"..\", \"MEMORY.md\");\n\tif (existsSync(workspaceMemoryPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(workspaceMemoryPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(`### Global Workspace Memory\\n${content}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read workspace memory\", `${workspaceMemoryPath}: ${error}`);\n\t\t}\n\t}\n\n\t// Read channel-specific memory\n\tconst channelMemoryPath = join(channelDir, \"MEMORY.md\");\n\tif (existsSync(channelMemoryPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(channelMemoryPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(`### Channel-Specific Memory\\n${content}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read channel memory\", `${channelMemoryPath}: ${error}`);\n\t\t}\n\t}\n\n\tif (parts.length === 0) {\n\t\treturn \"(no working memory yet)\";\n\t}\n\n\tconst combined = parts.join(\"\\n\\n\");\n\n\t// Warn if memory is getting too large (consumes system prompt token budget)\n\tif (combined.length > 5000) {\n\t\treturn `\\u26a0\\ufe0f Memory is large (${combined.length} chars). Consolidate: remove outdated entries, merge duplicates, tighten descriptions.\\n\\n${combined}`;\n\t}\n\n\treturn combined;\n}\n\nfunction loadPipiclawSkills(channelDir: string, workspacePath: string): Skill[] {\n\tconst skillMap = new Map<string, Skill>();\n\tconst hostWorkspacePath = join(channelDir, \"..\");\n\n\tconst translatePath = (hostPath: string): string => {\n\t\tif (hostPath.startsWith(hostWorkspacePath)) {\n\t\t\treturn workspacePath + hostPath.slice(hostWorkspacePath.length);\n\t\t}\n\t\treturn hostPath;\n\t};\n\n\t// Load workspace-level skills (global)\n\tconst workspaceSkillsDir = join(hostWorkspacePath, \"skills\");\n\tfor (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: \"workspace\" }).skills) {\n\t\tskill.filePath = translatePath(skill.filePath);\n\t\tskill.baseDir = translatePath(skill.baseDir);\n\t\tskillMap.set(skill.name, skill);\n\t}\n\n\t// Load channel-specific skills\n\tconst channelSkillsDir = join(channelDir, \"skills\");\n\tfor (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: \"channel\" }).skills) {\n\t\tskill.filePath = translatePath(skill.filePath);\n\t\tskill.baseDir = translatePath(skill.baseDir);\n\t\tskillMap.set(skill.name, skill);\n\t}\n\n\treturn Array.from(skillMap.values());\n}\n\n// ============================================================================\n// System Prompt Builder\n// ============================================================================\n\nfunction buildSystemPrompt(\n\tworkspacePath: string,\n\tchannelId: string,\n\tsoul: string,\n\tagentConfig: string,\n\tmemory: string,\n\tsandboxConfig: SandboxConfig,\n\tskills: Skill[],\n): string {\n\tconst channelPath = `${workspacePath}/${channelId}`;\n\tconst isDocker = sandboxConfig.type === \"docker\";\n\n\tconst envDescription = isDocker\n\t\t? `You are running inside a Docker container (Alpine Linux).\n- Bash working directory: / (use cd or absolute paths)\n- Install tools with: apk add <package>\n- Your changes persist across sessions`\n\t\t: `You are running directly on the host machine.\n- Bash working directory: ${process.cwd()}\n- Be careful with system modifications`;\n\n\t// Build system prompt with configuration file layering:\n\t// 1. SOUL.md (identity/personality)\n\t// 2. Core instructions\n\t// 3. AGENT.md (behavior instructions)\n\t// 4. Skills, Events, Memory\n\n\tconst sections: string[] = [];\n\n\t// 1. SOUL.md — Agent identity\n\tif (soul) {\n\t\tsections.push(soul);\n\t} else {\n\t\tsections.push(\"You are a DingTalk bot assistant. Be concise and helpful.\");\n\t}\n\n\t// 2. Core instructions\n\tsections.push(`## Context\n- For current date/time, use: date\n- You have access to previous conversation context including tool results from prior turns.\n- For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).\n\n## Formatting\nUse Markdown for formatting. DingTalk AI Card supports basic Markdown:\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`code\\`\\`\\`, Links: [text](url)\n\n## Environment\n${envDescription}\n\n## Workspace Layout\n${workspacePath}/\n├── SOUL.md # Your identity/personality (read-only)\n├── AGENT.md # Custom behavior instructions (read-only)\n├── MEMORY.md # Global memory (all channels, you can read/write)\n├── skills/ # Global CLI tools you create\n├── events/ # Scheduled events\n└── ${channelId}/ # This channel\n ├── AGENT.md # Channel-specific instructions (read-only)\n ├── MEMORY.md # Channel-specific memory (you can read/write)\n ├── log.jsonl # Message history (no tool results)\n ├── scratch/ # Your working directory\n └── skills/ # Channel-specific tools`);\n\n\t// 3. AGENT.md — User-defined instructions\n\tif (agentConfig) {\n\t\tsections.push(`## Agent Instructions\\n${agentConfig}`);\n\t}\n\n\t// 4. Skills\n\tsections.push(`## Skills (Custom CLI Tools)\nYou can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).\n\n### Creating Skills\nStore in \\`${workspacePath}/skills/<name>/\\` (global) or \\`${channelPath}/skills/<name>/\\` (channel-specific).\nEach skill directory needs a \\`SKILL.md\\` with YAML frontmatter:\n\n\\`\\`\\`markdown\n---\nname: skill-name\ndescription: Short description of what this skill does\n---\n\n# Skill Name\n\nUsage instructions, examples, etc.\nScripts are in: {baseDir}/\n\\`\\`\\`\n\n\\`name\\` and \\`description\\` are required. Use \\`{baseDir}\\` as placeholder for the skill's directory path.\n\n### Available Skills\n${skills.length > 0 ? formatSkillsForPrompt(skills) : \"(no skills installed yet)\"}`);\n\n\t// 5. Events\n\tsections.push(`## Events\nYou can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \\`${workspacePath}/events/\\`.\n\n### Event Types\n\n**Immediate** - Triggers as soon as harness sees the file.\n\\`\\`\\`json\n{\"type\": \"immediate\", \"channelId\": \"${channelId}\", \"text\": \"New event occurred\"}\n\\`\\`\\`\n\n**One-shot** - Triggers once at a specific time.\n\\`\\`\\`json\n{\"type\": \"one-shot\", \"channelId\": \"${channelId}\", \"text\": \"Reminder\", \"at\": \"2025-12-15T09:00:00+08:00\"}\n\\`\\`\\`\n\n**Periodic** - Triggers on a cron schedule.\n\\`\\`\\`json\n{\"type\": \"periodic\", \"channelId\": \"${channelId}\", \"text\": \"Check inbox\", \"schedule\": \"0 9 * * 1-5\", \"timezone\": \"${Intl.DateTimeFormat().resolvedOptions().timeZone}\"}\n\\`\\`\\`\n\n### Cron Format\n\\`minute hour day-of-month month day-of-week\\`\n\n### Creating Events\n\\`\\`\\`bash\ncat > ${workspacePath}/events/reminder-$(date +%s).json << 'EOF'\n{\"type\": \"one-shot\", \"channelId\": \"${channelId}\", \"text\": \"Reminder text\", \"at\": \"2025-12-14T09:00:00+08:00\"}\nEOF\n\\`\\`\\`\n\n### Silent Completion\nFor periodic events where there's nothing to report, respond with just \\`[SILENT]\\`. This deletes the status message. Use this to avoid spam when periodic checks find nothing.\n\n### Limits\nMaximum 5 events can be queued.`);\n\n\t// 6. Memory\n\tsections.push(`## Memory\nWrite to MEMORY.md files to persist context across conversations.\n- Global (${workspacePath}/MEMORY.md): skills, preferences, project info\n- Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work\n\n### Guidelines\n- Keep each MEMORY.md concise (target: under 50 lines)\n- Use clear headers to organize entries (## Preferences, ## Projects, etc.)\n- Remove outdated entries when they are no longer relevant\n- Merge duplicate or redundant items\n- Prefer structured formats (lists, key-value pairs) over prose\n- Update when you learn something important or when asked to remember something\n\n### Current Memory\n${memory}`);\n\n\t// 7. System Configuration Log\n\tsections.push(`## System Configuration Log\nMaintain ${workspacePath}/SYSTEM.md to log all environment modifications:\n- Installed packages (apk add, npm install, pip install)\n- Environment variables set\n- Config files modified\n- Skill dependencies installed\n\nUpdate this file whenever you modify the environment.`);\n\n\t// 8. Tools\n\tsections.push(`## Tools\n- bash: Run shell commands (primary tool). Install packages as needed.\n- read: Read files\n- write: Create/overwrite files\n- edit: Surgical file edits\n- attach: Share files (note: DingTalk file sharing is limited, output as text when possible)\n\nEach tool requires a \"label\" parameter (shown to user).`);\n\n\t// 9. Log Queries\n\tsections.push(`## Log Queries (for older history)\nFormat: \\`{\"date\":\"...\",\"ts\":\"...\",\"user\":\"...\",\"userName\":\"...\",\"text\":\"...\",\"isBot\":false}\\`\nThe log contains user messages and your final responses (not tool calls/results).\n${isDocker ? \"Install jq: apk add jq\" : \"\"}\n\n\\`\\`\\`bash\n# Recent messages\ntail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Search for specific topic\ngrep -i \"topic\" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\\`\\`\\``);\n\n\treturn sections.join(\"\\n\\n\");\n}\n\n// ============================================================================\n// Agent Runner\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// Cache runners per channel\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 = createRunner(sandboxConfig, channelId, channelDir);\n\tchannelRunners.set(channelId, runner);\n\treturn runner;\n}\n\nfunction createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst executor = createExecutor(sandboxConfig);\n\tconst workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, \"\"));\n\tconst workspaceDir = join(channelDir, \"..\");\n\n\t// Create tools\n\tconst tools = createPipiclawTools(executor);\n\n\t// Initial system prompt\n\tconst soul = getSoul(workspaceDir);\n\tconst agentConfig = getAgentConfig(channelDir);\n\tconst memory = getMemory(channelDir);\n\tconst initialSkills = loadPipiclawSkills(channelDir, workspacePath);\n\tlet currentSkills = initialSkills;\n\tconst systemPrompt = buildSystemPrompt(\n\t\tworkspacePath,\n\t\tchannelId,\n\t\tsoul,\n\t\tagentConfig,\n\t\tmemory,\n\t\tsandboxConfig,\n\t\tinitialSkills,\n\t);\n\n\t// Create session manager\n\tconst contextFile = join(channelDir, \"context.jsonl\");\n\tconst sessionManager = SessionManager.open(contextFile, channelDir);\n\tconst settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);\n\n\t// Create AuthStorage and ModelRegistry\n\tconst authStorage = AuthStorage.create(AUTH_CONFIG_PATH);\n\tconst modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);\n\n\t// Resolve model: prefer saved global default, fall back to first available model\n\tlet activeModel = resolveInitialModel(modelRegistry, settingsManager);\n\tlog.logInfo(`Using model: ${activeModel.provider}/${activeModel.id} (${activeModel.name})`);\n\n\t// Create agent\n\tconst agent = new Agent({\n\t\tinitialState: {\n\t\t\tsystemPrompt,\n\t\t\tmodel: activeModel,\n\t\t\tthinkingLevel: \"off\",\n\t\t\ttools,\n\t\t},\n\t\tconvertToLlm,\n\t\tgetApiKey: async () => getApiKeyForModel(modelRegistry, activeModel),\n\t});\n\n\t// Load existing messages\n\tconst loadedSession = sessionManager.buildSessionContext();\n\tif (loadedSession.messages.length > 0) {\n\t\tagent.replaceMessages(loadedSession.messages);\n\t\tlog.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);\n\t}\n\n\tconst resourceLoader = new DefaultResourceLoader({\n\t\tcwd: process.cwd(),\n\t\tagentDir: APP_HOME_DIR,\n\t\tsettingsManager: settingsManager as any,\n\t\tskillsOverride: (base) => ({\n\t\t\tskills: [...base.skills, ...currentSkills],\n\t\t\tdiagnostics: base.diagnostics,\n\t\t}),\n\t});\n\n\tconst baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n\n\t// Create AgentSession\n\tconst session = new AgentSession({\n\t\tagent,\n\t\tsessionManager,\n\t\tsettingsManager: settingsManager as any,\n\t\tcwd: process.cwd(),\n\t\tmodelRegistry,\n\t\tresourceLoader,\n\t\tbaseToolsOverride,\n\t});\n\n\t// Mutable per-run state\n\tconst runState: {\n\t\tctx: DingTalkContext | null;\n\t\tlogCtx: { channelId: string; userName?: string; channelName?: string } | null;\n\t\tqueue: {\n\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n\t\t} | null;\n\t\tpendingTools: Map<string, { toolName: string; args: unknown; startTime: number }>;\n\t\ttotalUsage: {\n\t\t\tinput: number;\n\t\t\toutput: number;\n\t\t\tcacheRead: number;\n\t\t\tcacheWrite: number;\n\t\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t\t};\n\t\tstopReason: string;\n\t\terrorMessage: string | undefined;\n\t\tfinalOutcome: FinalOutcome;\n\t\tfinalResponseDelivered: boolean;\n\t} = {\n\t\tctx: null as DingTalkContext | null,\n\t\tlogCtx: null as { channelId: string; userName?: string; channelName?: string } | null,\n\t\tqueue: null as {\n\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n\t\t} | null,\n\t\tpendingTools: new Map<string, { toolName: string; args: unknown; startTime: number }>(),\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 as string | undefined,\n\t\tfinalOutcome: { kind: \"none\" },\n\t\tfinalResponseDelivered: false,\n\t};\n\n\tconst sendCommandReply = async (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\tconst handleModelBuiltinCommand = async (ctx: DingTalkContext, args: string): Promise<void> => {\n\t\tmodelRegistry.refresh();\n\t\tconst availableModels = await modelRegistry.getAvailable();\n\t\tconst currentModel = 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 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 session.setModel(match.match);\n\t\t\tactiveModel = match.match;\n\t\t\tawait 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 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 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\tconst handleBuiltInCommand = async (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 sendCommandReply(ctx, renderBuiltInHelp());\n\t\t\t\t\treturn;\n\t\t\t\tcase \"new\": {\n\t\t\t\t\tconst completed = await session.newSession();\n\t\t\t\t\tawait sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\tcompleted\n\t\t\t\t\t\t\t? `已开启新会话。\n\nSession ID: \\`${session.sessionId}\\``\n\t\t\t\t\t\t\t: \"新会话已取消。\",\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 session.compact(command.args || undefined);\n\t\t\t\t\tawait 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 = session.getSessionStats();\n\t\t\t\t\tconst currentModel = session.model ? `\\`${formatModelReference(session.model)}\\`` : \"(none)\";\n\t\t\t\t\tconst sessionFile = stats.sessionFile ? `\\`${basename(stats.sessionFile)}\\`` : \"(none)\";\n\t\t\t\t\tawait 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: \\`${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 handleModelBuiltinCommand(ctx, command.args);\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(`[${channelId}] Built-in command failed`, errMsg);\n\t\t\tawait sendCommandReply(ctx, `命令执行失败:${errMsg}`);\n\t\t}\n\t};\n\n\t// Subscribe to events ONCE\n\tsession.subscribe(async (event: any) => {\n\t\tif (!runState.ctx || !runState.logCtx || !runState.queue) return;\n\n\t\tconst { ctx, logCtx, queue, pendingTools } = runState;\n\n\t\tif (event.type === \"tool_execution_start\") {\n\t\t\tconst agentEvent = event as any & { type: \"tool_execution_start\" };\n\t\t\tconst args = agentEvent.args as { label?: string };\n\t\t\tconst label = args.label || agentEvent.toolName;\n\n\t\t\tpendingTools.set(agentEvent.toolCallId, {\n\t\t\t\ttoolName: agentEvent.toolName,\n\t\t\t\targs: agentEvent.args,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t});\n\n\t\t\tlog.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args as Record<string, unknown>);\n\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"tool\", label), false), \"tool label\");\n\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\tconst agentEvent = event as any & { type: \"tool_execution_end\" };\n\t\t\tconst resultStr = extractToolResultText(agentEvent.result);\n\t\t\tconst pending = pendingTools.get(agentEvent.toolCallId);\n\t\t\tpendingTools.delete(agentEvent.toolCallId);\n\n\t\t\tconst durationMs = pending ? Date.now() - pending.startTime : 0;\n\n\t\t\tif (agentEvent.isError) {\n\t\t\t\tlog.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t} else {\n\t\t\t\tlog.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t}\n\n\t\t\tif (agentEvent.isError) {\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"error\", truncate(resultStr, 200)), false),\n\t\t\t\t\t\"tool error\",\n\t\t\t\t);\n\t\t\t}\n\t\t} else if (event.type === \"message_start\") {\n\t\t\tconst agentEvent = event as any & { type: \"message_start\" };\n\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\tlog.logResponseStart(logCtx);\n\t\t\t}\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tconst agentEvent = event as any & { type: \"message_end\" };\n\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = agentEvent.message as any;\n\n\t\t\t\tif (assistantMsg.stopReason) {\n\t\t\t\t\trunState.stopReason = assistantMsg.stopReason;\n\t\t\t\t}\n\t\t\t\tif (assistantMsg.errorMessage) {\n\t\t\t\t\trunState.errorMessage = assistantMsg.errorMessage;\n\t\t\t\t}\n\n\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\trunState.totalUsage.input += assistantMsg.usage.input;\n\t\t\t\t\trunState.totalUsage.output += assistantMsg.usage.output;\n\t\t\t\t\trunState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\t\trunState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\t\trunState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n\t\t\t\t\trunState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n\t\t\t\t\trunState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n\t\t\t\t\trunState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n\t\t\t\t\trunState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\t\t\t\t}\n\n\t\t\t\tconst content = agentEvent.message.content;\n\t\t\t\tconst thinkingParts: string[] = [];\n\t\t\t\tconst textParts: string[] = [];\n\t\t\t\tlet hasToolCalls = false;\n\t\t\t\tfor (const part of content) {\n\t\t\t\t\tif (part.type === \"thinking\") {\n\t\t\t\t\t\tthinkingParts.push((part as any).thinking);\n\t\t\t\t\t} else if (part.type === \"text\") {\n\t\t\t\t\t\ttextParts.push((part as any).text);\n\t\t\t\t\t} else if (part.type === \"toolCall\") {\n\t\t\t\t\t\thasToolCalls = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst text = textParts.join(\"\\n\");\n\n\t\t\t\tfor (const thinking of thinkingParts) {\n\t\t\t\t\tlog.logThinking(logCtx, thinking);\n\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"thinking\", thinking), false), \"thinking\");\n\t\t\t\t}\n\n\t\t\t\tif (hasToolCalls && text.trim()) {\n\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"assistant\", text), false), \"assistant progress\");\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tconst turnEvent = event as any & {\n\t\t\t\ttype: \"turn_end\";\n\t\t\t\tmessage: { role: string; stopReason?: string; content: Array<{ type: string; text?: string }> };\n\t\t\t\ttoolResults: unknown[];\n\t\t\t};\n\t\t\tif (turnEvent.message.role === \"assistant\" && turnEvent.toolResults.length === 0) {\n\t\t\t\tif (turnEvent.message.stopReason === \"error\" || turnEvent.message.stopReason === \"aborted\") {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst finalContent = turnEvent.message.content as Array<{ type: string; text?: string }>;\n\t\t\t\tconst finalText = finalContent\n\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\" && !!part.text)\n\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\tconst trimmedFinalText = finalText.trim();\n\t\t\t\tif (!trimmedFinalText) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (trimmedFinalText === \"[SILENT]\" || trimmedFinalText.startsWith(\"[SILENT]\")) {\n\t\t\t\t\trunState.finalOutcome = { kind: \"silent\" };\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (runState.finalOutcome.kind === \"final\" && runState.finalOutcome.text.trim() === trimmedFinalText) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\trunState.finalOutcome = { kind: \"final\", text: finalText };\n\t\t\t\tlog.logResponse(logCtx, finalText);\n\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\tconst delivered = await ctx.respondPlain(finalText);\n\t\t\t\t\tif (delivered) {\n\t\t\t\t\t\trunState.finalResponseDelivered = true;\n\t\t\t\t\t}\n\t\t\t\t}, \"final response\");\n\t\t\t}\n\t\t} else if (event.type === \"auto_compaction_start\") {\n\t\t\tlog.logInfo(`Auto-compaction started (reason: ${(event as any).reason})`);\n\t\t\tqueue.enqueue(\n\t\t\t\t() => ctx.respond(formatProgressEntry(\"assistant\", \"Compacting context...\"), false),\n\t\t\t\t\"compaction start\",\n\t\t\t);\n\t\t} else if (event.type === \"auto_compaction_end\") {\n\t\t\tconst compEvent = event as any;\n\t\t\tif (compEvent.result) {\n\t\t\t\tlog.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);\n\t\t\t} else if (compEvent.aborted) {\n\t\t\t\tlog.logInfo(\"Auto-compaction aborted\");\n\t\t\t}\n\t\t} else if (event.type === \"auto_retry_start\") {\n\t\t\tconst retryEvent = event as any;\n\t\t\tlog.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);\n\t\t\tqueue.enqueue(\n\t\t\t\t() =>\n\t\t\t\t\tctx.respond(\n\t\t\t\t\t\tformatProgressEntry(\"assistant\", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`),\n\t\t\t\t\t\tfalse,\n\t\t\t\t\t),\n\t\t\t\t\"retry\",\n\t\t\t);\n\t\t}\n\t});\n\n\treturn {\n\t\tasync handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void> {\n\t\t\tawait handleBuiltInCommand(ctx, command);\n\t\t},\n\n\t\tasync run(ctx: DingTalkContext, _store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }> {\n\t\t\t// Reset per-run state\n\t\t\trunState.ctx = ctx;\n\t\t\trunState.logCtx = {\n\t\t\t\tchannelId: ctx.message.channel,\n\t\t\t\tuserName: ctx.message.userName,\n\t\t\t\tchannelName: ctx.channelName,\n\t\t\t};\n\t\t\trunState.pendingTools.clear();\n\t\t\trunState.totalUsage = {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t};\n\t\t\trunState.stopReason = \"stop\";\n\t\t\trunState.errorMessage = undefined;\n\t\t\trunState.finalOutcome = { kind: \"none\" };\n\t\t\trunState.finalResponseDelivered = false;\n\n\t\t\t// Create queue for this run\n\t\t\tlet queueChain = Promise.resolve();\n\t\t\trunState.queue = {\n\t\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void {\n\t\t\t\t\tqueueChain = queueChain.then(async () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait fn();\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(`DingTalk API error (${errorContext})`, errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog = true): void {\n\t\t\t\t\tthis.enqueue(\n\t\t\t\t\t\t() => (target === \"main\" ? ctx.respond(text, doLog) : ctx.respondInThread(text)),\n\t\t\t\t\t\terrorContext,\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\t// Ensure channel directory exists\n\t\t\t\tawait mkdir(channelDir, { recursive: true });\n\n\t\t\t\t// Update system prompt and runtime resources with fresh config\n\t\t\t\tconst soul = getSoul(workspaceDir);\n\t\t\t\tconst agentConfig = getAgentConfig(channelDir);\n\t\t\t\tconst memory = getMemory(channelDir);\n\t\t\t\tconst skills = loadPipiclawSkills(channelDir, workspacePath);\n\t\t\t\tcurrentSkills = skills;\n\t\t\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\t\t\tworkspacePath,\n\t\t\t\t\tchannelId,\n\t\t\t\t\tsoul,\n\t\t\t\t\tagentConfig,\n\t\t\t\t\tmemory,\n\t\t\t\t\tsandboxConfig,\n\t\t\t\t\tskills,\n\t\t\t\t);\n\t\t\t\tsession.agent.setSystemPrompt(systemPrompt);\n\t\t\t\tawait session.reload();\n\n\t\t\t\t// Sync messages from log.jsonl\n\t\t\t\tconst syncedCount = syncLogToSessionManager(sessionManager, channelDir, ctx.message.ts);\n\t\t\t\tif (syncedCount > 0) {\n\t\t\t\t\tlog.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);\n\t\t\t\t}\n\n\t\t\t\t// Reload messages from context.jsonl\n\t\t\t\tconst reloadedSession = sessionManager.buildSessionContext();\n\t\t\t\tif (reloadedSession.messages.length > 0) {\n\t\t\t\t\tagent.replaceMessages(reloadedSession.messages);\n\t\t\t\t\tlog.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);\n\t\t\t\t}\n\n\t\t\t\t// Log context info\n\t\t\t\tlog.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);\n\n\t\t\t\t// Build user message with timestamp and username prefix\n\t\t\t\tconst now = new Date();\n\t\t\t\tconst pad = (n: number) => n.toString().padStart(2, \"0\");\n\t\t\t\tconst offset = -now.getTimezoneOffset();\n\t\t\t\tconst offsetSign = offset >= 0 ? \"+\" : \"-\";\n\t\t\t\tconst offsetHours = pad(Math.floor(Math.abs(offset) / 60));\n\t\t\t\tconst offsetMins = pad(Math.abs(offset) % 60);\n\t\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\t\tconst userMessage = `[${timestamp}] [${ctx.message.userName || \"unknown\"}]: ${ctx.message.text}`;\n\n\t\t\t\t// Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)\n\t\t\t\tif (process.env.PIPICLAW_DEBUG) {\n\t\t\t\t\tconst debugContext = {\n\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\tmessages: session.messages,\n\t\t\t\t\t\tnewUserMessage: userMessage,\n\t\t\t\t\t};\n\t\t\t\t\tawait writeFile(join(channelDir, \"last_prompt.json\"), JSON.stringify(debugContext, null, 2));\n\t\t\t\t}\n\n\t\t\t\tawait session.prompt(userMessage);\n\t\t\t} catch (err) {\n\t\t\t\trunState.stopReason = \"error\";\n\t\t\t\trunState.errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\t\tlog.logWarning(`[${channelId}] Runner failed`, runState.errorMessage);\n\t\t\t} finally {\n\t\t\t\tawait queueChain;\n\t\t\t\tconst finalOutcome = runState.finalOutcome;\n\t\t\t\tconst finalOutcomeText = getFinalOutcomeText(finalOutcome);\n\n\t\t\t\ttry {\n\t\t\t\t\tif (runState.stopReason === \"error\" && runState.errorMessage && !runState.finalResponseDelivered) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(\"_Sorry, something went wrong_\");\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to post error message\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (isSilentOutcome(finalOutcome)) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.deleteMessage();\n\t\t\t\t\t\t\tlog.logInfo(\"Silent response - deleted message\");\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to delete message for silent response\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (finalOutcomeText && !runState.finalResponseDelivered) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(finalOutcomeText);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to replace message with final text\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tawait ctx.flush();\n\t\t\t\t} finally {\n\t\t\t\t\tawait ctx.close();\n\t\t\t\t}\n\n\t\t\t\t// Log usage summary\n\t\t\t\tif (runState.totalUsage.cost.total > 0) {\n\t\t\t\t\tconst messages = session.messages;\n\t\t\t\t\tconst lastAssistantMessage = messages\n\t\t\t\t\t\t.slice()\n\t\t\t\t\t\t.reverse()\n\t\t\t\t\t\t.find((m: any) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as any;\n\n\t\t\t\t\tconst contextTokens = lastAssistantMessage\n\t\t\t\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t\t\t\t: 0;\n\t\t\t\t\tconst currentRunModel = session.model ?? activeModel;\n\t\t\t\t\tconst contextWindow = currentRunModel.contextWindow || 200000;\n\n\t\t\t\t\tlog.logUsageSummary(runState.logCtx!, runState.totalUsage, contextTokens, contextWindow);\n\t\t\t\t}\n\n\t\t\t\t// Clear run state\n\t\t\t\trunState.ctx = null;\n\t\t\t\trunState.logCtx = null;\n\t\t\t\trunState.queue = null;\n\t\t\t}\n\n\t\t\treturn { stopReason: runState.stopReason, errorMessage: runState.errorMessage };\n\t\t},\n\n\t\tabort(): void {\n\t\t\tsession.abort();\n\t\t},\n\t};\n}\n\n/**\n * Translate container path back to host path for file operations\n */\nexport function translateToHostPath(\n\tcontainerPath: string,\n\tchannelDir: string,\n\tworkspacePath: string,\n\tchannelId: string,\n): string {\n\tif (workspacePath === \"/workspace\") {\n\t\tconst prefix = `/workspace/${channelId}/`;\n\t\tif (containerPath.startsWith(prefix)) {\n\t\t\treturn join(channelDir, containerPath.slice(prefix.length));\n\t\t}\n\t\tif (containerPath.startsWith(\"/workspace/\")) {\n\t\t\treturn join(channelDir, \"..\", containerPath.slice(\"/workspace/\".length));\n\t\t}\n\t}\n\treturn containerPath;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AAEvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAM/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;AAqfD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAOlH;AAqnBD;;GAEG;AACH,wBAAgB,mBAAmB,CAClC,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,GACf,MAAM,CAWR","sourcesContent":["import { Agent } from \"@mariozechner/pi-agent-core\";\nimport { type Api, getModel, type Model } from \"@mariozechner/pi-ai\";\nimport {\n\tAgentSession,\n\tAuthStorage,\n\tconvertToLlm,\n\tDefaultResourceLoader,\n\tformatSkillsForPrompt,\n\tloadSkillsFromDir,\n\tModelRegistry,\n\tSessionManager,\n\ttype Skill,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { mkdir, writeFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport { type BuiltInCommand, renderBuiltInHelp } from \"./commands.js\";\nimport { PipiclawSettingsManager, syncLogToSessionManager } from \"./context.js\";\nimport type { DingTalkContext } from \"./dingtalk.js\";\nimport * as log from \"./log.js\";\nimport { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from \"./paths.js\";\nimport { createExecutor, type SandboxConfig } from \"./sandbox.js\";\nimport type { ChannelStore } from \"./store.js\";\nimport { createPipiclawTools } from \"./tools/index.js\";\n\n// Default model - will be overridden by ModelRegistry if custom models are configured\nconst defaultModel = getModel(\"anthropic\", \"claude-sonnet-4-5\");\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\nfunction formatModelReference(model: Model<Api>): string {\n\treturn `${model.provider}/${model.id}`;\n}\n\nfunction findExactModelReferenceMatch(\n\tmodelReference: string,\n\tavailableModels: Model<Api>[],\n): { match?: Model<Api>; ambiguous: boolean } {\n\tconst trimmedReference = modelReference.trim();\n\tif (!trimmedReference) {\n\t\treturn { ambiguous: false };\n\t}\n\n\tconst normalizedReference = trimmedReference.toLowerCase();\n\n\tconst canonicalMatches = availableModels.filter(\n\t\t(model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,\n\t);\n\tif (canonicalMatches.length === 1) {\n\t\treturn { match: canonicalMatches[0], ambiguous: false };\n\t}\n\tif (canonicalMatches.length > 1) {\n\t\treturn { ambiguous: true };\n\t}\n\n\tconst slashIndex = trimmedReference.indexOf(\"/\");\n\tif (slashIndex !== -1) {\n\t\tconst provider = trimmedReference.substring(0, slashIndex).trim();\n\t\tconst modelId = trimmedReference.substring(slashIndex + 1).trim();\n\t\tif (provider && modelId) {\n\t\t\tconst providerMatches = availableModels.filter(\n\t\t\t\t(model) =>\n\t\t\t\t\tmodel.provider.toLowerCase() === provider.toLowerCase() &&\n\t\t\t\t\tmodel.id.toLowerCase() === modelId.toLowerCase(),\n\t\t\t);\n\t\t\tif (providerMatches.length === 1) {\n\t\t\t\treturn { match: providerMatches[0], ambiguous: false };\n\t\t\t}\n\t\t\tif (providerMatches.length > 1) {\n\t\t\t\treturn { ambiguous: true };\n\t\t\t}\n\t\t}\n\t}\n\n\tconst idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);\n\tif (idMatches.length === 1) {\n\t\treturn { match: idMatches[0], ambiguous: false };\n\t}\n\n\treturn { ambiguous: idMatches.length > 1 };\n}\n\nfunction formatModelList(models: Model<Api>[], currentModel: Model<Api> | undefined, limit: number = 20): string {\n\tconst refs = models\n\t\t.slice()\n\t\t.sort((a, b) => formatModelReference(a).localeCompare(formatModelReference(b)))\n\t\t.map((model) => {\n\t\t\tconst ref = formatModelReference(model);\n\t\t\tconst marker =\n\t\t\t\tcurrentModel && currentModel.provider === model.provider && currentModel.id === model.id\n\t\t\t\t\t? \" (current)\"\n\t\t\t\t\t: \"\";\n\t\t\treturn `- \\`${ref}\\`${marker}`;\n\t\t});\n\n\tif (refs.length <= limit) {\n\t\treturn refs.join(\"\\n\");\n\t}\n\n\treturn `${refs.slice(0, limit).join(\"\\n\")}\\n- ... and ${refs.length - limit} more`;\n}\n\nfunction resolveInitialModel(modelRegistry: ModelRegistry, settingsManager: PipiclawSettingsManager): Model<Api> {\n\tconst savedProvider = settingsManager.getDefaultProvider();\n\tconst savedModelId = settingsManager.getDefaultModel();\n\tconst availableModels = modelRegistry.getAvailable();\n\tif (savedProvider && savedModelId) {\n\t\tconst savedModel = modelRegistry.find(savedProvider, savedModelId);\n\t\tif (\n\t\t\tsavedModel &&\n\t\t\tavailableModels.some((model) => model.provider === savedModel.provider && model.id === savedModel.id)\n\t\t) {\n\t\t\treturn savedModel;\n\t\t}\n\t}\n\n\tif (availableModels.length > 0) {\n\t\treturn availableModels[0];\n\t}\n\n\treturn defaultModel;\n}\n\nasync function getApiKeyForModel(modelRegistry: ModelRegistry, model: any): Promise<string> {\n\tconst key = await modelRegistry.getApiKeyForProvider(model.provider);\n\tif (key) return key;\n\t// Fallback: try anthropic env var\n\tconst envKey = process.env.ANTHROPIC_API_KEY;\n\tif (envKey) return envKey;\n\tthrow new Error(\n\t\t`No API key found for provider: ${model.provider}.\\n\\n` +\n\t\t\t\"Configure API key in ~/.pi/agent/models.json or set ANTHROPIC_API_KEY environment variable.\",\n\t);\n}\n\n// ============================================================================\n// Configuration file loaders: SOUL.md, AGENTS.md, MEMORY.md\n// ============================================================================\n\n/**\n * Load SOUL.md — defines the agent's identity, personality, and communication style.\n * Only loaded from workspace root (global).\n */\nfunction getSoul(workspaceDir: string): string {\n\tconst soulPath = join(workspaceDir, \"SOUL.md\");\n\tif (existsSync(soulPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(soulPath, \"utf-8\").trim();\n\t\t\tif (content) return content;\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read SOUL.md\", `${soulPath}: ${error}`);\n\t\t}\n\t}\n\treturn \"\";\n}\n\n/**\n * Load AGENTS.md — defines the agent's behavior instructions, capabilities, and constraints.\n * Supports both global (workspace root) and channel-level override.\n */\nfunction getAgentConfig(channelDir: string): string {\n\tconst parts: string[] = [];\n\n\t// Read workspace-level AGENTS.md (global)\n\tconst workspaceAgentPath = join(channelDir, \"..\", \"AGENTS.md\");\n\tif (existsSync(workspaceAgentPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(workspaceAgentPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(content);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read workspace AGENTS.md\", `${workspaceAgentPath}: ${error}`);\n\t\t}\n\t}\n\n\t// Read channel-specific AGENTS.md (overrides/extends global)\n\tconst channelAgentPath = join(channelDir, \"AGENTS.md\");\n\tif (existsSync(channelAgentPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(channelAgentPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(content);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read channel AGENTS.md\", `${channelAgentPath}: ${error}`);\n\t\t}\n\t}\n\n\treturn parts.join(\"\\n\\n\");\n}\n\nfunction getMemory(channelDir: string): string {\n\tconst parts: string[] = [];\n\n\t// Read workspace-level memory (shared across all channels)\n\tconst workspaceMemoryPath = join(channelDir, \"..\", \"MEMORY.md\");\n\tif (existsSync(workspaceMemoryPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(workspaceMemoryPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(`### Global Workspace Memory\\n${content}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read workspace memory\", `${workspaceMemoryPath}: ${error}`);\n\t\t}\n\t}\n\n\t// Read channel-specific memory\n\tconst channelMemoryPath = join(channelDir, \"MEMORY.md\");\n\tif (existsSync(channelMemoryPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(channelMemoryPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(`### Channel-Specific Memory\\n${content}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read channel memory\", `${channelMemoryPath}: ${error}`);\n\t\t}\n\t}\n\n\tif (parts.length === 0) {\n\t\treturn \"(no working memory yet)\";\n\t}\n\n\tconst combined = parts.join(\"\\n\\n\");\n\n\t// Warn if memory is getting too large (consumes system prompt token budget)\n\tif (combined.length > 5000) {\n\t\treturn `\\u26a0\\ufe0f Memory is large (${combined.length} chars). Consolidate: remove outdated entries, merge duplicates, tighten descriptions.\\n\\n${combined}`;\n\t}\n\n\treturn combined;\n}\n\nfunction loadPipiclawSkills(channelDir: string, workspacePath: string): Skill[] {\n\tconst skillMap = new Map<string, Skill>();\n\tconst hostWorkspacePath = join(channelDir, \"..\");\n\n\tconst translatePath = (hostPath: string): string => {\n\t\tif (hostPath.startsWith(hostWorkspacePath)) {\n\t\t\treturn workspacePath + hostPath.slice(hostWorkspacePath.length);\n\t\t}\n\t\treturn hostPath;\n\t};\n\n\t// Load workspace-level skills (global)\n\tconst workspaceSkillsDir = join(hostWorkspacePath, \"skills\");\n\tfor (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: \"workspace\" }).skills) {\n\t\tskill.filePath = translatePath(skill.filePath);\n\t\tskill.baseDir = translatePath(skill.baseDir);\n\t\tskillMap.set(skill.name, skill);\n\t}\n\n\t// Load channel-specific skills\n\tconst channelSkillsDir = join(channelDir, \"skills\");\n\tfor (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: \"channel\" }).skills) {\n\t\tskill.filePath = translatePath(skill.filePath);\n\t\tskill.baseDir = translatePath(skill.baseDir);\n\t\tskillMap.set(skill.name, skill);\n\t}\n\n\treturn Array.from(skillMap.values());\n}\n\n// ============================================================================\n// System Prompt Builder\n// ============================================================================\n\nfunction buildSystemPrompt(\n\tworkspacePath: string,\n\tchannelId: string,\n\tsoul: string,\n\tagentConfig: string,\n\tmemory: string,\n\tsandboxConfig: SandboxConfig,\n\tskills: Skill[],\n): string {\n\tconst channelPath = `${workspacePath}/${channelId}`;\n\tconst isDocker = sandboxConfig.type === \"docker\";\n\n\tconst envDescription = isDocker\n\t\t? `You are running inside a Docker container (Alpine Linux).\n- Bash working directory: / (use cd or absolute paths)\n- Install tools with: apk add <package>\n- Your changes persist across sessions`\n\t\t: `You are running directly on the host machine.\n- Bash working directory: ${process.cwd()}\n- Be careful with system modifications`;\n\n\t// Build system prompt with configuration file layering:\n\t// 1. SOUL.md (identity/personality)\n\t// 2. Core instructions\n\t// 3. AGENTS.md (behavior instructions)\n\t// 4. Skills, Events, Memory\n\n\tconst sections: string[] = [];\n\n\t// 1. SOUL.md — Agent identity\n\tif (soul) {\n\t\tsections.push(soul);\n\t} else {\n\t\tsections.push(\"You are a DingTalk bot assistant. Be concise and helpful.\");\n\t}\n\n\t// 2. Core instructions\n\tsections.push(`## Context\n- For current date/time, use: date\n- You have access to previous conversation context including tool results from prior turns.\n- For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).\n\n## Formatting\nUse Markdown for formatting. DingTalk AI Card supports basic Markdown:\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`code\\`\\`\\`, Links: [text](url)\n\n## Environment\n${envDescription}\n\n## Workspace Layout\n${workspacePath}/\n├── SOUL.md # Your identity/personality (read-only)\n├── AGENTS.md # Custom behavior instructions (read-only)\n├── MEMORY.md # Global memory (all channels, you can read/write)\n├── skills/ # Global CLI tools you create\n├── events/ # Scheduled events\n└── ${channelId}/ # This channel\n ├── AGENTS.md # Channel-specific instructions (read-only)\n ├── MEMORY.md # Channel-specific memory (you can read/write)\n ├── log.jsonl # Message history (no tool results)\n ├── scratch/ # Your working directory\n └── skills/ # Channel-specific tools`);\n\n\t// 3. AGENTS.md — User-defined instructions\n\tif (agentConfig) {\n\t\tsections.push(`## Agent Instructions\\n${agentConfig}`);\n\t}\n\n\t// 4. Skills\n\tsections.push(`## Skills (Custom CLI Tools)\nYou can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).\n\n### Creating Skills\nStore in \\`${workspacePath}/skills/<name>/\\` (global) or \\`${channelPath}/skills/<name>/\\` (channel-specific).\nEach skill directory needs a \\`SKILL.md\\` with YAML frontmatter:\n\n\\`\\`\\`markdown\n---\nname: skill-name\ndescription: Short description of what this skill does\n---\n\n# Skill Name\n\nUsage instructions, examples, etc.\nScripts are in: {baseDir}/\n\\`\\`\\`\n\n\\`name\\` and \\`description\\` are required. Use \\`{baseDir}\\` as placeholder for the skill's directory path.\n\n### Available Skills\n${skills.length > 0 ? formatSkillsForPrompt(skills) : \"(no skills installed yet)\"}`);\n\n\t// 5. Events\n\tsections.push(`## Events\nYou can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \\`${workspacePath}/events/\\`.\n\n### Event Types\n\n**Immediate** - Triggers as soon as harness sees the file.\n\\`\\`\\`json\n{\"type\": \"immediate\", \"channelId\": \"${channelId}\", \"text\": \"New event occurred\"}\n\\`\\`\\`\n\n**One-shot** - Triggers once at a specific time.\n\\`\\`\\`json\n{\"type\": \"one-shot\", \"channelId\": \"${channelId}\", \"text\": \"Reminder\", \"at\": \"2025-12-15T09:00:00+08:00\"}\n\\`\\`\\`\n\n**Periodic** - Triggers on a cron schedule.\n\\`\\`\\`json\n{\"type\": \"periodic\", \"channelId\": \"${channelId}\", \"text\": \"Check inbox\", \"schedule\": \"0 9 * * 1-5\", \"timezone\": \"${Intl.DateTimeFormat().resolvedOptions().timeZone}\"}\n\\`\\`\\`\n\n### Cron Format\n\\`minute hour day-of-month month day-of-week\\`\n\n### Creating Events\n\\`\\`\\`bash\ncat > ${workspacePath}/events/reminder-$(date +%s).json << 'EOF'\n{\"type\": \"one-shot\", \"channelId\": \"${channelId}\", \"text\": \"Reminder text\", \"at\": \"2025-12-14T09:00:00+08:00\"}\nEOF\n\\`\\`\\`\n\n### Silent Completion\nFor periodic events where there's nothing to report, respond with just \\`[SILENT]\\`. This deletes the status message. Use this to avoid spam when periodic checks find nothing.\n\n### Limits\nMaximum 5 events can be queued.`);\n\n\t// 6. Memory\n\tsections.push(`## Memory\nWrite to MEMORY.md files to persist context across conversations.\n- Global (${workspacePath}/MEMORY.md): skills, preferences, project info\n- Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work\n\n### Guidelines\n- Keep each MEMORY.md concise (target: under 50 lines)\n- Use clear headers to organize entries (## Preferences, ## Projects, etc.)\n- Remove outdated entries when they are no longer relevant\n- Merge duplicate or redundant items\n- Prefer structured formats (lists, key-value pairs) over prose\n- Update when you learn something important or when asked to remember something\n\n### Current Memory\n${memory}`);\n\n\t// 7. System Configuration Log\n\tsections.push(`## System Configuration Log\nMaintain ${workspacePath}/SYSTEM.md to log all environment modifications:\n- Installed packages (apk add, npm install, pip install)\n- Environment variables set\n- Config files modified\n- Skill dependencies installed\n\nUpdate this file whenever you modify the environment.`);\n\n\t// 8. Tools\n\tsections.push(`## Tools\n- bash: Run shell commands (primary tool). Install packages as needed.\n- read: Read files\n- write: Create/overwrite files\n- edit: Surgical file edits\n- attach: Share files (note: DingTalk file sharing is limited, output as text when possible)\n\nEach tool requires a \"label\" parameter (shown to user).`);\n\n\t// 9. Log Queries\n\tsections.push(`## Log Queries (for older history)\nFormat: \\`{\"date\":\"...\",\"ts\":\"...\",\"user\":\"...\",\"userName\":\"...\",\"text\":\"...\",\"isBot\":false}\\`\nThe log contains user messages and your final responses (not tool calls/results).\n${isDocker ? \"Install jq: apk add jq\" : \"\"}\n\n\\`\\`\\`bash\n# Recent messages\ntail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Search for specific topic\ngrep -i \"topic\" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\\`\\`\\``);\n\n\treturn sections.join(\"\\n\\n\");\n}\n\n// ============================================================================\n// Agent Runner\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// Cache runners per channel\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 = createRunner(sandboxConfig, channelId, channelDir);\n\tchannelRunners.set(channelId, runner);\n\treturn runner;\n}\n\nfunction createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst executor = createExecutor(sandboxConfig);\n\tconst workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, \"\"));\n\tconst workspaceDir = join(channelDir, \"..\");\n\n\t// Create tools\n\tconst tools = createPipiclawTools(executor);\n\n\t// Initial system prompt\n\tconst soul = getSoul(workspaceDir);\n\tconst agentConfig = getAgentConfig(channelDir);\n\tconst memory = getMemory(channelDir);\n\tconst initialSkills = loadPipiclawSkills(channelDir, workspacePath);\n\tlet currentSkills = initialSkills;\n\tconst systemPrompt = buildSystemPrompt(\n\t\tworkspacePath,\n\t\tchannelId,\n\t\tsoul,\n\t\tagentConfig,\n\t\tmemory,\n\t\tsandboxConfig,\n\t\tinitialSkills,\n\t);\n\n\t// Create session manager\n\tconst contextFile = join(channelDir, \"context.jsonl\");\n\tconst sessionManager = SessionManager.open(contextFile, channelDir);\n\tconst settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);\n\n\t// Create AuthStorage and ModelRegistry\n\tconst authStorage = AuthStorage.create(AUTH_CONFIG_PATH);\n\tconst modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);\n\n\t// Resolve model: prefer saved global default, fall back to first available model\n\tlet activeModel = resolveInitialModel(modelRegistry, settingsManager);\n\tlog.logInfo(`Using model: ${activeModel.provider}/${activeModel.id} (${activeModel.name})`);\n\n\t// Create agent\n\tconst agent = new Agent({\n\t\tinitialState: {\n\t\t\tsystemPrompt,\n\t\t\tmodel: activeModel,\n\t\t\tthinkingLevel: \"off\",\n\t\t\ttools,\n\t\t},\n\t\tconvertToLlm,\n\t\tgetApiKey: async () => getApiKeyForModel(modelRegistry, activeModel),\n\t});\n\n\t// Load existing messages\n\tconst loadedSession = sessionManager.buildSessionContext();\n\tif (loadedSession.messages.length > 0) {\n\t\tagent.replaceMessages(loadedSession.messages);\n\t\tlog.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);\n\t}\n\n\tconst resourceLoader = new DefaultResourceLoader({\n\t\tcwd: process.cwd(),\n\t\tagentDir: APP_HOME_DIR,\n\t\tsettingsManager: settingsManager as any,\n\t\tskillsOverride: (base) => ({\n\t\t\tskills: [...base.skills, ...currentSkills],\n\t\t\tdiagnostics: base.diagnostics,\n\t\t}),\n\t});\n\n\tconst baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n\n\t// Create AgentSession\n\tconst session = new AgentSession({\n\t\tagent,\n\t\tsessionManager,\n\t\tsettingsManager: settingsManager as any,\n\t\tcwd: process.cwd(),\n\t\tmodelRegistry,\n\t\tresourceLoader,\n\t\tbaseToolsOverride,\n\t});\n\n\t// Mutable per-run state\n\tconst runState: {\n\t\tctx: DingTalkContext | null;\n\t\tlogCtx: { channelId: string; userName?: string; channelName?: string } | null;\n\t\tqueue: {\n\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n\t\t} | null;\n\t\tpendingTools: Map<string, { toolName: string; args: unknown; startTime: number }>;\n\t\ttotalUsage: {\n\t\t\tinput: number;\n\t\t\toutput: number;\n\t\t\tcacheRead: number;\n\t\t\tcacheWrite: number;\n\t\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t\t};\n\t\tstopReason: string;\n\t\terrorMessage: string | undefined;\n\t\tfinalOutcome: FinalOutcome;\n\t\tfinalResponseDelivered: boolean;\n\t} = {\n\t\tctx: null as DingTalkContext | null,\n\t\tlogCtx: null as { channelId: string; userName?: string; channelName?: string } | null,\n\t\tqueue: null as {\n\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n\t\t} | null,\n\t\tpendingTools: new Map<string, { toolName: string; args: unknown; startTime: number }>(),\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 as string | undefined,\n\t\tfinalOutcome: { kind: \"none\" },\n\t\tfinalResponseDelivered: false,\n\t};\n\n\tconst sendCommandReply = async (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\tconst 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\tconst queueBusyMessage = async (delivery: \"steer\" | \"followUp\", text: string): Promise<void> => {\n\t\tif (!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 session.followUp(text);\n\t\t} else {\n\t\t\tawait session.steer(text);\n\t\t}\n\t};\n\n\tconst handleModelBuiltinCommand = async (ctx: DingTalkContext, args: string): Promise<void> => {\n\t\tmodelRegistry.refresh();\n\t\tconst availableModels = await modelRegistry.getAvailable();\n\t\tconst currentModel = 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 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 session.setModel(match.match);\n\t\t\tactiveModel = match.match;\n\t\t\tawait 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 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 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\tconst handleBuiltInCommand = async (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 sendCommandReply(ctx, renderBuiltInHelp());\n\t\t\t\t\treturn;\n\t\t\t\tcase \"new\": {\n\t\t\t\t\tconst completed = await session.newSession();\n\t\t\t\t\tawait sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\tcompleted\n\t\t\t\t\t\t\t? `已开启新会话。\n\nSession ID: \\`${session.sessionId}\\``\n\t\t\t\t\t\t\t: \"新会话已取消。\",\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 session.compact(command.args || undefined);\n\t\t\t\t\tawait 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 = session.getSessionStats();\n\t\t\t\t\tconst currentModel = session.model ? `\\`${formatModelReference(session.model)}\\`` : \"(none)\";\n\t\t\t\t\tconst sessionFile = stats.sessionFile ? `\\`${basename(stats.sessionFile)}\\`` : \"(none)\";\n\t\t\t\t\tawait 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: \\`${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 handleModelBuiltinCommand(ctx, command.args);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"stop\":\n\t\t\t\t\tawait 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\trequireQueuedMessage(command.args, \"steer\");\n\t\t\t\t\tawait sendCommandReply(ctx, \"No task is running. Send the message directly instead of using `/steer`.\");\n\t\t\t\t\treturn;\n\t\t\t\tcase \"followup\":\n\t\t\t\t\trequireQueuedMessage(command.args, \"followup\");\n\t\t\t\t\tawait 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(`[${channelId}] Built-in command failed`, errMsg);\n\t\t\tawait sendCommandReply(ctx, `命令执行失败:${errMsg}`);\n\t\t}\n\t};\n\n\t// Subscribe to events ONCE\n\tsession.subscribe(async (event: any) => {\n\t\tif (!runState.ctx || !runState.logCtx || !runState.queue) return;\n\n\t\tconst { ctx, logCtx, queue, pendingTools } = runState;\n\n\t\tif (event.type === \"tool_execution_start\") {\n\t\t\tconst agentEvent = event as any & { type: \"tool_execution_start\" };\n\t\t\tconst args = agentEvent.args as { label?: string };\n\t\t\tconst label = args.label || agentEvent.toolName;\n\n\t\t\tpendingTools.set(agentEvent.toolCallId, {\n\t\t\t\ttoolName: agentEvent.toolName,\n\t\t\t\targs: agentEvent.args,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t});\n\n\t\t\tlog.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args as Record<string, unknown>);\n\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"tool\", label), false), \"tool label\");\n\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\tconst agentEvent = event as any & { type: \"tool_execution_end\" };\n\t\t\tconst resultStr = extractToolResultText(agentEvent.result);\n\t\t\tconst pending = pendingTools.get(agentEvent.toolCallId);\n\t\t\tpendingTools.delete(agentEvent.toolCallId);\n\n\t\t\tconst durationMs = pending ? Date.now() - pending.startTime : 0;\n\n\t\t\tif (agentEvent.isError) {\n\t\t\t\tlog.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t} else {\n\t\t\t\tlog.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t}\n\n\t\t\tif (agentEvent.isError) {\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"error\", truncate(resultStr, 200)), false),\n\t\t\t\t\t\"tool error\",\n\t\t\t\t);\n\t\t\t}\n\t\t} else if (event.type === \"message_start\") {\n\t\t\tconst agentEvent = event as any & { type: \"message_start\" };\n\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\tlog.logResponseStart(logCtx);\n\t\t\t}\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tconst agentEvent = event as any & { type: \"message_end\" };\n\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = agentEvent.message as any;\n\n\t\t\t\tif (assistantMsg.stopReason) {\n\t\t\t\t\trunState.stopReason = assistantMsg.stopReason;\n\t\t\t\t}\n\t\t\t\tif (assistantMsg.errorMessage) {\n\t\t\t\t\trunState.errorMessage = assistantMsg.errorMessage;\n\t\t\t\t}\n\n\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\trunState.totalUsage.input += assistantMsg.usage.input;\n\t\t\t\t\trunState.totalUsage.output += assistantMsg.usage.output;\n\t\t\t\t\trunState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\t\trunState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\t\trunState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n\t\t\t\t\trunState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n\t\t\t\t\trunState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n\t\t\t\t\trunState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n\t\t\t\t\trunState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\t\t\t\t}\n\n\t\t\t\tconst content = agentEvent.message.content;\n\t\t\t\tconst thinkingParts: string[] = [];\n\t\t\t\tconst textParts: string[] = [];\n\t\t\t\tlet hasToolCalls = false;\n\t\t\t\tfor (const part of content) {\n\t\t\t\t\tif (part.type === \"thinking\") {\n\t\t\t\t\t\tthinkingParts.push((part as any).thinking);\n\t\t\t\t\t} else if (part.type === \"text\") {\n\t\t\t\t\t\ttextParts.push((part as any).text);\n\t\t\t\t\t} else if (part.type === \"toolCall\") {\n\t\t\t\t\t\thasToolCalls = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst text = textParts.join(\"\\n\");\n\n\t\t\t\tfor (const thinking of thinkingParts) {\n\t\t\t\t\tlog.logThinking(logCtx, thinking);\n\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"thinking\", thinking), false), \"thinking\");\n\t\t\t\t}\n\n\t\t\t\tif (hasToolCalls && text.trim()) {\n\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"assistant\", text), false), \"assistant progress\");\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tconst turnEvent = event as any & {\n\t\t\t\ttype: \"turn_end\";\n\t\t\t\tmessage: { role: string; stopReason?: string; content: Array<{ type: string; text?: string }> };\n\t\t\t\ttoolResults: unknown[];\n\t\t\t};\n\t\t\tif (turnEvent.message.role === \"assistant\" && turnEvent.toolResults.length === 0) {\n\t\t\t\tif (turnEvent.message.stopReason === \"error\" || turnEvent.message.stopReason === \"aborted\") {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst finalContent = turnEvent.message.content as Array<{ type: string; text?: string }>;\n\t\t\t\tconst finalText = finalContent\n\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\" && !!part.text)\n\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\tconst trimmedFinalText = finalText.trim();\n\t\t\t\tif (!trimmedFinalText) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (trimmedFinalText === \"[SILENT]\" || trimmedFinalText.startsWith(\"[SILENT]\")) {\n\t\t\t\t\trunState.finalOutcome = { kind: \"silent\" };\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (runState.finalOutcome.kind === \"final\" && runState.finalOutcome.text.trim() === trimmedFinalText) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\trunState.finalOutcome = { kind: \"final\", text: finalText };\n\t\t\t\tlog.logResponse(logCtx, finalText);\n\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\tconst delivered = await ctx.respondPlain(finalText);\n\t\t\t\t\tif (delivered) {\n\t\t\t\t\t\trunState.finalResponseDelivered = true;\n\t\t\t\t\t}\n\t\t\t\t}, \"final response\");\n\t\t\t}\n\t\t} else if (event.type === \"auto_compaction_start\") {\n\t\t\tlog.logInfo(`Auto-compaction started (reason: ${(event as any).reason})`);\n\t\t\tqueue.enqueue(\n\t\t\t\t() => ctx.respond(formatProgressEntry(\"assistant\", \"Compacting context...\"), false),\n\t\t\t\t\"compaction start\",\n\t\t\t);\n\t\t} else if (event.type === \"auto_compaction_end\") {\n\t\t\tconst compEvent = event as any;\n\t\t\tif (compEvent.result) {\n\t\t\t\tlog.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);\n\t\t\t} else if (compEvent.aborted) {\n\t\t\t\tlog.logInfo(\"Auto-compaction aborted\");\n\t\t\t}\n\t\t} else if (event.type === \"auto_retry_start\") {\n\t\t\tconst retryEvent = event as any;\n\t\t\tlog.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);\n\t\t\tqueue.enqueue(\n\t\t\t\t() =>\n\t\t\t\t\tctx.respond(\n\t\t\t\t\t\tformatProgressEntry(\"assistant\", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`),\n\t\t\t\t\t\tfalse,\n\t\t\t\t\t),\n\t\t\t\t\"retry\",\n\t\t\t);\n\t\t}\n\t});\n\n\treturn {\n\t\tasync handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void> {\n\t\t\tawait handleBuiltInCommand(ctx, command);\n\t\t},\n\n\t\tasync queueSteer(text: string): Promise<void> {\n\t\t\tawait queueBusyMessage(\"steer\", requireQueuedMessage(text, \"steer\"));\n\t\t},\n\n\t\tasync queueFollowUp(text: string): Promise<void> {\n\t\t\tawait queueBusyMessage(\"followUp\", requireQueuedMessage(text, \"followup\"));\n\t\t},\n\n\t\tasync run(ctx: DingTalkContext, _store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }> {\n\t\t\t// Reset per-run state\n\t\t\trunState.ctx = ctx;\n\t\t\trunState.logCtx = {\n\t\t\t\tchannelId: ctx.message.channel,\n\t\t\t\tuserName: ctx.message.userName,\n\t\t\t\tchannelName: ctx.channelName,\n\t\t\t};\n\t\t\trunState.pendingTools.clear();\n\t\t\trunState.totalUsage = {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t};\n\t\t\trunState.stopReason = \"stop\";\n\t\t\trunState.errorMessage = undefined;\n\t\t\trunState.finalOutcome = { kind: \"none\" };\n\t\t\trunState.finalResponseDelivered = false;\n\n\t\t\t// Create queue for this run\n\t\t\tlet queueChain = Promise.resolve();\n\t\t\trunState.queue = {\n\t\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void {\n\t\t\t\t\tqueueChain = queueChain.then(async () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait fn();\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(`DingTalk API error (${errorContext})`, errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog = true): void {\n\t\t\t\t\tthis.enqueue(\n\t\t\t\t\t\t() => (target === \"main\" ? ctx.respond(text, doLog) : ctx.respondInThread(text)),\n\t\t\t\t\t\terrorContext,\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\t// Ensure channel directory exists\n\t\t\t\tawait mkdir(channelDir, { recursive: true });\n\n\t\t\t\t// Update system prompt and runtime resources with fresh config\n\t\t\t\tconst soul = getSoul(workspaceDir);\n\t\t\t\tconst agentConfig = getAgentConfig(channelDir);\n\t\t\t\tconst memory = getMemory(channelDir);\n\t\t\t\tconst skills = loadPipiclawSkills(channelDir, workspacePath);\n\t\t\t\tcurrentSkills = skills;\n\t\t\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\t\t\tworkspacePath,\n\t\t\t\t\tchannelId,\n\t\t\t\t\tsoul,\n\t\t\t\t\tagentConfig,\n\t\t\t\t\tmemory,\n\t\t\t\t\tsandboxConfig,\n\t\t\t\t\tskills,\n\t\t\t\t);\n\t\t\t\tsession.agent.setSystemPrompt(systemPrompt);\n\t\t\t\tawait session.reload();\n\n\t\t\t\t// Sync messages from log.jsonl\n\t\t\t\tconst syncedCount = syncLogToSessionManager(sessionManager, channelDir, ctx.message.ts);\n\t\t\t\tif (syncedCount > 0) {\n\t\t\t\t\tlog.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);\n\t\t\t\t}\n\n\t\t\t\t// Reload messages from context.jsonl\n\t\t\t\tconst reloadedSession = sessionManager.buildSessionContext();\n\t\t\t\tif (reloadedSession.messages.length > 0) {\n\t\t\t\t\tagent.replaceMessages(reloadedSession.messages);\n\t\t\t\t\tlog.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);\n\t\t\t\t}\n\n\t\t\t\t// Log context info\n\t\t\t\tlog.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);\n\n\t\t\t\t// Build user message with timestamp and username prefix\n\t\t\t\tconst now = new Date();\n\t\t\t\tconst pad = (n: number) => n.toString().padStart(2, \"0\");\n\t\t\t\tconst offset = -now.getTimezoneOffset();\n\t\t\t\tconst offsetSign = offset >= 0 ? \"+\" : \"-\";\n\t\t\t\tconst offsetHours = pad(Math.floor(Math.abs(offset) / 60));\n\t\t\t\tconst offsetMins = pad(Math.abs(offset) % 60);\n\t\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\t\tconst userMessage = `[${timestamp}] [${ctx.message.userName || \"unknown\"}]: ${ctx.message.text}`;\n\n\t\t\t\t// Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)\n\t\t\t\tif (process.env.PIPICLAW_DEBUG) {\n\t\t\t\t\tconst debugContext = {\n\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\tmessages: session.messages,\n\t\t\t\t\t\tnewUserMessage: userMessage,\n\t\t\t\t\t};\n\t\t\t\t\tawait writeFile(join(channelDir, \"last_prompt.json\"), JSON.stringify(debugContext, null, 2));\n\t\t\t\t}\n\n\t\t\t\tawait session.prompt(userMessage);\n\t\t\t} catch (err) {\n\t\t\t\trunState.stopReason = \"error\";\n\t\t\t\trunState.errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\t\tlog.logWarning(`[${channelId}] Runner failed`, runState.errorMessage);\n\t\t\t} finally {\n\t\t\t\tawait queueChain;\n\t\t\t\tconst finalOutcome = runState.finalOutcome;\n\t\t\t\tconst finalOutcomeText = getFinalOutcomeText(finalOutcome);\n\n\t\t\t\ttry {\n\t\t\t\t\tif (runState.stopReason === \"error\" && runState.errorMessage && !runState.finalResponseDelivered) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(\"_Sorry, something went wrong_\");\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to post error message\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (isSilentOutcome(finalOutcome)) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.deleteMessage();\n\t\t\t\t\t\t\tlog.logInfo(\"Silent response - deleted message\");\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to delete message for silent response\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (finalOutcomeText && !runState.finalResponseDelivered) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(finalOutcomeText);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to replace message with final text\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tawait ctx.flush();\n\t\t\t\t} finally {\n\t\t\t\t\tawait ctx.close();\n\t\t\t\t}\n\n\t\t\t\t// Log usage summary\n\t\t\t\tif (runState.totalUsage.cost.total > 0) {\n\t\t\t\t\tconst messages = session.messages;\n\t\t\t\t\tconst lastAssistantMessage = messages\n\t\t\t\t\t\t.slice()\n\t\t\t\t\t\t.reverse()\n\t\t\t\t\t\t.find((m: any) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as any;\n\n\t\t\t\t\tconst contextTokens = lastAssistantMessage\n\t\t\t\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t\t\t\t: 0;\n\t\t\t\t\tconst currentRunModel = session.model ?? activeModel;\n\t\t\t\t\tconst contextWindow = currentRunModel.contextWindow || 200000;\n\n\t\t\t\t\tlog.logUsageSummary(runState.logCtx!, runState.totalUsage, contextTokens, contextWindow);\n\t\t\t\t}\n\n\t\t\t\t// Clear run state\n\t\t\t\trunState.ctx = null;\n\t\t\t\trunState.logCtx = null;\n\t\t\t\trunState.queue = null;\n\t\t\t}\n\n\t\t\treturn { stopReason: runState.stopReason, errorMessage: runState.errorMessage };\n\t\t},\n\n\t\tabort(): void {\n\t\t\tsession.abort();\n\t\t},\n\t};\n}\n\n/**\n * Translate container path back to host path for file operations\n */\nexport function translateToHostPath(\n\tcontainerPath: string,\n\tchannelDir: string,\n\tworkspacePath: string,\n\tchannelId: string,\n): string {\n\tif (workspacePath === \"/workspace\") {\n\t\tconst prefix = `/workspace/${channelId}/`;\n\t\tif (containerPath.startsWith(prefix)) {\n\t\t\treturn join(channelDir, containerPath.slice(prefix.length));\n\t\t}\n\t\tif (containerPath.startsWith(\"/workspace/\")) {\n\t\t\treturn join(channelDir, \"..\", containerPath.slice(\"/workspace/\".length));\n\t\t}\n\t}\n\treturn containerPath;\n}\n"]}
|
package/dist/agent.js
CHANGED
|
@@ -102,7 +102,7 @@ async function getApiKeyForModel(modelRegistry, model) {
|
|
|
102
102
|
"Configure API key in ~/.pi/agent/models.json or set ANTHROPIC_API_KEY environment variable.");
|
|
103
103
|
}
|
|
104
104
|
// ============================================================================
|
|
105
|
-
// Configuration file loaders: SOUL.md,
|
|
105
|
+
// Configuration file loaders: SOUL.md, AGENTS.md, MEMORY.md
|
|
106
106
|
// ============================================================================
|
|
107
107
|
/**
|
|
108
108
|
* Load SOUL.md — defines the agent's identity, personality, and communication style.
|
|
@@ -123,13 +123,13 @@ function getSoul(workspaceDir) {
|
|
|
123
123
|
return "";
|
|
124
124
|
}
|
|
125
125
|
/**
|
|
126
|
-
* Load
|
|
126
|
+
* Load AGENTS.md — defines the agent's behavior instructions, capabilities, and constraints.
|
|
127
127
|
* Supports both global (workspace root) and channel-level override.
|
|
128
128
|
*/
|
|
129
129
|
function getAgentConfig(channelDir) {
|
|
130
130
|
const parts = [];
|
|
131
|
-
// Read workspace-level
|
|
132
|
-
const workspaceAgentPath = join(channelDir, "..", "
|
|
131
|
+
// Read workspace-level AGENTS.md (global)
|
|
132
|
+
const workspaceAgentPath = join(channelDir, "..", "AGENTS.md");
|
|
133
133
|
if (existsSync(workspaceAgentPath)) {
|
|
134
134
|
try {
|
|
135
135
|
const content = readFileSync(workspaceAgentPath, "utf-8").trim();
|
|
@@ -138,11 +138,11 @@ function getAgentConfig(channelDir) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
catch (error) {
|
|
141
|
-
log.logWarning("Failed to read workspace
|
|
141
|
+
log.logWarning("Failed to read workspace AGENTS.md", `${workspaceAgentPath}: ${error}`);
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
|
-
// Read channel-specific
|
|
145
|
-
const channelAgentPath = join(channelDir, "
|
|
144
|
+
// Read channel-specific AGENTS.md (overrides/extends global)
|
|
145
|
+
const channelAgentPath = join(channelDir, "AGENTS.md");
|
|
146
146
|
if (existsSync(channelAgentPath)) {
|
|
147
147
|
try {
|
|
148
148
|
const content = readFileSync(channelAgentPath, "utf-8").trim();
|
|
@@ -151,7 +151,7 @@ function getAgentConfig(channelDir) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
catch (error) {
|
|
154
|
-
log.logWarning("Failed to read channel
|
|
154
|
+
log.logWarning("Failed to read channel AGENTS.md", `${channelAgentPath}: ${error}`);
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
return parts.join("\n\n");
|
|
@@ -236,7 +236,7 @@ function buildSystemPrompt(workspacePath, channelId, soul, agentConfig, memory,
|
|
|
236
236
|
// Build system prompt with configuration file layering:
|
|
237
237
|
// 1. SOUL.md (identity/personality)
|
|
238
238
|
// 2. Core instructions
|
|
239
|
-
// 3.
|
|
239
|
+
// 3. AGENTS.md (behavior instructions)
|
|
240
240
|
// 4. Skills, Events, Memory
|
|
241
241
|
const sections = [];
|
|
242
242
|
// 1. SOUL.md — Agent identity
|
|
@@ -262,17 +262,17 @@ ${envDescription}
|
|
|
262
262
|
## Workspace Layout
|
|
263
263
|
${workspacePath}/
|
|
264
264
|
├── SOUL.md # Your identity/personality (read-only)
|
|
265
|
-
├──
|
|
265
|
+
├── AGENTS.md # Custom behavior instructions (read-only)
|
|
266
266
|
├── MEMORY.md # Global memory (all channels, you can read/write)
|
|
267
267
|
├── skills/ # Global CLI tools you create
|
|
268
268
|
├── events/ # Scheduled events
|
|
269
269
|
└── ${channelId}/ # This channel
|
|
270
|
-
├──
|
|
270
|
+
├── AGENTS.md # Channel-specific instructions (read-only)
|
|
271
271
|
├── MEMORY.md # Channel-specific memory (you can read/write)
|
|
272
272
|
├── log.jsonl # Message history (no tool results)
|
|
273
273
|
├── scratch/ # Your working directory
|
|
274
274
|
└── skills/ # Channel-specific tools`);
|
|
275
|
-
// 3.
|
|
275
|
+
// 3. AGENTS.md — User-defined instructions
|
|
276
276
|
if (agentConfig) {
|
|
277
277
|
sections.push(`## Agent Instructions\n${agentConfig}`);
|
|
278
278
|
}
|
|
@@ -531,6 +531,24 @@ function createRunner(sandboxConfig, channelId, channelDir) {
|
|
|
531
531
|
await ctx.flush();
|
|
532
532
|
}
|
|
533
533
|
};
|
|
534
|
+
const requireQueuedMessage = (text, commandName) => {
|
|
535
|
+
const trimmedText = text.trim();
|
|
536
|
+
if (!trimmedText) {
|
|
537
|
+
throw new Error(`/${commandName} requires a message.`);
|
|
538
|
+
}
|
|
539
|
+
return trimmedText;
|
|
540
|
+
};
|
|
541
|
+
const queueBusyMessage = async (delivery, text) => {
|
|
542
|
+
if (!session.isStreaming) {
|
|
543
|
+
throw new Error("No task is currently running.");
|
|
544
|
+
}
|
|
545
|
+
if (delivery === "followUp") {
|
|
546
|
+
await session.followUp(text);
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
await session.steer(text);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
534
552
|
const handleModelBuiltinCommand = async (ctx, args) => {
|
|
535
553
|
modelRegistry.refresh();
|
|
536
554
|
const availableModels = await modelRegistry.getAvailable();
|
|
@@ -617,6 +635,17 @@ ${result.summary}
|
|
|
617
635
|
case "model":
|
|
618
636
|
await handleModelBuiltinCommand(ctx, command.args);
|
|
619
637
|
return;
|
|
638
|
+
case "stop":
|
|
639
|
+
await sendCommandReply(ctx, "No task is running. Use `/stop` only while a task is running.");
|
|
640
|
+
return;
|
|
641
|
+
case "steer":
|
|
642
|
+
requireQueuedMessage(command.args, "steer");
|
|
643
|
+
await sendCommandReply(ctx, "No task is running. Send the message directly instead of using `/steer`.");
|
|
644
|
+
return;
|
|
645
|
+
case "followup":
|
|
646
|
+
requireQueuedMessage(command.args, "followup");
|
|
647
|
+
await sendCommandReply(ctx, "No task is running. Send the message directly now, or use `/followup` while a task is running.");
|
|
648
|
+
return;
|
|
620
649
|
}
|
|
621
650
|
}
|
|
622
651
|
catch (err) {
|
|
@@ -765,6 +794,12 @@ ${result.summary}
|
|
|
765
794
|
async handleBuiltinCommand(ctx, command) {
|
|
766
795
|
await handleBuiltInCommand(ctx, command);
|
|
767
796
|
},
|
|
797
|
+
async queueSteer(text) {
|
|
798
|
+
await queueBusyMessage("steer", requireQueuedMessage(text, "steer"));
|
|
799
|
+
},
|
|
800
|
+
async queueFollowUp(text) {
|
|
801
|
+
await queueBusyMessage("followUp", requireQueuedMessage(text, "followup"));
|
|
802
|
+
},
|
|
768
803
|
async run(ctx, _store) {
|
|
769
804
|
// Reset per-run state
|
|
770
805
|
runState.ctx = ctx;
|