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,8 +1,8 @@
1
1
  import {
2
+ DEFAULT_PLUGINS_DIR,
2
3
  DEFAULT_AGENTS_DIR,
3
4
  DEFAULT_BASE_DIR,
4
5
  DEFAULT_CHANNELS_DIR,
5
- DEFAULT_PLUGINS_DIR,
6
6
  loadConfig,
7
7
  resolvePath,
8
8
  StoredVariable,
@@ -17,30 +17,19 @@ import {
17
17
  AgentDetails,
18
18
  Channel,
19
19
  ChannelDetails,
20
- Plugin,
21
- PluginKind,
20
+ PluginDescriptor,
22
21
  Thread,
23
22
  ThreadDetails,
24
- } from '../plugins/storage.js';
25
- import { getSystemAgentDetails } from '../agents/system.js';
23
+ } from '../bus/types.js';
24
+ import type { PluginRef } from '../bus/plugin.js';
25
+ import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
26
+ import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
27
+ import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
26
28
  import { OpenBotEvent, OpenBotState } from '../app/types.js';
29
+ import { processService } from '../harness/process.js';
30
+ import { memoryService } from './memory.js';
27
31
  import { pathToFileURL } from 'node:url';
28
32
 
29
- const mapNameToPlugin = (
30
- name: string,
31
- description: string,
32
- kind: PluginKind = 'tool',
33
- image?: string,
34
- ): Plugin => ({
35
- id: name,
36
- name,
37
- description,
38
- kind,
39
- image,
40
- createdAt: new Date(),
41
- updatedAt: new Date(),
42
- });
43
-
44
33
  const resolveBaseDir = () => {
45
34
  const config = loadConfig();
46
35
  return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
@@ -62,15 +51,6 @@ const tryReadSvgDataUrl = async (filePath: string): Promise<string | null> => {
62
51
  }
63
52
  };
64
53
 
65
- /**
66
- * Auto-discovers an entity SVG avatar and returns it as a data URL.
67
- *
68
- * Search order:
69
- * 1) <entity>/assets/avatar.svg|icon.svg|image.svg|logo.svg
70
- * 2) <entity>/avatar.svg|icon.svg|image.svg|logo.svg
71
- * 3) first *.svg in <entity>/assets
72
- * 4) first *.svg in <entity>
73
- */
74
54
  const resolveEntityImageDataUrl = async (entityDir: string): Promise<string | undefined> => {
75
55
  const preferredDirs = [path.join(entityDir, 'assets'), entityDir];
76
56
 
@@ -91,7 +71,7 @@ const resolveEntityImageDataUrl = async (entityDir: string): Promise<string | un
91
71
  const dataUrl = await tryReadSvgDataUrl(path.join(dir, firstSvg.name));
92
72
  if (dataUrl) return dataUrl;
93
73
  } catch {
94
- // ignore missing/unreadable folders
74
+ // ignore
95
75
  }
96
76
  }
97
77
 
@@ -103,6 +83,70 @@ const getConversationDir = (channelId: string, threadId?: string) => {
103
83
  return threadId ? `${base}/threads/${threadId}` : base;
104
84
  };
105
85
 
86
+ /** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
87
+ const SYSTEM_AGENT_ID = 'system';
88
+
89
+ const SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
90
+ { id: 'ai-sdk', config: { model: 'openai/gpt-5.4-nano' } },
91
+ { id: 'storage-tools' },
92
+ // { id: 'mcp' },
93
+ { id: 'shell' },
94
+ { id: 'delegation' },
95
+ // { id: 'ui' },
96
+ { id: 'approval' },
97
+ { id: 'memory' },
98
+ ];
99
+
100
+ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
101
+ const defaults: AgentDetails = {
102
+ id: SYSTEM_AGENT_ID,
103
+ name: 'OpenBot',
104
+ image: undefined,
105
+ description:
106
+ 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff and delegation.',
107
+ instructions: AI_SDK_SYSTEM_PROMPT,
108
+ plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
109
+ pluginRefs: SYSTEM_DEFAULT_PLUGINS,
110
+ createdAt: new Date(),
111
+ updatedAt: new Date(),
112
+ };
113
+
114
+ if (!overrides) return defaults;
115
+
116
+ const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
117
+ ? overrides.pluginRefs
118
+ : defaults.pluginRefs;
119
+
120
+ return {
121
+ ...defaults,
122
+ ...overrides,
123
+ id: SYSTEM_AGENT_ID,
124
+ image: overrides.image || defaults.image,
125
+ plugins: refs.map((ref) => ref.id),
126
+ pluginRefs: refs,
127
+ updatedAt: new Date(),
128
+ };
129
+ }
130
+
131
+ // Suppress unused warning until system agent customization re-uses aiSdkPlugin metadata.
132
+ void aiSdkPlugin;
133
+
134
+ const RESERVED_DISK_AGENT_IDS = new Set([SYSTEM_AGENT_ID]);
135
+
136
+ const assertValidDiskAgentId = (agentId: string): void => {
137
+ if (!agentId || typeof agentId !== 'string') {
138
+ throw new Error('agentId is required');
139
+ }
140
+ if (RESERVED_DISK_AGENT_IDS.has(agentId)) {
141
+ throw new Error(`Agent id "${agentId}" is reserved`);
142
+ }
143
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
144
+ throw new Error('agentId must contain only letters, digits, underscores, and hyphens');
145
+ }
146
+ };
147
+
148
+ const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
149
+
106
150
  const getLastReadFilePath = () =>
107
151
  path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
108
152
 
@@ -145,7 +189,6 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
145
189
  return {};
146
190
  }
147
191
 
148
- // Current format: { version: number, variables: StoredVariable[] }
149
192
  if ('variables' in raw && Array.isArray((raw as { variables?: unknown }).variables)) {
150
193
  const entries = (raw as { variables: StoredVariable[] }).variables
151
194
  .filter((variable) => typeof variable?.key === 'string')
@@ -153,7 +196,6 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
153
196
  return Object.fromEntries(entries);
154
197
  }
155
198
 
156
- // Legacy format: { [key: string]: string }
157
199
  return Object.fromEntries(
158
200
  Object.entries(raw as Record<string, unknown>).map(([key, value]) => [
159
201
  key,
@@ -162,15 +204,59 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
162
204
  );
163
205
  };
164
206
 
165
- const listBuiltInPlugins = async (): Promise<Plugin[]> => {
166
- return [
167
- mapNameToPlugin('storage', 'Built-in storage plugin'),
168
- mapNameToPlugin('ai-sdk', 'Built-in AI SDK plugin', 'runtime'),
169
- mapNameToPlugin('delegation', 'Built-in delegation plugin'),
170
- ];
207
+ const listBuiltInPluginDescriptors = async (): Promise<PluginDescriptor[]> => {
208
+ return listBuiltInPlugins().map((plugin) => ({
209
+ id: plugin.id,
210
+ name: plugin.name,
211
+ description: plugin.description,
212
+ builtIn: true,
213
+ image: plugin.image,
214
+ defaultInstructions: plugin.defaultInstructions,
215
+ configSchema: plugin.configSchema,
216
+ createdAt: new Date(),
217
+ updatedAt: new Date(),
218
+ }));
171
219
  };
172
220
 
173
- const listPluginsFromDisk = async (): Promise<Plugin[]> => {
221
+ /**
222
+ * Walk `plugins/` and yield candidate plugin ids (npm names). Includes scoped
223
+ * packages by recursing one level into directories starting with `@`.
224
+ */
225
+ const listInstalledPluginIds = async (pluginsDir: string): Promise<string[]> => {
226
+ const out: string[] = [];
227
+ let topEntries;
228
+ try {
229
+ topEntries = await fs.readdir(pluginsDir, { withFileTypes: true });
230
+ } catch {
231
+ return out;
232
+ }
233
+
234
+ for (const entry of topEntries) {
235
+ if (entry.name.startsWith('.')) continue;
236
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
237
+
238
+ if (entry.name.startsWith('@')) {
239
+ try {
240
+ const inner = await fs.readdir(path.join(pluginsDir, entry.name), { withFileTypes: true });
241
+ for (const sub of inner) {
242
+ if (sub.name.startsWith('.')) continue;
243
+ if (sub.isDirectory() || sub.isSymbolicLink()) {
244
+ out.push(`${entry.name}/${sub.name}`);
245
+ }
246
+ }
247
+ } catch {
248
+ // ignore
249
+ }
250
+ continue;
251
+ }
252
+
253
+ out.push(entry.name);
254
+ }
255
+
256
+ return out;
257
+ };
258
+
259
+ const listPluginsFromDisk = async (): Promise<PluginDescriptor[]> => {
174
260
  const pluginsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_PLUGINS_DIR);
175
261
  try {
176
262
  await fs.access(pluginsDir);
@@ -178,26 +264,64 @@ const listPluginsFromDisk = async (): Promise<Plugin[]> => {
178
264
  await fs.mkdir(pluginsDir, { recursive: true });
179
265
  }
180
266
 
181
- const plugins = (await fs.readdir(pluginsDir, { withFileTypes: true }))
182
- .filter(
183
- (entry) => !entry.name.startsWith('.') && (entry.isDirectory() || entry.isSymbolicLink()),
184
- )
185
- .map(async (entry) => {
186
- // get dist/index module and find inside module.plugin.description
187
- const module = await import(pathToFileURL(`${pluginsDir}/${entry.name}/dist/index.js`).href);
188
- const pluginDir = path.join(pluginsDir, entry.name);
189
- const image = await resolveEntityImageDataUrl(pluginDir);
190
- return mapNameToPlugin(
191
- module.plugin.name || entry.name,
192
- module.plugin.description || '',
193
- module.plugin.kind || 'tool',
194
- image,
195
- );
196
- });
267
+ const ids = await listInstalledPluginIds(pluginsDir);
268
+
269
+ const descriptors = await Promise.all(
270
+ ids.map(async (id): Promise<PluginDescriptor | null> => {
271
+ try {
272
+ const pluginDir = path.join(pluginsDir, id);
273
+ const distPath = path.join(pluginDir, 'dist', 'index.js');
274
+ const module = await import(pathToFileURL(distPath).href);
275
+ const parsed = parsePluginModule(module as Record<string, unknown>);
276
+ if (!parsed) return null;
277
+ const image = await resolveEntityImageDataUrl(pluginDir);
278
+ return {
279
+ id,
280
+ name: parsed.name || id,
281
+ description: parsed.description || '',
282
+ builtIn: false,
283
+ image: parsed.image || image,
284
+ defaultInstructions: parsed.defaultInstructions,
285
+ configSchema: parsed.configSchema,
286
+ createdAt: new Date(),
287
+ updatedAt: new Date(),
288
+ };
289
+ } catch (error) {
290
+ console.warn(`[storage] Failed to load plugin ${id}:`, error);
291
+ return null;
292
+ }
293
+ }),
294
+ );
295
+
296
+ return descriptors.filter((d): d is PluginDescriptor => d !== null);
297
+ };
197
298
 
198
- return Promise.all(plugins);
299
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
300
+ !!value && typeof value === 'object' && !Array.isArray(value);
301
+
302
+ /**
303
+ * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
304
+ * `id`; `config` is optional. Strings are accepted as a shorthand for `{ id }`.
305
+ */
306
+ const parsePluginRefs = (raw: unknown): PluginRef[] => {
307
+ if (!Array.isArray(raw)) return [];
308
+ const refs: PluginRef[] = [];
309
+ for (const entry of raw) {
310
+ if (typeof entry === 'string' && entry.trim()) {
311
+ refs.push({ id: entry.trim() });
312
+ continue;
313
+ }
314
+ if (isRecord(entry) && typeof entry.id === 'string' && entry.id.trim()) {
315
+ const config = isRecord(entry.config) ? (entry.config as Record<string, unknown>) : undefined;
316
+ refs.push({ id: entry.id.trim(), ...(config ? { config } : {}) });
317
+ }
318
+ }
319
+ return refs;
199
320
  };
200
321
 
322
+ const serializePluginRefs = (refs: PluginRef[]): unknown[] =>
323
+ refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
324
+
201
325
  export const storageService = {
202
326
  getLastReadByChannel: async (): Promise<Record<string, string>> => {
203
327
  return readJsonFile(getLastReadFilePath(), {});
@@ -241,7 +365,7 @@ export const storageService = {
241
365
  const state = JSON.parse(stateContent);
242
366
  cwd = typeof state.cwd === 'string' ? state.cwd : undefined;
243
367
  } catch {
244
- // Ignore if state.json is missing or invalid
368
+ // ignore
245
369
  }
246
370
 
247
371
  const channel: Channel = {
@@ -262,7 +386,6 @@ export const storageService = {
262
386
  }
263
387
 
264
388
  try {
265
- // Fetch up to 5 most recent threads for the sidebar
266
389
  const allThreads = await storageService.getThreads({ channelId: name });
267
390
  channel.recentThreads = allThreads
268
391
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
@@ -300,8 +423,9 @@ export const storageService = {
300
423
  try {
301
424
  await fs.access(channelDir);
302
425
  throw new Error(`Channel "${normalizedChannelId}" already exists`);
303
- } catch (error: any) {
304
- if (error.code !== 'ENOENT') {
426
+ } catch (error: unknown) {
427
+ const code = (error as NodeJS.ErrnoException)?.code;
428
+ if (code !== 'ENOENT') {
305
429
  throw error;
306
430
  }
307
431
  }
@@ -311,7 +435,7 @@ export const storageService = {
311
435
  };
312
436
 
313
437
  if (cwd) {
314
- finalState.cwd = cwd;
438
+ (finalState as Record<string, unknown>).cwd = cwd;
315
439
  }
316
440
 
317
441
  await fs.mkdir(channelDir, { recursive: true });
@@ -338,12 +462,8 @@ export const storageService = {
338
462
  const normalizedChannelId = channelId.trim();
339
463
  const normalizedThreadId = threadId.trim();
340
464
 
341
- if (!normalizedChannelId) {
342
- throw new Error('channelId is required');
343
- }
344
- if (!normalizedThreadId) {
345
- throw new Error('threadId is required');
346
- }
465
+ if (!normalizedChannelId) throw new Error('channelId is required');
466
+ if (!normalizedThreadId) throw new Error('threadId is required');
347
467
 
348
468
  const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
349
469
  const specPath = `${threadDir}/SPEC.md`;
@@ -354,13 +474,14 @@ export const storageService = {
354
474
  throw new Error(
355
475
  `Thread "${normalizedThreadId}" already exists in channel "${normalizedChannelId}"`,
356
476
  );
357
- } catch (error: any) {
358
- if (error.code !== 'ENOENT') {
477
+ } catch (error: unknown) {
478
+ const code = (error as NodeJS.ErrnoException)?.code;
479
+ if (code !== 'ENOENT') {
359
480
  throw error;
360
481
  }
361
482
  }
362
483
 
363
- const baseState = { ...(initialState || {}) };
484
+ const baseState: Record<string, unknown> = { ...(initialState || {}) };
364
485
  if (threadTitle?.trim()) {
365
486
  baseState.generatedName = threadTitle.trim();
366
487
  }
@@ -400,8 +521,8 @@ export const storageService = {
400
521
  if (generatedName) {
401
522
  threadDisplayName = generatedName;
402
523
  }
403
- } catch (error: any) {
404
- if (error.code !== 'ENOENT') {
524
+ } catch (error: unknown) {
525
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
405
526
  console.error(
406
527
  `Failed to read thread state for channel ${channelId} thread ${name}`,
407
528
  error,
@@ -435,8 +556,8 @@ export const storageService = {
435
556
  let spec = '';
436
557
  try {
437
558
  spec = await fs.readFile(specPath, 'utf-8');
438
- } catch (error: any) {
439
- if (error.code !== 'ENOENT') {
559
+ } catch (error: unknown) {
560
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
440
561
  console.error(
441
562
  `Failed to read thread spec for channel ${channelId} thread ${threadId}`,
442
563
  error,
@@ -444,12 +565,12 @@ export const storageService = {
444
565
  }
445
566
  }
446
567
 
447
- let state = {};
568
+ let state: unknown = {};
448
569
  try {
449
570
  const stateContent = await fs.readFile(statePath, 'utf-8');
450
571
  state = JSON.parse(stateContent);
451
- } catch (error: any) {
452
- if (error.code !== 'ENOENT') {
572
+ } catch (error: unknown) {
573
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
453
574
  console.error(
454
575
  `Failed to read thread state for channel ${channelId} thread ${threadId}`,
455
576
  error,
@@ -458,8 +579,8 @@ export const storageService = {
458
579
  }
459
580
 
460
581
  const generatedName =
461
- typeof (state as Record<string, unknown>).generatedName === 'string'
462
- ? ((state as Record<string, unknown>).generatedName as string).trim()
582
+ isRecord(state) && typeof state.generatedName === 'string'
583
+ ? state.generatedName.trim()
463
584
  : '';
464
585
 
465
586
  return {
@@ -478,28 +599,30 @@ export const storageService = {
478
599
  let spec = '';
479
600
  try {
480
601
  spec = await fs.readFile(specPath, 'utf-8');
481
- } catch (error: any) {
482
- if (error.code !== 'ENOENT') {
602
+ } catch (error: unknown) {
603
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
483
604
  console.error(`Failed to read spec file for channel ${channelId}`, error);
484
605
  }
485
606
  }
486
607
 
487
- let state = {};
608
+ let state: unknown = {};
488
609
  try {
489
610
  const stateContent = await fs.readFile(statePath, 'utf-8');
490
611
  state = JSON.parse(stateContent);
491
- } catch (error: any) {
492
- if (error.code !== 'ENOENT') {
612
+ } catch (error: unknown) {
613
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
493
614
  console.error(`Failed to read state file for channel ${channelId}`, error);
494
615
  }
495
616
  }
496
617
 
618
+ const cwd = isRecord(state) && typeof state.cwd === 'string' ? state.cwd : undefined;
619
+
497
620
  const details: ChannelDetails = {
498
621
  id: channelId,
499
622
  name: channelId,
500
623
  spec,
501
624
  state,
502
- cwd: typeof (state as any).cwd === 'string' ? (state as any).cwd : undefined,
625
+ cwd,
503
626
  };
504
627
 
505
628
  details.threads = await storageService.getThreads({ channelId });
@@ -517,17 +640,14 @@ export const storageService = {
517
640
  const statePath = `${channelDir}/state.json`;
518
641
 
519
642
  try {
520
- // 1. Fetch current details to get the existing state
521
643
  const currentDetails = await storageService.getChannelDetails({ channelId });
522
644
  const currentState = (currentDetails.state as Record<string, unknown>) || {};
523
645
 
524
- // 2. Perform a shallow merge (patch)
525
646
  const newState = {
526
647
  ...currentState,
527
648
  ...(patch as Record<string, unknown>),
528
649
  };
529
650
 
530
- // 3. Write back the merged state
531
651
  await fs.mkdir(channelDir, { recursive: true });
532
652
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
533
653
  } catch (error) {
@@ -548,17 +668,14 @@ export const storageService = {
548
668
  const statePath = `${threadDir}/state.json`;
549
669
 
550
670
  try {
551
- // 1. Fetch current details to get the existing state
552
671
  const currentDetails = await storageService.getThreadDetails({ channelId, threadId });
553
672
  const currentState = (currentDetails.state as Record<string, unknown>) || {};
554
673
 
555
- // 2. Perform a shallow merge (patch)
556
674
  const newState = {
557
675
  ...currentState,
558
676
  ...(patch as Record<string, unknown>),
559
677
  };
560
678
 
561
- // 3. Write back the merged state
562
679
  await fs.mkdir(threadDir, { recursive: true });
563
680
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
564
681
  } catch (error) {
@@ -629,29 +746,30 @@ export const storageService = {
629
746
  name: details.name || id,
630
747
  description: details.description || '',
631
748
  image: details.image,
632
- runtime: details.runtime,
749
+ plugins: details.plugins,
633
750
  createdAt: details.createdAt,
634
751
  updatedAt: details.updatedAt,
635
- };
752
+ } satisfies Agent;
636
753
  } catch {
637
754
  return {
638
755
  id,
639
756
  name: id,
640
757
  description: '',
758
+ plugins: [],
641
759
  createdAt: new Date(),
642
760
  updatedAt: new Date(),
643
- };
761
+ } satisfies Agent;
644
762
  }
645
763
  }),
646
764
  );
647
765
 
648
- const system = await storageService.getAgentDetails({ agentId: 'system' });
766
+ const system = await storageService.getAgentDetails({ agentId: SYSTEM_AGENT_ID });
649
767
  const builtInSystemAgent: Agent = {
650
768
  id: system.id,
651
769
  name: system.name,
652
770
  description: system.description || '',
653
771
  image: system.image,
654
- runtime: system.runtime,
772
+ plugins: system.plugins,
655
773
  createdAt: system.createdAt,
656
774
  updatedAt: system.updatedAt,
657
775
  };
@@ -664,20 +782,19 @@ export const storageService = {
664
782
 
665
783
  return Array.from(deduped.values());
666
784
  },
667
- getPlugins: async (): Promise<Plugin[]> => {
668
- const [builtInPlugins, diskPlugins] = await Promise.all([
669
- listBuiltInPlugins(),
785
+ getPlugins: async (): Promise<PluginDescriptor[]> => {
786
+ const [builtIn, fromDisk] = await Promise.all([
787
+ listBuiltInPluginDescriptors(),
670
788
  listPluginsFromDisk(),
671
789
  ]);
672
790
 
673
- const merged = [...builtInPlugins, ...diskPlugins];
674
- const deduped = new Map<string, Plugin>();
791
+ const merged = [...builtIn, ...fromDisk];
792
+ const deduped = new Map<string, PluginDescriptor>();
675
793
  for (const plugin of merged) {
676
794
  if (!deduped.has(plugin.id)) {
677
795
  deduped.set(plugin.id, plugin);
678
796
  }
679
797
  }
680
-
681
798
  return Array.from(deduped.values());
682
799
  },
683
800
  getAgentDetails: async ({ agentId }: { agentId: string }): Promise<AgentDetails> => {
@@ -691,27 +808,32 @@ export const storageService = {
691
808
  const agentMd = await fs.readFile(agentMdPath, 'utf-8');
692
809
  const { data, content: instructions } = matter(agentMd);
693
810
  const discoveredImage = await resolveEntityImageDataUrl(agentDir);
811
+ const stats = await fs.stat(agentMdPath);
812
+
813
+ const pluginRefs = parsePluginRefs(data.plugins);
694
814
 
695
815
  diskDetails = {
696
816
  id: agentId,
697
- name: data.name || agentId,
817
+ name: typeof data.name === 'string' ? data.name : agentId,
698
818
  instructions: instructions.trim(),
699
- runtime: data.runtime,
700
- plugins: data.plugins || [],
701
- description: data.description || '',
819
+ plugins: pluginRefs.map((ref) => ref.id),
820
+ pluginRefs,
821
+ description: typeof data.description === 'string' ? data.description : '',
702
822
  image: discoveredImage || undefined,
703
- createdAt: new Date(),
704
- updatedAt: new Date(),
823
+ createdAt: stats.birthtime,
824
+ updatedAt: stats.mtime,
705
825
  };
706
826
  } catch (error) {
707
- if (agentId !== 'system') {
827
+ if (agentId !== SYSTEM_AGENT_ID) {
708
828
  const err = new Error(`Agent "${agentId}" does not exist.`);
709
829
  (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
710
830
  throw err;
711
831
  }
832
+ // swallow: system agent has on-disk overrides optional
833
+ void error;
712
834
  }
713
835
 
714
- if (agentId === 'system') {
836
+ if (agentId === SYSTEM_AGENT_ID) {
715
837
  return getSystemAgentDetails(diskDetails);
716
838
  }
717
839
 
@@ -723,6 +845,132 @@ export const storageService = {
723
845
 
724
846
  return diskDetails as AgentDetails;
725
847
  },
848
+ createAgent: async ({
849
+ agentId,
850
+ name,
851
+ description = '',
852
+ instructions,
853
+ plugins,
854
+ }: {
855
+ agentId: string;
856
+ name: string;
857
+ description?: string;
858
+ instructions: string;
859
+ plugins: PluginRef[];
860
+ }): Promise<void> => {
861
+ assertValidDiskAgentId(agentId);
862
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
863
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
864
+
865
+ try {
866
+ await fs.access(agentMdPath);
867
+ throw new Error(`Agent "${agentId}" already exists`);
868
+ } catch (error: unknown) {
869
+ const code = (error as NodeJS.ErrnoException)?.code;
870
+ if (code === 'ENOENT') {
871
+ // proceed
872
+ } else if (error instanceof Error && error.message.includes('already exists')) {
873
+ throw error;
874
+ } else {
875
+ throw error;
876
+ }
877
+ }
878
+
879
+ await fs.mkdir(agentDir, { recursive: true });
880
+
881
+ const data: Record<string, unknown> = {
882
+ name,
883
+ description,
884
+ plugins: serializePluginRefs(plugins),
885
+ };
886
+
887
+ const body = matter.stringify(`${instructions.trim()}\n`, data);
888
+ await fs.writeFile(agentMdPath, body, 'utf-8');
889
+ },
890
+ updateAgent: async ({
891
+ agentId,
892
+ name,
893
+ description,
894
+ instructions,
895
+ plugins,
896
+ }: {
897
+ agentId: string;
898
+ name?: string;
899
+ description?: string;
900
+ instructions?: string;
901
+ plugins?: PluginRef[];
902
+ }): Promise<void> => {
903
+ assertValidDiskAgentId(agentId);
904
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
905
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
906
+
907
+ let raw: string;
908
+ try {
909
+ raw = await fs.readFile(agentMdPath, 'utf-8');
910
+ } catch (error: unknown) {
911
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
912
+ const err = new Error(`Agent "${agentId}" does not exist.`);
913
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
914
+ throw err;
915
+ }
916
+ throw error;
917
+ }
918
+
919
+ const parsed = matter(raw);
920
+ const nextData: Record<string, unknown> = { ...parsed.data };
921
+ if (name !== undefined) nextData.name = name;
922
+ if (description !== undefined) nextData.description = description;
923
+ if (plugins !== undefined) nextData.plugins = serializePluginRefs(plugins);
924
+
925
+ const nextContent = instructions !== undefined ? instructions : parsed.content;
926
+ const body = matter.stringify(`${String(nextContent).trim()}\n`, nextData);
927
+ await fs.writeFile(agentMdPath, body, 'utf-8');
928
+ },
929
+ deleteAgent: async ({ agentId }: { agentId: string }): Promise<void> => {
930
+ assertValidDiskAgentId(agentId);
931
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
932
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
933
+ const packageJsonPath = path.join(agentDir, 'package.json');
934
+
935
+ try {
936
+ await fs.access(agentDir);
937
+ } catch (error: unknown) {
938
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
939
+ const err = new Error(`Agent "${agentId}" does not exist.`);
940
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
941
+ throw err;
942
+ }
943
+ throw error;
944
+ }
945
+
946
+ let hasPackage = false;
947
+ let hasAgentMd = false;
948
+ try {
949
+ await fs.access(packageJsonPath);
950
+ hasPackage = true;
951
+ } catch {
952
+ // ignore
953
+ }
954
+ try {
955
+ await fs.access(agentMdPath);
956
+ hasAgentMd = true;
957
+ } catch {
958
+ // ignore
959
+ }
960
+
961
+ if (hasPackage && !hasAgentMd) {
962
+ throw new Error(
963
+ `Cannot delete TypeScript agent package "${agentId}" through this action; remove the folder manually.`,
964
+ );
965
+ }
966
+ if (!hasAgentMd) {
967
+ throw new Error(
968
+ `Agent "${agentId}" has no AGENT.md and cannot be deleted through this action.`,
969
+ );
970
+ }
971
+
972
+ await fs.rm(agentDir, { recursive: true, force: true });
973
+ },
726
974
  getEvents: async ({
727
975
  channelId,
728
976
  threadId,
@@ -748,7 +996,6 @@ export const storageService = {
748
996
  return event;
749
997
  });
750
998
 
751
- // If we are at the channel level (no threadId), check which events have threads
752
999
  if (!threadId) {
753
1000
  const threadsDir = resolvePath(
754
1001
  resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads',
@@ -758,32 +1005,26 @@ export const storageService = {
758
1005
  const threadSet = new Set(threadDirs);
759
1006
 
760
1007
  return events.map((event) => {
761
- // Check if this event has a threadId associated with it
762
- // The frontend provides the threadId, and it matches the directory name on disk
763
- const threadId = event.id;
764
-
765
- // If an explicit threadId exists and has a directory, use it
766
- if (threadId && threadSet.has(threadId)) {
1008
+ const eventThreadId = event.id;
1009
+ if (eventThreadId && threadSet.has(eventThreadId)) {
767
1010
  return {
768
1011
  ...event,
769
1012
  meta: {
770
- ...(event as any)?.meta,
1013
+ ...(event.meta || {}),
771
1014
  hasThread: true,
772
1015
  },
773
1016
  };
774
1017
  }
775
-
776
1018
  return event;
777
1019
  });
778
1020
  } catch {
779
- // No threads folder or other error, just return events as is
780
1021
  return events;
781
1022
  }
782
1023
  }
783
1024
 
784
1025
  return events;
785
- } catch (error: any) {
786
- if (error.code !== 'ENOENT') {
1026
+ } catch (error: unknown) {
1027
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
787
1028
  console.error(`Failed to get events for channel ${channelId} thread ${threadId}`, error);
788
1029
  }
789
1030
  return [];
@@ -803,8 +1044,8 @@ export const storageService = {
803
1044
  if (threadId) {
804
1045
  try {
805
1046
  await fs.access(threadDir);
806
- } catch (error: any) {
807
- if (error.code === 'ENOENT') {
1047
+ } catch (error: unknown) {
1048
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
808
1049
  const threadTitle = buildThreadTitleFromEvent(event);
809
1050
  await storageService.createThread({
810
1051
  channelId,
@@ -819,7 +1060,6 @@ export const storageService = {
819
1060
  await fs.mkdir(threadDir, { recursive: true });
820
1061
  }
821
1062
 
822
- // Ensure the event has a unique ID
823
1063
  if (!event.id) {
824
1064
  event.id = crypto.randomUUID();
825
1065
  }
@@ -832,19 +1072,102 @@ export const storageService = {
832
1072
  },
833
1073
  getVariables: async (): Promise<Record<string, string | { value: string; secret: boolean }>> => {
834
1074
  const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
835
- const raw = await readJsonFile<any>(variablesFilePath, {});
836
-
837
- if (raw && typeof raw === 'object' && 'variables' in raw && Array.isArray(raw.variables)) {
838
- const entries = (raw.variables as StoredVariable[])
1075
+ const raw = await readJsonFile<unknown>(variablesFilePath, {});
1076
+
1077
+ if (
1078
+ raw &&
1079
+ typeof raw === 'object' &&
1080
+ 'variables' in raw &&
1081
+ Array.isArray((raw as { variables: unknown }).variables)
1082
+ ) {
1083
+ const entries = ((raw as { variables: StoredVariable[] }).variables)
839
1084
  .filter((v) => typeof v?.key === 'string')
840
1085
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }] as const);
841
1086
  return Object.fromEntries(entries);
842
1087
  }
843
1088
 
844
- // Legacy or simple format
845
1089
  return toVariablesRecord(raw);
846
1090
  },
847
1091
 
1092
+ createVariable: async ({
1093
+ key,
1094
+ value,
1095
+ secret = false,
1096
+ }: {
1097
+ key: string;
1098
+ value: string;
1099
+ secret?: boolean;
1100
+ }): Promise<void> => {
1101
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
1102
+ const raw = await readJsonFile<unknown>(variablesFilePath, { version: 1, variables: [] });
1103
+
1104
+ let variables: StoredVariable[] = [];
1105
+ if (
1106
+ raw &&
1107
+ typeof raw === 'object' &&
1108
+ 'variables' in raw &&
1109
+ Array.isArray((raw as { variables: unknown }).variables)
1110
+ ) {
1111
+ variables = (raw as { variables: StoredVariable[] }).variables;
1112
+ } else {
1113
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
1114
+ key: k,
1115
+ value: v,
1116
+ secret: false,
1117
+ }));
1118
+ }
1119
+
1120
+ const existingIndex = variables.findIndex((v) => v.key === key);
1121
+ if (existingIndex !== -1) {
1122
+ variables[existingIndex] = { key, value, secret };
1123
+ } else {
1124
+ variables.push({ key, value, secret });
1125
+ }
1126
+
1127
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
1128
+ await fs.writeFile(
1129
+ variablesFilePath,
1130
+ JSON.stringify({ version: 1, variables }, null, 2),
1131
+ 'utf-8',
1132
+ );
1133
+ processService.syncWorkspaceVariablesToProcessEnv();
1134
+ },
1135
+
1136
+ deleteVariable: async ({ key }: { key: string }): Promise<void> => {
1137
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
1138
+ const raw = await readJsonFile<unknown>(variablesFilePath, { version: 1, variables: [] });
1139
+
1140
+ let variables: StoredVariable[] = [];
1141
+ if (
1142
+ raw &&
1143
+ typeof raw === 'object' &&
1144
+ 'variables' in raw &&
1145
+ Array.isArray((raw as { variables: unknown }).variables)
1146
+ ) {
1147
+ variables = (raw as { variables: StoredVariable[] }).variables;
1148
+ } else {
1149
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
1150
+ key: k,
1151
+ value: v,
1152
+ secret: false,
1153
+ }));
1154
+ }
1155
+
1156
+ const newVariables = variables.filter((v) => v.key !== key);
1157
+
1158
+ if (newVariables.length === variables.length) {
1159
+ return;
1160
+ }
1161
+
1162
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
1163
+ await fs.writeFile(
1164
+ variablesFilePath,
1165
+ JSON.stringify({ version: 1, variables: newVariables }, null, 2),
1166
+ 'utf-8',
1167
+ );
1168
+ processService.syncWorkspaceVariablesToProcessEnv();
1169
+ },
1170
+
848
1171
  listFiles: async ({
849
1172
  channelId,
850
1173
  path: subPath = '',
@@ -862,21 +1185,26 @@ export const storageService = {
862
1185
  const resolvedBase = path.resolve(baseCwd);
863
1186
  const targetDir = path.resolve(resolvedBase, subPath);
864
1187
 
865
- // Security check: ensure target is within baseCwd
866
1188
  if (!targetDir.startsWith(resolvedBase)) {
867
1189
  throw new Error('Access denied: directory escape');
868
1190
  }
869
1191
 
870
1192
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
871
1193
  return entries
872
- .filter((e) => !e.name.startsWith('.')) // Hide hidden files by default for MVP
1194
+ .filter((e) => !e.name.startsWith('.'))
873
1195
  .map((e) => ({
874
1196
  name: e.name,
875
1197
  isDirectory: e.isDirectory(),
876
1198
  }));
877
1199
  },
878
1200
 
879
- readFile: async ({ channelId, path: filePath }: { channelId: string; path: string }): Promise<string> => {
1201
+ readFile: async ({
1202
+ channelId,
1203
+ path: filePath,
1204
+ }: {
1205
+ channelId: string;
1206
+ path: string;
1207
+ }): Promise<string> => {
880
1208
  const details = await storageService.getChannelDetails({ channelId });
881
1209
  const baseCwd = details.cwd;
882
1210
 
@@ -887,7 +1215,6 @@ export const storageService = {
887
1215
  const resolvedBase = path.resolve(baseCwd);
888
1216
  const targetFile = path.resolve(resolvedBase, filePath);
889
1217
 
890
- // Security check: ensure target is within baseCwd
891
1218
  if (!targetFile.startsWith(resolvedBase)) {
892
1219
  throw new Error('Access denied: directory escape');
893
1220
  }
@@ -895,6 +1222,11 @@ export const storageService = {
895
1222
  return fs.readFile(targetFile, 'utf-8');
896
1223
  },
897
1224
 
1225
+ appendMemory: memoryService.appendMemory,
1226
+ listMemories: memoryService.listMemories,
1227
+ deleteMemory: memoryService.deleteMemory,
1228
+ updateMemory: memoryService.updateMemory,
1229
+
898
1230
  /**
899
1231
  * Hydrates the full OpenBot state from disk/storage before a run.
900
1232
  */
@@ -916,7 +1248,7 @@ export const storageService = {
916
1248
  }
917
1249
 
918
1250
  let channelDetails;
919
- if (channelId && channelId !== 'general') {
1251
+ if (channelId) {
920
1252
  try {
921
1253
  channelDetails = await storageService.getChannelDetails({ channelId });
922
1254
  } catch (error) {
@@ -946,10 +1278,13 @@ export const storageService = {
946
1278
  id: agentDetails.id,
947
1279
  name: agentDetails.name,
948
1280
  description: agentDetails.description || '',
1281
+ image: agentDetails.image,
949
1282
  instructions: agentDetails.instructions || '',
950
- runtime: agentDetails.runtime,
951
1283
  plugins: agentDetails.plugins,
952
- } as AgentDetails,
1284
+ pluginRefs: agentDetails.pluginRefs,
1285
+ createdAt: agentDetails.createdAt,
1286
+ updatedAt: agentDetails.updatedAt,
1287
+ } satisfies AgentDetails,
953
1288
  channelDetails: channelDetails as ChannelDetails,
954
1289
  threadDetails: threadDetails as ThreadDetails,
955
1290
  };