@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.
- package/.env.example +0 -1
- package/AGENTS.md +7 -7
- package/README.md +64 -32
- package/config/gateway.example.json +10 -6
- package/dist/plugins/connector-discord/src/mapper.js +75 -0
- package/dist/src/cli/commands/doctor.js +81 -2
- package/dist/src/cli/commands/extension.js +3 -1
- package/dist/src/cli/commands/init.js +43 -173
- package/dist/src/cli/commands/topology.js +38 -14
- package/dist/src/cli/program.js +15 -131
- package/dist/src/cli/shared/config-io.js +3 -31
- package/dist/src/cli/shared/config-mutators.js +33 -9
- package/dist/src/cli/shared/configure-sections.js +52 -12
- package/dist/src/cli/shared/init-catalog.js +89 -46
- package/dist/src/cli/shared/local-extension-specs.js +85 -0
- package/dist/src/cli/shared/schema-prompts.js +26 -2
- package/dist/src/cli/tests/config-io.test.js +5 -5
- package/dist/src/cli/tests/discord-mapper.test.js +90 -0
- package/dist/src/cli/tests/doctor.test.js +145 -0
- package/dist/src/cli/tests/init-catalog.test.js +108 -61
- package/dist/src/cli/tests/program-options.test.js +14 -28
- package/dist/src/cli/tests/routing-config.test.js +59 -4
- package/dist/src/core/gateway.js +3 -1
- package/dist/src/core/routing.js +53 -38
- package/dist/src/main.js +0 -0
- package/dist/src/shared/dobby-repo.js +40 -0
- package/docs/RUNBOOK.md +28 -27
- package/package.json +3 -2
- package/plugins/connector-discord/package-lock.json +2 -2
- package/plugins/connector-discord/package.json +1 -1
- package/plugins/connector-discord/src/connector.ts +0 -5
- package/plugins/connector-discord/src/mapper.ts +3 -4
- package/plugins/connector-feishu/package-lock.json +2 -2
- package/plugins/connector-feishu/package.json +1 -1
- package/plugins/plugin-sdk/package-lock.json +2 -2
- package/plugins/plugin-sdk/package.json +1 -1
- package/plugins/provider-claude/package-lock.json +2 -2
- package/plugins/provider-claude/package.json +1 -1
- package/plugins/provider-claude-cli/package-lock.json +2 -2
- package/plugins/provider-claude-cli/package.json +1 -1
- package/plugins/provider-pi/package-lock.json +2 -2
- package/plugins/provider-pi/package.json +1 -1
- package/plugins/provider-pi/src/contribution.ts +139 -9
- package/src/cli/commands/doctor.ts +103 -2
- package/src/cli/commands/extension.ts +3 -1
- package/src/cli/commands/init.ts +45 -230
- package/src/cli/commands/topology.ts +48 -16
- package/src/cli/program.ts +16 -167
- package/src/cli/shared/config-io.ts +3 -35
- package/src/cli/shared/config-mutators.ts +39 -9
- package/src/cli/shared/config-types.ts +10 -2
- package/src/cli/shared/configure-sections.ts +55 -11
- package/src/cli/shared/init-catalog.ts +126 -66
- package/src/cli/shared/local-extension-specs.ts +108 -0
- package/src/cli/shared/schema-prompts.ts +30 -1
- package/src/cli/tests/config-io.test.ts +5 -5
- package/src/cli/tests/discord-mapper.test.ts +128 -0
- package/src/cli/tests/doctor.test.ts +149 -0
- package/src/cli/tests/init-catalog.test.ts +112 -64
- package/src/cli/tests/program-options.test.ts +14 -32
- package/src/cli/tests/routing-config.test.ts +76 -4
- package/src/core/gateway.ts +3 -1
- package/src/core/routing.ts +70 -45
- package/src/core/types.ts +8 -2
- package/src/shared/dobby-repo.ts +48 -0
- package/config/models.custom.example.json +0 -27
- package/dist/src/agent/tests/event-forwarder.test.js +0 -113
- package/dist/src/cli/shared/config-path.js +0 -207
- package/dist/src/cli/shared/init-models-file.js +0 -65
- package/dist/src/cli/shared/presets.js +0 -86
- package/dist/src/cli/tests/config-path.test.js +0 -21
- package/dist/src/cli/tests/discord-config.test.js +0 -23
- package/dist/src/cli/tests/presets.test.js +0 -41
- package/dist/src/cli/tests/routing-legacy.test.js +0 -191
- package/dist/src/core/tests/gateway-update-strategy.test.js +0 -167
- package/src/cli/shared/init-models-file.ts +0 -77
package/src/core/routing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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 {
|
|
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:
|
|
263
|
-
provider: parsed.routes.
|
|
264
|
-
sandbox: parsed.routes.
|
|
265
|
-
tools: parsed.routes.
|
|
266
|
-
mentions: parsed.routes.
|
|
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.
|
|
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.
|
|
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:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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(
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|