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.
- package/dist/app/channel-ids.js +3 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/responding-agent.js +48 -0
- package/dist/app/server.js +112 -6
- 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 +55 -15
- 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/responding-agent.ts +74 -0
- package/src/app/server.ts +137 -7
- package/src/app/types.ts +7 -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 +80 -15
- package/src/services/plugins/domain.ts +22 -5
- 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
|
@@ -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
|
|
421
|
+
): { name?: string; cwd?: string } => {
|
|
421
422
|
if (!isRecord(parsed)) {
|
|
422
|
-
return {
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
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;
|
|
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
|
|
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:
|
|
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
|
-
}
|