openbot 0.4.5 → 0.4.6

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.
@@ -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;
@@ -64,8 +64,6 @@ export type Channel = {
64
64
  name: string;
65
65
  description: string;
66
66
  cwd?: string;
67
- /** Agent ids associated with this channel (from `state.json`). */
68
- participants: string[];
69
67
  createdAt: Date;
70
68
  updatedAt: Date;
71
69
  hasUnseenMessages?: boolean;
@@ -108,8 +106,6 @@ export type ChannelDetails = {
108
106
  spec: string;
109
107
  state: unknown;
110
108
  cwd?: string;
111
- /** Agent ids for this channel (from `state.json`). */
112
- participants: string[];
113
109
  threads?: Thread[];
114
110
  };
115
111
 
@@ -121,6 +117,13 @@ export interface Storage {
121
117
  initialState?: Record<string, unknown>;
122
118
  cwd?: string;
123
119
  }) => Promise<void>;
120
+ /** Idempotent channel setup; repairs partial dirs missing cwd/state. */
121
+ ensureChannel: (args: {
122
+ channelId: string;
123
+ spec?: string;
124
+ initialState?: Record<string, unknown>;
125
+ cwd?: string;
126
+ }) => Promise<void>;
124
127
  /** Removes the channel directory and cleans up `_meta/last-read.json`. */
125
128
  deleteChannel: (args: { channelId: string }) => Promise<void>;
126
129
  createThread: (args: {
@@ -49,8 +49,6 @@ export type MarketplaceChannelListing = {
49
49
  image?: string;
50
50
  spec?: string;
51
51
  initialState?: Record<string, unknown>;
52
- /** List of agent IDs that should be participants in the channel. */
53
- participants: string[];
54
52
  /** Starter prompts for the channel. */
55
53
  starterPrompts?: StarterPrompt[];
56
54
  };
@@ -133,21 +131,17 @@ export function parseMarketplaceRegistryJson(data: unknown): MarketplaceRegistry
133
131
  const id = item.id;
134
132
  const name = item.name;
135
133
  const description = item.description;
136
- const participants = item.participants;
137
134
 
138
135
  if (typeof id !== 'string' || !id)
139
136
  throw new Error(`channels[${i}].id must be a non-empty string`);
140
137
  if (typeof name !== 'string') throw new Error(`channels[${i}].name must be a string`);
141
138
  if (typeof description !== 'string')
142
139
  throw new Error(`channels[${i}].description must be a string`);
143
- if (!Array.isArray(participants))
144
- throw new Error(`channels[${i}].participants must be an array`);
145
140
 
146
141
  const listing: MarketplaceChannelListing = {
147
142
  id,
148
143
  name,
149
144
  description,
150
- participants: participants.filter((p): p is string => typeof p === 'string'),
151
145
  };
152
146
 
153
147
  if (typeof item.image === 'string') listing.image = item.image;
@@ -1,44 +0,0 @@
1
- import { generateText } from 'ai';
2
- import { openai } from '@ai-sdk/openai';
3
- import { anthropic } from '@ai-sdk/anthropic';
4
- const THREAD_TITLE_MAX_LENGTH = 80;
5
- function resolveModel(modelString) {
6
- const [provider, ...rest] = modelString.split('/');
7
- const modelId = rest.join('/');
8
- if (!modelId) {
9
- throw new Error(`Invalid model string: "${modelString}". Expected "provider/model-id".`);
10
- }
11
- switch (provider) {
12
- case 'openai':
13
- return openai(modelId);
14
- case 'anthropic':
15
- return anthropic(modelId);
16
- default:
17
- throw new Error(`Unsupported AI provider: "${provider}"`);
18
- }
19
- }
20
- function normalizeTitle(raw) {
21
- let title = raw
22
- .replace(/^["'`]+|["'`]+$/g, '')
23
- .replace(/[.!?]+$/g, '')
24
- .replace(/\s+/g, ' ')
25
- .trim();
26
- if (!title)
27
- return '';
28
- if (title.length > THREAD_TITLE_MAX_LENGTH) {
29
- title = `${title.slice(0, THREAD_TITLE_MAX_LENGTH).trimEnd()}...`;
30
- }
31
- return title;
32
- }
33
- export async function generateThreadTitle(content, modelString) {
34
- const normalized = content.replace(/\s+/g, ' ').trim();
35
- if (!normalized)
36
- return undefined;
37
- const result = await generateText({
38
- model: resolveModel(modelString),
39
- system: 'You name chat threads. Reply with ONLY a short title (3-6 words). No quotes, no trailing punctuation.',
40
- prompt: normalized.slice(0, 500),
41
- maxOutputTokens: 20,
42
- });
43
- return normalizeTitle(result.text) || undefined;
44
- }
@@ -1,103 +0,0 @@
1
- import { ORCHESTRATOR_AGENT_ID } from '../../app/agent-ids.js';
2
- import { loadConfig } from '../../app/config.js';
3
- import { generateThreadTitle } from './generate-title.js';
4
- const namingInFlight = new Set();
5
- function resolveNamingModel(pluginConfig, agentPluginRefs) {
6
- const fromPlugin = typeof pluginConfig.model === 'string' ? pluginConfig.model.trim() : '';
7
- if (fromPlugin)
8
- return fromPlugin;
9
- const openbotRef = agentPluginRefs?.find((ref) => ref.id === 'openbot');
10
- const fromOpenbot = typeof openbotRef?.config?.model === 'string' ? openbotRef.config.model.trim() : '';
11
- if (fromOpenbot)
12
- return fromOpenbot;
13
- return loadConfig().model || 'openai/gpt-4o-mini';
14
- }
15
- async function maybeGenerateThreadName(args) {
16
- const details = await args.storage.getThreadDetails({
17
- channelId: args.channelId,
18
- threadId: args.threadId,
19
- });
20
- const state = details.state || {};
21
- if (state.nameStatus === 'llm' || state.nameStatus === 'manual')
22
- return;
23
- const title = await generateThreadTitle(args.content, args.model);
24
- if (!title)
25
- return;
26
- await args.storage.patchThreadState({
27
- channelId: args.channelId,
28
- threadId: args.threadId,
29
- state: { generatedName: title, nameStatus: 'llm' },
30
- });
31
- if (!args.emitEvent)
32
- return;
33
- await args.emitEvent({
34
- type: 'client:ui:thread:updated',
35
- data: {
36
- channelId: args.channelId,
37
- threadId: args.threadId,
38
- name: title,
39
- },
40
- meta: {
41
- agentId: ORCHESTRATOR_AGENT_ID,
42
- channelId: args.channelId,
43
- threadId: args.threadId,
44
- },
45
- });
46
- }
47
- /**
48
- * `thread-naming` — generates short LLM titles for new threads on the system agent.
49
- * Runs in the background on the first user message so the main turn is not blocked.
50
- */
51
- export const threadNamingPlugin = {
52
- id: 'thread-naming',
53
- name: 'Thread naming',
54
- description: 'Automatically generates short LLM titles for new conversation threads.',
55
- configSchema: {
56
- type: 'object',
57
- properties: {
58
- model: {
59
- type: 'string',
60
- description: 'Provider model string for title generation. Defaults to the openbot plugin model, then workspace config.',
61
- },
62
- },
63
- },
64
- factory: ({ agentId, agentDetails, config, storage, emitEvent }) => {
65
- if (agentId !== ORCHESTRATOR_AGENT_ID) {
66
- return () => { };
67
- }
68
- const model = resolveNamingModel(config, agentDetails.pluginRefs);
69
- return (builder) => {
70
- builder.on('agent:invoke', async function* (event, context) {
71
- const invoke = event;
72
- if (invoke.data?.role && invoke.data.role !== 'user')
73
- return;
74
- const threadId = context.state.threadId;
75
- const channelId = context.state.channelId;
76
- if (!threadId || !channelId)
77
- return;
78
- const content = typeof invoke.data?.content === 'string' ? invoke.data.content : '';
79
- if (!content.trim())
80
- return;
81
- const key = `${channelId}:${threadId}`;
82
- if (namingInFlight.has(key))
83
- return;
84
- namingInFlight.add(key);
85
- void maybeGenerateThreadName({
86
- storage,
87
- channelId,
88
- threadId,
89
- content,
90
- model,
91
- emitEvent,
92
- })
93
- .catch((error) => {
94
- console.warn('[thread-naming] Failed to generate thread name:', error);
95
- })
96
- .finally(() => {
97
- namingInFlight.delete(key);
98
- });
99
- });
100
- };
101
- },
102
- };
103
- export default threadNamingPlugin;
@@ -1,81 +0,0 @@
1
- import { generateText } from 'ai';
2
- import { openai } from '@ai-sdk/openai';
3
- import { anthropic } from '@ai-sdk/anthropic';
4
- import { loadConfig } from '../app/config.js';
5
- import { storageService } from '../plugins/storage/service.js';
6
- const THREAD_TITLE_MAX_LENGTH = 80;
7
- const namingInFlight = new Set();
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
- function normalizeTitle(raw) {
24
- let title = raw
25
- .replace(/^["'`]+|["'`]+$/g, '')
26
- .replace(/[.!?]+$/g, '')
27
- .replace(/\s+/g, ' ')
28
- .trim();
29
- if (!title)
30
- return '';
31
- if (title.length > THREAD_TITLE_MAX_LENGTH) {
32
- title = `${title.slice(0, THREAD_TITLE_MAX_LENGTH).trimEnd()}...`;
33
- }
34
- return title;
35
- }
36
- export async function generateThreadTitle(content, modelString) {
37
- const normalized = content.replace(/\s+/g, ' ').trim();
38
- if (!normalized)
39
- return undefined;
40
- const config = loadConfig();
41
- const model = resolveModel(modelString || config.model || 'openai/gpt-4o-mini');
42
- const result = await generateText({
43
- model,
44
- system: 'You name chat threads. Reply with ONLY a short title (3-6 words). No quotes, no trailing punctuation.',
45
- prompt: normalized.slice(0, 500),
46
- maxOutputTokens: 20,
47
- });
48
- return normalizeTitle(result.text) || undefined;
49
- }
50
- export async function maybeGenerateThreadName(args) {
51
- const key = `${args.channelId}:${args.threadId}`;
52
- if (namingInFlight.has(key))
53
- return;
54
- namingInFlight.add(key);
55
- try {
56
- const details = await storageService.getThreadDetails({
57
- channelId: args.channelId,
58
- threadId: args.threadId,
59
- });
60
- const state = details.state || {};
61
- if (state.nameStatus === 'llm' || state.nameStatus === 'manual')
62
- return;
63
- if (state.nameStatus !== 'provisional')
64
- return;
65
- const title = await generateThreadTitle(args.content);
66
- if (!title)
67
- return;
68
- await storageService.patchThreadState({
69
- channelId: args.channelId,
70
- threadId: args.threadId,
71
- state: { generatedName: title, nameStatus: 'llm' },
72
- });
73
- await args.onUpdated?.(title);
74
- }
75
- catch (error) {
76
- console.warn('[thread-naming] Failed to generate thread name:', error);
77
- }
78
- finally {
79
- namingInFlight.delete(key);
80
- }
81
- }