@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
@@ -1,19 +1,20 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import { dirname, isAbsolute, resolve } from "node:path";
5
4
  import { z } from "zod";
5
+ import { isDobbyRepoRoot } from "../shared/dobby-repo.js";
6
6
  import { BUILTIN_HOST_SANDBOX_ID } from "./types.js";
7
7
  import type {
8
8
  BindingConfig,
9
9
  BindingResolution,
10
10
  BindingSource,
11
11
  ConnectorsConfig,
12
+ DefaultBindingConfig,
12
13
  ExtensionInstanceConfig,
13
14
  ExtensionsConfig,
14
15
  GatewayConfig,
15
16
  ProvidersConfig,
16
- RouteDefaultsConfig,
17
+ RouteDefaultConfig,
17
18
  RouteProfile,
18
19
  RouteResolution,
19
20
  RoutesConfig,
@@ -24,7 +25,8 @@ const extensionItemSchema = z.object({
24
25
  type: z.string().trim().min(1),
25
26
  }).catchall(z.unknown());
26
27
 
27
- const routeDefaultsSchema = z.object({
28
+ const routeDefaultSchema = z.object({
29
+ projectRoot: z.string().trim().min(1).optional(),
28
30
  provider: z.string().trim().min(1).optional(),
29
31
  sandbox: z.string().trim().min(1).optional(),
30
32
  tools: z.enum(["full", "readonly"]).optional(),
@@ -32,7 +34,7 @@ const routeDefaultsSchema = z.object({
32
34
  }).strict();
33
35
 
34
36
  const routeItemSchema = z.object({
35
- projectRoot: z.string().trim().min(1),
37
+ projectRoot: z.string().trim().min(1).optional(),
36
38
  tools: z.enum(["full", "readonly"]).optional(),
37
39
  mentions: z.enum(["required", "optional"]).optional(),
38
40
  provider: z.string().trim().min(1).optional(),
@@ -54,6 +56,10 @@ const bindingItemSchema = z.object({
54
56
  route: z.string().trim().min(1),
55
57
  }).strict();
56
58
 
59
+ const defaultBindingSchema = z.object({
60
+ route: z.string().trim().min(1),
61
+ }).strict();
62
+
57
63
  const gatewayConfigSchema = z.object({
58
64
  extensions: z.object({
59
65
  allowList: z
@@ -77,10 +83,11 @@ const gatewayConfigSchema = z.object({
77
83
  items: z.record(z.string(), extensionItemSchema).default({}),
78
84
  }).strict(),
79
85
  routes: z.object({
80
- defaults: routeDefaultsSchema.default({}),
86
+ default: routeDefaultSchema.default({}),
81
87
  items: z.record(z.string(), routeItemSchema),
82
88
  }).strict(),
83
89
  bindings: z.object({
90
+ default: defaultBindingSchema.optional(),
84
91
  items: z.record(z.string(), bindingItemSchema).default({}),
85
92
  }).strict(),
86
93
  data: z.object({
@@ -99,24 +106,6 @@ const FORBIDDEN_CONNECTOR_CONFIG_KEYS: Record<string, string> = {
99
106
  botTokenEnv: "Set botToken directly in connector config or inject it before the config is loaded.",
100
107
  };
101
108
 
102
- function isDobbyRepoRoot(candidateDir: string): boolean {
103
- const packageJsonPath = resolve(candidateDir, "package.json");
104
- const repoConfigPath = resolve(candidateDir, "config", "gateway.json");
105
- const localExtensionsScriptPath = resolve(candidateDir, "scripts", "local-extensions.mjs");
106
-
107
- if (!existsSync(packageJsonPath) || !existsSync(repoConfigPath) || !existsSync(localExtensionsScriptPath)) {
108
- return false;
109
- }
110
-
111
- try {
112
- const packageJsonRaw = readFileSync(packageJsonPath, "utf-8");
113
- const parsed = JSON.parse(packageJsonRaw) as { name?: unknown };
114
- return parsed.name === "dobby";
115
- } catch {
116
- return false;
117
- }
118
- }
119
-
120
109
  function resolveConfigBaseDir(configPath: string): string {
121
110
  const absoluteConfigPath = resolve(configPath);
122
111
  const configDir = dirname(absoluteConfigPath);
@@ -192,12 +181,18 @@ function normalizeSandboxes(parsed: ParsedGatewayConfig["sandboxes"]): Sandboxes
192
181
  }
193
182
 
194
183
  function normalizeRouteProfile(
184
+ routeId: string,
195
185
  baseDir: string,
196
186
  profile: ParsedRouteItem,
197
- defaults: RouteDefaultsConfig,
187
+ defaults: RouteDefaultConfig,
198
188
  ): RouteProfile {
189
+ const resolvedProjectRoot = profile.projectRoot ?? defaults.projectRoot;
190
+ if (!resolvedProjectRoot) {
191
+ throw new Error(`routes.items['${routeId}'].projectRoot is required when routes.default.projectRoot is not set`);
192
+ }
193
+
199
194
  const normalized: RouteProfile = {
200
- projectRoot: resolveMaybeAbsolute(baseDir, profile.projectRoot),
195
+ projectRoot: resolveMaybeAbsolute(baseDir, resolvedProjectRoot),
201
196
  tools: profile.tools ?? defaults.tools,
202
197
  mentions: profile.mentions ?? defaults.mentions,
203
198
  provider: profile.provider ?? defaults.provider,
@@ -211,14 +206,14 @@ function normalizeRouteProfile(
211
206
  return normalized;
212
207
  }
213
208
 
214
- function normalizeRoutes(parsed: ParsedGatewayConfig["routes"], baseDir: string, defaults: RouteDefaultsConfig): RoutesConfig {
209
+ function normalizeRoutes(parsed: ParsedGatewayConfig["routes"], baseDir: string, defaults: RouteDefaultConfig): RoutesConfig {
215
210
  const items: Record<string, RouteProfile> = {};
216
211
  for (const [routeId, profile] of Object.entries(parsed.items)) {
217
- items[routeId] = normalizeRouteProfile(baseDir, profile, defaults);
212
+ items[routeId] = normalizeRouteProfile(routeId, baseDir, profile, defaults);
218
213
  }
219
214
 
220
215
  return {
221
- defaults,
216
+ default: defaults,
222
217
  items,
223
218
  };
224
219
  }
@@ -236,7 +231,10 @@ function normalizeBindings(parsed: ParsedGatewayConfig["bindings"]): GatewayConf
236
231
  };
237
232
  }
238
233
 
239
- return { items };
234
+ return {
235
+ ...(parsed.default ? { default: { route: parsed.default.route } } : {}),
236
+ items,
237
+ };
240
238
  }
241
239
 
242
240
  function validateConnectorConfigKeys(parsed: ParsedGatewayConfig["connectors"]): void {
@@ -259,18 +257,18 @@ function validateReferences(parsed: ParsedGatewayConfig, normalizedRoutes: Route
259
257
  throw new Error(`sandboxes.default '${defaultSandbox}' does not exist in sandboxes.items`);
260
258
  }
261
259
 
262
- const resolvedDefaults: RouteDefaultsConfig = {
263
- provider: parsed.routes.defaults.provider ?? parsed.providers.default,
264
- sandbox: parsed.routes.defaults.sandbox ?? parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID,
265
- tools: parsed.routes.defaults.tools ?? "full",
266
- mentions: parsed.routes.defaults.mentions ?? "required",
260
+ const resolvedDefaults: RouteDefaultConfig = {
261
+ provider: parsed.routes.default.provider ?? parsed.providers.default,
262
+ sandbox: parsed.routes.default.sandbox ?? parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID,
263
+ tools: parsed.routes.default.tools ?? "full",
264
+ mentions: parsed.routes.default.mentions ?? "required",
267
265
  };
268
266
 
269
267
  if (!parsed.providers.items[resolvedDefaults.provider]) {
270
- throw new Error(`routes.defaults.provider references unknown provider '${resolvedDefaults.provider}'`);
268
+ throw new Error(`routes.default.provider references unknown provider '${resolvedDefaults.provider}'`);
271
269
  }
272
270
  if (resolvedDefaults.sandbox !== BUILTIN_HOST_SANDBOX_ID && !parsed.sandboxes.items[resolvedDefaults.sandbox]) {
273
- throw new Error(`routes.defaults.sandbox references unknown sandbox '${resolvedDefaults.sandbox}'`);
271
+ throw new Error(`routes.default.sandbox references unknown sandbox '${resolvedDefaults.sandbox}'`);
274
272
  }
275
273
 
276
274
  for (const [routeId, profile] of Object.entries(normalizedRoutes.items)) {
@@ -300,6 +298,10 @@ function validateReferences(parsed: ParsedGatewayConfig, normalizedRoutes: Route
300
298
  }
301
299
  seenSources.set(bindingKey, bindingId);
302
300
  }
301
+
302
+ if (parsed.bindings.default && !normalizedRoutes.items[parsed.bindings.default.route]) {
303
+ throw new Error(`bindings.default.route references unknown route '${parsed.bindings.default.route}'`);
304
+ }
303
305
  }
304
306
 
305
307
  export async function loadGatewayConfig(configPath: string): Promise<GatewayConfig> {
@@ -309,11 +311,12 @@ export async function loadGatewayConfig(configPath: string): Promise<GatewayConf
309
311
  const parsed = gatewayConfigSchema.parse(JSON.parse(raw) as unknown);
310
312
  validateConnectorConfigKeys(parsed.connectors);
311
313
 
312
- const routeDefaults: RouteDefaultsConfig = {
313
- provider: parsed.routes.defaults.provider ?? parsed.providers.default,
314
- sandbox: parsed.routes.defaults.sandbox ?? parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID,
315
- tools: parsed.routes.defaults.tools ?? "full",
316
- mentions: parsed.routes.defaults.mentions ?? "required",
314
+ const routeDefaults: RouteDefaultConfig = {
315
+ ...(parsed.routes.default.projectRoot ? { projectRoot: resolveMaybeAbsolute(configBaseDir, parsed.routes.default.projectRoot) } : {}),
316
+ provider: parsed.routes.default.provider ?? parsed.providers.default,
317
+ sandbox: parsed.routes.default.sandbox ?? parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID,
318
+ tools: parsed.routes.default.tools ?? "full",
319
+ mentions: parsed.routes.default.mentions ?? "required",
317
320
  };
318
321
 
319
322
  const normalizedRoutes = normalizeRoutes(parsed.routes, configBaseDir, routeDefaults);
@@ -340,7 +343,7 @@ export async function loadGatewayConfig(configPath: string): Promise<GatewayConf
340
343
  }
341
344
 
342
345
  export class RouteResolver {
343
- constructor(private readonly routes: RoutesConfig) {}
346
+ constructor(private readonly routes: RoutesConfig) { }
344
347
 
345
348
  resolve(routeId: string): RouteResolution | null {
346
349
  const normalizedRouteId = routeId.trim();
@@ -355,6 +358,7 @@ export class RouteResolver {
355
358
 
356
359
  export class BindingResolver {
357
360
  private readonly bindingsBySource = new Map<string, BindingResolution>();
361
+ private readonly defaultBinding: BindingResolution | null;
358
362
 
359
363
  constructor(bindings: GatewayConfig["bindings"]) {
360
364
  for (const [bindingId, binding] of Object.entries(bindings.items)) {
@@ -363,14 +367,35 @@ export class BindingResolver {
363
367
  config: binding,
364
368
  });
365
369
  }
370
+
371
+ this.defaultBinding = bindings.default
372
+ ? {
373
+ bindingId: "__default__",
374
+ config: {
375
+ connector: "__default__",
376
+ source: {
377
+ type: "chat",
378
+ id: "__direct_message__",
379
+ },
380
+ route: bindings.default.route,
381
+ },
382
+ }
383
+ : null;
366
384
  }
367
385
 
368
- resolve(connectorId: string, source: BindingSource): BindingResolution | null {
386
+ resolve(
387
+ connectorId: string,
388
+ source: BindingSource,
389
+ options?: {
390
+ isDirectMessage?: boolean;
391
+ },
392
+ ): BindingResolution | null {
369
393
  if (!connectorId.trim() || !source.id.trim()) {
370
- return null;
394
+ return options?.isDirectMessage ? this.defaultBinding : null;
371
395
  }
372
396
 
373
- return this.bindingsBySource.get(this.buildKey(connectorId, source)) ?? null;
397
+ return this.bindingsBySource.get(this.buildKey(connectorId, source))
398
+ ?? (options?.isDirectMessage ? this.defaultBinding : null);
374
399
  }
375
400
 
376
401
  private buildKey(connectorId: string, source: BindingSource): string {
package/src/core/types.ts CHANGED
@@ -106,7 +106,8 @@ export interface ConnectorPlugin {
106
106
  stop(): Promise<void>;
107
107
  }
108
108
 
109
- export interface RouteDefaultsConfig {
109
+ export interface RouteDefaultConfig {
110
+ projectRoot?: string;
110
111
  provider: string;
111
112
  sandbox: string;
112
113
  tools: ToolProfile;
@@ -123,7 +124,7 @@ export interface RouteProfile {
123
124
  }
124
125
 
125
126
  export interface RoutesConfig {
126
- defaults: RouteDefaultsConfig;
127
+ default: RouteDefaultConfig;
127
128
  items: Record<string, RouteProfile>;
128
129
  }
129
130
 
@@ -161,7 +162,12 @@ export interface BindingConfig {
161
162
  route: string;
162
163
  }
163
164
 
165
+ export interface DefaultBindingConfig {
166
+ route: string;
167
+ }
168
+
164
169
  export interface BindingsConfig {
170
+ default?: DefaultBindingConfig;
165
171
  items: Record<string, BindingConfig>;
166
172
  }
167
173
 
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ const DOBBY_REPO_PACKAGE_NAMES = new Set(["dobby", "@dobby.ai/dobby"]);
5
+
6
+ function readPackageName(candidateDir: string): string | undefined {
7
+ const packageJsonPath = resolve(candidateDir, "package.json");
8
+ if (!existsSync(packageJsonPath)) {
9
+ return undefined;
10
+ }
11
+
12
+ try {
13
+ const packageJsonRaw = readFileSync(packageJsonPath, "utf-8");
14
+ const parsed = JSON.parse(packageJsonRaw) as { name?: unknown };
15
+ return typeof parsed.name === "string" ? parsed.name : undefined;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ export function isDobbyRepoRoot(candidateDir: string): boolean {
22
+ const repoConfigPath = resolve(candidateDir, "config", "gateway.json");
23
+ const repoConfigExamplePath = resolve(candidateDir, "config", "gateway.example.json");
24
+ const localExtensionsScriptPath = resolve(candidateDir, "scripts", "local-extensions.mjs");
25
+
26
+ if ((!existsSync(repoConfigPath) && !existsSync(repoConfigExamplePath)) || !existsSync(localExtensionsScriptPath)) {
27
+ return false;
28
+ }
29
+
30
+ const packageName = readPackageName(candidateDir);
31
+ return packageName !== undefined && DOBBY_REPO_PACKAGE_NAMES.has(packageName);
32
+ }
33
+
34
+ export function findDobbyRepoRoot(startDir: string): string | null {
35
+ let currentDir = resolve(startDir);
36
+
37
+ while (true) {
38
+ if (isDobbyRepoRoot(currentDir)) {
39
+ return currentDir;
40
+ }
41
+
42
+ const parentDir = dirname(currentDir);
43
+ if (parentDir === currentDir) {
44
+ return null;
45
+ }
46
+ currentDir = parentDir;
47
+ }
48
+ }
@@ -1,27 +0,0 @@
1
- {
2
- "providers": {
3
- "custom-openai": {
4
- "baseUrl": "https://api.example.com/v1",
5
- "api": "openai-completions",
6
- "apiKey": "CUSTOM_PROVIDER_AUTH_TOKEN",
7
- "models": [
8
- {
9
- "id": "example-model",
10
- "name": "example-model",
11
- "reasoning": false,
12
- "input": [
13
- "text"
14
- ],
15
- "contextWindow": 128000,
16
- "maxTokens": 8192,
17
- "cost": {
18
- "input": 0,
19
- "output": 0,
20
- "cacheRead": 0,
21
- "cacheWrite": 0
22
- }
23
- }
24
- ]
25
- }
26
- }
27
- }
@@ -1,113 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { EventForwarder } from "../event-forwarder.js";
4
- class FakeConnector {
5
- id = "connector.test";
6
- platform = "test";
7
- name = "test";
8
- capabilities;
9
- sent = [];
10
- sentCount = 0;
11
- constructor(updateStrategy, maxTextLength) {
12
- this.capabilities = {
13
- updateStrategy,
14
- supportsThread: false,
15
- supportsTyping: false,
16
- supportsFileUpload: false,
17
- ...(maxTextLength !== undefined ? { maxTextLength } : {}),
18
- };
19
- }
20
- async start(_ctx) { }
21
- async send(message) {
22
- this.sent.push(message);
23
- this.sentCount += 1;
24
- return { messageId: `msg-${this.sentCount}` };
25
- }
26
- async stop() { }
27
- }
28
- const noopLogger = {
29
- info: () => { },
30
- warn: () => { },
31
- error: () => { },
32
- debug: () => { },
33
- };
34
- function createInbound() {
35
- return {
36
- connectorId: "connector.test",
37
- platform: "test",
38
- accountId: "bot",
39
- routeId: "route.main",
40
- routeChannelId: "route.main",
41
- chatId: "chat.main",
42
- messageId: "inbound-1",
43
- userId: "user-1",
44
- text: "hello",
45
- attachments: [],
46
- timestampMs: Date.now(),
47
- raw: {},
48
- isDirectMessage: false,
49
- mentionedBot: true,
50
- };
51
- }
52
- function sleep(ms) {
53
- return new Promise((resolve) => {
54
- setTimeout(resolve, ms);
55
- });
56
- }
57
- test("edit strategy keeps create+update flow", async () => {
58
- const connector = new FakeConnector("edit");
59
- const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger, {
60
- updateIntervalMs: 1,
61
- });
62
- forwarder.handleEvent({ type: "message_complete", text: "hello" });
63
- await sleep(5);
64
- forwarder.handleEvent({ type: "message_complete", text: "hello world" });
65
- await sleep(5);
66
- await forwarder.finalize();
67
- assert.equal(connector.sent.length, 2);
68
- assert.equal(connector.sent[0]?.mode, "create");
69
- assert.equal(connector.sent[0]?.text, "hello");
70
- assert.equal(connector.sent[1]?.mode, "update");
71
- assert.equal(connector.sent[1]?.text, "hello world");
72
- });
73
- test("final_only suppresses tool/status outbound messages", async () => {
74
- const connector = new FakeConnector("final_only");
75
- const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger, {
76
- toolMessageMode: "all",
77
- updateIntervalMs: 1,
78
- });
79
- forwarder.handleEvent({ type: "status", message: "starting" });
80
- forwarder.handleEvent({ type: "tool_start", toolName: "bash" });
81
- forwarder.handleEvent({ type: "message_delta", delta: "hello " });
82
- forwarder.handleEvent({ type: "tool_end", toolName: "bash", isError: false, output: "ok" });
83
- forwarder.handleEvent({ type: "message_delta", delta: "world" });
84
- await forwarder.finalize();
85
- assert.equal(connector.sent.length, 1);
86
- assert.equal(connector.sent[0]?.mode, "create");
87
- assert.equal(connector.sent[0]?.text, "hello world");
88
- });
89
- test("final_only splits long final text into multiple create messages", async () => {
90
- const connector = new FakeConnector("final_only", 5);
91
- const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger);
92
- forwarder.handleEvent({ type: "message_complete", text: "12345678901" });
93
- await forwarder.finalize();
94
- assert.equal(connector.sent.length, 3);
95
- assert.deepEqual(connector.sent.map((message) => ({ mode: message.mode, text: message.text })), [
96
- { mode: "create", text: "12345" },
97
- { mode: "create", text: "67890" },
98
- { mode: "create", text: "1" },
99
- ]);
100
- });
101
- test("append sends streaming increments as create messages only", async () => {
102
- const connector = new FakeConnector("append");
103
- const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger, {
104
- updateIntervalMs: 5,
105
- });
106
- forwarder.handleEvent({ type: "message_delta", delta: "hello" });
107
- await sleep(20);
108
- forwarder.handleEvent({ type: "message_delta", delta: " world" });
109
- await sleep(20);
110
- await forwarder.finalize();
111
- assert.deepEqual(connector.sent.map((message) => message.mode), ["create", "create"]);
112
- assert.deepEqual(connector.sent.map((message) => message.text), ["hello", " world"]);
113
- });
@@ -1,207 +0,0 @@
1
- const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
2
- /**
3
- * Returns true when a path segment represents an array index.
4
- */
5
- function isIndexSegment(raw) {
6
- return /^[0-9]+$/.test(raw);
7
- }
8
- /**
9
- * Rejects dangerous object keys that could enable prototype pollution.
10
- */
11
- function validatePathSegments(path) {
12
- for (const segment of path) {
13
- if (!isIndexSegment(segment) && BLOCKED_OBJECT_KEYS.has(segment)) {
14
- throw new Error(`Invalid path segment: ${segment}`);
15
- }
16
- }
17
- }
18
- /**
19
- * Parses dot/bracket path syntax into normalized path segments.
20
- */
21
- export function parsePath(rawPath) {
22
- const trimmed = rawPath.trim();
23
- if (!trimmed) {
24
- return [];
25
- }
26
- const segments = [];
27
- let current = "";
28
- let index = 0;
29
- while (index < trimmed.length) {
30
- const char = trimmed[index];
31
- if (char === "\\") {
32
- const next = trimmed[index + 1];
33
- if (next) {
34
- current += next;
35
- }
36
- index += 2;
37
- continue;
38
- }
39
- if (char === ".") {
40
- if (current) {
41
- segments.push(current);
42
- }
43
- current = "";
44
- index += 1;
45
- continue;
46
- }
47
- if (char === "[") {
48
- if (current) {
49
- segments.push(current);
50
- }
51
- current = "";
52
- const closeIndex = trimmed.indexOf("]", index);
53
- if (closeIndex === -1) {
54
- throw new Error(`Invalid path (missing ']'): ${rawPath}`);
55
- }
56
- const inside = trimmed.slice(index + 1, closeIndex).trim();
57
- if (!inside) {
58
- throw new Error(`Invalid path (empty '[]'): ${rawPath}`);
59
- }
60
- segments.push(inside);
61
- index = closeIndex + 1;
62
- continue;
63
- }
64
- current += char;
65
- index += 1;
66
- }
67
- if (current) {
68
- segments.push(current);
69
- }
70
- const normalized = segments.map((segment) => segment.trim()).filter(Boolean);
71
- validatePathSegments(normalized);
72
- return normalized;
73
- }
74
- /**
75
- * Safe own-property check wrapper.
76
- */
77
- function hasOwnKey(value, key) {
78
- return Object.prototype.hasOwnProperty.call(value, key);
79
- }
80
- /**
81
- * Reads a value from object/array structures by parsed path segments.
82
- */
83
- export function getAtPath(root, path) {
84
- let current = root;
85
- for (const segment of path) {
86
- if (!current || typeof current !== "object") {
87
- return { found: false };
88
- }
89
- if (Array.isArray(current)) {
90
- if (!isIndexSegment(segment)) {
91
- return { found: false };
92
- }
93
- const index = Number.parseInt(segment, 10);
94
- if (!Number.isFinite(index) || index < 0 || index >= current.length) {
95
- return { found: false };
96
- }
97
- current = current[index];
98
- continue;
99
- }
100
- const record = current;
101
- if (!hasOwnKey(record, segment)) {
102
- return { found: false };
103
- }
104
- current = record[segment];
105
- }
106
- return { found: true, value: current };
107
- }
108
- /**
109
- * Sets a value at path, creating intermediate objects/arrays as needed.
110
- */
111
- export function setAtPath(root, path, value) {
112
- if (path.length === 0) {
113
- throw new Error("Path is empty.");
114
- }
115
- let current = root;
116
- for (let i = 0; i < path.length - 1; i += 1) {
117
- const segment = path[i];
118
- const next = path[i + 1];
119
- const nextIsIndex = Boolean(next && isIndexSegment(next));
120
- if (Array.isArray(current)) {
121
- if (!isIndexSegment(segment)) {
122
- throw new Error(`Expected numeric index for array segment '${segment}'`);
123
- }
124
- const index = Number.parseInt(segment, 10);
125
- const existing = current[index];
126
- if (!existing || typeof existing !== "object") {
127
- current[index] = nextIsIndex ? [] : {};
128
- }
129
- current = current[index];
130
- continue;
131
- }
132
- if (!current || typeof current !== "object") {
133
- throw new Error(`Cannot traverse into '${segment}' (not an object)`);
134
- }
135
- const record = current;
136
- const existing = hasOwnKey(record, segment) ? record[segment] : undefined;
137
- if (!existing || typeof existing !== "object") {
138
- record[segment] = nextIsIndex ? [] : {};
139
- }
140
- current = record[segment];
141
- }
142
- const tail = path[path.length - 1];
143
- if (Array.isArray(current)) {
144
- if (!isIndexSegment(tail)) {
145
- throw new Error(`Expected numeric index for array segment '${tail}'`);
146
- }
147
- const index = Number.parseInt(tail, 10);
148
- current[index] = value;
149
- return;
150
- }
151
- if (!current || typeof current !== "object") {
152
- throw new Error(`Cannot set '${tail}' (parent is not an object)`);
153
- }
154
- current[tail] = value;
155
- }
156
- /**
157
- * Removes a value at path and returns whether the target existed.
158
- */
159
- export function unsetAtPath(root, path) {
160
- if (path.length === 0) {
161
- return false;
162
- }
163
- let current = root;
164
- for (let i = 0; i < path.length - 1; i += 1) {
165
- const segment = path[i];
166
- if (!current || typeof current !== "object") {
167
- return false;
168
- }
169
- if (Array.isArray(current)) {
170
- if (!isIndexSegment(segment)) {
171
- return false;
172
- }
173
- const index = Number.parseInt(segment, 10);
174
- if (!Number.isFinite(index) || index < 0 || index >= current.length) {
175
- return false;
176
- }
177
- current = current[index];
178
- continue;
179
- }
180
- const record = current;
181
- if (!hasOwnKey(record, segment)) {
182
- return false;
183
- }
184
- current = record[segment];
185
- }
186
- const tail = path[path.length - 1];
187
- if (Array.isArray(current)) {
188
- if (!isIndexSegment(tail)) {
189
- return false;
190
- }
191
- const index = Number.parseInt(tail, 10);
192
- if (!Number.isFinite(index) || index < 0 || index >= current.length) {
193
- return false;
194
- }
195
- current.splice(index, 1);
196
- return true;
197
- }
198
- if (!current || typeof current !== "object") {
199
- return false;
200
- }
201
- const record = current;
202
- if (!hasOwnKey(record, tail)) {
203
- return false;
204
- }
205
- delete record[tail];
206
- return true;
207
- }