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.
@@ -340,18 +340,16 @@ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArr
340
340
  /** Display-oriented fields persisted in a channel's `state.json`. */
341
341
  const readChannelStateFileFields = (parsed) => {
342
342
  if (!isRecord(parsed)) {
343
- return { participants: [] };
343
+ return {};
344
344
  }
345
345
  const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : undefined;
346
346
  const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : undefined;
347
- const participants = [];
348
- if (Array.isArray(parsed.participants)) {
349
- for (const x of parsed.participants) {
350
- if (typeof x === 'string' && x.trim())
351
- participants.push(x.trim());
352
- }
353
- }
354
- return { name, cwd, participants };
347
+ return { name, cwd };
348
+ };
349
+ const isChannelProvisioned = async (channelId) => {
350
+ const statePath = `${getConversationDir(channelId)}/state.json`;
351
+ const state = await readJsonFile(statePath, {});
352
+ return !!readChannelStateFileFields(state).cwd;
355
353
  };
356
354
  /**
357
355
  * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
@@ -412,23 +410,23 @@ export const storageService = {
412
410
  const statePath = path.join(channelDir, 'state.json');
413
411
  let cwd;
414
412
  let displayName = name;
415
- let participants = [];
416
413
  try {
417
414
  const parsed = await readJsonFile(statePath, {});
418
415
  const fields = readChannelStateFileFields(parsed);
419
416
  cwd = fields.cwd;
420
417
  displayName = fields.name ?? name;
421
- participants = fields.participants;
422
418
  }
423
419
  catch {
424
420
  // ignore
425
421
  }
422
+ if (!cwd) {
423
+ return null;
424
+ }
426
425
  const channel = {
427
426
  id: name,
428
427
  name: displayName,
429
428
  description: '',
430
429
  cwd,
431
- participants,
432
430
  createdAt: stats.birthtime,
433
431
  updatedAt: stats.mtime,
434
432
  };
@@ -452,7 +450,9 @@ export const storageService = {
452
450
  }
453
451
  return channel;
454
452
  }));
455
- return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
453
+ return channels
454
+ .filter((channel) => channel !== null)
455
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
456
456
  },
457
457
  createChannel: async ({ channelId, spec, initialState, cwd, }) => {
458
458
  const normalizedChannelId = channelId.trim();
@@ -486,6 +486,44 @@ export const storageService = {
486
486
  `# ${normalizedChannelId}\n\n`);
487
487
  await writeJsonFileAtomically(statePath, finalState);
488
488
  },
489
+ ensureChannel: async ({ channelId, spec, initialState, cwd, }) => {
490
+ const normalizedChannelId = channelId.trim();
491
+ if (!normalizedChannelId) {
492
+ throw new Error('channelId is required');
493
+ }
494
+ const channelDir = getConversationDir(normalizedChannelId);
495
+ const specPath = `${channelDir}/SPEC.md`;
496
+ const statePath = `${channelDir}/state.json`;
497
+ const existingState = await readJsonFile(statePath, {});
498
+ const existingFields = readChannelStateFileFields(existingState);
499
+ if (existingFields.cwd) {
500
+ await fs.mkdir(resolvePath(existingFields.cwd), { recursive: true });
501
+ return;
502
+ }
503
+ const finalState = {
504
+ ...existingState,
505
+ ...(initialState || {}),
506
+ };
507
+ const rawCwd = (typeof cwd === 'string' && cwd.trim()) ||
508
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
509
+ getDefaultChannelCwd(normalizedChannelId);
510
+ const resolvedCwd = resolvePath(rawCwd);
511
+ finalState.cwd = resolvedCwd;
512
+ await fs.mkdir(resolvedCwd, { recursive: true });
513
+ await fs.mkdir(channelDir, { recursive: true });
514
+ try {
515
+ await fs.access(specPath);
516
+ }
517
+ catch (error) {
518
+ if (error?.code === 'ENOENT') {
519
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
520
+ }
521
+ else {
522
+ throw error;
523
+ }
524
+ }
525
+ await writeJsonFileAtomically(statePath, finalState);
526
+ },
489
527
  deleteChannel: async ({ channelId }) => {
490
528
  const normalizedChannelId = channelId.trim();
491
529
  if (!normalizedChannelId) {
@@ -648,7 +686,6 @@ export const storageService = {
648
686
  spec,
649
687
  state,
650
688
  cwd,
651
- participants: diskFields.participants,
652
689
  };
653
690
  details.threads = await storageService.getThreads({ channelId });
654
691
  return details;
@@ -1030,6 +1067,9 @@ export const storageService = {
1030
1067
  },
1031
1068
  storeEvent: async ({ channelId, threadId, event, }) => {
1032
1069
  try {
1070
+ if (!(await isChannelProvisioned(channelId))) {
1071
+ return;
1072
+ }
1033
1073
  const threadDir = getConversationDir(channelId, threadId);
1034
1074
  if (threadId) {
1035
1075
  let exists = false;
@@ -74,20 +74,16 @@ export function parseMarketplaceRegistryJson(data) {
74
74
  const id = item.id;
75
75
  const name = item.name;
76
76
  const description = item.description;
77
- const participants = item.participants;
78
77
  if (typeof id !== 'string' || !id)
79
78
  throw new Error(`channels[${i}].id must be a non-empty string`);
80
79
  if (typeof name !== 'string')
81
80
  throw new Error(`channels[${i}].name must be a string`);
82
81
  if (typeof description !== 'string')
83
82
  throw new Error(`channels[${i}].description must be a string`);
84
- if (!Array.isArray(participants))
85
- throw new Error(`channels[${i}].participants must be an array`);
86
83
  const listing = {
87
84
  id,
88
85
  name,
89
86
  description,
90
- participants: participants.filter((p) => typeof p === 'string'),
91
87
  };
92
88
  if (typeof item.image === 'string')
93
89
  listing.image = item.image;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@ai-sdk/anthropic": "^3.0.33",
19
+ "@ai-sdk/google": "^3.0.82",
19
20
  "@ai-sdk/openai": "^3.0.13",
20
21
  "@anthropic-ai/claude-agent-sdk": "^0.2.138",
21
22
  "@types/cors": "^2.8.19",
@@ -0,0 +1,5 @@
1
+ /** Default channel when requests omit channelId (general-purpose conversation). */
2
+ export const UNCATEGORIZED_CHANNEL_ID = 'uncategorized';
3
+
4
+ export const DEFAULT_UNCATEGORIZED_SPEC =
5
+ '# Uncategorized\n\nGeneral-purpose channel for conversations without a dedicated channel.';
package/src/app/cli.ts CHANGED
@@ -25,7 +25,7 @@ function checkNodeVersion() {
25
25
 
26
26
  checkNodeVersion();
27
27
 
28
- program.name('openbot').description('OpenBot CLI').version('0.4.5');
28
+ program.name('openbot').description('OpenBot CLI').version('0.4.7');
29
29
 
30
30
  program
31
31
  .command('start')
@@ -4,6 +4,9 @@ import { storageService } from '../plugins/storage/service.js';
4
4
  /** Thread `state.json` key for the sticky responding agent id. */
5
5
  export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
6
6
 
7
+ /** Publish events that continue a pending UI interaction rather than a new user turn. */
8
+ export const CONTINUATION_EVENT_TYPES = new Set(['client:ui:widget:response']);
9
+
7
10
  export type ResolveRespondingAgentOptions = {
8
11
  channelId: string;
9
12
  threadId?: string;
@@ -20,6 +23,14 @@ export type ResolveRespondingAgentResult = {
20
23
  overridden: boolean;
21
24
  };
22
25
 
26
+ const readMetaAgentId = (meta: unknown): string | undefined => {
27
+ if (!meta || typeof meta !== 'object') return undefined;
28
+ const value = (meta as Record<string, unknown>).agentId;
29
+ if (typeof value !== 'string') return undefined;
30
+ const trimmed = value.trim();
31
+ return trimmed || undefined;
32
+ };
33
+
23
34
  const readBoundAgentId = (state: unknown): string | undefined => {
24
35
  if (!state || typeof state !== 'object') return undefined;
25
36
  const value = (state as Record<string, unknown>)[THREAD_RESPONDING_AGENT_ID_KEY];
@@ -72,3 +83,38 @@ export async function resolveRespondingAgentId(
72
83
 
73
84
  return { agentId: fallback, bound: true, overridden: false };
74
85
  }
86
+
87
+ export type ResolvePublishTargetAgentOptions = {
88
+ eventType: string;
89
+ channelId: string;
90
+ threadId?: string;
91
+ requestedAgentId?: string;
92
+ eventMeta?: unknown;
93
+ bindIfUnbound?: boolean;
94
+ };
95
+
96
+ /**
97
+ * Resolves the agent that should handle a publish event.
98
+ * UI continuations route to the widget origin agent; everything else uses sticky thread binding.
99
+ */
100
+ export async function resolvePublishTargetAgentId(
101
+ options: ResolvePublishTargetAgentOptions,
102
+ ): Promise<{ agentId: string } | { error: 'agentId_required' }> {
103
+ const { eventType, channelId, threadId, requestedAgentId, eventMeta, bindIfUnbound } = options;
104
+
105
+ if (CONTINUATION_EVENT_TYPES.has(eventType)) {
106
+ const originAgentId = readMetaAgentId(eventMeta) || requestedAgentId?.trim();
107
+ if (!originAgentId) {
108
+ return { error: 'agentId_required' };
109
+ }
110
+ return { agentId: originAgentId };
111
+ }
112
+
113
+ const resolved = await resolveRespondingAgentId({
114
+ channelId,
115
+ threadId,
116
+ requestedAgentId,
117
+ bindIfUnbound,
118
+ });
119
+ return { agentId: resolved.agentId };
120
+ }
package/src/app/server.ts CHANGED
@@ -21,7 +21,11 @@ import {
21
21
  } from '../plugins/storage/files.js';
22
22
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
23
23
  import { abortRegistry, abortKey } from '../services/abort.js';
24
- import { resolveRespondingAgentId } from './responding-agent.js';
24
+ import { resolvePublishTargetAgentId, resolveRespondingAgentId } from './responding-agent.js';
25
+ import {
26
+ DEFAULT_UNCATEGORIZED_SPEC,
27
+ UNCATEGORIZED_CHANNEL_ID,
28
+ } from './channel-ids.js';
25
29
 
26
30
  type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
27
31
 
@@ -65,9 +69,18 @@ export async function startServer(options: ServerOptions = {}) {
65
69
  storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
66
70
  storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
67
71
 
72
+ const getRawChannelId = (req: express.Request): string | undefined => {
73
+ const raw =
74
+ req.get('x-openbot-channel-id') ||
75
+ req.query.channelId ||
76
+ (req.body && req.body.channelId);
77
+ if (typeof raw !== 'string') return undefined;
78
+ const trimmed = raw.trim();
79
+ return trimmed || undefined;
80
+ };
81
+
68
82
  const getContext = (req: express.Request) => {
69
- const channelId =
70
- req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
83
+ const rawChannelId = getRawChannelId(req);
71
84
  const threadId =
72
85
  req.get('x-openbot-thread-id') || req.query.threadId || (req.body && req.body.threadId);
73
86
  const agentId =
@@ -83,7 +96,8 @@ export async function startServer(options: ServerOptions = {}) {
83
96
  (req.body && req.body.responseType);
84
97
 
85
98
  return {
86
- channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')) as string, // Default to uncategorized if none
99
+ channelId: (rawChannelId || UNCATEGORIZED_CHANNEL_ID) as string,
100
+ rawChannelId,
87
101
  threadId: threadId as string | undefined,
88
102
  agentId: agentId as string | undefined,
89
103
  runId: runId as string,
@@ -532,17 +546,38 @@ export async function startServer(options: ServerOptions = {}) {
532
546
  try {
533
547
  ensureEventId(event);
534
548
 
549
+ const isUserConversationStart =
550
+ event.type === 'agent:invoke' &&
551
+ event.data?.role === 'user' &&
552
+ typeof event.data.content === 'string' &&
553
+ event.data.content.trim().length > 0;
554
+
555
+ if (isUserConversationStart && channelId === UNCATEGORIZED_CHANNEL_ID) {
556
+ await storageService.ensureChannel({
557
+ channelId: UNCATEGORIZED_CHANNEL_ID,
558
+ spec: DEFAULT_UNCATEGORIZED_SPEC,
559
+ initialState: { name: 'Uncategorized' },
560
+ });
561
+ }
562
+
535
563
  const bindIfUnbound = event.type === 'agent:invoke';
536
- const resolved = await resolveRespondingAgentId({
564
+ const target = await resolvePublishTargetAgentId({
565
+ eventType: event.type,
537
566
  channelId,
538
567
  threadId,
539
568
  requestedAgentId: agentId,
569
+ eventMeta: event.meta,
540
570
  bindIfUnbound,
541
571
  });
542
572
 
573
+ if ('error' in target) {
574
+ res.status(400).json({ error: 'agentId is required for widget response' });
575
+ return;
576
+ }
577
+
543
578
  await runAgent({
544
579
  runId,
545
- agentId: resolved.agentId,
580
+ agentId: target.agentId,
546
581
  event,
547
582
  channelId,
548
583
  threadId,
package/src/app/types.ts CHANGED
@@ -301,8 +301,6 @@ export type PatchChannelDetailsEvent = BaseEvent & {
301
301
  state?: Record<string, unknown>;
302
302
  spec?: string;
303
303
  cwd?: string;
304
- /** When set, replaces `state.json` `participants` (merged after `state` if both are sent). */
305
- participants?: string[];
306
304
  };
307
305
  };
308
306
 
@@ -310,7 +308,7 @@ export type PatchChannelDetailsResultEvent = BaseEvent & {
310
308
  type: 'action:patch_channel_details:result';
311
309
  data: {
312
310
  success: boolean;
313
- updatedFields: ('state' | 'spec' | 'cwd' | 'participants')[];
311
+ updatedFields: ('state' | 'spec' | 'cwd')[];
314
312
  };
315
313
  };
316
314
 
@@ -569,8 +567,6 @@ export type CreateChannelEvent = BaseEvent & {
569
567
  spec?: string;
570
568
  initialState?: Record<string, unknown>;
571
569
  cwd?: string;
572
- /** Initial channel agent ids; written into `state.json` (overrides `initialState.participants` if both are set). */
573
- participants?: string[];
574
570
  };
575
571
  meta?: {
576
572
  toolCallId?: string;
@@ -594,8 +590,6 @@ export type UpdateChannelEvent = BaseEvent & {
594
590
  channelId?: string;
595
591
  name?: string;
596
592
  cwd?: string;
597
- /** Replaces the channel participant list when provided. */
598
- participants?: string[];
599
593
  };
600
594
  };
601
595
 
@@ -655,7 +649,7 @@ export type UIWidgetAction = {
655
649
  export type UIWidgetField = {
656
650
  id: string;
657
651
  label: string;
658
- type: 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'multiselect' | 'date';
652
+ type: 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'multiselect' | 'date' | 'password';
659
653
  description?: string;
660
654
  placeholder?: string;
661
655
  required?: boolean;
@@ -736,6 +730,11 @@ export type UIWidgetResponseEvent = BaseEvent & {
736
730
  values?: Record<string, unknown>;
737
731
  metadata?: Record<string, unknown>;
738
732
  };
733
+ meta?: {
734
+ agentId?: string;
735
+ threadId?: string;
736
+ [key: string]: unknown;
737
+ };
739
738
  };
740
739
 
741
740
  export type BashEvent = BaseEvent & {
@@ -851,7 +850,6 @@ export type ListMarketplaceRegistryResultEvent = BaseEvent & {
851
850
  image?: string;
852
851
  spec?: string;
853
852
  initialState?: Record<string, unknown>;
854
- participants: string[];
855
853
  starterPrompts?: Array<{ label: string; prompt: string }>;
856
854
  }>;
857
855
  error?: string;
@@ -863,7 +861,6 @@ export type InstallChannelEvent = BaseEvent & {
863
861
  data: {
864
862
  channelId: string;
865
863
  name?: string;
866
- participants?: string[];
867
864
  initialState?: Record<string, unknown>;
868
865
  };
869
866
  };
@@ -27,20 +27,11 @@ export const getContextBudgetForModel = (modelString: string): number => {
27
27
  /** Built-in orchestrator agent id. */
28
28
  export const ORCHESTRATOR_AGENT_ID = 'system';
29
29
 
30
- /**
31
- * Check if a channel is a solo DM (only the agent is present).
32
- */
33
- export function isDmSoloChannel(participants: string[], agentId: string): boolean {
34
- return participants.length === 0 || (participants.length === 1 && participants[0] === agentId);
35
- }
36
-
37
30
  /**
38
31
  * Simplified context builder for MVP.
39
32
  */
40
33
  export async function buildContext(state: OpenBotState, storage?: Storage): Promise<string> {
41
34
  const { channelId, threadId, channelDetails, agentId, threadDetails, agentDetails } = state;
42
- const participants = channelDetails?.participants || [];
43
- const isDm = isDmSoloChannel(participants, agentId);
44
35
 
45
36
  const sections: string[] = [];
46
37
 
@@ -54,23 +45,13 @@ export async function buildContext(state: OpenBotState, storage?: Storage): Prom
54
45
 
55
46
  // 2. Environment
56
47
  let env = '## ENVIRONMENT\n';
57
- if (isDm) {
58
- env += '- Mode: Direct Message (Solo)\n';
59
- } else {
60
- const channelName = channelDetails?.name || channelId;
61
- env += `- Mode: Channel (#${channelName})\n`;
62
- if (channelDetails?.cwd) {
63
- env += `- Workspace: ${channelDetails.cwd}\n`;
64
- }
65
- if (threadId) {
66
- env += `- Thread: ${threadDetails?.name || threadId}\n`;
67
- }
68
- const peerIds = participants.filter((id: string) => id !== agentId);
69
- const participantLabels = peerIds.map((id) => {
70
- const agent = allAgents.find((a) => a.id === id);
71
- return agent ? `${agent.name} (${id})` : id;
72
- });
73
- env += `- Participants: ${participantLabels.length > 0 ? participantLabels.join(', ') : 'None'}\n`;
48
+ const channelName = channelDetails?.name || channelId;
49
+ env += `- Mode: Channel (#${channelName})\n`;
50
+ if (channelDetails?.cwd) {
51
+ env += `- Workspace: ${channelDetails.cwd}\n`;
52
+ }
53
+ if (threadId) {
54
+ env += `- Thread: ${threadDetails?.name || threadId}\n`;
74
55
  }
75
56
  sections.push(env);
76
57
 
@@ -49,7 +49,8 @@ export const openbotPlugin: Plugin = {
49
49
  ...memoryPlugin.toolDefinitions,
50
50
  ...storagePlugin.toolDefinitions,
51
51
  ...delegationPlugin.toolDefinitions,
52
- ...uiPlugin.toolDefinitions,
52
+ // this is the capability to render UI widgets to the user. We dont need it for now.
53
+ // ...uiPlugin.toolDefinitions,
53
54
  },
54
55
  factory: (context) => (builder) => {
55
56
  const { config, storage, tools, abortSignal } = context;