openbot 0.2.11 → 0.2.13

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 (141) hide show
  1. package/.prettierrc +8 -0
  2. package/AGENTS.md +68 -0
  3. package/CONTRIBUTING.md +74 -0
  4. package/LICENSE +21 -0
  5. package/README.md +117 -14
  6. package/dist/agents/system.js +106 -0
  7. package/dist/app/cli.js +27 -0
  8. package/dist/app/config.js +64 -0
  9. package/dist/app/server.js +237 -0
  10. package/dist/app/utils.js +35 -0
  11. package/dist/harness/agent-harness.js +45 -0
  12. package/dist/harness/mcp.js +61 -0
  13. package/dist/harness/orchestrator.js +273 -0
  14. package/dist/harness/process.js +7 -0
  15. package/dist/plugins/ai-sdk.js +141 -0
  16. package/dist/plugins/delegation.js +52 -0
  17. package/dist/plugins/mcp.js +140 -0
  18. package/dist/plugins/storage.js +502 -0
  19. package/dist/plugins/ui.js +47 -0
  20. package/dist/registry/plugins.js +73 -0
  21. package/dist/services/storage.js +724 -0
  22. package/docs/README.md +7 -0
  23. package/docs/agents.md +83 -0
  24. package/docs/architecture.md +34 -0
  25. package/docs/plugins.md +77 -0
  26. package/logo-black.png +0 -0
  27. package/{dist/assets/logo.js → logo-black.svg} +24 -24
  28. package/{dist/ui/sidebar.js → logo-white.svg} +23 -88
  29. package/package.json +10 -9
  30. package/src/agents/system.ts +112 -0
  31. package/src/app/cli.ts +38 -0
  32. package/src/app/config.ts +104 -0
  33. package/src/app/server.ts +284 -0
  34. package/src/app/types.ts +476 -0
  35. package/src/app/utils.ts +43 -0
  36. package/src/assets/icon.svg +1 -0
  37. package/src/harness/agent-harness.ts +58 -0
  38. package/src/harness/mcp.ts +78 -0
  39. package/src/harness/orchestrator.ts +342 -0
  40. package/src/harness/process.ts +9 -0
  41. package/src/harness/types.ts +34 -0
  42. package/src/plugins/ai-sdk.ts +197 -0
  43. package/src/plugins/delegation.ts +60 -0
  44. package/src/plugins/mcp.ts +154 -0
  45. package/src/plugins/storage.ts +725 -0
  46. package/src/plugins/ui.ts +57 -0
  47. package/src/registry/plugins.ts +85 -0
  48. package/src/services/storage.ts +957 -0
  49. package/tsconfig.json +18 -0
  50. package/dist/agents/agent-creator.js +0 -74
  51. package/dist/agents/browser-agent.js +0 -31
  52. package/dist/agents/os-agent.js +0 -32
  53. package/dist/agents/planner-agent.js +0 -32
  54. package/dist/agents/topic-agent.js +0 -46
  55. package/dist/architecture/execution-engine.js +0 -151
  56. package/dist/architecture/intent-classifier.js +0 -26
  57. package/dist/architecture/planner.js +0 -106
  58. package/dist/automation-worker.js +0 -121
  59. package/dist/automations.js +0 -52
  60. package/dist/cli.js +0 -275
  61. package/dist/config.js +0 -53
  62. package/dist/core/agents.js +0 -41
  63. package/dist/core/delegation.js +0 -230
  64. package/dist/core/manager.js +0 -96
  65. package/dist/core/plugins.js +0 -74
  66. package/dist/core/router.js +0 -191
  67. package/dist/handlers/init.js +0 -29
  68. package/dist/handlers/session-change.js +0 -21
  69. package/dist/handlers/settings.js +0 -47
  70. package/dist/handlers/tab-change.js +0 -14
  71. package/dist/installers.js +0 -156
  72. package/dist/marketplace.js +0 -80
  73. package/dist/model-catalog.js +0 -132
  74. package/dist/model-defaults.js +0 -25
  75. package/dist/models.js +0 -47
  76. package/dist/open-bot.js +0 -51
  77. package/dist/orchestrator/direct-invocation.js +0 -13
  78. package/dist/orchestrator/events.js +0 -36
  79. package/dist/orchestrator/state.js +0 -54
  80. package/dist/orchestrator.js +0 -422
  81. package/dist/plugins/agent/index.js +0 -81
  82. package/dist/plugins/approval/index.js +0 -100
  83. package/dist/plugins/brain/identity.js +0 -77
  84. package/dist/plugins/brain/index.js +0 -204
  85. package/dist/plugins/brain/memory.js +0 -120
  86. package/dist/plugins/brain/prompt.js +0 -46
  87. package/dist/plugins/brain/types.js +0 -45
  88. package/dist/plugins/brain/ui.js +0 -7
  89. package/dist/plugins/browser/index.js +0 -629
  90. package/dist/plugins/browser/ui.js +0 -13
  91. package/dist/plugins/file-system/index.js +0 -171
  92. package/dist/plugins/file-system/ui.js +0 -6
  93. package/dist/plugins/llm/context-budget.js +0 -139
  94. package/dist/plugins/llm/context-shaping.js +0 -177
  95. package/dist/plugins/llm/index.js +0 -380
  96. package/dist/plugins/memory/index.js +0 -220
  97. package/dist/plugins/memory/memory.js +0 -122
  98. package/dist/plugins/memory/prompt.js +0 -55
  99. package/dist/plugins/memory/types.js +0 -45
  100. package/dist/plugins/meta-agent/index.js +0 -570
  101. package/dist/plugins/meta-agent/ui.js +0 -11
  102. package/dist/plugins/shell/index.js +0 -100
  103. package/dist/plugins/shell/ui.js +0 -6
  104. package/dist/plugins/skills/index.js +0 -286
  105. package/dist/plugins/skills/types.js +0 -50
  106. package/dist/plugins/skills/ui.js +0 -12
  107. package/dist/registry/agent-registry.js +0 -35
  108. package/dist/registry/index.js +0 -2
  109. package/dist/registry/plugin-loader.js +0 -499
  110. package/dist/registry/plugin-registry.js +0 -44
  111. package/dist/registry/ts-agent-loader.js +0 -82
  112. package/dist/registry/yaml-agent-loader.js +0 -246
  113. package/dist/runtime/execution-trace.js +0 -41
  114. package/dist/runtime/intent-routing.js +0 -26
  115. package/dist/runtime/openbot-runtime.js +0 -354
  116. package/dist/server.js +0 -890
  117. package/dist/session.js +0 -179
  118. package/dist/ui/block.js +0 -12
  119. package/dist/ui/header.js +0 -52
  120. package/dist/ui/layout.js +0 -26
  121. package/dist/ui/navigation.js +0 -15
  122. package/dist/ui/settings.js +0 -106
  123. package/dist/ui/skills.js +0 -7
  124. package/dist/ui/thread.js +0 -16
  125. package/dist/ui/widgets/action-list.js +0 -2
  126. package/dist/ui/widgets/approval-card.js +0 -9
  127. package/dist/ui/widgets/code-snippet.js +0 -2
  128. package/dist/ui/widgets/data-block.js +0 -2
  129. package/dist/ui/widgets/data-table.js +0 -2
  130. package/dist/ui/widgets/delegation.js +0 -29
  131. package/dist/ui/widgets/empty-state.js +0 -2
  132. package/dist/ui/widgets/index.js +0 -23
  133. package/dist/ui/widgets/inquiry.js +0 -7
  134. package/dist/ui/widgets/key-value.js +0 -2
  135. package/dist/ui/widgets/progress-step.js +0 -2
  136. package/dist/ui/widgets/resource-card.js +0 -2
  137. package/dist/ui/widgets/status.js +0 -2
  138. package/dist/ui/widgets/todo-list.js +0 -2
  139. package/dist/version.js +0 -62
  140. /package/dist/{types.js → app/types.js} +0 -0
  141. /package/dist/{architecture/contracts.js → harness/types.js} +0 -0
@@ -0,0 +1,957 @@
1
+ import {
2
+ DEFAULT_AGENTS_DIR,
3
+ DEFAULT_BASE_DIR,
4
+ DEFAULT_CHANNELS_DIR,
5
+ DEFAULT_PLUGINS_DIR,
6
+ loadConfig,
7
+ resolvePath,
8
+ StoredVariable,
9
+ VARIABLES_FILE,
10
+ } from '../app/config.js';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import matter from 'gray-matter';
15
+ import {
16
+ Agent,
17
+ AgentDetails,
18
+ Channel,
19
+ ChannelDetails,
20
+ Plugin,
21
+ PluginKind,
22
+ Thread,
23
+ ThreadDetails,
24
+ } from '../plugins/storage.js';
25
+ import { getSystemAgentDetails } from '../agents/system.js';
26
+ import { OpenBotEvent, OpenBotState } from '../app/types.js';
27
+ import { pathToFileURL } from 'node:url';
28
+
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
+ const resolveBaseDir = () => {
45
+ const config = loadConfig();
46
+ return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
47
+ };
48
+
49
+ const ENTITY_SVG_CANDIDATE_NAMES = ['avatar.svg', 'icon.svg', 'image.svg', 'logo.svg'] as const;
50
+
51
+ const toSvgDataUrl = (svg: string) =>
52
+ `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
53
+
54
+ const tryReadSvgDataUrl = async (filePath: string): Promise<string | null> => {
55
+ try {
56
+ const svg = await fs.readFile(filePath, 'utf-8');
57
+ const trimmed = svg.trim();
58
+ if (!trimmed.startsWith('<svg')) return null;
59
+ return toSvgDataUrl(trimmed);
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
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
+ const resolveEntityImageDataUrl = async (entityDir: string): Promise<string | undefined> => {
75
+ const preferredDirs = [path.join(entityDir, 'assets'), entityDir];
76
+
77
+ for (const dir of preferredDirs) {
78
+ for (const fileName of ENTITY_SVG_CANDIDATE_NAMES) {
79
+ const dataUrl = await tryReadSvgDataUrl(path.join(dir, fileName));
80
+ if (dataUrl) return dataUrl;
81
+ }
82
+ }
83
+
84
+ for (const dir of preferredDirs) {
85
+ try {
86
+ const entries = await fs.readdir(dir, { withFileTypes: true });
87
+ const firstSvg = entries.find(
88
+ (entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.svg'),
89
+ );
90
+ if (!firstSvg) continue;
91
+ const dataUrl = await tryReadSvgDataUrl(path.join(dir, firstSvg.name));
92
+ if (dataUrl) return dataUrl;
93
+ } catch {
94
+ // ignore missing/unreadable folders
95
+ }
96
+ }
97
+
98
+ return undefined;
99
+ };
100
+
101
+ const getConversationDir = (channelId: string, threadId?: string) => {
102
+ const base = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId);
103
+ return threadId ? `${base}/threads/${threadId}` : base;
104
+ };
105
+
106
+ const getLastReadFilePath = () =>
107
+ path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
108
+
109
+ const THREAD_TITLE_MAX_LENGTH = 80;
110
+
111
+ const buildThreadTitleFromEvent = (event: OpenBotEvent): string | undefined => {
112
+ let rawContent = '';
113
+
114
+ if (event.type === 'user:input' && typeof event.data?.content === 'string') {
115
+ rawContent = event.data.content;
116
+ } else if (
117
+ event.type === 'agent:invoke' &&
118
+ event.data?.role === 'user' &&
119
+ typeof event.data.content === 'string'
120
+ ) {
121
+ rawContent = event.data.content;
122
+ }
123
+
124
+ const normalized = rawContent.replace(/\s+/g, ' ').trim();
125
+ if (!normalized) return undefined;
126
+
127
+ if (normalized.length <= THREAD_TITLE_MAX_LENGTH) {
128
+ return normalized;
129
+ }
130
+
131
+ return `${normalized.slice(0, THREAD_TITLE_MAX_LENGTH).trimEnd()}...`;
132
+ };
133
+
134
+ const readJsonFile = async <T>(filePath: string, fallback: T): Promise<T> => {
135
+ try {
136
+ return JSON.parse(await fs.readFile(filePath, 'utf-8')) as T;
137
+ } catch (e: unknown) {
138
+ if ((e as { code?: string })?.code === 'ENOENT') return fallback;
139
+ throw e;
140
+ }
141
+ };
142
+
143
+ const toVariablesRecord = (raw: unknown): Record<string, string> => {
144
+ if (!raw || typeof raw !== 'object') {
145
+ return {};
146
+ }
147
+
148
+ // Current format: { version: number, variables: StoredVariable[] }
149
+ if ('variables' in raw && Array.isArray((raw as { variables?: unknown }).variables)) {
150
+ const entries = (raw as { variables: StoredVariable[] }).variables
151
+ .filter((variable) => typeof variable?.key === 'string')
152
+ .map((variable) => [variable.key, String(variable.value ?? '')] as const);
153
+ return Object.fromEntries(entries);
154
+ }
155
+
156
+ // Legacy format: { [key: string]: string }
157
+ return Object.fromEntries(
158
+ Object.entries(raw as Record<string, unknown>).map(([key, value]) => [
159
+ key,
160
+ String(value ?? ''),
161
+ ]),
162
+ );
163
+ };
164
+
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
+ ];
171
+ };
172
+
173
+ const listPluginsFromDisk = async (): Promise<Plugin[]> => {
174
+ const pluginsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_PLUGINS_DIR);
175
+ try {
176
+ await fs.access(pluginsDir);
177
+ } catch {
178
+ await fs.mkdir(pluginsDir, { recursive: true });
179
+ }
180
+
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
+ });
197
+
198
+ return Promise.all(plugins);
199
+ };
200
+
201
+ export const storageService = {
202
+ getLastReadByChannel: async (): Promise<Record<string, string>> => {
203
+ return readJsonFile(getLastReadFilePath(), {});
204
+ },
205
+
206
+ setLastReadForChannel: async ({
207
+ channelId,
208
+ lastReadEventId,
209
+ }: {
210
+ channelId: string;
211
+ lastReadEventId: string;
212
+ }): Promise<void> => {
213
+ const p = getLastReadFilePath();
214
+ await fs.mkdir(path.dirname(p), { recursive: true });
215
+ const map = await readJsonFile<Record<string, string>>(p, {});
216
+ map[channelId] = lastReadEventId;
217
+ await fs.writeFile(p, JSON.stringify(map, null, 2), 'utf-8');
218
+ },
219
+
220
+ getChannels: async (): Promise<Channel[]> => {
221
+ const channelsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR);
222
+ try {
223
+ await fs.access(channelsDir);
224
+ } catch {
225
+ await fs.mkdir(channelsDir, { recursive: true });
226
+ }
227
+
228
+ const channelNames = (await fs.readdir(channelsDir)).filter(
229
+ (name) => !name.startsWith('.') && name !== '_meta',
230
+ );
231
+ const lastReadByChannel = await storageService.getLastReadByChannel();
232
+
233
+ const channels = await Promise.all(
234
+ channelNames.map(async (name) => {
235
+ const channelDir = getConversationDir(name);
236
+ const statePath = path.join(channelDir, 'state.json');
237
+ let cwd: string | undefined;
238
+
239
+ try {
240
+ const stateContent = await fs.readFile(statePath, 'utf-8');
241
+ const state = JSON.parse(stateContent);
242
+ cwd = typeof state.cwd === 'string' ? state.cwd : undefined;
243
+ } catch {
244
+ // Ignore if state.json is missing or invalid
245
+ }
246
+
247
+ const channel: Channel = {
248
+ id: name,
249
+ name: name,
250
+ description: '',
251
+ cwd,
252
+ createdAt: new Date(),
253
+ updatedAt: new Date(),
254
+ };
255
+ const rid = lastReadByChannel[name];
256
+ try {
257
+ const events = await storageService.getEvents({ channelId: name });
258
+ const latestId = events[events.length - 1]?.id;
259
+ channel.hasUnseenMessages = !!(latestId && latestId !== rid);
260
+ } catch {
261
+ channel.hasUnseenMessages = false;
262
+ }
263
+
264
+ try {
265
+ // Fetch up to 5 most recent threads for the sidebar
266
+ const allThreads = await storageService.getThreads({ channelId: name });
267
+ channel.recentThreads = allThreads
268
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
269
+ .slice(0, 5);
270
+ } catch {
271
+ channel.recentThreads = [];
272
+ }
273
+
274
+ return channel;
275
+ }),
276
+ );
277
+
278
+ return channels;
279
+ },
280
+ createChannel: async ({
281
+ channelId,
282
+ spec,
283
+ initialState,
284
+ cwd,
285
+ }: {
286
+ channelId: string;
287
+ spec?: string;
288
+ initialState?: Record<string, unknown>;
289
+ cwd?: string;
290
+ }): Promise<void> => {
291
+ const normalizedChannelId = channelId.trim();
292
+ if (!normalizedChannelId) {
293
+ throw new Error('channelId is required');
294
+ }
295
+
296
+ const channelDir = getConversationDir(normalizedChannelId);
297
+ const specPath = `${channelDir}/SPEC.md`;
298
+ const statePath = `${channelDir}/state.json`;
299
+
300
+ try {
301
+ await fs.access(channelDir);
302
+ throw new Error(`Channel "${normalizedChannelId}" already exists`);
303
+ } catch (error: any) {
304
+ if (error.code !== 'ENOENT') {
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ const finalState = {
310
+ ...(initialState || {}),
311
+ };
312
+
313
+ if (cwd) {
314
+ finalState.cwd = cwd;
315
+ }
316
+
317
+ await fs.mkdir(channelDir, { recursive: true });
318
+ await fs.writeFile(
319
+ specPath,
320
+ spec?.trim() ||
321
+ `# ${normalizedChannelId}\n\nDefine the goals and rules for this channel here.\n`,
322
+ );
323
+ await fs.writeFile(statePath, JSON.stringify(finalState, null, 2));
324
+ },
325
+ createThread: async ({
326
+ channelId,
327
+ threadId,
328
+ threadTitle,
329
+ spec,
330
+ initialState,
331
+ }: {
332
+ channelId: string;
333
+ threadId: string;
334
+ threadTitle?: string;
335
+ spec?: string;
336
+ initialState?: Record<string, unknown>;
337
+ }): Promise<void> => {
338
+ const normalizedChannelId = channelId.trim();
339
+ const normalizedThreadId = threadId.trim();
340
+
341
+ if (!normalizedChannelId) {
342
+ throw new Error('channelId is required');
343
+ }
344
+ if (!normalizedThreadId) {
345
+ throw new Error('threadId is required');
346
+ }
347
+
348
+ const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
349
+ const specPath = `${threadDir}/SPEC.md`;
350
+ const statePath = `${threadDir}/state.json`;
351
+
352
+ try {
353
+ await fs.access(threadDir);
354
+ throw new Error(
355
+ `Thread "${normalizedThreadId}" already exists in channel "${normalizedChannelId}"`,
356
+ );
357
+ } catch (error: any) {
358
+ if (error.code !== 'ENOENT') {
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ const baseState = { ...(initialState || {}) };
364
+ if (threadTitle?.trim()) {
365
+ baseState.generatedName = threadTitle.trim();
366
+ }
367
+
368
+ await fs.mkdir(threadDir, { recursive: true });
369
+ await fs.writeFile(
370
+ specPath,
371
+ spec?.trim() ||
372
+ `# ${normalizedThreadId}\n\nDefine the goals and plan for this thread here.\n`,
373
+ );
374
+ await fs.writeFile(statePath, JSON.stringify(baseState, null, 2));
375
+ },
376
+ getThreads: async ({ channelId }: { channelId: string }): Promise<Thread[]> => {
377
+ const threadsDir = resolvePath(
378
+ resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads',
379
+ );
380
+ try {
381
+ await fs.access(threadsDir);
382
+ } catch {
383
+ return [];
384
+ }
385
+
386
+ const threadNames = (await fs.readdir(threadsDir)).filter((name) => !name.startsWith('.'));
387
+
388
+ const threads = await Promise.all(
389
+ threadNames.map(async (name) => {
390
+ const threadPath = path.join(threadsDir, name);
391
+ const stats = await fs.stat(threadPath);
392
+ const threadStatePath = path.join(threadPath, 'state.json');
393
+ let threadDisplayName = name;
394
+
395
+ try {
396
+ const threadStateRaw = await fs.readFile(threadStatePath, 'utf-8');
397
+ const threadState = JSON.parse(threadStateRaw) as Record<string, unknown>;
398
+ const generatedName =
399
+ typeof threadState.generatedName === 'string' ? threadState.generatedName.trim() : '';
400
+ if (generatedName) {
401
+ threadDisplayName = generatedName;
402
+ }
403
+ } catch (error: any) {
404
+ if (error.code !== 'ENOENT') {
405
+ console.error(
406
+ `Failed to read thread state for channel ${channelId} thread ${name}`,
407
+ error,
408
+ );
409
+ }
410
+ }
411
+
412
+ return {
413
+ id: name,
414
+ name: threadDisplayName,
415
+ channelId,
416
+ createdAt: stats.birthtime,
417
+ updatedAt: stats.mtime,
418
+ };
419
+ }),
420
+ );
421
+
422
+ return threads;
423
+ },
424
+ getThreadDetails: async ({
425
+ channelId,
426
+ threadId,
427
+ }: {
428
+ channelId: string;
429
+ threadId: string;
430
+ }): Promise<ThreadDetails> => {
431
+ const threadDir = getConversationDir(channelId, threadId);
432
+ const specPath = `${threadDir}/SPEC.md`;
433
+ const statePath = `${threadDir}/state.json`;
434
+
435
+ let spec = '';
436
+ try {
437
+ spec = await fs.readFile(specPath, 'utf-8');
438
+ } catch (error: any) {
439
+ if (error.code !== 'ENOENT') {
440
+ console.error(
441
+ `Failed to read thread spec for channel ${channelId} thread ${threadId}`,
442
+ error,
443
+ );
444
+ }
445
+ }
446
+
447
+ let state = {};
448
+ try {
449
+ const stateContent = await fs.readFile(statePath, 'utf-8');
450
+ state = JSON.parse(stateContent);
451
+ } catch (error: any) {
452
+ if (error.code !== 'ENOENT') {
453
+ console.error(
454
+ `Failed to read thread state for channel ${channelId} thread ${threadId}`,
455
+ error,
456
+ );
457
+ }
458
+ }
459
+
460
+ const generatedName =
461
+ typeof (state as Record<string, unknown>).generatedName === 'string'
462
+ ? ((state as Record<string, unknown>).generatedName as string).trim()
463
+ : '';
464
+
465
+ return {
466
+ id: threadId,
467
+ name: generatedName || threadId,
468
+ channelId,
469
+ spec,
470
+ state,
471
+ };
472
+ },
473
+ getChannelDetails: async ({ channelId }: { channelId: string }): Promise<ChannelDetails> => {
474
+ const channelDir = getConversationDir(channelId);
475
+ const specPath = `${channelDir}/SPEC.md`;
476
+ const statePath = `${channelDir}/state.json`;
477
+
478
+ let spec = '';
479
+ try {
480
+ spec = await fs.readFile(specPath, 'utf-8');
481
+ } catch (error: any) {
482
+ if (error.code !== 'ENOENT') {
483
+ console.error(`Failed to read spec file for channel ${channelId}`, error);
484
+ }
485
+ }
486
+
487
+ let state = {};
488
+ try {
489
+ const stateContent = await fs.readFile(statePath, 'utf-8');
490
+ state = JSON.parse(stateContent);
491
+ } catch (error: any) {
492
+ if (error.code !== 'ENOENT') {
493
+ console.error(`Failed to read state file for channel ${channelId}`, error);
494
+ }
495
+ }
496
+
497
+ const details: ChannelDetails = {
498
+ id: channelId,
499
+ name: channelId,
500
+ spec,
501
+ state,
502
+ cwd: typeof (state as any).cwd === 'string' ? (state as any).cwd : undefined,
503
+ };
504
+
505
+ details.threads = await storageService.getThreads({ channelId });
506
+
507
+ return details;
508
+ },
509
+ patchChannelState: async ({
510
+ channelId,
511
+ state: patch,
512
+ }: {
513
+ channelId: string;
514
+ state: unknown;
515
+ }): Promise<void> => {
516
+ const channelDir = getConversationDir(channelId);
517
+ const statePath = `${channelDir}/state.json`;
518
+
519
+ try {
520
+ // 1. Fetch current details to get the existing state
521
+ const currentDetails = await storageService.getChannelDetails({ channelId });
522
+ const currentState = (currentDetails.state as Record<string, unknown>) || {};
523
+
524
+ // 2. Perform a shallow merge (patch)
525
+ const newState = {
526
+ ...currentState,
527
+ ...(patch as Record<string, unknown>),
528
+ };
529
+
530
+ // 3. Write back the merged state
531
+ await fs.mkdir(channelDir, { recursive: true });
532
+ await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
533
+ } catch (error) {
534
+ console.error(`Failed to patch channel state for channel ${channelId}`, error);
535
+ throw error;
536
+ }
537
+ },
538
+ patchThreadState: async ({
539
+ channelId,
540
+ threadId,
541
+ state: patch,
542
+ }: {
543
+ channelId: string;
544
+ threadId: string;
545
+ state: unknown;
546
+ }): Promise<void> => {
547
+ const threadDir = getConversationDir(channelId, threadId);
548
+ const statePath = `${threadDir}/state.json`;
549
+
550
+ try {
551
+ // 1. Fetch current details to get the existing state
552
+ const currentDetails = await storageService.getThreadDetails({ channelId, threadId });
553
+ const currentState = (currentDetails.state as Record<string, unknown>) || {};
554
+
555
+ // 2. Perform a shallow merge (patch)
556
+ const newState = {
557
+ ...currentState,
558
+ ...(patch as Record<string, unknown>),
559
+ };
560
+
561
+ // 3. Write back the merged state
562
+ await fs.mkdir(threadDir, { recursive: true });
563
+ await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
564
+ } catch (error) {
565
+ console.error(
566
+ `Failed to patch thread state for channel ${channelId} thread ${threadId}`,
567
+ error,
568
+ );
569
+ throw error;
570
+ }
571
+ },
572
+ patchChannelSpec: async ({
573
+ channelId,
574
+ spec,
575
+ }: {
576
+ channelId: string;
577
+ spec: string;
578
+ }): Promise<void> => {
579
+ const channelDir = getConversationDir(channelId);
580
+ const specPath = `${channelDir}/SPEC.md`;
581
+
582
+ try {
583
+ await fs.mkdir(channelDir, { recursive: true });
584
+ await fs.writeFile(specPath, spec);
585
+ } catch (error) {
586
+ console.error(`Failed to patch channel spec for channel ${channelId}`, error);
587
+ throw error;
588
+ }
589
+ },
590
+ patchThreadSpec: async ({
591
+ channelId,
592
+ threadId,
593
+ spec,
594
+ }: {
595
+ channelId: string;
596
+ threadId: string;
597
+ spec: string;
598
+ }): Promise<void> => {
599
+ const threadDir = getConversationDir(channelId, threadId);
600
+ const specPath = `${threadDir}/SPEC.md`;
601
+
602
+ try {
603
+ await fs.mkdir(threadDir, { recursive: true });
604
+ await fs.writeFile(specPath, spec);
605
+ } catch (error) {
606
+ console.error(
607
+ `Failed to patch thread spec for channel ${channelId} thread ${threadId}`,
608
+ error,
609
+ );
610
+ throw error;
611
+ }
612
+ },
613
+ getAgents: async (): Promise<Agent[]> => {
614
+ const agentsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_AGENTS_DIR);
615
+ try {
616
+ await fs.access(agentsDir);
617
+ } catch {
618
+ await fs.mkdir(agentsDir, { recursive: true });
619
+ }
620
+
621
+ const agentIds = (await fs.readdir(agentsDir)).filter((name) => !name.startsWith('.'));
622
+
623
+ const agents = await Promise.all(
624
+ agentIds.map(async (id) => {
625
+ try {
626
+ const details = await storageService.getAgentDetails({ agentId: id });
627
+ return {
628
+ id,
629
+ name: details.name || id,
630
+ description: details.description || '',
631
+ image: details.image,
632
+ runtime: details.runtime,
633
+ createdAt: details.createdAt,
634
+ updatedAt: details.updatedAt,
635
+ };
636
+ } catch {
637
+ return {
638
+ id,
639
+ name: id,
640
+ description: '',
641
+ createdAt: new Date(),
642
+ updatedAt: new Date(),
643
+ };
644
+ }
645
+ }),
646
+ );
647
+
648
+ const system = await storageService.getAgentDetails({ agentId: 'system' });
649
+ const builtInSystemAgent: Agent = {
650
+ id: system.id,
651
+ name: system.name,
652
+ description: system.description || '',
653
+ image: system.image,
654
+ runtime: system.runtime,
655
+ createdAt: system.createdAt,
656
+ updatedAt: system.updatedAt,
657
+ };
658
+
659
+ const deduped = new Map<string, Agent>();
660
+ deduped.set(builtInSystemAgent.id, builtInSystemAgent);
661
+ for (const agent of agents) {
662
+ if (!deduped.has(agent.id)) deduped.set(agent.id, agent);
663
+ }
664
+
665
+ return Array.from(deduped.values());
666
+ },
667
+ getPlugins: async (): Promise<Plugin[]> => {
668
+ const [builtInPlugins, diskPlugins] = await Promise.all([
669
+ listBuiltInPlugins(),
670
+ listPluginsFromDisk(),
671
+ ]);
672
+
673
+ const merged = [...builtInPlugins, ...diskPlugins];
674
+ const deduped = new Map<string, Plugin>();
675
+ for (const plugin of merged) {
676
+ if (!deduped.has(plugin.id)) {
677
+ deduped.set(plugin.id, plugin);
678
+ }
679
+ }
680
+
681
+ return Array.from(deduped.values());
682
+ },
683
+ getAgentDetails: async ({ agentId }: { agentId: string }): Promise<AgentDetails> => {
684
+ const agentDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_AGENTS_DIR + '/' + agentId);
685
+ const agentMdPath = `${agentDir}/AGENT.md`;
686
+
687
+ let diskDetails: Partial<AgentDetails> | undefined;
688
+
689
+ try {
690
+ await fs.access(agentMdPath);
691
+ const agentMd = await fs.readFile(agentMdPath, 'utf-8');
692
+ const { data, content: instructions } = matter(agentMd);
693
+ const discoveredImage = await resolveEntityImageDataUrl(agentDir);
694
+
695
+ diskDetails = {
696
+ id: agentId,
697
+ name: data.name || agentId,
698
+ instructions: instructions.trim(),
699
+ runtime: data.runtime,
700
+ plugins: data.plugins || [],
701
+ description: data.description || '',
702
+ image: discoveredImage || undefined,
703
+ createdAt: new Date(),
704
+ updatedAt: new Date(),
705
+ };
706
+ } catch (error) {
707
+ if (agentId !== 'system') {
708
+ const err = new Error(`Agent "${agentId}" does not exist.`);
709
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
710
+ throw err;
711
+ }
712
+ }
713
+
714
+ if (agentId === 'system') {
715
+ return getSystemAgentDetails(diskDetails);
716
+ }
717
+
718
+ if (!diskDetails) {
719
+ const error = new Error(`Agent "${agentId}" does not exist.`);
720
+ (error as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
721
+ throw error;
722
+ }
723
+
724
+ return diskDetails as AgentDetails;
725
+ },
726
+ getEvents: async ({
727
+ channelId,
728
+ threadId,
729
+ }: {
730
+ channelId: string;
731
+ threadId?: string;
732
+ }): Promise<OpenBotEvent[]> => {
733
+ try {
734
+ const threadDir = getConversationDir(channelId, threadId);
735
+ const eventsPath = `${threadDir}/events.jsonl`;
736
+ const eventsData = await fs.readFile(eventsPath);
737
+
738
+ const events = eventsData
739
+ .toString()
740
+ .split(/\r?\n/)
741
+ .map((line) => line.trim())
742
+ .filter(Boolean)
743
+ .map((line) => {
744
+ const event = JSON.parse(line) as OpenBotEvent;
745
+ if (!event.id) {
746
+ event.id = crypto.randomUUID();
747
+ }
748
+ return event;
749
+ });
750
+
751
+ // If we are at the channel level (no threadId), check which events have threads
752
+ if (!threadId) {
753
+ const threadsDir = resolvePath(
754
+ resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads',
755
+ );
756
+ try {
757
+ const threadDirs = await fs.readdir(threadsDir);
758
+ const threadSet = new Set(threadDirs);
759
+
760
+ 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)) {
767
+ return {
768
+ ...event,
769
+ meta: {
770
+ ...(event as any)?.meta,
771
+ hasThread: true,
772
+ },
773
+ };
774
+ }
775
+
776
+ return event;
777
+ });
778
+ } catch {
779
+ // No threads folder or other error, just return events as is
780
+ return events;
781
+ }
782
+ }
783
+
784
+ return events;
785
+ } catch (error: any) {
786
+ if (error.code !== 'ENOENT') {
787
+ console.error(`Failed to get events for channel ${channelId} thread ${threadId}`, error);
788
+ }
789
+ return [];
790
+ }
791
+ },
792
+ storeEvent: async ({
793
+ channelId,
794
+ threadId,
795
+ event,
796
+ }: {
797
+ channelId: string;
798
+ threadId?: string;
799
+ event: OpenBotEvent;
800
+ }): Promise<void> => {
801
+ try {
802
+ const threadDir = getConversationDir(channelId, threadId);
803
+ if (threadId) {
804
+ try {
805
+ await fs.access(threadDir);
806
+ } catch (error: any) {
807
+ if (error.code === 'ENOENT') {
808
+ const threadTitle = buildThreadTitleFromEvent(event);
809
+ await storageService.createThread({
810
+ channelId,
811
+ threadId,
812
+ threadTitle,
813
+ });
814
+ } else {
815
+ throw error;
816
+ }
817
+ }
818
+ } else {
819
+ await fs.mkdir(threadDir, { recursive: true });
820
+ }
821
+
822
+ // Ensure the event has a unique ID
823
+ if (!event.id) {
824
+ event.id = crypto.randomUUID();
825
+ }
826
+
827
+ await fs.appendFile(`${threadDir}/events.jsonl`, `${JSON.stringify(event)}\n`);
828
+ } catch (error) {
829
+ console.error(`Failed to store event for channel ${channelId} thread ${threadId}`, error);
830
+ throw error;
831
+ }
832
+ },
833
+ getVariables: async (): Promise<Record<string, string | { value: string; secret: boolean }>> => {
834
+ 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[])
839
+ .filter((v) => typeof v?.key === 'string')
840
+ .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }] as const);
841
+ return Object.fromEntries(entries);
842
+ }
843
+
844
+ // Legacy or simple format
845
+ return toVariablesRecord(raw);
846
+ },
847
+
848
+ listFiles: async ({
849
+ channelId,
850
+ path: subPath = '',
851
+ }: {
852
+ channelId: string;
853
+ path?: string;
854
+ }): Promise<Array<{ name: string; isDirectory: boolean }>> => {
855
+ const details = await storageService.getChannelDetails({ channelId });
856
+ const baseCwd = details.cwd;
857
+
858
+ if (!baseCwd) {
859
+ throw new Error('Channel has no CWD configured');
860
+ }
861
+
862
+ const resolvedBase = path.resolve(baseCwd);
863
+ const targetDir = path.resolve(resolvedBase, subPath);
864
+
865
+ // Security check: ensure target is within baseCwd
866
+ if (!targetDir.startsWith(resolvedBase)) {
867
+ throw new Error('Access denied: directory escape');
868
+ }
869
+
870
+ const entries = await fs.readdir(targetDir, { withFileTypes: true });
871
+ return entries
872
+ .filter((e) => !e.name.startsWith('.')) // Hide hidden files by default for MVP
873
+ .map((e) => ({
874
+ name: e.name,
875
+ isDirectory: e.isDirectory(),
876
+ }));
877
+ },
878
+
879
+ readFile: async ({ channelId, path: filePath }: { channelId: string; path: string }): Promise<string> => {
880
+ const details = await storageService.getChannelDetails({ channelId });
881
+ const baseCwd = details.cwd;
882
+
883
+ if (!baseCwd) {
884
+ throw new Error('Channel has no CWD configured');
885
+ }
886
+
887
+ const resolvedBase = path.resolve(baseCwd);
888
+ const targetFile = path.resolve(resolvedBase, filePath);
889
+
890
+ // Security check: ensure target is within baseCwd
891
+ if (!targetFile.startsWith(resolvedBase)) {
892
+ throw new Error('Access denied: directory escape');
893
+ }
894
+
895
+ return fs.readFile(targetFile, 'utf-8');
896
+ },
897
+
898
+ /**
899
+ * Hydrates the full OpenBot state from disk/storage before a run.
900
+ */
901
+ getOpenBotState: async (options: {
902
+ runId: string;
903
+ agentId: string;
904
+ channelId: string;
905
+ threadId?: string;
906
+ event: OpenBotEvent;
907
+ }): Promise<OpenBotState> => {
908
+ const { runId, agentId, channelId, threadId, event } = options;
909
+
910
+ let agentDetails: AgentDetails;
911
+ try {
912
+ agentDetails = await storageService.getAgentDetails({ agentId });
913
+ } catch (error) {
914
+ console.warn(`[storage] Failed to load agent details for agent: ${agentId}`, error);
915
+ throw error;
916
+ }
917
+
918
+ let channelDetails;
919
+ if (channelId && channelId !== 'general') {
920
+ try {
921
+ channelDetails = await storageService.getChannelDetails({ channelId });
922
+ } catch (error) {
923
+ console.warn(`[storage] Failed to load channel details for channel ${channelId}`, error);
924
+ }
925
+ }
926
+
927
+ let threadDetails;
928
+ if (channelId && threadId) {
929
+ try {
930
+ threadDetails = await storageService.getThreadDetails({ channelId, threadId });
931
+ } catch (error) {
932
+ console.warn(
933
+ `[storage] Failed to load thread details for channel ${channelId} thread: ${threadId}`,
934
+ error,
935
+ );
936
+ }
937
+ }
938
+
939
+ return {
940
+ runId,
941
+ agentId,
942
+ channelId,
943
+ threadId,
944
+ triggerEvent: event,
945
+ agentDetails: {
946
+ id: agentDetails.id,
947
+ name: agentDetails.name,
948
+ description: agentDetails.description || '',
949
+ instructions: agentDetails.instructions || '',
950
+ runtime: agentDetails.runtime,
951
+ plugins: agentDetails.plugins,
952
+ } as AgentDetails,
953
+ channelDetails: channelDetails as ChannelDetails,
954
+ threadDetails: threadDetails as ThreadDetails,
955
+ };
956
+ },
957
+ };