@agi-cli/server 0.1.55

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.
@@ -0,0 +1,392 @@
1
+ import { generateText } from 'ai';
2
+ import { eq } from 'drizzle-orm';
3
+ import type { AGIConfig } from '@agi-cli/sdk';
4
+ import type { DB } from '@agi-cli/database';
5
+ import { messages, messageParts, sessions } from '@agi-cli/database/schema';
6
+ import { publish } from '../events/bus.ts';
7
+ import { enqueueAssistantRun } from './runner.ts';
8
+ import { resolveModel } from './provider.ts';
9
+ import type { ProviderId } from '@agi-cli/sdk';
10
+ import { debugLog } from './debug.ts';
11
+
12
+ type SessionRow = typeof sessions.$inferSelect;
13
+
14
+ type DispatchOptions = {
15
+ cfg: AGIConfig;
16
+ db: DB;
17
+ session: SessionRow;
18
+ agent: string;
19
+ provider: ProviderId;
20
+ model: string;
21
+ content: string;
22
+ oneShot?: boolean;
23
+ };
24
+
25
+ export async function dispatchAssistantMessage(
26
+ options: DispatchOptions,
27
+ ): Promise<{ assistantMessageId: string }> {
28
+ const { cfg, db, session, agent, provider, model, content, oneShot } =
29
+ options;
30
+ const sessionId = session.id;
31
+ const now = Date.now();
32
+ const userMessageId = crypto.randomUUID();
33
+
34
+ await db.insert(messages).values({
35
+ id: userMessageId,
36
+ sessionId,
37
+ role: 'user',
38
+ status: 'complete',
39
+ agent,
40
+ provider,
41
+ model,
42
+ createdAt: now,
43
+ });
44
+ await db.insert(messageParts).values({
45
+ id: crypto.randomUUID(),
46
+ messageId: userMessageId,
47
+ index: 0,
48
+ type: 'text',
49
+ content: JSON.stringify({ text: String(content) }),
50
+ agent,
51
+ provider,
52
+ model,
53
+ });
54
+ publish({
55
+ type: 'message.created',
56
+ sessionId,
57
+ payload: { id: userMessageId, role: 'user' },
58
+ });
59
+
60
+ enqueueSessionTitle({ cfg, db, sessionId, content });
61
+
62
+ const assistantMessageId = crypto.randomUUID();
63
+ await db.insert(messages).values({
64
+ id: assistantMessageId,
65
+ sessionId,
66
+ role: 'assistant',
67
+ status: 'pending',
68
+ agent,
69
+ provider,
70
+ model,
71
+ createdAt: Date.now(),
72
+ });
73
+ const assistantPartId = crypto.randomUUID();
74
+ const startTs = Date.now();
75
+ await db.insert(messageParts).values({
76
+ id: assistantPartId,
77
+ messageId: assistantMessageId,
78
+ index: 0,
79
+ stepIndex: 0,
80
+ type: 'text',
81
+ content: JSON.stringify({ text: '' }),
82
+ agent,
83
+ provider,
84
+ model,
85
+ startedAt: startTs,
86
+ });
87
+ publish({
88
+ type: 'message.created',
89
+ sessionId,
90
+ payload: { id: assistantMessageId, role: 'assistant' },
91
+ });
92
+
93
+ enqueueAssistantRun({
94
+ sessionId,
95
+ assistantMessageId,
96
+ assistantPartId,
97
+ agent,
98
+ provider,
99
+ model,
100
+ projectRoot: cfg.projectRoot,
101
+ oneShot: Boolean(oneShot),
102
+ });
103
+
104
+ void touchSessionLastActive({ db, sessionId });
105
+
106
+ return { assistantMessageId };
107
+ }
108
+
109
+ const TITLE_CONCURRENCY_LIMIT = 1;
110
+ const titleQueue: Array<() => void> = [];
111
+ let titleActiveCount = 0;
112
+ const titleInFlight = new Set<string>();
113
+ const titlePending = new Set<string>();
114
+
115
+ function scheduleSessionTitle(args: {
116
+ cfg: AGIConfig;
117
+ db: DB;
118
+ sessionId: string;
119
+ content: unknown;
120
+ }): void {
121
+ const { sessionId } = args;
122
+ if (titleInFlight.has(sessionId)) return;
123
+ titleInFlight.add(sessionId);
124
+ void (async () => {
125
+ try {
126
+ const alreadyTitled = await sessionHasTitle(args.db, sessionId);
127
+ if (alreadyTitled) return;
128
+ await withTitleSlot(() => updateSessionTitle(args));
129
+ } catch {
130
+ // Swallow title generation errors; they are non-blocking.
131
+ } finally {
132
+ titleInFlight.delete(sessionId);
133
+ }
134
+ })();
135
+ }
136
+
137
+ function enqueueSessionTitle(args: {
138
+ cfg: AGIConfig;
139
+ db: DB;
140
+ sessionId: string;
141
+ content: unknown;
142
+ }) {
143
+ const { sessionId } = args;
144
+ if (titlePending.has(sessionId) || titleInFlight.has(sessionId)) return;
145
+ titlePending.add(sessionId);
146
+ Promise.resolve()
147
+ .then(() => {
148
+ titlePending.delete(sessionId);
149
+ scheduleSessionTitle(args);
150
+ })
151
+ .catch(() => {
152
+ titlePending.delete(sessionId);
153
+ });
154
+ }
155
+
156
+ async function updateSessionTitle(args: {
157
+ cfg: AGIConfig;
158
+ db: DB;
159
+ sessionId: string;
160
+ content: unknown;
161
+ }) {
162
+ const { cfg, db, sessionId, content } = args;
163
+ try {
164
+ const rows = await db
165
+ .select()
166
+ .from(sessions)
167
+ .where(eq(sessions.id, sessionId));
168
+ if (!rows.length) return;
169
+ const current = rows[0];
170
+ const alreadyHasTitle =
171
+ current.title != null && String(current.title).trim().length > 0;
172
+ let heuristic = '';
173
+ if (!alreadyHasTitle) {
174
+ heuristic = deriveTitle(String(content ?? ''));
175
+ if (heuristic) {
176
+ await db
177
+ .update(sessions)
178
+ .set({ title: heuristic })
179
+ .where(eq(sessions.id, sessionId));
180
+ publish({
181
+ type: 'session.updated',
182
+ sessionId,
183
+ payload: { title: heuristic },
184
+ });
185
+ }
186
+ }
187
+
188
+ const providerId =
189
+ (current.provider as ProviderId) ?? cfg.defaults.provider;
190
+ const modelId = current.model ?? cfg.defaults.model;
191
+
192
+ debugLog('[TITLE_GEN] Starting title generation');
193
+ debugLog(`[TITLE_GEN] Provider: ${providerId}, Model: ${modelId}`);
194
+
195
+ // Check if we need OAuth spoof prompt (same logic as runner)
196
+ const { getAuth } = await import('@agi-cli/sdk');
197
+ const { getProviderSpoofPrompt } = await import('./prompt.ts');
198
+ const auth = await getAuth(providerId, cfg.projectRoot);
199
+ const needsSpoof = auth?.type === 'oauth';
200
+ const spoofPrompt = needsSpoof
201
+ ? getProviderSpoofPrompt(providerId)
202
+ : undefined;
203
+
204
+ debugLog(`[TITLE_GEN] Auth type: ${auth?.type ?? 'none'}`);
205
+ debugLog(`[TITLE_GEN] Needs spoof: ${needsSpoof}`);
206
+ debugLog(`[TITLE_GEN] Spoof prompt length: ${spoofPrompt?.length ?? 0}`);
207
+
208
+ const model = await resolveModel(providerId, modelId, cfg);
209
+ const promptText = String(content ?? '').slice(0, 2000);
210
+
211
+ const titlePrompt = [
212
+ "Create a short, descriptive session title from the user's request.",
213
+ 'Max 6–8 words. No quotes. No trailing punctuation.',
214
+ 'Avoid generic phrases like "help me"; be specific.',
215
+ ].join(' ');
216
+
217
+ // Build system prompt and messages
218
+ // For OAuth: Keep spoof pure, add instructions to user message
219
+ // For API key: Use instructions as system
220
+ let system: string;
221
+ let messagesArray: Array<{ role: 'user'; content: string }>;
222
+
223
+ if (spoofPrompt) {
224
+ // OAuth mode: spoof stays pure, instructions go in user message
225
+ system = spoofPrompt;
226
+ messagesArray = [
227
+ {
228
+ role: 'user',
229
+ content: `${titlePrompt}\n\n${promptText}`,
230
+ },
231
+ ];
232
+
233
+ debugLog('[TITLE_GEN] Using OAuth mode:');
234
+ debugLog(`[TITLE_GEN] System prompt (spoof): ${spoofPrompt}`);
235
+ debugLog(`[TITLE_GEN] User message (instructions + content):`);
236
+ debugLog(`[TITLE_GEN] Instructions: ${titlePrompt}`);
237
+ debugLog(`[TITLE_GEN] Content: ${promptText.substring(0, 100)}...`);
238
+ } else {
239
+ // API key mode: normal flow
240
+ system = titlePrompt;
241
+ messagesArray = [{ role: 'user', content: promptText }];
242
+
243
+ debugLog('[TITLE_GEN] Using API key mode:');
244
+ debugLog(`[TITLE_GEN] System prompt: ${system}`);
245
+ debugLog(`[TITLE_GEN] User message: ${promptText.substring(0, 100)}...`);
246
+ }
247
+
248
+ debugLog('[TITLE_GEN] Calling generateText...');
249
+ let modelTitle = '';
250
+ try {
251
+ const out = await generateText({
252
+ model,
253
+ system,
254
+ messages: messagesArray,
255
+ });
256
+ modelTitle = (out?.text || '').trim();
257
+
258
+ debugLog('[TITLE_GEN] Raw response from model:');
259
+ debugLog(`[TITLE_GEN] "${modelTitle}"`);
260
+ } catch (err) {
261
+ debugLog('[TITLE_GEN] Error generating title:');
262
+ debugLog(err);
263
+ }
264
+
265
+ if (!modelTitle) {
266
+ debugLog('[TITLE_GEN] No title returned, aborting');
267
+ return;
268
+ }
269
+
270
+ const sanitized = sanitizeTitle(modelTitle);
271
+ debugLog(`[TITLE_GEN] After sanitization: "${sanitized}"`);
272
+
273
+ if (!sanitized) {
274
+ debugLog('[TITLE_GEN] Title sanitized to empty, aborting');
275
+ return;
276
+ }
277
+
278
+ modelTitle = sanitized;
279
+
280
+ const check = await db
281
+ .select()
282
+ .from(sessions)
283
+ .where(eq(sessions.id, sessionId));
284
+ if (!check.length) return;
285
+ const currentTitle = String(check[0].title ?? '').trim();
286
+ if (currentTitle && currentTitle !== heuristic) {
287
+ debugLog(
288
+ `[TITLE_GEN] Session already has different title: "${currentTitle}", skipping`,
289
+ );
290
+ return;
291
+ }
292
+
293
+ debugLog(`[TITLE_GEN] Setting final title: "${modelTitle}"`);
294
+ await db
295
+ .update(sessions)
296
+ .set({ title: modelTitle })
297
+ .where(eq(sessions.id, sessionId));
298
+ publish({
299
+ type: 'session.updated',
300
+ sessionId,
301
+ payload: { title: modelTitle },
302
+ });
303
+ } catch (err) {
304
+ debugLog('[TITLE_GEN] Fatal error:');
305
+ debugLog(err);
306
+ }
307
+ }
308
+
309
+ async function withTitleSlot<T>(fn: () => Promise<T>): Promise<T> {
310
+ await acquireTitleSlot();
311
+ try {
312
+ return await fn();
313
+ } finally {
314
+ releaseTitleSlot();
315
+ }
316
+ }
317
+
318
+ async function sessionHasTitle(db: DB, sessionId: string): Promise<boolean> {
319
+ try {
320
+ const rows = await db
321
+ .select({ title: sessions.title })
322
+ .from(sessions)
323
+ .where(eq(sessions.id, sessionId))
324
+ .limit(1);
325
+ if (!rows.length) return false;
326
+ const title = rows[0]?.title;
327
+ return typeof title === 'string' && title.trim().length > 0;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ function acquireTitleSlot(): Promise<void> {
334
+ if (titleActiveCount < TITLE_CONCURRENCY_LIMIT) {
335
+ titleActiveCount += 1;
336
+ return Promise.resolve();
337
+ }
338
+ return new Promise((resolve) => {
339
+ titleQueue.push(() => {
340
+ titleActiveCount += 1;
341
+ resolve();
342
+ });
343
+ });
344
+ }
345
+
346
+ function releaseTitleSlot(): void {
347
+ if (titleActiveCount > 0) titleActiveCount -= 1;
348
+ const next = titleQueue.shift();
349
+ if (next) {
350
+ next();
351
+ }
352
+ }
353
+
354
+ async function touchSessionLastActive(args: { db: DB; sessionId: string }) {
355
+ const { db, sessionId } = args;
356
+ try {
357
+ await db
358
+ .update(sessions)
359
+ .set({ lastActiveAt: Date.now() })
360
+ .where(eq(sessions.id, sessionId));
361
+ } catch {}
362
+ }
363
+
364
+ function deriveTitle(text: string): string {
365
+ const cleaned = String(text || '')
366
+ .replace(/```[\s\S]*?```/g, ' ')
367
+ .replace(/`[^`]*`/g, ' ')
368
+ .replace(/\s+/g, ' ')
369
+ .trim();
370
+ if (!cleaned) return '';
371
+ const endIdx = (() => {
372
+ const punct = ['? ', '. ', '! ']
373
+ .map((p) => cleaned.indexOf(p))
374
+ .filter((i) => i > 0);
375
+ const idx = Math.min(...(punct.length ? punct : [cleaned.length]));
376
+ return Math.min(idx + 1, cleaned.length);
377
+ })();
378
+ const first = cleaned.slice(0, endIdx).trim();
379
+ const maxLen = 64;
380
+ const base = first.length > 8 ? first : cleaned;
381
+ const truncated =
382
+ base.length > maxLen ? `${base.slice(0, maxLen - 1).trimEnd()}…` : base;
383
+ return truncated;
384
+ }
385
+
386
+ function sanitizeTitle(s: string): string {
387
+ let t = s.trim();
388
+ t = t.replace(/^['"""''()[\]]+|['"""''()[\]]+$/g, '').trim();
389
+ t = t.replace(/[\s\-_:–—]+$/g, '').trim();
390
+ if (t.length > 64) t = `${t.slice(0, 63).trimEnd()}…`;
391
+ return t;
392
+ }
@@ -0,0 +1,79 @@
1
+ import { providerBasePrompt } from '@agi-cli/sdk';
2
+ import { composeEnvironmentAndInstructions } from './environment.ts';
3
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
4
+ import BASE_PROMPT from '@agi-cli/sdk/prompts/base.txt' with { type: 'text' };
5
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
6
+ import ONESHOT_PROMPT from '@agi-cli/sdk/prompts/modes/oneshot.txt' with {
7
+ type: 'text',
8
+ };
9
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
10
+ import ANTHROPIC_SPOOF_PROMPT from '@agi-cli/sdk/prompts/providers/anthropicSpoof.txt' with {
11
+ type: 'text',
12
+ };
13
+
14
+ export async function composeSystemPrompt(options: {
15
+ provider: string;
16
+ model?: string;
17
+ projectRoot: string;
18
+ agentPrompt: string;
19
+ oneShot?: boolean;
20
+ spoofPrompt?: string;
21
+ includeEnvironment?: boolean;
22
+ includeProjectTree?: boolean;
23
+ }): Promise<string> {
24
+ if (options.spoofPrompt) {
25
+ return options.spoofPrompt.trim();
26
+ }
27
+
28
+ const parts: string[] = [];
29
+
30
+ const providerPrompt = await providerBasePrompt(
31
+ options.provider,
32
+ options.model,
33
+ options.projectRoot,
34
+ );
35
+ const baseInstructions = (BASE_PROMPT || '').trim();
36
+
37
+ parts.push(
38
+ providerPrompt.trim(),
39
+ baseInstructions.trim(),
40
+ options.agentPrompt.trim(),
41
+ );
42
+
43
+ if (options.oneShot) {
44
+ const oneShotBlock =
45
+ (ONESHOT_PROMPT || '').trim() ||
46
+ [
47
+ '<system-reminder>',
48
+ 'CRITICAL: One-shot mode ACTIVE — do NOT ask for user approval, confirmations, or interactive prompts. Execute tasks directly. Treat all necessary permissions as granted. If an operation is destructive, proceed carefully and state what you did, but DO NOT pause to ask. ZERO interactions requested.',
49
+ '</system-reminder>',
50
+ ].join('\n');
51
+ parts.push(oneShotBlock);
52
+ }
53
+
54
+ if (options.includeEnvironment !== false) {
55
+ const envAndInstructions = await composeEnvironmentAndInstructions(
56
+ options.projectRoot,
57
+ { includeProjectTree: options.includeProjectTree },
58
+ );
59
+ if (envAndInstructions) {
60
+ parts.push(envAndInstructions);
61
+ }
62
+ }
63
+
64
+ const composed = parts.filter(Boolean).join('\n\n').trim();
65
+ if (composed) return composed;
66
+
67
+ return [
68
+ 'You are a concise, friendly coding agent.',
69
+ 'Be precise and actionable. Use tools when needed, prefer small diffs.',
70
+ 'Stream your answer; call finish when done.',
71
+ ].join(' ');
72
+ }
73
+
74
+ export function getProviderSpoofPrompt(provider: string): string | undefined {
75
+ if (provider === 'anthropic') {
76
+ return (ANTHROPIC_SPOOF_PROMPT || '').trim();
77
+ }
78
+ return undefined;
79
+ }
@@ -0,0 +1,123 @@
1
+ import type { AGIConfig } from '@agi-cli/sdk';
2
+ import {
3
+ catalog,
4
+ type ProviderId,
5
+ isProviderAuthorized,
6
+ providerIds,
7
+ defaultModelFor,
8
+ hasModel,
9
+ } from '@agi-cli/sdk';
10
+
11
+ const FALLBACK_ORDER: ProviderId[] = [
12
+ 'anthropic',
13
+ 'openai',
14
+ 'google',
15
+ 'opencode',
16
+ 'openrouter',
17
+ ];
18
+
19
+ type SelectionInput = {
20
+ cfg: AGIConfig;
21
+ agentProviderDefault: ProviderId;
22
+ agentModelDefault: string;
23
+ explicitProvider?: ProviderId;
24
+ explicitModel?: string;
25
+ skipAuth?: boolean;
26
+ };
27
+
28
+ export type ProviderSelection = {
29
+ provider: ProviderId;
30
+ model: string;
31
+ providerOverride?: ProviderId;
32
+ modelOverride?: string;
33
+ };
34
+
35
+ export async function selectProviderAndModel(
36
+ input: SelectionInput,
37
+ ): Promise<ProviderSelection> {
38
+ const {
39
+ cfg,
40
+ agentProviderDefault,
41
+ agentModelDefault,
42
+ explicitProvider,
43
+ explicitModel,
44
+ skipAuth,
45
+ } = input;
46
+
47
+ const provider = skipAuth
48
+ ? (explicitProvider ?? agentProviderDefault)
49
+ : await pickAuthorizedProvider({
50
+ cfg,
51
+ candidate: explicitProvider ?? agentProviderDefault,
52
+ explicitProvider,
53
+ });
54
+
55
+ if (!provider) {
56
+ throw new Error(
57
+ 'No authorized providers found. Run `agi auth login` to configure at least one provider.',
58
+ );
59
+ }
60
+
61
+ const model = resolveModelForProvider({
62
+ provider,
63
+ explicitModel,
64
+ agentModelDefault,
65
+ });
66
+
67
+ const providerOverride =
68
+ explicitProvider ??
69
+ (provider !== agentProviderDefault ? provider : undefined);
70
+ const modelOverride =
71
+ explicitModel ?? (model !== agentModelDefault ? model : undefined);
72
+
73
+ return { provider, model, providerOverride, modelOverride };
74
+ }
75
+
76
+ async function pickAuthorizedProvider(args: {
77
+ cfg: AGIConfig;
78
+ candidate: ProviderId;
79
+ explicitProvider?: ProviderId;
80
+ }): Promise<ProviderId | undefined> {
81
+ const { cfg, candidate, explicitProvider } = args;
82
+ const candidates = uniqueProviders([
83
+ candidate,
84
+ ...FALLBACK_ORDER,
85
+ ...providerIds,
86
+ ]);
87
+ for (const provider of candidates) {
88
+ const enabled = cfg.providers[provider]?.enabled ?? true;
89
+ const explicitlyRequested =
90
+ explicitProvider != null && provider === explicitProvider;
91
+ if (!enabled && !explicitlyRequested) continue;
92
+ const ok = await isProviderAuthorized(cfg, provider);
93
+ if (ok) return provider;
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ function uniqueProviders(list: ProviderId[]): ProviderId[] {
99
+ const seen = new Set<ProviderId>();
100
+ const ordered: ProviderId[] = [];
101
+ for (const provider of list) {
102
+ if (!providerIds.includes(provider)) continue;
103
+ if (seen.has(provider)) continue;
104
+ seen.add(provider);
105
+ ordered.push(provider);
106
+ }
107
+ return ordered;
108
+ }
109
+
110
+ function resolveModelForProvider(args: {
111
+ provider: ProviderId;
112
+ explicitModel?: string;
113
+ agentModelDefault: string;
114
+ }): string {
115
+ const { provider, explicitModel, agentModelDefault } = args;
116
+ if (explicitModel && hasModel(provider, explicitModel)) return explicitModel;
117
+ if (hasModel(provider, agentModelDefault)) return agentModelDefault;
118
+ return (
119
+ defaultModelFor(provider) ??
120
+ catalog[provider]?.models?.[0]?.id ??
121
+ agentModelDefault
122
+ );
123
+ }