openbot 0.4.5 → 0.4.7

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.
@@ -2,6 +2,7 @@ import { MelonyPlugin, RuntimeContext } from 'melony';
2
2
  import { generateText, type LanguageModel } from 'ai';
3
3
  import { openai } from '@ai-sdk/openai';
4
4
  import { anthropic } from '@ai-sdk/anthropic';
5
+ import { google } from '@ai-sdk/google';
5
6
  import { OpenBotEvent, OpenBotState, AgentInvokeEvent } from '../../app/types.js';
6
7
  import { eventsToModelMessages } from './history.js';
7
8
  import { Storage } from '../../services/plugins/domain.js';
@@ -10,8 +11,33 @@ import {
10
11
  ORCHESTRATOR_AGENT_ID,
11
12
  buildContext,
12
13
  } from './context.js';
13
- import { saveConfig } from '../../app/config.js';
14
- import { API_KEY_SETUP_MESSAGE, OPENBOT_SYSTEM_PROMPT } from './system-prompt.js';
14
+ import { saveConfig, DEFAULT_MARKETPLACE_REGISTRY_URL } from '../../app/config.js';
15
+ import { OPENBOT_SYSTEM_PROMPT } from './system-prompt.js';
16
+
17
+ interface ModelRegistry {
18
+ providers: Record<
19
+ string,
20
+ {
21
+ label: string;
22
+ models: Array<{ id: string; label: string; description: string }>;
23
+ }
24
+ >;
25
+ }
26
+
27
+ let cachedRegistry: ModelRegistry | null = null;
28
+
29
+ async function fetchRegistry(): Promise<ModelRegistry | null> {
30
+ if (cachedRegistry) return cachedRegistry;
31
+ try {
32
+ const response = await fetch(DEFAULT_MARKETPLACE_REGISTRY_URL);
33
+ if (!response.ok) throw new Error(`Failed to fetch registry: ${response.statusText}`);
34
+ cachedRegistry = (await response.json()) as ModelRegistry;
35
+ return cachedRegistry;
36
+ } catch (error) {
37
+ console.error('[openbot] Failed to fetch model registry:', error);
38
+ return null;
39
+ }
40
+ }
15
41
 
16
42
  export interface OpenBotRuntimeOptions {
17
43
  /** Provider model string (e.g. `openai/gpt-4o-mini`, `anthropic/claude-3-5-sonnet-20240620`). */
@@ -34,6 +60,8 @@ function resolveModel(modelString: string): LanguageModel {
34
60
  return openai(modelId);
35
61
  case 'anthropic':
36
62
  return anthropic(modelId);
63
+ case 'google':
64
+ return google(modelId);
37
65
  default:
38
66
  throw new Error(`Unsupported AI provider: "${provider}"`);
39
67
  }
@@ -246,49 +274,20 @@ export const openbotRuntime =
246
274
  errorMessage.includes('authentication');
247
275
 
248
276
  if (isApiKeyError) {
249
- const [currentProvider, ...rest] = currentModelString.split('/');
250
- const currentModelId = rest.join('/');
251
-
252
277
  yield {
253
278
  type: 'client:ui:widget',
254
279
  data: {
255
- kind: 'form',
256
- widgetId: `api_key_request_${Date.now()}`,
257
- title: `AI Provider API Key Required`,
258
- description: API_KEY_SETUP_MESSAGE,
259
- fields: [
260
- {
261
- id: 'provider',
262
- label: 'Provider',
263
- type: 'select',
264
- required: true,
265
- options: [
266
- { label: 'OpenAI', value: 'openai' },
267
- { label: 'Anthropic', value: 'anthropic' },
268
- ],
269
- defaultValue: currentProvider === 'anthropic' ? 'anthropic' : 'openai',
270
- },
271
- {
272
- id: 'model',
273
- label: 'Model',
274
- type: 'text',
275
- description:
276
- 'Model name without the provider prefix (e.g. `gpt-4o-mini` or `claude-3-5-sonnet-20240620`).',
277
- placeholder: 'gpt-4o-mini',
278
- required: true,
279
- defaultValue: currentModelId,
280
- },
281
- {
282
- id: 'apiKey',
283
- label: 'API Key',
284
- type: 'text',
285
- placeholder: `sk-...`,
286
- required: true,
287
- },
280
+ kind: 'choice',
281
+ widgetId: `api_provider_selection_${Date.now()}`,
282
+ title: `Setup AI Provider`,
283
+ description: `Select a provider to continue.`,
284
+ actions: [
285
+ { id: 'openai', label: 'OpenAI', variant: 'primary' },
286
+ { id: 'anthropic', label: 'Anthropic', variant: 'primary' },
287
+ { id: 'google', label: 'Google', variant: 'primary' },
288
288
  ],
289
- submitLabel: 'Save & Continue',
290
289
  metadata: {
291
- type: 'api_key_request',
290
+ type: 'api_provider_selection',
292
291
  },
293
292
  },
294
293
  meta: { agentId: context.state.agentId, threadId },
@@ -315,49 +314,6 @@ export const openbotRuntime =
315
314
 
316
315
  const threadId = event.meta?.threadId || context.state.threadId;
317
316
 
318
- // Auto-add participants if tagged in the prompt
319
- const content = (event as AgentInvokeEvent).data?.content;
320
- if (content && storage) {
321
- try {
322
- const allAgents = await storage.getAgents();
323
- const tags = content.match(/@([\w-]+)/g);
324
- if (tags) {
325
- const taggedAgentIds = tags.map((t) => t.slice(1));
326
- const validAgentIds = taggedAgentIds.filter((id) =>
327
- allAgents.some((a) => a.id === id),
328
- );
329
-
330
- const currentParticipants = context.state.channelDetails?.participants || [];
331
- const newParticipants = [...new Set([...currentParticipants, ...validAgentIds])];
332
-
333
- if (newParticipants.length > currentParticipants.length) {
334
- // Update storage
335
- await storage.patchChannelState({
336
- channelId: context.state.channelId,
337
- state: { participants: newParticipants },
338
- });
339
-
340
- // Refresh local state
341
- context.state.channelDetails = await storage.getChannelDetails({
342
- channelId: context.state.channelId,
343
- });
344
-
345
- // Notify UI/others about the change
346
- yield {
347
- type: 'action:patch_channel_details:result',
348
- data: { success: true, updatedFields: ['participants'] },
349
- meta: {
350
- agentId: context.state.agentId,
351
- threadId,
352
- },
353
- } as OpenBotEvent;
354
- }
355
- }
356
- } catch (error) {
357
- console.warn('[openbot] Failed to auto-add participants from tags:', error);
358
- }
359
- }
360
-
361
317
  // clear the tool batch if the agent is invoked
362
318
  // this is to prevent the tool batch from being used for a new agent invocation
363
319
  await createToolBatchTracker(
@@ -394,15 +350,98 @@ export const openbotRuntime =
394
350
  });
395
351
 
396
352
  builder.on('client:ui:widget:response', async function* (event, context) {
397
- const { metadata, values } = event.data;
353
+ const { metadata, values, actionId } = event.data;
354
+ const threadId = event.meta?.threadId || context.state.threadId;
355
+
356
+ if (metadata?.type === 'api_provider_selection') {
357
+ const provider = actionId;
358
+ const [_, ...rest] = currentModelString.split('/');
359
+ const currentModelId = rest.join('/');
360
+
361
+ const registry = await fetchRegistry();
362
+ const providerData = registry?.providers[provider as string];
363
+
364
+ const providerLinks: Record<string, string> = {
365
+ openai: 'https://platform.openai.com/api-keys',
366
+ anthropic: 'https://console.anthropic.com/settings/keys',
367
+ google: 'https://aistudio.google.com/app/apikey',
368
+ };
369
+
370
+ const label = providerData?.label || (provider as string);
371
+ const link = providerLinks[provider as string] || '';
372
+
373
+ const modelOptions = providerData?.models.map((m) => ({
374
+ label: m.label,
375
+ value: m.id,
376
+ }));
377
+
378
+ const defaultModel = modelOptions?.[0]?.value || 'gpt-4o-mini';
379
+ const defaultValue =
380
+ modelOptions?.find((m) => m.value === currentModelId)?.value ||
381
+ currentModelId ||
382
+ defaultModel;
383
+
384
+ yield {
385
+ type: 'client:ui:widget',
386
+ data: {
387
+ widgetId: event.data.widgetId,
388
+ kind: 'message',
389
+ title: 'Provider Selected',
390
+ body: `${label} provider was selected.`,
391
+ state: 'submitted',
392
+ display: 'collapsed',
393
+ disabled: true,
394
+ actions: [],
395
+ },
396
+ meta: { agentId: context.state.agentId, threadId },
397
+ } as OpenBotEvent;
398
+
399
+ yield {
400
+ type: 'client:ui:widget',
401
+ data: {
402
+ kind: 'form',
403
+ widgetId: `api_key_request_${Date.now()}`,
404
+ title: `${label} Setup`,
405
+ description: `Enter your API key and select a model.`,
406
+ fields: [
407
+ {
408
+ id: 'model',
409
+ label: 'Model',
410
+ type: modelOptions ? 'select' : 'text',
411
+ description: modelOptions ? undefined : `Model name (e.g. \`${defaultModel}\`).`,
412
+ options: modelOptions,
413
+ placeholder: defaultModel,
414
+ required: true,
415
+ defaultValue,
416
+ },
417
+ {
418
+ id: 'apiKey',
419
+ label: 'API Key',
420
+ type: 'password',
421
+ description: `Get your key here: [${link}](${link})`,
422
+ placeholder: `sk-...`,
423
+ required: true,
424
+ },
425
+ ],
426
+ submitLabel: 'Save & Continue',
427
+ metadata: {
428
+ type: 'api_key_request',
429
+ provider,
430
+ },
431
+ },
432
+ meta: { agentId: context.state.agentId, threadId },
433
+ } as OpenBotEvent;
434
+ return;
435
+ }
436
+
398
437
  if (metadata?.type !== 'api_key_request') return;
399
- if (!values?.apiKey || !values?.provider || !values?.model) return;
438
+ if (!values?.apiKey || !values?.model) return;
400
439
 
401
- const provider = String(values.provider);
440
+ const provider = String(values.provider || metadata.provider);
402
441
  const modelId = String(values.model).trim();
403
442
  const apiKey = String(values.apiKey);
404
443
 
405
- if (provider !== 'openai' && provider !== 'anthropic') {
444
+ if (provider !== 'openai' && provider !== 'anthropic' && provider !== 'google') {
406
445
  yield {
407
446
  type: 'agent:output',
408
447
  data: { content: `Unsupported provider: ${provider}` },
@@ -411,7 +450,12 @@ export const openbotRuntime =
411
450
  return;
412
451
  }
413
452
 
414
- const envVar = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
453
+ const envVar =
454
+ provider === 'openai'
455
+ ? 'OPENAI_API_KEY'
456
+ : provider === 'anthropic'
457
+ ? 'ANTHROPIC_API_KEY'
458
+ : 'GOOGLE_GENERATIVE_AI_API_KEY';
415
459
  const newModelString = `${provider}/${modelId}`;
416
460
 
417
461
  if (!storage) return;
@@ -460,10 +504,14 @@ export const openbotRuntime =
460
504
  title: 'API Key Saved',
461
505
  body: `Successfully saved ${provider} API key and selected model \`${newModelString}\`. You can now continue your conversation.`,
462
506
  state: 'submitted',
463
- actions: [{ id: 'ok', label: 'Got it', variant: 'primary' }],
507
+ display: 'collapsed',
508
+ disabled: true,
509
+ actions: [],
464
510
  },
465
511
  meta: { agentId: context.state.agentId },
466
512
  };
513
+
514
+ yield* runLLM(context, threadId);
467
515
  } catch (error) {
468
516
  yield {
469
517
  type: 'agent:output',
@@ -7,21 +7,16 @@ export const OPENBOT_SYSTEM_PROMPT = [
7
7
  '- **Credential Guidance**: If an agent or tool requires credentials, inform the user they can be managed under "Settings > Variables".',
8
8
  '',
9
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.',
10
+ 'You almost never execute tasks yourself. Instead, you delegate tasks to specialized agents. You act as a high-level manager, ensuring the right agent is working on the right task.',
11
11
  '',
12
12
  '# OPERATIONAL GUIDELINES',
13
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.',
14
+ '- **Delegation**: You can delegate tasks to any specialized agent in the `INSTALLED AGENTS` list.',
15
+ '- **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 agent can handle.',
17
16
  '- **Context Awareness**: Use the provided ENVIRONMENT, CHANNEL SPECIFICATION, and MEMORIES to maintain continuity. Do not ask for information already present in these sections.',
18
17
  '- **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.',
18
+ '- **Hub-and-Spoke**: Specialized agents cannot communicate directly; as coordinator, you must pass relevant data from one agent to another.',
20
19
  '',
21
20
  '# COMMUNICATION STYLE',
22
21
  '- Be always concise, professional, and proactive.',
23
22
  ].join('\n');
24
-
25
- /** Shown in the API key setup form when no provider credentials are configured. */
26
- export const API_KEY_SETUP_MESSAGE =
27
- '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.';
@@ -67,10 +67,9 @@ export const pluginManagerPlugin: Plugin = {
67
67
  const {
68
68
  channelId: instanceId,
69
69
  name: templateName,
70
- participants: customParticipants,
71
70
  initialState: customInitialState,
72
71
  } = event.data;
73
- const { agents: marketplaceAgents, channels } = await resolveMarketplaceRegistry();
72
+ const { channels } = await resolveMarketplaceRegistry();
74
73
 
75
74
  // Try to find the template by ID or Name
76
75
  const channelListing =
@@ -78,64 +77,17 @@ export const pluginManagerPlugin: Plugin = {
78
77
  channels.find((c) => c.name === templateName);
79
78
 
80
79
  const channelId = instanceId;
81
- const participants = customParticipants || channelListing?.participants || [];
82
80
  const initialState = {
83
81
  ...(channelListing?.initialState || {}),
84
82
  ...(customInitialState || {}),
85
83
  };
86
84
  const spec = channelListing?.spec || '';
87
85
 
88
- // 1. Auto-install participant agents if missing
89
- for (const agentId of participants) {
90
- const existingAgents = await storage.getAgents();
91
- if (existingAgents.some((a) => a.id === agentId)) {
92
- continue;
93
- }
94
-
95
- // Not found locally, look in marketplace
96
- const agentListing = marketplaceAgents.find((a) => a.id === agentId);
97
- if (agentListing) {
98
- console.log(`[plugin-manager] Auto-installing agent ${agentId} for channel ${channelId}`);
99
-
100
- // Install plugins for this agent
101
- for (const ref of agentListing.plugins) {
102
- const installed = await pluginService.isInstalled(ref.id);
103
- if (
104
- !installed &&
105
- ref.id.includes('/') === false &&
106
- ref.id.includes('-plugin-') === false
107
- ) {
108
- continue;
109
- }
110
- if (!installed) {
111
- try {
112
- await pluginService.install({ packageName: ref.id });
113
- } catch (err) {
114
- console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
115
- }
116
- }
117
- }
118
-
119
- // Create the agent
120
- await storage.createAgent({
121
- agentId: agentListing.id,
122
- name: agentListing.name,
123
- description: agentListing.description,
124
- image: agentListing.image,
125
- instructions: agentListing.instructions,
126
- plugins: agentListing.plugins,
127
- });
128
- }
129
- }
130
-
131
86
  // 2. Create the channel
132
87
  await storage.createChannel({
133
88
  channelId,
134
89
  spec,
135
- initialState: {
136
- ...initialState,
137
- participants,
138
- },
90
+ initialState,
139
91
  });
140
92
 
141
93
  const channelUrl = `/channels/${channelId}`;
@@ -48,20 +48,13 @@ const storageToolDefinitions = {
48
48
  'Markdown content for the channel specification (SPEC.md). Use for goals and rules.',
49
49
  ),
50
50
  cwd: z.string().optional().describe('Current working directory for the channel.'),
51
- participants: z
52
- .array(z.string())
53
- .optional()
54
- .describe(
55
- 'List of agent IDs that are participants in this channel. When a user tags an agent (e.g. @agent-id), you should ensure they are added to this list if they are not already there.',
56
- ),
57
51
  })
58
52
  .refine(
59
53
  (value) =>
60
54
  value.state !== undefined ||
61
55
  value.spec !== undefined ||
62
- value.cwd !== undefined ||
63
- value.participants !== undefined,
64
- { message: 'Provide at least one of state, spec, cwd, or participants.' },
56
+ value.cwd !== undefined,
57
+ { message: 'Provide at least one of state, spec, or cwd.' },
65
58
  ),
66
59
  },
67
60
  patch_thread_details: {
@@ -155,7 +148,7 @@ export const storagePlugin: Plugin = {
155
148
  });
156
149
 
157
150
  builder.on('action:create_channel', async function* (event, context) {
158
- const { channelId, spec, initialState, cwd, participants } = (event as any).data;
151
+ const { channelId, spec, initialState, cwd } = (event as any).data;
159
152
  const rawChannelId = (channelId || '').trim();
160
153
  const channelSpec = typeof spec === 'string' ? spec : '';
161
154
 
@@ -173,15 +166,6 @@ export const storagePlugin: Plugin = {
173
166
  const channelUrl = `/channels/${rawChannelId}`;
174
167
 
175
168
  const mergedInitial: Record<string, unknown> = { ...(initialState || {}) };
176
- if (participants !== undefined) {
177
- const normalized = Array.isArray(participants)
178
- ? participants
179
- .filter((x: unknown): x is string => typeof x === 'string')
180
- .map((s: string) => s.trim())
181
- .filter(Boolean)
182
- : [];
183
- mergedInitial.participants = normalized;
184
- }
185
169
 
186
170
  try {
187
171
  await storage.createChannel({
@@ -254,7 +238,6 @@ export const storagePlugin: Plugin = {
254
238
  channelId?: string;
255
239
  name?: string;
256
240
  cwd?: string;
257
- participants?: string[];
258
241
  };
259
242
  const targetChannelId = (data.channelId || context.state.channelId || '').trim();
260
243
  const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
@@ -279,17 +262,6 @@ export const storagePlugin: Plugin = {
279
262
  patch.cwd = data.cwd.trim();
280
263
  updatedFields.push('cwd');
281
264
  }
282
- if (data.participants !== undefined) {
283
- if (Array.isArray(data.participants)) {
284
- patch.participants = data.participants
285
- .filter((x): x is string => typeof x === 'string')
286
- .map((s) => s.trim())
287
- .filter(Boolean);
288
- } else {
289
- patch.participants = [];
290
- }
291
- updatedFields.push('participants');
292
- }
293
265
 
294
266
  try {
295
267
  if (updatedFields.length > 0) {
@@ -317,13 +289,12 @@ export const storagePlugin: Plugin = {
317
289
  });
318
290
 
319
291
  builder.on('action:patch_channel_details', async function* (event, context) {
320
- const updatedFields: ('state' | 'spec' | 'cwd' | 'participants')[] = [];
292
+ const updatedFields: ('state' | 'spec' | 'cwd')[] = [];
321
293
  const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
322
294
  const data = (event.data || {}) as {
323
295
  state?: Record<string, unknown>;
324
296
  spec?: string;
325
297
  cwd?: string;
326
- participants?: string[];
327
298
  };
328
299
  try {
329
300
  if (data.state !== undefined) {
@@ -347,19 +318,6 @@ export const storagePlugin: Plugin = {
347
318
  });
348
319
  updatedFields.push('cwd');
349
320
  }
350
- if (data.participants !== undefined) {
351
- const normalized = Array.isArray(data.participants)
352
- ? data.participants
353
- .filter((x): x is string => typeof x === 'string')
354
- .map((s) => s.trim())
355
- .filter(Boolean)
356
- : [];
357
- await storage.patchChannelState({
358
- channelId: context.state.channelId,
359
- state: { participants: normalized },
360
- });
361
- updatedFields.push('participants');
362
- }
363
321
 
364
322
  context.state.channelDetails = await storage.getChannelDetails({
365
323
  channelId: context.state.channelId,
@@ -418,20 +418,20 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
418
418
  /** Display-oriented fields persisted in a channel's `state.json`. */
419
419
  const readChannelStateFileFields = (
420
420
  parsed: unknown,
421
- ): { name?: string; cwd?: string; participants: string[] } => {
421
+ ): { name?: string; cwd?: string } => {
422
422
  if (!isRecord(parsed)) {
423
- return { participants: [] };
423
+ return {};
424
424
  }
425
425
  const name =
426
426
  typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : undefined;
427
427
  const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : undefined;
428
- const participants: string[] = [];
429
- if (Array.isArray(parsed.participants)) {
430
- for (const x of parsed.participants) {
431
- if (typeof x === 'string' && x.trim()) participants.push(x.trim());
432
- }
433
- }
434
- return { name, cwd, participants };
428
+ return { name, cwd };
429
+ };
430
+
431
+ const isChannelProvisioned = async (channelId: string): Promise<boolean> => {
432
+ const statePath = `${getConversationDir(channelId)}/state.json`;
433
+ const state = await readJsonFile(statePath, {});
434
+ return !!readChannelStateFileFields(state).cwd;
435
435
  };
436
436
 
437
437
  /**
@@ -507,24 +507,25 @@ export const storageService = {
507
507
  const statePath = path.join(channelDir, 'state.json');
508
508
  let cwd: string | undefined;
509
509
  let displayName = name;
510
- let participants: string[] = [];
511
510
 
512
511
  try {
513
512
  const parsed = await readJsonFile(statePath, {});
514
513
  const fields = readChannelStateFileFields(parsed);
515
514
  cwd = fields.cwd;
516
515
  displayName = fields.name ?? name;
517
- participants = fields.participants;
518
516
  } catch {
519
517
  // ignore
520
518
  }
521
519
 
520
+ if (!cwd) {
521
+ return null;
522
+ }
523
+
522
524
  const channel: Channel = {
523
525
  id: name,
524
526
  name: displayName,
525
527
  description: '',
526
528
  cwd,
527
- participants,
528
529
  createdAt: stats.birthtime,
529
530
  updatedAt: stats.mtime,
530
531
  };
@@ -554,7 +555,9 @@ export const storageService = {
554
555
  }),
555
556
  );
556
557
 
557
- return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
558
+ return channels
559
+ .filter((channel): channel is Channel => channel !== null)
560
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
558
561
  },
559
562
  createChannel: async ({
560
563
  channelId,
@@ -606,6 +609,64 @@ export const storageService = {
606
609
  );
607
610
  await writeJsonFileAtomically(statePath, finalState);
608
611
  },
612
+ ensureChannel: async ({
613
+ channelId,
614
+ spec,
615
+ initialState,
616
+ cwd,
617
+ }: {
618
+ channelId: string;
619
+ spec?: string;
620
+ initialState?: Record<string, unknown>;
621
+ cwd?: string;
622
+ }): Promise<void> => {
623
+ const normalizedChannelId = channelId.trim();
624
+ if (!normalizedChannelId) {
625
+ throw new Error('channelId is required');
626
+ }
627
+
628
+ const channelDir = getConversationDir(normalizedChannelId);
629
+ const specPath = `${channelDir}/SPEC.md`;
630
+ const statePath = `${channelDir}/state.json`;
631
+
632
+ const existingState = await readJsonFile<Record<string, unknown>>(statePath, {});
633
+ const existingFields = readChannelStateFileFields(existingState);
634
+ if (existingFields.cwd) {
635
+ await fs.mkdir(resolvePath(existingFields.cwd), { recursive: true });
636
+ return;
637
+ }
638
+
639
+ const finalState: Record<string, unknown> = {
640
+ ...existingState,
641
+ ...(initialState || {}),
642
+ };
643
+
644
+ const rawCwd =
645
+ (typeof cwd === 'string' && cwd.trim()) ||
646
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
647
+ getDefaultChannelCwd(normalizedChannelId);
648
+
649
+ const resolvedCwd = resolvePath(rawCwd);
650
+ finalState.cwd = resolvedCwd;
651
+
652
+ await fs.mkdir(resolvedCwd, { recursive: true });
653
+ await fs.mkdir(channelDir, { recursive: true });
654
+
655
+ try {
656
+ await fs.access(specPath);
657
+ } catch (error: unknown) {
658
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
659
+ await fs.writeFile(
660
+ specPath,
661
+ spec?.trim() || `# ${normalizedChannelId}\n\n`,
662
+ );
663
+ } else {
664
+ throw error;
665
+ }
666
+ }
667
+
668
+ await writeJsonFileAtomically(statePath, finalState);
669
+ },
609
670
  deleteChannel: async ({ channelId }: { channelId: string }): Promise<void> => {
610
671
  const normalizedChannelId = channelId.trim();
611
672
  if (!normalizedChannelId) {
@@ -808,7 +869,6 @@ export const storageService = {
808
869
  spec,
809
870
  state,
810
871
  cwd,
811
- participants: diskFields.participants,
812
872
  };
813
873
 
814
874
  details.threads = await storageService.getThreads({ channelId });
@@ -1290,6 +1350,10 @@ export const storageService = {
1290
1350
  event: OpenBotEvent;
1291
1351
  }): Promise<void> => {
1292
1352
  try {
1353
+ if (!(await isChannelProvisioned(channelId))) {
1354
+ return;
1355
+ }
1356
+
1293
1357
  const threadDir = getConversationDir(channelId, threadId);
1294
1358
  if (threadId) {
1295
1359
  let exists = false;