openbot 0.2.14 → 0.3.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 (84) hide show
  1. package/dist/agents/openbot/index.js +76 -0
  2. package/dist/agents/openbot/middleware/approval.js +132 -0
  3. package/dist/agents/openbot/runtime.js +289 -0
  4. package/dist/agents/openbot/system-prompt.js +32 -0
  5. package/dist/agents/openbot/tools/delegation.js +78 -0
  6. package/dist/agents/openbot/tools/mcp.js +99 -0
  7. package/dist/agents/openbot/tools/shell.js +91 -0
  8. package/dist/agents/openbot/tools/storage.js +75 -0
  9. package/dist/agents/openbot/tools/ui.js +176 -0
  10. package/dist/agents/system.js +20 -93
  11. package/dist/app/cli.js +1 -1
  12. package/dist/app/config.js +4 -1
  13. package/dist/app/server.js +15 -8
  14. package/dist/bus/agent-package.js +1 -0
  15. package/dist/bus/plugin.js +1 -0
  16. package/dist/bus/services.js +711 -0
  17. package/dist/bus/types.js +1 -0
  18. package/dist/harness/context.js +250 -0
  19. package/dist/harness/event-normalizer.js +59 -0
  20. package/dist/harness/orchestrator.js +27 -227
  21. package/dist/harness/process.js +25 -3
  22. package/dist/harness/queue-processor.js +227 -0
  23. package/dist/harness/runtime-factory.js +103 -0
  24. package/dist/plugins/ai-sdk/index.js +37 -0
  25. package/dist/plugins/ai-sdk/runtime.js +402 -0
  26. package/dist/plugins/ai-sdk/system-prompt.js +3 -0
  27. package/dist/plugins/ai-sdk.js +277 -87
  28. package/dist/plugins/approval/index.js +159 -0
  29. package/dist/plugins/approval.js +163 -0
  30. package/dist/plugins/delegation/index.js +79 -0
  31. package/dist/plugins/delegation.js +67 -11
  32. package/dist/plugins/mcp/index.js +108 -0
  33. package/dist/plugins/memory/index.js +71 -0
  34. package/dist/plugins/shell/index.js +99 -0
  35. package/dist/plugins/shell.js +123 -0
  36. package/dist/plugins/storage-tools/index.js +85 -0
  37. package/dist/plugins/storage.js +240 -5
  38. package/dist/plugins/ui/index.js +184 -0
  39. package/dist/plugins/ui.js +185 -21
  40. package/dist/registry/agents.js +138 -0
  41. package/dist/registry/plugins.js +93 -50
  42. package/dist/services/agent-packages.js +103 -0
  43. package/dist/services/memory.js +152 -0
  44. package/dist/services/plugins.js +98 -0
  45. package/dist/services/storage.js +366 -94
  46. package/docs/agents.md +52 -65
  47. package/docs/architecture.md +1 -1
  48. package/docs/plugins.md +70 -58
  49. package/docs/templates/AGENT.example.md +57 -0
  50. package/package.json +8 -7
  51. package/src/app/cli.ts +1 -1
  52. package/src/app/config.ts +14 -4
  53. package/src/app/server.ts +23 -10
  54. package/src/app/types.ts +445 -16
  55. package/src/assets/icon.svg +4 -1
  56. package/src/bus/plugin.ts +67 -0
  57. package/src/bus/services.ts +786 -0
  58. package/src/bus/types.ts +160 -0
  59. package/src/harness/context.ts +293 -0
  60. package/src/harness/event-normalizer.ts +82 -0
  61. package/src/harness/orchestrator.ts +35 -273
  62. package/src/harness/process.ts +28 -4
  63. package/src/harness/queue-processor.ts +309 -0
  64. package/src/harness/runtime-factory.ts +125 -0
  65. package/src/plugins/ai-sdk/index.ts +44 -0
  66. package/src/plugins/ai-sdk/runtime.ts +484 -0
  67. package/src/plugins/ai-sdk/system-prompt.ts +4 -0
  68. package/src/plugins/approval/index.ts +228 -0
  69. package/src/plugins/delegation/index.ts +94 -0
  70. package/src/plugins/mcp/index.ts +128 -0
  71. package/src/plugins/memory/index.ts +85 -0
  72. package/src/plugins/shell/index.ts +123 -0
  73. package/src/plugins/storage-tools/index.ts +101 -0
  74. package/src/plugins/ui/index.ts +227 -0
  75. package/src/registry/plugins.ts +108 -55
  76. package/src/services/memory.ts +213 -0
  77. package/src/services/plugins.ts +133 -0
  78. package/src/services/storage.ts +472 -137
  79. package/src/agents/system.ts +0 -112
  80. package/src/plugins/ai-sdk.ts +0 -197
  81. package/src/plugins/delegation.ts +0 -60
  82. package/src/plugins/mcp.ts +0 -154
  83. package/src/plugins/storage.ts +0 -725
  84. package/src/plugins/ui.ts +0 -57
@@ -1,19 +1,14 @@
1
- import { DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, DEFAULT_PLUGINS_DIR, loadConfig, resolvePath, VARIABLES_FILE, } from '../app/config.js';
1
+ import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, loadConfig, resolvePath, VARIABLES_FILE, } from '../app/config.js';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import matter from 'gray-matter';
6
- import { getSystemAgentDetails } from '../agents/system.js';
6
+ import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
7
+ import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
8
+ import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
9
+ import { processService } from '../harness/process.js';
10
+ import { memoryService } from './memory.js';
7
11
  import { pathToFileURL } from 'node:url';
8
- const mapNameToPlugin = (name, description, kind = 'tool', image) => ({
9
- id: name,
10
- name,
11
- description,
12
- kind,
13
- image,
14
- createdAt: new Date(),
15
- updatedAt: new Date(),
16
- });
17
12
  const resolveBaseDir = () => {
18
13
  const config = loadConfig();
19
14
  return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
@@ -32,15 +27,6 @@ const tryReadSvgDataUrl = async (filePath) => {
32
27
  return null;
33
28
  }
34
29
  };
35
- /**
36
- * Auto-discovers an entity SVG avatar and returns it as a data URL.
37
- *
38
- * Search order:
39
- * 1) <entity>/assets/avatar.svg|icon.svg|image.svg|logo.svg
40
- * 2) <entity>/avatar.svg|icon.svg|image.svg|logo.svg
41
- * 3) first *.svg in <entity>/assets
42
- * 4) first *.svg in <entity>
43
- */
44
30
  const resolveEntityImageDataUrl = async (entityDir) => {
45
31
  const preferredDirs = [path.join(entityDir, 'assets'), entityDir];
46
32
  for (const dir of preferredDirs) {
@@ -61,7 +47,7 @@ const resolveEntityImageDataUrl = async (entityDir) => {
61
47
  return dataUrl;
62
48
  }
63
49
  catch {
64
- // ignore missing/unreadable folders
50
+ // ignore
65
51
  }
66
52
  }
67
53
  return undefined;
@@ -70,6 +56,60 @@ const getConversationDir = (channelId, threadId) => {
70
56
  const base = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId);
71
57
  return threadId ? `${base}/threads/${threadId}` : base;
72
58
  };
59
+ /** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
60
+ const SYSTEM_AGENT_ID = 'system';
61
+ const SYSTEM_DEFAULT_PLUGINS = [
62
+ { id: 'ai-sdk', config: { model: 'openai/gpt-5.4-nano' } },
63
+ { id: 'storage-tools' },
64
+ // { id: 'mcp' },
65
+ { id: 'shell' },
66
+ { id: 'delegation' },
67
+ // { id: 'ui' },
68
+ { id: 'approval' },
69
+ { id: 'memory' },
70
+ ];
71
+ function getSystemAgentDetails(overrides) {
72
+ const defaults = {
73
+ id: SYSTEM_AGENT_ID,
74
+ name: 'OpenBot',
75
+ image: undefined,
76
+ description: 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff and delegation.',
77
+ instructions: AI_SDK_SYSTEM_PROMPT,
78
+ plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
79
+ pluginRefs: SYSTEM_DEFAULT_PLUGINS,
80
+ createdAt: new Date(),
81
+ updatedAt: new Date(),
82
+ };
83
+ if (!overrides)
84
+ return defaults;
85
+ const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
86
+ ? overrides.pluginRefs
87
+ : defaults.pluginRefs;
88
+ return {
89
+ ...defaults,
90
+ ...overrides,
91
+ id: SYSTEM_AGENT_ID,
92
+ image: overrides.image || defaults.image,
93
+ plugins: refs.map((ref) => ref.id),
94
+ pluginRefs: refs,
95
+ updatedAt: new Date(),
96
+ };
97
+ }
98
+ // Suppress unused warning until system agent customization re-uses aiSdkPlugin metadata.
99
+ void aiSdkPlugin;
100
+ const RESERVED_DISK_AGENT_IDS = new Set([SYSTEM_AGENT_ID]);
101
+ const assertValidDiskAgentId = (agentId) => {
102
+ if (!agentId || typeof agentId !== 'string') {
103
+ throw new Error('agentId is required');
104
+ }
105
+ if (RESERVED_DISK_AGENT_IDS.has(agentId)) {
106
+ throw new Error(`Agent id "${agentId}" is reserved`);
107
+ }
108
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
109
+ throw new Error('agentId must contain only letters, digits, underscores, and hyphens');
110
+ }
111
+ };
112
+ const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
73
113
  const getLastReadFilePath = () => path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
74
114
  const THREAD_TITLE_MAX_LENGTH = 80;
75
115
  const buildThreadTitleFromEvent = (event) => {
@@ -104,25 +144,67 @@ const toVariablesRecord = (raw) => {
104
144
  if (!raw || typeof raw !== 'object') {
105
145
  return {};
106
146
  }
107
- // Current format: { version: number, variables: StoredVariable[] }
108
147
  if ('variables' in raw && Array.isArray(raw.variables)) {
109
148
  const entries = raw.variables
110
149
  .filter((variable) => typeof variable?.key === 'string')
111
150
  .map((variable) => [variable.key, String(variable.value ?? '')]);
112
151
  return Object.fromEntries(entries);
113
152
  }
114
- // Legacy format: { [key: string]: string }
115
153
  return Object.fromEntries(Object.entries(raw).map(([key, value]) => [
116
154
  key,
117
155
  String(value ?? ''),
118
156
  ]));
119
157
  };
120
- const listBuiltInPlugins = async () => {
121
- return [
122
- mapNameToPlugin('storage', 'Built-in storage plugin'),
123
- mapNameToPlugin('ai-sdk', 'Built-in AI SDK plugin', 'runtime'),
124
- mapNameToPlugin('delegation', 'Built-in delegation plugin'),
125
- ];
158
+ const listBuiltInPluginDescriptors = async () => {
159
+ return listBuiltInPlugins().map((plugin) => ({
160
+ id: plugin.id,
161
+ name: plugin.name,
162
+ description: plugin.description,
163
+ builtIn: true,
164
+ image: plugin.image,
165
+ defaultInstructions: plugin.defaultInstructions,
166
+ configSchema: plugin.configSchema,
167
+ createdAt: new Date(),
168
+ updatedAt: new Date(),
169
+ }));
170
+ };
171
+ /**
172
+ * Walk `plugins/` and yield candidate plugin ids (npm names). Includes scoped
173
+ * packages by recursing one level into directories starting with `@`.
174
+ */
175
+ const listInstalledPluginIds = async (pluginsDir) => {
176
+ const out = [];
177
+ let topEntries;
178
+ try {
179
+ topEntries = await fs.readdir(pluginsDir, { withFileTypes: true });
180
+ }
181
+ catch {
182
+ return out;
183
+ }
184
+ for (const entry of topEntries) {
185
+ if (entry.name.startsWith('.'))
186
+ continue;
187
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
188
+ continue;
189
+ if (entry.name.startsWith('@')) {
190
+ try {
191
+ const inner = await fs.readdir(path.join(pluginsDir, entry.name), { withFileTypes: true });
192
+ for (const sub of inner) {
193
+ if (sub.name.startsWith('.'))
194
+ continue;
195
+ if (sub.isDirectory() || sub.isSymbolicLink()) {
196
+ out.push(`${entry.name}/${sub.name}`);
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // ignore
202
+ }
203
+ continue;
204
+ }
205
+ out.push(entry.name);
206
+ }
207
+ return out;
126
208
  };
127
209
  const listPluginsFromDisk = async () => {
128
210
  const pluginsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_PLUGINS_DIR);
@@ -132,17 +214,57 @@ const listPluginsFromDisk = async () => {
132
214
  catch {
133
215
  await fs.mkdir(pluginsDir, { recursive: true });
134
216
  }
135
- const plugins = (await fs.readdir(pluginsDir, { withFileTypes: true }))
136
- .filter((entry) => !entry.name.startsWith('.') && (entry.isDirectory() || entry.isSymbolicLink()))
137
- .map(async (entry) => {
138
- // get dist/index module and find inside module.plugin.description
139
- const module = await import(pathToFileURL(`${pluginsDir}/${entry.name}/dist/index.js`).href);
140
- const pluginDir = path.join(pluginsDir, entry.name);
141
- const image = await resolveEntityImageDataUrl(pluginDir);
142
- return mapNameToPlugin(module.plugin.name || entry.name, module.plugin.description || '', module.plugin.kind || 'tool', image);
143
- });
144
- return Promise.all(plugins);
217
+ const ids = await listInstalledPluginIds(pluginsDir);
218
+ const descriptors = await Promise.all(ids.map(async (id) => {
219
+ try {
220
+ const pluginDir = path.join(pluginsDir, id);
221
+ const distPath = path.join(pluginDir, 'dist', 'index.js');
222
+ const module = await import(pathToFileURL(distPath).href);
223
+ const parsed = parsePluginModule(module);
224
+ if (!parsed)
225
+ return null;
226
+ const image = await resolveEntityImageDataUrl(pluginDir);
227
+ return {
228
+ id,
229
+ name: parsed.name || id,
230
+ description: parsed.description || '',
231
+ builtIn: false,
232
+ image: parsed.image || image,
233
+ defaultInstructions: parsed.defaultInstructions,
234
+ configSchema: parsed.configSchema,
235
+ createdAt: new Date(),
236
+ updatedAt: new Date(),
237
+ };
238
+ }
239
+ catch (error) {
240
+ console.warn(`[storage] Failed to load plugin ${id}:`, error);
241
+ return null;
242
+ }
243
+ }));
244
+ return descriptors.filter((d) => d !== null);
245
+ };
246
+ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
247
+ /**
248
+ * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
249
+ * `id`; `config` is optional. Strings are accepted as a shorthand for `{ id }`.
250
+ */
251
+ const parsePluginRefs = (raw) => {
252
+ if (!Array.isArray(raw))
253
+ return [];
254
+ const refs = [];
255
+ for (const entry of raw) {
256
+ if (typeof entry === 'string' && entry.trim()) {
257
+ refs.push({ id: entry.trim() });
258
+ continue;
259
+ }
260
+ if (isRecord(entry) && typeof entry.id === 'string' && entry.id.trim()) {
261
+ const config = isRecord(entry.config) ? entry.config : undefined;
262
+ refs.push({ id: entry.id.trim(), ...(config ? { config } : {}) });
263
+ }
264
+ }
265
+ return refs;
145
266
  };
267
+ const serializePluginRefs = (refs) => refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
146
268
  export const storageService = {
147
269
  getLastReadByChannel: async () => {
148
270
  return readJsonFile(getLastReadFilePath(), {});
@@ -174,7 +296,7 @@ export const storageService = {
174
296
  cwd = typeof state.cwd === 'string' ? state.cwd : undefined;
175
297
  }
176
298
  catch {
177
- // Ignore if state.json is missing or invalid
299
+ // ignore
178
300
  }
179
301
  const channel = {
180
302
  id: name,
@@ -194,7 +316,6 @@ export const storageService = {
194
316
  channel.hasUnseenMessages = false;
195
317
  }
196
318
  try {
197
- // Fetch up to 5 most recent threads for the sidebar
198
319
  const allThreads = await storageService.getThreads({ channelId: name });
199
320
  channel.recentThreads = allThreads
200
321
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
@@ -220,7 +341,8 @@ export const storageService = {
220
341
  throw new Error(`Channel "${normalizedChannelId}" already exists`);
221
342
  }
222
343
  catch (error) {
223
- if (error.code !== 'ENOENT') {
344
+ const code = error?.code;
345
+ if (code !== 'ENOENT') {
224
346
  throw error;
225
347
  }
226
348
  }
@@ -238,12 +360,10 @@ export const storageService = {
238
360
  createThread: async ({ channelId, threadId, threadTitle, spec, initialState, }) => {
239
361
  const normalizedChannelId = channelId.trim();
240
362
  const normalizedThreadId = threadId.trim();
241
- if (!normalizedChannelId) {
363
+ if (!normalizedChannelId)
242
364
  throw new Error('channelId is required');
243
- }
244
- if (!normalizedThreadId) {
365
+ if (!normalizedThreadId)
245
366
  throw new Error('threadId is required');
246
- }
247
367
  const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
248
368
  const specPath = `${threadDir}/SPEC.md`;
249
369
  const statePath = `${threadDir}/state.json`;
@@ -252,7 +372,8 @@ export const storageService = {
252
372
  throw new Error(`Thread "${normalizedThreadId}" already exists in channel "${normalizedChannelId}"`);
253
373
  }
254
374
  catch (error) {
255
- if (error.code !== 'ENOENT') {
375
+ const code = error?.code;
376
+ if (code !== 'ENOENT') {
256
377
  throw error;
257
378
  }
258
379
  }
@@ -288,7 +409,7 @@ export const storageService = {
288
409
  }
289
410
  }
290
411
  catch (error) {
291
- if (error.code !== 'ENOENT') {
412
+ if (error?.code !== 'ENOENT') {
292
413
  console.error(`Failed to read thread state for channel ${channelId} thread ${name}`, error);
293
414
  }
294
415
  }
@@ -311,7 +432,7 @@ export const storageService = {
311
432
  spec = await fs.readFile(specPath, 'utf-8');
312
433
  }
313
434
  catch (error) {
314
- if (error.code !== 'ENOENT') {
435
+ if (error?.code !== 'ENOENT') {
315
436
  console.error(`Failed to read thread spec for channel ${channelId} thread ${threadId}`, error);
316
437
  }
317
438
  }
@@ -321,11 +442,11 @@ export const storageService = {
321
442
  state = JSON.parse(stateContent);
322
443
  }
323
444
  catch (error) {
324
- if (error.code !== 'ENOENT') {
445
+ if (error?.code !== 'ENOENT') {
325
446
  console.error(`Failed to read thread state for channel ${channelId} thread ${threadId}`, error);
326
447
  }
327
448
  }
328
- const generatedName = typeof state.generatedName === 'string'
449
+ const generatedName = isRecord(state) && typeof state.generatedName === 'string'
329
450
  ? state.generatedName.trim()
330
451
  : '';
331
452
  return {
@@ -345,7 +466,7 @@ export const storageService = {
345
466
  spec = await fs.readFile(specPath, 'utf-8');
346
467
  }
347
468
  catch (error) {
348
- if (error.code !== 'ENOENT') {
469
+ if (error?.code !== 'ENOENT') {
349
470
  console.error(`Failed to read spec file for channel ${channelId}`, error);
350
471
  }
351
472
  }
@@ -355,16 +476,17 @@ export const storageService = {
355
476
  state = JSON.parse(stateContent);
356
477
  }
357
478
  catch (error) {
358
- if (error.code !== 'ENOENT') {
479
+ if (error?.code !== 'ENOENT') {
359
480
  console.error(`Failed to read state file for channel ${channelId}`, error);
360
481
  }
361
482
  }
483
+ const cwd = isRecord(state) && typeof state.cwd === 'string' ? state.cwd : undefined;
362
484
  const details = {
363
485
  id: channelId,
364
486
  name: channelId,
365
487
  spec,
366
488
  state,
367
- cwd: typeof state.cwd === 'string' ? state.cwd : undefined,
489
+ cwd,
368
490
  };
369
491
  details.threads = await storageService.getThreads({ channelId });
370
492
  return details;
@@ -373,15 +495,12 @@ export const storageService = {
373
495
  const channelDir = getConversationDir(channelId);
374
496
  const statePath = `${channelDir}/state.json`;
375
497
  try {
376
- // 1. Fetch current details to get the existing state
377
498
  const currentDetails = await storageService.getChannelDetails({ channelId });
378
499
  const currentState = currentDetails.state || {};
379
- // 2. Perform a shallow merge (patch)
380
500
  const newState = {
381
501
  ...currentState,
382
502
  ...patch,
383
503
  };
384
- // 3. Write back the merged state
385
504
  await fs.mkdir(channelDir, { recursive: true });
386
505
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
387
506
  }
@@ -394,15 +513,12 @@ export const storageService = {
394
513
  const threadDir = getConversationDir(channelId, threadId);
395
514
  const statePath = `${threadDir}/state.json`;
396
515
  try {
397
- // 1. Fetch current details to get the existing state
398
516
  const currentDetails = await storageService.getThreadDetails({ channelId, threadId });
399
517
  const currentState = currentDetails.state || {};
400
- // 2. Perform a shallow merge (patch)
401
518
  const newState = {
402
519
  ...currentState,
403
520
  ...patch,
404
521
  };
405
- // 3. Write back the merged state
406
522
  await fs.mkdir(threadDir, { recursive: true });
407
523
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
408
524
  }
@@ -452,7 +568,7 @@ export const storageService = {
452
568
  name: details.name || id,
453
569
  description: details.description || '',
454
570
  image: details.image,
455
- runtime: details.runtime,
571
+ plugins: details.plugins,
456
572
  createdAt: details.createdAt,
457
573
  updatedAt: details.updatedAt,
458
574
  };
@@ -462,18 +578,19 @@ export const storageService = {
462
578
  id,
463
579
  name: id,
464
580
  description: '',
581
+ plugins: [],
465
582
  createdAt: new Date(),
466
583
  updatedAt: new Date(),
467
584
  };
468
585
  }
469
586
  }));
470
- const system = await storageService.getAgentDetails({ agentId: 'system' });
587
+ const system = await storageService.getAgentDetails({ agentId: SYSTEM_AGENT_ID });
471
588
  const builtInSystemAgent = {
472
589
  id: system.id,
473
590
  name: system.name,
474
591
  description: system.description || '',
475
592
  image: system.image,
476
- runtime: system.runtime,
593
+ plugins: system.plugins,
477
594
  createdAt: system.createdAt,
478
595
  updatedAt: system.updatedAt,
479
596
  };
@@ -486,11 +603,11 @@ export const storageService = {
486
603
  return Array.from(deduped.values());
487
604
  },
488
605
  getPlugins: async () => {
489
- const [builtInPlugins, diskPlugins] = await Promise.all([
490
- listBuiltInPlugins(),
606
+ const [builtIn, fromDisk] = await Promise.all([
607
+ listBuiltInPluginDescriptors(),
491
608
  listPluginsFromDisk(),
492
609
  ]);
493
- const merged = [...builtInPlugins, ...diskPlugins];
610
+ const merged = [...builtIn, ...fromDisk];
494
611
  const deduped = new Map();
495
612
  for (const plugin of merged) {
496
613
  if (!deduped.has(plugin.id)) {
@@ -508,26 +625,30 @@ export const storageService = {
508
625
  const agentMd = await fs.readFile(agentMdPath, 'utf-8');
509
626
  const { data, content: instructions } = matter(agentMd);
510
627
  const discoveredImage = await resolveEntityImageDataUrl(agentDir);
628
+ const stats = await fs.stat(agentMdPath);
629
+ const pluginRefs = parsePluginRefs(data.plugins);
511
630
  diskDetails = {
512
631
  id: agentId,
513
- name: data.name || agentId,
632
+ name: typeof data.name === 'string' ? data.name : agentId,
514
633
  instructions: instructions.trim(),
515
- runtime: data.runtime,
516
- plugins: data.plugins || [],
517
- description: data.description || '',
634
+ plugins: pluginRefs.map((ref) => ref.id),
635
+ pluginRefs,
636
+ description: typeof data.description === 'string' ? data.description : '',
518
637
  image: discoveredImage || undefined,
519
- createdAt: new Date(),
520
- updatedAt: new Date(),
638
+ createdAt: stats.birthtime,
639
+ updatedAt: stats.mtime,
521
640
  };
522
641
  }
523
642
  catch (error) {
524
- if (agentId !== 'system') {
643
+ if (agentId !== SYSTEM_AGENT_ID) {
525
644
  const err = new Error(`Agent "${agentId}" does not exist.`);
526
645
  err.code = 'AGENT_NOT_FOUND';
527
646
  throw err;
528
647
  }
648
+ // swallow: system agent has on-disk overrides optional
649
+ void error;
529
650
  }
530
- if (agentId === 'system') {
651
+ if (agentId === SYSTEM_AGENT_ID) {
531
652
  return getSystemAgentDetails(diskDetails);
532
653
  }
533
654
  if (!diskDetails) {
@@ -537,6 +658,103 @@ export const storageService = {
537
658
  }
538
659
  return diskDetails;
539
660
  },
661
+ createAgent: async ({ agentId, name, description = '', instructions, plugins, }) => {
662
+ assertValidDiskAgentId(agentId);
663
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
664
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
665
+ try {
666
+ await fs.access(agentMdPath);
667
+ throw new Error(`Agent "${agentId}" already exists`);
668
+ }
669
+ catch (error) {
670
+ const code = error?.code;
671
+ if (code === 'ENOENT') {
672
+ // proceed
673
+ }
674
+ else if (error instanceof Error && error.message.includes('already exists')) {
675
+ throw error;
676
+ }
677
+ else {
678
+ throw error;
679
+ }
680
+ }
681
+ await fs.mkdir(agentDir, { recursive: true });
682
+ const data = {
683
+ name,
684
+ description,
685
+ plugins: serializePluginRefs(plugins),
686
+ };
687
+ const body = matter.stringify(`${instructions.trim()}\n`, data);
688
+ await fs.writeFile(agentMdPath, body, 'utf-8');
689
+ },
690
+ updateAgent: async ({ agentId, name, description, instructions, plugins, }) => {
691
+ assertValidDiskAgentId(agentId);
692
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
693
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
694
+ let raw;
695
+ try {
696
+ raw = await fs.readFile(agentMdPath, 'utf-8');
697
+ }
698
+ catch (error) {
699
+ if (error?.code === 'ENOENT') {
700
+ const err = new Error(`Agent "${agentId}" does not exist.`);
701
+ err.code = 'AGENT_NOT_FOUND';
702
+ throw err;
703
+ }
704
+ throw error;
705
+ }
706
+ const parsed = matter(raw);
707
+ const nextData = { ...parsed.data };
708
+ if (name !== undefined)
709
+ nextData.name = name;
710
+ if (description !== undefined)
711
+ nextData.description = description;
712
+ if (plugins !== undefined)
713
+ nextData.plugins = serializePluginRefs(plugins);
714
+ const nextContent = instructions !== undefined ? instructions : parsed.content;
715
+ const body = matter.stringify(`${String(nextContent).trim()}\n`, nextData);
716
+ await fs.writeFile(agentMdPath, body, 'utf-8');
717
+ },
718
+ deleteAgent: async ({ agentId }) => {
719
+ assertValidDiskAgentId(agentId);
720
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
721
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
722
+ const packageJsonPath = path.join(agentDir, 'package.json');
723
+ try {
724
+ await fs.access(agentDir);
725
+ }
726
+ catch (error) {
727
+ if (error?.code === 'ENOENT') {
728
+ const err = new Error(`Agent "${agentId}" does not exist.`);
729
+ err.code = 'AGENT_NOT_FOUND';
730
+ throw err;
731
+ }
732
+ throw error;
733
+ }
734
+ let hasPackage = false;
735
+ let hasAgentMd = false;
736
+ try {
737
+ await fs.access(packageJsonPath);
738
+ hasPackage = true;
739
+ }
740
+ catch {
741
+ // ignore
742
+ }
743
+ try {
744
+ await fs.access(agentMdPath);
745
+ hasAgentMd = true;
746
+ }
747
+ catch {
748
+ // ignore
749
+ }
750
+ if (hasPackage && !hasAgentMd) {
751
+ throw new Error(`Cannot delete TypeScript agent package "${agentId}" through this action; remove the folder manually.`);
752
+ }
753
+ if (!hasAgentMd) {
754
+ throw new Error(`Agent "${agentId}" has no AGENT.md and cannot be deleted through this action.`);
755
+ }
756
+ await fs.rm(agentDir, { recursive: true, force: true });
757
+ },
540
758
  getEvents: async ({ channelId, threadId, }) => {
541
759
  try {
542
760
  const threadDir = getConversationDir(channelId, threadId);
@@ -554,22 +772,18 @@ export const storageService = {
554
772
  }
555
773
  return event;
556
774
  });
557
- // If we are at the channel level (no threadId), check which events have threads
558
775
  if (!threadId) {
559
776
  const threadsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads');
560
777
  try {
561
778
  const threadDirs = await fs.readdir(threadsDir);
562
779
  const threadSet = new Set(threadDirs);
563
780
  return events.map((event) => {
564
- // Check if this event has a threadId associated with it
565
- // The frontend provides the threadId, and it matches the directory name on disk
566
- const threadId = event.id;
567
- // If an explicit threadId exists and has a directory, use it
568
- if (threadId && threadSet.has(threadId)) {
781
+ const eventThreadId = event.id;
782
+ if (eventThreadId && threadSet.has(eventThreadId)) {
569
783
  return {
570
784
  ...event,
571
785
  meta: {
572
- ...event?.meta,
786
+ ...(event.meta || {}),
573
787
  hasThread: true,
574
788
  },
575
789
  };
@@ -578,14 +792,13 @@ export const storageService = {
578
792
  });
579
793
  }
580
794
  catch {
581
- // No threads folder or other error, just return events as is
582
795
  return events;
583
796
  }
584
797
  }
585
798
  return events;
586
799
  }
587
800
  catch (error) {
588
- if (error.code !== 'ENOENT') {
801
+ if (error?.code !== 'ENOENT') {
589
802
  console.error(`Failed to get events for channel ${channelId} thread ${threadId}`, error);
590
803
  }
591
804
  return [];
@@ -599,7 +812,7 @@ export const storageService = {
599
812
  await fs.access(threadDir);
600
813
  }
601
814
  catch (error) {
602
- if (error.code === 'ENOENT') {
815
+ if (error?.code === 'ENOENT') {
603
816
  const threadTitle = buildThreadTitleFromEvent(event);
604
817
  await storageService.createThread({
605
818
  channelId,
@@ -615,7 +828,6 @@ export const storageService = {
615
828
  else {
616
829
  await fs.mkdir(threadDir, { recursive: true });
617
830
  }
618
- // Ensure the event has a unique ID
619
831
  if (!event.id) {
620
832
  event.id = crypto.randomUUID();
621
833
  }
@@ -629,15 +841,70 @@ export const storageService = {
629
841
  getVariables: async () => {
630
842
  const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
631
843
  const raw = await readJsonFile(variablesFilePath, {});
632
- if (raw && typeof raw === 'object' && 'variables' in raw && Array.isArray(raw.variables)) {
633
- const entries = raw.variables
844
+ if (raw &&
845
+ typeof raw === 'object' &&
846
+ 'variables' in raw &&
847
+ Array.isArray(raw.variables)) {
848
+ const entries = (raw.variables)
634
849
  .filter((v) => typeof v?.key === 'string')
635
850
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }]);
636
851
  return Object.fromEntries(entries);
637
852
  }
638
- // Legacy or simple format
639
853
  return toVariablesRecord(raw);
640
854
  },
855
+ createVariable: async ({ key, value, secret = false, }) => {
856
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
857
+ const raw = await readJsonFile(variablesFilePath, { version: 1, variables: [] });
858
+ let variables = [];
859
+ if (raw &&
860
+ typeof raw === 'object' &&
861
+ 'variables' in raw &&
862
+ Array.isArray(raw.variables)) {
863
+ variables = raw.variables;
864
+ }
865
+ else {
866
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
867
+ key: k,
868
+ value: v,
869
+ secret: false,
870
+ }));
871
+ }
872
+ const existingIndex = variables.findIndex((v) => v.key === key);
873
+ if (existingIndex !== -1) {
874
+ variables[existingIndex] = { key, value, secret };
875
+ }
876
+ else {
877
+ variables.push({ key, value, secret });
878
+ }
879
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
880
+ await fs.writeFile(variablesFilePath, JSON.stringify({ version: 1, variables }, null, 2), 'utf-8');
881
+ processService.syncWorkspaceVariablesToProcessEnv();
882
+ },
883
+ deleteVariable: async ({ key }) => {
884
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
885
+ const raw = await readJsonFile(variablesFilePath, { version: 1, variables: [] });
886
+ let variables = [];
887
+ if (raw &&
888
+ typeof raw === 'object' &&
889
+ 'variables' in raw &&
890
+ Array.isArray(raw.variables)) {
891
+ variables = raw.variables;
892
+ }
893
+ else {
894
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
895
+ key: k,
896
+ value: v,
897
+ secret: false,
898
+ }));
899
+ }
900
+ const newVariables = variables.filter((v) => v.key !== key);
901
+ if (newVariables.length === variables.length) {
902
+ return;
903
+ }
904
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
905
+ await fs.writeFile(variablesFilePath, JSON.stringify({ version: 1, variables: newVariables }, null, 2), 'utf-8');
906
+ processService.syncWorkspaceVariablesToProcessEnv();
907
+ },
641
908
  listFiles: async ({ channelId, path: subPath = '', }) => {
642
909
  const details = await storageService.getChannelDetails({ channelId });
643
910
  const baseCwd = details.cwd;
@@ -646,19 +913,18 @@ export const storageService = {
646
913
  }
647
914
  const resolvedBase = path.resolve(baseCwd);
648
915
  const targetDir = path.resolve(resolvedBase, subPath);
649
- // Security check: ensure target is within baseCwd
650
916
  if (!targetDir.startsWith(resolvedBase)) {
651
917
  throw new Error('Access denied: directory escape');
652
918
  }
653
919
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
654
920
  return entries
655
- .filter((e) => !e.name.startsWith('.')) // Hide hidden files by default for MVP
921
+ .filter((e) => !e.name.startsWith('.'))
656
922
  .map((e) => ({
657
923
  name: e.name,
658
924
  isDirectory: e.isDirectory(),
659
925
  }));
660
926
  },
661
- readFile: async ({ channelId, path: filePath }) => {
927
+ readFile: async ({ channelId, path: filePath, }) => {
662
928
  const details = await storageService.getChannelDetails({ channelId });
663
929
  const baseCwd = details.cwd;
664
930
  if (!baseCwd) {
@@ -666,12 +932,15 @@ export const storageService = {
666
932
  }
667
933
  const resolvedBase = path.resolve(baseCwd);
668
934
  const targetFile = path.resolve(resolvedBase, filePath);
669
- // Security check: ensure target is within baseCwd
670
935
  if (!targetFile.startsWith(resolvedBase)) {
671
936
  throw new Error('Access denied: directory escape');
672
937
  }
673
938
  return fs.readFile(targetFile, 'utf-8');
674
939
  },
940
+ appendMemory: memoryService.appendMemory,
941
+ listMemories: memoryService.listMemories,
942
+ deleteMemory: memoryService.deleteMemory,
943
+ updateMemory: memoryService.updateMemory,
675
944
  /**
676
945
  * Hydrates the full OpenBot state from disk/storage before a run.
677
946
  */
@@ -686,7 +955,7 @@ export const storageService = {
686
955
  throw error;
687
956
  }
688
957
  let channelDetails;
689
- if (channelId && channelId !== 'general') {
958
+ if (channelId) {
690
959
  try {
691
960
  channelDetails = await storageService.getChannelDetails({ channelId });
692
961
  }
@@ -713,9 +982,12 @@ export const storageService = {
713
982
  id: agentDetails.id,
714
983
  name: agentDetails.name,
715
984
  description: agentDetails.description || '',
985
+ image: agentDetails.image,
716
986
  instructions: agentDetails.instructions || '',
717
- runtime: agentDetails.runtime,
718
987
  plugins: agentDetails.plugins,
988
+ pluginRefs: agentDetails.pluginRefs,
989
+ createdAt: agentDetails.createdAt,
990
+ updatedAt: agentDetails.updatedAt,
719
991
  },
720
992
  channelDetails: channelDetails,
721
993
  threadDetails: threadDetails,