openbot 0.3.6 → 0.4.2

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 (104) hide show
  1. package/README.md +15 -16
  2. package/dist/app/agent-ids.js +4 -0
  3. package/dist/app/cli.js +1 -1
  4. package/dist/app/config.js +10 -19
  5. package/dist/app/server.js +208 -17
  6. package/dist/bus/services.js +34 -124
  7. package/dist/harness/agent-invoke-run.js +44 -0
  8. package/dist/harness/agent-turn.js +99 -0
  9. package/dist/harness/channel-participants.js +40 -0
  10. package/dist/harness/constants.js +2 -0
  11. package/dist/harness/context-meter.js +97 -0
  12. package/dist/harness/context.js +95 -47
  13. package/dist/harness/dispatch.js +144 -0
  14. package/dist/harness/dispatcher.js +45 -156
  15. package/dist/harness/history.js +177 -0
  16. package/dist/harness/index.js +109 -0
  17. package/dist/harness/orchestration.js +88 -0
  18. package/dist/harness/participants.js +22 -0
  19. package/dist/harness/run-harness.js +154 -0
  20. package/dist/harness/run.js +98 -0
  21. package/dist/harness/runtime-factory.js +0 -34
  22. package/dist/harness/runtime.js +57 -0
  23. package/dist/harness/todo-dispatch.js +51 -0
  24. package/dist/harness/todos.js +5 -0
  25. package/dist/harness/turn.js +79 -0
  26. package/dist/plugins/approval/index.js +120 -149
  27. package/dist/plugins/bash/index.js +195 -0
  28. package/dist/plugins/delegation/index.js +121 -32
  29. package/dist/plugins/memory/index.js +103 -14
  30. package/dist/plugins/memory/service.js +152 -0
  31. package/dist/plugins/openbot/context.js +125 -0
  32. package/dist/plugins/openbot/history.js +144 -0
  33. package/dist/plugins/openbot/index.js +71 -0
  34. package/dist/plugins/openbot/runtime.js +381 -0
  35. package/dist/plugins/openbot/system-prompt.js +25 -0
  36. package/dist/plugins/plugin-manager/index.js +189 -0
  37. package/dist/plugins/shell/index.js +2 -1
  38. package/dist/plugins/storage/files.js +67 -0
  39. package/dist/plugins/storage/index.js +750 -0
  40. package/dist/plugins/storage/service.js +1316 -0
  41. package/dist/plugins/storage-tools/index.js +2 -2
  42. package/dist/plugins/thread-namer/index.js +72 -0
  43. package/dist/plugins/thread-naming/generate-title.js +44 -0
  44. package/dist/plugins/thread-naming/index.js +103 -0
  45. package/dist/plugins/threads/index.js +114 -0
  46. package/dist/plugins/todo/index.js +24 -25
  47. package/dist/plugins/ui/index.js +109 -180
  48. package/dist/registry/plugins.js +3 -9
  49. package/dist/services/abort.js +43 -0
  50. package/dist/services/plugins/domain.js +1 -0
  51. package/dist/services/plugins/plugin-cache.js +9 -0
  52. package/dist/services/plugins/registry.js +112 -0
  53. package/dist/services/plugins/service.js +232 -0
  54. package/dist/services/plugins/types.js +1 -0
  55. package/dist/services/process.js +29 -0
  56. package/dist/services/storage.js +11 -10
  57. package/dist/services/thread-naming.js +81 -0
  58. package/docs/agents.md +15 -12
  59. package/docs/architecture.md +2 -2
  60. package/docs/plugins.md +29 -17
  61. package/docs/templates/AGENT.example.md +8 -14
  62. package/package.json +1 -2
  63. package/src/app/agent-ids.ts +5 -0
  64. package/src/app/cli.ts +1 -1
  65. package/src/app/config.ts +14 -31
  66. package/src/app/server.ts +243 -19
  67. package/src/app/types.ts +331 -187
  68. package/src/harness/index.ts +166 -0
  69. package/src/plugins/approval/index.ts +107 -188
  70. package/src/plugins/bash/index.ts +232 -0
  71. package/src/plugins/delegation/index.ts +139 -39
  72. package/src/plugins/memory/index.ts +112 -15
  73. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  74. package/src/plugins/openbot/context.ts +140 -0
  75. package/src/plugins/openbot/history.ts +158 -0
  76. package/src/plugins/openbot/index.ts +79 -0
  77. package/src/plugins/openbot/runtime.ts +478 -0
  78. package/src/plugins/openbot/system-prompt.ts +27 -0
  79. package/src/plugins/plugin-manager/index.ts +224 -0
  80. package/src/plugins/storage/files.ts +81 -0
  81. package/src/plugins/storage/index.ts +823 -0
  82. package/src/{services/storage.ts → plugins/storage/service.ts} +485 -105
  83. package/src/plugins/ui/index.ts +117 -221
  84. package/src/services/abort.ts +46 -0
  85. package/src/{bus/types.ts → services/plugins/domain.ts} +50 -8
  86. package/src/services/plugins/plugin-cache.ts +13 -0
  87. package/src/{registry/plugins.ts → services/plugins/registry.ts} +28 -28
  88. package/src/services/plugins/service.ts +318 -0
  89. package/src/{bus/plugin.ts → services/plugins/types.ts} +7 -3
  90. package/src/bus/services.ts +0 -954
  91. package/src/harness/context.ts +0 -365
  92. package/src/harness/dispatcher.ts +0 -379
  93. package/src/harness/mcp.ts +0 -78
  94. package/src/harness/runtime-factory.ts +0 -129
  95. package/src/harness/todo-advance.ts +0 -128
  96. package/src/plugins/ai-sdk/index.ts +0 -41
  97. package/src/plugins/ai-sdk/runtime.ts +0 -468
  98. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  99. package/src/plugins/mcp/index.ts +0 -128
  100. package/src/plugins/shell/index.ts +0 -123
  101. package/src/plugins/storage-tools/index.ts +0 -90
  102. package/src/plugins/todo/index.ts +0 -64
  103. package/src/services/plugins.ts +0 -133
  104. /package/src/{harness → services}/process.ts +0 -0
package/src/app/config.ts CHANGED
@@ -9,20 +9,13 @@ export interface OpenBotconfig {
9
9
  image?: string;
10
10
  baseDir?: string;
11
11
  port?: number;
12
- mcpServers?: MCPServerConfig[];
13
12
  /**
14
13
  * Overrides the default public marketplace registry URL. If omitted or blank,
15
14
  * {@link DEFAULT_MARKETPLACE_REGISTRY_URL} is used.
16
15
  */
17
16
  marketplaceRegistryUrl?: string;
18
- }
19
-
20
- export interface MCPServerConfig {
21
- id: string;
22
- command: string;
23
- args?: string[];
24
- env?: Record<string, string>;
25
- cwd?: string;
17
+ /** Public base URL for workspace file links (e.g. https://my-host.example). Falls back to OPENBOT_PUBLIC_URL env or http://localhost:{port}. */
18
+ publicUrl?: string;
26
19
  }
27
20
 
28
21
  export interface StoredVariable {
@@ -32,6 +25,8 @@ export interface StoredVariable {
32
25
  }
33
26
 
34
27
  export const DEFAULT_BASE_DIR = '~/.openbot';
28
+ /** Default parent directory for per-channel working directories (user-facing workspace). */
29
+ export const DEFAULT_CHANNELS_WORKSPACE_DIR = '~/openbot';
35
30
  export const DEFAULT_PLUGINS_DIR = 'plugins';
36
31
  export const DEFAULT_AGENTS_DIR = 'agents';
37
32
  export const DEFAULT_CHANNELS_DIR = 'channels';
@@ -46,6 +41,15 @@ export function resolvePath(p: string) {
46
41
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(p);
47
42
  }
48
43
 
44
+ /** Default absolute cwd for a channel when none is provided at creation time. */
45
+ export function getDefaultChannelCwd(channelId: string): string {
46
+ const id = channelId.trim();
47
+ if (!id) {
48
+ throw new Error('channelId is required');
49
+ }
50
+ return resolvePath(`${DEFAULT_CHANNELS_WORKSPACE_DIR}/${id}`);
51
+ }
52
+
49
53
  export function loadConfig(): OpenBotconfig {
50
54
  const configPath = path.join(os.homedir(), '.openbot', CONFIG_FILE);
51
55
  if (fs.existsSync(configPath)) {
@@ -90,25 +94,4 @@ export function loadVariables(): { version: number; variables: StoredVariable[]
90
94
  };
91
95
  }
92
96
  return { version: 1, variables: [] };
93
- }
94
-
95
- export const DEFAULT_AGENT_MD = `---
96
- description: A specialized AI agent
97
- ---
98
-
99
- # Agent Profile
100
-
101
- You are a specialized AI agent within the OpenBot system.
102
- Your role is defined by your configuration and the tools you have access to.
103
-
104
- ## Persona
105
- - Helpful and precise
106
- - Focused on my specific domain
107
- - Professional in all interactions
108
- `;
109
-
110
- export const DEFAULT_USER_MD = `# About Me
111
-
112
- <!-- OpenBot reads this file to understand who you are and how you like to work. -->
113
- <!-- Edit it here or just chat — agents can update it with the "remember" tool. -->
114
- `;
97
+ }
package/src/app/server.ts CHANGED
@@ -10,11 +10,17 @@ const pkg = require('../../package.json');
10
10
  import { generateId } from 'melony';
11
11
  import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
12
12
  import { ActiveRunsSnapshotEvent, OpenBotEvent, OpenBotState } from './types.js';
13
- import { processService } from '../harness/process.js';
14
- import { storageService } from '../services/storage.js';
15
- import { dispatch } from '../harness/dispatcher.js';
16
- import { initPlugins } from '../registry/plugins.js';
13
+ import { processService } from '../services/process.js';
14
+ import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
15
+ import { initPlugins } from '../services/plugins/registry.js';
16
+ import { storageService } from '../plugins/storage/service.js';
17
+ import {
18
+ buildWorkspaceFileUrl,
19
+ getPublicBaseUrl,
20
+ openChannelFileStream,
21
+ } from '../plugins/storage/files.js';
17
22
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
23
+ import { abortRegistry, abortKey } from '../services/abort.js';
18
24
 
19
25
  type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
20
26
 
@@ -54,6 +60,10 @@ export async function startServer(options: ServerOptions = {}) {
54
60
 
55
61
  initPlugins(pluginsDir);
56
62
 
63
+ // Pre-warm caches for agents and plugins to speed up first UI load
64
+ storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
65
+ storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
66
+
57
67
  const getContext = (req: express.Request) => {
58
68
  const channelId =
59
69
  req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
@@ -72,7 +82,7 @@ export async function startServer(options: ServerOptions = {}) {
72
82
  (req.body && req.body.responseType);
73
83
 
74
84
  return {
75
- channelId: (channelId || (threadId ? 'general' : 'general')) as string, // Default to general if none
85
+ channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')) as string, // Default to uncategorized if none
76
86
  threadId: threadId as string | undefined,
77
87
  agentId: agentId as string | undefined,
78
88
  runId: runId as string,
@@ -87,7 +97,16 @@ export async function startServer(options: ServerOptions = {}) {
87
97
 
88
98
  const sendToClientKey = (clientKey: string, chunk: OpenBotEvent) => {
89
99
  const threadClients = clients.get(clientKey);
90
- if (!threadClients) return;
100
+ if (!threadClients || threadClients.length === 0) return;
101
+
102
+ // Auto-detect "read" state: if someone is listening, they just "read" this event.
103
+ if (chunk.id && clientKey !== GLOBAL_CHANNEL_ID) {
104
+ const parts = clientKey.split(':');
105
+ const channelId = parts[0];
106
+ const threadId = parts[1]; // undefined if no ":"
107
+ storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => {});
108
+ }
109
+
91
110
  threadClients.forEach((client) => {
92
111
  if (!client.writableEnded) {
93
112
  client.write(`data: ${JSON.stringify(chunk)}\n\n`);
@@ -131,8 +150,37 @@ export async function startServer(options: ServerOptions = {}) {
131
150
  };
132
151
  };
133
152
 
153
+ // Drop every tracked run for a channel/thread. A stop aborts the whole
154
+ // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
155
+ // events can be swallowed when the parent run loop breaks on abort, leaving
156
+ // orphaned entries that keep a channel falsely "active". Purging by
157
+ // channel/thread guarantees the snapshot self-heals after a stop.
158
+ const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
159
+ const target = threadId || undefined;
160
+ for (const [key, run] of activeRuns) {
161
+ if (run.channelId === channelId && (run.threadId || undefined) === target) {
162
+ activeRuns.delete(key);
163
+ }
164
+ }
165
+ };
166
+
134
167
  app.use(cors());
135
- app.use(express.json({ limit: '20mb' }));
168
+
169
+ const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
170
+
171
+ app.use((req, res, next) => {
172
+ const isWorkspaceUpload =
173
+ req.method === 'POST' &&
174
+ req.path === '/api/publish' &&
175
+ req.get('x-openbot-event-type') === 'action:storage:upload-file';
176
+
177
+ if (isWorkspaceUpload) {
178
+ express.raw({ type: () => true, limit: '100mb' })(req, res, next);
179
+ return;
180
+ }
181
+
182
+ express.json({ limit: '20mb' })(req, res, next);
183
+ });
136
184
 
137
185
  app.get('/api/health', (req, res) => {
138
186
  res.json({ status: 'ok', version: pkg.version });
@@ -162,6 +210,19 @@ export async function startServer(options: ServerOptions = {}) {
162
210
  }
163
211
  clients.get(clientKey)!.push(res);
164
212
 
213
+ // Auto-detect "read" state on connection: mark the latest event as seen.
214
+ if (channelId !== GLOBAL_CHANNEL_ID) {
215
+ storageService
216
+ .getEvents({ channelId, threadId })
217
+ .then((events) => {
218
+ const latestId = events[events.length - 1]?.id;
219
+ if (latestId) {
220
+ return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
221
+ }
222
+ })
223
+ .catch(() => {});
224
+ }
225
+
165
226
  if (channelId === GLOBAL_CHANNEL_ID) {
166
227
  const snapshot = buildActiveRunsSnapshot();
167
228
 
@@ -193,6 +254,57 @@ export async function startServer(options: ServerOptions = {}) {
193
254
  });
194
255
 
195
256
  app.post('/api/publish', async (req, res) => {
257
+ if (req.get('x-openbot-event-type') === 'action:storage:upload-file') {
258
+ const channelId =
259
+ req.get('x-openbot-channel-id') ||
260
+ (typeof req.query.channelId === 'string' ? req.query.channelId : undefined);
261
+ const filePath = req.get('x-openbot-file-path');
262
+ const overwrite = req.get('x-openbot-file-overwrite') === 'true';
263
+
264
+ if (!channelId?.trim()) {
265
+ res.status(400).json({ error: 'channelId is required' });
266
+ return;
267
+ }
268
+ if (!filePath?.trim()) {
269
+ res.status(400).json({ error: 'x-openbot-file-path header is required' });
270
+ return;
271
+ }
272
+
273
+ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
274
+ if (body.length === 0) {
275
+ res.status(400).json({ error: 'Request body is empty' });
276
+ return;
277
+ }
278
+
279
+ try {
280
+ const result = await storageService.uploadChannelFile({
281
+ channelId: channelId.trim(),
282
+ path: filePath.trim(),
283
+ body,
284
+ overwrite,
285
+ });
286
+ const url = buildWorkspaceFileUrl({
287
+ baseUrl: resolvePublicBaseUrl(),
288
+ channelId: channelId.trim(),
289
+ filePath: result.path,
290
+ });
291
+ res.json({
292
+ type: 'action:storage:upload-file:result',
293
+ data: { success: true, ...result, url },
294
+ });
295
+ } catch (error) {
296
+ res.status(400).json({
297
+ type: 'action:storage:upload-file:result',
298
+ data: {
299
+ success: false,
300
+ path: filePath,
301
+ error: error instanceof Error ? error.message : 'Upload failed',
302
+ },
303
+ });
304
+ }
305
+ return;
306
+ }
307
+
196
308
  const parseResult = publishEventSchema.safeParse(req.body);
197
309
  if (!parseResult.success) {
198
310
  res.status(400).json({
@@ -211,9 +323,93 @@ export async function startServer(options: ServerOptions = {}) {
211
323
  return;
212
324
  }
213
325
 
214
- const onEvent = async (chunk: OpenBotEvent, state?: OpenBotState) => {
215
- ensureEventId(chunk);
326
+ if (event.type === 'action:storage:write-file') {
327
+ const data = (event.data ?? {}) as {
328
+ path?: string;
329
+ content?: string;
330
+ encoding?: 'utf8' | 'base64';
331
+ overwrite?: boolean;
332
+ };
333
+
334
+ if (!data.path?.trim()) {
335
+ res.status(400).json({
336
+ type: 'action:storage:write-file:result',
337
+ data: { success: false, path: '', error: 'path is required' },
338
+ });
339
+ return;
340
+ }
341
+ if (typeof data.content !== 'string') {
342
+ res.status(400).json({
343
+ type: 'action:storage:write-file:result',
344
+ data: { success: false, path: data.path, error: 'content is required' },
345
+ });
346
+ return;
347
+ }
348
+
349
+ try {
350
+ const result = await storageService.writeChannelFile({
351
+ channelId,
352
+ path: data.path.trim(),
353
+ content: data.content,
354
+ encoding: data.encoding ?? 'utf8',
355
+ overwrite: data.overwrite ?? false,
356
+ });
357
+ const url = buildWorkspaceFileUrl({
358
+ baseUrl: resolvePublicBaseUrl(),
359
+ channelId,
360
+ filePath: result.path,
361
+ });
362
+ res.json({
363
+ type: 'action:storage:write-file:result',
364
+ data: { success: true, ...result, url },
365
+ });
366
+ } catch (error) {
367
+ res.status(400).json({
368
+ type: 'action:storage:write-file:result',
369
+ data: {
370
+ success: false,
371
+ path: data.path,
372
+ error: error instanceof Error ? error.message : 'Write failed',
373
+ },
374
+ });
375
+ }
376
+ return;
377
+ }
378
+
379
+ // Stop request: cancel the in-flight run (and any delegated sub-agents in the
380
+ // same thread) instead of spinning up a new agent turn.
381
+ if (event.type === 'action:agent_run_stop') {
382
+ const data = (event.data ?? {}) as {
383
+ runId?: string;
384
+ agentId?: string;
385
+ channelId?: string;
386
+ threadId?: string;
387
+ reason?: string;
388
+ };
389
+ const targetChannelId = data.channelId || channelId;
390
+ const targetThreadId = data.threadId || threadId;
391
+ const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
392
+ purgeActiveRunsForThread(targetChannelId, targetThreadId);
393
+
394
+ const stoppedEvent: OpenBotEvent = {
395
+ type: 'agent:run:stopped',
396
+ data: {
397
+ runId: data.runId || runId,
398
+ agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
399
+ channelId: targetChannelId,
400
+ threadId: targetThreadId,
401
+ reason: data.reason,
402
+ },
403
+ } as OpenBotEvent;
404
+ ensureEventId(stoppedEvent);
405
+ sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
406
+ sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
407
+
408
+ res.json({ success: stopped });
409
+ return;
410
+ }
216
411
 
412
+ const onEvent = async (chunk: OpenBotEvent, state?: OpenBotState) => {
217
413
  const targetChannelId = state?.channelId || channelId;
218
414
  const targetThreadId = state?.threadId || threadId;
219
415
  const targetClientKey = getClientKey(targetChannelId, targetThreadId);
@@ -232,14 +428,10 @@ export async function startServer(options: ServerOptions = {}) {
232
428
  activeRuns.delete(
233
429
  getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId),
234
430
  );
431
+ } else if (chunk.type === 'agent:run:stopped') {
432
+ purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
235
433
  }
236
434
 
237
- await storageService.storeEvent({
238
- channelId: targetChannelId,
239
- threadId: targetThreadId,
240
- event: chunk,
241
- });
242
-
243
435
  sendToClientKey(targetClientKey, chunk);
244
436
 
245
437
  if (
@@ -254,12 +446,13 @@ export async function startServer(options: ServerOptions = {}) {
254
446
  try {
255
447
  ensureEventId(event);
256
448
 
257
- await dispatch({
449
+ await runAgent({
258
450
  runId,
259
- agentId: agentId || 'system',
451
+ agentId: agentId || ORCHESTRATOR_AGENT_ID,
260
452
  event,
261
453
  channelId,
262
454
  threadId,
455
+ publicBaseUrl: resolvePublicBaseUrl(),
263
456
  onEvent,
264
457
  });
265
458
  res.sendStatus(200);
@@ -286,6 +479,35 @@ export async function startServer(options: ServerOptions = {}) {
286
479
  }
287
480
 
288
481
  const { channelId, threadId, agentId, runId } = getContext(req);
482
+
483
+ if (event.type === 'action:storage:serve-file') {
484
+ const filePath = (event.data as { path?: string })?.path;
485
+ if (!channelId?.trim()) {
486
+ res.status(400).json({ error: 'channelId is required' });
487
+ return;
488
+ }
489
+ if (!filePath?.trim()) {
490
+ res.status(400).json({ error: 'path is required' });
491
+ return;
492
+ }
493
+
494
+ try {
495
+ const { abs, size, mimeType } = await storageService.getChannelFileStat({
496
+ channelId,
497
+ path: filePath.trim(),
498
+ });
499
+ res.setHeader('Content-Type', mimeType);
500
+ res.setHeader('Content-Length', String(size));
501
+ res.setHeader('Cache-Control', 'private, max-age=3600');
502
+ openChannelFileStream(abs).pipe(res);
503
+ } catch (error) {
504
+ res.status(404).json({
505
+ error: error instanceof Error ? error.message : 'File not found',
506
+ });
507
+ }
508
+ return;
509
+ }
510
+
289
511
  const events: OpenBotEvent[] = [];
290
512
 
291
513
  const onEvent = async (chunk: OpenBotEvent) => {
@@ -295,12 +517,14 @@ export async function startServer(options: ServerOptions = {}) {
295
517
  try {
296
518
  ensureEventId(event);
297
519
 
298
- await dispatch({
520
+ await runAgent({
299
521
  runId,
300
- agentId: agentId || 'system',
522
+ agentId: agentId || STATE_AGENT_ID,
301
523
  event,
302
524
  channelId,
303
525
  threadId,
526
+ persistEvents: false,
527
+ publicBaseUrl: resolvePublicBaseUrl(),
304
528
  onEvent,
305
529
  });
306
530
  res.json({ events });