@dobby.ai/dobby 0.1.0 → 0.1.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.
Files changed (76) hide show
  1. package/.env.example +0 -1
  2. package/AGENTS.md +7 -7
  3. package/README.md +64 -32
  4. package/config/gateway.example.json +10 -6
  5. package/dist/plugins/connector-discord/src/mapper.js +75 -0
  6. package/dist/src/cli/commands/doctor.js +81 -2
  7. package/dist/src/cli/commands/extension.js +3 -1
  8. package/dist/src/cli/commands/init.js +43 -173
  9. package/dist/src/cli/commands/topology.js +38 -14
  10. package/dist/src/cli/program.js +15 -131
  11. package/dist/src/cli/shared/config-io.js +3 -31
  12. package/dist/src/cli/shared/config-mutators.js +33 -9
  13. package/dist/src/cli/shared/configure-sections.js +52 -12
  14. package/dist/src/cli/shared/init-catalog.js +89 -46
  15. package/dist/src/cli/shared/local-extension-specs.js +85 -0
  16. package/dist/src/cli/shared/schema-prompts.js +26 -2
  17. package/dist/src/cli/tests/config-io.test.js +5 -5
  18. package/dist/src/cli/tests/discord-mapper.test.js +90 -0
  19. package/dist/src/cli/tests/doctor.test.js +145 -0
  20. package/dist/src/cli/tests/init-catalog.test.js +108 -61
  21. package/dist/src/cli/tests/program-options.test.js +14 -28
  22. package/dist/src/cli/tests/routing-config.test.js +59 -4
  23. package/dist/src/core/gateway.js +3 -1
  24. package/dist/src/core/routing.js +53 -38
  25. package/dist/src/main.js +0 -0
  26. package/dist/src/shared/dobby-repo.js +40 -0
  27. package/docs/RUNBOOK.md +28 -27
  28. package/package.json +3 -2
  29. package/plugins/connector-discord/package-lock.json +2 -2
  30. package/plugins/connector-discord/package.json +1 -1
  31. package/plugins/connector-discord/src/connector.ts +0 -5
  32. package/plugins/connector-discord/src/mapper.ts +3 -4
  33. package/plugins/connector-feishu/package-lock.json +2 -2
  34. package/plugins/connector-feishu/package.json +1 -1
  35. package/plugins/plugin-sdk/package-lock.json +2 -2
  36. package/plugins/plugin-sdk/package.json +1 -1
  37. package/plugins/provider-claude/package-lock.json +2 -2
  38. package/plugins/provider-claude/package.json +1 -1
  39. package/plugins/provider-claude-cli/package-lock.json +2 -2
  40. package/plugins/provider-claude-cli/package.json +1 -1
  41. package/plugins/provider-pi/package-lock.json +2 -2
  42. package/plugins/provider-pi/package.json +1 -1
  43. package/plugins/provider-pi/src/contribution.ts +139 -9
  44. package/src/cli/commands/doctor.ts +103 -2
  45. package/src/cli/commands/extension.ts +3 -1
  46. package/src/cli/commands/init.ts +45 -230
  47. package/src/cli/commands/topology.ts +48 -16
  48. package/src/cli/program.ts +16 -167
  49. package/src/cli/shared/config-io.ts +3 -35
  50. package/src/cli/shared/config-mutators.ts +39 -9
  51. package/src/cli/shared/config-types.ts +10 -2
  52. package/src/cli/shared/configure-sections.ts +55 -11
  53. package/src/cli/shared/init-catalog.ts +126 -66
  54. package/src/cli/shared/local-extension-specs.ts +108 -0
  55. package/src/cli/shared/schema-prompts.ts +30 -1
  56. package/src/cli/tests/config-io.test.ts +5 -5
  57. package/src/cli/tests/discord-mapper.test.ts +128 -0
  58. package/src/cli/tests/doctor.test.ts +149 -0
  59. package/src/cli/tests/init-catalog.test.ts +112 -64
  60. package/src/cli/tests/program-options.test.ts +14 -32
  61. package/src/cli/tests/routing-config.test.ts +76 -4
  62. package/src/core/gateway.ts +3 -1
  63. package/src/core/routing.ts +70 -45
  64. package/src/core/types.ts +8 -2
  65. package/src/shared/dobby-repo.ts +48 -0
  66. package/config/models.custom.example.json +0 -27
  67. package/dist/src/agent/tests/event-forwarder.test.js +0 -113
  68. package/dist/src/cli/shared/config-path.js +0 -207
  69. package/dist/src/cli/shared/init-models-file.js +0 -65
  70. package/dist/src/cli/shared/presets.js +0 -86
  71. package/dist/src/cli/tests/config-path.test.js +0 -21
  72. package/dist/src/cli/tests/discord-config.test.js +0 -23
  73. package/dist/src/cli/tests/presets.test.js +0 -41
  74. package/dist/src/cli/tests/routing-legacy.test.js +0 -191
  75. package/dist/src/core/tests/gateway-update-strategy.test.js +0 -167
  76. package/src/cli/shared/init-models-file.ts +0 -77
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import { access, mkdtemp, readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import pino from "pino";
7
+ import { mapDiscordMessage } from "../../../plugins/connector-discord/src/mapper.js";
8
+ function createMessage(overrides) {
9
+ return {
10
+ id: overrides?.id ?? "msg-1",
11
+ content: overrides?.content ?? "hello",
12
+ author: {
13
+ id: "user-1",
14
+ username: "alice",
15
+ bot: false,
16
+ },
17
+ attachments: overrides?.attachments ?? new Map(),
18
+ mentions: {
19
+ users: {
20
+ has: () => false,
21
+ },
22
+ },
23
+ guildId: "guild-1",
24
+ channelId: "channel-1",
25
+ channel: {
26
+ isThread: () => false,
27
+ },
28
+ createdTimestamp: 1_700_000_000_000,
29
+ toJSON: () => ({ ok: true }),
30
+ };
31
+ }
32
+ async function pathExists(path) {
33
+ try {
34
+ await access(path);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ const logger = pino({ enabled: false });
42
+ test("mapDiscordMessage does not create attachment directory when message has no attachments", async () => {
43
+ const root = await mkdtemp(join(tmpdir(), "dobby-discord-mapper-empty-"));
44
+ const message = createMessage();
45
+ const envelope = await mapDiscordMessage(message, "discord.main", "bot-1", "source-1", root, logger);
46
+ assert.ok(envelope);
47
+ assert.deepEqual(envelope.attachments, []);
48
+ assert.equal(await pathExists(join(root, "source-1", "msg-1")), false);
49
+ });
50
+ test("mapDiscordMessage only creates attachment directory when a download succeeds", async (t) => {
51
+ t.mock.method(globalThis, "fetch", async () => new Response("file-body", { status: 200 }));
52
+ const root = await mkdtemp(join(tmpdir(), "dobby-discord-mapper-file-"));
53
+ const message = createMessage({
54
+ attachments: new Map([
55
+ ["att-1", {
56
+ id: "att-1",
57
+ name: "hello.png",
58
+ contentType: "image/png",
59
+ size: 9,
60
+ url: "https://example.test/hello.png",
61
+ }],
62
+ ]),
63
+ });
64
+ const envelope = await mapDiscordMessage(message, "discord.main", "bot-1", "source-1", root, logger);
65
+ assert.ok(envelope);
66
+ assert.equal(envelope.attachments.length, 1);
67
+ assert.equal(await pathExists(join(root, "source-1", "msg-1")), true);
68
+ assert.equal(envelope.attachments[0]?.localPath, join(root, "source-1", "msg-1", "hello.png"));
69
+ assert.equal(await readFile(join(root, "source-1", "msg-1", "hello.png"), "utf-8"), "file-body");
70
+ });
71
+ test("mapDiscordMessage does not leave an empty attachment directory when download fails", async (t) => {
72
+ t.mock.method(globalThis, "fetch", async () => new Response("nope", { status: 500 }));
73
+ const root = await mkdtemp(join(tmpdir(), "dobby-discord-mapper-fail-"));
74
+ const message = createMessage({
75
+ attachments: new Map([
76
+ ["att-1", {
77
+ id: "att-1",
78
+ name: "broken.png",
79
+ contentType: "image/png",
80
+ size: 9,
81
+ url: "https://example.test/broken.png",
82
+ }],
83
+ ]),
84
+ });
85
+ const envelope = await mapDiscordMessage(message, "discord.main", "bot-1", "source-1", root, logger);
86
+ assert.ok(envelope);
87
+ assert.equal(envelope.attachments.length, 1);
88
+ assert.equal(envelope.attachments[0]?.localPath, undefined);
89
+ assert.equal(await pathExists(join(root, "source-1", "msg-1")), false);
90
+ });
@@ -105,3 +105,148 @@ test("doctor reports invalid binding route references", async () => {
105
105
  await rm(homeDir, { recursive: true, force: true });
106
106
  }
107
107
  });
108
+ test("doctor reports invalid default binding route references", async () => {
109
+ const homeDir = await mkdtemp(join(tmpdir(), "dobby-doctor-default-binding-"));
110
+ try {
111
+ const configPath = await writeTempHomeConfig(homeDir, {
112
+ extensions: { allowList: [] },
113
+ providers: {
114
+ default: "pi.main",
115
+ items: {
116
+ "pi.main": {
117
+ type: "provider.pi",
118
+ },
119
+ },
120
+ },
121
+ connectors: {
122
+ items: {
123
+ "discord.main": {
124
+ type: "connector.discord",
125
+ botName: "dobby-main",
126
+ botToken: "token",
127
+ },
128
+ },
129
+ },
130
+ sandboxes: {
131
+ default: "host.builtin",
132
+ items: {},
133
+ },
134
+ routes: {
135
+ defaults: {
136
+ projectRoot: process.cwd(),
137
+ provider: "pi.main",
138
+ sandbox: "host.builtin",
139
+ tools: "full",
140
+ mentions: "required",
141
+ },
142
+ items: {
143
+ main: {},
144
+ },
145
+ },
146
+ bindings: {
147
+ default: {
148
+ route: "missing-route",
149
+ },
150
+ items: {},
151
+ },
152
+ data: {
153
+ rootDir: "./data",
154
+ dedupTtlMs: 604800000,
155
+ },
156
+ });
157
+ const result = await runDoctorWithHome(homeDir, configPath);
158
+ assert.equal(result.code, 1);
159
+ assert.equal(result.output.includes("bindings.default.route") && result.output.includes("missing-route"), true);
160
+ }
161
+ finally {
162
+ await rm(homeDir, { recursive: true, force: true });
163
+ }
164
+ });
165
+ test("doctor reports init template placeholders as errors and warnings", async () => {
166
+ const homeDir = await mkdtemp(join(tmpdir(), "dobby-doctor-placeholders-"));
167
+ try {
168
+ const configPath = await writeTempHomeConfig(homeDir, {
169
+ extensions: { allowList: [] },
170
+ providers: {
171
+ default: "pi.main",
172
+ items: {
173
+ "pi.main": {
174
+ type: "provider.pi",
175
+ model: "REPLACE_WITH_PROVIDER_MODEL_ID",
176
+ baseUrl: "REPLACE_WITH_PROVIDER_BASE_URL",
177
+ apiKey: "REPLACE_WITH_PROVIDER_API_KEY_OR_ENV",
178
+ },
179
+ },
180
+ },
181
+ connectors: {
182
+ items: {
183
+ "discord.main": {
184
+ type: "connector.discord",
185
+ botName: "dobby-main",
186
+ botToken: "REPLACE_WITH_DISCORD_BOT_TOKEN",
187
+ },
188
+ "feishu.main": {
189
+ type: "connector.feishu",
190
+ appId: "REPLACE_WITH_FEISHU_APP_ID",
191
+ appSecret: "REPLACE_WITH_FEISHU_APP_SECRET",
192
+ },
193
+ },
194
+ },
195
+ sandboxes: {
196
+ default: "host.builtin",
197
+ items: {},
198
+ },
199
+ routes: {
200
+ defaults: {
201
+ provider: "pi.main",
202
+ sandbox: "host.builtin",
203
+ tools: "full",
204
+ mentions: "required",
205
+ },
206
+ items: {
207
+ main: {
208
+ projectRoot: "./REPLACE_WITH_PROJECT_ROOT",
209
+ },
210
+ },
211
+ },
212
+ bindings: {
213
+ items: {
214
+ "discord.main.main": {
215
+ connector: "discord.main",
216
+ source: {
217
+ type: "channel",
218
+ id: "YOUR_DISCORD_CHANNEL_ID",
219
+ },
220
+ route: "main",
221
+ },
222
+ "feishu.main.main": {
223
+ connector: "feishu.main",
224
+ source: {
225
+ type: "chat",
226
+ id: "YOUR_FEISHU_CHAT_ID",
227
+ },
228
+ route: "main",
229
+ },
230
+ },
231
+ },
232
+ data: {
233
+ rootDir: "./data",
234
+ dedupTtlMs: 604800000,
235
+ },
236
+ });
237
+ const result = await runDoctorWithHome(homeDir, configPath);
238
+ assert.equal(result.code, 1);
239
+ assert.equal(result.output.includes("providers.items['pi.main'].model still uses placeholder value"), true);
240
+ assert.equal(result.output.includes("providers.items['pi.main'].baseUrl still uses placeholder value"), true);
241
+ assert.equal(result.output.includes("providers.items['pi.main'].apiKey still uses placeholder value"), true);
242
+ assert.equal(result.output.includes("connectors.items['discord.main'].botToken still uses placeholder value"), true);
243
+ assert.equal(result.output.includes("connectors.items['feishu.main'].appId still uses placeholder value"), true);
244
+ assert.equal(result.output.includes("connectors.items['feishu.main'].appSecret still uses placeholder value"), true);
245
+ assert.equal(result.output.includes("routes.items['main'].projectRoot still uses placeholder value"), true);
246
+ assert.equal(result.output.includes("bindings.items['discord.main.main'].source.id still uses placeholder value"), true);
247
+ assert.equal(result.output.includes("bindings.items['feishu.main.main'].source.id still uses placeholder value"), true);
248
+ }
249
+ finally {
250
+ await rm(homeDir, { recursive: true, force: true });
251
+ }
252
+ });
@@ -1,87 +1,134 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createInitSelectionConfig } from "../shared/init-catalog.js";
4
- test("createInitSelectionConfig wires explicit Discord bot config for provider.pi", () => {
5
- const selected = createInitSelectionConfig(["provider.pi"], "connector.discord", {
6
- routeId: "main",
7
- projectRoot: "/tmp/project",
8
- allowAllMessages: false,
9
- botName: "dobby-main",
10
- botToken: "token-abc",
11
- channelId: "123",
4
+ test("createInitSelectionConfig writes Discord starter template for provider.pi", () => {
5
+ const selected = createInitSelectionConfig(["provider.pi"], ["connector.discord"], {
12
6
  routeProviderChoiceId: "provider.pi",
13
- });
14
- assert.deepEqual(selected.connectorConfig, {
15
- botName: "dobby-main",
16
- botToken: "token-abc",
17
- reconnectStaleMs: 60_000,
18
- reconnectCheckIntervalMs: 10_000,
7
+ defaultProjectRoot: "/Users/oolong/workspace/dobby",
19
8
  });
20
9
  assert.deepEqual(selected.providerChoiceIds, ["provider.pi"]);
21
- assert.equal(selected.providerInstances.length, 1);
10
+ assert.deepEqual(selected.connectorChoiceIds, ["connector.discord"]);
11
+ assert.equal(selected.routeId, "main");
22
12
  assert.equal(selected.providerInstanceId, "pi.main");
23
- assert.equal(selected.providerContributionId, "provider.pi");
24
- assert.equal(selected.routeProfile.provider, "pi.main");
25
- assert.equal(selected.routeProfile.mentions, "required");
26
- assert.equal(selected.bindingId, "discord.main.main");
27
- assert.deepEqual(selected.bindingConfig, {
28
- connector: "discord.main",
29
- source: {
30
- type: "channel",
31
- id: "123",
32
- },
13
+ assert.deepEqual(selected.providerInstances, [{
14
+ choiceId: "provider.pi",
15
+ instanceId: "pi.main",
16
+ contributionId: "provider.pi",
17
+ config: {
18
+ model: "REPLACE_WITH_PROVIDER_MODEL_ID",
19
+ baseUrl: "REPLACE_WITH_PROVIDER_BASE_URL",
20
+ apiKey: "REPLACE_WITH_PROVIDER_API_KEY_OR_ENV",
21
+ },
22
+ }]);
23
+ assert.deepEqual(selected.connectorInstances, [{
24
+ choiceId: "connector.discord",
25
+ instanceId: "discord.main",
26
+ contributionId: "connector.discord",
27
+ config: {
28
+ botName: "dobby-main",
29
+ botToken: "REPLACE_WITH_DISCORD_BOT_TOKEN",
30
+ reconnectStaleMs: 60_000,
31
+ reconnectCheckIntervalMs: 10_000,
32
+ },
33
+ }]);
34
+ assert.deepEqual(selected.routeDefaults, {
35
+ projectRoot: "/Users/oolong/workspace/dobby",
36
+ tools: "full",
37
+ mentions: "required",
38
+ provider: "pi.main",
39
+ sandbox: "host.builtin",
40
+ });
41
+ assert.deepEqual(selected.routeProfile, {});
42
+ assert.deepEqual(selected.defaultBinding, {
33
43
  route: "main",
34
44
  });
45
+ assert.deepEqual(selected.bindings, [{
46
+ id: "discord.main.main",
47
+ config: {
48
+ connector: "discord.main",
49
+ source: {
50
+ type: "channel",
51
+ id: "YOUR_DISCORD_CHANNEL_ID",
52
+ },
53
+ route: "main",
54
+ },
55
+ }]);
35
56
  });
36
- test("createInitSelectionConfig wires explicit Discord bot config for provider.claude-cli", () => {
37
- const selected = createInitSelectionConfig(["provider.claude-cli"], "connector.discord", {
38
- routeId: "support",
39
- projectRoot: "/tmp/project",
40
- allowAllMessages: true,
41
- botName: "ops-bot",
42
- botToken: "token-xyz",
43
- channelId: "999",
57
+ test("createInitSelectionConfig writes Feishu starter template for provider.claude-cli", () => {
58
+ const selected = createInitSelectionConfig(["provider.claude-cli"], ["connector.feishu"], {
44
59
  routeProviderChoiceId: "provider.claude-cli",
45
- });
46
- assert.deepEqual(selected.connectorConfig, {
47
- botName: "ops-bot",
48
- botToken: "token-xyz",
49
- reconnectStaleMs: 60_000,
50
- reconnectCheckIntervalMs: 10_000,
60
+ defaultProjectRoot: "/Users/oolong/workspace/dobby",
51
61
  });
52
62
  assert.deepEqual(selected.providerChoiceIds, ["provider.claude-cli"]);
53
- assert.equal(selected.providerInstances.length, 1);
63
+ assert.deepEqual(selected.connectorChoiceIds, ["connector.feishu"]);
64
+ assert.equal(selected.routeId, "main");
54
65
  assert.equal(selected.providerInstanceId, "claude-cli.main");
55
- assert.equal(selected.providerContributionId, "provider.claude-cli");
56
- assert.equal(selected.routeProfile.provider, "claude-cli.main");
57
- assert.equal(selected.routeProfile.mentions, "optional");
58
- assert.deepEqual(selected.bindingConfig, {
59
- connector: "discord.main",
60
- source: {
61
- type: "channel",
62
- id: "999",
63
- },
64
- route: "support",
65
- });
66
+ assert.deepEqual(selected.connectorInstances, [{
67
+ choiceId: "connector.feishu",
68
+ instanceId: "feishu.main",
69
+ contributionId: "connector.feishu",
70
+ config: {
71
+ appId: "REPLACE_WITH_FEISHU_APP_ID",
72
+ appSecret: "REPLACE_WITH_FEISHU_APP_SECRET",
73
+ domain: "feishu",
74
+ messageFormat: "card_markdown",
75
+ replyMode: "direct",
76
+ downloadAttachments: true,
77
+ },
78
+ }]);
79
+ assert.deepEqual(selected.bindings, [{
80
+ id: "feishu.main.main",
81
+ config: {
82
+ connector: "feishu.main",
83
+ source: {
84
+ type: "chat",
85
+ id: "YOUR_FEISHU_CHAT_ID",
86
+ },
87
+ route: "main",
88
+ },
89
+ }]);
66
90
  });
67
- test("createInitSelectionConfig supports multiple providers and uses explicit route provider", () => {
68
- const selected = createInitSelectionConfig(["provider.pi", "provider.claude-cli"], "connector.discord", {
69
- routeId: "ops",
70
- projectRoot: "/tmp/project",
71
- allowAllMessages: false,
72
- botName: "dobby-multi",
73
- botToken: "token-multi",
74
- channelId: "777",
91
+ test("createInitSelectionConfig supports multiple providers and connectors with one default provider", () => {
92
+ const selected = createInitSelectionConfig(["provider.pi", "provider.claude-cli"], ["connector.discord", "connector.feishu"], {
75
93
  routeProviderChoiceId: "provider.claude-cli",
94
+ defaultProjectRoot: "/Users/oolong/workspace/dobby",
76
95
  });
77
96
  assert.deepEqual(selected.providerChoiceIds, ["provider.pi", "provider.claude-cli"]);
78
- assert.deepEqual(selected.providerInstances.map((item) => item.instanceId), ["pi.main", "claude-cli.main"]);
97
+ assert.deepEqual(selected.connectorChoiceIds, ["connector.discord", "connector.feishu"]);
79
98
  assert.deepEqual(selected.extensionPackages, [
80
99
  "@dobby.ai/provider-pi",
81
100
  "@dobby.ai/provider-claude-cli",
82
101
  "@dobby.ai/connector-discord",
102
+ "@dobby.ai/connector-feishu",
83
103
  ]);
84
104
  assert.equal(selected.providerInstanceId, "claude-cli.main");
85
- assert.equal(selected.routeProfile.provider, "claude-cli.main");
105
+ assert.equal(selected.routeDefaults.provider, "claude-cli.main");
86
106
  assert.equal(selected.routeProviderChoiceId, "provider.claude-cli");
107
+ assert.deepEqual(selected.defaultBinding, {
108
+ route: "main",
109
+ });
110
+ assert.deepEqual(selected.bindings, [
111
+ {
112
+ id: "discord.main.main",
113
+ config: {
114
+ connector: "discord.main",
115
+ source: {
116
+ type: "channel",
117
+ id: "YOUR_DISCORD_CHANNEL_ID",
118
+ },
119
+ route: "main",
120
+ },
121
+ },
122
+ {
123
+ id: "feishu.main.main",
124
+ config: {
125
+ connector: "feishu.main",
126
+ source: {
127
+ type: "chat",
128
+ id: "YOUR_FEISHU_CHAT_ID",
129
+ },
130
+ route: "main",
131
+ },
132
+ },
133
+ ]);
87
134
  });
@@ -29,15 +29,15 @@ test("init help has no merge/overwrite flags", () => {
29
29
  assert.equal(help.includes("--yes"), false);
30
30
  assert.equal(help.includes("--config"), false);
31
31
  });
32
- test("config help shows show/list/edit and schema", () => {
32
+ test("config help shows read-only inspect commands and schema", () => {
33
33
  const program = buildProgram();
34
34
  const configCommand = program.commands.find((command) => command.name() === "config");
35
35
  assert.ok(configCommand);
36
36
  const help = configCommand.helpInformation();
37
37
  assert.match(help, /show \[options\] \[section\]/);
38
38
  assert.match(help, /list \[options\] \[section\]/);
39
- assert.match(help, /edit \[options\]/);
40
39
  assert.match(help, /schema/);
40
+ assert.equal(help.includes("edit"), false);
41
41
  assert.equal(help.includes("get"), false);
42
42
  assert.equal(help.includes("set"), false);
43
43
  assert.equal(help.includes("unset"), false);
@@ -62,31 +62,17 @@ test("cron help shows core subcommands", () => {
62
62
  assert.match(help, /run \[options\] <jobId>/);
63
63
  assert.match(help, /remove \[options\] <jobId>/);
64
64
  });
65
- test("binding help shows list, set, and remove subcommands", () => {
65
+ test("top-level help keeps bootstrap, inspect, install, validate, and ops commands only", () => {
66
66
  const program = buildProgram();
67
- const bindingCommand = program.commands.find((command) => command.name() === "binding");
68
- assert.ok(bindingCommand);
69
- const help = bindingCommand.helpInformation();
70
- assert.match(help, /list \[options\]/);
71
- assert.match(help, /set \[options\] <bindingId>/);
72
- assert.match(help, /remove <bindingId>/);
73
- });
74
- test("route help reflects provider, sandbox, mentions, and cascade-bindings options", () => {
75
- const program = buildProgram();
76
- const routeCommand = program.commands.find((command) => command.name() === "route");
77
- assert.ok(routeCommand);
78
- const setCommand = routeCommand.commands.find((command) => command.name() === "set");
79
- const removeCommand = routeCommand.commands.find((command) => command.name() === "remove");
80
- assert.ok(setCommand);
81
- assert.ok(removeCommand);
82
- const setHelp = setCommand.helpInformation();
83
- const removeHelp = removeCommand.helpInformation();
84
- assert.match(setHelp, /--provider <id>/);
85
- assert.match(setHelp, /--sandbox <id>/);
86
- assert.match(setHelp, /--mentions <policy>/);
87
- assert.equal(setHelp.includes("--provider-id"), false);
88
- assert.equal(setHelp.includes("--sandbox-id"), false);
89
- assert.equal(setHelp.includes("--mentions-only"), false);
90
- assert.equal(setHelp.includes("--default"), false);
91
- assert.match(removeHelp, /--cascade-bindings/);
67
+ const help = program.helpInformation();
68
+ assert.match(help, /start/);
69
+ assert.match(help, /init/);
70
+ assert.match(help, /config/);
71
+ assert.match(help, /extension/);
72
+ assert.match(help, /doctor/);
73
+ assert.match(help, /cron/);
74
+ assert.equal(help.includes("configure"), false);
75
+ assert.equal(help.includes("bot"), false);
76
+ assert.equal(help.includes("binding"), false);
77
+ assert.equal(help.includes("route"), false);
92
78
  });
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
- import { loadGatewayConfig } from "../../core/routing.js";
6
+ import { BindingResolver, loadGatewayConfig } from "../../core/routing.js";
7
7
  async function writeTempConfig(payload) {
8
8
  const dir = await mkdtemp(join(tmpdir(), "dobby-routing-"));
9
9
  const configPath = join(dir, "gateway.json");
@@ -15,7 +15,7 @@ async function writeRepoTempConfig(payload) {
15
15
  const configDir = join(repoRoot, "config");
16
16
  await mkdir(configDir, { recursive: true });
17
17
  await mkdir(join(repoRoot, "scripts"), { recursive: true });
18
- await writeFile(join(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
18
+ await writeFile(join(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
19
19
  await writeFile(join(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
20
20
  const configPath = join(configDir, "gateway.json");
21
21
  await writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
@@ -46,7 +46,7 @@ function validConfig() {
46
46
  items: {},
47
47
  },
48
48
  routes: {
49
- defaults: {
49
+ default: {
50
50
  provider: "pi.main",
51
51
  sandbox: "host.builtin",
52
52
  tools: "full",
@@ -84,7 +84,7 @@ test("loadGatewayConfig applies route defaults and resolves relative paths", asy
84
84
  const loaded = await loadGatewayConfig(configPath);
85
85
  const configDir = dirname(configPath);
86
86
  assert.equal(loaded.providers.default, "pi.main");
87
- assert.deepEqual(loaded.routes.defaults, {
87
+ assert.deepEqual(loaded.routes.default, {
88
88
  provider: "pi.main",
89
89
  sandbox: "host.builtin",
90
90
  tools: "full",
@@ -126,6 +126,61 @@ test("loadGatewayConfig resolves data.rootDir from repo root for repo-local conf
126
126
  await rm(repoRoot, { recursive: true, force: true });
127
127
  }
128
128
  });
129
+ test("loadGatewayConfig applies routes.default.projectRoot and bindings.default for direct messages", async () => {
130
+ const payload = validConfig();
131
+ payload.routes = {
132
+ default: {
133
+ projectRoot: "./workspace/default-root",
134
+ provider: "pi.main",
135
+ sandbox: "host.builtin",
136
+ tools: "full",
137
+ mentions: "required",
138
+ },
139
+ items: {
140
+ main: {},
141
+ },
142
+ };
143
+ payload.bindings = {
144
+ default: {
145
+ route: "main",
146
+ },
147
+ items: {},
148
+ };
149
+ const configPath = await writeTempConfig(payload);
150
+ try {
151
+ const loaded = await loadGatewayConfig(configPath);
152
+ const configDir = dirname(configPath);
153
+ const resolver = new BindingResolver(loaded.bindings);
154
+ assert.deepEqual(loaded.routes.default, {
155
+ projectRoot: join(configDir, "workspace/default-root"),
156
+ provider: "pi.main",
157
+ sandbox: "host.builtin",
158
+ tools: "full",
159
+ mentions: "required",
160
+ });
161
+ assert.deepEqual(loaded.routes.items.main, {
162
+ projectRoot: join(configDir, "workspace/default-root"),
163
+ provider: "pi.main",
164
+ sandbox: "host.builtin",
165
+ tools: "full",
166
+ mentions: "required",
167
+ });
168
+ assert.deepEqual(loaded.bindings.default, {
169
+ route: "main",
170
+ });
171
+ assert.equal(resolver.resolve("discord.main", {
172
+ type: "channel",
173
+ id: "dm-123",
174
+ }, { isDirectMessage: true })?.config.route, "main");
175
+ assert.equal(resolver.resolve("discord.main", {
176
+ type: "channel",
177
+ id: "dm-123",
178
+ }, { isDirectMessage: false }), null);
179
+ }
180
+ finally {
181
+ await rm(dirname(configPath), { recursive: true, force: true });
182
+ }
183
+ });
129
184
  test("loadGatewayConfig rejects connector fields reserved by the host", async () => {
130
185
  const payload = validConfig();
131
186
  payload.connectors = {
@@ -129,7 +129,9 @@ export class Gateway {
129
129
  route,
130
130
  };
131
131
  }
132
- const binding = this.options.bindingResolver.resolve(message.connectorId, message.source);
132
+ const binding = this.options.bindingResolver.resolve(message.connectorId, message.source, {
133
+ isDirectMessage: message.isDirectMessage,
134
+ });
133
135
  if (!binding) {
134
136
  if (handling.origin === "connector") {
135
137
  this.options.logger.debug({