@lovelybunch/api 1.0.70 → 1.0.71-alpha.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,926 @@
1
+ import { homedir } from 'os';
2
+ import { join, resolve as pathResolve, basename } from 'path';
3
+ import { existsSync, readFileSync, promises as fs, createReadStream } from 'fs';
4
+ import readline from 'readline';
5
+ import { getLogsDir } from '@lovelybunch/core';
6
+ import { proposalsReadOnlyTool, knowledgeTool, normalizeKnowledgeMetadata, eventsTool, projectContextTool, architectureContextTool } from '@lovelybunch/mcp';
7
+ import matter from 'gray-matter';
8
+ import { FileStorageAdapter } from '../../../../lib/storage/file-storage.js';
9
+ // Function to get global config API key as fallback
10
+ function getGlobalApiKey(provider) {
11
+ try {
12
+ const platform = process.platform;
13
+ let configDir;
14
+ if (platform === 'win32') {
15
+ configDir = join(process.env.APPDATA || homedir(), 'coconuts');
16
+ }
17
+ else if (platform === 'darwin') {
18
+ configDir = join(homedir(), 'Library', 'Application Support', 'coconuts');
19
+ }
20
+ else {
21
+ configDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'coconuts');
22
+ }
23
+ const configFile = join(configDir, 'config.json');
24
+ if (!existsSync(configFile)) {
25
+ return null;
26
+ }
27
+ const config = JSON.parse(readFileSync(configFile, 'utf-8'));
28
+ return config.apiKeys?.[provider] || null;
29
+ }
30
+ catch (error) {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * POST /api/v1/ai/tools
36
+ * Execute tool calls that were detected during streaming
37
+ */
38
+ export async function POST(c) {
39
+ try {
40
+ const { toolCalls, model, history, context, contextContent, agentPersona, attachedContextFiles, previousContent } = await c.req.json();
41
+ if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) {
42
+ return c.json({ error: "Tool calls are required" }, 400);
43
+ }
44
+ let openRouterKey = getGlobalApiKey('openrouter') || process.env.OPENROUTER_API_KEY;
45
+ if (!openRouterKey) {
46
+ return c.json({
47
+ error: "OpenRouter API key not configured",
48
+ hint: "Set via 'coconut config set-key -p openrouter -k <KEY>' or set OPENROUTER_API_KEY env var"
49
+ }, 500);
50
+ }
51
+ // Execute all tool calls
52
+ const toolResults = await executeToolCalls(toolCalls);
53
+ // Build the system prompt
54
+ const baseSystem = getSystemPrompt(context, contextContent);
55
+ const systemPrompt = agentPersona
56
+ ? `${baseSystem}\n\nThe following persona is authoritative and overrides general guidance above. You must strictly follow it.\n\n${agentPersona}`
57
+ : baseSystem;
58
+ // Build context messages from attached files
59
+ const contextMessages = Array.isArray(attachedContextFiles)
60
+ ? attachedContextFiles
61
+ .filter((file) => file && typeof file.content === 'string' && file.content.trim().length > 0)
62
+ .map((file) => ({
63
+ role: 'system',
64
+ content: `[ATTACHED CONTEXT - FULL CONTENT INCLUDED BELOW - DO NOT USE TOOLS TO FETCH THIS FILE]\nDocument: ${file.name || file.path || 'reference'}\nType: ${file.type || 'context'}\n\n--- BEGIN CONTENT ---\n${file.content}\n--- END CONTENT ---`
65
+ }))
66
+ : [];
67
+ // Reconstruct the conversation with tool results
68
+ const sanitizedHistory = Array.isArray(history)
69
+ ? history.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
70
+ : [];
71
+ // Build the assistant message that triggered the tool calls
72
+ const assistantMessage = {
73
+ role: 'assistant',
74
+ content: previousContent || null,
75
+ tool_calls: toolCalls.map((tc) => ({
76
+ id: tc.id,
77
+ type: tc.type || 'function',
78
+ function: tc.function
79
+ }))
80
+ };
81
+ // Build tool result messages
82
+ const toolResultMessages = toolResults.map((result) => ({
83
+ role: 'tool',
84
+ content: result.content,
85
+ tool_call_id: result.tool_call_id
86
+ }));
87
+ let messagesPayload = [
88
+ { role: 'system', content: systemPrompt },
89
+ ...contextMessages,
90
+ ...sanitizedHistory,
91
+ assistantMessage,
92
+ ...toolResultMessages
93
+ ];
94
+ // Prepare tools for potential additional tool calls
95
+ // Note: proposals is read-only, knowledge/project/architecture are read+write, events is read-only
96
+ const tools = [
97
+ {
98
+ type: "function",
99
+ function: proposalsReadOnlyTool
100
+ },
101
+ {
102
+ type: "function",
103
+ function: knowledgeTool
104
+ },
105
+ {
106
+ type: "function",
107
+ function: eventsTool
108
+ },
109
+ {
110
+ type: "function",
111
+ function: projectContextTool
112
+ },
113
+ {
114
+ type: "function",
115
+ function: architectureContextTool
116
+ }
117
+ ];
118
+ // Accumulate all tool calls and results across multiple rounds
119
+ const allToolCalls = [...toolCalls];
120
+ const allToolResults = [...toolResults];
121
+ // Maximum iterations - keep at 1 for simplicity and reliability
122
+ // The AI is instructed to do one operation at a time
123
+ const MAX_TOOL_ITERATIONS = 1;
124
+ let iterations = 0;
125
+ let finalContent = '';
126
+ // Loop until AI gives a response without tool calls
127
+ while (iterations < MAX_TOOL_ITERATIONS) {
128
+ iterations++;
129
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
130
+ method: "POST",
131
+ headers: {
132
+ "Authorization": `Bearer ${openRouterKey}`,
133
+ "Content-Type": "application/json",
134
+ "HTTP-Referer": process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3001",
135
+ "X-Title": "Coconut AI Assistant",
136
+ },
137
+ body: JSON.stringify({
138
+ model: model || "openai/gpt-4o",
139
+ messages: messagesPayload,
140
+ temperature: 0.5,
141
+ max_tokens: 4000,
142
+ tools: tools,
143
+ tool_choice: "auto"
144
+ }),
145
+ });
146
+ if (!response.ok) {
147
+ const error = await response.text();
148
+ console.error("OpenRouter API error for tool follow-up:", error);
149
+ return c.json({ error: "Failed to get AI response after tool execution" }, 500);
150
+ }
151
+ const data = await response.json();
152
+ const message = data.choices?.[0]?.message;
153
+ if (!message) {
154
+ console.error("No message in tool follow-up response");
155
+ break;
156
+ }
157
+ // Accumulate content if present
158
+ if (message.content) {
159
+ finalContent += (finalContent ? '\n\n' : '') + message.content;
160
+ }
161
+ // Check if AI wants to make more tool calls
162
+ if (message.tool_calls && message.tool_calls.length > 0) {
163
+ console.log(`Tool iteration ${iterations}: AI requested ${message.tool_calls.length} more tool call(s)`);
164
+ // Execute the new tool calls
165
+ const newToolResults = await executeToolCalls(message.tool_calls);
166
+ // Accumulate tool calls and results
167
+ allToolCalls.push(...message.tool_calls);
168
+ allToolResults.push(...newToolResults);
169
+ // Add the assistant's tool call message and results to the conversation
170
+ messagesPayload = [
171
+ ...messagesPayload,
172
+ {
173
+ role: 'assistant',
174
+ content: message.content || null,
175
+ tool_calls: message.tool_calls
176
+ },
177
+ ...newToolResults.map((result) => ({
178
+ role: 'tool',
179
+ content: result.content,
180
+ tool_call_id: result.tool_call_id
181
+ }))
182
+ ];
183
+ // Continue loop to get next response
184
+ continue;
185
+ }
186
+ // No more tool calls - we have the final response
187
+ break;
188
+ }
189
+ if (iterations >= MAX_TOOL_ITERATIONS && allToolCalls.length > 0) {
190
+ // Don't add the note if we completed successfully
191
+ const hasErrors = allToolResults.some(r => {
192
+ try {
193
+ return !JSON.parse(r.content).success;
194
+ }
195
+ catch {
196
+ return true;
197
+ }
198
+ });
199
+ if (!hasErrors) {
200
+ finalContent += '\n\nOperation completed. Let me know if you need anything else created.';
201
+ }
202
+ }
203
+ // Check if any tool results had errors
204
+ const failedResults = allToolResults.filter(r => {
205
+ try {
206
+ const parsed = JSON.parse(r.content);
207
+ return !parsed.success;
208
+ }
209
+ catch {
210
+ return true;
211
+ }
212
+ });
213
+ if (failedResults.length > 0 && !finalContent.includes('encountered')) {
214
+ finalContent += `\n\n*Note: ${failedResults.length} tool operation(s) encountered errors. You can ask me to retry those.*`;
215
+ }
216
+ return c.json({
217
+ response: finalContent || "Tool execution completed",
218
+ toolCalls: allToolCalls,
219
+ toolResults: allToolResults
220
+ });
221
+ }
222
+ catch (error) {
223
+ console.error("Tool execution API error:", error);
224
+ return c.json({ error: "Internal server error" }, 500);
225
+ }
226
+ }
227
+ async function executeToolCalls(toolCalls) {
228
+ const storage = new FileStorageAdapter();
229
+ const resultPromises = toolCalls.map(async (toolCall) => {
230
+ try {
231
+ const functionName = toolCall.function?.name || toolCall.name;
232
+ // Parse function arguments with error handling for malformed JSON
233
+ let functionArgs;
234
+ try {
235
+ if (toolCall.function?.arguments && typeof toolCall.function.arguments === 'string') {
236
+ // Try to sanitize common JSON issues before parsing
237
+ let argsStr = toolCall.function.arguments;
238
+ // Remove trailing commas before ] or }
239
+ argsStr = argsStr.replace(/,(\s*[}\]])/g, '$1');
240
+ // Try parsing the sanitized string
241
+ functionArgs = JSON.parse(argsStr);
242
+ }
243
+ else {
244
+ functionArgs = toolCall.function?.arguments || toolCall.arguments;
245
+ }
246
+ }
247
+ catch (parseError) {
248
+ console.error('Failed to parse tool arguments:', {
249
+ functionName,
250
+ arguments: toolCall.function?.arguments?.substring(0, 500),
251
+ error: parseError instanceof Error ? parseError.message : 'Unknown parse error'
252
+ });
253
+ return {
254
+ tool_call_id: toolCall.id,
255
+ content: JSON.stringify({
256
+ success: false,
257
+ error: `Invalid tool arguments: ${parseError instanceof Error ? parseError.message : 'JSON parse error'}. Please retry with simpler content.`
258
+ })
259
+ };
260
+ }
261
+ let result = { success: false, error: 'Unknown tool' };
262
+ if (functionName === 'change_proposals') {
263
+ result = await executeProposalsToolDirect(functionArgs, storage);
264
+ }
265
+ else if (functionName === 'knowledge_documents') {
266
+ result = await executeKnowledgeToolDirect(functionArgs);
267
+ }
268
+ else if (functionName === 'activity_events') {
269
+ result = await executeEventsToolDirect(functionArgs);
270
+ }
271
+ else if (functionName === 'project_context') {
272
+ result = await executeProjectContextToolDirect(functionArgs);
273
+ }
274
+ else if (functionName === 'architecture_context') {
275
+ result = await executeArchitectureContextToolDirect(functionArgs);
276
+ }
277
+ return {
278
+ tool_call_id: toolCall.id,
279
+ content: JSON.stringify({
280
+ success: result.success,
281
+ data: result.data,
282
+ message: result.message,
283
+ error: result.error
284
+ })
285
+ };
286
+ }
287
+ catch (error) {
288
+ return {
289
+ tool_call_id: toolCall.id,
290
+ content: JSON.stringify({
291
+ success: false,
292
+ error: error instanceof Error ? error.message : 'Unknown error'
293
+ })
294
+ };
295
+ }
296
+ });
297
+ return Promise.all(resultPromises);
298
+ }
299
+ // Proposals tool is READ-ONLY - only list and get operations are supported
300
+ async function executeProposalsToolDirect(args, storage) {
301
+ const { operation, id, filters } = args;
302
+ try {
303
+ switch (operation) {
304
+ case 'list': {
305
+ const proposals = await storage.listCPs(filters || {});
306
+ return {
307
+ success: true,
308
+ data: proposals,
309
+ message: `Found ${proposals.length} proposals`
310
+ };
311
+ }
312
+ case 'get': {
313
+ if (!id) {
314
+ return { success: false, error: 'Proposal ID is required for get operation' };
315
+ }
316
+ const proposal = await storage.getCP(id);
317
+ if (!proposal) {
318
+ return { success: false, error: 'Proposal not found' };
319
+ }
320
+ return {
321
+ success: true,
322
+ data: proposal,
323
+ message: `Retrieved proposal ${id}`
324
+ };
325
+ }
326
+ default:
327
+ return { success: false, error: `Proposals are read-only. Only 'list' and 'get' operations are supported. To create proposals, use a coding agent (Claude Code, Cursor, etc.) or the Proposals UI.` };
328
+ }
329
+ }
330
+ catch (error) {
331
+ console.error('Error executing proposals tool:', error);
332
+ return { success: false, error: error.message || 'Tool execution failed' };
333
+ }
334
+ }
335
+ // Helper to strip undefined/null values from an object (gray-matter can't handle them)
336
+ function stripUndefined(obj) {
337
+ const result = {};
338
+ for (const key of Object.keys(obj)) {
339
+ const value = obj[key];
340
+ if (value !== undefined && value !== null) {
341
+ if (Array.isArray(value)) {
342
+ // Filter out undefined/null from arrays too
343
+ result[key] = value.filter(v => v !== undefined && v !== null);
344
+ }
345
+ else if (typeof value === 'object' && !Array.isArray(value)) {
346
+ // Recursively strip nested objects
347
+ const stripped = stripUndefined(value);
348
+ if (Object.keys(stripped).length > 0) {
349
+ result[key] = stripped;
350
+ }
351
+ }
352
+ else {
353
+ result[key] = value;
354
+ }
355
+ }
356
+ }
357
+ return result;
358
+ }
359
+ async function executeKnowledgeToolDirect(args) {
360
+ const { operation, filename, title, content, metadata, summary, query } = args;
361
+ try {
362
+ const knowledgePath = getKnowledgeBasePath();
363
+ await fs.mkdir(knowledgePath, { recursive: true });
364
+ switch (operation) {
365
+ case 'list': {
366
+ const searchQuery = query && typeof query === 'string' ? query : undefined;
367
+ const documents = await listKnowledgeDocuments(knowledgePath, searchQuery);
368
+ return {
369
+ success: true,
370
+ data: documents,
371
+ message: searchQuery
372
+ ? `Found ${documents.length} knowledge documents matching "${searchQuery}"`
373
+ : `Found ${documents.length} knowledge documents`
374
+ };
375
+ }
376
+ case 'get': {
377
+ if (!filename) {
378
+ return { success: false, error: 'Filename is required for get operation' };
379
+ }
380
+ const document = await readKnowledgeDocument(knowledgePath, filename);
381
+ if (!document) {
382
+ return { success: false, error: 'Knowledge document not found' };
383
+ }
384
+ return {
385
+ success: true,
386
+ data: document,
387
+ message: `Loaded knowledge document ${document.filename}`
388
+ };
389
+ }
390
+ case 'create': {
391
+ if (!title || !content) {
392
+ return { success: false, error: 'Title and content are required for create operation' };
393
+ }
394
+ const targetFilename = resolveKnowledgeFilename(filename, title);
395
+ const filePath = join(knowledgePath, targetFilename);
396
+ try {
397
+ await fs.access(filePath);
398
+ return { success: false, error: 'A knowledge document with this filename already exists' };
399
+ }
400
+ catch {
401
+ // File does not exist - good
402
+ }
403
+ // Build clean metadata with defaults, stripping any undefined values
404
+ const baseMetadata = {
405
+ version: '1.0',
406
+ updated: new Date().toISOString().split('T')[0],
407
+ type: 'knowledge',
408
+ tags: [],
409
+ sources: [],
410
+ related: [],
411
+ audience: []
412
+ };
413
+ // Only add summary if it's a valid string
414
+ if (typeof summary === 'string' && summary.trim()) {
415
+ baseMetadata.summary = summary.trim();
416
+ }
417
+ // Merge with provided metadata, but strip undefined values
418
+ const safeMetadata = metadata && typeof metadata === 'object'
419
+ ? stripUndefined(metadata)
420
+ : {};
421
+ const finalMetadata = stripUndefined({
422
+ ...baseMetadata,
423
+ ...safeMetadata
424
+ });
425
+ const markdown = matter.stringify(content, finalMetadata);
426
+ await fs.writeFile(filePath, markdown, 'utf-8');
427
+ return {
428
+ success: true,
429
+ data: {
430
+ filename: targetFilename,
431
+ title,
432
+ metadata: finalMetadata,
433
+ content
434
+ },
435
+ message: `Created knowledge document ${targetFilename}`
436
+ };
437
+ }
438
+ case 'update': {
439
+ if (!filename || !content) {
440
+ return { success: false, error: 'Filename and content are required for update operation' };
441
+ }
442
+ const currentFilename = resolveKnowledgeFilename(filename, title || 'knowledge-entry');
443
+ const currentPath = join(knowledgePath, currentFilename);
444
+ let existingDocument = null;
445
+ try {
446
+ existingDocument = await readKnowledgeDocument(knowledgePath, currentFilename);
447
+ }
448
+ catch (error) {
449
+ if (error.code === 'ENOENT') {
450
+ return { success: false, error: 'Knowledge document not found' };
451
+ }
452
+ throw error;
453
+ }
454
+ if (!existingDocument) {
455
+ return { success: false, error: 'Knowledge document not found' };
456
+ }
457
+ const nextTitle = title || existingDocument.title;
458
+ const shouldRename = Boolean(title && slugify(title) !== slugify(existingDocument.title));
459
+ const nextFilename = shouldRename ? resolveKnowledgeFilename(undefined, nextTitle) : currentFilename;
460
+ const nextPath = join(knowledgePath, nextFilename);
461
+ if (nextFilename !== currentFilename) {
462
+ try {
463
+ await fs.access(nextPath);
464
+ return { success: false, error: 'A document with the requested title already exists' };
465
+ }
466
+ catch {
467
+ // Safe to rename
468
+ }
469
+ }
470
+ // Safely merge metadata, stripping undefined values
471
+ const safeExistingMetadata = stripUndefined(existingDocument.metadata || {});
472
+ const safeNewMetadata = metadata && typeof metadata === 'object'
473
+ ? stripUndefined(metadata)
474
+ : {};
475
+ const mergedMetadata = stripUndefined({
476
+ ...safeExistingMetadata,
477
+ ...safeNewMetadata,
478
+ updated: new Date().toISOString().split('T')[0],
479
+ ...(typeof summary === 'string' && summary.trim() ? { summary: summary.trim() } : {})
480
+ });
481
+ const markdown = matter.stringify(content, mergedMetadata);
482
+ await fs.writeFile(nextPath, markdown, 'utf-8');
483
+ if (nextFilename !== currentFilename) {
484
+ await fs.unlink(currentPath);
485
+ }
486
+ return {
487
+ success: true,
488
+ data: {
489
+ filename: nextFilename,
490
+ title: nextTitle,
491
+ metadata: mergedMetadata,
492
+ content
493
+ },
494
+ message: `Updated knowledge document ${nextFilename}`
495
+ };
496
+ }
497
+ default:
498
+ return { success: false, error: `Unknown operation: ${operation}` };
499
+ }
500
+ }
501
+ catch (error) {
502
+ console.error('Error executing knowledge tool:', error);
503
+ return { success: false, error: error.message || 'Knowledge tool execution failed' };
504
+ }
505
+ }
506
+ // Events tool is READ-ONLY - returns recent activity events
507
+ async function executeEventsToolDirect(args) {
508
+ const { operation, limit = 20, kind } = args;
509
+ if (operation !== 'list') {
510
+ return {
511
+ success: false,
512
+ error: `Events are read-only. Only 'list' operation is supported.`
513
+ };
514
+ }
515
+ try {
516
+ const eventsDir = getLogsDir();
517
+ const currentFile = join(eventsDir, 'events-current.jsonl');
518
+ // Check if file exists
519
+ try {
520
+ await fs.access(currentFile);
521
+ }
522
+ catch {
523
+ return {
524
+ success: true,
525
+ data: [],
526
+ message: 'No events found'
527
+ };
528
+ }
529
+ // Read all events from file
530
+ const allEvents = [];
531
+ const fileStream = createReadStream(currentFile);
532
+ const rl = readline.createInterface({
533
+ input: fileStream,
534
+ crlfDelay: Infinity,
535
+ });
536
+ for await (const line of rl) {
537
+ if (!line.trim())
538
+ continue;
539
+ try {
540
+ const event = JSON.parse(line);
541
+ // Apply kind filter if specified
542
+ if (kind && typeof kind === 'string') {
543
+ if (!event.kind || !event.kind.startsWith(kind)) {
544
+ continue;
545
+ }
546
+ }
547
+ allEvents.push(event);
548
+ }
549
+ catch {
550
+ // Skip malformed lines
551
+ continue;
552
+ }
553
+ }
554
+ // Sort by seq descending (most recent first) and limit
555
+ const cappedLimit = Math.min(Math.max(1, limit), 100);
556
+ const sortedEvents = allEvents
557
+ .sort((a, b) => (b.seq || 0) - (a.seq || 0))
558
+ .slice(0, cappedLimit);
559
+ // Format events for readability
560
+ const formattedEvents = sortedEvents.map(event => ({
561
+ seq: event.seq,
562
+ kind: event.kind,
563
+ actor: event.actor,
564
+ subject: event.subject,
565
+ timestamp: event.ts,
566
+ level: event.level,
567
+ tags: event.tags,
568
+ summary: event.payload?.summary || event.payload?.message || event.payload?.title || undefined
569
+ }));
570
+ return {
571
+ success: true,
572
+ data: formattedEvents,
573
+ message: kind
574
+ ? `Found ${formattedEvents.length} recent '${kind}' events`
575
+ : `Found ${formattedEvents.length} recent events`
576
+ };
577
+ }
578
+ catch (error) {
579
+ console.error('Error executing events tool:', error);
580
+ return { success: false, error: error.message || 'Events tool execution failed' };
581
+ }
582
+ }
583
+ // Project context tool - read/write the project definition document
584
+ async function executeProjectContextToolDirect(args) {
585
+ const { operation, content, old_text, new_text } = args;
586
+ try {
587
+ const contextPath = getContextBasePath();
588
+ const filePath = join(contextPath, 'project.md');
589
+ switch (operation) {
590
+ case 'get': {
591
+ try {
592
+ const fileContent = await fs.readFile(filePath, 'utf-8');
593
+ const parsed = matter(fileContent);
594
+ return {
595
+ success: true,
596
+ data: {
597
+ content: parsed.content,
598
+ frontmatter: parsed.data,
599
+ raw: fileContent
600
+ },
601
+ message: 'Retrieved project context'
602
+ };
603
+ }
604
+ catch (err) {
605
+ if (err.code === 'ENOENT') {
606
+ return {
607
+ success: true,
608
+ data: { content: '', frontmatter: {}, raw: '' },
609
+ message: 'Project context document does not exist yet. You can create it with an update operation.'
610
+ };
611
+ }
612
+ throw err;
613
+ }
614
+ }
615
+ case 'append': {
616
+ if (!content) {
617
+ return { success: false, error: 'Content is required for append operation' };
618
+ }
619
+ await fs.mkdir(contextPath, { recursive: true });
620
+ // Read existing content if file exists
621
+ let existingContent = '';
622
+ try {
623
+ existingContent = await fs.readFile(filePath, 'utf-8');
624
+ }
625
+ catch (err) {
626
+ if (err.code !== 'ENOENT')
627
+ throw err;
628
+ }
629
+ // Append new content with a newline separator
630
+ const newContent = existingContent
631
+ ? existingContent.trimEnd() + '\n\n' + content
632
+ : content;
633
+ await fs.writeFile(filePath, newContent, 'utf-8');
634
+ return {
635
+ success: true,
636
+ message: 'Appended content to project context document'
637
+ };
638
+ }
639
+ case 'replace_section': {
640
+ if (!old_text || !new_text) {
641
+ return { success: false, error: 'Both old_text and new_text are required for replace_section operation' };
642
+ }
643
+ // Read existing content
644
+ let existingContent = '';
645
+ try {
646
+ existingContent = await fs.readFile(filePath, 'utf-8');
647
+ }
648
+ catch (err) {
649
+ if (err.code === 'ENOENT') {
650
+ return { success: false, error: 'Project context document does not exist. Use append or update to create it first.' };
651
+ }
652
+ throw err;
653
+ }
654
+ // Check if old_text exists in the document
655
+ if (!existingContent.includes(old_text)) {
656
+ return {
657
+ success: false,
658
+ error: 'Could not find the specified text in the document. The text may have been paraphrased or changed.',
659
+ fallback_markdown: new_text,
660
+ suggestion: 'Here is the replacement text. You can copy it and manually edit the document at /context/project'
661
+ };
662
+ }
663
+ // Replace the text
664
+ const updatedContent = existingContent.replace(old_text, new_text);
665
+ await fs.writeFile(filePath, updatedContent, 'utf-8');
666
+ return {
667
+ success: true,
668
+ message: 'Replaced section in project context document'
669
+ };
670
+ }
671
+ case 'update': {
672
+ if (!content) {
673
+ return { success: false, error: 'Content is required for update operation' };
674
+ }
675
+ await fs.mkdir(contextPath, { recursive: true });
676
+ await fs.writeFile(filePath, content, 'utf-8');
677
+ return {
678
+ success: true,
679
+ message: 'Updated project context document'
680
+ };
681
+ }
682
+ default:
683
+ return { success: false, error: `Unknown operation: ${operation}. Use 'get', 'append', 'replace_section', or 'update'.` };
684
+ }
685
+ }
686
+ catch (error) {
687
+ console.error('Error executing project context tool:', error);
688
+ return { success: false, error: error.message || 'Project context tool execution failed' };
689
+ }
690
+ }
691
+ // Architecture context tool - read/write the architecture document
692
+ async function executeArchitectureContextToolDirect(args) {
693
+ const { operation, content, old_text, new_text } = args;
694
+ try {
695
+ const contextPath = getContextBasePath();
696
+ const filePath = join(contextPath, 'architecture.md');
697
+ switch (operation) {
698
+ case 'get': {
699
+ try {
700
+ const fileContent = await fs.readFile(filePath, 'utf-8');
701
+ const parsed = matter(fileContent);
702
+ return {
703
+ success: true,
704
+ data: {
705
+ content: parsed.content,
706
+ frontmatter: parsed.data,
707
+ raw: fileContent
708
+ },
709
+ message: 'Retrieved architecture context'
710
+ };
711
+ }
712
+ catch (err) {
713
+ if (err.code === 'ENOENT') {
714
+ return {
715
+ success: true,
716
+ data: { content: '', frontmatter: {}, raw: '' },
717
+ message: 'Architecture context document does not exist yet. You can create it with an update operation.'
718
+ };
719
+ }
720
+ throw err;
721
+ }
722
+ }
723
+ case 'append': {
724
+ if (!content) {
725
+ return { success: false, error: 'Content is required for append operation' };
726
+ }
727
+ await fs.mkdir(contextPath, { recursive: true });
728
+ // Read existing content if file exists
729
+ let existingContent = '';
730
+ try {
731
+ existingContent = await fs.readFile(filePath, 'utf-8');
732
+ }
733
+ catch (err) {
734
+ if (err.code !== 'ENOENT')
735
+ throw err;
736
+ }
737
+ // Append new content with a newline separator
738
+ const newContent = existingContent
739
+ ? existingContent.trimEnd() + '\n\n' + content
740
+ : content;
741
+ await fs.writeFile(filePath, newContent, 'utf-8');
742
+ return {
743
+ success: true,
744
+ message: 'Appended content to architecture context document'
745
+ };
746
+ }
747
+ case 'replace_section': {
748
+ if (!old_text || !new_text) {
749
+ return { success: false, error: 'Both old_text and new_text are required for replace_section operation' };
750
+ }
751
+ // Read existing content
752
+ let existingContent = '';
753
+ try {
754
+ existingContent = await fs.readFile(filePath, 'utf-8');
755
+ }
756
+ catch (err) {
757
+ if (err.code === 'ENOENT') {
758
+ return { success: false, error: 'Architecture context document does not exist. Use append or update to create it first.' };
759
+ }
760
+ throw err;
761
+ }
762
+ // Check if old_text exists in the document
763
+ if (!existingContent.includes(old_text)) {
764
+ return {
765
+ success: false,
766
+ error: 'Could not find the specified text in the document. The text may have been paraphrased or changed.',
767
+ fallback_markdown: new_text,
768
+ suggestion: 'Here is the replacement text. You can copy it and manually edit the document at /context/architecture'
769
+ };
770
+ }
771
+ // Replace the text
772
+ const updatedContent = existingContent.replace(old_text, new_text);
773
+ await fs.writeFile(filePath, updatedContent, 'utf-8');
774
+ return {
775
+ success: true,
776
+ message: 'Replaced section in architecture context document'
777
+ };
778
+ }
779
+ case 'update': {
780
+ if (!content) {
781
+ return { success: false, error: 'Content is required for update operation' };
782
+ }
783
+ await fs.mkdir(contextPath, { recursive: true });
784
+ await fs.writeFile(filePath, content, 'utf-8');
785
+ return {
786
+ success: true,
787
+ message: 'Updated architecture context document'
788
+ };
789
+ }
790
+ default:
791
+ return { success: false, error: `Unknown operation: ${operation}. Use 'get', 'append', 'replace_section', or 'update'.` };
792
+ }
793
+ }
794
+ catch (error) {
795
+ console.error('Error executing architecture context tool:', error);
796
+ return { success: false, error: error.message || 'Architecture context tool execution failed' };
797
+ }
798
+ }
799
+ function getContextBasePath() {
800
+ let basePath;
801
+ if (process.env.NODE_ENV === 'development' && process.env.GAIT_DEV_ROOT) {
802
+ basePath = process.env.GAIT_DEV_ROOT;
803
+ }
804
+ else if (process.env.GAIT_DATA_PATH) {
805
+ basePath = pathResolve(process.env.GAIT_DATA_PATH, '.nut');
806
+ }
807
+ else {
808
+ basePath = pathResolve(process.cwd(), '.nut');
809
+ }
810
+ return join(basePath, 'context');
811
+ }
812
+ function getKnowledgeBasePath() {
813
+ let basePath;
814
+ if (process.env.NODE_ENV === 'development' && process.env.GAIT_DEV_ROOT) {
815
+ basePath = process.env.GAIT_DEV_ROOT;
816
+ }
817
+ else if (process.env.GAIT_DATA_PATH) {
818
+ basePath = pathResolve(process.env.GAIT_DATA_PATH, '.nut');
819
+ }
820
+ else {
821
+ basePath = pathResolve(process.cwd(), '.nut');
822
+ }
823
+ return join(basePath, 'context', 'knowledge');
824
+ }
825
+ function slugify(value) {
826
+ const slug = value
827
+ .toLowerCase()
828
+ .replace(/[^a-z0-9\s-]/g, '')
829
+ .replace(/\s+/g, '-')
830
+ .replace(/--+/g, '-')
831
+ .replace(/^-|-$/g, '');
832
+ return slug || 'knowledge-entry';
833
+ }
834
+ function resolveKnowledgeFilename(input, fallbackTitle) {
835
+ if (input && input.trim().length > 0) {
836
+ const trimmed = basename(input.trim());
837
+ if (trimmed.endsWith('.md')) {
838
+ return trimmed;
839
+ }
840
+ return `${slugify(trimmed)}.md`;
841
+ }
842
+ const fallback = fallbackTitle && fallbackTitle.trim().length > 0
843
+ ? basename(fallbackTitle.trim())
844
+ : 'knowledge-entry';
845
+ return `${slugify(fallback)}.md`;
846
+ }
847
+ function extractKnowledgeTitle(content, fallback) {
848
+ const headingMatch = content.match(/^#\s+(.+)$/m);
849
+ if (headingMatch && headingMatch[1]) {
850
+ return headingMatch[1].trim();
851
+ }
852
+ const baseName = fallback.replace('.md', '').replace(/[_-]/g, ' ');
853
+ return baseName.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
854
+ }
855
+ async function readKnowledgeDocument(basePath, filename) {
856
+ const actualFilename = resolveKnowledgeFilename(filename, filename);
857
+ const filePath = join(basePath, actualFilename);
858
+ const raw = await fs.readFile(filePath, 'utf-8');
859
+ const { data, content } = matter(raw);
860
+ const normalizedMetadata = normalizeKnowledgeMetadata(data);
861
+ const title = extractKnowledgeTitle(content, actualFilename);
862
+ return {
863
+ filename: actualFilename,
864
+ title,
865
+ metadata: normalizedMetadata,
866
+ content
867
+ };
868
+ }
869
+ async function listKnowledgeDocuments(basePath, query) {
870
+ const files = await fs.readdir(basePath);
871
+ const markdownFiles = files.filter(file => file.endsWith('.md'));
872
+ const documents = await Promise.all(markdownFiles.map(async (file) => {
873
+ try {
874
+ return await readKnowledgeDocument(basePath, file);
875
+ }
876
+ catch (error) {
877
+ console.error(`Failed to read knowledge document ${file}:`, error);
878
+ return null;
879
+ }
880
+ }));
881
+ const validDocs = documents.filter((doc) => doc !== null);
882
+ if (!query?.trim()) {
883
+ return validDocs.sort((a, b) => a.filename.localeCompare(b.filename));
884
+ }
885
+ // Simple text search for the tools endpoint
886
+ const lowerQuery = query.toLowerCase();
887
+ return validDocs
888
+ .filter(doc => doc.title.toLowerCase().includes(lowerQuery) ||
889
+ doc.content.toLowerCase().includes(lowerQuery) ||
890
+ doc.metadata?.summary?.toLowerCase().includes(lowerQuery))
891
+ .sort((a, b) => a.filename.localeCompare(b.filename));
892
+ }
893
+ function getSystemPrompt(context, contextContent) {
894
+ const basePrompt = `You are the Coconut 🥥 AI assistant for agent-native development workflows. You help developers with proposals, architecture decisions, planning, and execution.`;
895
+ switch (context) {
896
+ case "proposals":
897
+ case "proposals-edit":
898
+ case "proposals-view":
899
+ let proposalPrompt = `${basePrompt} You are currently in the proposals section. Help users:
900
+ - Refine and improve their change proposals
901
+ - Suggest implementation approaches
902
+ - Identify potential challenges or risks
903
+ - Break down complex changes into manageable steps
904
+ - Provide feedback on proposal clarity and completeness`;
905
+ if (contextContent) {
906
+ proposalPrompt += `\n\nYou have access to the following proposal data:\n\n${contextContent}`;
907
+ }
908
+ return proposalPrompt;
909
+ case "context":
910
+ return `${basePrompt} You are currently in the context section (architecture, decisions, knowledge). Help users:
911
+ - Analyze architectural decisions and patterns
912
+ - Suggest improvements to documentation
913
+ - Identify gaps in knowledge or decisions
914
+ - Recommend best practices
915
+ - Help maintain consistency across the project`;
916
+ case "settings":
917
+ return `${basePrompt} You are currently in the settings section. Help users:
918
+ - Configure their GAIT environment
919
+ - Set up integrations and workflows
920
+ - Optimize their development process
921
+ - Understand configuration options
922
+ - Troubleshoot setup issues`;
923
+ default:
924
+ return `${basePrompt} Provide helpful, contextual assistance based on the user's current needs in their GAIT workflow.`;
925
+ }
926
+ }