@tenex-chat/backend 0.9.5 → 0.9.6

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.
Files changed (148) hide show
  1. package/README.md +5 -1
  2. package/dist/daemon-wrapper.cjs +47 -0
  3. package/dist/index.js +59268 -0
  4. package/dist/wrapper.js +171 -0
  5. package/package.json +19 -27
  6. package/src/agents/AgentRegistry.ts +9 -7
  7. package/src/agents/AgentStorage.ts +24 -1
  8. package/src/agents/agent-installer.ts +6 -0
  9. package/src/agents/agent-loader.ts +7 -2
  10. package/src/agents/constants.ts +10 -2
  11. package/src/agents/execution/AgentExecutor.ts +35 -6
  12. package/src/agents/execution/StreamCallbacks.ts +53 -13
  13. package/src/agents/execution/StreamExecutionHandler.ts +110 -16
  14. package/src/agents/execution/StreamSetup.ts +19 -9
  15. package/src/agents/execution/ToolEventHandlers.ts +112 -0
  16. package/src/agents/role-categories.ts +53 -0
  17. package/src/agents/types/runtime.ts +7 -0
  18. package/src/agents/types/storage.ts +7 -0
  19. package/src/commands/agent/import/openclaw-distiller.ts +63 -7
  20. package/src/commands/agent/import/openclaw-reader.ts +54 -0
  21. package/src/commands/agent/import/openclaw.ts +120 -29
  22. package/src/commands/agent/index.ts +83 -2
  23. package/src/commands/setup/display.ts +123 -0
  24. package/src/commands/setup/embed.ts +13 -13
  25. package/src/commands/setup/global-system-prompt.ts +15 -17
  26. package/src/commands/setup/image.ts +17 -20
  27. package/src/commands/setup/interactive.ts +37 -20
  28. package/src/commands/setup/llm.ts +12 -7
  29. package/src/commands/setup/onboarding.ts +1580 -248
  30. package/src/commands/setup/providers.ts +3 -3
  31. package/src/conversations/ConversationStore.ts +23 -2
  32. package/src/conversations/MessageBuilder.ts +51 -73
  33. package/src/conversations/formatters/utils/conversation-transcript-formatter.ts +425 -0
  34. package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +40 -98
  35. package/src/conversations/search/embeddings/ConversationIndexingJob.ts +40 -52
  36. package/src/conversations/services/ConversationSummarizer.ts +1 -2
  37. package/src/conversations/types.ts +11 -0
  38. package/src/daemon/Daemon.ts +78 -57
  39. package/src/daemon/ProjectRuntime.ts +6 -12
  40. package/src/daemon/SubscriptionManager.ts +13 -0
  41. package/src/daemon/index.ts +0 -1
  42. package/src/event-handler/index.ts +1 -0
  43. package/src/index.ts +20 -1
  44. package/src/llm/ChunkHandler.ts +1 -1
  45. package/src/llm/FinishHandler.ts +28 -4
  46. package/src/llm/LLMConfigEditor.ts +218 -106
  47. package/src/llm/index.ts +0 -4
  48. package/src/llm/meta/MetaModelResolver.ts +3 -18
  49. package/src/llm/middleware/message-sanitizer.ts +153 -0
  50. package/src/llm/providers/ollama-models.ts +0 -38
  51. package/src/llm/service.ts +50 -15
  52. package/src/llm/types.ts +0 -12
  53. package/src/llm/utils/ConfigurationManager.ts +88 -465
  54. package/src/llm/utils/ConfigurationTester.ts +42 -185
  55. package/src/llm/utils/ModelSelector.ts +156 -92
  56. package/src/llm/utils/ProviderConfigUI.ts +10 -141
  57. package/src/llm/utils/models-dev-cache.ts +102 -23
  58. package/src/llm/utils/provider-select-prompt.ts +284 -0
  59. package/src/llm/utils/provider-setup.ts +81 -34
  60. package/src/llm/utils/variant-list-prompt.ts +361 -0
  61. package/src/nostr/AgentEventDecoder.ts +1 -0
  62. package/src/nostr/AgentEventEncoder.ts +37 -0
  63. package/src/nostr/AgentProfilePublisher.ts +13 -0
  64. package/src/nostr/AgentPublisher.ts +26 -0
  65. package/src/nostr/kinds.ts +1 -0
  66. package/src/nostr/ndkClient.ts +4 -1
  67. package/src/nostr/types.ts +12 -0
  68. package/src/prompts/fragments/25-rag-instructions.ts +22 -21
  69. package/src/prompts/fragments/31-agents-md-guidance.ts +7 -21
  70. package/src/prompts/fragments/index.ts +2 -0
  71. package/src/prompts/utils/systemPromptBuilder.ts +18 -28
  72. package/src/services/AgentDefinitionMonitor.ts +8 -0
  73. package/src/services/ConfigService.ts +34 -0
  74. package/src/services/PubkeyService.ts +7 -1
  75. package/src/services/compression/CompressionService.ts +133 -74
  76. package/src/services/compression/compression-utils.ts +110 -19
  77. package/src/services/config/types.ts +0 -6
  78. package/src/services/dispatch/AgentDispatchService.ts +79 -0
  79. package/src/services/intervention/InterventionService.ts +78 -5
  80. package/src/services/nip46/Nip46SigningService.ts +30 -1
  81. package/src/services/projects/ProjectContext.ts +8 -6
  82. package/src/services/rag/RAGCollectionRegistry.ts +199 -0
  83. package/src/services/rag/RAGDatabaseService.ts +2 -7
  84. package/src/services/rag/RAGOperations.ts +25 -45
  85. package/src/services/rag/RAGService.ts +0 -31
  86. package/src/services/rag/RagSubscriptionService.ts +71 -122
  87. package/src/services/rag/rag-utils.ts +13 -0
  88. package/src/services/ral/RALRegistry.ts +25 -184
  89. package/src/services/reports/ReportEmbeddingService.ts +63 -113
  90. package/src/services/search/UnifiedSearchService.ts +115 -4
  91. package/src/services/search/index.ts +1 -0
  92. package/src/services/search/projectFilter.ts +20 -4
  93. package/src/services/search/providers/ConversationSearchProvider.ts +1 -0
  94. package/src/services/search/providers/GenericCollectionSearchProvider.ts +81 -0
  95. package/src/services/search/providers/LessonSearchProvider.ts +1 -8
  96. package/src/services/search/providers/ReportSearchProvider.ts +1 -0
  97. package/src/services/search/types.ts +24 -3
  98. package/src/services/trust-pubkeys/SystemPubkeyListService.ts +148 -0
  99. package/src/services/trust-pubkeys/TrustPubkeyService.ts +70 -9
  100. package/src/telemetry/setup.ts +2 -13
  101. package/src/tools/implementations/ask.ts +3 -3
  102. package/src/tools/implementations/conversation_get.ts +28 -268
  103. package/src/tools/implementations/fs_grep.ts +6 -6
  104. package/src/tools/implementations/fs_read.ts +2 -0
  105. package/src/tools/implementations/fs_write.ts +2 -0
  106. package/src/tools/implementations/learn.ts +38 -50
  107. package/src/tools/implementations/rag_add_documents.ts +6 -4
  108. package/src/tools/implementations/rag_create_collection.ts +37 -4
  109. package/src/tools/implementations/rag_delete_collection.ts +9 -0
  110. package/src/tools/implementations/{search.ts → rag_search.ts} +31 -25
  111. package/src/tools/registry.ts +7 -8
  112. package/src/tools/types.ts +11 -2
  113. package/src/tools/utils/transcript-args.ts +13 -0
  114. package/src/utils/cli-theme.ts +13 -0
  115. package/src/utils/logger.ts +55 -0
  116. package/src/utils/metadataKeys.ts +17 -0
  117. package/src/utils/sqlEscaping.ts +39 -0
  118. package/src/wrapper.ts +7 -3
  119. package/dist/src/index.js +0 -46790
  120. package/dist/tenex-backend-wrapper.cjs +0 -3
  121. package/src/agents/execution/constants.ts +0 -16
  122. package/src/agents/execution/index.ts +0 -3
  123. package/src/agents/index.ts +0 -4
  124. package/src/commands/agent.ts +0 -235
  125. package/src/conversations/formatters/DelegationXmlFormatter.ts +0 -64
  126. package/src/conversations/formatters/index.ts +0 -9
  127. package/src/conversations/index.ts +0 -2
  128. package/src/conversations/utils/content-utils.ts +0 -69
  129. package/src/daemon/UnixSocketTransport.ts +0 -318
  130. package/src/event-handler/newConversation.ts +0 -165
  131. package/src/events/NDKProjectStatus.ts +0 -384
  132. package/src/events/index.ts +0 -4
  133. package/src/lib/json-parser.ts +0 -30
  134. package/src/llm/RecordingState.ts +0 -37
  135. package/src/llm/StreamPublisher.ts +0 -40
  136. package/src/llm/middleware/flight-recorder.ts +0 -188
  137. package/src/llm/utils/claudeCodePromptCompiler.ts +0 -141
  138. package/src/nostr/constants.ts +0 -38
  139. package/src/prompts/core/index.ts +0 -3
  140. package/src/prompts/index.ts +0 -21
  141. package/src/services/image/index.ts +0 -12
  142. package/src/services/status/index.ts +0 -11
  143. package/src/telemetry/diagnostics.ts +0 -27
  144. package/src/tools/implementations/rag_query.ts +0 -107
  145. package/src/types/index.ts +0 -46
  146. package/src/utils/agentFetcher.ts +0 -107
  147. package/src/utils/conversation-utils.ts +0 -1
  148. package/src/utils/process.ts +0 -49
@@ -1,10 +1,8 @@
1
1
  import { trace } from "@opentelemetry/api";
2
2
  import { EventEmitter, type DefaultEventMap } from "tseep";
3
- import { getPubkeyService } from "@/services/PubkeyService";
4
3
  import { INJECTION_ABORT_REASON, llmOpsRegistry } from "@/services/LLMOperationsRegistry";
5
4
  import { shortenConversationId } from "@/utils/conversation-id";
6
5
  import { logger } from "@/utils/logger";
7
- import { PREFIX_LENGTH } from "@/utils/nostr-entity-parser";
8
6
  import { ConversationStore } from "@/conversations/ConversationStore";
9
7
  // Note: FullEventId type is available via @/types/event-ids for future typed method signatures
10
8
  import type {
@@ -1499,181 +1497,6 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
1499
1497
  return rals.find(ral => ral.queuedInjections.length > 0);
1500
1498
  }
1501
1499
 
1502
- /**
1503
- * Build a message containing delegation results for injection into the RAL.
1504
- * Shows complete conversation transcript for each delegation.
1505
- * Uses shortened delegation IDs (PREFIX_LENGTH chars) for display; agents can use
1506
- * these prefixes directly with delegate_followup which will resolve them.
1507
- * Format: [@sender -> @recipient]: message content
1508
- */
1509
- /**
1510
- * Helper to format transcript with error handling for name resolution.
1511
- */
1512
- private async formatTranscript(transcript: DelegationMessage[]): Promise<string[]> {
1513
- const pubkeyService = getPubkeyService();
1514
- const lines: string[] = [];
1515
-
1516
- for (const msg of transcript) {
1517
- try {
1518
- const senderName = await pubkeyService.getName(msg.senderPubkey);
1519
- const recipientName = await pubkeyService.getName(msg.recipientPubkey);
1520
- lines.push(`[@${senderName} -> @${recipientName}]: ${msg.content}`);
1521
- } catch (error) {
1522
- // Fallback to shortened pubkeys on error
1523
- const senderFallback = msg.senderPubkey.substring(0, 12);
1524
- const recipientFallback = msg.recipientPubkey.substring(0, 12);
1525
- lines.push(`[@${senderFallback} -> @${recipientFallback}]: ${msg.content}`);
1526
- }
1527
- }
1528
-
1529
- return lines;
1530
- }
1531
-
1532
- /**
1533
- * Helper to render a list of pending delegations with error handling.
1534
- */
1535
- private async renderPendingList(pending: PendingDelegation[]): Promise<string[]> {
1536
- if (pending.length === 0) {
1537
- return [];
1538
- }
1539
-
1540
- const pubkeyService = getPubkeyService();
1541
- const lines: string[] = [];
1542
-
1543
- lines.push("## Still Pending");
1544
- for (const p of pending) {
1545
- try {
1546
- const recipientName = await pubkeyService.getName(p.recipientPubkey);
1547
- lines.push(`- @${recipientName} (${p.delegationConversationId.substring(0, PREFIX_LENGTH)})`);
1548
- } catch (error) {
1549
- const fallbackName = p.recipientPubkey.substring(0, 12);
1550
- lines.push(`- @${fallbackName} (${p.delegationConversationId.substring(0, PREFIX_LENGTH)})`);
1551
- }
1552
- }
1553
- lines.push("");
1554
-
1555
- return lines;
1556
- }
1557
-
1558
- /**
1559
- * Helper to render delegation header (agent name + ID) with error handling.
1560
- */
1561
- private async renderDelegationHeader(
1562
- recipientPubkey: string,
1563
- conversationId: string,
1564
- statusText: string
1565
- ): Promise<string[]> {
1566
- const pubkeyService = getPubkeyService();
1567
- const lines: string[] = [];
1568
-
1569
- try {
1570
- const recipientName = await pubkeyService.getName(recipientPubkey);
1571
- lines.push(`**@${recipientName} ${statusText}**`);
1572
- } catch (error) {
1573
- const fallbackName = recipientPubkey.substring(0, 12);
1574
- lines.push(`**@${fallbackName} ${statusText}**`);
1575
- }
1576
-
1577
- lines.push("");
1578
- lines.push(`## Delegation ID: ${conversationId.substring(0, PREFIX_LENGTH)}`);
1579
-
1580
- return lines;
1581
- }
1582
-
1583
- /**
1584
- * Build a message for completed delegations.
1585
- * Shows the agent name, conversation ID, and full transcript.
1586
- */
1587
- async buildDelegationResultsMessage(
1588
- completions: CompletedDelegation[],
1589
- pending: PendingDelegation[] = []
1590
- ): Promise<string> {
1591
- if (completions.length === 0) {
1592
- return "";
1593
- }
1594
-
1595
- const lines: string[] = [];
1596
-
1597
- lines.push("# DELEGATION COMPLETED");
1598
- lines.push("");
1599
-
1600
- for (const c of completions) {
1601
- lines.push(...await this.renderDelegationHeader(
1602
- c.recipientPubkey,
1603
- c.delegationConversationId,
1604
- "has finished and returned their final response."
1605
- ));
1606
- lines.push("");
1607
- lines.push("### Transcript:");
1608
- lines.push(...await this.formatTranscript(c.transcript));
1609
- lines.push("");
1610
- }
1611
-
1612
- // Show pending delegations if any remain
1613
- if (pending.length > 0) {
1614
- lines.push(...await this.renderPendingList(pending));
1615
- }
1616
-
1617
- return lines.join("\n");
1618
- }
1619
-
1620
- /**
1621
- * Build a message for aborted delegations.
1622
- * Shows the agent name, conversation ID, abort details, and partial transcript.
1623
- * Format matches buildDelegationResultsMessage for consistency.
1624
- */
1625
- async buildDelegationAbortMessage(
1626
- abortedDelegations: CompletedDelegation[],
1627
- pending: PendingDelegation[] = []
1628
- ): Promise<string> {
1629
- if (abortedDelegations.length === 0) {
1630
- return "";
1631
- }
1632
-
1633
- const lines: string[] = [];
1634
-
1635
- // Header indicating abort event
1636
- lines.push("# DELEGATION ABORTED");
1637
- lines.push("");
1638
-
1639
- // Format each aborted delegation
1640
- for (const c of abortedDelegations) {
1641
- if (c.status !== "aborted") continue; // Type guard for discriminated union
1642
-
1643
- lines.push(...await this.renderDelegationHeader(
1644
- c.recipientPubkey,
1645
- c.delegationConversationId,
1646
- "was aborted and did not complete their task."
1647
- ));
1648
-
1649
- // Add abort-specific metadata with error handling for timestamp
1650
- try {
1651
- lines.push(`**Aborted at:** ${new Date(c.completedAt).toISOString()}`);
1652
- } catch {
1653
- lines.push(`**Aborted at:** (invalid timestamp)`);
1654
- }
1655
- lines.push(`**Reason:** ${c.abortReason}`);
1656
- lines.push("");
1657
-
1658
- // Show partial transcript if available
1659
- if (c.transcript && c.transcript.length > 0) {
1660
- lines.push("### Partial Progress:");
1661
- lines.push(...await this.formatTranscript(c.transcript));
1662
- } else {
1663
- lines.push("### Partial Progress:");
1664
- lines.push("(No messages exchanged before abort)");
1665
- }
1666
- lines.push("");
1667
- }
1668
-
1669
- // Show remaining pending delegations if any
1670
- if (pending.length > 0) {
1671
- lines.push(...await this.renderPendingList(pending));
1672
- }
1673
-
1674
- return lines.join("\n");
1675
- }
1676
-
1677
1500
  /**
1678
1501
  * Determine if a new message should wake up an execution.
1679
1502
  *
@@ -2635,13 +2458,17 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2635
2458
  /**
2636
2459
  * Check if there's any outstanding work for a conversation that would prevent finalization.
2637
2460
  *
2638
- * This method consolidates checking for both:
2461
+ * This method consolidates checking for:
2639
2462
  * 1. Queued injections (messages waiting to be processed in the next LLM step)
2640
2463
  * 2. Pending delegations (delegations that haven't completed yet)
2464
+ * 3. Completed delegations (delegations that completed but whose results haven't
2465
+ * been incorporated into the agent's messages via resolveRAL yet)
2641
2466
  *
2642
- * This is the key guard against the race condition where delegation results arrive
2643
- * (via debounce) after the last prepareStep but before the executor finalizes.
2644
- * By checking this before publishing status:completed, we ensure no work is orphaned.
2467
+ * Checking completed delegations is critical for fast-completing delegations:
2468
+ * recordCompletion() moves delegations from pending→completed immediately (no debounce),
2469
+ * but the executor only processes them via resolveRAL() after the debounce fires.
2470
+ * Without this check, the executor sees pendingDelegations=0 and finalizes prematurely,
2471
+ * clearing the RAL before the completed delegation can be processed.
2645
2472
  *
2646
2473
  * @param agentPubkey - The agent's pubkey
2647
2474
  * @param conversationId - The conversation ID
@@ -2657,6 +2484,7 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2657
2484
  details: {
2658
2485
  queuedInjections: number;
2659
2486
  pendingDelegations: number;
2487
+ completedDelegations: number;
2660
2488
  };
2661
2489
  } {
2662
2490
  const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
@@ -2669,13 +2497,23 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2669
2497
  ralNumber
2670
2498
  ).length;
2671
2499
 
2672
- // If RAL doesn't exist, we can't have queued injections but may still have pending delegations
2500
+ // Count completed delegations that haven't been consumed by resolveRAL yet.
2501
+ // These are delegations where recordCompletion() has run but the executor hasn't
2502
+ // processed them into conversation markers yet.
2503
+ const completedDelegations = this.getConversationCompletedDelegations(
2504
+ agentPubkey,
2505
+ conversationId,
2506
+ ralNumber
2507
+ ).length;
2508
+
2509
+ // If RAL doesn't exist, we can't have queued injections but may still have delegations
2673
2510
  if (!ral) {
2674
- const hasWork = pendingDelegations > 0;
2511
+ const hasWork = pendingDelegations > 0 || completedDelegations > 0;
2675
2512
  if (hasWork) {
2676
2513
  trace.getActiveSpan()?.addEvent("ral.outstanding_work_no_ral", {
2677
2514
  "ral.number": ralNumber,
2678
2515
  "outstanding.pending_delegations": pendingDelegations,
2516
+ "outstanding.completed_delegations": completedDelegations,
2679
2517
  "agent.pubkey": agentPubkey.substring(0, 12),
2680
2518
  "conversation.id": shortenConversationId(conversationId),
2681
2519
  });
@@ -2685,6 +2523,7 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2685
2523
  details: {
2686
2524
  queuedInjections: 0,
2687
2525
  pendingDelegations,
2526
+ completedDelegations,
2688
2527
  },
2689
2528
  };
2690
2529
  }
@@ -2692,7 +2531,7 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2692
2531
  // Count queued injections from the RAL entry
2693
2532
  const queuedInjections = ral.queuedInjections.length;
2694
2533
 
2695
- const hasWork = queuedInjections > 0 || pendingDelegations > 0;
2534
+ const hasWork = queuedInjections > 0 || pendingDelegations > 0 || completedDelegations > 0;
2696
2535
 
2697
2536
  // Add telemetry for debugging race conditions
2698
2537
  if (hasWork) {
@@ -2700,6 +2539,7 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2700
2539
  "ral.number": ralNumber,
2701
2540
  "outstanding.queued_injections": queuedInjections,
2702
2541
  "outstanding.pending_delegations": pendingDelegations,
2542
+ "outstanding.completed_delegations": completedDelegations,
2703
2543
  "agent.pubkey": agentPubkey.substring(0, 12),
2704
2544
  "conversation.id": shortenConversationId(conversationId),
2705
2545
  });
@@ -2710,6 +2550,7 @@ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
2710
2550
  details: {
2711
2551
  queuedInjections,
2712
2552
  pendingDelegations,
2553
+ completedDelegations,
2713
2554
  },
2714
2555
  };
2715
2556
  }
@@ -7,13 +7,13 @@
7
7
  * Key features:
8
8
  * - Project-scoped: reports are tagged with projectId for isolation
9
9
  * - Index on write: called from report_write tool after successful publish
10
- * - Upsert semantics: re-indexing updates existing documents (by slug + projectId)
11
- * - Graceful degradation: RAG failures don't break report writes
10
+ * - Upsert semantics: re-indexing updates existing documents via bulkUpsert
12
11
  * - Nostr remains authoritative source; RAG is just a search layer
13
12
  */
14
13
 
15
14
  import { logger } from "@/utils/logger";
16
15
  import { RAGService, type RAGDocument, type RAGQueryResult } from "@/services/rag/RAGService";
16
+ import { buildProjectFilter } from "@/services/search/projectFilter";
17
17
  import type { ReportInfo } from "./ReportService";
18
18
 
19
19
  /** Collection name for report embeddings */
@@ -187,59 +187,43 @@ export class ReportEmbeddingService {
187
187
  ): Promise<boolean> {
188
188
  await this.ensureInitialized();
189
189
 
190
- try {
191
- const documentId = this.buildDocumentId(projectId, report.slug);
192
- const embeddingContent = this.buildEmbeddingContent(report);
193
-
194
- if (!embeddingContent.trim()) {
195
- logger.debug("No content to embed for report", { slug: report.slug });
196
- return false;
197
- }
198
-
199
- // Delete existing document before inserting (upsert semantics)
200
- try {
201
- await this.ragService.deleteDocumentById(REPORT_COLLECTION, documentId);
202
- } catch {
203
- // Document might not exist - that's fine
204
- }
205
-
206
- const document: RAGDocument = {
207
- id: documentId,
208
- content: embeddingContent,
209
- metadata: {
210
- slug: report.slug,
211
- projectId,
212
- title: report.title || "",
213
- summary: report.summary || "",
214
- hashtags: report.hashtags,
215
- agentPubkey,
216
- agentName: agentName || "",
217
- type: "report",
218
- publishedAt: report.publishedAt ?? Math.floor(Date.now() / 1000),
219
- },
220
- timestamp: Date.now(),
221
- source: "report",
222
- };
190
+ const documentId = this.buildDocumentId(projectId, report.slug);
191
+ const embeddingContent = this.buildEmbeddingContent(report);
223
192
 
224
- await this.ragService.addDocuments(REPORT_COLLECTION, [document]);
193
+ if (!embeddingContent.trim()) {
194
+ logger.debug("No content to embed for report", { slug: report.slug });
195
+ return false;
196
+ }
225
197
 
226
- logger.info("📝 Report indexed in RAG", {
198
+ const document: RAGDocument = {
199
+ id: documentId,
200
+ content: embeddingContent,
201
+ metadata: {
227
202
  slug: report.slug,
228
203
  projectId,
229
- documentId,
230
- agentName,
231
- });
204
+ title: report.title || "",
205
+ summary: report.summary || "",
206
+ hashtags: report.hashtags,
207
+ agentPubkey,
208
+ agentName: agentName || "",
209
+ type: "report",
210
+ publishedAt: report.publishedAt ?? Math.floor(Date.now() / 1000),
211
+ },
212
+ timestamp: Date.now(),
213
+ source: "report",
214
+ };
232
215
 
233
- return true;
234
- } catch (error) {
235
- const message = error instanceof Error ? error.message : String(error);
236
- logger.warn("Failed to index report in RAG", {
237
- slug: report.slug,
238
- projectId,
239
- error: message,
240
- });
241
- return false;
242
- }
216
+ // Atomic upsert via mergeInsert — one LanceDB version per chunk
217
+ await this.ragService.bulkUpsert(REPORT_COLLECTION, [document]);
218
+
219
+ logger.info("📝 Report indexed in RAG", {
220
+ slug: report.slug,
221
+ projectId,
222
+ documentId,
223
+ agentName,
224
+ });
225
+
226
+ return true;
243
227
  }
244
228
 
245
229
  /**
@@ -247,40 +231,16 @@ export class ReportEmbeddingService {
247
231
  * Called when a report is deleted.
248
232
  */
249
233
  public async removeReport(slug: string, projectId: string): Promise<void> {
250
- try {
251
- // Check if the collection exists before attempting deletion.
252
- // Avoids ensureInitialized() which would create the collection as a side-effect.
253
- const collections = await this.ragService.listCollections();
254
- if (!collections.includes(REPORT_COLLECTION)) {
255
- logger.debug("Report collection does not exist, nothing to remove", {
256
- slug,
257
- projectId,
258
- });
259
- return;
260
- }
261
-
262
- const documentId = this.buildDocumentId(projectId, slug);
263
- await this.ragService.deleteDocumentById(REPORT_COLLECTION, documentId);
264
- logger.info("🗑️ Report removed from RAG", { slug, projectId, documentId });
265
- } catch (error) {
266
- logger.debug("Could not remove report from RAG (may not exist)", {
267
- slug,
268
- projectId,
269
- error,
270
- });
234
+ // Check if the collection exists before attempting deletion.
235
+ // Avoids ensureInitialized() which would create the collection as a side-effect.
236
+ const collections = await this.ragService.listCollections();
237
+ if (!collections.includes(REPORT_COLLECTION)) {
238
+ return;
271
239
  }
272
- }
273
240
 
274
- /**
275
- * Build SQL prefilter for project isolation.
276
- * Applied DURING vector search, not after, to ensure proper project boundaries.
277
- */
278
- private buildProjectFilter(projectId?: string): string | undefined {
279
- if (!projectId || projectId.toLowerCase() === "all") {
280
- return undefined;
281
- }
282
- const escapedProjectId = projectId.replace(/'/g, "''");
283
- return `metadata LIKE '%"projectId":"${escapedProjectId}"%'`;
241
+ const documentId = this.buildDocumentId(projectId, slug);
242
+ await this.ragService.deleteDocumentById(REPORT_COLLECTION, documentId);
243
+ logger.info("🗑️ Report removed from RAG", { slug, projectId, documentId });
284
244
  }
285
245
 
286
246
  /**
@@ -298,36 +258,30 @@ export class ReportEmbeddingService {
298
258
 
299
259
  const { limit = 10, minScore = 0.3, projectId } = options;
300
260
 
301
- try {
302
- logger.info("🔍 Report semantic search", { query, limit, minScore, projectId });
261
+ logger.info("🔍 Report semantic search", { query, limit, minScore, projectId });
303
262
 
304
- const filter = this.buildProjectFilter(projectId);
263
+ const filter = buildProjectFilter(projectId);
305
264
 
306
- const results = await this.ragService.queryWithFilter(
307
- REPORT_COLLECTION,
308
- query,
309
- limit * 2, // Request more to account for minScore filtering
310
- filter
311
- );
265
+ const results = await this.ragService.queryWithFilter(
266
+ REPORT_COLLECTION,
267
+ query,
268
+ limit * 2,
269
+ filter
270
+ );
312
271
 
313
- const searchResults: ReportSearchResult[] = results
314
- .filter((result: RAGQueryResult) => result.score >= minScore)
315
- .slice(0, limit)
316
- .map((result: RAGQueryResult) => this.transformResult(result));
272
+ const searchResults: ReportSearchResult[] = results
273
+ .filter((result: RAGQueryResult) => result.score >= minScore)
274
+ .slice(0, limit)
275
+ .map((result: RAGQueryResult) => this.transformResult(result));
317
276
 
318
- logger.info("✅ Report semantic search complete", {
319
- query,
320
- found: searchResults.length,
321
- limit,
322
- projectFilter: filter || "none",
323
- });
277
+ logger.info("✅ Report semantic search complete", {
278
+ query,
279
+ found: searchResults.length,
280
+ limit,
281
+ projectFilter: filter || "none",
282
+ });
324
283
 
325
- return searchResults;
326
- } catch (error) {
327
- const message = error instanceof Error ? error.message : String(error);
328
- logger.error("Report semantic search failed", { query, error: message });
329
- return [];
330
- }
284
+ return searchResults;
331
285
  }
332
286
 
333
287
  /**
@@ -403,12 +357,8 @@ export class ReportEmbeddingService {
403
357
  * Clear all report embeddings
404
358
  */
405
359
  public async clearIndex(): Promise<void> {
406
- try {
407
- await this.ragService.deleteCollection(REPORT_COLLECTION);
408
- logger.info("Cleared report embeddings index");
409
- } catch (error) {
410
- logger.debug("No report index to clear or error clearing", { error });
411
- }
360
+ await this.ragService.deleteCollection(REPORT_COLLECTION);
361
+ logger.info("Cleared report embeddings index");
412
362
 
413
363
  this.initialized = false;
414
364
  this.initializationPromise = null;
@@ -9,13 +9,17 @@
9
9
  * - Parallel queries across all providers
10
10
  * - Graceful degradation: one collection failure doesn't block others
11
11
  * - Project-scoped isolation via projectId
12
+ * - Scope-aware collection filtering (global/project/personal)
12
13
  * - Optional LLM extraction with configurable prompt
13
14
  */
14
15
 
15
16
  import { logger } from "@/utils/logger";
16
17
  import { config as configService } from "@/services/ConfigService";
18
+ import { RAGService } from "@/services/rag/RAGService";
19
+ import { RAGCollectionRegistry } from "@/services/rag/RAGCollectionRegistry";
17
20
  import { SearchProviderRegistry } from "./SearchProviderRegistry";
18
- import type { SearchOptions, SearchResult, UnifiedSearchOutput } from "./types";
21
+ import { GenericCollectionSearchProvider } from "./providers/GenericCollectionSearchProvider";
22
+ import type { SearchOptions, SearchProvider, SearchResult, UnifiedSearchOutput } from "./types";
19
23
 
20
24
  /** Default search parameters */
21
25
  const DEFAULT_LIMIT = 10;
@@ -47,6 +51,7 @@ export class UnifiedSearchService {
47
51
  minScore = DEFAULT_MIN_SCORE,
48
52
  prompt,
49
53
  collections,
54
+ agentPubkey,
50
55
  } = options;
51
56
 
52
57
  logger.info("🔍 [UnifiedSearch] Starting search", {
@@ -55,11 +60,12 @@ export class UnifiedSearchService {
55
60
  limit,
56
61
  minScore,
57
62
  prompt: prompt ? `${prompt.substring(0, 50)}...` : undefined,
58
- collections: collections || "all",
63
+ collections: collections || "all (scope-aware)",
64
+ agentPubkey: agentPubkey ? `${agentPubkey.substring(0, 12)}...` : undefined,
59
65
  });
60
66
 
61
- // Get providers to search
62
- const providers = this.registry.getByNames(collections);
67
+ // Resolve providers: static (specialized) + dynamic (generic for discovered RAG collections)
68
+ const providers = await this.resolveProviders(collections, projectId, agentPubkey);
63
69
  if (providers.length === 0) {
64
70
  logger.warn("[UnifiedSearch] No search providers available");
65
71
  return {
@@ -136,6 +142,111 @@ export class UnifiedSearchService {
136
142
  };
137
143
  }
138
144
 
145
+ /**
146
+ * Resolve all providers to query: specialized (from registry) + generic
147
+ * (dynamically created for any RAG collections not covered by a specialized provider).
148
+ *
149
+ * When `requestedCollections` is provided, only those are returned (explicit choice,
150
+ * no scope filtering). When absent, scope-aware filtering is applied.
151
+ *
152
+ * @param requestedCollections - Optional explicit collection filter (bypasses scoping)
153
+ * @param projectId - Current project ID for scope filtering
154
+ * @param agentPubkey - Current agent pubkey for personal scope filtering
155
+ */
156
+ private async resolveProviders(
157
+ requestedCollections: string[] | undefined,
158
+ projectId: string,
159
+ agentPubkey?: string
160
+ ): Promise<SearchProvider[]> {
161
+ const specializedProviders = this.registry.getAll();
162
+
163
+ // Derive covered RAG collections from the providers themselves.
164
+ // A collection is "covered" if a specialized provider declares it via
165
+ // `collectionName` or if its `name` matches a RAG collection name.
166
+ const coveredCollections = new Set<string>();
167
+ for (const provider of specializedProviders) {
168
+ if (provider.collectionName) {
169
+ coveredCollections.add(provider.collectionName);
170
+ }
171
+ coveredCollections.add(provider.name);
172
+ }
173
+
174
+ // Discover all RAG collections
175
+ let allCollections: string[];
176
+ try {
177
+ const ragService = RAGService.getInstance();
178
+ allCollections = await ragService.listCollections();
179
+ } catch (error) {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ logger.warn("[UnifiedSearch] Failed to discover RAG collections", { error: message });
182
+ allCollections = [];
183
+ }
184
+
185
+ // Apply scope filtering to discovered collections (only when no explicit filter)
186
+ let scopedCollections = allCollections;
187
+ if (!requestedCollections) {
188
+ try {
189
+ const collectionRegistry = RAGCollectionRegistry.getInstance();
190
+ scopedCollections = collectionRegistry.getMatchingCollections(
191
+ allCollections,
192
+ projectId,
193
+ agentPubkey
194
+ );
195
+
196
+ if (scopedCollections.length < allCollections.length) {
197
+ logger.debug("[UnifiedSearch] Scope filtering applied", {
198
+ total: allCollections.length,
199
+ matched: scopedCollections.length,
200
+ excluded: allCollections.length - scopedCollections.length,
201
+ });
202
+ }
203
+ } catch (error) {
204
+ // Registry not available — fall through with all collections
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ logger.debug("[UnifiedSearch] Collection registry unavailable, using all collections", {
207
+ error: message,
208
+ });
209
+ }
210
+ }
211
+
212
+ // Create generic providers for collections not covered by specialized providers
213
+ const genericProviders = scopedCollections
214
+ .filter((name) => !coveredCollections.has(name))
215
+ .map((name) => new GenericCollectionSearchProvider(name));
216
+
217
+ const allProviders = [...specializedProviders, ...genericProviders];
218
+
219
+ if (genericProviders.length > 0) {
220
+ logger.debug("[UnifiedSearch] Dynamic collections discovered", {
221
+ specialized: specializedProviders.map((p) => p.name),
222
+ generic: genericProviders.map((p) => p.name),
223
+ });
224
+ }
225
+
226
+ // Apply explicit collection filter by provider name
227
+ if (requestedCollections && requestedCollections.length > 0) {
228
+ const requestedSet = new Set(requestedCollections);
229
+
230
+ // For explicitly requested collections that aren't in scopedCollections
231
+ // (because scope would have excluded them), add generic providers anyway.
232
+ // The agent explicitly asked — no scope filtering.
233
+ const missingGenericNames = requestedCollections.filter(
234
+ (name) =>
235
+ !specializedProviders.some((p) => p.name === name) &&
236
+ !genericProviders.some((p) => p.name === name) &&
237
+ allCollections.includes(name)
238
+ );
239
+ const extraProviders = missingGenericNames.map(
240
+ (name) => new GenericCollectionSearchProvider(name)
241
+ );
242
+
243
+ const expandedProviders = [...allProviders, ...extraProviders];
244
+ return expandedProviders.filter((p) => requestedSet.has(p.name));
245
+ }
246
+
247
+ return allProviders;
248
+ }
249
+
139
250
  /**
140
251
  * Use a fast/cheap LLM to extract focused information from search results.
141
252
  *
@@ -19,6 +19,7 @@ export type {
19
19
  export { ReportSearchProvider } from "./providers/ReportSearchProvider";
20
20
  export { ConversationSearchProvider } from "./providers/ConversationSearchProvider";
21
21
  export { LessonSearchProvider } from "./providers/LessonSearchProvider";
22
+ export { GenericCollectionSearchProvider } from "./providers/GenericCollectionSearchProvider";
22
23
 
23
24
  import { SearchProviderRegistry } from "./SearchProviderRegistry";
24
25
  import { ReportSearchProvider } from "./providers/ReportSearchProvider";