@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,6 @@
1
+ import type { Hono } from 'hono';
2
+ import { getOpenAPISpec } from '../openapi/spec.ts';
3
+
4
+ export function registerOpenApiRoute(app: Hono) {
5
+ app.get('/openapi.json', (c) => c.json(getOpenAPISpec()));
6
+ }
@@ -0,0 +1,5 @@
1
+ import type { Hono } from 'hono';
2
+
3
+ export function registerRootRoutes(app: Hono) {
4
+ app.get('/', (c) => c.text('agi server running'));
5
+ }
@@ -0,0 +1,123 @@
1
+ import type { Hono } from 'hono';
2
+ import { loadConfig } from '@agi-cli/sdk';
3
+ import { getDb } from '@agi-cli/database';
4
+ import { messages, messageParts, sessions } from '@agi-cli/database/schema';
5
+ import { eq, inArray } from 'drizzle-orm';
6
+ import {
7
+ validateProviderModel,
8
+ isProviderAuthorized,
9
+ ensureProviderEnv,
10
+ } from '@agi-cli/sdk';
11
+ import { dispatchAssistantMessage } from '../runtime/message-service.ts';
12
+
13
+ type MessagePartRow = typeof messageParts.$inferSelect;
14
+ type SessionRow = typeof sessions.$inferSelect;
15
+
16
+ export function registerSessionMessagesRoutes(app: Hono) {
17
+ // List messages for a session
18
+ app.get('/v1/sessions/:id/messages', async (c) => {
19
+ const projectRoot = c.req.query('project') || process.cwd();
20
+ const cfg = await loadConfig(projectRoot);
21
+ const db = await getDb(cfg.projectRoot);
22
+ const id = c.req.param('id');
23
+ const rows = await db
24
+ .select()
25
+ .from(messages)
26
+ .where(eq(messages.sessionId, id))
27
+ .orderBy(messages.createdAt);
28
+ const without = c.req.query('without');
29
+ if (without !== 'parts') {
30
+ const ids = rows.map((m) => m.id);
31
+ const parts = ids.length
32
+ ? await db
33
+ .select()
34
+ .from(messageParts)
35
+ .where(inArray(messageParts.messageId, ids))
36
+ : [];
37
+ const partsByMsg = new Map<string, MessagePartRow[]>();
38
+ for (const p of parts) {
39
+ const existing = partsByMsg.get(p.messageId);
40
+ if (existing) existing.push(p);
41
+ else partsByMsg.set(p.messageId, [p]);
42
+ }
43
+ const wantParsed = (() => {
44
+ const q = (c.req.query('parsed') || '').toLowerCase();
45
+ return q === '1' || q === 'true' || q === 'yes';
46
+ })();
47
+ function parseContent(raw: string): Record<string, unknown> | string {
48
+ try {
49
+ const v = JSON.parse(String(raw ?? ''));
50
+ if (v && typeof v === 'object' && !Array.isArray(v))
51
+ return v as Record<string, unknown>;
52
+ } catch {}
53
+ return raw;
54
+ }
55
+ const enriched = rows.map((m) => {
56
+ const parts = (partsByMsg.get(m.id) ?? []).sort(
57
+ (a, b) => a.index - b.index,
58
+ );
59
+ const mapped = parts.map((p) => {
60
+ const parsed = parseContent(p.content);
61
+ return wantParsed
62
+ ? { ...p, content: parsed }
63
+ : { ...p, contentJson: parsed };
64
+ });
65
+ return { ...m, parts: mapped };
66
+ });
67
+ return c.json(enriched);
68
+ }
69
+ return c.json(rows);
70
+ });
71
+
72
+ // Post a user message and get assistant reply (non-streaming for v0)
73
+ app.post('/v1/sessions/:id/messages', async (c) => {
74
+ const projectRoot = c.req.query('project') || process.cwd();
75
+ const cfg = await loadConfig(projectRoot);
76
+ const db = await getDb(cfg.projectRoot);
77
+ const sessionId = c.req.param('id');
78
+ const body = await c.req.json().catch(() => ({}));
79
+ // Load session to inherit its provider/model/agent by default
80
+ const sessionRows = await db
81
+ .select()
82
+ .from(sessions)
83
+ .where(eq(sessions.id, sessionId));
84
+ if (!sessionRows.length) return c.json({ error: 'Session not found' }, 404);
85
+ const sess: SessionRow = sessionRows[0];
86
+ const provider = body?.provider ?? sess.provider ?? cfg.defaults.provider;
87
+ const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
88
+ const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
89
+ const content = body?.content ?? '';
90
+
91
+ // Validate model capabilities if tools are allowed for this agent
92
+ const wantsToolCalls = true; // agent toolset may be non-empty
93
+ try {
94
+ validateProviderModel(provider, modelName, { wantsToolCalls });
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ return c.json({ error: message }, 400);
98
+ }
99
+ // Enforce provider auth: only allow providers/models the user authenticated for
100
+ const authorized = await isProviderAuthorized(cfg, provider);
101
+ if (!authorized) {
102
+ return c.json(
103
+ {
104
+ error: `Provider ${provider} is not configured. Run \`agi auth login\` to add credentials.`,
105
+ },
106
+ 400,
107
+ );
108
+ }
109
+ await ensureProviderEnv(cfg, provider);
110
+
111
+ const { assistantMessageId } = await dispatchAssistantMessage({
112
+ cfg,
113
+ db,
114
+ session: sess,
115
+ agent,
116
+ provider,
117
+ model: modelName,
118
+ content,
119
+ oneShot: Boolean(body?.oneShot),
120
+ });
121
+ return c.json({ messageId: assistantMessageId }, 202);
122
+ });
123
+ }
@@ -0,0 +1,45 @@
1
+ import type { Hono } from 'hono';
2
+ import { subscribe } from '../events/bus.ts';
3
+ import type { AGIEvent } from '../events/types.ts';
4
+
5
+ export function registerSessionStreamRoute(app: Hono) {
6
+ app.get('/v1/sessions/:id/stream', async (c) => {
7
+ const sessionId = c.req.param('id');
8
+ const headers = new Headers({
9
+ 'Content-Type': 'text/event-stream',
10
+ 'Cache-Control': 'no-cache, no-transform',
11
+ Connection: 'keep-alive',
12
+ });
13
+
14
+ const encoder = new TextEncoder();
15
+
16
+ const stream = new ReadableStream<Uint8Array>({
17
+ start(controller) {
18
+ const write = (evt: AGIEvent) => {
19
+ const line =
20
+ `event: ${evt.type}\n` +
21
+ `data: ${JSON.stringify(evt.payload ?? {})}\n\n`;
22
+ controller.enqueue(encoder.encode(line));
23
+ };
24
+ const unsubscribe = subscribe(sessionId, write);
25
+ // Initial ping
26
+ controller.enqueue(encoder.encode(`: connected ${sessionId}\n\n`));
27
+ const hb = setInterval(() => {
28
+ // Comment line as heartbeat to keep connection alive
29
+ controller.enqueue(encoder.encode(`: hb ${Date.now()}\n\n`));
30
+ }, 15000);
31
+
32
+ const signal = c.req.raw?.signal as AbortSignal | undefined;
33
+ signal?.addEventListener('abort', () => {
34
+ clearInterval(hb);
35
+ unsubscribe();
36
+ try {
37
+ controller.close();
38
+ } catch {}
39
+ });
40
+ },
41
+ });
42
+
43
+ return new Response(stream, { headers });
44
+ });
45
+ }
@@ -0,0 +1,87 @@
1
+ import type { Hono } from 'hono';
2
+ import { loadConfig } from '@agi-cli/sdk';
3
+ import { getDb } from '@agi-cli/database';
4
+ import { sessions } from '@agi-cli/database/schema';
5
+ import { desc, eq } from 'drizzle-orm';
6
+ import type { ProviderId } from '@agi-cli/sdk';
7
+ import { isProviderId } from '@agi-cli/sdk';
8
+ import { resolveAgentConfig } from '../runtime/agent-registry.ts';
9
+ import { createSession as createSessionRow } from '../runtime/session-manager.ts';
10
+
11
+ export function registerSessionsRoutes(app: Hono) {
12
+ // List sessions
13
+ app.get('/v1/sessions', async (c) => {
14
+ const projectRoot = c.req.query('project') || process.cwd();
15
+ const cfg = await loadConfig(projectRoot);
16
+ const db = await getDb(cfg.projectRoot);
17
+ // Only return sessions for this project
18
+ const rows = await db
19
+ .select()
20
+ .from(sessions)
21
+ .where(eq(sessions.projectPath, cfg.projectRoot))
22
+ .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt));
23
+ const normalized = rows.map((r) => {
24
+ let counts: Record<string, unknown> | undefined;
25
+ if (r.toolCountsJson) {
26
+ try {
27
+ const parsed = JSON.parse(r.toolCountsJson);
28
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
29
+ counts = parsed as Record<string, unknown>;
30
+ }
31
+ } catch {}
32
+ }
33
+ const { toolCountsJson: _toolCountsJson, ...rest } = r;
34
+ return counts ? { ...rest, toolCounts: counts } : rest;
35
+ });
36
+ return c.json(normalized);
37
+ });
38
+
39
+ // Create session
40
+ app.post('/v1/sessions', async (c) => {
41
+ const projectRoot = c.req.query('project') || process.cwd();
42
+ const cfg = await loadConfig(projectRoot);
43
+ const db = await getDb(cfg.projectRoot);
44
+ const body = (await c.req.json().catch(() => ({}))) as Record<
45
+ string,
46
+ unknown
47
+ >;
48
+ const agent = (body.agent as string | undefined) ?? cfg.defaults.agent;
49
+ const agentCfg = await resolveAgentConfig(cfg.projectRoot, agent);
50
+ const providerCandidate =
51
+ typeof body.provider === 'string' ? body.provider : undefined;
52
+ const provider: ProviderId = (() => {
53
+ if (providerCandidate && isProviderId(providerCandidate))
54
+ return providerCandidate;
55
+ if (agentCfg.provider && isProviderId(agentCfg.provider))
56
+ return agentCfg.provider;
57
+ return cfg.defaults.provider;
58
+ })();
59
+ const modelCandidate =
60
+ typeof body.model === 'string' ? body.model.trim() : undefined;
61
+ const model = modelCandidate?.length
62
+ ? modelCandidate
63
+ : (agentCfg.model ?? cfg.defaults.model);
64
+ try {
65
+ const row = await createSessionRow({
66
+ db,
67
+ cfg,
68
+ agent,
69
+ provider,
70
+ model,
71
+ title: (body.title as string | null | undefined) ?? null,
72
+ });
73
+ return c.json(row, 201);
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ return c.json({ error: message }, 400);
77
+ }
78
+ });
79
+
80
+ // Abort session stream
81
+ app.delete('/v1/sessions/:sessionId/abort', async (c) => {
82
+ const sessionId = c.req.param('sessionId');
83
+ const { abortSession } = await import('../runtime/runner.ts');
84
+ abortSession(sessionId);
85
+ return c.json({ success: true });
86
+ });
87
+ }
@@ -0,0 +1,327 @@
1
+ import { getGlobalAgentsJsonPath, getGlobalAgentsDir } from '@agi-cli/sdk';
2
+ import { debugLog } from './debug.ts';
3
+ import type { ProviderName } from '@agi-cli/sdk';
4
+ import { catalog } from '@agi-cli/sdk';
5
+ // Embed default agent prompts; only user overrides read from disk.
6
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
7
+ import AGENT_BUILD from '@agi-cli/sdk/prompts/agents/build.txt' with {
8
+ type: 'text',
9
+ };
10
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
11
+ import AGENT_PLAN from '@agi-cli/sdk/prompts/agents/plan.txt' with {
12
+ type: 'text',
13
+ };
14
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
15
+ import AGENT_GENERAL from '@agi-cli/sdk/prompts/agents/general.txt' with {
16
+ type: 'text',
17
+ };
18
+
19
+ export type AgentConfig = {
20
+ name: string;
21
+ prompt: string;
22
+ tools: string[]; // allowed tool names
23
+ provider?: ProviderName;
24
+ model?: string;
25
+ };
26
+
27
+ export type AgentConfigEntry = {
28
+ tools?: string[];
29
+ appendTools?: string[];
30
+ prompt?: string;
31
+ provider?: string;
32
+ model?: string;
33
+ };
34
+
35
+ type AgentsJson = Record<string, AgentConfigEntry>;
36
+
37
+ function normalizeStringList(value: unknown): string[] {
38
+ if (!Array.isArray(value)) return [];
39
+ const seen = new Set<string>();
40
+ const out: string[] = [];
41
+ for (const item of value) {
42
+ if (typeof item !== 'string') continue;
43
+ const trimmed = item.trim();
44
+ if (!trimmed || seen.has(trimmed)) continue;
45
+ seen.add(trimmed);
46
+ out.push(trimmed);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const providerValues = new Set<ProviderName>(
52
+ Object.keys(catalog) as ProviderName[],
53
+ );
54
+
55
+ function normalizeProvider(value: unknown): ProviderName | undefined {
56
+ if (typeof value !== 'string') return undefined;
57
+ const trimmed = value.trim().toLowerCase();
58
+ if (!trimmed) return undefined;
59
+ return providerValues.has(trimmed as ProviderName)
60
+ ? (trimmed as ProviderName)
61
+ : undefined;
62
+ }
63
+
64
+ function normalizeModel(value: unknown): string | undefined {
65
+ if (typeof value !== 'string') return undefined;
66
+ const trimmed = value.trim();
67
+ return trimmed.length ? trimmed : undefined;
68
+ }
69
+
70
+ function mergeAgentEntries(
71
+ base: AgentConfigEntry | undefined,
72
+ override: AgentConfigEntry,
73
+ ): AgentConfigEntry {
74
+ const merged: AgentConfigEntry = {};
75
+ const baseTools = normalizeStringList(base?.tools);
76
+ if (baseTools.length) merged.tools = [...baseTools];
77
+ const baseAppend = normalizeStringList(base?.appendTools);
78
+ if (baseAppend.length) merged.appendTools = [...baseAppend];
79
+ if (base && Object.hasOwn(base, 'prompt')) merged.prompt = base.prompt;
80
+ if (base && Object.hasOwn(base, 'provider'))
81
+ merged.provider = normalizeProvider(base.provider);
82
+ if (base && Object.hasOwn(base, 'model'))
83
+ merged.model = normalizeModel(base.model);
84
+
85
+ if (Array.isArray(override.tools))
86
+ merged.tools = normalizeStringList(override.tools);
87
+ if (Array.isArray(override.appendTools)) {
88
+ const extras = normalizeStringList(override.appendTools);
89
+ const union = new Set([...(merged.appendTools ?? []), ...extras]);
90
+ merged.appendTools = Array.from(union);
91
+ } else if (
92
+ Object.hasOwn(override, 'appendTools') &&
93
+ !Array.isArray(override.appendTools)
94
+ ) {
95
+ delete merged.appendTools;
96
+ }
97
+ if (Object.hasOwn(override, 'prompt')) merged.prompt = override.prompt;
98
+
99
+ if (Object.hasOwn(override, 'provider')) {
100
+ const normalized = normalizeProvider(override.provider);
101
+ if (normalized) merged.provider = normalized;
102
+ else delete merged.provider;
103
+ }
104
+ if (Object.hasOwn(override, 'model')) {
105
+ const normalized = normalizeModel(override.model);
106
+ if (normalized) merged.model = normalized;
107
+ else delete merged.model;
108
+ }
109
+ return merged;
110
+ }
111
+
112
+ const baseToolSet = ['progress_update', 'finish'] as const;
113
+
114
+ const defaultToolExtras: Record<string, string[]> = {
115
+ build: [
116
+ 'read',
117
+ 'write',
118
+ 'ls',
119
+ 'tree',
120
+ 'bash',
121
+ 'update_plan',
122
+ 'grep',
123
+ 'git_status',
124
+ 'git_diff',
125
+ 'ripgrep',
126
+ 'apply_patch',
127
+ 'websearch',
128
+ ],
129
+ plan: ['read', 'ls', 'tree', 'ripgrep', 'update_plan', 'websearch'],
130
+ general: [
131
+ 'read',
132
+ 'write',
133
+ 'ls',
134
+ 'tree',
135
+ 'bash',
136
+ 'ripgrep',
137
+ 'websearch',
138
+ 'update_plan',
139
+ ],
140
+ git: ['git_status', 'git_diff', 'git_commit', 'read', 'ls'],
141
+ commit: ['git_status', 'git_diff', 'git_commit', 'read', 'ls'],
142
+ };
143
+
144
+ export function defaultToolsForAgent(name: string): string[] {
145
+ const extras = defaultToolExtras[name] ? [...defaultToolExtras[name]] : [];
146
+ return Array.from(new Set([...baseToolSet, ...extras]));
147
+ }
148
+
149
+ export async function loadAgentsConfig(
150
+ projectRoot: string,
151
+ ): Promise<AgentsJson> {
152
+ const localPath = `${projectRoot}/.agi/agents.json`.replace(/\\/g, '/');
153
+ const globalPath = getGlobalAgentsJsonPath();
154
+ let globalCfg: AgentsJson = {};
155
+ let localCfg: AgentsJson = {};
156
+ try {
157
+ const gf = Bun.file(globalPath);
158
+ if (await gf.exists())
159
+ globalCfg = (await gf.json().catch(() => ({}))) as AgentsJson;
160
+ } catch {}
161
+ try {
162
+ const lf = Bun.file(localPath);
163
+ if (await lf.exists())
164
+ localCfg = (await lf.json().catch(() => ({}))) as AgentsJson;
165
+ } catch {}
166
+ const merged: AgentsJson = {};
167
+ for (const [name, entry] of Object.entries(globalCfg)) {
168
+ merged[name] = mergeAgentEntries(undefined, entry ?? {});
169
+ }
170
+ for (const [name, entry] of Object.entries(localCfg)) {
171
+ const base = merged[name];
172
+ merged[name] = mergeAgentEntries(base, entry ?? {});
173
+ }
174
+ return merged;
175
+ }
176
+
177
+ export async function resolveAgentConfig(
178
+ projectRoot: string,
179
+ name: string,
180
+ inlineConfig?: {
181
+ prompt?: string;
182
+ tools?: string[];
183
+ provider?: string;
184
+ model?: string;
185
+ },
186
+ ): Promise<AgentConfig> {
187
+ if (inlineConfig?.prompt) {
188
+ const provider = normalizeProvider(inlineConfig.provider);
189
+ const model = normalizeModel(inlineConfig.model);
190
+ return {
191
+ name,
192
+ prompt: inlineConfig.prompt,
193
+ tools: inlineConfig.tools ?? defaultToolsForAgent(name),
194
+ provider,
195
+ model,
196
+ };
197
+ }
198
+ const agents = await loadAgentsConfig(projectRoot);
199
+ const entry = agents[name];
200
+ let prompt = '';
201
+ let promptSource: string = 'none';
202
+
203
+ // Override files: project first, then global
204
+ const globalAgentsDir = getGlobalAgentsDir();
205
+ const localDirTxt = `${projectRoot}/.agi/agents/${name}/agent.txt`.replace(
206
+ /\\/g,
207
+ '/',
208
+ );
209
+ const localDirMd = `${projectRoot}/.agi/agents/${name}/agent.md`.replace(
210
+ /\\/g,
211
+ '/',
212
+ );
213
+ const localFlatTxt = `${projectRoot}/.agi/agents/${name}.txt`.replace(
214
+ /\\/g,
215
+ '/',
216
+ );
217
+ const localFlatMd = `${projectRoot}/.agi/agents/${name}.md`.replace(
218
+ /\\/g,
219
+ '/',
220
+ );
221
+ const globalDirTxt = `${globalAgentsDir}/${name}/agent.txt`.replace(
222
+ /\\/g,
223
+ '/',
224
+ );
225
+ const globalDirMd = `${globalAgentsDir}/${name}/agent.md`.replace(/\\/g, '/');
226
+ const globalFlatTxt = `${globalAgentsDir}/${name}.txt`.replace(/\\/g, '/');
227
+ const globalFlatMd = `${globalAgentsDir}/${name}.md`.replace(/\\/g, '/');
228
+ const files = [
229
+ localDirMd,
230
+ localFlatMd,
231
+ localDirTxt,
232
+ localFlatTxt,
233
+ globalDirMd,
234
+ globalFlatMd,
235
+ globalDirTxt,
236
+ globalFlatTxt,
237
+ ];
238
+ for (const p of files) {
239
+ try {
240
+ const f = Bun.file(p);
241
+ if (await f.exists()) {
242
+ const text = await f.text();
243
+ if (text.trim()) {
244
+ prompt = text;
245
+ promptSource = `file:${p}`;
246
+ break;
247
+ }
248
+ }
249
+ } catch {}
250
+ }
251
+
252
+ // If agents.json provides a 'prompt' field, accept inline content or a relative/absolute path
253
+ if (entry?.prompt) {
254
+ const p = entry.prompt.trim();
255
+ if (
256
+ /[.](md|txt)$/i.test(p) ||
257
+ p.startsWith('.') ||
258
+ p.startsWith('/') ||
259
+ p.startsWith('~/')
260
+ ) {
261
+ const candidates: string[] = [];
262
+ if (p.startsWith('~/')) {
263
+ const home = process.env.HOME || process.env.USERPROFILE || '';
264
+ candidates.push(`${home}/${p.slice(2)}`);
265
+ } else if (p.startsWith('/')) candidates.push(p);
266
+ else candidates.push(`${projectRoot}/${p}`.replace(/\\/g, '/'));
267
+ for (const candidate of candidates) {
268
+ const pf = Bun.file(candidate);
269
+ if (await pf.exists()) {
270
+ const t = await pf.text();
271
+ if (t.trim()) {
272
+ prompt = t;
273
+ promptSource = `agents.json:file:${candidate}`;
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ } else {
279
+ prompt = p;
280
+ promptSource = 'agents.json:inline';
281
+ }
282
+ }
283
+
284
+ // Fallback: use embedded defaults (plan/build/general); else default to build
285
+ if (!prompt) {
286
+ const byName = (n: string): string | undefined => {
287
+ if (n === 'build') return AGENT_BUILD;
288
+ if (n === 'plan') return AGENT_PLAN;
289
+ if (n === 'general') return AGENT_GENERAL;
290
+ return undefined;
291
+ };
292
+ const candidate = byName(name)?.trim();
293
+ if (candidate?.length) {
294
+ prompt = candidate;
295
+ promptSource = `fallback:embedded:${name}.txt`;
296
+ } else {
297
+ prompt = (AGENT_BUILD || '').trim();
298
+ promptSource = 'fallback:embedded:build.txt';
299
+ }
300
+ }
301
+
302
+ // Default tool access per agent if not explicitly configured
303
+ let tools = Array.isArray(entry?.tools)
304
+ ? [...(entry?.tools as string[])]
305
+ : defaultToolsForAgent(name);
306
+ if (!entry || !entry.tools) {
307
+ tools = defaultToolsForAgent(name);
308
+ }
309
+ if (Array.isArray(entry?.appendTools) && entry.appendTools.length) {
310
+ for (const t of entry.appendTools) {
311
+ if (typeof t === 'string' && t.trim()) tools.push(t.trim());
312
+ }
313
+ }
314
+ // Deduplicate and ensure base tools are always available
315
+ const deduped = Array.from(new Set([...tools, ...baseToolSet]));
316
+ const provider = normalizeProvider(entry?.provider);
317
+ const model = normalizeModel(entry?.model);
318
+ debugLog(`[agent] ${name} prompt source: ${promptSource}`);
319
+ debugLog(`[agent] ${name} prompt:\n${prompt}`);
320
+ return {
321
+ name,
322
+ prompt,
323
+ tools: deduped,
324
+ provider,
325
+ model,
326
+ };
327
+ }