openbot 0.5.7 → 0.5.9

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/CLAUDE.md CHANGED
@@ -31,7 +31,7 @@ OpenBot is a local-first, event-driven harness for running AI agents, built on t
31
31
 
32
32
  Core built-ins: `storage` (infra handlers for `/api/state`), `plugin-manager` (marketplace + npm plugin install). The default agent runtime is **`@meetopenbot/openbot`**, a community npm package installed to `~/.openbot/plugins/@meetopenbot/openbot/` on first boot (`ensureDefaultStack`). Disk-installed plugins take precedence over built-ins in `resolvePlugin`.
33
33
 
34
- A `Plugin`'s `factory(context)` returns a `MelonyPlugin`; `PluginContext` carries `agentId`, `agentDetails`, per-agent `config` (from `AGENT.md` frontmatter), the shared `storage` service, the merged `tools` map, `publicBaseUrl`, the run's `abortSignal`, and `host` (platform hooks: `runAgent`, cloud auth, model registry, config).
34
+ A `Plugin`'s `factory(context)` returns a `MelonyPlugin`; `PluginContext` carries `agentId`, `agentDetails`, per-agent `config` (from `AGENT.md` frontmatter), the shared `storage` service, the merged `tools` map, `publicBaseUrl`, the run's `abortSignal`, and `host` (platform hooks: `runAgent`, cloud auth, config).
35
35
 
36
36
  ### Agents
37
37
 
package/dist/app/cli.js CHANGED
@@ -18,7 +18,7 @@ function checkNodeVersion() {
18
18
  }
19
19
  }
20
20
  checkNodeVersion();
21
- program.name('openbot').description('OpenBot CLI').version('0.5.7');
21
+ program.name('openbot').description('OpenBot CLI').version('0.5.9');
22
22
  program
23
23
  .command('start')
24
24
  .description('Start the OpenBot harness')
@@ -7,6 +7,7 @@ import { abortRegistry, abortKey } from '../services/abort.js';
7
7
  import { loadConfig } from '../app/config.js';
8
8
  import { getPublicBaseUrl } from '../plugins/storage/files.js';
9
9
  import { createPluginHost } from '../services/plugins/host.js';
10
+ import { getInvokeConfigOverrides, mergePluginConfig } from './merge-plugin-config.js';
10
11
  export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
11
12
  async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId, }) {
12
13
  ensureEventId(chunk);
@@ -69,6 +70,11 @@ export async function runAgent(options) {
69
70
  }
70
71
  const builder = melony().initialState(state);
71
72
  const pluginHost = createPluginHost(runAgent);
73
+ const invokeConfigOverrides = getInvokeConfigOverrides(event);
74
+ const pluginConfigs = Object.fromEntries(pluginRefs.map((ref) => [
75
+ ref.id,
76
+ mergePluginConfig(ref.config, invokeConfigOverrides, ref.id),
77
+ ]));
72
78
  for (const ref of pluginRefs) {
73
79
  const plugin = await resolvePlugin(ref.id);
74
80
  if (!plugin)
@@ -76,7 +82,7 @@ export async function runAgent(options) {
76
82
  builder.use(plugin.factory({
77
83
  agentId,
78
84
  agentDetails,
79
- config: ref.config ?? {},
85
+ config: pluginConfigs[ref.id],
80
86
  storage: storageService,
81
87
  tools,
82
88
  publicBaseUrl,
@@ -0,0 +1,26 @@
1
+ function isPlainObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ function isPluginKeyedConfig(config) {
5
+ return Object.values(config).every(isPlainObject);
6
+ }
7
+ export function getInvokeConfigOverrides(event) {
8
+ if (event.type !== 'agent:invoke')
9
+ return undefined;
10
+ const config = event.data?.config;
11
+ if (!isPlainObject(config) || Object.keys(config).length === 0)
12
+ return undefined;
13
+ return config;
14
+ }
15
+ export function mergePluginConfig(base, overrides, pluginId) {
16
+ const baseConfig = base ?? {};
17
+ if (!overrides)
18
+ return baseConfig;
19
+ if (isPluginKeyedConfig(overrides)) {
20
+ const pluginOverride = overrides[pluginId];
21
+ if (!isPlainObject(pluginOverride))
22
+ return baseConfig;
23
+ return { ...baseConfig, ...pluginOverride };
24
+ }
25
+ return { ...baseConfig, ...overrides };
26
+ }
@@ -9,10 +9,10 @@ import crypto from 'node:crypto';
9
9
  import matter from 'gray-matter';
10
10
  import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
11
11
  import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
12
- import { enrichOpenbotPluginDescriptor, listRegistryModelOptions, } from '../../services/plugins/model-registry.js';
12
+ import { enrichOpenbotPluginDescriptor } from '../../services/plugins/enrich-openbot-plugin.js';
13
13
  import { processService } from '../../services/process.js';
14
14
  import { memoryService } from '../memory/service.js';
15
- import { guessMimeType, resolveChannelFile, statChannelFile, } from './files.js';
15
+ import { guessMimeType, resolveChannelFile, statChannelFile } from './files.js';
16
16
  const resolveBaseDir = () => getBaseDir();
17
17
  const ENTITY_SVG_CANDIDATE_NAMES = ['avatar.svg', 'icon.svg', 'image.svg', 'logo.svg'];
18
18
  const toSvgDataUrl = (svg) => `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
@@ -115,10 +115,7 @@ function mergeCloudSystemPluginRefs(defaults, diskRefs) {
115
115
  ];
116
116
  }
117
117
  /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
118
- const STATE_DEFAULT_PLUGINS = [
119
- { id: 'storage' },
120
- { id: 'plugin-manager' },
121
- ];
118
+ const STATE_DEFAULT_PLUGINS = [{ id: 'storage' }, { id: 'plugin-manager' }];
122
119
  const STATE_AGENT_INSTRUCTIONS = 'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
123
120
  function getSystemAgentDetails(overrides) {
124
121
  const defaultPlugins = isCloudMode() ? getCloudSystemDefaultPlugins() : SYSTEM_DEFAULT_PLUGINS;
@@ -289,12 +286,37 @@ const toVariablesRecord = (raw) => {
289
286
  String(value ?? ''),
290
287
  ]));
291
288
  };
289
+ let cachedRuntimeVersion;
290
+ const getRuntimeVersion = () => {
291
+ if (cachedRuntimeVersion !== undefined)
292
+ return cachedRuntimeVersion || undefined;
293
+ try {
294
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json');
295
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
296
+ cachedRuntimeVersion = typeof pkg.version === 'string' ? pkg.version : '';
297
+ }
298
+ catch {
299
+ cachedRuntimeVersion = '';
300
+ }
301
+ return cachedRuntimeVersion || undefined;
302
+ };
303
+ const readPluginPackageVersion = async (pluginDir) => {
304
+ try {
305
+ const pkg = JSON.parse(await fs.readFile(path.join(pluginDir, 'package.json'), 'utf-8'));
306
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
307
+ }
308
+ catch {
309
+ return undefined;
310
+ }
311
+ };
292
312
  const listBuiltInPluginDescriptors = async () => {
313
+ const version = getRuntimeVersion();
293
314
  return listBuiltInPlugins().map((plugin) => ({
294
315
  id: plugin.id,
295
316
  name: plugin.name,
296
317
  description: plugin.description,
297
318
  builtIn: true,
319
+ version,
298
320
  image: plugin.image,
299
321
  configSchema: plugin.configSchema,
300
322
  createdAt: new Date(),
@@ -356,12 +378,16 @@ const listPluginsFromDisk = async () => {
356
378
  const parsed = parsePluginModule(module);
357
379
  if (!parsed)
358
380
  return null;
359
- const image = await resolveEntityImageDataUrl(pluginDir);
381
+ const [image, version] = await Promise.all([
382
+ resolveEntityImageDataUrl(pluginDir),
383
+ readPluginPackageVersion(pluginDir),
384
+ ]);
360
385
  return {
361
386
  id,
362
387
  name: parsed.name || id,
363
388
  description: parsed.description || '',
364
389
  builtIn: false,
390
+ version,
365
391
  image: parsed.image || image,
366
392
  configSchema: parsed.configSchema,
367
393
  createdAt: new Date(),
@@ -480,7 +506,7 @@ export const storageService = {
480
506
  channel.recentThreads = allThreads
481
507
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
482
508
  .slice(0, 5);
483
- const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
509
+ const threadsUnseen = allThreads.some((t) => t.hasUnseenMessages);
484
510
  channel.hasUnseenMessages = rootUnseen || threadsUnseen;
485
511
  }
486
512
  catch {
@@ -521,8 +547,7 @@ export const storageService = {
521
547
  finalState.cwd = resolvedCwd;
522
548
  await fs.mkdir(resolvedCwd, { recursive: true });
523
549
  await fs.mkdir(channelDir, { recursive: true });
524
- await fs.writeFile(specPath, spec?.trim() ||
525
- `# ${normalizedChannelId}\n\n`);
550
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
526
551
  await writeJsonFileAtomically(statePath, finalState);
527
552
  },
528
553
  ensureChannel: async ({ channelId, spec, initialState, cwd, }) => {
@@ -684,9 +709,7 @@ export const storageService = {
684
709
  console.error(`Failed to read thread state for channel ${channelId} thread ${threadId}`, error);
685
710
  }
686
711
  }
687
- const threadName = isRecord(state) && typeof state.name === 'string'
688
- ? state.name.trim()
689
- : '';
712
+ const threadName = isRecord(state) && typeof state.name === 'string' ? state.name.trim() : '';
690
713
  return {
691
714
  id: threadId,
692
715
  name: threadName || threadId,
@@ -814,13 +837,12 @@ export const storageService = {
814
837
  return Array.from(deduped.values()).filter((agent) => !agent.hidden);
815
838
  },
816
839
  getPlugins: async () => {
817
- const [builtIn, fromDisk, modelOptions] = await Promise.all([
840
+ const [builtIn, fromDisk] = await Promise.all([
818
841
  listBuiltInPluginDescriptors(),
819
842
  listPluginsFromDisk(),
820
- listRegistryModelOptions(),
821
843
  ]);
822
- const enrichedBuiltIn = builtIn.map((plugin) => enrichOpenbotPluginDescriptor(plugin, modelOptions));
823
- const merged = [...enrichedBuiltIn, ...fromDisk];
844
+ const enrichPlugin = (plugin) => enrichOpenbotPluginDescriptor(plugin);
845
+ const merged = [...builtIn.map(enrichPlugin), ...fromDisk.map(enrichPlugin)];
824
846
  const deduped = new Map();
825
847
  for (const plugin of merged) {
826
848
  if (!deduped.has(plugin.id)) {
@@ -840,9 +862,7 @@ export const storageService = {
840
862
  const discoveredImage = await resolveEntityImageDataUrl(agentDir);
841
863
  const stats = await fs.stat(agentMdPath);
842
864
  const pluginRefs = parsePluginRefs(data.plugins);
843
- const frontmatterImage = typeof data.image === 'string' && data.image.trim() !== ''
844
- ? data.image.trim()
845
- : undefined;
865
+ const frontmatterImage = typeof data.image === 'string' && data.image.trim() !== '' ? data.image.trim() : undefined;
846
866
  diskDetails = {
847
867
  id: agentId,
848
868
  name: typeof data.name === 'string' ? data.name : agentId,
@@ -964,8 +984,7 @@ export const storageService = {
964
984
  // Built-in agents merge disk overlays with code defaults on read; on write, partial
965
985
  // updates (e.g. plugins-only) must still persist a complete AGENT.md.
966
986
  if (isBuiltinOverlayAgentId(agentId)) {
967
- const pluginRefs = plugins ??
968
- (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
987
+ const pluginRefs = plugins ?? (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
969
988
  const diskOverrides = {};
970
989
  if (typeof nextData.name === 'string' && nextData.name.trim() !== '') {
971
990
  diskOverrides.name = nextData.name;
@@ -1170,7 +1189,7 @@ export const storageService = {
1170
1189
  typeof raw === 'object' &&
1171
1190
  'variables' in raw &&
1172
1191
  Array.isArray(raw.variables)) {
1173
- const entries = (raw.variables)
1192
+ const entries = raw.variables
1174
1193
  .filter((v) => typeof v?.key === 'string')
1175
1194
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }]);
1176
1195
  return Object.fromEntries(entries);
@@ -1236,9 +1255,7 @@ export const storageService = {
1236
1255
  if (!baseCwd) {
1237
1256
  throw new Error('Channel has no CWD configured');
1238
1257
  }
1239
- const targetDir = subPath
1240
- ? resolveChannelFile(baseCwd, subPath)
1241
- : resolvePath(baseCwd);
1258
+ const targetDir = subPath ? resolveChannelFile(baseCwd, subPath) : resolvePath(baseCwd);
1242
1259
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1243
1260
  return entries
1244
1261
  .filter((e) => !e.name.startsWith('.'))
@@ -0,0 +1,38 @@
1
+ import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
2
+ import { DEFAULT_CLOUD_OPENBOT_AUTH_MODE, isCloudMode } from '../../app/cloud-mode.js';
3
+ /** Injects cloud platform fields into the openbot plugin descriptor for settings UI. */
4
+ export function enrichOpenbotPluginDescriptor(descriptor) {
5
+ if (descriptor.id !== OPENBOT_PLUGIN_ID || !descriptor.configSchema)
6
+ return descriptor;
7
+ if (!isCloudMode())
8
+ return descriptor;
9
+ const { authMode: _authMode, ...otherProperties } = descriptor.configSchema.properties;
10
+ const cloudAuthModeProperty = {
11
+ type: 'string',
12
+ description: 'Credits uses OpenBot platform billing. BYOK uses your own API keys.',
13
+ enum: ['credits', 'byok'],
14
+ default: DEFAULT_CLOUD_OPENBOT_AUTH_MODE,
15
+ options: [
16
+ {
17
+ label: 'Credits',
18
+ value: 'credits',
19
+ description: 'Use OpenBot platform credits.',
20
+ },
21
+ {
22
+ label: 'Bring your own key',
23
+ value: 'byok',
24
+ description: 'Use your own provider API keys.',
25
+ },
26
+ ],
27
+ };
28
+ return {
29
+ ...descriptor,
30
+ configSchema: {
31
+ ...descriptor.configSchema,
32
+ properties: {
33
+ authMode: cloudAuthModeProperty,
34
+ ...otherProperties,
35
+ },
36
+ },
37
+ };
38
+ }
@@ -2,15 +2,12 @@ import { DEFAULT_CLOUD_OPENBOT_AUTH_MODE, isCloudMode, isCloudSystemAgent, parse
2
2
  import { ORCHESTRATOR_AGENT_ID } from '../../app/agent-ids.js';
3
3
  import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
4
4
  import { getBaseDir, resolvePath, saveConfig } from '../../app/config.js';
5
- import { listApiKeyProvidersFromRegistry, resolveModelRegistry, } from './model-registry.js';
6
5
  export function createPluginHost(runAgent) {
7
6
  return {
8
7
  runAgent,
9
8
  isCloudSystemAgent,
10
9
  isCloudMode,
11
10
  parseOpenbotAuthMode,
12
- resolveModelRegistry,
13
- listApiKeyProvidersFromRegistry,
14
11
  saveConfig,
15
12
  getBaseDir,
16
13
  resolvePath,
@@ -74,7 +74,7 @@ export function enrichOpenbotPluginDescriptor(descriptor, modelOptions) {
74
74
  : pickDefaultModelValue(modelOptions) ?? staticDefault;
75
75
  const nextModelProperty = {
76
76
  ...modelProperty,
77
- description: 'Model from the hosted marketplace registry.',
77
+ description: modelProperty.description ?? 'Model from the hosted marketplace registry.',
78
78
  };
79
79
  if (values.length > 0) {
80
80
  nextModelProperty.enum = values;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,7 +11,7 @@
11
11
  "build": "tsc && mkdir -p dist/assets && cp src/assets/icon.svg dist/assets/icon.svg",
12
12
  "start": "node dist/app/cli.js start",
13
13
  "format": "prettier --write .",
14
- "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.7"
14
+ "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.9"
15
15
  },
16
16
  "bin": {
17
17
  "openbot": "./dist/app/cli.js"
package/src/app/cli.ts CHANGED
@@ -27,7 +27,7 @@ function checkNodeVersion() {
27
27
 
28
28
  checkNodeVersion();
29
29
 
30
- program.name('openbot').description('OpenBot CLI').version('0.5.7');
30
+ program.name('openbot').description('OpenBot CLI').version('0.5.9');
31
31
 
32
32
  program
33
33
  .command('start')
package/src/app/types.ts CHANGED
@@ -42,6 +42,8 @@ export type AgentInvokeEvent = BaseEvent & {
42
42
  role?: 'user' | 'assistant' | 'system';
43
43
  content: string;
44
44
  agentId?: string;
45
+ /** Per-run config overrides: flat fields (e.g. model) or nested by plugin id. */
46
+ config?: Record<string, unknown>;
45
47
  };
46
48
  };
47
49
 
@@ -9,6 +9,7 @@ import { abortRegistry, abortKey } from '../services/abort.js';
9
9
  import { loadConfig } from '../app/config.js';
10
10
  import { getPublicBaseUrl } from '../plugins/storage/files.js';
11
11
  import { createPluginHost } from '../services/plugins/host.js';
12
+ import { getInvokeConfigOverrides, mergePluginConfig } from './merge-plugin-config.js';
12
13
 
13
14
  export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
14
15
 
@@ -121,6 +122,13 @@ export async function runAgent(options: RunAgentOptions): Promise<void> {
121
122
  const builder = melony<OpenBotState, OpenBotEvent>().initialState(state);
122
123
 
123
124
  const pluginHost = createPluginHost(runAgent);
125
+ const invokeConfigOverrides = getInvokeConfigOverrides(event);
126
+ const pluginConfigs = Object.fromEntries(
127
+ pluginRefs.map((ref) => [
128
+ ref.id,
129
+ mergePluginConfig(ref.config, invokeConfigOverrides, ref.id),
130
+ ]),
131
+ );
124
132
 
125
133
  for (const ref of pluginRefs) {
126
134
  const plugin = await resolvePlugin(ref.id);
@@ -130,7 +138,7 @@ export async function runAgent(options: RunAgentOptions): Promise<void> {
130
138
  plugin.factory({
131
139
  agentId,
132
140
  agentDetails,
133
- config: ref.config ?? {},
141
+ config: pluginConfigs[ref.id],
134
142
  storage: storageService,
135
143
  tools,
136
144
  publicBaseUrl,
@@ -0,0 +1,35 @@
1
+ import { OpenBotEvent } from '../app/types.js';
2
+
3
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ function isPluginKeyedConfig(config: Record<string, unknown>): boolean {
8
+ return Object.values(config).every(isPlainObject);
9
+ }
10
+
11
+ export function getInvokeConfigOverrides(event: OpenBotEvent): Record<string, unknown> | undefined {
12
+ if (event.type !== 'agent:invoke') return undefined;
13
+
14
+ const config = event.data?.config;
15
+ if (!isPlainObject(config) || Object.keys(config).length === 0) return undefined;
16
+
17
+ return config;
18
+ }
19
+
20
+ export function mergePluginConfig(
21
+ base: Record<string, unknown> | undefined,
22
+ overrides: Record<string, unknown> | undefined,
23
+ pluginId: string,
24
+ ): Record<string, unknown> {
25
+ const baseConfig = base ?? {};
26
+ if (!overrides) return baseConfig;
27
+
28
+ if (isPluginKeyedConfig(overrides)) {
29
+ const pluginOverride = overrides[pluginId];
30
+ if (!isPlainObject(pluginOverride)) return baseConfig;
31
+ return { ...baseConfig, ...pluginOverride };
32
+ }
33
+
34
+ return { ...baseConfig, ...overrides };
35
+ }
@@ -33,18 +33,11 @@ import {
33
33
  import type { PluginRef } from '../../services/plugins/types.js';
34
34
  import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
35
35
  import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
36
- import {
37
- enrichOpenbotPluginDescriptor,
38
- listRegistryModelOptions,
39
- } from '../../services/plugins/model-registry.js';
36
+ import { enrichOpenbotPluginDescriptor } from '../../services/plugins/enrich-openbot-plugin.js';
40
37
  import { OpenBotEvent, OpenBotState } from '../../app/types.js';
41
38
  import { processService } from '../../services/process.js';
42
39
  import { memoryService } from '../memory/service.js';
43
- import {
44
- guessMimeType,
45
- resolveChannelFile,
46
- statChannelFile,
47
- } from './files.js';
40
+ import { guessMimeType, resolveChannelFile, statChannelFile } from './files.js';
48
41
 
49
42
  const resolveBaseDir = () => getBaseDir();
50
43
 
@@ -142,10 +135,7 @@ function getCloudSystemDefaultPlugins(): PluginRef[] {
142
135
  });
143
136
  }
144
137
 
145
- function mergeCloudSystemPluginRefs(
146
- defaults: PluginRef[],
147
- diskRefs: PluginRef[],
148
- ): PluginRef[] {
138
+ function mergeCloudSystemPluginRefs(defaults: PluginRef[], diskRefs: PluginRef[]): PluginRef[] {
149
139
  const defaultOpenbot = defaults.find((ref) => ref.id === OPENBOT_PLUGIN_ID);
150
140
  const diskOpenbot = diskRefs.find((ref) => ref.id === OPENBOT_PLUGIN_ID);
151
141
 
@@ -163,10 +153,7 @@ function mergeCloudSystemPluginRefs(
163
153
  }
164
154
 
165
155
  /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
166
- const STATE_DEFAULT_PLUGINS: PluginRef[] = [
167
- { id: 'storage' },
168
- { id: 'plugin-manager' },
169
- ];
156
+ const STATE_DEFAULT_PLUGINS: PluginRef[] = [{ id: 'storage' }, { id: 'plugin-manager' }];
170
157
 
171
158
  const STATE_AGENT_INSTRUCTIONS =
172
159
  'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
@@ -177,8 +164,7 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
177
164
  id: SYSTEM_AGENT_ID,
178
165
  name: 'OpenBot',
179
166
  image: getBundledSystemAgentImage(),
180
- description:
181
- 'First-party orchestration agent for OpenBot.',
167
+ description: 'First-party orchestration agent for OpenBot.',
182
168
  instructions: '',
183
169
  plugins: defaultPlugins.map((ref) => ref.id),
184
170
  pluginRefs: defaultPlugins,
@@ -211,9 +197,10 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
211
197
  };
212
198
  }
213
199
 
214
- const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
215
- ? overrides.pluginRefs
216
- : defaults.pluginRefs;
200
+ const refs =
201
+ overrides.pluginRefs && overrides.pluginRefs.length > 0
202
+ ? overrides.pluginRefs
203
+ : defaults.pluginRefs;
217
204
 
218
205
  return {
219
206
  ...defaults,
@@ -243,9 +230,10 @@ function getStateAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
243
230
 
244
231
  if (!overrides) return defaults;
245
232
 
246
- const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
247
- ? overrides.pluginRefs
248
- : defaults.pluginRefs;
233
+ const refs =
234
+ overrides.pluginRefs && overrides.pluginRefs.length > 0
235
+ ? overrides.pluginRefs
236
+ : defaults.pluginRefs;
249
237
 
250
238
  const diskInstructions = overrides.instructions?.trim();
251
239
  const instructions =
@@ -374,12 +362,42 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
374
362
  );
375
363
  };
376
364
 
365
+ let cachedRuntimeVersion: string | undefined;
366
+
367
+ const getRuntimeVersion = (): string | undefined => {
368
+ if (cachedRuntimeVersion !== undefined) return cachedRuntimeVersion || undefined;
369
+ try {
370
+ const pkgPath = path.join(
371
+ path.dirname(fileURLToPath(import.meta.url)),
372
+ '../../../package.json',
373
+ );
374
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown };
375
+ cachedRuntimeVersion = typeof pkg.version === 'string' ? pkg.version : '';
376
+ } catch {
377
+ cachedRuntimeVersion = '';
378
+ }
379
+ return cachedRuntimeVersion || undefined;
380
+ };
381
+
382
+ const readPluginPackageVersion = async (pluginDir: string): Promise<string | undefined> => {
383
+ try {
384
+ const pkg = JSON.parse(await fs.readFile(path.join(pluginDir, 'package.json'), 'utf-8')) as {
385
+ version?: unknown;
386
+ };
387
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
388
+ } catch {
389
+ return undefined;
390
+ }
391
+ };
392
+
377
393
  const listBuiltInPluginDescriptors = async (): Promise<PluginDescriptor[]> => {
394
+ const version = getRuntimeVersion();
378
395
  return listBuiltInPlugins().map((plugin) => ({
379
396
  id: plugin.id,
380
397
  name: plugin.name,
381
398
  description: plugin.description,
382
399
  builtIn: true,
400
+ version,
383
401
  image: plugin.image,
384
402
  configSchema: plugin.configSchema,
385
403
  createdAt: new Date(),
@@ -443,12 +461,16 @@ const listPluginsFromDisk = async (): Promise<PluginDescriptor[]> => {
443
461
  const module = await import(pathToFileURL(distPath).href);
444
462
  const parsed = parsePluginModule(module as Record<string, unknown>);
445
463
  if (!parsed) return null;
446
- const image = await resolveEntityImageDataUrl(pluginDir);
464
+ const [image, version] = await Promise.all([
465
+ resolveEntityImageDataUrl(pluginDir),
466
+ readPluginPackageVersion(pluginDir),
467
+ ]);
447
468
  return {
448
469
  id,
449
470
  name: parsed.name || id,
450
471
  description: parsed.description || '',
451
472
  builtIn: false,
473
+ version,
452
474
  image: parsed.image || image,
453
475
  configSchema: parsed.configSchema,
454
476
  createdAt: new Date(),
@@ -468,9 +490,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
468
490
  !!value && typeof value === 'object' && !Array.isArray(value);
469
491
 
470
492
  /** Display-oriented fields persisted in a channel's `state.json`. */
471
- const readChannelStateFileFields = (
472
- parsed: unknown,
473
- ): { name?: string; cwd?: string } => {
493
+ const readChannelStateFileFields = (parsed: unknown): { name?: string; cwd?: string } => {
474
494
  if (!isRecord(parsed)) {
475
495
  return {};
476
496
  }
@@ -583,7 +603,7 @@ export const storageService = {
583
603
  };
584
604
 
585
605
  const channelLastRead = lastReadMap[name] || {};
586
-
606
+
587
607
  try {
588
608
  // Check root unread
589
609
  const rootEvents = await storageService.getEvents({ channelId: name });
@@ -595,8 +615,8 @@ export const storageService = {
595
615
  channel.recentThreads = allThreads
596
616
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
597
617
  .slice(0, 5);
598
-
599
- const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
618
+
619
+ const threadsUnseen = allThreads.some((t) => t.hasUnseenMessages);
600
620
  channel.hasUnseenMessages = rootUnseen || threadsUnseen;
601
621
  } catch {
602
622
  channel.hasUnseenMessages = false;
@@ -654,11 +674,7 @@ export const storageService = {
654
674
  finalState.cwd = resolvedCwd;
655
675
  await fs.mkdir(resolvedCwd, { recursive: true });
656
676
  await fs.mkdir(channelDir, { recursive: true });
657
- await fs.writeFile(
658
- specPath,
659
- spec?.trim() ||
660
- `# ${normalizedChannelId}\n\n`,
661
- );
677
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
662
678
  await writeJsonFileAtomically(statePath, finalState);
663
679
  },
664
680
  ensureChannel: async ({
@@ -708,10 +724,7 @@ export const storageService = {
708
724
  await fs.access(specPath);
709
725
  } catch (error: unknown) {
710
726
  if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
711
- await fs.writeFile(
712
- specPath,
713
- spec?.trim() || `# ${normalizedChannelId}\n\n`,
714
- );
727
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
715
728
  } else {
716
729
  throw error;
717
730
  }
@@ -818,8 +831,7 @@ export const storageService = {
818
831
 
819
832
  try {
820
833
  const threadState = await readJsonFile<Record<string, unknown>>(threadStatePath, {});
821
- const threadName =
822
- typeof threadState.name === 'string' ? threadState.name.trim() : '';
834
+ const threadName = typeof threadState.name === 'string' ? threadState.name.trim() : '';
823
835
  if (threadName) {
824
836
  threadDisplayName = threadName;
825
837
  }
@@ -876,10 +888,7 @@ export const storageService = {
876
888
  }
877
889
  }
878
890
 
879
- const threadName =
880
- isRecord(state) && typeof state.name === 'string'
881
- ? state.name.trim()
882
- : '';
891
+ const threadName = isRecord(state) && typeof state.name === 'string' ? state.name.trim() : '';
883
892
 
884
893
  return {
885
894
  id: threadId,
@@ -1044,17 +1053,14 @@ export const storageService = {
1044
1053
  return Array.from(deduped.values()).filter((agent) => !agent.hidden);
1045
1054
  },
1046
1055
  getPlugins: async (): Promise<PluginDescriptor[]> => {
1047
- const [builtIn, fromDisk, modelOptions] = await Promise.all([
1056
+ const [builtIn, fromDisk] = await Promise.all([
1048
1057
  listBuiltInPluginDescriptors(),
1049
1058
  listPluginsFromDisk(),
1050
- listRegistryModelOptions(),
1051
1059
  ]);
1052
1060
 
1053
- const enrichedBuiltIn = builtIn.map((plugin) =>
1054
- enrichOpenbotPluginDescriptor(plugin, modelOptions),
1055
- );
1061
+ const enrichPlugin = (plugin: PluginDescriptor) => enrichOpenbotPluginDescriptor(plugin);
1056
1062
 
1057
- const merged = [...enrichedBuiltIn, ...fromDisk];
1063
+ const merged = [...builtIn.map(enrichPlugin), ...fromDisk.map(enrichPlugin)];
1058
1064
  const deduped = new Map<string, PluginDescriptor>();
1059
1065
  for (const plugin of merged) {
1060
1066
  if (!deduped.has(plugin.id)) {
@@ -1078,9 +1084,7 @@ export const storageService = {
1078
1084
 
1079
1085
  const pluginRefs = parsePluginRefs(data.plugins);
1080
1086
  const frontmatterImage =
1081
- typeof data.image === 'string' && data.image.trim() !== ''
1082
- ? data.image.trim()
1083
- : undefined;
1087
+ typeof data.image === 'string' && data.image.trim() !== '' ? data.image.trim() : undefined;
1084
1088
 
1085
1089
  diskDetails = {
1086
1090
  id: agentId,
@@ -1237,8 +1241,7 @@ export const storageService = {
1237
1241
  // updates (e.g. plugins-only) must still persist a complete AGENT.md.
1238
1242
  if (isBuiltinOverlayAgentId(agentId)) {
1239
1243
  const pluginRefs =
1240
- plugins ??
1241
- (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
1244
+ plugins ?? (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
1242
1245
  const diskOverrides: Partial<AgentDetails> = {};
1243
1246
  if (typeof nextData.name === 'string' && nextData.name.trim() !== '') {
1244
1247
  diskOverrides.name = nextData.name;
@@ -1472,7 +1475,7 @@ export const storageService = {
1472
1475
  'variables' in raw &&
1473
1476
  Array.isArray((raw as { variables: unknown }).variables)
1474
1477
  ) {
1475
- const entries = ((raw as { variables: StoredVariable[] }).variables)
1478
+ const entries = (raw as { variables: StoredVariable[] }).variables
1476
1479
  .filter((v) => typeof v?.key === 'string')
1477
1480
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }] as const);
1478
1481
  return Object.fromEntries(entries);
@@ -1574,9 +1577,7 @@ export const storageService = {
1574
1577
  throw new Error('Channel has no CWD configured');
1575
1578
  }
1576
1579
 
1577
- const targetDir = subPath
1578
- ? resolveChannelFile(baseCwd, subPath)
1579
- : resolvePath(baseCwd);
1580
+ const targetDir = subPath ? resolveChannelFile(baseCwd, subPath) : resolvePath(baseCwd);
1580
1581
 
1581
1582
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1582
1583
  return entries
@@ -1623,8 +1624,7 @@ export const storageService = {
1623
1624
 
1624
1625
  const targetFile = resolveChannelFile(baseCwd, filePath);
1625
1626
  const buf = await fs.readFile(targetFile);
1626
- const content =
1627
- encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1627
+ const content = encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1628
1628
 
1629
1629
  return {
1630
1630
  content,
@@ -35,6 +35,8 @@ export type PluginDescriptor = {
35
35
  description: string;
36
36
  /** True when bundled with the core server (`src/registry/plugins`); false for ~/.openbot/plugins installs. */
37
37
  builtIn: boolean;
38
+ /** Installed npm semver for disk plugins; runtime package version for built-ins. */
39
+ version?: string;
38
40
  image?: string;
39
41
  configSchema?: ConfigSchema;
40
42
  createdAt: Date;
@@ -136,7 +138,11 @@ export interface Storage {
136
138
  }) => Promise<void>;
137
139
  getThreads: (args: { channelId: string }) => Promise<Thread[]>;
138
140
  getThreadDetails: (args: { channelId: string; threadId: string }) => Promise<ThreadDetails>;
139
- setLastRead: (args: { channelId: string; threadId?: string; lastReadEventId: string }) => Promise<void>;
141
+ setLastRead: (args: {
142
+ channelId: string;
143
+ threadId?: string;
144
+ lastReadEventId: string;
145
+ }) => Promise<void>;
140
146
  /** User-facing agent list; excludes agents with `hidden: true` (e.g. built-in `state`). */
141
147
  getAgents: () => Promise<Agent[]>;
142
148
  getPlugins: () => Promise<PluginDescriptor[]>;
@@ -0,0 +1,41 @@
1
+ import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
2
+ import { DEFAULT_CLOUD_OPENBOT_AUTH_MODE, isCloudMode } from '../../app/cloud-mode.js';
3
+ import type { ConfigSchema, PluginDescriptor } from './domain.js';
4
+
5
+ /** Injects cloud platform fields into the openbot plugin descriptor for settings UI. */
6
+ export function enrichOpenbotPluginDescriptor(descriptor: PluginDescriptor): PluginDescriptor {
7
+ if (descriptor.id !== OPENBOT_PLUGIN_ID || !descriptor.configSchema) return descriptor;
8
+ if (!isCloudMode()) return descriptor;
9
+
10
+ const { authMode: _authMode, ...otherProperties } = descriptor.configSchema.properties;
11
+
12
+ const cloudAuthModeProperty: ConfigSchema['properties'][string] = {
13
+ type: 'string',
14
+ description: 'Credits uses OpenBot platform billing. BYOK uses your own API keys.',
15
+ enum: ['credits', 'byok'],
16
+ default: DEFAULT_CLOUD_OPENBOT_AUTH_MODE,
17
+ options: [
18
+ {
19
+ label: 'Credits',
20
+ value: 'credits',
21
+ description: 'Use OpenBot platform credits.',
22
+ },
23
+ {
24
+ label: 'Bring your own key',
25
+ value: 'byok',
26
+ description: 'Use your own provider API keys.',
27
+ },
28
+ ],
29
+ };
30
+
31
+ return {
32
+ ...descriptor,
33
+ configSchema: {
34
+ ...descriptor.configSchema,
35
+ properties: {
36
+ authMode: cloudAuthModeProperty,
37
+ ...otherProperties,
38
+ },
39
+ },
40
+ };
41
+ }
@@ -8,10 +8,6 @@ import {
8
8
  import { ORCHESTRATOR_AGENT_ID } from '../../app/agent-ids.js';
9
9
  import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
10
10
  import { getBaseDir, resolvePath, saveConfig } from '../../app/config.js';
11
- import {
12
- listApiKeyProvidersFromRegistry,
13
- resolveModelRegistry,
14
- } from './model-registry.js';
15
11
  import type { PluginHost, RunAgentHostOptions } from './types.js';
16
12
 
17
13
  export function createPluginHost(
@@ -22,8 +18,6 @@ export function createPluginHost(
22
18
  isCloudSystemAgent,
23
19
  isCloudMode,
24
20
  parseOpenbotAuthMode,
25
- resolveModelRegistry,
26
- listApiKeyProvidersFromRegistry,
27
21
  saveConfig,
28
22
  getBaseDir,
29
23
  resolvePath,
@@ -38,12 +38,6 @@ export interface PluginHost {
38
38
  isCloudSystemAgent: (agentId: string) => boolean;
39
39
  isCloudMode: () => boolean;
40
40
  parseOpenbotAuthMode: (value: unknown) => 'credits' | 'byok';
41
- resolveModelRegistry: () => ReturnType<
42
- typeof import('./model-registry.js').resolveModelRegistry
43
- >;
44
- listApiKeyProvidersFromRegistry: (
45
- registry: Awaited<ReturnType<PluginHost['resolveModelRegistry']>>,
46
- ) => Array<{ id: string; label: string }>;
47
41
  saveConfig: (patch: Record<string, unknown>) => void;
48
42
  getBaseDir: () => string;
49
43
  resolvePath: (p: string) => string;
@@ -1,178 +0,0 @@
1
- import {
2
- DEFAULT_MARKETPLACE_REGISTRY_URL,
3
- loadConfig,
4
- } from '../../app/config.js';
5
- import { OPENBOT_PLUGIN_ID } from '../../app/openbot-plugin.js';
6
- import {
7
- DEFAULT_CLOUD_OPENBOT_AUTH_MODE,
8
- isCloudMode,
9
- } from '../../app/cloud-mode.js';
10
- import type { ConfigSchema, PluginDescriptor } from './domain.js';
11
-
12
- export type RegistryProviderCatalog = Record<
13
- string,
14
- {
15
- label: string;
16
- models: Array<{ id: string; label: string; description: string }>;
17
- }
18
- >;
19
-
20
- export type ModelRegistry = {
21
- providers?: RegistryProviderCatalog;
22
- };
23
-
24
- export type RegistryModelOption = {
25
- value: string;
26
- label: string;
27
- description?: string;
28
- provider: string;
29
- };
30
-
31
- let cachedRegistry: ModelRegistry | null = null;
32
- let cacheUrl: string | null = null;
33
-
34
- function getRegistryUrl(): string {
35
- const { marketplaceRegistryUrl } = loadConfig();
36
- return marketplaceRegistryUrl?.trim() || DEFAULT_MARKETPLACE_REGISTRY_URL;
37
- }
38
-
39
- export async function resolveModelRegistry(): Promise<ModelRegistry> {
40
- const url = getRegistryUrl();
41
- if (cachedRegistry && cacheUrl === url) return cachedRegistry;
42
-
43
- try {
44
- const res = await fetch(url, {
45
- headers: { Accept: 'application/json' },
46
- signal: AbortSignal.timeout(15_000),
47
- });
48
- if (!res.ok) {
49
- throw new Error(`Registry HTTP ${res.status} ${res.statusText}`);
50
- }
51
- cachedRegistry = (await res.json()) as ModelRegistry;
52
- cacheUrl = url;
53
- return cachedRegistry;
54
- } catch (err) {
55
- console.warn(
56
- '[model-registry] fetch failed:',
57
- err instanceof Error ? err.message : err,
58
- );
59
- return { providers: {} };
60
- }
61
- }
62
-
63
- export function listModelOptionsFromRegistry(registry: ModelRegistry): RegistryModelOption[] {
64
- const providers = registry.providers ?? {};
65
- const out: RegistryModelOption[] = [];
66
-
67
- for (const [providerId, provider] of Object.entries(providers)) {
68
- for (const model of provider.models ?? []) {
69
- out.push({
70
- value: `${providerId}/${model.id}`,
71
- label: `${provider.label} — ${model.label}`,
72
- description: model.description,
73
- provider: providerId,
74
- });
75
- }
76
- }
77
-
78
- return out;
79
- }
80
-
81
- export async function listRegistryModelOptions(): Promise<RegistryModelOption[]> {
82
- const registry = await resolveModelRegistry();
83
- return listModelOptionsFromRegistry(registry);
84
- }
85
-
86
- export function listApiKeyProvidersFromRegistry(
87
- registry: ModelRegistry,
88
- ): Array<{ id: string; label: string }> {
89
- const providers = registry.providers ?? {};
90
- return Object.entries(providers).map(([id, provider]) => ({
91
- id,
92
- label: provider.label,
93
- }));
94
- }
95
-
96
- function pickDefaultModelValue(options: RegistryModelOption[]): string | undefined {
97
- if (options.length === 0) return undefined;
98
- const values = options.map((option) => option.value);
99
- const preferred = values.find((value) => value.startsWith('openai/'));
100
- return preferred ?? values[0];
101
- }
102
-
103
- export function enrichOpenbotPluginDescriptor(
104
- descriptor: PluginDescriptor,
105
- modelOptions: RegistryModelOption[],
106
- ): PluginDescriptor {
107
- if (descriptor.id !== OPENBOT_PLUGIN_ID || !descriptor.configSchema) return descriptor;
108
-
109
- const modelProperty = descriptor.configSchema.properties.model;
110
- if (!modelProperty) return descriptor;
111
-
112
- const values = modelOptions.map((option) => option.value);
113
- const staticDefault =
114
- typeof modelProperty.default === 'string' ? modelProperty.default : undefined;
115
- const defaultModel =
116
- staticDefault && values.includes(staticDefault)
117
- ? staticDefault
118
- : pickDefaultModelValue(modelOptions) ?? staticDefault;
119
-
120
- const nextModelProperty: ConfigSchema['properties'][string] = {
121
- ...modelProperty,
122
- description: 'Model from the hosted marketplace registry.',
123
- };
124
-
125
- if (values.length > 0) {
126
- nextModelProperty.enum = values;
127
- nextModelProperty.options = modelOptions.map((option) => ({
128
- label: option.label,
129
- value: option.value,
130
- description: option.description,
131
- }));
132
- }
133
-
134
- if (defaultModel) {
135
- nextModelProperty.default = defaultModel;
136
- }
137
-
138
- const { model: _model, authMode: _authMode, ...otherProperties } =
139
- descriptor.configSchema.properties;
140
-
141
- const cloudAuthModeProperty: ConfigSchema['properties'][string] = {
142
- type: 'string',
143
- description: 'Credits uses OpenBot platform billing. BYOK uses your own API keys.',
144
- enum: ['credits', 'byok'],
145
- default: DEFAULT_CLOUD_OPENBOT_AUTH_MODE,
146
- options: [
147
- {
148
- label: 'Credits',
149
- value: 'credits',
150
- description: 'Use OpenBot platform credits.',
151
- },
152
- {
153
- label: 'Bring your own key',
154
- value: 'byok',
155
- description: 'Use your own provider API keys.',
156
- },
157
- ],
158
- };
159
-
160
- const nextProperties: ConfigSchema['properties'] = isCloudMode()
161
- ? {
162
- authMode: cloudAuthModeProperty,
163
- model: nextModelProperty,
164
- ...otherProperties,
165
- }
166
- : {
167
- model: nextModelProperty,
168
- ...otherProperties,
169
- };
170
-
171
- return {
172
- ...descriptor,
173
- configSchema: {
174
- ...descriptor.configSchema,
175
- properties: nextProperties,
176
- },
177
- };
178
- }