@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
@@ -42,10 +42,32 @@ type RuntimeTool = NonNullable<CreateAgentSessionOptions["tools"]>[number];
42
42
  interface PiProviderConfig {
43
43
  provider: string;
44
44
  model: string;
45
+ baseUrl: string;
46
+ apiKey: string;
47
+ api: Api;
48
+ headers?: Record<string, string>;
49
+ authHeader: boolean;
50
+ models: PiProviderModelConfig[];
45
51
  thinkingLevel: ThinkingLevel;
46
52
  agentDir?: string;
47
53
  authFile?: string;
48
- modelsFile?: string;
54
+ }
55
+
56
+ interface PiProviderModelConfig {
57
+ id: string;
58
+ name: string;
59
+ api?: Api;
60
+ reasoning: boolean;
61
+ input: Array<"text" | "image">;
62
+ cost: {
63
+ input: number;
64
+ output: number;
65
+ cacheRead: number;
66
+ cacheWrite: number;
67
+ };
68
+ contextWindow: number;
69
+ maxTokens: number;
70
+ headers?: Record<string, string>;
49
71
  }
50
72
 
51
73
  interface BuiltTools {
@@ -53,15 +75,100 @@ interface BuiltTools {
53
75
  customTools: ToolDefinition[];
54
76
  }
55
77
 
78
+ const DEFAULT_PI_PROVIDER_NAME = "custom-openai";
79
+ const DEFAULT_PI_PROVIDER_API = "openai-completions";
80
+ const DEFAULT_PI_MODEL_COST = {
81
+ input: 0,
82
+ output: 0,
83
+ cacheRead: 0,
84
+ cacheWrite: 0,
85
+ } as const;
86
+
87
+ const PI_PROVIDER_APIS = [
88
+ "anthropic-messages",
89
+ "openai-completions",
90
+ "mistral-conversations",
91
+ "openai-responses",
92
+ "azure-openai-responses",
93
+ "openai-codex-responses",
94
+ "google-generative-ai",
95
+ "google-gemini-cli",
96
+ "google-vertex",
97
+ "bedrock-converse-stream",
98
+ ] as const;
99
+
100
+ const piProviderApiSchema = z.enum(PI_PROVIDER_APIS);
101
+
102
+ const piProviderModelSchema = z.object({
103
+ id: z.string().min(1),
104
+ name: z.string().min(1).optional(),
105
+ api: piProviderApiSchema.optional(),
106
+ reasoning: z.boolean().default(false),
107
+ input: z.array(z.enum(["text", "image"])).min(1).default(["text"]),
108
+ cost: z.object({
109
+ input: z.number().default(0),
110
+ output: z.number().default(0),
111
+ cacheRead: z.number().default(0),
112
+ cacheWrite: z.number().default(0),
113
+ }).default(DEFAULT_PI_MODEL_COST),
114
+ contextWindow: z.number().int().positive().default(128000),
115
+ maxTokens: z.number().int().positive().default(16384),
116
+ headers: z.record(z.string(), z.string()).optional(),
117
+ });
118
+
56
119
  const piProviderConfigSchema = z.object({
57
- provider: z.string().min(1),
120
+ provider: z.string().min(1).optional(),
58
121
  model: z.string().min(1),
59
- thinkingLevel: z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]).default("off"),
122
+ baseUrl: z.string().min(1),
123
+ apiKey: z.string().min(1),
124
+ api: piProviderApiSchema.optional(),
125
+ headers: z.record(z.string(), z.string()).optional(),
126
+ authHeader: z.boolean().optional(),
127
+ models: z.array(piProviderModelSchema).min(1).optional(),
128
+ thinkingLevel: z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]).optional(),
60
129
  agentDir: z.string().optional(),
61
130
  authFile: z.string().optional(),
62
- modelsFile: z.string().default("./models.custom.json"),
63
131
  });
64
132
 
133
+ function buildImplicitModelConfig(modelId: string): PiProviderModelConfig {
134
+ return {
135
+ id: modelId,
136
+ name: modelId,
137
+ reasoning: false,
138
+ input: ["text"],
139
+ cost: { ...DEFAULT_PI_MODEL_COST },
140
+ contextWindow: 128000,
141
+ maxTokens: 16384,
142
+ };
143
+ }
144
+
145
+ function normalizePiProviderModels(
146
+ activeModelId: string,
147
+ models: Array<z.infer<typeof piProviderModelSchema>> | undefined,
148
+ ): PiProviderModelConfig[] {
149
+ if (!models || models.length === 0) {
150
+ return [buildImplicitModelConfig(activeModelId)];
151
+ }
152
+
153
+ const normalizedModels = models.map((model) => ({
154
+ id: model.id,
155
+ name: model.name ?? model.id,
156
+ ...(model.api ? { api: model.api } : {}),
157
+ reasoning: model.reasoning,
158
+ input: model.input,
159
+ cost: model.cost,
160
+ contextWindow: model.contextWindow,
161
+ maxTokens: model.maxTokens,
162
+ ...(model.headers ? { headers: model.headers } : {}),
163
+ }));
164
+
165
+ if (!normalizedModels.some((model) => model.id === activeModelId)) {
166
+ throw new Error(`Configured model '${activeModelId}' is not present in provider.pi models`);
167
+ }
168
+
169
+ return normalizedModels;
170
+ }
171
+
65
172
  const IMAGE_MIME_TYPES: Record<string, string> = {
66
173
  ".jpg": "image/jpeg",
67
174
  ".jpeg": "image/jpeg",
@@ -222,7 +329,26 @@ class PiProviderInstanceImpl implements ProviderInstance {
222
329
  private readonly logger: ProviderInstanceCreateOptions["host"]["logger"],
223
330
  ) {
224
331
  this.authStorage = AuthStorage.create(providerConfig.authFile);
225
- this.modelRegistry = new ModelRegistry(this.authStorage, providerConfig.modelsFile);
332
+ // Point the registry at a never-created file so provider.pi relies solely on inline config.
333
+ this.modelRegistry = new ModelRegistry(this.authStorage, join(dataConfig.stateDir, "__dobby_provider_pi_inline_models__", `${safeSegment(id)}.json`));
334
+ this.modelRegistry.registerProvider(providerConfig.provider, {
335
+ baseUrl: providerConfig.baseUrl,
336
+ apiKey: providerConfig.apiKey,
337
+ api: providerConfig.api,
338
+ ...(providerConfig.headers ? { headers: providerConfig.headers } : {}),
339
+ ...(providerConfig.authHeader ? { authHeader: true } : {}),
340
+ models: providerConfig.models.map((model) => ({
341
+ id: model.id,
342
+ name: model.name,
343
+ ...(model.api ? { api: model.api } : {}),
344
+ reasoning: model.reasoning,
345
+ input: model.input,
346
+ cost: model.cost,
347
+ contextWindow: model.contextWindow,
348
+ maxTokens: model.maxTokens,
349
+ ...(model.headers ? { headers: model.headers } : {}),
350
+ })),
351
+ });
226
352
 
227
353
  const model = this.modelRegistry.find(providerConfig.provider, providerConfig.model);
228
354
  if (!model) {
@@ -458,15 +584,19 @@ export const providerPiContribution: ProviderContributionModule = {
458
584
  const parsed = piProviderConfigSchema.parse(options.config);
459
585
  const agentDir = normalizeMaybePath(options.host.configBaseDir, parsed.agentDir);
460
586
  const authFile = normalizeMaybePath(options.host.configBaseDir, parsed.authFile);
461
- const modelsFile = normalizeMaybePath(options.host.configBaseDir, parsed.modelsFile);
462
587
 
463
588
  const normalizedConfig: PiProviderConfig = {
464
- provider: parsed.provider,
589
+ provider: parsed.provider ?? DEFAULT_PI_PROVIDER_NAME,
465
590
  model: parsed.model,
466
- thinkingLevel: parsed.thinkingLevel,
591
+ baseUrl: parsed.baseUrl,
592
+ apiKey: parsed.apiKey,
593
+ api: parsed.api ?? DEFAULT_PI_PROVIDER_API,
594
+ ...(parsed.headers ? { headers: parsed.headers } : {}),
595
+ authHeader: parsed.authHeader ?? false,
596
+ models: normalizePiProviderModels(parsed.model, parsed.models),
597
+ thinkingLevel: parsed.thinkingLevel ?? "off",
467
598
  ...(agentDir ? { agentDir } : {}),
468
599
  ...(authFile ? { authFile } : {}),
469
- ...(modelsFile ? { modelsFile } : {}),
470
600
  };
471
601
 
472
602
  return new PiProviderInstanceImpl(options.instanceId, normalizedConfig, options.data, options.host.logger);
@@ -16,6 +16,45 @@ interface DoctorIssue {
16
16
  message: string;
17
17
  }
18
18
 
19
+ interface PlaceholderHit {
20
+ path: string;
21
+ value: string;
22
+ }
23
+
24
+ function isPlaceholderValue(value: unknown): value is string {
25
+ if (typeof value !== "string") {
26
+ return false;
27
+ }
28
+ const normalized = value.trim().toUpperCase();
29
+ return normalized.includes("REPLACE_WITH_") || normalized.includes("YOUR_");
30
+ }
31
+
32
+ function isCredentialLikeKey(key: string): boolean {
33
+ return /(?:token|secret|api[-_]?key|appid|appsecret)/i.test(key);
34
+ }
35
+
36
+ function walkPlaceholders(value: unknown, path: string): PlaceholderHit[] {
37
+ if (isPlaceholderValue(value)) {
38
+ return [{ path, value }];
39
+ }
40
+
41
+ if (Array.isArray(value)) {
42
+ return value.flatMap((item, index) => walkPlaceholders(item, `${path}[${index}]`));
43
+ }
44
+
45
+ if (!value || typeof value !== "object") {
46
+ return [];
47
+ }
48
+
49
+ return Object.entries(value).flatMap(([key, nested]) => walkPlaceholders(nested, `${path}.${key}`));
50
+ }
51
+
52
+ function lastPathSegment(path: string): string {
53
+ const withoutIndexes = path.replaceAll(/\[\d+\]/g, "");
54
+ const segments = withoutIndexes.split(".");
55
+ return segments[segments.length - 1] ?? withoutIndexes;
56
+ }
57
+
19
58
  function expandHome(value: string): string {
20
59
  if (value === "~") {
21
60
  return homedir();
@@ -102,6 +141,17 @@ export async function runDoctorCommand(options: {
102
141
  message: `providers.items['${instanceId}'] references missing contribution '${instance.type}'`,
103
142
  });
104
143
  }
144
+
145
+ for (const hit of walkPlaceholders(instance, `providers.items['${instanceId}']`)) {
146
+ if (hit.path.endsWith(".type")) {
147
+ continue;
148
+ }
149
+
150
+ issues.push({
151
+ level: isCredentialLikeKey(lastPathSegment(hit.path)) ? "error" : "warning",
152
+ message: `${hit.path} still uses placeholder value '${hit.value}'`,
153
+ });
154
+ }
105
155
  }
106
156
 
107
157
  for (const [instanceId, instance] of Object.entries(normalized.connectors.items)) {
@@ -112,6 +162,17 @@ export async function runDoctorCommand(options: {
112
162
  });
113
163
  }
114
164
 
165
+ for (const hit of walkPlaceholders(instance, `connectors.items['${instanceId}']`)) {
166
+ if (hit.path.endsWith(".type")) {
167
+ continue;
168
+ }
169
+
170
+ issues.push({
171
+ level: isCredentialLikeKey(lastPathSegment(hit.path)) ? "error" : "warning",
172
+ message: `${hit.path} still uses placeholder value '${hit.value}'`,
173
+ });
174
+ }
175
+
115
176
  if (instance.type === DISCORD_CONNECTOR_CONTRIBUTION_ID) {
116
177
  const botName = typeof instance.botName === "string" ? instance.botName.trim() : "";
117
178
  const botToken = typeof instance.botToken === "string" ? instance.botToken.trim() : "";
@@ -139,18 +200,51 @@ export async function runDoctorCommand(options: {
139
200
  }
140
201
  }
141
202
 
203
+ if (normalized.routes.default.projectRoot && isPlaceholderValue(normalized.routes.default.projectRoot)) {
204
+ issues.push({
205
+ level: "warning",
206
+ message: `routes.default.projectRoot still uses placeholder value '${normalized.routes.default.projectRoot}'`,
207
+ });
208
+ }
209
+
142
210
  for (const [routeId, route] of Object.entries(normalized.routes.items)) {
211
+ const effectiveProjectRoot = route.projectRoot ?? normalized.routes.default.projectRoot;
212
+ const projectRootSource = route.projectRoot ? `routes.items['${routeId}'].projectRoot` : "routes.default.projectRoot";
213
+
214
+ if (!effectiveProjectRoot) {
215
+ issues.push({
216
+ level: "error",
217
+ message: `routes.items['${routeId}'].projectRoot is required when routes.default.projectRoot is not set`,
218
+ });
219
+ continue;
220
+ }
221
+
222
+ if (isPlaceholderValue(effectiveProjectRoot)) {
223
+ issues.push({
224
+ level: "warning",
225
+ message: `${projectRootSource} still uses placeholder value '${effectiveProjectRoot}'`,
226
+ });
227
+ continue;
228
+ }
229
+
143
230
  try {
144
- const projectRootPath = resolveRouteProjectRoot(configPath, route.projectRoot);
231
+ const projectRootPath = resolveRouteProjectRoot(configPath, effectiveProjectRoot);
145
232
  await access(projectRootPath);
146
233
  } catch {
147
234
  issues.push({
148
235
  level: "warning",
149
- message: `routes.items['${routeId}'].projectRoot does not exist: ${route.projectRoot}`,
236
+ message: `${projectRootSource} does not exist: ${effectiveProjectRoot}`,
150
237
  });
151
238
  }
152
239
  }
153
240
 
241
+ if (normalized.bindings.default && !normalized.routes.items[normalized.bindings.default.route]) {
242
+ issues.push({
243
+ level: "error",
244
+ message: `bindings.default.route references unknown route '${normalized.bindings.default.route}'`,
245
+ });
246
+ }
247
+
154
248
  const seenBindingSources = new Map<string, string>();
155
249
  for (const [bindingId, binding] of Object.entries(normalized.bindings.items)) {
156
250
  if (!normalized.connectors.items[binding.connector]) {
@@ -166,6 +260,13 @@ export async function runDoctorCommand(options: {
166
260
  });
167
261
  }
168
262
 
263
+ if (isPlaceholderValue(binding.source.id)) {
264
+ issues.push({
265
+ level: "warning",
266
+ message: `bindings.items['${bindingId}'].source.id still uses placeholder value '${binding.source.id}'`,
267
+ });
268
+ }
269
+
169
270
  const bindingKey = `${binding.connector}:${binding.source.type}:${binding.source.id}`;
170
271
  const existingBindingId = seenBindingSources.get(bindingKey);
171
272
  if (existingBindingId) {
@@ -12,6 +12,7 @@ import {
12
12
  } from "../shared/config-mutators.js";
13
13
  import { readRawConfig, requireRawConfig, resolveConfigPath, resolveDataRootDir, writeConfigWithValidation } from "../shared/config-io.js";
14
14
  import type { RawGatewayConfig } from "../shared/config-types.js";
15
+ import { resolveExtensionInstallSpecs } from "../shared/local-extension-specs.js";
15
16
  import { createLogger } from "../shared/runtime.js";
16
17
 
17
18
  /**
@@ -42,7 +43,8 @@ export async function runExtensionInstallCommand(options: {
42
43
 
43
44
  const rawConfig = (await readRawConfig(configPath)) ?? {};
44
45
  const manager = new ExtensionStoreManager(logger, extensionStoreDirFromRaw(configPath, rawConfig));
45
- const installed = await manager.install(options.spec);
46
+ const [resolvedSpec] = await resolveExtensionInstallSpecs([options.spec]);
47
+ const installed = await manager.install(resolvedSpec ?? options.spec);
46
48
 
47
49
  if (!options.enable) {
48
50
  const templates = buildContributionTemplates(installed.manifest.contributions);