@tenex-chat/backend 0.9.5 → 0.9.7

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 +34 -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,7 +1,7 @@
1
1
  import * as fileSystem from "@/lib/fs";
2
2
  import { runProviderSetup } from "@/llm/utils/provider-setup";
3
3
  import { config } from "@/services/ConfigService";
4
- import { logger } from "@/utils/logger";
4
+ import chalk from "chalk";
5
5
  import { Command } from "commander";
6
6
 
7
7
  export const providersCommand = new Command("providers")
@@ -15,13 +15,13 @@ export const providersCommand = new Command("providers")
15
15
  const updatedProviders = await runProviderSetup(existingProviders);
16
16
 
17
17
  await config.saveGlobalProviders(updatedProviders);
18
- logger.info(`✅ Provider credentials saved to ${globalPath}/providers.json`);
18
+ console.log(chalk.green("✓") + chalk.bold(` Provider credentials saved to ${globalPath}/providers.json`));
19
19
  } catch (error: unknown) {
20
20
  const errorMessage = error instanceof Error ? error.message : String(error);
21
21
  if (errorMessage?.includes("SIGINT") || errorMessage?.includes("force closed")) {
22
22
  return;
23
23
  }
24
- logger.error(`Failed to configure providers: ${error}`);
24
+ console.log(chalk.red(`❌ Failed to configure providers: ${error}`));
25
25
  process.exitCode = 1;
26
26
  }
27
27
  });
@@ -409,6 +409,16 @@ export class ConversationStore {
409
409
  return index;
410
410
  }
411
411
 
412
+ relocateToEnd(eventId: string, updates: Partial<ConversationEntry>): boolean {
413
+ const index = this.state.messages.findIndex(m => m.eventId === eventId);
414
+ if (index === -1) return false;
415
+
416
+ const [entry] = this.state.messages.splice(index, 1);
417
+ Object.assign(entry, updates);
418
+ this.state.messages.push(entry);
419
+ return true;
420
+ }
421
+
412
422
  getAllMessages(): ConversationEntry[] {
413
423
  return this.state.messages;
414
424
  }
@@ -547,8 +557,19 @@ export class ConversationStore {
547
557
  if (updates.abortReason) {
548
558
  dm.abortReason = updates.abortReason;
549
559
  }
550
- // DON'T update entry.timestamp - it should remain the initiation time
551
- // Only the delegationMarker.completedAt changes
560
+
561
+ // Move the completed marker to the end of the messages array.
562
+ // The pending marker was inserted during the delegate tool execution,
563
+ // which places it BEFORE the agent's post-delegation response text.
564
+ // When the marker is expanded during message building, it becomes a
565
+ // user message — if it stays at its original position, the conversation
566
+ // ends with the agent's trailing assistant message and Anthropic rejects
567
+ // it with "conversation must end with a user message".
568
+ const markerIndex = this.state.messages.indexOf(markerEntry);
569
+ if (markerIndex >= 0 && markerIndex < this.state.messages.length - 1) {
570
+ this.state.messages.splice(markerIndex, 1);
571
+ this.state.messages.push(markerEntry);
572
+ }
552
573
 
553
574
  return true;
554
575
  }
@@ -13,6 +13,7 @@
13
13
  import type { ModelMessage, ToolCallPart, ToolResultPart } from "ai";
14
14
  import { trace } from "@opentelemetry/api";
15
15
  import type { ConversationEntry, DelegationMarker } from "./types";
16
+ import { renderConversationXml } from "@/conversations/formatters/utils/conversation-transcript-formatter";
16
17
  import { getPubkeyService } from "@/services/PubkeyService";
17
18
  import { convertToMultimodalContent, hasImageUrls } from "./utils/multimodal-content";
18
19
  import { processToolResult, shouldTruncateToolResult, type TruncationContext } from "./utils/tool-result-truncator";
@@ -106,9 +107,8 @@ interface AgentsMdContext {
106
107
  * Rules:
107
108
  * - Explicit role override: If entry.role is set (for synthetic entries like compressed summaries), use it
108
109
  * - assistant: Only for the viewing agent's own messages
109
- * - user: All other messages (regardless of targeting)
110
+ * - user: All other messages (regardless of targeting), including compressed summaries
110
111
  * - tool: Tool results (fixed)
111
- * - system: Synthetic system messages (compressed summaries, etc.)
112
112
  *
113
113
  * Note: Attribution context is not added to LLM input. Role simply distinguishes
114
114
  * between the agent's own messages and messages from others.
@@ -136,6 +136,19 @@ function deriveRole(
136
136
  return "user"; // All non-self messages
137
137
  }
138
138
 
139
+ /**
140
+ * Resolve a display name through PubkeyService, falling back to its sync path.
141
+ * This keeps all name formatting centralized in PubkeyService.
142
+ */
143
+ async function resolveDisplayName(pubkey: string): Promise<string> {
144
+ const pubkeyService = getPubkeyService();
145
+ try {
146
+ return await pubkeyService.getName(pubkey);
147
+ } catch {
148
+ return pubkeyService.getNameSync(pubkey);
149
+ }
150
+ }
151
+
139
152
  /**
140
153
  * Compute an attribution prefix for a conversation entry.
141
154
  *
@@ -166,14 +179,7 @@ export function computeAttributionPrefix(
166
179
  agentPubkeys: Set<string>,
167
180
  resolveDisplayName?: (pubkey: string) => string
168
181
  ): string {
169
- const resolve = resolveDisplayName ?? ((pk: string) => {
170
- try {
171
- const name = getPubkeyService().getNameSync(pk);
172
- return name || pk.substring(0, 8);
173
- } catch {
174
- return pk.substring(0, 8);
175
- }
176
- });
182
+ const resolve = resolveDisplayName ?? ((pk: string) => getPubkeyService().getNameSync(pk));
177
183
 
178
184
  // Determine the actual sender (injected messages track original sender via senderPubkey)
179
185
  const senderPubkey = entry.senderPubkey ?? entry.pubkey;
@@ -357,41 +363,25 @@ async function expandDelegationMarker(
357
363
  marker: DelegationMarker,
358
364
  delegationMessages: ConversationEntry[] | undefined
359
365
  ): Promise<ModelMessage> {
360
- const pubkeyService = getPubkeyService();
361
-
362
366
  // Handle pending delegations - show that work is in progress
363
367
  if (marker.status === "pending") {
364
- try {
365
- const recipientName = await pubkeyService.getName(marker.recipientPubkey);
366
- return {
367
- role: "user",
368
- content: `# DELEGATION IN PROGRESS\n\n@${recipientName} is currently working on this task.`,
369
- };
370
- } catch {
371
- return {
372
- role: "user",
373
- content: `# DELEGATION IN PROGRESS\n\nAgent ${marker.recipientPubkey.substring(0, 12)} is currently working on this task.`,
374
- };
375
- }
368
+ const recipientName = await resolveDisplayName(marker.recipientPubkey);
369
+ return {
370
+ role: "user",
371
+ content: `# DELEGATION IN PROGRESS\n\n@${recipientName} is currently working on this task.`,
372
+ };
376
373
  }
377
374
 
378
375
  if (!delegationMessages) {
379
376
  // Delegation conversation not found - return placeholder
380
- try {
381
- const recipientName = await pubkeyService.getName(marker.recipientPubkey);
382
- const statusText = marker.status === "aborted"
383
- ? `was aborted: ${marker.abortReason || "unknown reason"}`
384
- : "completed (transcript unavailable)";
385
- return {
386
- role: "user",
387
- content: `# DELEGATION ${marker.status.toUpperCase()}\n\n@${recipientName} ${statusText}`,
388
- };
389
- } catch {
390
- return {
391
- role: "user",
392
- content: `# DELEGATION ${marker.status.toUpperCase()}\n\nAgent ${marker.recipientPubkey.substring(0, 12)} ${marker.status === "aborted" ? "was aborted" : "completed"} (transcript unavailable)`,
393
- };
394
- }
377
+ const recipientName = await resolveDisplayName(marker.recipientPubkey);
378
+ const statusText = marker.status === "aborted"
379
+ ? `was aborted: ${marker.abortReason || "unknown reason"}`
380
+ : "completed (transcript unavailable)";
381
+ return {
382
+ role: "user",
383
+ content: `# DELEGATION ${marker.status.toUpperCase()}\n\n@${recipientName} ${statusText}`,
384
+ };
395
385
  }
396
386
 
397
387
  // Build flat transcript from delegation conversation
@@ -410,32 +400,15 @@ async function expandDelegationMarker(
410
400
  lines.push("");
411
401
  }
412
402
 
413
- // Filter for targeted text messages only (no tool calls, no nested markers)
414
- const transcriptMessages = delegationMessages.filter(msg =>
415
- msg.messageType === "text" &&
416
- msg.targetedPubkeys &&
417
- msg.targetedPubkeys.length > 0
418
- );
403
+ const { xml: transcriptXml } = renderConversationXml(delegationMessages, {
404
+ conversationId: marker.delegationConversationId,
405
+ includeMessageTypes: ["text"],
406
+ requireTargetedPubkeys: true,
407
+ includeToolCalls: false,
408
+ });
419
409
 
420
- if (transcriptMessages.length === 0) {
421
- lines.push("(No messages in delegation transcript)");
422
- } else {
423
- lines.push("### Transcript:");
424
- for (const msg of transcriptMessages) {
425
- try {
426
- const senderName = await pubkeyService.getName(msg.pubkey);
427
- const recipientName = msg.targetedPubkeys?.[0]
428
- ? await pubkeyService.getName(msg.targetedPubkeys[0])
429
- : "unknown";
430
- lines.push(`[@${senderName} -> @${recipientName}]: ${msg.content}`);
431
- } catch {
432
- // Fallback to shortened pubkeys
433
- const senderFallback = msg.pubkey.substring(0, 12);
434
- const recipientFallback = msg.targetedPubkeys?.[0]?.substring(0, 12) || "unknown";
435
- lines.push(`[@${senderFallback} -> @${recipientFallback}]: ${msg.content}`);
436
- }
437
- }
438
- }
410
+ lines.push("### Transcript:");
411
+ lines.push(transcriptXml);
439
412
 
440
413
  return {
441
414
  role: "user",
@@ -462,14 +435,7 @@ async function expandDelegationMarker(
462
435
  async function formatNestedDelegationMarker(
463
436
  marker: DelegationMarker
464
437
  ): Promise<ModelMessage> {
465
- const pubkeyService = getPubkeyService();
466
-
467
- let recipientName: string;
468
- try {
469
- recipientName = await pubkeyService.getName(marker.recipientPubkey);
470
- } catch {
471
- recipientName = marker.recipientPubkey.substring(0, 12);
472
- }
438
+ const recipientName = await resolveDisplayName(marker.recipientPubkey);
473
439
 
474
440
  const shortConversationId = marker.delegationConversationId.substring(0, 12);
475
441
 
@@ -668,9 +634,21 @@ export async function buildMessagesFromEntries(
668
634
 
669
635
  // TOOL-RESULT: Add to result and mark tool-call as resolved
670
636
  if (entry.messageType === "tool-result" && entry.toolData) {
637
+ const parts = entry.toolData as ToolResultPart[];
638
+
639
+ // Skip orphaned tool-results whose tool-calls were removed by compression/truncation.
640
+ // If the corresponding tool-call is not in pendingToolCalls, sending this result
641
+ // to the LLM would produce an API error (tool_result without matching tool_use).
642
+ if (parts.some((part) => !pendingToolCalls.has(part.toolCallId))) {
643
+ trace.getActiveSpan?.()?.addEvent("conversation.orphaned_tool_result_skipped", {
644
+ "orphan.tool_call_ids": parts.map((p) => p.toolCallId).join(","),
645
+ });
646
+ continue;
647
+ }
648
+
671
649
  result.push(await entryToMessage(entry, viewingAgentPubkey, truncationContext, agentPubkeys, imageTracker, agentsMdContext));
672
650
 
673
- for (const part of entry.toolData as ToolResultPart[]) {
651
+ for (const part of parts) {
674
652
  pendingToolCalls.delete(part.toolCallId);
675
653
  }
676
654
 
@@ -0,0 +1,425 @@
1
+ import type { ConversationEntry } from "@/conversations/types";
2
+ import { getPubkeyService } from "@/services/PubkeyService";
3
+ import { PREFIX_LENGTH } from "@/utils/nostr-entity-parser";
4
+ import type { ToolCallPart } from "ai";
5
+
6
+ const DEFAULT_SHORT_ID_LENGTH = 12;
7
+ const DEFAULT_MAX_TOOL_DESCRIPTION_LENGTH = 150;
8
+ const DEFAULT_MAX_TOOL_INPUT_JSON_LENGTH = 200;
9
+
10
+ const FULL_HEX_EVENT_ID_REGEX = /^[0-9a-f]{64}$/i;
11
+
12
+ export interface ConversationTimelineEntry {
13
+ entry: ConversationEntry;
14
+ relativeSeconds: number;
15
+ author: string;
16
+ recipients: string[];
17
+ }
18
+
19
+ export interface ConversationTimeline {
20
+ t0: number;
21
+ entries: ConversationTimelineEntry[];
22
+ }
23
+
24
+ export interface ConversationTimelineOptions {
25
+ includeMessageTypes?: Array<ConversationEntry["messageType"]>;
26
+ requireTargetedPubkeys?: boolean;
27
+ includeToolCalls?: boolean;
28
+ }
29
+
30
+ export interface ConversationXmlRenderOptions {
31
+ conversationId?: string;
32
+ includeMessageTypes?: Array<ConversationEntry["messageType"]>;
33
+ requireTargetedPubkeys?: boolean;
34
+ includeToolCalls?: boolean;
35
+ shortIdLength?: number;
36
+ maxToolDescriptionLength?: number;
37
+ maxToolInputJsonLength?: number;
38
+ }
39
+
40
+ export interface ConversationXmlRenderResult {
41
+ xml: string;
42
+ shortIdToEventId: Map<string, string>;
43
+ firstShortId: string | null;
44
+ lastShortId: string | null;
45
+ }
46
+
47
+ function escapeXml(value: string): string {
48
+ return value
49
+ .replaceAll("&", "&amp;")
50
+ .replaceAll("<", "&lt;")
51
+ .replaceAll(">", "&gt;")
52
+ .replaceAll('"', "&quot;")
53
+ .replaceAll("'", "&apos;");
54
+ }
55
+
56
+ function computeBaselineTimestamp(entries: ConversationEntry[]): number {
57
+ for (const entry of entries) {
58
+ if (entry.timestamp !== undefined) {
59
+ return entry.timestamp;
60
+ }
61
+ }
62
+ return 0;
63
+ }
64
+
65
+ function shouldIncludeEntry(
66
+ entry: ConversationEntry,
67
+ options: ConversationTimelineOptions
68
+ ): boolean {
69
+ const includeToolCalls = options.includeToolCalls ?? true;
70
+
71
+ if (entry.messageType === "tool-result") {
72
+ return false;
73
+ }
74
+
75
+ if (entry.messageType === "tool-call" && !includeToolCalls) {
76
+ return false;
77
+ }
78
+
79
+ if (options.includeMessageTypes && !options.includeMessageTypes.includes(entry.messageType)) {
80
+ return false;
81
+ }
82
+
83
+ if (options.requireTargetedPubkeys && (!entry.targetedPubkeys || entry.targetedPubkeys.length === 0)) {
84
+ return false;
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ function getShortEventId(
91
+ eventId: string,
92
+ shortIdLength: number,
93
+ usedShortIds: Set<string>
94
+ ): string {
95
+ const base = eventId.substring(0, shortIdLength) || "event";
96
+ let candidate = base;
97
+ let suffix = 2;
98
+
99
+ while (usedShortIds.has(candidate)) {
100
+ candidate = `${base}-${suffix}`;
101
+ suffix++;
102
+ }
103
+
104
+ usedShortIds.add(candidate);
105
+ return candidate;
106
+ }
107
+
108
+ function formatDelegationMarkerContent(entry: ConversationEntry): string | null {
109
+ const marker = entry.delegationMarker;
110
+ if (!marker?.delegationConversationId || !marker?.recipientPubkey || !marker?.status) {
111
+ return null;
112
+ }
113
+
114
+ const pubkeyService = getPubkeyService();
115
+ const shortConversationId = marker.delegationConversationId.slice(0, PREFIX_LENGTH);
116
+ const recipientName = pubkeyService.getNameSync(marker.recipientPubkey);
117
+
118
+ if (marker.status === "pending") {
119
+ return `⏳ Delegation ${shortConversationId} → ${recipientName} in progress`;
120
+ }
121
+
122
+ if (marker.status === "completed") {
123
+ return `✅ Delegation ${shortConversationId} → ${recipientName} completed`;
124
+ }
125
+
126
+ return `⚠️ Delegation ${shortConversationId} → ${recipientName} aborted`;
127
+ }
128
+
129
+ function safeStringify(value: unknown): string {
130
+ const seen = new WeakSet<object>();
131
+
132
+ try {
133
+ const serialized = JSON.stringify(value, (_key, currentValue) => {
134
+ if (typeof currentValue === "bigint") {
135
+ return currentValue.toString();
136
+ }
137
+
138
+ if (typeof currentValue === "object" && currentValue !== null) {
139
+ if (seen.has(currentValue)) {
140
+ return "[Circular]";
141
+ }
142
+ seen.add(currentValue);
143
+ }
144
+
145
+ return currentValue;
146
+ });
147
+
148
+ if (serialized === undefined) {
149
+ return "";
150
+ }
151
+
152
+ return serialized;
153
+ } catch {
154
+ return "[Unserializable]";
155
+ }
156
+ }
157
+
158
+ function truncateWithSuffix(value: string, maxLength: number): string {
159
+ if (value.length <= maxLength) {
160
+ return value;
161
+ }
162
+
163
+ const truncatedChars = value.length - maxLength;
164
+ return `${value.slice(0, maxLength)}... [truncated ${truncatedChars} chars]`;
165
+ }
166
+
167
+ function extractToolCallParts(entry: ConversationEntry): ToolCallPart[] {
168
+ if (!entry.toolData || entry.toolData.length === 0) {
169
+ return [];
170
+ }
171
+
172
+ return entry.toolData
173
+ .map((tool) => tool as unknown as Record<string, unknown>)
174
+ .filter((tool) => (tool.type ?? entry.messageType) === "tool-call")
175
+ .map((tool) => ({
176
+ type: "tool-call",
177
+ toolCallId: typeof tool.toolCallId === "string" ? tool.toolCallId : "",
178
+ toolName: typeof tool.toolName === "string" ? tool.toolName : "unknown",
179
+ input: tool.input,
180
+ })) as ToolCallPart[];
181
+ }
182
+
183
+ function resolveToolCallEventIdMap(entries: ConversationEntry[]): Map<string, string> {
184
+ const result = new Map<string, string>();
185
+
186
+ for (const entry of entries) {
187
+ if (entry.messageType !== "tool-result" || !entry.eventId || !entry.toolData) {
188
+ continue;
189
+ }
190
+
191
+ for (const part of entry.toolData) {
192
+ const raw = part as unknown as Record<string, unknown>;
193
+ if (typeof raw.toolCallId !== "string" || raw.toolCallId.length === 0) {
194
+ continue;
195
+ }
196
+
197
+ if (!result.has(raw.toolCallId)) {
198
+ result.set(raw.toolCallId, entry.eventId);
199
+ }
200
+ }
201
+ }
202
+
203
+ return result;
204
+ }
205
+
206
+ function resolveConversationRootEventId(
207
+ entries: ConversationEntry[],
208
+ conversationId?: string
209
+ ): string | null {
210
+ if (conversationId && conversationId.length > 0) {
211
+ return conversationId;
212
+ }
213
+
214
+ for (const entry of entries) {
215
+ if (entry.eventId) {
216
+ return entry.eventId;
217
+ }
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ function buildToolXmlAttributes(
224
+ entry: ConversationEntry,
225
+ maxToolDescriptionLength: number,
226
+ maxToolInputJsonLength: number
227
+ ): Record<string, string> {
228
+ const attrs: Record<string, string> = {};
229
+
230
+ if (entry.transcriptToolAttributes) {
231
+ Object.assign(attrs, entry.transcriptToolAttributes);
232
+ }
233
+
234
+ const toolCallParts = extractToolCallParts(entry);
235
+ const firstPart = toolCallParts[0];
236
+ const firstInput = firstPart?.input as Record<string, unknown> | undefined;
237
+
238
+ if (!attrs.description && firstInput && typeof firstInput.description === "string") {
239
+ attrs.description = truncateWithSuffix(firstInput.description, maxToolDescriptionLength);
240
+ }
241
+
242
+ const fallbackArgMappings: Array<{ key: string; attribute: string }> = [
243
+ { key: "path", attribute: "file_path" },
244
+ { key: "pattern", attribute: "pattern" },
245
+ { key: "query", attribute: "query" },
246
+ { key: "glob", attribute: "glob" },
247
+ { key: "file_path", attribute: "file_path" },
248
+ ];
249
+ for (const mapping of fallbackArgMappings) {
250
+ if (attrs[mapping.attribute]) {
251
+ continue;
252
+ }
253
+ const candidate = firstInput?.[mapping.key];
254
+ if (typeof candidate === "string" && candidate.length > 0) {
255
+ attrs[mapping.attribute] = candidate;
256
+ }
257
+ }
258
+
259
+ const toolName = firstPart?.toolName ?? "unknown";
260
+ if (toolName.startsWith("mcp_") && !attrs.args) {
261
+ attrs.args = truncateWithSuffix(safeStringify(firstInput ?? {}), maxToolInputJsonLength);
262
+ }
263
+
264
+ return attrs;
265
+ }
266
+
267
+ export function buildConversationTimeline(
268
+ entries: ConversationEntry[],
269
+ options: ConversationTimelineOptions = {}
270
+ ): ConversationTimeline {
271
+ const pubkeyService = getPubkeyService();
272
+ const t0 = computeBaselineTimestamp(entries);
273
+ let lastKnownTimestamp = t0;
274
+ const timelineEntries: ConversationTimelineEntry[] = [];
275
+
276
+ for (const entry of entries) {
277
+ const effectiveTimestamp = entry.timestamp ?? lastKnownTimestamp;
278
+ const relativeSeconds = Math.floor(effectiveTimestamp - t0);
279
+
280
+ if (entry.timestamp !== undefined) {
281
+ lastKnownTimestamp = entry.timestamp;
282
+ }
283
+
284
+ if (!shouldIncludeEntry(entry, options)) {
285
+ continue;
286
+ }
287
+
288
+ const authorPubkey = entry.senderPubkey ?? entry.pubkey;
289
+ const author = pubkeyService.getNameSync(authorPubkey);
290
+ const recipients = (entry.targetedPubkeys ?? []).map((pubkey) =>
291
+ pubkeyService.getNameSync(pubkey)
292
+ );
293
+
294
+ timelineEntries.push({
295
+ entry,
296
+ relativeSeconds,
297
+ author,
298
+ recipients,
299
+ });
300
+ }
301
+
302
+ return {
303
+ t0,
304
+ entries: timelineEntries,
305
+ };
306
+ }
307
+
308
+ export function renderConversationXml(
309
+ entries: ConversationEntry[],
310
+ options: ConversationXmlRenderOptions = {}
311
+ ): ConversationXmlRenderResult {
312
+ const shortIdLength = options.shortIdLength ?? DEFAULT_SHORT_ID_LENGTH;
313
+ const maxToolDescriptionLength =
314
+ options.maxToolDescriptionLength ?? DEFAULT_MAX_TOOL_DESCRIPTION_LENGTH;
315
+ const maxToolInputJsonLength =
316
+ options.maxToolInputJsonLength ?? DEFAULT_MAX_TOOL_INPUT_JSON_LENGTH;
317
+
318
+ const timeline = buildConversationTimeline(entries, {
319
+ includeMessageTypes: options.includeMessageTypes,
320
+ requireTargetedPubkeys: options.requireTargetedPubkeys,
321
+ includeToolCalls: options.includeToolCalls,
322
+ });
323
+
324
+ const toolCallEventIdMap = resolveToolCallEventIdMap(entries);
325
+ const usedShortIds = new Set<string>();
326
+ const shortIdToEventId = new Map<string, string>();
327
+ const renderedIds: string[] = [];
328
+
329
+ const getOrCreateShortId = (eventId: string): string => {
330
+ for (const [shortId, fullId] of shortIdToEventId.entries()) {
331
+ if (fullId === eventId) {
332
+ return shortId;
333
+ }
334
+ }
335
+
336
+ const shortId = getShortEventId(eventId, shortIdLength, usedShortIds);
337
+ shortIdToEventId.set(shortId, eventId);
338
+ return shortId;
339
+ };
340
+
341
+ const rootEventId = resolveConversationRootEventId(entries, options.conversationId);
342
+ const rootId = rootEventId
343
+ ? getOrCreateShortId(rootEventId)
344
+ : "unknown";
345
+
346
+ const lines: string[] = [`<conversation id="${escapeXml(rootId)}" t0="${timeline.t0}">`];
347
+
348
+ for (const timelineEntry of timeline.entries) {
349
+ const { entry, relativeSeconds, author, recipients } = timelineEntry;
350
+
351
+ if (entry.messageType === "tool-result") {
352
+ continue;
353
+ }
354
+
355
+ const timeIndicator = `+${relativeSeconds}`;
356
+
357
+ if (entry.messageType === "tool-call") {
358
+ const toolParts = extractToolCallParts(entry);
359
+ if (toolParts.length === 0) {
360
+ continue;
361
+ }
362
+
363
+ const primaryPart = toolParts[0];
364
+ const toolCallId = primaryPart.toolCallId;
365
+ const candidateToolEventId = entry.eventId || toolCallEventIdMap.get(toolCallId);
366
+ const toolId = candidateToolEventId
367
+ ? (FULL_HEX_EVENT_ID_REGEX.test(candidateToolEventId)
368
+ ? candidateToolEventId
369
+ : getOrCreateShortId(candidateToolEventId))
370
+ : null;
371
+ if (toolId) {
372
+ renderedIds.push(toolId);
373
+ if (candidateToolEventId && FULL_HEX_EVENT_ID_REGEX.test(candidateToolEventId)) {
374
+ shortIdToEventId.set(toolId, candidateToolEventId);
375
+ }
376
+ }
377
+
378
+ const idAttr = toolId ? ` id="${escapeXml(toolId)}"` : "";
379
+ const nameAttr = ` name="${escapeXml(primaryPart.toolName || "unknown")}"`;
380
+ const transcriptAttrs = buildToolXmlAttributes(
381
+ entry,
382
+ maxToolDescriptionLength,
383
+ maxToolInputJsonLength
384
+ );
385
+ const extraAttrs = Object.entries(transcriptAttrs)
386
+ .map(([key, value]) => ` ${key}="${escapeXml(value)}"`)
387
+ .join("");
388
+
389
+ lines.push(
390
+ ` <tool${idAttr} user="${escapeXml(author)}"${nameAttr}${extraAttrs} time="${timeIndicator}" />`
391
+ );
392
+ continue;
393
+ }
394
+
395
+ const messageText = entry.messageType === "delegation-marker"
396
+ ? formatDelegationMarkerContent(entry)
397
+ : (entry.content || "(empty)");
398
+
399
+ if (messageText === null) {
400
+ continue;
401
+ }
402
+
403
+ const shortEventId = entry.eventId ? getOrCreateShortId(entry.eventId) : null;
404
+ if (shortEventId) {
405
+ renderedIds.push(shortEventId);
406
+ }
407
+ const idAttr = shortEventId ? ` id="${escapeXml(shortEventId)}"` : "";
408
+ const recipientAttr = recipients.length > 0
409
+ ? ` recipient="${escapeXml(recipients.join(", "))}"`
410
+ : "";
411
+
412
+ lines.push(
413
+ ` <message${idAttr} author="${escapeXml(author)}"${recipientAttr} time="${timeIndicator}">${escapeXml(messageText)}</message>`
414
+ );
415
+ }
416
+
417
+ lines.push("</conversation>");
418
+
419
+ return {
420
+ xml: lines.join("\n"),
421
+ shortIdToEventId,
422
+ firstShortId: renderedIds[0] ?? null,
423
+ lastShortId: renderedIds[renderedIds.length - 1] ?? null,
424
+ };
425
+ }