@dobby.ai/dobby 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,490 +1,102 @@
1
1
  # dobby
2
2
 
3
- Discord-first 本地 Agent Gateway。宿主只负责 CLI、网关主流程、扩展加载和计划任务调度;Provider / Connector / Sandbox 通过扩展 contribution 接入。
4
-
5
- 当前仓库内维护的扩展包:
6
-
7
- - `@dobby.ai/connector-discord`
8
- - `@dobby.ai/connector-feishu`
9
- - `@dobby.ai/provider-pi`
10
- - `@dobby.ai/provider-codex-cli`
11
- - `@dobby.ai/provider-claude-cli`
12
- - `@dobby.ai/provider-claude`
13
- - `@dobby.ai/sandbox-core`
14
-
15
- 文档默认以 `@dobby.ai/*` 为准,不再把旧 `@dobby/*` 作为推荐配置。
16
-
17
- ## 核心能力
18
-
19
- - connector source -> binding -> route -> provider / sandbox
20
- - Discord 频道 / 线程接入;线程消息继续按父频道命中 binding
21
- - Feishu 长连接消息接入(self-built app)
22
- - Feishu 出站支持普通文本和 Markdown 卡片;默认群内直发,不走 reply thread
23
- - conversation 级 runtime 复用与串行化
24
- - 扩展 store 安装、启用、列举与 schema 驱动配置
25
- - Discord 流式回复、typing、附件下载与图片输入
26
- - cron 调度:一次性、固定间隔、cron expression
27
- - 交互式初始化:`dobby init`(支持多 provider / 多 connector starter)
28
- - 配置检查与 schema inspect:`dobby config show|list|schema`
29
- - 诊断与保守修复:`dobby doctor [--fix]`
30
-
31
- ## 架构概览
32
-
33
- ```text
34
- Discord / Cron
35
- -> Connector
36
- -> Gateway
37
- -> Dedup / Control Commands / Binding Resolver / Route Resolver
38
- -> Runtime Registry
39
- -> Provider Runtime
40
- -> Sandbox Executor
41
- -> Event Forwarder
42
- -> Connector Reply
43
- ```
44
-
45
- 主要目录:
46
-
47
- - `src/cli`:CLI 程序和各子命令
48
- - `src/core`:gateway 主流程、路由、去重、runtime registry
49
- - `src/extension`:扩展 store、manifest 解析、扩展加载与实例化
50
- - `src/cron`:计划任务配置、持久化与调度
51
- - `src/sandbox`:宿主执行器接口与 `HostExecutor`
52
- - `plugins/*`:本地维护的扩展源码
53
- - `config/*.example.json`:示例配置
54
-
55
- 注意:运行时只从 `<data.rootDir>/extensions/node_modules` 加载扩展,不会从 `plugins/*` 源码目录 fallback。
56
-
57
- ## 环境要求
58
-
59
- - Node.js `>=20`
60
- - npm
61
- - 对应 provider / connector 的外部运行条件
62
- - 例如 Discord bot token
63
- - Codex CLI、Claude CLI 或 Claude Agent SDK 所需认证
64
- - 可选的 Docker / Boxlite 运行环境
65
-
66
- ## 快速开始
67
-
68
- 1. 安装依赖
69
-
70
- ```bash
71
- npm install
72
- ```
73
-
74
- 2. 构建
75
-
76
- ```bash
77
- npm run build
78
- ```
79
-
80
- 3. 初始化模板配置
81
-
82
- ```bash
83
- npm run start -- init
84
- ```
85
-
86
- `init` 会做这些事情:
87
-
88
- - 交互选择 provider 和 connector(均可多选)
89
- - 自动安装所选扩展到运行时 extension store
90
- - 写入一份带占位符的 `gateway.json` 模板
91
- - 把 `routes.default.projectRoot` 设为当前工作目录
92
- - 为 direct message 生成 `bindings.default`,回落到默认 route
93
- - 为每个所选 connector 生成一个默认 binding 到同一条 route
94
- - 生成 `gateway.json`
95
- - `provider.pi` 默认写入最小 inline 配置,不再依赖 `models.custom.json`
96
-
97
- 说明:当前 `init` 内建这些 starter 选择:
98
-
99
- - provider:`provider.pi`、`provider.claude-cli`
100
- - connector:`connector.discord`、`connector.feishu`
101
-
102
- 4. 编辑 `gateway.json`
103
-
104
- 把 `REPLACE_WITH_*` / `YOUR_*` 占位值替换成你的真实配置,例如:
105
-
106
- - `connectors.items[*]` 中的 token / appId / appSecret
107
- - `bindings.items[*].source.id`
108
- - `routes.items[*].projectRoot`(如需覆盖默认 project root)
109
-
110
- 5. 运行诊断
3
+ [![npm package](https://img.shields.io/badge/npm-%40dobby.ai%2Fdobby-CB3837?logo=npm)](https://www.npmjs.com/package/@dobby.ai/dobby)
4
+ [![npm version](https://img.shields.io/npm/v/@dobby.ai/dobby?logo=npm)](https://www.npmjs.com/package/@dobby.ai/dobby)
111
5
 
112
- ```bash
113
- npm run start -- doctor
114
- ```
115
-
116
- `doctor` 会同时检查:
117
-
118
- - 配置结构 / 引用关系
119
- - 缺失的扩展安装
120
- - `REPLACE_WITH_*` / `YOUR_*` 这类 init 占位值是否还未替换
121
-
122
- 6. 启动网关
123
-
124
- ```bash
125
- npm run start --
126
- ```
127
-
128
- 说明:
129
-
130
- - `dobby` 无子命令时,默认等价于 `dobby start`
131
- - `dobby --version` 可直接查看当前 CLI 版本
132
- - 在仓库内直接运行时,CLI 会自动使用 `./config/gateway.json`
133
- - 在仓库内执行 `init` / `extension install` 时,会优先安装 `plugins/*` 的本地构建产物
134
- - 也可以通过环境变量覆盖配置路径:
135
-
136
- ```bash
137
- DOBBY_CONFIG_PATH=./config/gateway.json npm run start --
138
- ```
139
-
140
- ## 配置文件路径
141
-
142
- gateway 配置路径优先级:
6
+ > Discord-first 本地 Agent Gateway,把聊天频道和 cron 任务变成你机器上 Agent 的统一入口。
143
7
 
144
- 1. `DOBBY_CONFIG_PATH`
145
- 2. 当前目录向上查找 dobby 仓库时的 `./config/gateway.json`
146
- 3. 默认 `~/.dobby/gateway.json`
8
+ `dobby` 让 Agent 继续跑在本机,直接使用本地仓库、凭据和工具链。你把一个频道或群聊绑定到一个项目目录,再按 route 选择 Provider、Sandbox 和工具权限;宿主本身保持轻量,只负责 CLI、路由、扩展加载、会话复用和调度。
147
9
 
148
- cron 配置路径优先级:
10
+ ## What is dobby
149
11
 
150
- 1. `--cron-config`
151
- 2. `DOBBY_CRON_CONFIG_PATH`
152
- 3. gateway 配置同目录的 `cron.json`
153
- 4. `<data.rootDir>/state/cron.config.json`
12
+ - 本地执行,不把代码仓库搬到远端中控。
13
+ - IM 入口统一成 `binding -> route -> runtime`,按频道切项目、切 Provider。
14
+ - Provider / Connector / Sandbox 都走扩展;当前仓库维护的包使用 `@dobby.ai/*`。
15
+ - 同一套链路同时支持聊天消息和 cron 计划任务。
154
16
 
155
- 如果 cron 配置文件不存在,启动时会自动生成默认文件。
17
+ ## Quickstart
156
18
 
157
- ## 运行时目录
158
-
159
- `data.rootDir` 默认是 `./data`。如果配置文件是仓库内的 `./config/gateway.json`,它会相对仓库根目录解析;否则相对配置文件所在目录解析。加载后会生成这些目录:
160
-
161
- - `sessions/`
162
- - `attachments/`
163
- - `logs/`
164
- - `state/`
165
- - `extensions/`
166
-
167
- 扩展 store 实际路径是:
168
-
169
- ```text
170
- <data.rootDir>/extensions/node_modules/*
171
- ```
172
-
173
- ## CLI 概览
174
-
175
- 顶层命令:
19
+ 要求:Node.js `>=20`、npm,以及对应 Connector / Provider 的认证环境。
176
20
 
177
21
  ```bash
178
- dobby --version
179
- dobby start
22
+ npm install -g @dobby.ai/dobby
180
23
  dobby init
181
- dobby doctor [--fix]
182
- ```
183
-
184
- 配置检查:
185
-
186
- ```bash
187
- dobby config show [section] [--json]
188
- dobby config list [section] [--json]
189
- dobby config schema list [--json]
190
- dobby config schema show <contributionId> [--json]
191
- ```
192
-
193
- 配置变更建议直接编辑 `gateway.json`,再通过 `dobby doctor` 或 `dobby start` 做校验。
194
-
195
- 连接器状态:
196
-
197
- ```bash
198
- dobby connector status [connectorId] [--json]
199
- ```
200
-
201
- 扩展管理:
202
-
203
- ```bash
204
- dobby extension install <packageSpec>
205
- dobby extension install <packageSpec> --enable
206
- dobby extension uninstall <packageName>
207
- dobby extension list [--json]
208
24
  ```
209
25
 
210
- 计划任务:
26
+ `init` 当前内建 starter:
211
27
 
212
- ```bash
213
- dobby cron add <name> --prompt <text> --connector <id> --route <id> --channel <id> [--thread <id>] [--at <iso> | --every-ms <ms> | --cron <expr>] [--tz <tz>]
214
- dobby cron list [--json]
215
- dobby cron status [jobId] [--json]
216
- dobby cron run <jobId>
217
- dobby cron update <jobId> ...
218
- dobby cron pause <jobId>
219
- dobby cron resume <jobId>
220
- dobby cron remove <jobId>
221
- ```
28
+ - Provider: `provider.pi`、`provider.claude-cli`
29
+ - Connector: `connector.discord`、`connector.feishu`
222
30
 
223
- ## Release 流程
31
+ 然后编辑 `config/gateway.json`,至少替换这些占位值:
224
32
 
225
- 仓库现在内置了两条 GitHub Actions:
33
+ - `botToken` / Feishu 凭据
34
+ - 频道或群聊 ID
35
+ - route 的 `projectRoot`
36
+ - Provider 的模型、地址、认证信息
226
37
 
227
- - `.github/workflows/ci.yml`
228
- - 在 PR / push 到 `main` 时执行 `npm ci`、`npm run plugins:install`、`npm run check`、`npm run build`、`npm run test:cli`、`npm run plugins:check`、`npm run plugins:build`
229
- - `.github/workflows/release.yml`
230
- - 在 push 到 `main` 时运行 Release Please
231
- - 有 releasable commit 时自动维护 release PR
232
- - release PR 合并后自动发布对应 npm 包,并为每个包生成独立 GitHub release / tag
233
-
234
- 推荐的日常流程:
235
-
236
- 1. 正常提交功能改动到 PR(建议继续使用 Conventional Commits 风格,例如 `feat(...)` / `fix(...)`)
237
- 2. 合并到 `main`
238
- 3. 等待 Release Please 自动更新或创建 release PR
239
- 4. review 并合并 release PR
240
- 5. 合并后由 `release.yml` 自动执行 npm trusted publishing
241
-
242
- 注意:
243
-
244
- - 首次启用前,需要在 npm 后台为每个 `@dobby.ai/*` 包配置 GitHub trusted publisher,指向当前仓库和 `.github/workflows/release.yml`
245
- - 建议在 GitHub 仓库里创建 `npm-publish` environment,后续若需要人工审批可以直接加保护规则
246
- - 进入自动发版流程后,后续版本号应由 Release Please 维护,不再手动执行 `npm version`
247
- - 本地手动兜底发布仍然保留,可用:
38
+ 启动前先做一次诊断:
248
39
 
249
40
  ```bash
250
- node scripts/publish-packages.mjs --package plugins/provider-codex-cli --skip-existing
251
- node scripts/publish-packages.mjs --package . --tag next
41
+ dobby doctor
42
+ dobby start
252
43
  ```
253
44
 
254
- ## Gateway 配置模型
255
-
256
- 顶层结构:
257
-
258
- - `extensions`
259
- - `providers`
260
- - `connectors`
261
- - `sandboxes`
262
- - `routes`
263
- - `bindings`
264
- - `data`
265
-
266
- 关键语义:
267
-
268
- - `extensions.allowList`
269
- - 只声明启用状态,不负责安装
270
- - `providers.default`
271
- - 默认 provider instance ID
272
- - `providers.items[*].type` / `connectors.items[*].type` / `sandboxes.items[*].type`
273
- - 指向某个 contribution,实例配置直接内联在对象里
274
- - `routes.default`
275
- - 统一提供 route 默认的 `projectRoot`、`provider`、`sandbox`、`tools`、`mentions`
276
- - `routes.items[*]`
277
- - route 是可复用的执行 profile,可继承默认 `projectRoot`,并按需覆盖 `provider`、`sandbox`、`tools`、`mentions`、`systemPromptFile`
278
- - `bindings.default`
279
- - direct message 未命中显式 binding 时使用的默认 route fallback
280
- - `bindings.items[*]`
281
- - `(connector, source.type, source.id) -> route` 的入口绑定
282
- - `sandboxes.default`
283
- - 未指定时默认使用 `host.builtin`
284
- - 未匹配 binding 的入站消息会被直接忽略;仅 direct message 可回落到 `bindings.default`
285
-
286
- 示例配置:
287
-
288
- - gateway:[`config/gateway.example.json`](config/gateway.example.json)
289
- - cron:[`config/cron.example.json`](config/cron.example.json)
290
-
291
- `provider.pi` 现在使用 inline custom provider 配置。最小常用字段是:
292
-
293
- - `model`
294
- - `baseUrl`
295
- - `apiKey`
296
-
297
- 这些字段默认自动补齐:
298
-
299
- - `provider = "custom-openai"`
300
- - `api = "openai-completions"`
301
- - `authHeader = false`
302
- - `thinkingLevel = "off"`
303
- - `models = [{ id: model }]`
304
-
305
- 只有在你需要多模型元数据或覆盖能力参数时,才需要手工展开 `models`。
306
-
307
- `apiKey` 支持直接写 literal,也支持写环境变量名,由 `pi` 的 `AuthStorage` / `ModelRegistry` 按上游规则解析。
308
-
309
- ## 扩展包与 contribution
310
-
311
- 仓库内现有 contribution:
312
-
313
- - `connector.discord`
314
- - `provider.pi`
315
- - `provider.codex-cli`
316
- - `provider.claude-cli`
317
- - `provider.claude`
318
- - `sandbox.boxlite`
319
- - `sandbox.docker`
320
-
321
- `dobby init` 当前只内建这些 starter 选择:
322
-
323
- - provider:`provider.pi`、`provider.claude-cli`
324
- - connector:`connector.discord`、`connector.feishu`
325
-
326
- `provider.codex-cli`、`provider.claude` 与 sandbox 相关扩展需要手工安装和配置,例如:
45
+ 也可以显式指定配置路径:
327
46
 
328
47
  ```bash
329
- npm run start -- extension install @dobby.ai/provider-codex-cli --enable
330
- npm run start -- extension install @dobby.ai/provider-claude --enable
331
- npm run start -- extension install @dobby.ai/sandbox-core --enable
48
+ DOBBY_CONFIG_PATH=./config/gateway.json dobby start
332
49
  ```
333
50
 
334
- 若使用 `provider.codex-cli`,启动前建议检查:
51
+ 如果你希望把仓库内维护的 `skills/` 同步到本机 dobby 根目录,直接运行:
335
52
 
336
53
  ```bash
337
- codex --version
338
- codex login status
339
- ```
340
-
341
- 最小配置字段:
342
-
343
- - `command`(默认 `codex`)
344
- - `commandArgs`(默认 `[]`)
345
- - `model`(可选;未设置时沿用 Codex CLI 当前 profile / `~/.codex/config.toml` 的默认模型)
346
- - `profile`(可选;等价于 `codex -p <profile>`)
347
- - `approvalPolicy`(可选;默认 `never`)
348
- - `sandboxMode`(可选;不填时按 route 的 `tools` 推导:`readonly -> read-only`,`full -> workspace-write`)
349
- - `configOverrides`(可选;字符串数组,按原样转成重复的 `codex -c key=value`)
350
- - `skipGitRepoCheck`(默认 `false`)
351
-
352
- 例如希望网关里的 Codex 会话复用本机 profile,并显式打开无人值守执行:
353
-
354
- ```json
355
- {
356
- "type": "provider.codex-cli",
357
- "command": "codex",
358
- "profile": "background",
359
- "approvalPolicy": "never",
360
- "sandboxMode": "danger-full-access",
361
- "configOverrides": [
362
- "model_reasoning_effort = \"xhigh\""
363
- ]
364
- }
54
+ ./scripts/install-project-skills.sh
365
55
  ```
366
56
 
367
- 注意:`provider.codex-cli` 当前是 host-only,`danger-full-access` 会直接作用在宿主机上。
368
-
369
- `--enable` 的行为:
370
-
371
- - 把包写入 `extensions.allowList`
372
- - 按 manifest contribution 生成默认实例模板
373
- - 在需要时补默认 provider
374
-
375
- ## 计划任务 / Cron
376
-
377
- job 支持三种调度方式:
378
-
379
- - `--at <ISO timestamp>`
380
- - `--every-ms <ms>`
381
- - `--cron "<expr>" [--tz <timezone>]`
382
-
383
- 示例:
57
+ 默认会把当前仓库下的 `skills/*` 同步到 `~/.dobby/skills/*`;如果设置了 `DOBBY_CONFIG_PATH`,则会同步到该配置文件所在目录的 `skills/`。也可以直接传目标根目录:
384
58
 
385
59
  ```bash
386
- npm run start -- cron add daily-report \
387
- --prompt "Summarize open issues in this repo" \
388
- --connector discord.main \
389
- --route projectA \
390
- --channel 1234567890 \
391
- --cron "0 9 * * 1-5" \
392
- --tz "Asia/Shanghai"
60
+ ./scripts/install-project-skills.sh ~/.dobby
393
61
  ```
394
62
 
395
- 说明:
396
-
397
- - `cron run <jobId>` 会额外排队一次立即执行,不会恢复 paused 状态,也不会改写原有 `nextRunAtMs`
398
- - 需要已有一个正在运行的 `dobby start`
399
- - 当前 scheduled run 一律按 stateless / ephemeral 执行
400
-
401
- ## Discord 连接器的当前行为
402
-
403
- - 所有 connector 都会经过宿主侧 health supervisor 包装
404
- - 统一暴露 `starting / ready / degraded / reconnecting / failed / stopped` 状态
405
- - 若 connector 长时间停留在 `starting`、`degraded`、`reconnecting` 或 `failed`,宿主会 stop 并重建实例
406
- - 运行中的 gateway 会把 connector 状态快照写到 `<data.rootDir>/state/connectors-status.json`
407
- - `dobby connector status` 会读取这份快照并展示当前 connector 健康状态
408
- - guild channel 仍按显式 binding 匹配
409
- - DM 可通过 `bindings.default` 回落到默认 route
410
- - 线程消息使用父频道 ID 做 binding 查找
411
- - 会自动下载附件到本地
412
- - 图片会作为 image input 传给 provider
413
- - 非图片附件会把路径注入 prompt
414
- - 内置 reconnect watchdog
415
- - `reconnectStaleMs` 默认 `60000`
416
- - `reconnectCheckIntervalMs` 默认 `10000`
417
-
418
- ## 会话控制命令
419
-
420
- 在 Discord 频道内可用:
421
-
422
- - `stop`
423
- - `/stop`
424
- - `/cancel`
425
- - `/new`
426
- - `/reset`
63
+ 对应的 `provider.pi.agentDir` 建议指向 dobby 根目录本身,例如 `~/.dobby`。
427
64
 
428
- 当前语义:
65
+ ## What you can plug in
429
66
 
430
- - `stop` / `/cancel`:取消该会话当前和排队中的任务
431
- - `/new` / `/reset`:重置当前会话,并在 provider 支持时归档旧 session
67
+ - Entrypoints: `connector.discord`、`connector.feishu`、cron
68
+ - Providers: `provider.pi`、`provider.codex-cli`、`provider.claude-cli`、`provider.claude`
69
+ - Sandboxes: `host.builtin`、`sandbox.boxlite`、`sandbox.docker`
432
70
 
433
- ## 本地插件开发
434
-
435
- 开发流程:
436
-
437
- ```bash
438
- npm run plugins:install
439
- npm run plugins:check
440
- npm run plugins:build
441
- npm run extensions:install:local
442
- ```
443
-
444
- 或一步完成:
71
+ `provider.codex-cli`、`provider.claude` 和 sandbox 扩展默认不在 `init` starter 里,需要手工安装 / 启用:
445
72
 
446
73
  ```bash
447
- npm run plugins:setup:local
74
+ dobby extension install @dobby.ai/provider-codex-cli --enable
75
+ dobby extension install @dobby.ai/provider-claude --enable
76
+ dobby extension install @dobby.ai/sandbox-core --enable
448
77
  ```
449
78
 
450
- 补充说明:
79
+ ## Docs
451
80
 
452
- - `plugins/*` 是扩展源码,不是运行时加载入口
453
- - 本地扩展安装到 extension store 后,才会被宿主识别
454
- - `@dobby.ai/plugin-sdk` 在插件里按非 optional 的 `peerDependencies` 暴露,开发期通过 `file:../plugin-sdk` 提供
81
+ - 配置示例:[config/gateway.example.json](config/gateway.example.json)
82
+ - Cron 示例:[config/cron.example.json](config/cron.example.json)
83
+ - 运行与排障:[docs/RUNBOOK.md](docs/RUNBOOK.md)
84
+ - 架构与教程:[docs/tutorials/README.md](docs/tutorials/README.md)
85
+ - 扩展系统:[docs/EXTENSION_SYSTEM_ARCHITECTURE.md](docs/EXTENSION_SYSTEM_ARCHITECTURE.md)
86
+ - Cron 设计:[docs/CRON_SCHEDULER_DESIGN.md](docs/CRON_SCHEDULER_DESIGN.md)
455
87
 
456
- ## 检查与测试
88
+ ## Development
457
89
 
458
90
  最小校验:
459
91
 
460
92
  ```bash
461
- npm run check
462
- npm run build
463
- npm run test:cli
93
+ npm run check && npm run build && npm run test:cli
464
94
  ```
465
95
 
466
- 如果改了插件代码,建议再执行:
96
+ 如果你是在仓库里直接运行源码:
467
97
 
468
98
  ```bash
469
- npm run plugins:check
470
- npm run plugins:build
99
+ npm install
100
+ npm run build
101
+ npm run start -- init
471
102
  ```
472
-
473
- 当前测试现状:
474
-
475
- - 已有 CLI / core 的 focused tests
476
- - 暂无完整的 e2e 自动化
477
- - 仍建议做一次手工 Discord 冒烟
478
-
479
- ## 本地运行小提示
480
-
481
- - `npm run dev:local` 与 `npm run start:local` 会尝试读取 `.env`
482
- - 普通 `npm run start -- ...` 不会自动载入 `.env`
483
- - `dobby init` 生成的是模板配置;运行前先替换 `gateway.json` 中的 placeholder
484
-
485
- ## 相关文档
486
-
487
- - 扩展系统:[`docs/EXTENSION_SYSTEM_ARCHITECTURE.md`](docs/EXTENSION_SYSTEM_ARCHITECTURE.md)
488
- - cron 设计:[`docs/CRON_SCHEDULER_DESIGN.md`](docs/CRON_SCHEDULER_DESIGN.md)
489
- - 运行与排障:[`docs/RUNBOOK.md`](docs/RUNBOOK.md)
490
- - Teamwork handoff:[`docs/TEAMWORK_HANDOFF_DESIGN.md`](docs/TEAMWORK_HANDOFF_DESIGN.md)
@@ -443,12 +443,12 @@ export class EventForwarder {
443
443
  }
444
444
  async finalizeEdit() {
445
445
  await this.flushNow();
446
- if (this.responseText.trim().length === 0) {
447
- await this.sendEditPrimary("(completed with no text response)");
448
- return;
449
- }
450
- const chunks = splitForMaxLength(this.responseText, this.maxTextLength);
451
446
  try {
447
+ if (this.responseText.trim().length === 0) {
448
+ await this.sendEditPrimary("(completed with no text response)");
449
+ return;
450
+ }
451
+ const chunks = splitForMaxLength(this.responseText, this.maxTextLength);
452
452
  await this.sendEditPrimary(chunks[0] ?? "");
453
453
  for (const chunk of chunks.slice(1)) {
454
454
  await this.sendCreate(chunk);
@@ -460,7 +460,7 @@ export class EventForwarder {
460
460
  connectorId: this.inbound.connectorId,
461
461
  chatId: this.inbound.chatId,
462
462
  targetMessageId: this.rootMessageId,
463
- }, "Failed to send split final response");
463
+ }, "Failed to send final response");
464
464
  }
465
465
  }
466
466
  async finalizeFinalOnly() {
@@ -4,6 +4,46 @@ import { computeInitialNextRunAtMs, describeSchedule } from "../../cron/schedule
4
4
  import { CronStore } from "../../cron/store.js";
5
5
  import { resolveConfigPath } from "../shared/config-io.js";
6
6
  import { createLogger } from "../shared/runtime.js";
7
+ function printMarkdown(lines) {
8
+ for (const line of lines) {
9
+ console.log(line);
10
+ }
11
+ }
12
+ function formatCode(value) {
13
+ return `\`${value}\``;
14
+ }
15
+ function formatTimestamp(timestampMs) {
16
+ return timestampMs !== undefined ? formatCode(new Date(timestampMs).toISOString()) : "-";
17
+ }
18
+ function formatDelivery(job) {
19
+ const segments = [
20
+ job.delivery.connectorId,
21
+ job.delivery.routeId,
22
+ job.delivery.channelId,
23
+ ...(job.delivery.threadId ? [job.delivery.threadId] : []),
24
+ ];
25
+ return formatCode(segments.join("/"));
26
+ }
27
+ function buildJobMarkdown(job, headingLevel = 3) {
28
+ const heading = `${"#".repeat(headingLevel)} ${formatCode(job.id)}`;
29
+ const lines = [
30
+ heading,
31
+ `- name: ${job.name}`,
32
+ `- state: ${job.enabled ? "enabled" : "paused"}`,
33
+ `- schedule: ${formatCode(describeSchedule(job.schedule))}`,
34
+ `- next run: ${formatTimestamp(job.state.nextRunAtMs)}`,
35
+ `- last run: ${formatTimestamp(job.state.lastRunAtMs)}`,
36
+ `- last status: ${job.state.lastStatus ?? "-"}`,
37
+ `- delivery: ${formatDelivery(job)}`,
38
+ ];
39
+ if (job.state.manualRunRequestedAtMs !== undefined) {
40
+ lines.push(`- manual run queued at: ${formatTimestamp(job.state.manualRunRequestedAtMs)}`);
41
+ }
42
+ if (job.state.lastError) {
43
+ lines.push(`- last error: ${job.state.lastError}`);
44
+ }
45
+ return lines;
46
+ }
7
47
  function slugify(value) {
8
48
  const normalized = value
9
49
  .trim()
@@ -97,10 +137,14 @@ export async function runCronAddCommand(options) {
97
137
  },
98
138
  };
99
139
  await context.store.upsertJob(job);
100
- console.log(`Added cron job ${job.id}`);
101
- console.log(`- schedule: ${describeSchedule(job.schedule)}`);
102
- console.log(`- delivery: ${job.delivery.connectorId}/${job.delivery.routeId}/${job.delivery.channelId}`);
103
- console.log(`- cron config: ${context.cronConfigPath}`);
140
+ printMarkdown([
141
+ "## Cron Job Added",
142
+ `- id: ${formatCode(job.id)}`,
143
+ `- name: ${job.name}`,
144
+ `- schedule: ${formatCode(describeSchedule(job.schedule))}`,
145
+ `- delivery: ${formatDelivery(job)}`,
146
+ `- cron config: ${formatCode(context.cronConfigPath)}`,
147
+ ]);
104
148
  }
105
149
  export async function runCronListCommand(options) {
106
150
  const context = await loadCronContext(options.cronConfigPath ? { cronConfigPath: options.cronConfigPath } : undefined);
@@ -110,22 +154,25 @@ export async function runCronListCommand(options) {
110
154
  return;
111
155
  }
112
156
  if (jobs.length === 0) {
113
- console.log(`No cron jobs configured (${context.cronConfigPath})`);
157
+ printMarkdown([
158
+ "## Cron Jobs",
159
+ "",
160
+ "_No cron jobs configured._",
161
+ "",
162
+ `- cron config: ${formatCode(context.cronConfigPath)}`,
163
+ ]);
114
164
  return;
115
165
  }
116
- console.log(`Cron jobs (${context.cronConfigPath}):`);
166
+ const lines = [
167
+ "## Cron Jobs",
168
+ "",
169
+ `- cron config: ${formatCode(context.cronConfigPath)}`,
170
+ `- total jobs: ${jobs.length}`,
171
+ ];
117
172
  for (const job of jobs) {
118
- const next = job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "-";
119
- const last = job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : "-";
120
- const schedule = describeSchedule(job.schedule);
121
- console.log(`- ${job.id} [${job.enabled ? "enabled" : "paused"}] ${job.name}`);
122
- console.log(` schedule=${schedule}`);
123
- console.log(` next=${next} last=${last} status=${job.state.lastStatus ?? "-"}`);
124
- if (job.state.manualRunRequestedAtMs !== undefined) {
125
- console.log(` manualRun=${new Date(job.state.manualRunRequestedAtMs).toISOString()}`);
126
- }
127
- console.log(` delivery=${job.delivery.connectorId}/${job.delivery.routeId}/${job.delivery.channelId}`);
173
+ lines.push("", ...buildJobMarkdown(job));
128
174
  }
175
+ printMarkdown(lines);
129
176
  }
130
177
  export async function runCronStatusCommand(options) {
131
178
  const context = await loadCronContext(options.cronConfigPath ? { cronConfigPath: options.cronConfigPath } : undefined);
@@ -142,19 +189,12 @@ export async function runCronStatusCommand(options) {
142
189
  return;
143
190
  }
144
191
  if (target) {
145
- console.log(`Cron status for ${target.id}`);
146
- console.log(`- name: ${target.name}`);
147
- console.log(`- enabled: ${target.enabled}`);
148
- console.log(`- schedule: ${describeSchedule(target.schedule)}`);
149
- console.log(`- nextRun: ${target.state.nextRunAtMs ? new Date(target.state.nextRunAtMs).toISOString() : "-"}`);
150
- console.log(`- lastRun: ${target.state.lastRunAtMs ? new Date(target.state.lastRunAtMs).toISOString() : "-"}`);
151
- console.log(`- lastStatus: ${target.state.lastStatus ?? "-"}`);
152
- if (target.state.manualRunRequestedAtMs !== undefined) {
153
- console.log(`- manualRunQueuedAt: ${new Date(target.state.manualRunRequestedAtMs).toISOString()}`);
154
- }
155
- if (target.state.lastError) {
156
- console.log(`- lastError: ${target.state.lastError}`);
157
- }
192
+ printMarkdown([
193
+ "## Cron Job Status",
194
+ ...buildJobMarkdown(target, 3),
195
+ "",
196
+ `- cron config: ${formatCode(context.cronConfigPath)}`,
197
+ ]);
158
198
  return;
159
199
  }
160
200
  await runCronListCommand({
@@ -173,9 +213,14 @@ export async function runCronRunCommand(options) {
173
213
  manualRunRequestedAtMs: current.state.manualRunRequestedAtMs ?? now,
174
214
  },
175
215
  }));
176
- console.log(`Queued one manual run for cron job ${options.jobId}.`);
177
- console.log("It will execute once without changing the job's enabled state or next scheduled run.");
178
- console.log("Ensure the gateway process is running for execution.");
216
+ printMarkdown([
217
+ "## Cron Job Run Queued",
218
+ `- id: ${formatCode(options.jobId)}`,
219
+ "- action: queued one immediate execution",
220
+ "- enabled state: unchanged",
221
+ "- schedule: unchanged",
222
+ `- note: Ensure ${formatCode("dobby start")} is running so the queued execution can be consumed.`,
223
+ ]);
179
224
  }
180
225
  export async function runCronUpdateCommand(options) {
181
226
  const context = await loadCronContext(options.cronConfigPath ? { cronConfigPath: options.cronConfigPath } : undefined);
@@ -247,7 +292,10 @@ export async function runCronUpdateCommand(options) {
247
292
  };
248
293
  return nextJob;
249
294
  });
250
- console.log(`Updated cron job ${options.jobId}`);
295
+ printMarkdown([
296
+ "## Cron Job Updated",
297
+ `- id: ${formatCode(options.jobId)}`,
298
+ ]);
251
299
  }
252
300
  export async function runCronRemoveCommand(options) {
253
301
  const context = await loadCronContext(options.cronConfigPath ? { cronConfigPath: options.cronConfigPath } : undefined);
@@ -255,7 +303,10 @@ export async function runCronRemoveCommand(options) {
255
303
  if (!removed) {
256
304
  throw new Error(`Cron job '${options.jobId}' does not exist`);
257
305
  }
258
- console.log(`Removed cron job ${options.jobId}`);
306
+ printMarkdown([
307
+ "## Cron Job Removed",
308
+ `- id: ${formatCode(options.jobId)}`,
309
+ ]);
259
310
  }
260
311
  export async function runCronPauseCommand(options) {
261
312
  const context = await loadCronContext(options.cronConfigPath ? { cronConfigPath: options.cronConfigPath } : undefined);
@@ -265,7 +316,11 @@ export async function runCronPauseCommand(options) {
265
316
  enabled: false,
266
317
  updatedAtMs: now,
267
318
  }));
268
- console.log(`Paused cron job ${options.jobId}`);
319
+ printMarkdown([
320
+ "## Cron Job Paused",
321
+ `- id: ${formatCode(options.jobId)}`,
322
+ "- state: paused",
323
+ ]);
269
324
  }
270
325
  export async function runCronResumeCommand(options) {
271
326
  const context = await loadCronContext(options.cronConfigPath ? { cronConfigPath: options.cronConfigPath } : undefined);
@@ -288,5 +343,9 @@ export async function runCronResumeCommand(options) {
288
343
  state: nextState,
289
344
  };
290
345
  });
291
- console.log(`Resumed cron job ${options.jobId}`);
346
+ printMarkdown([
347
+ "## Cron Job Resumed",
348
+ `- id: ${formatCode(options.jobId)}`,
349
+ "- state: enabled",
350
+ ]);
292
351
  }
@@ -217,13 +217,34 @@ export class SupervisedConnector {
217
217
  return;
218
218
  }
219
219
  this.noteInbound();
220
- await this.ctx.emitInbound(message);
220
+ try {
221
+ await this.ctx.emitInbound(message);
222
+ }
223
+ catch (error) {
224
+ this.logger.error({
225
+ err: error,
226
+ connectorId: this.id,
227
+ messageId: message.messageId,
228
+ sourceType: message.source.type,
229
+ sourceId: message.source.id,
230
+ }, "Connector inbound handler failed");
231
+ }
221
232
  },
222
233
  emitControl: async (event) => {
223
234
  if (generation !== this.generation || !this.ctx) {
224
235
  return;
225
236
  }
226
- await this.ctx.emitControl(event);
237
+ try {
238
+ await this.ctx.emitControl(event);
239
+ }
240
+ catch (error) {
241
+ this.logger.error({
242
+ err: error,
243
+ connectorId: this.id,
244
+ chatId: event.chatId,
245
+ threadId: event.threadId ?? null,
246
+ }, "Connector control handler failed");
247
+ }
227
248
  },
228
249
  };
229
250
  }
@@ -267,7 +288,7 @@ export class SupervisedConnector {
267
288
  restartCount: this.restartCount,
268
289
  };
269
290
  this.health = merged;
270
- if (statusChanged || merged.detail !== previous.detail) {
291
+ if (statusChanged || merged.lastError !== previous.lastError || (merged.status !== "ready" && merged.detail !== previous.detail)) {
271
292
  this.logHealthTransition(previous, merged);
272
293
  }
273
294
  }
@@ -16,6 +16,7 @@ export class Gateway {
16
16
  options;
17
17
  connectorsById = new Map();
18
18
  started = false;
19
+ stopping = false;
19
20
  constructor(options) {
20
21
  this.options = options;
21
22
  for (const connector of options.connectors) {
@@ -25,6 +26,7 @@ export class Gateway {
25
26
  async start() {
26
27
  if (this.started)
27
28
  return;
29
+ this.stopping = false;
28
30
  await this.options.dedupStore.load();
29
31
  this.options.dedupStore.startAutoFlush();
30
32
  const startedConnectors = [];
@@ -60,13 +62,37 @@ export class Gateway {
60
62
  async stop() {
61
63
  if (!this.started)
62
64
  return;
65
+ this.stopping = true;
66
+ let firstError;
67
+ try {
68
+ await this.options.runtimeRegistry.closeAll();
69
+ }
70
+ catch (error) {
71
+ firstError = error;
72
+ this.options.logger.warn({ err: error }, "Failed to close active runtimes during shutdown");
73
+ }
63
74
  for (const connector of this.options.connectors) {
64
- await connector.stop();
75
+ try {
76
+ await connector.stop();
77
+ }
78
+ catch (error) {
79
+ firstError ??= error;
80
+ this.options.logger.warn({ err: error, connectorId: connector.id }, "Failed to stop connector during shutdown");
81
+ }
65
82
  }
66
83
  this.options.dedupStore.stopAutoFlush();
67
- await this.options.dedupStore.flush();
68
- await this.options.runtimeRegistry.closeAll();
84
+ try {
85
+ await this.options.dedupStore.flush();
86
+ }
87
+ catch (error) {
88
+ firstError ??= error;
89
+ this.options.logger.warn({ err: error }, "Failed to flush dedup store during shutdown");
90
+ }
69
91
  this.started = false;
92
+ this.stopping = false;
93
+ if (firstError) {
94
+ throw firstError;
95
+ }
70
96
  }
71
97
  listConnectorStatuses() {
72
98
  return this.options.connectors
@@ -74,6 +100,9 @@ export class Gateway {
74
100
  .sort((a, b) => a.connectorId.localeCompare(b.connectorId));
75
101
  }
76
102
  async handleScheduled(request) {
103
+ if (this.stopping) {
104
+ throw new Error("Gateway is stopping");
105
+ }
77
106
  const connector = this.connectorsById.get(request.connectorId);
78
107
  if (!connector) {
79
108
  throw new Error(`No connector found for scheduled run '${request.runId}' (${request.connectorId})`);
@@ -137,6 +166,13 @@ export class Gateway {
137
166
  });
138
167
  }
139
168
  async handleInbound(message) {
169
+ if (this.stopping) {
170
+ this.options.logger.debug({
171
+ connectorId: message.connectorId,
172
+ messageId: message.messageId,
173
+ }, "Ignoring inbound message while gateway is stopping");
174
+ return;
175
+ }
140
176
  await this.handleMessage(message, {
141
177
  origin: "connector",
142
178
  useDedup: true,
@@ -325,23 +361,33 @@ export class Gateway {
325
361
  this.options.logger.error({ err: error, routeId: route.routeId }, "Failed to process inbound message");
326
362
  const rootMessageId = forwarder.primaryMessageId();
327
363
  const canEditExisting = connector.capabilities.updateStrategy === "edit" && rootMessageId !== null;
328
- await connector.send(canEditExisting
329
- ? {
330
- ...this.outboundBaseFromInbound(message),
331
- mode: "update",
332
- targetMessageId: rootMessageId,
333
- text: `Error: ${error instanceof Error ? error.message : String(error)}`,
334
- }
335
- : {
336
- ...this.outboundBaseFromInbound(message),
337
- mode: "create",
338
- ...(rootMessageId
339
- ? { replyToMessageId: rootMessageId }
340
- : options.includeReplyTo
341
- ? { replyToMessageId: message.messageId }
342
- : {}),
343
- text: `Error: ${error instanceof Error ? error.message : String(error)}`,
344
- });
364
+ try {
365
+ await connector.send(canEditExisting
366
+ ? {
367
+ ...this.outboundBaseFromInbound(message),
368
+ mode: "update",
369
+ targetMessageId: rootMessageId,
370
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
371
+ }
372
+ : {
373
+ ...this.outboundBaseFromInbound(message),
374
+ mode: "create",
375
+ ...(rootMessageId
376
+ ? { replyToMessageId: rootMessageId }
377
+ : options.includeReplyTo
378
+ ? { replyToMessageId: message.messageId }
379
+ : {}),
380
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
381
+ });
382
+ }
383
+ catch (sendError) {
384
+ this.options.logger.warn({
385
+ err: sendError,
386
+ connectorId: message.connectorId,
387
+ chatId: message.chatId,
388
+ rootMessageId,
389
+ }, "Failed to send error reply");
390
+ }
345
391
  }
346
392
  finally {
347
393
  unsubscribe?.();
@@ -442,6 +488,13 @@ export class Gateway {
442
488
  await this.sendCommandReply(connector, message, "_Started a new session._");
443
489
  }
444
490
  async handleControl(event) {
491
+ if (this.stopping) {
492
+ this.options.logger.debug({
493
+ connectorId: event.connectorId,
494
+ chatId: event.chatId,
495
+ }, "Ignoring control event while gateway is stopping");
496
+ return;
497
+ }
445
498
  const convKey = `${event.connectorId}:${event.platform}:${event.accountId}:${event.chatId}:${event.threadId ?? "root"}`;
446
499
  const connector = this.connectorsById.get(event.connectorId);
447
500
  const cancelled = await this.options.runtimeRegistry.cancel(convKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dobby.ai/dobby",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Discord-first local agent gateway built on pi packages",