@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,363 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { loadConfig } from '@agi-cli/sdk';
3
+ import { getDb } from '@agi-cli/database';
4
+ import {
5
+ createSession,
6
+ getLastSession,
7
+ getSessionById,
8
+ } from './session-manager.ts';
9
+ import {
10
+ selectProviderAndModel,
11
+ type ProviderSelection,
12
+ } from './provider-selection.ts';
13
+ import { resolveAgentConfig } from './agent-registry.ts';
14
+ import { dispatchAssistantMessage } from './message-service.ts';
15
+ import {
16
+ validateProviderModel,
17
+ isProviderAuthorized,
18
+ ensureProviderEnv,
19
+ isProviderId,
20
+ type ProviderId,
21
+ } from '@agi-cli/sdk';
22
+ import { sessions } from '@agi-cli/database/schema';
23
+ import { time } from './debug.ts';
24
+
25
+ export class AskServiceError extends Error {
26
+ constructor(
27
+ message: string,
28
+ public status = 400,
29
+ public code = 'ASK_SERVICE_ERROR',
30
+ ) {
31
+ super(message);
32
+ this.name = 'AskServiceError';
33
+ }
34
+
35
+ toJSON() {
36
+ return {
37
+ name: this.name,
38
+ message: this.message,
39
+ code: this.code,
40
+ status: this.status,
41
+ };
42
+ }
43
+ }
44
+
45
+ export type InjectableConfig = {
46
+ provider?: string;
47
+ model?: string;
48
+ apiKey?: string;
49
+ agent?: string;
50
+ };
51
+
52
+ export type InjectableCredentials = Partial<
53
+ Record<ProviderId, { apiKey: string }>
54
+ >;
55
+
56
+ export type AskServerRequest = {
57
+ projectRoot?: string;
58
+ prompt: string;
59
+ agent?: string;
60
+ provider?: string;
61
+ model?: string;
62
+ sessionId?: string;
63
+ last?: boolean;
64
+ jsonMode?: boolean;
65
+ skipFileConfig?: boolean;
66
+ config?: InjectableConfig;
67
+ credentials?: InjectableCredentials;
68
+ agentPrompt?: string;
69
+ tools?: string[];
70
+ };
71
+
72
+ export type AskServerResponse = {
73
+ sessionId: string;
74
+ header: {
75
+ agent?: string;
76
+ provider?: string;
77
+ model?: string;
78
+ sessionId: string;
79
+ };
80
+ provider: ProviderId;
81
+ model: string;
82
+ agent: string;
83
+ assistantMessageId: string;
84
+ message?: { kind: 'created' | 'last'; sessionId: string };
85
+ };
86
+
87
+ type SessionRow =
88
+ typeof import('@agi-cli/database/schema').sessions.$inferSelect;
89
+
90
+ export async function handleAskRequest(
91
+ request: AskServerRequest,
92
+ ): Promise<AskServerResponse> {
93
+ try {
94
+ return await processAskRequest(request);
95
+ } catch (err) {
96
+ throw normalizeAskServiceError(err);
97
+ }
98
+ }
99
+
100
+ async function processAskRequest(
101
+ request: AskServerRequest,
102
+ ): Promise<AskServerResponse> {
103
+ const projectRoot = request.projectRoot || process.cwd();
104
+ const configTimer = time('ask:loadConfig+db');
105
+
106
+ let cfg: import('@agi-cli/sdk').AGIConfig;
107
+
108
+ if (request.skipFileConfig || request.config) {
109
+ const injectedProvider = (request.config?.provider ||
110
+ request.provider ||
111
+ 'openai') as ProviderId;
112
+ const injectedModel =
113
+ request.config?.model || request.model || 'gpt-4o-mini';
114
+ const injectedAgent = request.config?.agent || request.agent || 'general';
115
+
116
+ cfg = {
117
+ projectRoot,
118
+ defaults: {
119
+ provider: injectedProvider,
120
+ model: injectedModel,
121
+ agent: injectedAgent,
122
+ },
123
+ providers: {
124
+ openai: { enabled: true },
125
+ anthropic: { enabled: true },
126
+ google: { enabled: true },
127
+ openrouter: { enabled: true },
128
+ opencode: { enabled: true },
129
+ },
130
+ paths: {
131
+ dataDir: `${projectRoot}/.agi`,
132
+ dbPath: `${projectRoot}/.agi/agi.sqlite`,
133
+ projectConfigPath: null,
134
+ globalConfigPath: null,
135
+ },
136
+ };
137
+
138
+ if (request.credentials) {
139
+ for (const [provider, creds] of Object.entries(request.credentials)) {
140
+ const envKey = `${provider.toUpperCase()}_API_KEY`;
141
+ process.env[envKey] = creds.apiKey;
142
+ }
143
+ } else if (request.config?.apiKey) {
144
+ const envKey = `${injectedProvider.toUpperCase()}_API_KEY`;
145
+ process.env[envKey] = request.config.apiKey;
146
+ }
147
+ } else {
148
+ cfg = await loadConfig(projectRoot);
149
+ }
150
+
151
+ const db = await getDb(cfg.projectRoot);
152
+ configTimer.end();
153
+
154
+ let session: SessionRow | undefined;
155
+ let messageIndicator: AskServerResponse['message'];
156
+
157
+ if (request.sessionId) {
158
+ session = await getSessionById({
159
+ db,
160
+ projectPath: cfg.projectRoot,
161
+ sessionId: request.sessionId,
162
+ });
163
+ if (!session) {
164
+ throw new AskServiceError(`Session not found: ${request.sessionId}`, 404);
165
+ }
166
+ } else if (request.last) {
167
+ session = await getLastSession({ db, projectPath: cfg.projectRoot });
168
+ if (session) {
169
+ messageIndicator = { kind: 'last', sessionId: session.id };
170
+ }
171
+ } else {
172
+ session = undefined;
173
+ }
174
+
175
+ const agentName = (() => {
176
+ if (request.agent) return request.agent;
177
+ if (session?.agent) return session.agent;
178
+ return cfg.defaults.agent;
179
+ })();
180
+
181
+ const agentTimer = time('ask:resolveAgentConfig');
182
+ const agentCfg = request.agentPrompt
183
+ ? {
184
+ name: agentName,
185
+ prompt: request.agentPrompt,
186
+ tools: request.tools ?? ['progress_update', 'finish'],
187
+ provider: isProviderId(request.provider)
188
+ ? (request.provider as ProviderId)
189
+ : undefined,
190
+ model: request.model,
191
+ }
192
+ : await resolveAgentConfig(cfg.projectRoot, agentName);
193
+ agentTimer.end({ agent: agentName });
194
+ const agentProviderDefault = isProviderId(agentCfg.provider)
195
+ ? agentCfg.provider
196
+ : cfg.defaults.provider;
197
+ const agentModelDefault = agentCfg.model ?? cfg.defaults.model;
198
+
199
+ const explicitProvider = isProviderId(request.provider)
200
+ ? (request.provider as ProviderId)
201
+ : undefined;
202
+
203
+ let providerSelection: ProviderSelection;
204
+ const selectTimer = time('ask:selectProviderModel');
205
+ try {
206
+ providerSelection = await selectProviderAndModel({
207
+ cfg,
208
+ agentProviderDefault,
209
+ agentModelDefault,
210
+ explicitProvider,
211
+ explicitModel: request.model,
212
+ skipAuth: Boolean(
213
+ request.skipFileConfig || request.config || request.credentials,
214
+ ),
215
+ });
216
+ selectTimer.end({ provider: providerSelection.provider });
217
+ } catch (err) {
218
+ selectTimer.end();
219
+ throw normalizeAskServiceError(err);
220
+ }
221
+
222
+ if (!session) {
223
+ const createTimer = time('ask:createSession');
224
+ const newSession = await createSession({
225
+ db,
226
+ cfg,
227
+ agent: agentName,
228
+ provider: providerSelection.provider,
229
+ model: providerSelection.model,
230
+ title: null,
231
+ });
232
+ createTimer.end();
233
+ session = newSession;
234
+ messageIndicator = { kind: 'created', sessionId: newSession.id };
235
+ }
236
+ if (!session)
237
+ throw new AskServiceError('Failed to resolve or create session.', 500);
238
+
239
+ const overridesProvided = Boolean(request.provider || request.model);
240
+
241
+ let providerForMessage: ProviderId;
242
+ let modelForMessage: string;
243
+
244
+ if (overridesProvided) {
245
+ providerForMessage = providerSelection.provider;
246
+ modelForMessage = providerSelection.model;
247
+ } else if (session.provider && session.model) {
248
+ const sessionProvider = isProviderId(session.provider)
249
+ ? (session.provider as ProviderId)
250
+ : agentProviderDefault;
251
+ providerForMessage = sessionProvider;
252
+ modelForMessage = session.model;
253
+ } else {
254
+ providerForMessage = providerSelection.provider;
255
+ modelForMessage = providerSelection.model;
256
+ }
257
+
258
+ const providerAuthorized = await isProviderAuthorized(
259
+ cfg,
260
+ providerForMessage,
261
+ );
262
+ let fellBackToSelection = false;
263
+ if (!providerAuthorized) {
264
+ providerForMessage = providerSelection.provider;
265
+ modelForMessage = providerSelection.model;
266
+ fellBackToSelection = true;
267
+ }
268
+ if (
269
+ session &&
270
+ fellBackToSelection &&
271
+ (session.provider !== providerForMessage ||
272
+ session.model !== modelForMessage)
273
+ ) {
274
+ await db
275
+ .update(sessions)
276
+ .set({ provider: providerForMessage, model: modelForMessage })
277
+ .where(eq(sessions.id, session.id));
278
+ session = {
279
+ ...session,
280
+ provider: providerForMessage,
281
+ model: modelForMessage,
282
+ } as SessionRow;
283
+ }
284
+
285
+ validateProviderModel(providerForMessage, modelForMessage);
286
+
287
+ if (!request.skipFileConfig && !request.config && !request.credentials) {
288
+ await ensureProviderEnv(cfg, providerForMessage);
289
+ }
290
+
291
+ const assistantMessage = await dispatchAssistantMessage({
292
+ cfg,
293
+ db,
294
+ session,
295
+ agent: agentName,
296
+ provider: providerForMessage,
297
+ model: modelForMessage,
298
+ content: request.prompt,
299
+ oneShot: !request.sessionId && !request.last,
300
+ });
301
+
302
+ const headerAgent = session.agent ?? agentName;
303
+ const headerProvider = providerForMessage;
304
+ const headerModel = modelForMessage;
305
+
306
+ return {
307
+ sessionId: session.id,
308
+ header: {
309
+ agent: headerAgent,
310
+ provider: headerProvider,
311
+ model: headerModel,
312
+ sessionId: session.id,
313
+ },
314
+ provider: providerForMessage,
315
+ model: modelForMessage,
316
+ agent: agentName,
317
+ assistantMessageId: assistantMessage.assistantMessageId,
318
+ message: messageIndicator,
319
+ };
320
+ }
321
+
322
+ function normalizeAskServiceError(err: unknown): AskServiceError {
323
+ if (err instanceof AskServiceError) return err;
324
+ if (err instanceof Error) {
325
+ const status = inferStatus(err);
326
+ const message = err.message || 'Unknown error';
327
+ return new AskServiceError(message, status);
328
+ }
329
+ return new AskServiceError(String(err ?? 'Unknown error'));
330
+ }
331
+
332
+ export function inferStatus(err: Error): number {
333
+ const anyErr = err as { status?: unknown; code?: unknown };
334
+ if (typeof anyErr.status === 'number') {
335
+ const s = anyErr.status;
336
+ if (Number.isFinite(s) && s >= 400 && s < 600) return s;
337
+ }
338
+ if (typeof anyErr.code === 'number') {
339
+ const s = anyErr.code;
340
+ if (Number.isFinite(s) && s >= 400 && s < 600) return s;
341
+ }
342
+ const derived = deriveStatusFromMessage(err.message || '');
343
+ return derived ?? 400;
344
+ }
345
+
346
+ const STATUS_PATTERNS: Array<{ regex: RegExp; status: number }> = [
347
+ { regex: /not found/i, status: 404 },
348
+ { regex: /missing credentials/i, status: 401 },
349
+ { regex: /unauthorized/i, status: 401 },
350
+ { regex: /not configured/i, status: 401 },
351
+ { regex: /authorized providers/i, status: 401 },
352
+ { regex: /forbidden/i, status: 403 },
353
+ { regex: /timeout/i, status: 504 },
354
+ ];
355
+
356
+ export function deriveStatusFromMessage(message: string): number | undefined {
357
+ const trimmed = message.trim();
358
+ if (!trimmed) return undefined;
359
+ for (const { regex, status } of STATUS_PATTERNS) {
360
+ if (regex.test(trimmed)) return status;
361
+ }
362
+ return undefined;
363
+ }
@@ -0,0 +1,69 @@
1
+ const cwdMap = new Map<string, string>();
2
+
3
+ export function getCwd(sessionId: string): string {
4
+ return cwdMap.get(sessionId) ?? '.';
5
+ }
6
+
7
+ export function setCwd(sessionId: string, cwd: string) {
8
+ cwdMap.set(sessionId, cwd || '.');
9
+ }
10
+
11
+ // normalize relative path like './a/../b' -> 'b', never escapes above '.'
12
+ export function normalizeRelative(path: string): string {
13
+ const parts = path.replace(/\\/g, '/').split('/');
14
+ const stack: string[] = [];
15
+ for (const part of parts) {
16
+ if (!part || part === '.') continue;
17
+ if (part === '..') {
18
+ if (stack.length > 0) stack.pop();
19
+ continue;
20
+ }
21
+ stack.push(part);
22
+ }
23
+ return stack.length ? stack.join('/') : '.';
24
+ }
25
+
26
+ export function joinRelative(base: string, p: string): string {
27
+ if (!p || p === '.') return base || '.';
28
+
29
+ // Expand tilde to home directory if present
30
+ const home = process.env.HOME || process.env.USERPROFILE || '';
31
+ if (p === '~' && home) p = home;
32
+ else if (p.startsWith('~/') && home) p = `${home}/${p.slice(2)}`;
33
+
34
+ // If target is absolute, preserve it as absolute
35
+ if (p.startsWith('/')) {
36
+ // Canonicalize: collapse //, resolve . and .. without escaping above root
37
+ const parts = p.replace(/\\/g, '/').split('/');
38
+ const stack: string[] = [];
39
+ for (const part of parts) {
40
+ if (!part || part === '.') continue;
41
+ if (part === '..') {
42
+ if (stack.length > 0) stack.pop();
43
+ continue;
44
+ }
45
+ stack.push(part);
46
+ }
47
+ return `/${stack.join('/')}` || '/';
48
+ }
49
+
50
+ // If base is absolute, join and return absolute
51
+ const baseIsAbs = Boolean(base) && base.startsWith('/');
52
+ if (baseIsAbs) {
53
+ const joined = `${base.replace(/\/$/, '')}/${p}`;
54
+ const parts = joined.replace(/\\/g, '/').split('/');
55
+ const stack: string[] = [];
56
+ for (const part of parts) {
57
+ if (!part || part === '.') continue;
58
+ if (part === '..') {
59
+ if (stack.length > 0) stack.pop();
60
+ continue;
61
+ }
62
+ stack.push(part);
63
+ }
64
+ return `/${stack.join('/')}` || '/';
65
+ }
66
+
67
+ // Relative -> Relative join
68
+ return normalizeRelative(`${base || '.'}/${p}`);
69
+ }
@@ -0,0 +1,94 @@
1
+ import type { getDb } from '@agi-cli/database';
2
+ import { messages, messageParts, sessions } from '@agi-cli/database/schema';
3
+ import { eq } from 'drizzle-orm';
4
+ import type { RunOpts } from './session-queue.ts';
5
+
6
+ /**
7
+ * Updates session token counts after a run completes.
8
+ */
9
+ export async function updateSessionTokens(
10
+ fin: { usage?: { inputTokens?: number; outputTokens?: number } },
11
+ opts: RunOpts,
12
+ db: Awaited<ReturnType<typeof getDb>>,
13
+ ) {
14
+ if (!fin.usage) return;
15
+
16
+ const sessRows = await db
17
+ .select()
18
+ .from(sessions)
19
+ .where(eq(sessions.id, opts.sessionId));
20
+
21
+ if (sessRows.length > 0 && sessRows[0]) {
22
+ const row = sessRows[0];
23
+ const priorInput = Number(row.totalInputTokens ?? 0);
24
+ const priorOutput = Number(row.totalOutputTokens ?? 0);
25
+ const nextInput = priorInput + Number(fin.usage.inputTokens ?? 0);
26
+ const nextOutput = priorOutput + Number(fin.usage.outputTokens ?? 0);
27
+
28
+ await db
29
+ .update(sessions)
30
+ .set({
31
+ totalInputTokens: nextInput,
32
+ totalOutputTokens: nextOutput,
33
+ })
34
+ .where(eq(sessions.id, opts.sessionId));
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Marks an assistant message as complete with token usage information.
40
+ */
41
+ export async function completeAssistantMessage(
42
+ fin: {
43
+ usage?: {
44
+ inputTokens?: number;
45
+ outputTokens?: number;
46
+ totalTokens?: number;
47
+ };
48
+ },
49
+ opts: RunOpts,
50
+ db: Awaited<ReturnType<typeof getDb>>,
51
+ ) {
52
+ const vals: Record<string, unknown> = {
53
+ status: 'complete',
54
+ completedAt: Date.now(),
55
+ };
56
+
57
+ if (fin.usage) {
58
+ vals.promptTokens = fin.usage.inputTokens;
59
+ vals.completionTokens = fin.usage.outputTokens;
60
+ vals.totalTokens =
61
+ fin.usage.totalTokens ??
62
+ (vals.promptTokens as number) + (vals.completionTokens as number);
63
+ }
64
+
65
+ await db
66
+ .update(messages)
67
+ .set(vals)
68
+ .where(eq(messages.id, opts.assistantMessageId));
69
+ }
70
+
71
+ /**
72
+ * Removes empty text parts from an assistant message.
73
+ */
74
+ export async function cleanupEmptyTextParts(
75
+ opts: RunOpts,
76
+ db: Awaited<ReturnType<typeof getDb>>,
77
+ ) {
78
+ const parts = await db
79
+ .select()
80
+ .from(messageParts)
81
+ .where(eq(messageParts.messageId, opts.assistantMessageId));
82
+
83
+ for (const p of parts) {
84
+ if (p.type === 'text') {
85
+ let t = '';
86
+ try {
87
+ t = JSON.parse(p.content || '{}')?.text || '';
88
+ } catch {}
89
+ if (!t || t.length === 0) {
90
+ await db.delete(messageParts).where(eq(messageParts.id, p.id));
91
+ }
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,104 @@
1
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
2
+
3
+ const SYNONYMS: Record<string, string> = {
4
+ debug: 'log',
5
+ logs: 'log',
6
+ logging: 'log',
7
+ trace: 'log',
8
+ verbose: 'log',
9
+ log: 'log',
10
+ time: 'timing',
11
+ timing: 'timing',
12
+ timings: 'timing',
13
+ perf: 'timing',
14
+ };
15
+
16
+ type DebugConfig = { flags: Set<string> };
17
+
18
+ let cachedConfig: DebugConfig | null = null;
19
+
20
+ function isTruthy(raw: string | undefined): boolean {
21
+ if (!raw) return false;
22
+ const trimmed = raw.trim().toLowerCase();
23
+ if (!trimmed) return false;
24
+ return TRUTHY.has(trimmed) || trimmed === 'all';
25
+ }
26
+
27
+ function normalizeToken(token: string): string {
28
+ const trimmed = token.trim().toLowerCase();
29
+ if (!trimmed) return '';
30
+ if (TRUTHY.has(trimmed) || trimmed === 'all') return 'all';
31
+ return SYNONYMS[trimmed] ?? trimmed;
32
+ }
33
+
34
+ function parseDebugConfig(): DebugConfig {
35
+ const flags = new Set<string>();
36
+ const sources = [process.env.AGI_DEBUG, process.env.DEBUG_AGI];
37
+ let sawValue = false;
38
+ for (const raw of sources) {
39
+ if (typeof raw !== 'string') continue;
40
+ const trimmed = raw.trim();
41
+ if (!trimmed) continue;
42
+ sawValue = true;
43
+ const tokens = trimmed.split(/[\s,]+/);
44
+ let matched = false;
45
+ for (const token of tokens) {
46
+ const normalized = normalizeToken(token);
47
+ if (!normalized) continue;
48
+ matched = true;
49
+ flags.add(normalized);
50
+ }
51
+ if (!matched && isTruthy(trimmed)) flags.add('all');
52
+ }
53
+ if (isTruthy(process.env.AGI_DEBUG_TIMING)) flags.add('timing');
54
+ if (!flags.size && sawValue) flags.add('all');
55
+ return { flags };
56
+ }
57
+
58
+ function getDebugConfig(): DebugConfig {
59
+ if (!cachedConfig) cachedConfig = parseDebugConfig();
60
+ return cachedConfig;
61
+ }
62
+
63
+ export function isDebugEnabled(flag?: string): boolean {
64
+ const config = getDebugConfig();
65
+ if (config.flags.has('all')) return true;
66
+ if (flag) return config.flags.has(flag);
67
+ return config.flags.has('log');
68
+ }
69
+
70
+ export function debugLog(...args: unknown[]) {
71
+ if (!isDebugEnabled('log')) return;
72
+ try {
73
+ console.log('[debug]', ...args);
74
+ } catch {}
75
+ }
76
+
77
+ function nowMs(): number {
78
+ const perf = (globalThis as { performance?: { now?: () => number } })
79
+ .performance;
80
+ if (perf && typeof perf.now === 'function') return perf.now();
81
+ return Date.now();
82
+ }
83
+
84
+ type Timer = { end(meta?: Record<string, unknown>): void };
85
+
86
+ export function time(label: string): Timer {
87
+ if (!isDebugEnabled('timing')) {
88
+ return { end() {} };
89
+ }
90
+ const start = nowMs();
91
+ let finished = false;
92
+ return {
93
+ end(meta?: Record<string, unknown>) {
94
+ if (finished) return;
95
+ finished = true;
96
+ const duration = nowMs() - start;
97
+ try {
98
+ const line = `[timing] ${label} ${duration.toFixed(1)}ms`;
99
+ if (meta && Object.keys(meta).length) console.log(line, meta);
100
+ else console.log(line);
101
+ } catch {}
102
+ },
103
+ };
104
+ }