@aakrit512/gatekeep 1.0.0

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,523 @@
1
+ import 'dotenv/config';
2
+ import { execFile, spawn } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as http from 'http';
5
+ import * as path from 'path';
6
+ import { pathToFileURL } from 'url';
7
+ import { cleanString, configureApp, DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_PROVIDER, } from '../config.js';
8
+ import { getConfigPath, readUserConfig, resolveConfig, writeUserConfig } from '../cli/configStore.js';
9
+ import { maskSecret, validateConfig, validateNodeRuntime, validateProject } from '../cli/validation.js';
10
+ import { clearChatMessages, deleteProject, getChatMessages, getDatabasePath, getProjectByName, getRecentProjects, saveChatMessage, updateChatMessage, upsertProject, } from './projectDb.js';
11
+ import { initializeProject, resetProjectSession, sendChatMessage } from './webAgent.js';
12
+ const WEB_FEATURES = [
13
+ { id: 'init', name: 'Initialize project', description: 'Save project metadata in SQLite and create docs/intro.md.' },
14
+ { id: 'config', name: 'Config', description: 'View and update provider, model, API key, and base URL.' },
15
+ { id: 'doctor', name: 'Doctor', description: 'Check Node, provider config, and project path readiness.' },
16
+ { id: 'run', name: 'Chat review loop', description: 'Ask code review and project questions with file, diff, and directory tools.' },
17
+ ];
18
+ const CURATED_OPENAI_MODELS = [
19
+ 'gpt-5.5',
20
+ 'gpt-5.4',
21
+ 'gpt-5.4-mini',
22
+ 'gpt-5',
23
+ 'gpt-4.1',
24
+ 'gpt-4o',
25
+ ];
26
+ function jsonResponse(res, status, payload) {
27
+ const body = JSON.stringify(payload);
28
+ res.writeHead(status, {
29
+ 'content-type': 'application/json; charset=utf-8',
30
+ 'content-length': Buffer.byteLength(body),
31
+ });
32
+ res.end(body);
33
+ }
34
+ function htmlResponse(res, body) {
35
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
36
+ res.end(body);
37
+ }
38
+ function sseResponse(res) {
39
+ res.writeHead(200, {
40
+ 'content-type': 'text/event-stream; charset=utf-8',
41
+ 'cache-control': 'no-cache, no-transform',
42
+ connection: 'keep-alive',
43
+ 'x-accel-buffering': 'no',
44
+ });
45
+ }
46
+ function writeSse(res, event, payload) {
47
+ res.write(`event: ${event}\n`);
48
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
49
+ }
50
+ function readBody(req) {
51
+ return new Promise((resolve, reject) => {
52
+ let raw = '';
53
+ req.setEncoding('utf-8');
54
+ req.on('data', (chunk) => {
55
+ raw += chunk;
56
+ if (raw.length > 1_000_000) {
57
+ req.destroy(new Error('Request body is too large.'));
58
+ }
59
+ });
60
+ req.on('end', () => {
61
+ if (!raw.trim()) {
62
+ resolve({});
63
+ return;
64
+ }
65
+ try {
66
+ const parsed = JSON.parse(raw);
67
+ resolve(parsed && typeof parsed === 'object' ? parsed : {});
68
+ }
69
+ catch {
70
+ reject(new Error('Request body must be valid JSON.'));
71
+ }
72
+ });
73
+ req.on('error', reject);
74
+ });
75
+ }
76
+ function getString(body, key) {
77
+ const value = body[key];
78
+ return typeof value === 'string' ? value.trim() : '';
79
+ }
80
+ function publicConfig(config) {
81
+ return {
82
+ provider: config.provider,
83
+ apiKey: maskSecret(config.apiKey),
84
+ hasApiKey: Boolean(config.apiKey),
85
+ model: config.model,
86
+ baseUrl: config.baseUrl,
87
+ };
88
+ }
89
+ function resolveProjectPath(bodyPath, fallbackProject) {
90
+ return path.resolve(cleanString(bodyPath) || fallbackProject || process.cwd());
91
+ }
92
+ function explicitProjectPath(body) {
93
+ const projectPath = cleanString(getString(body, 'projectPath'));
94
+ return projectPath ? path.resolve(projectPath) : undefined;
95
+ }
96
+ function doctor(project, config = resolveConfig({})) {
97
+ return [
98
+ ...validateNodeRuntime(),
99
+ ...validateConfig(config),
100
+ ...validateProject(project),
101
+ ];
102
+ }
103
+ function buildConfigFromBody(body) {
104
+ const stored = readUserConfig();
105
+ return {
106
+ provider: DEFAULT_PROVIDER,
107
+ apiKey: cleanString(getString(body, 'apiKey')) || cleanString(stored.apiKey) || cleanString(process.env.AI_API_KEY) || '',
108
+ model: cleanString(getString(body, 'model')) || cleanString(stored.model) || cleanString(process.env.MODEL_NAME) || DEFAULT_MODEL,
109
+ baseUrl: cleanString(getString(body, 'baseUrl')) || cleanString(stored.baseUrl) || cleanString(process.env.AI_BASE_URL) || DEFAULT_BASE_URL,
110
+ };
111
+ }
112
+ function uniqueStrings(values) {
113
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
114
+ }
115
+ function modelListUrl(baseUrl) {
116
+ const normalized = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
117
+ return new URL('models', normalized).toString();
118
+ }
119
+ async function listModels(config) {
120
+ const fallback = uniqueStrings([config.model, ...CURATED_OPENAI_MODELS]);
121
+ if (!config.apiKey.trim()) {
122
+ return { models: fallback, source: 'fallback', warning: 'Add an API key to load account models.' };
123
+ }
124
+ const response = await fetch(modelListUrl(config.baseUrl), {
125
+ headers: {
126
+ authorization: `Bearer ${config.apiKey}`,
127
+ },
128
+ });
129
+ if (!response.ok) {
130
+ return {
131
+ models: fallback,
132
+ source: 'fallback',
133
+ warning: `Could not load models (${response.status}).`,
134
+ };
135
+ }
136
+ const body = await response.json();
137
+ const available = new Set((body.data || []).map((model) => typeof model.id === 'string' ? model.id : '').filter(Boolean));
138
+ const models = uniqueStrings([
139
+ config.model,
140
+ ...CURATED_OPENAI_MODELS.filter((model) => available.has(model)),
141
+ ]);
142
+ return { models: models.length ? models : fallback, source: 'api' };
143
+ }
144
+ function pickDirectory() {
145
+ return new Promise((resolve, reject) => {
146
+ if (process.platform === 'darwin') {
147
+ execFile('osascript', [
148
+ '-e',
149
+ 'POSIX path of (choose folder with prompt "Choose a project directory")',
150
+ ], { timeout: 120_000 }, (error, stdout) => {
151
+ if (error) {
152
+ reject(new Error('Directory selection was cancelled.'));
153
+ return;
154
+ }
155
+ resolve(stdout.trim().replace(/\/$/, ''));
156
+ });
157
+ return;
158
+ }
159
+ reject(new Error('Folder picker is currently available on macOS. Paste the project path instead.'));
160
+ });
161
+ }
162
+ function projectHref(project) {
163
+ return `/${encodeURIComponent(project.name)}`;
164
+ }
165
+ function projectPayload(project) {
166
+ return {
167
+ ...project,
168
+ href: projectHref(project),
169
+ };
170
+ }
171
+ function readIntroMarkdown(projectPath) {
172
+ const introPath = path.join(projectPath, 'docs', 'intro.md');
173
+ if (!fs.existsSync(introPath))
174
+ return '';
175
+ return fs.readFileSync(introPath, 'utf-8').trim();
176
+ }
177
+ function resolveProjectByRouteName(rawName) {
178
+ return getProjectByName(decodeURIComponent(rawName));
179
+ }
180
+ function splitProjectRoute(pathname) {
181
+ const match = pathname.match(/^\/api\/projects\/([^/]+)(?:\/([^/]+))?$/);
182
+ if (!match)
183
+ return undefined;
184
+ return {
185
+ name: match[1],
186
+ action: match[2] || '',
187
+ };
188
+ }
189
+ function estimateCost(model, promptTokens, completionTokens) {
190
+ const pricing = {
191
+ 'gpt-4o': { input: 2.5, output: 10 },
192
+ 'gpt-4o-mini': { input: 0.15, output: 0.6 },
193
+ 'gpt-4.1': { input: 2, output: 8 },
194
+ 'gpt-4.1-mini': { input: 0.4, output: 1.6 },
195
+ 'gpt-4.1-nano': { input: 0.1, output: 0.4 },
196
+ 'gpt-5': { input: 1.25, output: 10 },
197
+ 'gpt-5.4': { input: 1.25, output: 10 },
198
+ 'gpt-5.4-mini': { input: 0.25, output: 2 },
199
+ 'gpt-5.5': { input: 1.25, output: 10 },
200
+ 'gpt-5-mini': { input: 0.25, output: 2 },
201
+ 'gpt-5-nano': { input: 0.05, output: 0.4 },
202
+ };
203
+ const price = pricing[model.toLowerCase()];
204
+ if (!price)
205
+ return undefined;
206
+ return ((promptTokens / 1_000_000) * price.input) + ((completionTokens / 1_000_000) * price.output);
207
+ }
208
+ const UI_TEMPLATE_PATH = new URL('../../ui/app.html', import.meta.url);
209
+ let uiTemplateCache;
210
+ function appHtml() {
211
+ uiTemplateCache ??= fs.readFileSync(UI_TEMPLATE_PATH, 'utf-8');
212
+ return uiTemplateCache.replace('__FALLBACK_MODELS__', JSON.stringify(CURATED_OPENAI_MODELS));
213
+ }
214
+ async function streamChat(res, project, body) {
215
+ const config = buildConfigFromBody(body);
216
+ const errors = doctor(project.path, config);
217
+ if (errors.length > 0) {
218
+ jsonResponse(res, 400, { error: errors.join('\n'), errors });
219
+ return;
220
+ }
221
+ const message = getString(body, 'message');
222
+ if (!message) {
223
+ jsonResponse(res, 400, { error: 'Message is required.' });
224
+ return;
225
+ }
226
+ writeUserConfig(config);
227
+ configureApp(config);
228
+ upsertProject(project.path, config);
229
+ saveChatMessage(project.id, 'user', message);
230
+ const turnUsage = {
231
+ model: config.model,
232
+ promptTokens: 0,
233
+ completionTokens: 0,
234
+ totalTokens: 0,
235
+ estimatedCost: undefined,
236
+ };
237
+ const activity = [{
238
+ type: 'status',
239
+ label: 'thinking',
240
+ detail: 'reading project context',
241
+ }];
242
+ const assistantMessageId = saveChatMessage(project.id, 'assistant', '', {
243
+ model: turnUsage.model,
244
+ activity,
245
+ });
246
+ const persistAssistantTurn = (content = assistantMessage) => {
247
+ updateChatMessage(assistantMessageId, content, {
248
+ model: turnUsage.model,
249
+ promptTokens: turnUsage.promptTokens,
250
+ completionTokens: turnUsage.completionTokens,
251
+ totalTokens: turnUsage.totalTokens,
252
+ estimatedCost: turnUsage.estimatedCost,
253
+ activity,
254
+ });
255
+ };
256
+ sseResponse(res);
257
+ writeSse(res, 'status', { type: 'status', message: 'reading project context' });
258
+ let assistantMessage = '';
259
+ try {
260
+ const events = await sendChatMessage(project.path, message, (event) => {
261
+ if (event.type === 'assistant') {
262
+ assistantMessage = event.message;
263
+ persistAssistantTurn();
264
+ return;
265
+ }
266
+ if (event.type === 'usage') {
267
+ const cost = estimateCost(event.model, event.promptTokens, event.completionTokens);
268
+ turnUsage.model = event.model;
269
+ turnUsage.promptTokens += event.promptTokens;
270
+ turnUsage.completionTokens += event.completionTokens;
271
+ turnUsage.totalTokens += event.totalTokens;
272
+ if (typeof cost === 'number') {
273
+ turnUsage.estimatedCost = (turnUsage.estimatedCost ?? 0) + cost;
274
+ }
275
+ activity.push({
276
+ type: 'tokens',
277
+ label: 'tokens',
278
+ detail: [
279
+ `${turnUsage.totalTokens} total`,
280
+ `${turnUsage.promptTokens} in`,
281
+ `${turnUsage.completionTokens} out`,
282
+ typeof turnUsage.estimatedCost === 'number' ? `$${turnUsage.estimatedCost.toFixed(4)}` : undefined,
283
+ ].filter(Boolean).join(' · '),
284
+ });
285
+ persistAssistantTurn();
286
+ writeSse(res, 'usage', {
287
+ type: 'usage',
288
+ model: turnUsage.model,
289
+ promptTokens: turnUsage.promptTokens,
290
+ completionTokens: turnUsage.completionTokens,
291
+ totalTokens: turnUsage.totalTokens,
292
+ cost: turnUsage.estimatedCost,
293
+ });
294
+ return;
295
+ }
296
+ if (event.type === 'status') {
297
+ activity.push({ type: 'status', label: 'thinking', detail: event.message });
298
+ }
299
+ else if (event.type === 'tool_call') {
300
+ const detail = Object.entries(event.args)
301
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
302
+ .slice(0, 3)
303
+ .map(([key, value]) => {
304
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
305
+ return `${key}: ${text.slice(0, 90)}`;
306
+ })
307
+ .join(' · ');
308
+ activity.push({ type: 'tool_call', label: `tool_call ${event.name}`, detail });
309
+ }
310
+ else if (event.type === 'tool') {
311
+ activity.push({
312
+ type: 'tool_result',
313
+ label: `tool_result ${event.name}`,
314
+ detail: event.summary,
315
+ preview: event.preview,
316
+ full: event.full,
317
+ truncated: event.truncated,
318
+ });
319
+ }
320
+ persistAssistantTurn();
321
+ writeSse(res, event.type, event);
322
+ });
323
+ const assistant = assistantMessage || [...events].reverse().find((event) => event.type === 'assistant')?.message || '';
324
+ for (const chunk of assistant.match(/.{1,96}(?:\s|$)/gs) || [assistant]) {
325
+ writeSse(res, 'assistant_delta', { delta: chunk });
326
+ }
327
+ if (assistant) {
328
+ updateChatMessage(assistantMessageId, assistant, {
329
+ model: turnUsage.model,
330
+ promptTokens: turnUsage.promptTokens,
331
+ completionTokens: turnUsage.completionTokens,
332
+ totalTokens: turnUsage.totalTokens,
333
+ estimatedCost: turnUsage.estimatedCost,
334
+ activity,
335
+ });
336
+ }
337
+ writeSse(res, 'done', { ok: true });
338
+ res.end();
339
+ }
340
+ catch (error) {
341
+ writeSse(res, 'error', { error: error instanceof Error ? error.message : String(error) });
342
+ res.end();
343
+ }
344
+ }
345
+ export async function startUiServer(options = {}) {
346
+ const projectPath = path.resolve(options.project || process.cwd());
347
+ const port = options.port || Number(process.env.MY_CODING_AGENT_PORT) || 9808;
348
+ const host = options.host || '127.0.0.1';
349
+ const server = http.createServer(async (req, res) => {
350
+ try {
351
+ const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
352
+ if (req.method === 'GET' && !url.pathname.startsWith('/api/')) {
353
+ htmlResponse(res, appHtml());
354
+ return;
355
+ }
356
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
357
+ const config = resolveConfig({});
358
+ const projects = getRecentProjects().map(projectPayload);
359
+ jsonResponse(res, 200, {
360
+ projectPath,
361
+ projects,
362
+ config: publicConfig(config),
363
+ configPath: getConfigPath(),
364
+ databasePath: getDatabasePath(),
365
+ features: WEB_FEATURES,
366
+ });
367
+ return;
368
+ }
369
+ if (req.method === 'GET' && url.pathname === '/api/config') {
370
+ jsonResponse(res, 200, {
371
+ config: publicConfig(resolveConfig({})),
372
+ configPath: getConfigPath(),
373
+ });
374
+ return;
375
+ }
376
+ if (req.method === 'POST' && url.pathname === '/api/models') {
377
+ const body = await readBody(req);
378
+ const config = buildConfigFromBody(body);
379
+ const result = await listModels(config);
380
+ jsonResponse(res, 200, result);
381
+ return;
382
+ }
383
+ if (req.method === 'POST' && url.pathname === '/api/pick-directory') {
384
+ const directory = await pickDirectory();
385
+ jsonResponse(res, 200, { path: directory });
386
+ return;
387
+ }
388
+ if (req.method === 'GET') {
389
+ const route = splitProjectRoute(url.pathname);
390
+ if (route && !route.action) {
391
+ const project = resolveProjectByRouteName(route.name);
392
+ if (!project) {
393
+ jsonResponse(res, 404, { error: 'Project not found.' });
394
+ return;
395
+ }
396
+ const config = resolveConfig({});
397
+ jsonResponse(res, 200, {
398
+ project: projectPayload(project),
399
+ projects: getRecentProjects().map(projectPayload),
400
+ config: publicConfig(config),
401
+ configPath: getConfigPath(),
402
+ databasePath: getDatabasePath(),
403
+ features: WEB_FEATURES,
404
+ messages: getChatMessages(project.id),
405
+ });
406
+ return;
407
+ }
408
+ }
409
+ if (req.method === 'POST' && url.pathname === '/api/init') {
410
+ const body = await readBody(req);
411
+ const targetProject = explicitProjectPath(body);
412
+ if (!targetProject) {
413
+ jsonResponse(res, 400, { error: 'Choose a project directory first.', errors: ['Choose a project directory first.'] });
414
+ return;
415
+ }
416
+ const config = buildConfigFromBody(body);
417
+ const errors = [...validateConfig(config), ...validateProject(targetProject)];
418
+ if (errors.length > 0) {
419
+ jsonResponse(res, 400, { error: errors.join('\n'), errors });
420
+ return;
421
+ }
422
+ writeUserConfig(config);
423
+ configureApp(config);
424
+ const project = upsertProject(targetProject, config);
425
+ const events = await initializeProject(targetProject);
426
+ const existingMessages = getChatMessages(project.id);
427
+ const introMarkdown = readIntroMarkdown(targetProject);
428
+ if (introMarkdown && existingMessages.length === 0) {
429
+ saveChatMessage(project.id, 'assistant', introMarkdown, {
430
+ activity: [
431
+ { type: 'status', label: 'Initialized project', detail: 'Loaded docs/intro.md' },
432
+ ],
433
+ });
434
+ }
435
+ else if (existingMessages.length === 0) {
436
+ saveChatMessage(project.id, 'system', 'Project initialized.');
437
+ }
438
+ jsonResponse(res, 200, { project: projectPayload(project), events });
439
+ return;
440
+ }
441
+ if ((req.method === 'POST' || req.method === 'PATCH') && url.pathname === '/api/config') {
442
+ const body = await readBody(req);
443
+ const config = buildConfigFromBody(body);
444
+ const errors = validateConfig(config);
445
+ if (errors.length > 0) {
446
+ jsonResponse(res, 400, { error: errors.join('\n'), errors });
447
+ return;
448
+ }
449
+ writeUserConfig(config);
450
+ configureApp(config);
451
+ jsonResponse(res, 200, {
452
+ config: publicConfig(config),
453
+ configPath: getConfigPath(),
454
+ });
455
+ return;
456
+ }
457
+ if (req.method === 'POST' && url.pathname === '/api/doctor') {
458
+ const body = await readBody(req);
459
+ const targetProject = explicitProjectPath(body);
460
+ if (!targetProject) {
461
+ jsonResponse(res, 200, { errors: ['Choose a project directory first.'] });
462
+ return;
463
+ }
464
+ const config = buildConfigFromBody(body);
465
+ jsonResponse(res, 200, { errors: doctor(targetProject, config) });
466
+ return;
467
+ }
468
+ if (req.method === 'POST') {
469
+ const route = splitProjectRoute(url.pathname);
470
+ if (route) {
471
+ const project = resolveProjectByRouteName(route.name);
472
+ if (!project) {
473
+ jsonResponse(res, 404, { error: 'Project not found.' });
474
+ return;
475
+ }
476
+ if (route.action === 'open-code') {
477
+ const child = spawn('code', [project.path], {
478
+ detached: true,
479
+ stdio: 'ignore',
480
+ });
481
+ child.unref();
482
+ jsonResponse(res, 200, { ok: true });
483
+ return;
484
+ }
485
+ if (route.action === 'chat-stream') {
486
+ const body = await readBody(req);
487
+ await streamChat(res, project, body);
488
+ return;
489
+ }
490
+ if (route.action === 'clear-chat') {
491
+ const deleted = clearChatMessages(project.id);
492
+ resetProjectSession(project.path);
493
+ jsonResponse(res, 200, { ok: true, deleted });
494
+ return;
495
+ }
496
+ if (route.action === 'delete') {
497
+ const deleted = deleteProject(project.id);
498
+ resetProjectSession(project.path);
499
+ jsonResponse(res, 200, { ok: true, deleted });
500
+ return;
501
+ }
502
+ }
503
+ }
504
+ jsonResponse(res, 404, { error: 'Not found.' });
505
+ }
506
+ catch (error) {
507
+ jsonResponse(res, 500, { error: error instanceof Error ? error.message : String(error) });
508
+ }
509
+ });
510
+ await new Promise((resolve, reject) => {
511
+ server.once('error', reject);
512
+ server.listen(port, host, () => {
513
+ server.off('error', reject);
514
+ resolve();
515
+ });
516
+ });
517
+ const url = `http://${host}:${port}`;
518
+ console.log(`Gatekeep UI is running at ${url}`);
519
+ console.log(`Project: ${projectPath}`);
520
+ console.log(`SQLite: ${getDatabasePath()}`);
521
+ console.log(`Open ${pathToFileURL(process.cwd()).href} in your editor and use the browser at ${url}.`);
522
+ return server;
523
+ }
@@ -0,0 +1,170 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { createChatCompletion } from '../ai/chat.js';
4
+ import { getModelName } from '../ai/openAiClient.js';
5
+ import { isFunctionToolCall, runToolCall } from '../functions/toolCallHandler.js';
6
+ import { initializationPrompt, missingIntroNudge } from '../prompts/initializationPrompt.js';
7
+ import { systemMessage } from '../prompts/systemPrompt.js';
8
+ const sessions = new Map();
9
+ function countLines(content) {
10
+ return content ? content.split('\n').length : 0;
11
+ }
12
+ function summarizeToolResult(name, content) {
13
+ if (content.startsWith('Error executing tool:'))
14
+ return content;
15
+ if (name === 'read_file')
16
+ return `Read ${countLines(content)} lines`;
17
+ if (name === 'list_directory')
18
+ return `Listed ${countLines(content)} entries`;
19
+ if (name === 'write_file')
20
+ return content;
21
+ if (name === 'get_git_diff')
22
+ return content.includes('None.') ? 'Checked git diff' : 'Found repository changes';
23
+ return `Returned ${countLines(content)} lines`;
24
+ }
25
+ function previewToolResult(content) {
26
+ const normalized = content.trim();
27
+ const lines = normalized ? normalized.split('\n') : [];
28
+ const maxLines = lines.length > 18 ? Math.max(6, Math.floor(lines.length * 0.4)) : lines.length;
29
+ const linePreview = lines.slice(0, maxLines).join('\n');
30
+ const limit = 1200;
31
+ const preview = linePreview.length > limit ? linePreview.slice(0, limit).trimEnd() : linePreview;
32
+ const truncated = preview.length < normalized.length;
33
+ if (!truncated) {
34
+ return {
35
+ preview: normalized,
36
+ full: normalized,
37
+ truncated: false,
38
+ };
39
+ }
40
+ return {
41
+ preview,
42
+ full: normalized,
43
+ truncated: true,
44
+ };
45
+ }
46
+ function loadProjectSummary(history, projectSummaryPath) {
47
+ if (!fs.existsSync(projectSummaryPath))
48
+ return false;
49
+ const summary = fs.readFileSync(projectSummaryPath, 'utf-8').trim();
50
+ if (!summary)
51
+ return false;
52
+ history.push({
53
+ role: 'system',
54
+ content: [
55
+ 'Project summary loaded from ./docs/intro.md.',
56
+ 'Use this as cached repository context. Refresh it only when the user asks or when code changes make it materially stale.',
57
+ '',
58
+ summary,
59
+ ].join('\n'),
60
+ });
61
+ return true;
62
+ }
63
+ function getSession(project) {
64
+ const resolvedProject = path.resolve(project);
65
+ const existing = sessions.get(resolvedProject);
66
+ if (existing)
67
+ return existing;
68
+ const history = [systemMessage];
69
+ const initialized = loadProjectSummary(history, path.join(resolvedProject, 'docs', 'intro.md'));
70
+ const session = { project: resolvedProject, history, initialized };
71
+ sessions.set(resolvedProject, session);
72
+ return session;
73
+ }
74
+ export function resetProjectSession(project) {
75
+ sessions.delete(path.resolve(project));
76
+ }
77
+ async function pushEvent(events, event, onEvent) {
78
+ events.push(event);
79
+ await onEvent?.(event);
80
+ }
81
+ async function runToolLoop(session, events, maxSteps = 12, requireAnswer = true, onEvent) {
82
+ for (let step = 0; step < maxSteps; step++) {
83
+ await pushEvent(events, { type: 'status', message: step === 0 ? 'thinking through the request' : 'continuing after tool results' }, onEvent);
84
+ const response = await createChatCompletion(session.history);
85
+ const usage = response.usage;
86
+ if (usage) {
87
+ await pushEvent(events, {
88
+ type: 'usage',
89
+ promptTokens: usage.prompt_tokens ?? 0,
90
+ completionTokens: usage.completion_tokens ?? 0,
91
+ totalTokens: usage.total_tokens ?? 0,
92
+ model: getModelName(),
93
+ }, onEvent);
94
+ }
95
+ const message = response.choices[0]?.message;
96
+ if (!message) {
97
+ throw new Error('Provider returned no assistant message.');
98
+ }
99
+ session.history.push(message);
100
+ if (message.tool_calls && message.tool_calls.length > 0) {
101
+ for (const toolCall of message.tool_calls) {
102
+ if (!isFunctionToolCall(toolCall)) {
103
+ session.history.push({
104
+ role: 'tool',
105
+ tool_call_id: toolCall.id,
106
+ content: 'Unsupported custom tool call type.',
107
+ });
108
+ continue;
109
+ }
110
+ const { args, message: toolMessage } = runToolCall(toolCall);
111
+ const content = String(toolMessage.content || '');
112
+ await pushEvent(events, {
113
+ type: 'tool_call',
114
+ name: toolCall.function.name,
115
+ args,
116
+ }, onEvent);
117
+ session.history.push(toolMessage);
118
+ const preview = previewToolResult(content);
119
+ await pushEvent(events, {
120
+ type: 'tool',
121
+ name: toolCall.function.name,
122
+ summary: summarizeToolResult(toolCall.function.name, content),
123
+ ...preview,
124
+ }, onEvent);
125
+ }
126
+ continue;
127
+ }
128
+ const content = typeof message.content === 'string' ? message.content : '';
129
+ await pushEvent(events, { type: 'assistant', message: content }, onEvent);
130
+ return content;
131
+ }
132
+ if (requireAnswer) {
133
+ throw new Error('Agent reached the maximum number of tool steps before answering.');
134
+ }
135
+ return '';
136
+ }
137
+ export async function initializeProject(project) {
138
+ const session = getSession(project);
139
+ const events = [];
140
+ process.chdir(session.project);
141
+ if (session.initialized) {
142
+ events.push({ type: 'status', message: 'Project summary already exists at docs/intro.md.' });
143
+ return events;
144
+ }
145
+ events.push({ type: 'status', message: 'Scanning project and creating docs/intro.md.' });
146
+ session.history.push({ role: 'user', content: initializationPrompt });
147
+ for (let step = 0; step < 15; step++) {
148
+ const before = session.history.length;
149
+ await runToolLoop(session, events, 1, false);
150
+ const wroteIntro = session.history.slice(before).some((message) => (message.role === 'tool' &&
151
+ typeof message.content === 'string' &&
152
+ message.content.startsWith('Success: Wrote')));
153
+ if (wroteIntro || fs.existsSync(path.join(session.project, 'docs', 'intro.md'))) {
154
+ session.initialized = true;
155
+ events.push({ type: 'status', message: 'Initialization complete.' });
156
+ return events;
157
+ }
158
+ session.history.push({ role: 'user', content: missingIntroNudge });
159
+ }
160
+ events.push({ type: 'status', message: 'Initialization stopped after 15 steps. You can ask the agent to continue.' });
161
+ return events;
162
+ }
163
+ export async function sendChatMessage(project, content, onEvent) {
164
+ const session = getSession(project);
165
+ const events = [];
166
+ process.chdir(session.project);
167
+ session.history.push({ role: 'user', content });
168
+ await runToolLoop(session, events, 12, true, onEvent);
169
+ return events;
170
+ }