@lovelybunch/api 1.0.70 → 1.0.71-alpha.1

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.
@@ -1,11 +1,13 @@
1
1
  import { homedir } from 'os';
2
2
  import { join, resolve as pathResolve, basename } from 'path';
3
- import { existsSync, readFileSync, promises as fs } from 'fs';
3
+ import { existsSync, readFileSync, promises as fs, createReadStream } from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
- import { proposalsTool, listProposalsTool, validateProposalData, knowledgeTool, normalizeKnowledgeMetadata } from '@lovelybunch/mcp';
5
+ import readline from 'readline';
6
+ import { getLogsDir } from '@lovelybunch/core';
7
+ import { proposalsReadOnlyTool, knowledgeTool, normalizeKnowledgeMetadata, eventsTool, projectContextTool, architectureContextTool } from '@lovelybunch/mcp';
6
8
  import matter from 'gray-matter';
9
+ import Fuse from 'fuse.js';
7
10
  import { FileStorageAdapter } from '../../../../lib/storage/file-storage.js';
8
- import { getAuthorInfo } from '../../../../lib/user-preferences.js';
9
11
  // Function to get global config API key as fallback
10
12
  function getGlobalApiKey(provider) {
11
13
  try {
@@ -51,18 +53,27 @@ export async function POST(c) {
51
53
  ? `${baseSystem}\n\nThe following persona is authoritative and overrides general guidance above. You must strictly follow it.\n\n${agentPersona}`
52
54
  : baseSystem;
53
55
  // Prepare tools for function calling
56
+ // Note: proposals is read-only, knowledge/project/architecture are read+write, events is read-only
54
57
  const tools = enableTools ? [
55
58
  {
56
59
  type: "function",
57
- function: listProposalsTool
60
+ function: proposalsReadOnlyTool
58
61
  },
59
62
  {
60
63
  type: "function",
61
- function: proposalsTool
64
+ function: knowledgeTool
62
65
  },
63
66
  {
64
67
  type: "function",
65
- function: knowledgeTool
68
+ function: eventsTool
69
+ },
70
+ {
71
+ type: "function",
72
+ function: projectContextTool
73
+ },
74
+ {
75
+ type: "function",
76
+ function: architectureContextTool
66
77
  }
67
78
  ] : null;
68
79
  const contextMessages = Array.isArray(attachedContextFiles)
@@ -70,7 +81,7 @@ export async function POST(c) {
70
81
  .filter((file) => file && typeof file.content === 'string' && file.content.trim().length > 0)
71
82
  .map((file) => ({
72
83
  role: 'system',
73
- content: `Context from ${file.name || file.path || 'reference'}:\n${file.content}`
84
+ 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 ---`
74
85
  }))
75
86
  : [];
76
87
  // Compose the message list: system + history (preferred) or single message
@@ -121,9 +132,26 @@ export async function POST(c) {
121
132
  body: JSON.stringify(requestBody),
122
133
  });
123
134
  if (!response.ok) {
124
- const error = await response.text();
125
- console.error("OpenRouter API error:", error);
126
- return c.json({ error: "Failed to get AI response" }, 500);
135
+ const errorText = await response.text();
136
+ console.error("OpenRouter API error:", {
137
+ status: response.status,
138
+ statusText: response.statusText,
139
+ error: errorText,
140
+ model: requestBody.model
141
+ });
142
+ // Try to parse error for more detail
143
+ let errorDetail = "Failed to get AI response";
144
+ try {
145
+ const errorJson = JSON.parse(errorText);
146
+ errorDetail = errorJson.error?.message || errorJson.error || errorDetail;
147
+ }
148
+ catch {
149
+ // Use raw text if not JSON
150
+ if (errorText.length < 200) {
151
+ errorDetail = errorText || errorDetail;
152
+ }
153
+ }
154
+ return c.json({ error: errorDetail, status: response.status }, response.status);
127
155
  }
128
156
  // Handle streaming response
129
157
  if (stream === true && response.body) {
@@ -139,7 +167,13 @@ export async function POST(c) {
139
167
  const data = await response.json();
140
168
  const aiMessage = data.choices?.[0]?.message;
141
169
  if (!aiMessage) {
142
- return c.json({ error: "No response from AI model" }, 500);
170
+ console.error("No AI message in response:", {
171
+ model: requestBody.model,
172
+ choices: data.choices,
173
+ error: data.error
174
+ });
175
+ const errorDetail = data.error?.message || data.error || "No response from AI model";
176
+ return c.json({ error: errorDetail }, 500);
143
177
  }
144
178
  // Handle tool calls
145
179
  if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
@@ -198,20 +232,44 @@ async function executeToolCalls(toolCalls) {
198
232
  try {
199
233
  // Handle both function calling formats
200
234
  const functionName = toolCall.function?.name || toolCall.name;
201
- const functionArgs = toolCall.function?.arguments ?
202
- JSON.parse(toolCall.function.arguments) :
203
- toolCall.arguments;
235
+ let functionArgs;
236
+ if (toolCall.function?.arguments) {
237
+ try {
238
+ functionArgs = JSON.parse(toolCall.function.arguments);
239
+ }
240
+ catch (parseError) {
241
+ // LLM may generate invalid JSON with unescaped characters in long content
242
+ // Return a helpful error message
243
+ const errorMsg = parseError instanceof Error ? parseError.message : 'Unknown parse error';
244
+ return {
245
+ tool_call_id: toolCall.id,
246
+ content: JSON.stringify({
247
+ success: false,
248
+ error: `Invalid tool arguments: ${errorMsg}. Please retry with simpler content.`
249
+ })
250
+ };
251
+ }
252
+ }
253
+ else {
254
+ functionArgs = toolCall.arguments;
255
+ }
204
256
  let result = { success: false, error: 'Unknown tool' };
205
257
  // Execute tools directly instead of making HTTP requests
206
258
  if (functionName === 'change_proposals') {
207
259
  result = await executeProposalsToolDirect(functionArgs, storage);
208
260
  }
209
- else if (functionName === 'list_proposals') {
210
- result = await executeListProposalsToolDirect(functionArgs, storage);
211
- }
212
261
  else if (functionName === 'knowledge_documents') {
213
262
  result = await executeKnowledgeToolDirect(functionArgs);
214
263
  }
264
+ else if (functionName === 'activity_events') {
265
+ result = await executeEventsToolDirect(functionArgs);
266
+ }
267
+ else if (functionName === 'project_context') {
268
+ result = await executeProjectContextToolDirect(functionArgs);
269
+ }
270
+ else if (functionName === 'architecture_context') {
271
+ result = await executeArchitectureContextToolDirect(functionArgs);
272
+ }
215
273
  return {
216
274
  tool_call_id: toolCall.id,
217
275
  content: JSON.stringify({
@@ -234,8 +292,9 @@ async function executeToolCalls(toolCalls) {
234
292
  });
235
293
  return Promise.all(resultPromises);
236
294
  }
295
+ // Proposals tool is READ-ONLY - only list and get operations are supported
237
296
  async function executeProposalsToolDirect(args, storage) {
238
- const { operation, id, filters, proposal } = args;
297
+ const { operation, id, filters } = args;
239
298
  try {
240
299
  switch (operation) {
241
300
  case 'list': {
@@ -260,121 +319,8 @@ async function executeProposalsToolDirect(args, storage) {
260
319
  message: `Retrieved proposal ${id}`
261
320
  };
262
321
  }
263
- case 'create': {
264
- if (!proposal) {
265
- return { success: false, error: 'Proposal data is required for create operation' };
266
- }
267
- // Validate proposal data - check required fields
268
- if (!proposal.intent || !proposal.content) {
269
- return { success: false, error: 'Proposal intent and content are required' };
270
- }
271
- // Get author info
272
- const author = await getAuthorInfo();
273
- // Normalize and validate incoming proposal
274
- const validatedProposal = validateProposalData(proposal);
275
- // Normalize plan steps: accept strings or objects
276
- const planSteps = (validatedProposal.planSteps || []).map((step, index) => {
277
- if (typeof step === 'string') {
278
- return {
279
- id: `step-${index + 1}`,
280
- description: step,
281
- status: 'pending'
282
- };
283
- }
284
- return {
285
- id: step.id || `step-${index + 1}`,
286
- description: step.description || '',
287
- status: step.status || 'pending',
288
- command: step.command,
289
- expectedOutcome: step.expectedOutcome,
290
- output: step.output,
291
- error: step.error,
292
- executedAt: step.executedAt
293
- };
294
- });
295
- const now = new Date();
296
- const newProposal = {
297
- id: validatedProposal.id || `cp-${Date.now()}`,
298
- intent: validatedProposal.intent || '',
299
- content: validatedProposal.content || '',
300
- author: {
301
- id: validatedProposal.author?.id || 'current-user',
302
- name: validatedProposal.author?.name || author.name || 'Unknown User',
303
- email: validatedProposal.author?.email || author.email || '',
304
- type: validatedProposal.author?.type || 'human'
305
- },
306
- planSteps,
307
- evidence: validatedProposal.evidence || [],
308
- policies: validatedProposal.policies || [],
309
- featureFlags: validatedProposal.featureFlags || [],
310
- experiments: validatedProposal.experiments || [],
311
- telemetryContracts: validatedProposal.telemetryContracts || [],
312
- releasePlan: validatedProposal.releasePlan || { strategy: 'immediate' },
313
- status: validatedProposal.status || 'draft',
314
- metadata: {
315
- createdAt: now,
316
- updatedAt: now,
317
- reviewers: validatedProposal.metadata?.reviewers || [],
318
- aiInteractions: validatedProposal.metadata?.aiInteractions || [],
319
- tags: validatedProposal.metadata?.tags || [],
320
- priority: validatedProposal.metadata?.priority || 'medium'
321
- },
322
- productSpecRef: validatedProposal.productSpecRef
323
- };
324
- await storage.createCP(newProposal);
325
- return {
326
- success: true,
327
- data: newProposal,
328
- message: `Created proposal ${newProposal.id} (${newProposal.intent})`
329
- };
330
- }
331
- case 'update': {
332
- if (!id || !proposal) {
333
- return { success: false, error: 'Proposal ID and data are required for update operation' };
334
- }
335
- // Validate proposal data - check required fields
336
- if (!proposal.intent || !proposal.content) {
337
- return { success: false, error: 'Proposal intent and content are required' };
338
- }
339
- // Get existing proposal to preserve author and creation date
340
- const existingProposal = await storage.getCP(id);
341
- if (!existingProposal) {
342
- return { success: false, error: 'Proposal not found' };
343
- }
344
- // Update the proposal
345
- const proposalToUpdate = {
346
- ...proposal,
347
- author: existingProposal.author, // Preserve original author
348
- metadata: {
349
- ...existingProposal.metadata,
350
- ...proposal.metadata,
351
- updatedAt: new Date().toISOString()
352
- }
353
- };
354
- await storage.updateCP(id, proposalToUpdate);
355
- return {
356
- success: true,
357
- data: proposalToUpdate,
358
- message: `Updated proposal ${id}`
359
- };
360
- }
361
- case 'delete': {
362
- if (!id) {
363
- return { success: false, error: 'Proposal ID is required for delete operation' };
364
- }
365
- // Check if proposal exists before deleting
366
- const existingProposal = await storage.getCP(id);
367
- if (!existingProposal) {
368
- return { success: false, error: 'Proposal not found' };
369
- }
370
- await storage.deleteCP(id);
371
- return {
372
- success: true,
373
- message: `Deleted proposal ${id}`
374
- };
375
- }
376
322
  default:
377
- return { success: false, error: `Unknown operation: ${operation}` };
323
+ 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.` };
378
324
  }
379
325
  }
380
326
  catch (error) {
@@ -382,49 +328,21 @@ async function executeProposalsToolDirect(args, storage) {
382
328
  return { success: false, error: error.message || 'Tool execution failed' };
383
329
  }
384
330
  }
385
- async function executeListProposalsToolDirect(args, storage) {
386
- const { filters } = args;
387
- try {
388
- const proposals = await storage.listCPs(filters || {});
389
- // Return only metadata, not full content
390
- const metadataOnly = proposals.map(proposal => ({
391
- id: proposal.id,
392
- intent: proposal.intent,
393
- status: proposal.status,
394
- priority: proposal.metadata?.priority || 'medium',
395
- tags: proposal.metadata?.tags || [],
396
- author: {
397
- name: proposal.author.name,
398
- email: proposal.author.email
399
- },
400
- createdAt: proposal.metadata.createdAt,
401
- updatedAt: proposal.metadata.updatedAt,
402
- reviewers: proposal.metadata.reviewers || [],
403
- productSpecRef: proposal.productSpecRef
404
- }));
405
- return {
406
- success: true,
407
- data: metadataOnly,
408
- message: `Found ${metadataOnly.length} proposals`
409
- };
410
- }
411
- catch (error) {
412
- console.error('Error executing list proposals tool:', error);
413
- return { success: false, error: error.message || 'Failed to list proposals' };
414
- }
415
- }
416
331
  async function executeKnowledgeToolDirect(args) {
417
- const { operation, filename, title, content, metadata, summary } = args;
332
+ const { operation, filename, title, content, metadata, summary, query } = args;
418
333
  try {
419
334
  const knowledgePath = getKnowledgeBasePath();
420
335
  await fs.mkdir(knowledgePath, { recursive: true });
421
336
  switch (operation) {
422
337
  case 'list': {
423
- const documents = await listKnowledgeDocuments(knowledgePath);
338
+ const searchQuery = query && typeof query === 'string' ? query : undefined;
339
+ const documents = await listKnowledgeDocuments(knowledgePath, searchQuery);
424
340
  return {
425
341
  success: true,
426
342
  data: documents,
427
- message: `Found ${documents.length} knowledge documents`
343
+ message: searchQuery
344
+ ? `Found ${documents.length} knowledge documents matching "${searchQuery}"`
345
+ : `Found ${documents.length} knowledge documents`
428
346
  };
429
347
  }
430
348
  case 'get': {
@@ -563,6 +481,315 @@ async function executeKnowledgeToolDirect(args) {
563
481
  return { success: false, error: error.message || 'Knowledge tool execution failed' };
564
482
  }
565
483
  }
484
+ // Events tool is READ-ONLY - returns recent activity events
485
+ async function executeEventsToolDirect(args) {
486
+ const { operation, limit = 20, kind } = args;
487
+ if (operation !== 'list') {
488
+ return {
489
+ success: false,
490
+ error: `Events are read-only. Only 'list' operation is supported.`
491
+ };
492
+ }
493
+ try {
494
+ const eventsDir = getLogsDir();
495
+ const currentFile = join(eventsDir, 'events-current.jsonl');
496
+ // Check if file exists
497
+ try {
498
+ await fs.access(currentFile);
499
+ }
500
+ catch {
501
+ return {
502
+ success: true,
503
+ data: [],
504
+ message: 'No events found'
505
+ };
506
+ }
507
+ // Read all events from file
508
+ const allEvents = [];
509
+ const fileStream = createReadStream(currentFile);
510
+ const rl = readline.createInterface({
511
+ input: fileStream,
512
+ crlfDelay: Infinity,
513
+ });
514
+ for await (const line of rl) {
515
+ if (!line.trim())
516
+ continue;
517
+ try {
518
+ const event = JSON.parse(line);
519
+ // Apply kind filter if specified
520
+ if (kind && typeof kind === 'string') {
521
+ if (!event.kind || !event.kind.startsWith(kind)) {
522
+ continue;
523
+ }
524
+ }
525
+ allEvents.push(event);
526
+ }
527
+ catch {
528
+ // Skip malformed lines
529
+ continue;
530
+ }
531
+ }
532
+ // Sort by timestamp descending (most recent first) and limit
533
+ const cappedLimit = Math.min(Math.max(1, limit), 100);
534
+ const sortedEvents = allEvents
535
+ .sort((a, b) => {
536
+ // Sort by seq descending (higher seq = more recent)
537
+ return (b.seq || 0) - (a.seq || 0);
538
+ })
539
+ .slice(0, cappedLimit);
540
+ // Format events for readability
541
+ const formattedEvents = sortedEvents.map(event => ({
542
+ seq: event.seq,
543
+ kind: event.kind,
544
+ actor: event.actor,
545
+ subject: event.subject,
546
+ timestamp: event.ts,
547
+ level: event.level,
548
+ tags: event.tags,
549
+ summary: event.payload?.summary || event.payload?.message || event.payload?.title || undefined
550
+ }));
551
+ return {
552
+ success: true,
553
+ data: formattedEvents,
554
+ message: kind
555
+ ? `Found ${formattedEvents.length} recent '${kind}' events`
556
+ : `Found ${formattedEvents.length} recent events`
557
+ };
558
+ }
559
+ catch (error) {
560
+ console.error('Error executing events tool:', error);
561
+ return { success: false, error: error.message || 'Events tool execution failed' };
562
+ }
563
+ }
564
+ // Project context tool - read/write the project definition document
565
+ async function executeProjectContextToolDirect(args) {
566
+ const { operation, content, old_text, new_text } = args;
567
+ try {
568
+ const contextPath = getContextBasePath();
569
+ const filePath = join(contextPath, 'project.md');
570
+ switch (operation) {
571
+ case 'get': {
572
+ try {
573
+ const fileContent = await fs.readFile(filePath, 'utf-8');
574
+ const parsed = matter(fileContent);
575
+ return {
576
+ success: true,
577
+ data: {
578
+ content: parsed.content,
579
+ frontmatter: parsed.data,
580
+ raw: fileContent
581
+ },
582
+ message: 'Retrieved project context'
583
+ };
584
+ }
585
+ catch (err) {
586
+ if (err.code === 'ENOENT') {
587
+ return {
588
+ success: true,
589
+ data: { content: '', frontmatter: {}, raw: '' },
590
+ message: 'Project context document does not exist yet. You can create it with an update operation.'
591
+ };
592
+ }
593
+ throw err;
594
+ }
595
+ }
596
+ case 'append': {
597
+ if (!content) {
598
+ return { success: false, error: 'Content is required for append operation' };
599
+ }
600
+ await fs.mkdir(contextPath, { recursive: true });
601
+ // Read existing content if file exists
602
+ let existingContent = '';
603
+ try {
604
+ existingContent = await fs.readFile(filePath, 'utf-8');
605
+ }
606
+ catch (err) {
607
+ if (err.code !== 'ENOENT')
608
+ throw err;
609
+ }
610
+ // Append new content with a newline separator
611
+ const newContent = existingContent
612
+ ? existingContent.trimEnd() + '\n\n' + content
613
+ : content;
614
+ await fs.writeFile(filePath, newContent, 'utf-8');
615
+ return {
616
+ success: true,
617
+ message: 'Appended content to project context document'
618
+ };
619
+ }
620
+ case 'replace_section': {
621
+ if (!old_text || !new_text) {
622
+ return { success: false, error: 'Both old_text and new_text are required for replace_section operation' };
623
+ }
624
+ // Read existing content
625
+ let existingContent = '';
626
+ try {
627
+ existingContent = await fs.readFile(filePath, 'utf-8');
628
+ }
629
+ catch (err) {
630
+ if (err.code === 'ENOENT') {
631
+ return { success: false, error: 'Project context document does not exist. Use append or update to create it first.' };
632
+ }
633
+ throw err;
634
+ }
635
+ // Check if old_text exists in the document
636
+ if (!existingContent.includes(old_text)) {
637
+ return {
638
+ success: false,
639
+ error: 'Could not find the specified text in the document. The text may have been paraphrased or changed.',
640
+ fallback_markdown: new_text,
641
+ suggestion: 'Here is the replacement text. You can copy it and manually edit the document at /context/project'
642
+ };
643
+ }
644
+ // Replace the text
645
+ const updatedContent = existingContent.replace(old_text, new_text);
646
+ await fs.writeFile(filePath, updatedContent, 'utf-8');
647
+ return {
648
+ success: true,
649
+ message: 'Replaced section in project context document'
650
+ };
651
+ }
652
+ case 'update': {
653
+ if (!content) {
654
+ return { success: false, error: 'Content is required for update operation' };
655
+ }
656
+ await fs.mkdir(contextPath, { recursive: true });
657
+ await fs.writeFile(filePath, content, 'utf-8');
658
+ return {
659
+ success: true,
660
+ message: 'Updated project context document'
661
+ };
662
+ }
663
+ default:
664
+ return { success: false, error: `Unknown operation: ${operation}. Use 'get', 'append', 'replace_section', or 'update'.` };
665
+ }
666
+ }
667
+ catch (error) {
668
+ console.error('Error executing project context tool:', error);
669
+ return { success: false, error: error.message || 'Project context tool execution failed' };
670
+ }
671
+ }
672
+ // Architecture context tool - read/write the architecture document
673
+ async function executeArchitectureContextToolDirect(args) {
674
+ const { operation, content, old_text, new_text } = args;
675
+ try {
676
+ const contextPath = getContextBasePath();
677
+ const filePath = join(contextPath, 'architecture.md');
678
+ switch (operation) {
679
+ case 'get': {
680
+ try {
681
+ const fileContent = await fs.readFile(filePath, 'utf-8');
682
+ const parsed = matter(fileContent);
683
+ return {
684
+ success: true,
685
+ data: {
686
+ content: parsed.content,
687
+ frontmatter: parsed.data,
688
+ raw: fileContent
689
+ },
690
+ message: 'Retrieved architecture context'
691
+ };
692
+ }
693
+ catch (err) {
694
+ if (err.code === 'ENOENT') {
695
+ return {
696
+ success: true,
697
+ data: { content: '', frontmatter: {}, raw: '' },
698
+ message: 'Architecture context document does not exist yet. You can create it with an update operation.'
699
+ };
700
+ }
701
+ throw err;
702
+ }
703
+ }
704
+ case 'append': {
705
+ if (!content) {
706
+ return { success: false, error: 'Content is required for append operation' };
707
+ }
708
+ await fs.mkdir(contextPath, { recursive: true });
709
+ // Read existing content if file exists
710
+ let existingContent = '';
711
+ try {
712
+ existingContent = await fs.readFile(filePath, 'utf-8');
713
+ }
714
+ catch (err) {
715
+ if (err.code !== 'ENOENT')
716
+ throw err;
717
+ }
718
+ // Append new content with a newline separator
719
+ const newContent = existingContent
720
+ ? existingContent.trimEnd() + '\n\n' + content
721
+ : content;
722
+ await fs.writeFile(filePath, newContent, 'utf-8');
723
+ return {
724
+ success: true,
725
+ message: 'Appended content to architecture context document'
726
+ };
727
+ }
728
+ case 'replace_section': {
729
+ if (!old_text || !new_text) {
730
+ return { success: false, error: 'Both old_text and new_text are required for replace_section operation' };
731
+ }
732
+ // Read existing content
733
+ let existingContent = '';
734
+ try {
735
+ existingContent = await fs.readFile(filePath, 'utf-8');
736
+ }
737
+ catch (err) {
738
+ if (err.code === 'ENOENT') {
739
+ return { success: false, error: 'Architecture context document does not exist. Use append or update to create it first.' };
740
+ }
741
+ throw err;
742
+ }
743
+ // Check if old_text exists in the document
744
+ if (!existingContent.includes(old_text)) {
745
+ return {
746
+ success: false,
747
+ error: 'Could not find the specified text in the document. The text may have been paraphrased or changed.',
748
+ fallback_markdown: new_text,
749
+ suggestion: 'Here is the replacement text. You can copy it and manually edit the document at /context/architecture'
750
+ };
751
+ }
752
+ // Replace the text
753
+ const updatedContent = existingContent.replace(old_text, new_text);
754
+ await fs.writeFile(filePath, updatedContent, 'utf-8');
755
+ return {
756
+ success: true,
757
+ message: 'Replaced section in architecture context document'
758
+ };
759
+ }
760
+ case 'update': {
761
+ if (!content) {
762
+ return { success: false, error: 'Content is required for update operation' };
763
+ }
764
+ await fs.mkdir(contextPath, { recursive: true });
765
+ await fs.writeFile(filePath, content, 'utf-8');
766
+ return {
767
+ success: true,
768
+ message: 'Updated architecture context document'
769
+ };
770
+ }
771
+ default:
772
+ return { success: false, error: `Unknown operation: ${operation}. Use 'get', 'append', 'replace_section', or 'update'.` };
773
+ }
774
+ }
775
+ catch (error) {
776
+ console.error('Error executing architecture context tool:', error);
777
+ return { success: false, error: error.message || 'Architecture context tool execution failed' };
778
+ }
779
+ }
780
+ function getContextBasePath() {
781
+ let basePath;
782
+ if (process.env.NODE_ENV === 'development' && process.env.GAIT_DEV_ROOT) {
783
+ basePath = process.env.GAIT_DEV_ROOT;
784
+ }
785
+ else if (process.env.GAIT_DATA_PATH) {
786
+ basePath = pathResolve(process.env.GAIT_DATA_PATH, '.nut');
787
+ }
788
+ else {
789
+ basePath = pathResolve(process.cwd(), '.nut');
790
+ }
791
+ return join(basePath, 'context');
792
+ }
566
793
  function getKnowledgeBasePath() {
567
794
  let basePath;
568
795
  if (process.env.NODE_ENV === 'development' && process.env.GAIT_DEV_ROOT) {
@@ -609,9 +836,14 @@ function extractKnowledgeTitle(content, fallback) {
609
836
  async function readKnowledgeDocument(basePath, filename) {
610
837
  const actualFilename = resolveKnowledgeFilename(filename, filename);
611
838
  const filePath = join(basePath, actualFilename);
612
- const raw = await fs.readFile(filePath, 'utf-8');
839
+ const [raw, stats] = await Promise.all([
840
+ fs.readFile(filePath, 'utf-8'),
841
+ fs.stat(filePath)
842
+ ]);
613
843
  const { data, content } = matter(raw);
614
844
  const metadata = normalizeKnowledgeMetadata(data);
845
+ // Use file mtime for updated timestamp instead of frontmatter
846
+ metadata.updated = stats.mtime.toISOString();
615
847
  const title = extractKnowledgeTitle(content, actualFilename);
616
848
  return {
617
849
  filename: actualFilename,
@@ -620,7 +852,54 @@ async function readKnowledgeDocument(basePath, filename) {
620
852
  content
621
853
  };
622
854
  }
623
- async function listKnowledgeDocuments(basePath) {
855
+ function extractSnippet(content, query, maxLength = 200) {
856
+ const lowerContent = content.toLowerCase();
857
+ const lowerQuery = query.toLowerCase();
858
+ const terms = lowerQuery.split(/\s+/);
859
+ // Find the first occurrence of any search term
860
+ let bestIndex = -1;
861
+ let matchedTerm = '';
862
+ for (const term of terms) {
863
+ const index = lowerContent.indexOf(term);
864
+ if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
865
+ bestIndex = index;
866
+ matchedTerm = term;
867
+ }
868
+ }
869
+ if (bestIndex === -1) {
870
+ // No match found, return beginning of content
871
+ return content.slice(0, maxLength) + (content.length > maxLength ? '...' : '');
872
+ }
873
+ // Extract snippet around the match
874
+ const start = Math.max(0, bestIndex - 60);
875
+ const end = Math.min(content.length, bestIndex + matchedTerm.length + 140);
876
+ let snippet = content.slice(start, end);
877
+ // Add ellipsis if needed
878
+ if (start > 0)
879
+ snippet = '...' + snippet;
880
+ if (end < content.length)
881
+ snippet = snippet + '...';
882
+ return snippet;
883
+ }
884
+ function findMatchedFields(doc, query) {
885
+ const lowerQuery = query.toLowerCase();
886
+ const terms = lowerQuery.split(/\s+/);
887
+ const matched = [];
888
+ const checkField = (value, fieldName) => {
889
+ if (value && terms.some(term => value.toLowerCase().includes(term))) {
890
+ matched.push(fieldName);
891
+ }
892
+ };
893
+ checkField(doc.title, 'title');
894
+ checkField(doc.content, 'content');
895
+ checkField(doc.metadata?.category, 'category');
896
+ checkField(doc.metadata?.summary, 'summary');
897
+ if (doc.metadata?.tags?.some(tag => terms.some(term => tag.toLowerCase().includes(term)))) {
898
+ matched.push('tags');
899
+ }
900
+ return matched;
901
+ }
902
+ async function listKnowledgeDocuments(basePath, query) {
624
903
  const files = await fs.readdir(basePath);
625
904
  const markdownFiles = files.filter(file => file.endsWith('.md'));
626
905
  const documents = await Promise.all(markdownFiles.map(async (file) => {
@@ -632,9 +911,33 @@ async function listKnowledgeDocuments(basePath) {
632
911
  return null;
633
912
  }
634
913
  }));
635
- return documents
636
- .filter((doc) => doc !== null)
637
- .sort((a, b) => a.filename.localeCompare(b.filename));
914
+ const validDocs = documents.filter((doc) => doc !== null);
915
+ // If no query, return all documents sorted by filename
916
+ if (!query?.trim()) {
917
+ return validDocs.sort((a, b) => a.filename.localeCompare(b.filename));
918
+ }
919
+ // Use Fuse.js for fuzzy search
920
+ const fuse = new Fuse(validDocs, {
921
+ keys: [
922
+ { name: 'title', weight: 2 },
923
+ { name: 'content', weight: 1 },
924
+ { name: 'metadata.summary', weight: 1.5 },
925
+ { name: 'metadata.category', weight: 1 },
926
+ { name: 'metadata.tags', weight: 1.5 }
927
+ ],
928
+ threshold: 0.4,
929
+ includeScore: true,
930
+ ignoreLocation: true,
931
+ minMatchCharLength: 2
932
+ });
933
+ const results = fuse.search(query);
934
+ // Enhance results with snippets and matched fields
935
+ return results.map(result => ({
936
+ ...result.item,
937
+ snippet: extractSnippet(result.item.content, query),
938
+ matchedIn: findMatchedFields(result.item, query),
939
+ score: result.score
940
+ }));
638
941
  }
639
942
  function readSharedPrompt(name) {
640
943
  // Try multiple candidate paths depending on cwd when running via Turbo