openbot 0.4.4 → 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.
@@ -24,6 +24,7 @@ import {
24
24
  PluginDescriptor,
25
25
  Thread,
26
26
  ThreadDetails,
27
+ ThreadState,
27
28
  } from '../../services/plugins/domain.js';
28
29
  import type { PluginRef } from '../../services/plugins/types.js';
29
30
  import { openbotPlugin } from '../openbot/index.js';
@@ -417,20 +418,20 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
417
418
  /** Display-oriented fields persisted in a channel's `state.json`. */
418
419
  const readChannelStateFileFields = (
419
420
  parsed: unknown,
420
- ): { name?: string; cwd?: string; participants: string[] } => {
421
+ ): { name?: string; cwd?: string } => {
421
422
  if (!isRecord(parsed)) {
422
- return { participants: [] };
423
+ return {};
423
424
  }
424
425
  const name =
425
426
  typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : undefined;
426
427
  const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : undefined;
427
- const participants: string[] = [];
428
- if (Array.isArray(parsed.participants)) {
429
- for (const x of parsed.participants) {
430
- if (typeof x === 'string' && x.trim()) participants.push(x.trim());
431
- }
432
- }
433
- 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;
434
435
  };
435
436
 
436
437
  /**
@@ -506,24 +507,25 @@ export const storageService = {
506
507
  const statePath = path.join(channelDir, 'state.json');
507
508
  let cwd: string | undefined;
508
509
  let displayName = name;
509
- let participants: string[] = [];
510
510
 
511
511
  try {
512
512
  const parsed = await readJsonFile(statePath, {});
513
513
  const fields = readChannelStateFileFields(parsed);
514
514
  cwd = fields.cwd;
515
515
  displayName = fields.name ?? name;
516
- participants = fields.participants;
517
516
  } catch {
518
517
  // ignore
519
518
  }
520
519
 
520
+ if (!cwd) {
521
+ return null;
522
+ }
523
+
521
524
  const channel: Channel = {
522
525
  id: name,
523
526
  name: displayName,
524
527
  description: '',
525
528
  cwd,
526
- participants,
527
529
  createdAt: stats.birthtime,
528
530
  updatedAt: stats.mtime,
529
531
  };
@@ -553,7 +555,9 @@ export const storageService = {
553
555
  }),
554
556
  );
555
557
 
556
- 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());
557
561
  },
558
562
  createChannel: async ({
559
563
  channelId,
@@ -605,6 +609,64 @@ export const storageService = {
605
609
  );
606
610
  await writeJsonFileAtomically(statePath, finalState);
607
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
+ },
608
670
  deleteChannel: async ({ channelId }: { channelId: string }): Promise<void> => {
609
671
  const normalizedChannelId = channelId.trim();
610
672
  if (!normalizedChannelId) {
@@ -771,7 +833,7 @@ export const storageService = {
771
833
  id: threadId,
772
834
  name: threadName || threadId,
773
835
  channelId,
774
- state,
836
+ state: (isRecord(state) ? state : {}) as ThreadState,
775
837
  };
776
838
  },
777
839
  getChannelDetails: async ({ channelId }: { channelId: string }): Promise<ChannelDetails> => {
@@ -807,7 +869,6 @@ export const storageService = {
807
869
  spec,
808
870
  state,
809
871
  cwd,
810
- participants: diskFields.participants,
811
872
  };
812
873
 
813
874
  details.threads = await storageService.getThreads({ channelId });
@@ -1289,6 +1350,10 @@ export const storageService = {
1289
1350
  event: OpenBotEvent;
1290
1351
  }): Promise<void> => {
1291
1352
  try {
1353
+ if (!(await isChannelProvisioned(channelId))) {
1354
+ return;
1355
+ }
1356
+
1292
1357
  const threadDir = getConversationDir(channelId, threadId);
1293
1358
  if (threadId) {
1294
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;
@@ -81,11 +79,25 @@ export type Thread = {
81
79
  hasUnseenMessages?: boolean;
82
80
  };
83
81
 
82
+ /** Persisted thread `state.json` fields (additional keys are allowed). */
83
+ export type ThreadState = {
84
+ name?: string;
85
+ /** Sticky agent id for this thread (`system` = orchestrator). Set once, then enforced on publish. */
86
+ respondingAgentId?: string;
87
+ pendingToolCallIds?: string[];
88
+ usage?: {
89
+ promptTokens?: number;
90
+ completionTokens?: number;
91
+ totalTokens?: number;
92
+ };
93
+ [key: string]: unknown;
94
+ };
95
+
84
96
  export type ThreadDetails = {
85
97
  id: string;
86
98
  name: string;
87
99
  channelId: string;
88
- state: unknown;
100
+ state: ThreadState;
89
101
  };
90
102
 
91
103
  export type ChannelDetails = {
@@ -94,8 +106,6 @@ export type ChannelDetails = {
94
106
  spec: string;
95
107
  state: unknown;
96
108
  cwd?: string;
97
- /** Agent ids for this channel (from `state.json`). */
98
- participants: string[];
99
109
  threads?: Thread[];
100
110
  };
101
111
 
@@ -107,6 +117,13 @@ export interface Storage {
107
117
  initialState?: Record<string, unknown>;
108
118
  cwd?: string;
109
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>;
110
127
  /** Removes the channel directory and cleans up `_meta/last-read.json`. */
111
128
  deleteChannel: (args: { channelId: string }) => Promise<void>;
112
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
- }