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.
- package/dist/app/channel-ids.js +3 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/server.js +24 -2
- package/dist/plugins/openbot/context.js +6 -25
- package/dist/plugins/openbot/runtime.js +116 -81
- package/dist/plugins/openbot/system-prompt.js +3 -6
- package/dist/plugins/plugin-manager/index.js +3 -45
- package/dist/plugins/storage/index.js +2 -41
- package/dist/plugins/storage/service.js +54 -14
- package/dist/services/plugins/service.js +0 -4
- package/package.json +2 -1
- package/src/app/channel-ids.ts +5 -0
- package/src/app/cli.ts +1 -1
- package/src/app/server.ts +31 -3
- package/src/app/types.ts +2 -10
- package/src/plugins/openbot/context.ts +7 -26
- package/src/plugins/openbot/runtime.ts +137 -89
- package/src/plugins/openbot/system-prompt.ts +3 -8
- package/src/plugins/plugin-manager/index.ts +2 -50
- package/src/plugins/storage/index.ts +4 -46
- package/src/plugins/storage/service.ts +78 -14
- package/src/services/plugins/domain.ts +7 -4
- package/src/services/plugins/service.ts +0 -6
- package/dist/plugins/thread-naming/generate-title.js +0 -44
- package/dist/plugins/thread-naming/index.js +0 -103
- package/dist/services/thread-naming.js +0 -81
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
|
421
|
+
): { name?: string; cwd?: string } => {
|
|
422
422
|
if (!isRecord(parsed)) {
|
|
423
|
-
return {
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
return
|
|
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
|
|
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
|
-
}
|