@oyasmi/pipiclaw 0.1.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 +31 -0
- package/README.md +247 -0
- package/dist/agent.d.ts +18 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +938 -0
- package/dist/agent.js.map +1 -0
- package/dist/commands.d.ts +9 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +45 -0
- package/dist/commands.js.map +1 -0
- package/dist/context.d.ts +139 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +432 -0
- package/dist/context.js.map +1 -0
- package/dist/delivery.d.ts +4 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +221 -0
- package/dist/delivery.js.map +1 -0
- package/dist/dingtalk.d.ts +109 -0
- package/dist/dingtalk.d.ts.map +1 -0
- package/dist/dingtalk.js +655 -0
- package/dist/dingtalk.js.map +1 -0
- package/dist/events.d.ts +51 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +287 -0
- package/dist/events.js.map +1 -0
- package/dist/log.d.ts +33 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +188 -0
- package/dist/log.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +298 -0
- package/dist/main.js.map +1 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +10 -0
- package/dist/paths.js.map +1 -0
- package/dist/sandbox.d.ts +34 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +180 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/shell-escape.d.ts +6 -0
- package/dist/shell-escape.d.ts.map +1 -0
- package/dist/shell-escape.js +8 -0
- package/dist/shell-escape.js.map +1 -0
- package/dist/store.d.ts +41 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +110 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/attach.d.ts +14 -0
- package/dist/tools/attach.d.ts.map +1 -0
- package/dist/tools/attach.js +35 -0
- package/dist/tools/attach.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +78 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit.d.ts +11 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +129 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +15 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +132 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/truncate.d.ts +57 -0
- package/dist/tools/truncate.d.ts.map +1 -0
- package/dist/tools/truncate.js +184 -0
- package/dist/tools/truncate.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +31 -0
- package/dist/tools/write.js.map +1 -0
- package/package.json +54 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Initial implementation of pipiclaw package
|
|
8
|
+
- Memory management guidelines in system prompt
|
|
9
|
+
- MEMORY.md size warning (> 5000 chars prompts Agent to consolidate)
|
|
10
|
+
- log.jsonl rotation (> 1MB archived to .1)
|
|
11
|
+
- Periodic memory consolidation event template in README
|
|
12
|
+
- DingTalk channel now intercepts `/help`, `/new`, `/compact`, `/session`, and `/model` as built-in slash commands instead of sending them to the LLM
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `syncLogToSessionManager` uses byte-offset incremental reads instead of full file scan
|
|
17
|
+
- `syncLogToSessionManager` uses timestamp-based dedup instead of text matching (fixes duplicate-text-drop bug)
|
|
18
|
+
- Shared `shellEscape` utility replaces 4 duplicated definitions
|
|
19
|
+
- `attachTool` uses factory function instead of module-level global state
|
|
20
|
+
- Debug file (`last_prompt.json`) gated behind `PIPICLAW_DEBUG` env var
|
|
21
|
+
- Markdown detection regex is more conservative (no longer triggers on plain multi-line text)
|
|
22
|
+
- DingTalk reconnection logic auto-retries with exponential backoff on failure
|
|
23
|
+
- Message dedup uses `Set` with FIFO eviction instead of `O(n)` array scan
|
|
24
|
+
- Replaced inline `await import("axios")` with top-level import
|
|
25
|
+
- Refactored DingTalk delivery into an explicit progress/final lifecycle so AI Cards only show process output and final answers are sent as standalone Markdown messages
|
|
26
|
+
- Final answer emission now keys off agent turn completion instead of every assistant `message_end`, avoiding intermediate assistant text being sent as the final reply
|
|
27
|
+
- Conversation metadata is persisted per channel so scheduled events and proactive sends continue to work after process restarts
|
|
28
|
+
- Package, CLI, and data directory renamed to `pipiclaw`, `@oyasmi/pipiclaw`, and `~/.pi/pipiclaw/`
|
|
29
|
+
- Pipiclaw now bootstraps `channel.json`, `auth.json`, `models.json`, `settings.json`, and the workspace skeleton automatically on first start
|
|
30
|
+
- Global pipiclaw settings now live in `~/.pi/pipiclaw/settings.json`, and saved default models are restored on restart
|
|
31
|
+
- DingTalk channel configuration is now read from `~/.pi/pipiclaw/channel.json`
|
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# pipiclaw
|
|
2
|
+
|
|
3
|
+
Pipiclaw 是一个接入钉钉的 AI Card 机器人,把 [pi-coding-agent](../coding-agent) 带到钉钉对话里,支持过程性 AI 卡片、最终 Markdown 回复、内置 Slash 命令、技能扩展和定时事件。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 钉钉 Stream 模式接收消息,自动重连
|
|
8
|
+
- 过程性思考和执行信息通过 AI Card 展示,最终答复独立快速返回
|
|
9
|
+
- 内置 Slash 命令:`/help`、`/new`、`/compact`、`/session`、`/model`
|
|
10
|
+
- 每个 DM / 群聊独立工作空间
|
|
11
|
+
- 支持全局和频道级 `SOUL.md`、`AGENT.md`、`MEMORY.md`
|
|
12
|
+
- 支持全局和频道级技能目录
|
|
13
|
+
- 支持 immediate / one-shot / periodic 定时事件
|
|
14
|
+
- 支持自定义模型配置和模型切换
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @oyasmi/pipiclaw
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 首次运行
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipiclaw
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
首次运行时,Pipiclaw 会自动创建 `~/.pi/pipiclaw/`,并生成这些文件和目录:
|
|
29
|
+
|
|
30
|
+
- `channel.json`
|
|
31
|
+
- `auth.json`
|
|
32
|
+
- `models.json`
|
|
33
|
+
- `settings.json`
|
|
34
|
+
- `workspace/`
|
|
35
|
+
- `workspace/events/`
|
|
36
|
+
- `workspace/skills/`
|
|
37
|
+
- `workspace/SOUL.md`
|
|
38
|
+
- `workspace/AGENT.md`
|
|
39
|
+
- `workspace/MEMORY.md`
|
|
40
|
+
|
|
41
|
+
如果 `channel.json` 还是示例占位符,程序会提示你先填写真实配置,然后退出。
|
|
42
|
+
|
|
43
|
+
## 钉钉应用配置
|
|
44
|
+
|
|
45
|
+
在 [钉钉开放平台](https://open-dev.dingtalk.com/) 创建企业内部应用:
|
|
46
|
+
|
|
47
|
+
1. 创建应用并获取 `Client ID` 和 `Client Secret`
|
|
48
|
+
2. 开启机器人能力并启用 Stream 模式
|
|
49
|
+
3. 如需 AI Card 流式输出,创建 AI 卡片模板并获取 `Card Template ID`
|
|
50
|
+
|
|
51
|
+
## 配置文件
|
|
52
|
+
|
|
53
|
+
### channel.json
|
|
54
|
+
|
|
55
|
+
`~/.pi/pipiclaw/channel.json`
|
|
56
|
+
|
|
57
|
+
程序会自动生成一个模板文件。你需要至少填写:
|
|
58
|
+
|
|
59
|
+
- `clientId`
|
|
60
|
+
- `clientSecret`
|
|
61
|
+
|
|
62
|
+
通常还会填写:
|
|
63
|
+
|
|
64
|
+
- `robotCode`
|
|
65
|
+
- `cardTemplateId`
|
|
66
|
+
- `allowFrom`
|
|
67
|
+
|
|
68
|
+
模板示例:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"clientId": "your-dingtalk-client-id",
|
|
73
|
+
"clientSecret": "your-dingtalk-client-secret",
|
|
74
|
+
"robotCode": "your-robot-code",
|
|
75
|
+
"cardTemplateId": "your-card-template-id",
|
|
76
|
+
"cardTemplateKey": "content",
|
|
77
|
+
"allowFrom": ["your-staff-id"]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
说明:
|
|
82
|
+
|
|
83
|
+
- `robotCode` 留空时默认回退到 `clientId`
|
|
84
|
+
- `cardTemplateId` 留空时不使用 AI Card 流式输出
|
|
85
|
+
- `allowFrom` 设为 `[]` 或删除时允许所有人
|
|
86
|
+
|
|
87
|
+
### auth.json
|
|
88
|
+
|
|
89
|
+
`~/.pi/pipiclaw/auth.json`
|
|
90
|
+
|
|
91
|
+
首次运行会自动生成空对象:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
如果你使用环境变量提供模型密钥,可以一直保持为空。也可以手工写成:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"anthropic": "your-anthropic-api-key"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### models.json
|
|
106
|
+
|
|
107
|
+
`~/.pi/pipiclaw/models.json`
|
|
108
|
+
|
|
109
|
+
首次运行会自动生成一个自定义模型示例,结构参考 `pi-coding-agent` 的模型配置,但 API key 默认留空,不会生效,供你按需填写:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"providers": {
|
|
114
|
+
"zpai": {
|
|
115
|
+
"baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4",
|
|
116
|
+
"api": "openai-completions",
|
|
117
|
+
"apiKey": "",
|
|
118
|
+
"models": [
|
|
119
|
+
{
|
|
120
|
+
"id": "glm-5-turbo",
|
|
121
|
+
"name": "glm-5-turbo"
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
"bailian": {
|
|
126
|
+
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
|
127
|
+
"api": "openai-completions",
|
|
128
|
+
"apiKey": "",
|
|
129
|
+
"models": [
|
|
130
|
+
{
|
|
131
|
+
"id": "kimi-k2.5",
|
|
132
|
+
"name": "kimi-k2.5"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": "glm-5",
|
|
136
|
+
"name": "glm-5"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"id": "qwen3-max-2026-01-23",
|
|
140
|
+
"name": "qwen3-max-2026-01-23"
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### settings.json
|
|
149
|
+
|
|
150
|
+
`~/.pi/pipiclaw/settings.json`
|
|
151
|
+
|
|
152
|
+
首次运行会自动生成:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`/model` 等命令写入的默认模型会保存在这里,并在重启后继续生效。
|
|
159
|
+
|
|
160
|
+
## 运行
|
|
161
|
+
|
|
162
|
+
填写好 `channel.json` 和模型认证信息后,再次启动:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pipiclaw
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
如需 Docker sandbox,可以显式指定:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
pipiclaw --sandbox=docker:your-container
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## 内置 Slash 命令
|
|
175
|
+
|
|
176
|
+
以下命令由 Pipiclaw 直接处理,不会作为普通 prompt 发送给模型:
|
|
177
|
+
|
|
178
|
+
- `/help`
|
|
179
|
+
- `/new`
|
|
180
|
+
- `/compact [instructions]`
|
|
181
|
+
- `/session`
|
|
182
|
+
- `/model [provider/modelId|modelId]`
|
|
183
|
+
|
|
184
|
+
说明:
|
|
185
|
+
|
|
186
|
+
- `/model` 无参数时返回当前模型和可用模型列表
|
|
187
|
+
- `/model <ref>` 只支持精确匹配
|
|
188
|
+
- 未被 Pipiclaw 拦截的其他 slash 输入,仍会按 `AgentSession.prompt()` 的原有逻辑处理
|
|
189
|
+
|
|
190
|
+
## 工作空间布局
|
|
191
|
+
|
|
192
|
+
```text
|
|
193
|
+
~/.pi/pipiclaw/
|
|
194
|
+
├── channel.json
|
|
195
|
+
├── auth.json
|
|
196
|
+
├── models.json
|
|
197
|
+
├── settings.json
|
|
198
|
+
└── workspace/
|
|
199
|
+
├── SOUL.md
|
|
200
|
+
├── AGENT.md
|
|
201
|
+
├── MEMORY.md
|
|
202
|
+
├── skills/
|
|
203
|
+
├── events/
|
|
204
|
+
└── dm_{userId}/
|
|
205
|
+
├── AGENT.md
|
|
206
|
+
├── MEMORY.md
|
|
207
|
+
├── .channel-meta.json
|
|
208
|
+
├── context.jsonl
|
|
209
|
+
├── log.jsonl
|
|
210
|
+
└── skills/
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## 定时事件
|
|
214
|
+
|
|
215
|
+
在 `~/.pi/pipiclaw/workspace/events/` 中创建 JSON 文件来触发定时任务:
|
|
216
|
+
|
|
217
|
+
- `immediate`
|
|
218
|
+
- `one-shot`
|
|
219
|
+
- `periodic`
|
|
220
|
+
|
|
221
|
+
示例:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"type": "periodic",
|
|
226
|
+
"channelId": "dm_your-staff-id",
|
|
227
|
+
"text": "Review your MEMORY.md files. Remove outdated entries, merge duplicates, ensure well-organized.",
|
|
228
|
+
"schedule": "0 3 * * 0",
|
|
229
|
+
"timezone": "Asia/Shanghai"
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## 环境变量
|
|
234
|
+
|
|
235
|
+
| 变量 | 说明 |
|
|
236
|
+
|------|------|
|
|
237
|
+
| `ANTHROPIC_API_KEY` | Anthropic API 密钥 |
|
|
238
|
+
| `PIPICLAW_DEBUG` | 设为任意值启用调试模式,将完整上下文写入 `last_prompt.json` |
|
|
239
|
+
| `DINGTALK_FORCE_PROXY` | 设为 `true` 保留 axios 代理设置 |
|
|
240
|
+
|
|
241
|
+
## 开发
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm install
|
|
245
|
+
npm run build
|
|
246
|
+
npm run dev
|
|
247
|
+
```
|
package/dist/agent.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type BuiltInCommand } from "./commands.js";
|
|
2
|
+
import type { DingTalkContext } from "./dingtalk.js";
|
|
3
|
+
import { type SandboxConfig } from "./sandbox.js";
|
|
4
|
+
import type { ChannelStore } from "./store.js";
|
|
5
|
+
export interface AgentRunner {
|
|
6
|
+
run(ctx: DingTalkContext, store: ChannelStore): Promise<{
|
|
7
|
+
stopReason: string;
|
|
8
|
+
errorMessage?: string;
|
|
9
|
+
}>;
|
|
10
|
+
handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;
|
|
11
|
+
abort(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner;
|
|
14
|
+
/**
|
|
15
|
+
* Translate container path back to host path for file operations
|
|
16
|
+
*/
|
|
17
|
+
export declare function translateToHostPath(containerPath: string, channelDir: string, workspacePath: string, channelId: string): string;
|
|
18
|
+
//# sourceMappingURL=agent.d.ts.map
|
|
@@ -0,0 +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"]}
|