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
@@ -0,0 +1,381 @@
1
+ import { generateText } from 'ai';
2
+ import { openai } from '@ai-sdk/openai';
3
+ import { anthropic } from '@ai-sdk/anthropic';
4
+ import { eventsToModelMessages } from './history.js';
5
+ import { buildContext, } from './context.js';
6
+ import { saveConfig } from '../../app/config.js';
7
+ import { API_KEY_SETUP_MESSAGE, OPENBOT_SYSTEM_PROMPT } from './system-prompt.js';
8
+ function resolveModel(modelString) {
9
+ const [provider, ...rest] = modelString.split('/');
10
+ const modelId = rest.join('/');
11
+ if (!modelId) {
12
+ throw new Error(`Invalid model string: "${modelString}". Expected "provider/model-id".`);
13
+ }
14
+ switch (provider) {
15
+ case 'openai':
16
+ return openai(modelId);
17
+ case 'anthropic':
18
+ return anthropic(modelId);
19
+ default:
20
+ throw new Error(`Unsupported AI provider: "${provider}"`);
21
+ }
22
+ }
23
+ async function buildSystemPrompt(state, storage) {
24
+ const context = await buildContext(state, storage);
25
+ const sections = [OPENBOT_SYSTEM_PROMPT, '', context];
26
+ // Hardcoded naming hint logic
27
+ const threadState = state.threadDetails?.state;
28
+ if (!threadState?.isSmartNamed) {
29
+ sections.push('', '## SYSTEM HINT', 'This thread is unnamed. Please use the `patch_thread_details` tool to set a concise, descriptive, and regular `name` (e.g., "Project Brainstorming" instead of "project-brainstorm") in the thread state and set `isSmartNamed: true` in the same patch. Only do this once.');
30
+ }
31
+ return sections.join('\n');
32
+ }
33
+ /**
34
+ * Tracks tool-call IDs from one LLM turn until matching `:result` events arrive.
35
+ *
36
+ * Melony runs yielded `action:*` events depth-first, so parallel tool calls from
37
+ * a single `generateText` response execute one-by-one. We must wait for every ID
38
+ * in the batch before calling the LLM again — not after the first result.
39
+ */
40
+ function createToolBatchTracker(state, storage, channelId, threadId) {
41
+ const save = async (ids) => {
42
+ if (!storage || !channelId || !threadId)
43
+ return;
44
+ try {
45
+ await storage.patchThreadState({
46
+ channelId,
47
+ threadId,
48
+ state: { pendingToolCallIds: ids },
49
+ });
50
+ }
51
+ catch (error) {
52
+ console.error('[openbot] Failed to persist pendingToolCallIds:', error);
53
+ }
54
+ };
55
+ return {
56
+ async startBatch(toolCallIds) {
57
+ state.pendingToolCallIds = [...toolCallIds];
58
+ await save(state.pendingToolCallIds);
59
+ },
60
+ async clear() {
61
+ state.pendingToolCallIds = undefined;
62
+ await save(undefined);
63
+ },
64
+ /** Returns true when this result completes the batch (time to call the LLM again). */
65
+ async recordResult(toolCallId) {
66
+ if (!state.pendingToolCallIds?.includes(toolCallId))
67
+ return false;
68
+ state.pendingToolCallIds = state.pendingToolCallIds.filter((id) => id !== toolCallId);
69
+ const done = state.pendingToolCallIds.length === 0;
70
+ if (done) {
71
+ state.pendingToolCallIds = undefined;
72
+ }
73
+ await save(state.pendingToolCallIds);
74
+ return done;
75
+ },
76
+ };
77
+ }
78
+ /**
79
+ * OpenBot agent runtime.
80
+ *
81
+ * - One `generateText` call per `runLLM` (tools have no `execute`; SDK stops at 1 step).
82
+ * - Tool calls become `action:*` events; plugins emit `:result` when done.
83
+ * - When a full batch of results is in, `runLLM` runs again with updated history.
84
+ */
85
+ export const openbotRuntime = (options) => (builder) => {
86
+ const { model: modelString = 'openai/gpt-4o-mini', storage, toolDefinitions = {}, abortSignal, } = options;
87
+ let currentModelString = modelString;
88
+ let model = resolveModel(currentModelString);
89
+ const runLLM = async function* (context, threadId, trigger) {
90
+ if (!storage)
91
+ return;
92
+ if (abortSignal?.aborted)
93
+ return;
94
+ const toolBatch = createToolBatchTracker(context.state, storage, context.state.channelId, threadId || context.state.threadId);
95
+ // Capture parent metadata for event enrichment
96
+ const triggerEvent = trigger || context.state.triggerEvent;
97
+ const parentAgentId = triggerEvent?.meta?.parentAgentId;
98
+ const parentToolCallId = triggerEvent?.meta?.parentToolCallId;
99
+ context.state.model = currentModelString;
100
+ const systemPrompt = await buildSystemPrompt(context.state, storage);
101
+ const events = await storage.getEvents({
102
+ channelId: context.state.channelId,
103
+ threadId: context.state.threadId,
104
+ });
105
+ const messages = eventsToModelMessages(events);
106
+ // console.log('systemPrompt:::::::\n', systemPrompt);
107
+ // console.log('messages:::::::\n', JSON.stringify(messages));
108
+ // console.log('toolDefinitions:::::::\n', JSON.stringify(toolDefinitions));
109
+ try {
110
+ // Single LLM request — tool execution happens externally via action:* handlers.
111
+ const result = await generateText({
112
+ model,
113
+ system: systemPrompt,
114
+ messages,
115
+ tools: toolDefinitions,
116
+ stopWhen: ({ steps }) => steps.length === 1,
117
+ allowSystemInMessages: true,
118
+ abortSignal,
119
+ });
120
+ const toolCalls = result.toolCalls ?? [];
121
+ // if (result.usage) {
122
+ // const usage = result.usage;
123
+ // yield {
124
+ // type: 'agent:usage',
125
+ // data: {
126
+ // usage: {
127
+ // promptTokens: usage.inputTokens,
128
+ // completionTokens: usage.outputTokens,
129
+ // totalTokens: usage.totalTokens,
130
+ // currentContextTokens: usage.inputTokens,
131
+ // contextBudget: getContextBudgetForModel(currentModelString),
132
+ // },
133
+ // model: currentModelString,
134
+ // },
135
+ // meta: {
136
+ // agentId: context.state.agentId,
137
+ // threadId,
138
+ // runId: context.state.runId,
139
+ // },
140
+ // } as OpenBotEvent;
141
+ // }
142
+ const outputMeta = {
143
+ agentId: context.state.agentId,
144
+ threadId,
145
+ parentAgentId,
146
+ parentToolCallId,
147
+ };
148
+ // Text before actions so history/UI show the model's intent first.
149
+ if (result.text) {
150
+ yield {
151
+ type: 'agent:output',
152
+ data: { content: result.text },
153
+ meta: outputMeta,
154
+ };
155
+ }
156
+ if (toolCalls.length > 0) {
157
+ // when multiple tool calls are made, Melony runtime handles them one by one, thats why we need to start a new batch
158
+ await toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
159
+ for (const toolCall of toolCalls) {
160
+ yield {
161
+ type: `action:${toolCall.toolName}`,
162
+ data: toolCall.input,
163
+ meta: {
164
+ toolCallId: toolCall.toolCallId,
165
+ ...outputMeta,
166
+ },
167
+ };
168
+ }
169
+ }
170
+ else {
171
+ // clear the tool batch if there are no tool calls
172
+ await toolBatch.clear();
173
+ }
174
+ }
175
+ catch (error) {
176
+ // Run was stopped — unwind quietly without surfacing an error.
177
+ if (abortSignal?.aborted)
178
+ return;
179
+ const errorMessage = error instanceof Error ? error.message : String(error);
180
+ const isApiKeyError = errorMessage.includes('API key') ||
181
+ errorMessage.includes('401') ||
182
+ errorMessage.includes('Unauthorized') ||
183
+ errorMessage.includes('authentication');
184
+ if (isApiKeyError) {
185
+ const [currentProvider, ...rest] = currentModelString.split('/');
186
+ const currentModelId = rest.join('/');
187
+ yield {
188
+ type: 'client:ui:widget',
189
+ data: {
190
+ kind: 'form',
191
+ widgetId: `api_key_request_${Date.now()}`,
192
+ title: `AI Provider API Key Required`,
193
+ description: API_KEY_SETUP_MESSAGE,
194
+ fields: [
195
+ {
196
+ id: 'provider',
197
+ label: 'Provider',
198
+ type: 'select',
199
+ required: true,
200
+ options: [
201
+ { label: 'OpenAI', value: 'openai' },
202
+ { label: 'Anthropic', value: 'anthropic' },
203
+ ],
204
+ defaultValue: currentProvider === 'anthropic' ? 'anthropic' : 'openai',
205
+ },
206
+ {
207
+ id: 'model',
208
+ label: 'Model',
209
+ type: 'text',
210
+ description: 'Model name without the provider prefix (e.g. `gpt-4o-mini` or `claude-3-5-sonnet-20240620`).',
211
+ placeholder: 'gpt-4o-mini',
212
+ required: true,
213
+ defaultValue: currentModelId,
214
+ },
215
+ {
216
+ id: 'apiKey',
217
+ label: 'API Key',
218
+ type: 'text',
219
+ placeholder: `sk-...`,
220
+ required: true,
221
+ },
222
+ ],
223
+ submitLabel: 'Save & Continue',
224
+ metadata: {
225
+ type: 'api_key_request',
226
+ },
227
+ },
228
+ meta: { agentId: context.state.agentId, threadId },
229
+ };
230
+ return;
231
+ }
232
+ throw error;
233
+ }
234
+ };
235
+ builder.on('agent:invoke', async function* (event, context) {
236
+ const routedTo = event.data?.agentId;
237
+ if (typeof routedTo === 'string' && routedTo && routedTo !== context.state.agentId) {
238
+ return;
239
+ }
240
+ // Capture user info from meta if available
241
+ if (event.meta?.userName) {
242
+ context.state.currentUser = {
243
+ userName: event.meta.userName,
244
+ };
245
+ }
246
+ const threadId = event.meta?.threadId || context.state.threadId;
247
+ // Auto-add participants if tagged in the prompt
248
+ const content = event.data?.content;
249
+ if (content && storage) {
250
+ try {
251
+ const allAgents = await storage.getAgents();
252
+ const tags = content.match(/@([\w-]+)/g);
253
+ if (tags) {
254
+ const taggedAgentIds = tags.map((t) => t.slice(1));
255
+ const validAgentIds = taggedAgentIds.filter((id) => allAgents.some((a) => a.id === id));
256
+ const currentParticipants = context.state.channelDetails?.participants || [];
257
+ const newParticipants = [...new Set([...currentParticipants, ...validAgentIds])];
258
+ if (newParticipants.length > currentParticipants.length) {
259
+ // Update storage
260
+ await storage.patchChannelState({
261
+ channelId: context.state.channelId,
262
+ state: { participants: newParticipants },
263
+ });
264
+ // Refresh local state
265
+ context.state.channelDetails = await storage.getChannelDetails({
266
+ channelId: context.state.channelId,
267
+ });
268
+ // Notify UI/others about the change
269
+ yield {
270
+ type: 'action:patch_channel_details:result',
271
+ data: { success: true, updatedFields: ['participants'] },
272
+ meta: {
273
+ agentId: context.state.agentId,
274
+ threadId,
275
+ },
276
+ };
277
+ }
278
+ }
279
+ }
280
+ catch (error) {
281
+ console.warn('[openbot] Failed to auto-add participants from tags:', error);
282
+ }
283
+ }
284
+ // clear the tool batch if the agent is invoked
285
+ // this is to prevent the tool batch from being used for a new agent invocation
286
+ await createToolBatchTracker(context.state, storage, context.state.channelId, threadId).clear();
287
+ yield* runLLM(context, threadId, event);
288
+ });
289
+ // this is to handle the tool results from the tool calls
290
+ // because Melony runtime handles them one by one, thats why we need to record the result
291
+ builder.on('*', async function* (event, context) {
292
+ if (!event.type.endsWith(':result'))
293
+ return;
294
+ if (event.meta?.agentId !== context.state.agentId)
295
+ return;
296
+ const toolCallId = event.meta?.toolCallId;
297
+ // record the result of the tool call
298
+ if (!toolCallId ||
299
+ !(await createToolBatchTracker(context.state, storage, context.state.channelId, event.meta?.threadId || context.state.threadId).recordResult(toolCallId)))
300
+ return;
301
+ const threadId = event.meta?.threadId || context.state.threadId;
302
+ yield* runLLM(context, threadId);
303
+ });
304
+ builder.on('client:ui:widget:response', async function* (event, context) {
305
+ const { metadata, values } = event.data;
306
+ if (metadata?.type !== 'api_key_request')
307
+ return;
308
+ if (!values?.apiKey || !values?.provider || !values?.model)
309
+ return;
310
+ const provider = String(values.provider);
311
+ const modelId = String(values.model).trim();
312
+ const apiKey = String(values.apiKey);
313
+ if (provider !== 'openai' && provider !== 'anthropic') {
314
+ yield {
315
+ type: 'agent:output',
316
+ data: { content: `Unsupported provider: ${provider}` },
317
+ meta: { agentId: context.state.agentId },
318
+ };
319
+ return;
320
+ }
321
+ const envVar = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
322
+ const newModelString = `${provider}/${modelId}`;
323
+ if (!storage)
324
+ return;
325
+ try {
326
+ await storage.createVariable({ key: envVar, value: apiKey, secret: true });
327
+ process.env[envVar] = apiKey;
328
+ currentModelString = newModelString;
329
+ model = resolveModel(currentModelString);
330
+ try {
331
+ saveConfig({ model: currentModelString });
332
+ // Also update the agent's AGENT.md if it has an openbot plugin config
333
+ const details = await storage.getAgentDetails({ agentId: context.state.agentId });
334
+ const updatedPlugins = details.pluginRefs.map((ref) => {
335
+ if (ref.id === 'openbot') {
336
+ return {
337
+ ...ref,
338
+ config: { ...ref.config, model: currentModelString },
339
+ };
340
+ }
341
+ return ref;
342
+ });
343
+ await storage.updateAgent({
344
+ agentId: context.state.agentId,
345
+ plugins: updatedPlugins,
346
+ });
347
+ }
348
+ catch {
349
+ // best-effort: config persistence failure shouldn't block the conversation
350
+ }
351
+ yield {
352
+ type: 'agent:output',
353
+ data: {
354
+ content: `Saved ${provider} API key and set model to \`${newModelString}\`.`,
355
+ },
356
+ meta: { agentId: context.state.agentId },
357
+ };
358
+ yield {
359
+ type: 'client:ui:widget',
360
+ data: {
361
+ widgetId: event.data.widgetId,
362
+ kind: 'message',
363
+ title: 'API Key Saved',
364
+ body: `Successfully saved ${provider} API key and selected model \`${newModelString}\`. You can now continue your conversation.`,
365
+ state: 'submitted',
366
+ actions: [{ id: 'ok', label: 'Got it', variant: 'primary' }],
367
+ },
368
+ meta: { agentId: context.state.agentId },
369
+ };
370
+ }
371
+ catch (error) {
372
+ yield {
373
+ type: 'agent:output',
374
+ data: {
375
+ content: `Failed to save API key: ${error instanceof Error ? error.message : 'Unknown error'}`,
376
+ },
377
+ meta: { agentId: context.state.agentId },
378
+ };
379
+ }
380
+ });
381
+ };
@@ -0,0 +1,25 @@
1
+ export const OPENBOT_SYSTEM_PROMPT = [
2
+ '# ROLE',
3
+ 'You are an OpenBot, the main coordinator and router agent. Your primary role is to orchestrate specialized agents to help the human achieve their goals.',
4
+ '',
5
+ '# SECURITY POLICY',
6
+ '- **CRITICAL**: Never request API keys, passwords, or sensitive credentials via text or UI widgets; these are managed deterministically via secure forms and must never enter your context.',
7
+ '- **Credential Guidance**: If an agent or tool requires credentials, inform the user they can be managed under "Settings > Variables".',
8
+ '',
9
+ '# CORE MISSION',
10
+ 'You almost never execute tasks yourself. Instead, you delegate tasks to specialized agents (channel participants). You act as a high-level manager, ensuring the right agent is working on the right task.',
11
+ '',
12
+ '# OPERATIONAL GUIDELINES',
13
+ '- **Channel and Threads**: The main and only way to communicate and act is through channels and threads. There might be a channel called "uncategorized" for general purpose communication.',
14
+ '- **Agent Participation**: ONLY add an agent via `patch_channel_details` if the user manually tags them (e.g., `@name`) AND they are missing from the `Participants` list in `ENVIRONMENT`.',
15
+ '- **Delegation**: NEVER delegate to an agent who is not a participant. Only if existing participants clearly cannot handle a task should you suggest relevant agents from the `INSTALLED AGENTS` list.',
16
+ '- **Bash Tool Usage**: You should use the `bash` tool very rarely. Only use it when the user explicitly requests a command to be run or when it is absolutely necessary for a task that no other participant can handle.',
17
+ '- **Context Awareness**: Use the provided ENVIRONMENT, CHANNEL SPECIFICATION, and MEMORIES to maintain continuity. Do not ask for information already present in these sections.',
18
+ '- **Durable Memory**: Use the `remember` tool to store important facts, preferences, or project details that should persist across sessions.',
19
+ '- **Structured Interaction**: Use the `render_widget` tool to collect information via forms, offer choices, or display lists. This is preferred over asking multiple separate questions in plain text.',
20
+ '',
21
+ '# COMMUNICATION STYLE',
22
+ '- Be always concise, professional, and proactive.',
23
+ ].join('\n');
24
+ /** Shown in the API key setup form when no provider credentials are configured. */
25
+ export const API_KEY_SETUP_MESSAGE = 'OpenBot runs AI agents locally with tools, memory, and delegation. Bring your own OpenAI or Anthropic key — it stays on your machine. Use the form below to get started.';
@@ -0,0 +1,189 @@
1
+ import { STATE_AGENT_ID } from '../../app/agent-ids.js';
2
+ import { pluginService, resolveMarketplaceRegistry, } from '../../services/plugins/service.js';
3
+ /**
4
+ * `plugin-manager` — marketplace listing, npm plugin install/uninstall, and
5
+ * installing agents from the registry. Wired on the **`state`** built-in agent
6
+ * via its default `pluginRefs`.
7
+ *
8
+ * Handlers register only when `agentId === state` so attaching this plugin to
9
+ * other agents via AGENT.md does not widen infra privileges.
10
+ */
11
+ export const pluginManagerPlugin = {
12
+ id: 'plugin-manager',
13
+ name: 'Plugin manager',
14
+ description: 'Marketplace listings, npm-based plugin lifecycle, and agent installs from marketplace metadata.',
15
+ factory: ({ agentId, storage }) => {
16
+ if (agentId !== STATE_AGENT_ID) {
17
+ return () => { };
18
+ }
19
+ return (builder) => {
20
+ builder.on('action:plugin:install', async function* (event) {
21
+ try {
22
+ const { name, version } = event.data;
23
+ const result = await pluginService.install({ packageName: name, version });
24
+ yield {
25
+ type: 'action:plugin:install:result',
26
+ data: { success: true, plugin: result },
27
+ };
28
+ }
29
+ catch (error) {
30
+ yield {
31
+ type: 'action:plugin:install:result',
32
+ data: { success: false, error: error.message },
33
+ };
34
+ }
35
+ });
36
+ builder.on('action:plugin:uninstall', async function* (event) {
37
+ try {
38
+ await pluginService.uninstall(event.data.id);
39
+ yield { type: 'action:plugin:uninstall:result', data: { success: true } };
40
+ }
41
+ catch (error) {
42
+ yield {
43
+ type: 'action:plugin:uninstall:result',
44
+ data: { success: false, error: error.message },
45
+ };
46
+ }
47
+ });
48
+ builder.on('action:marketplace:list', async function* () {
49
+ const { agents, channels } = await resolveMarketplaceRegistry();
50
+ yield {
51
+ type: 'action:marketplace:list:result',
52
+ data: { success: true, agents, channels },
53
+ };
54
+ });
55
+ builder.on('action:channel:install', async function* (event) {
56
+ try {
57
+ const { channelId: instanceId, name: templateName, participants: customParticipants, initialState: customInitialState, } = event.data;
58
+ const { agents: marketplaceAgents, channels } = await resolveMarketplaceRegistry();
59
+ // Try to find the template by ID or Name
60
+ const channelListing = channels.find((c) => c.id === instanceId) ||
61
+ channels.find((c) => c.name === templateName);
62
+ const channelId = instanceId;
63
+ const participants = customParticipants || channelListing?.participants || [];
64
+ const initialState = {
65
+ ...(channelListing?.initialState || {}),
66
+ ...(customInitialState || {}),
67
+ };
68
+ const spec = channelListing?.spec || '';
69
+ // 1. Auto-install participant agents if missing
70
+ for (const agentId of participants) {
71
+ const existingAgents = await storage.getAgents();
72
+ if (existingAgents.some((a) => a.id === agentId)) {
73
+ continue;
74
+ }
75
+ // Not found locally, look in marketplace
76
+ const agentListing = marketplaceAgents.find((a) => a.id === agentId);
77
+ if (agentListing) {
78
+ console.log(`[plugin-manager] Auto-installing agent ${agentId} for channel ${channelId}`);
79
+ // Install plugins for this agent
80
+ for (const ref of agentListing.plugins) {
81
+ const installed = await pluginService.isInstalled(ref.id);
82
+ if (!installed &&
83
+ ref.id.includes('/') === false &&
84
+ ref.id.includes('-plugin-') === false) {
85
+ continue;
86
+ }
87
+ if (!installed) {
88
+ try {
89
+ await pluginService.install({ packageName: ref.id });
90
+ }
91
+ catch (err) {
92
+ console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
93
+ }
94
+ }
95
+ }
96
+ // Create the agent
97
+ await storage.createAgent({
98
+ agentId: agentListing.id,
99
+ name: agentListing.name,
100
+ description: agentListing.description,
101
+ image: agentListing.image,
102
+ instructions: agentListing.instructions,
103
+ plugins: agentListing.plugins,
104
+ });
105
+ }
106
+ }
107
+ // 2. Create the channel
108
+ await storage.createChannel({
109
+ channelId,
110
+ spec,
111
+ initialState: {
112
+ ...initialState,
113
+ participants,
114
+ },
115
+ });
116
+ const channelUrl = `/channels/${channelId}`;
117
+ yield {
118
+ type: 'action:channel:install:result',
119
+ data: { success: true, channelId, channelUrl },
120
+ };
121
+ yield {
122
+ type: 'agent:output',
123
+ data: {
124
+ content: `Successfully installed channel **${channelListing?.name || templateName || channelId}** and created channel \`${channelId}\`.`,
125
+ },
126
+ meta: { agentId: 'system' },
127
+ };
128
+ }
129
+ catch (error) {
130
+ yield {
131
+ type: 'action:channel:install:result',
132
+ data: {
133
+ success: false,
134
+ error: error instanceof Error ? error.message : 'Unknown error',
135
+ },
136
+ };
137
+ }
138
+ });
139
+ builder.on('action:agent:install', async function* (event) {
140
+ try {
141
+ const { agentId: newAgentId, name, description, image, instructions, plugins, } = event.data;
142
+ for (const ref of plugins) {
143
+ const installed = await pluginService.isInstalled(ref.id);
144
+ if (!installed && ref.id.includes('/') === false && ref.id.includes('-plugin-') === false) {
145
+ continue;
146
+ }
147
+ if (!installed) {
148
+ try {
149
+ await pluginService.install({ packageName: ref.id });
150
+ }
151
+ catch (err) {
152
+ console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
153
+ }
154
+ }
155
+ }
156
+ await storage.createAgent({
157
+ agentId: newAgentId,
158
+ name,
159
+ description,
160
+ image,
161
+ instructions,
162
+ plugins,
163
+ });
164
+ yield {
165
+ type: 'action:agent:install:result',
166
+ data: { success: true, agentId: newAgentId },
167
+ };
168
+ yield {
169
+ type: 'agent:output',
170
+ data: {
171
+ content: `Successfully installed agent **${name}** (${newAgentId}) from marketplace.`,
172
+ },
173
+ meta: { agentId: 'system' },
174
+ };
175
+ }
176
+ catch (error) {
177
+ yield {
178
+ type: 'action:agent:install:result',
179
+ data: {
180
+ success: false,
181
+ agentId: event.data.agentId,
182
+ error: error instanceof Error ? error.message : 'Unknown error',
183
+ },
184
+ };
185
+ }
186
+ });
187
+ };
188
+ },
189
+ };
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { spawn } from 'node:child_process';
3
+ import { resolvePath } from '../../app/config.js';
3
4
  const shellToolDefinitions = {
4
5
  shell_exec: {
5
6
  description: 'Execute a shell command in the terminal. Use this for file operations, running scripts, or system tasks.',
@@ -22,7 +23,7 @@ const shellPluginRuntime = () => (builder) => {
22
23
  builder.on('action:shell_exec', async function* (event, context) {
23
24
  const { command, cwd, shell = 'bash', timeoutMs = 30000 } = event.data;
24
25
  const actualTimeout = Math.max(1000, Math.min(timeoutMs, 60000));
25
- const actualCwd = cwd || context.state.channelDetails?.cwd || process.cwd();
26
+ const actualCwd = resolvePath(cwd || context.state.channelDetails?.cwd || process.cwd());
26
27
  try {
27
28
  const result = await new Promise((resolve) => {
28
29
  const child = spawn(command, {
@@ -0,0 +1,67 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { resolvePath } from '../../app/config.js';
5
+ const MIME_BY_EXT = {
6
+ '.png': 'image/png',
7
+ '.jpg': 'image/jpeg',
8
+ '.jpeg': 'image/jpeg',
9
+ '.gif': 'image/gif',
10
+ '.webp': 'image/webp',
11
+ '.svg': 'image/svg+xml',
12
+ '.ico': 'image/x-icon',
13
+ '.mp4': 'video/mp4',
14
+ '.webm': 'video/webm',
15
+ '.mov': 'video/quicktime',
16
+ '.mp3': 'audio/mpeg',
17
+ '.wav': 'audio/wav',
18
+ '.ogg': 'audio/ogg',
19
+ '.pdf': 'application/pdf',
20
+ '.json': 'application/json',
21
+ '.txt': 'text/plain',
22
+ '.html': 'text/html',
23
+ '.css': 'text/css',
24
+ '.js': 'text/javascript',
25
+ '.zip': 'application/zip',
26
+ };
27
+ export function guessMimeType(filePath) {
28
+ return MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
29
+ }
30
+ /** Resolve a relative path under a channel cwd; rejects directory escape. */
31
+ export function resolveChannelFile(baseCwd, relativePath) {
32
+ const resolvedBase = resolvePath(baseCwd);
33
+ const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
34
+ const target = path.resolve(resolvedBase, normalized);
35
+ if (target !== resolvedBase && !target.startsWith(resolvedBase + path.sep)) {
36
+ throw new Error('Access denied: directory escape');
37
+ }
38
+ return target;
39
+ }
40
+ export async function statChannelFile(baseCwd, relativePath) {
41
+ const abs = resolveChannelFile(baseCwd, relativePath);
42
+ const stat = await fs.stat(abs);
43
+ if (!stat.isFile()) {
44
+ throw new Error('Not a file');
45
+ }
46
+ return { abs, size: stat.size };
47
+ }
48
+ export function openChannelFileStream(abs) {
49
+ return createReadStream(abs);
50
+ }
51
+ export function buildWorkspaceFileUrl(args) {
52
+ const base = args.baseUrl.replace(/\/$/, '');
53
+ const data = encodeURIComponent(JSON.stringify({ path: args.filePath }));
54
+ const channelId = encodeURIComponent(args.channelId);
55
+ return `${base}/api/state?channelId=${channelId}&type=${encodeURIComponent('action:storage:serve-file')}&data=${data}`;
56
+ }
57
+ export function getPublicBaseUrl(port, configPublicUrl) {
58
+ const fromConfig = configPublicUrl?.trim();
59
+ if (fromConfig) {
60
+ return fromConfig.replace(/\/$/, '');
61
+ }
62
+ const fromEnv = process.env.OPENBOT_PUBLIC_URL?.trim();
63
+ if (fromEnv) {
64
+ return fromEnv.replace(/\/$/, '');
65
+ }
66
+ return `http://localhost:${port}`;
67
+ }