@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,3 +1,4 @@
1
+ import type { ToolCallPart, ToolResultPart } from "ai";
1
2
  import type { CompiledMessage } from "@/agents/execution/MessageCompiler";
2
3
  import type { ConversationEntry } from "@/conversations/types";
3
4
  import type {
@@ -258,13 +259,20 @@ export function validateSegmentsForEntries(
258
259
  };
259
260
  }
260
261
 
261
- // Check for gaps between segments
262
+ // Check for gaps between segments.
263
+ // Non-eventId entries (tool-calls, tool-results) between two segments are fine:
264
+ // applySegmentsToEntries keeps them as-is in the output between the two summaries.
265
+ // Only flag a gap when eventId-bearing entries sit between segments and would
266
+ // be left uncompressed while we expected the LLM to cover the full range.
262
267
  if (i > 0) {
263
268
  const prevSegment = segments[i - 1];
264
269
  const prevToIndex = rangeEntries.findIndex(
265
270
  (e) => e.eventId === prevSegment.toEventId
266
271
  );
267
- if (fromIndex !== prevToIndex + 1) {
272
+ const hasEventIdInGap = rangeEntries
273
+ .slice(prevToIndex + 1, fromIndex)
274
+ .some((e) => !!e.eventId);
275
+ if (hasEventIdInGap) {
268
276
  return {
269
277
  valid: false,
270
278
  error: `Gap between segment ${i - 1} and ${i}`,
@@ -314,9 +322,11 @@ export function applySegments(
314
322
  currentIndex++;
315
323
  }
316
324
 
317
- // Add compressed summary as system message
325
+ // Add compressed summary as user message
326
+ // NOTE: Cannot use "system" role here because Anthropic requires all system
327
+ // messages at the top of the prompt, not interleaved with user/assistant turns.
318
328
  result.push({
319
- role: "system",
329
+ role: "user",
320
330
  content: `[Compressed history]\n${segment.compressed}`,
321
331
  eventId: `compressed-${segment.fromEventId}-${segment.toEventId}`,
322
332
  });
@@ -359,10 +369,12 @@ export function truncateSlidingWindow(
359
369
 
360
370
  const kept = messages.slice(-windowSize);
361
371
 
362
- // Add a system message at the start indicating truncation
372
+ // Add a user message at the start indicating truncation
373
+ // NOTE: Cannot use "system" role because Anthropic requires all system
374
+ // messages at the top of the prompt, not interleaved with user/assistant turns.
363
375
  return [
364
376
  {
365
- role: "system",
377
+ role: "user",
366
378
  content: `[Earlier messages truncated. Showing last ${windowSize} messages.]`,
367
379
  eventId: `truncated-${currentTimestamp}`,
368
380
  },
@@ -407,15 +419,16 @@ export function applySegmentsToEntries(
407
419
  currentIndex++;
408
420
  }
409
421
 
410
- // Add compressed summary as system entry with explicit role
411
- // CRITICAL: role field prevents deriveRole from misclassifying as "user"
422
+ // Add compressed summary as user entry with explicit role
423
+ // NOTE: Cannot use "system" role here because Anthropic requires all system
424
+ // messages at the top of the prompt, not interleaved with user/assistant turns.
412
425
  result.push({
413
426
  pubkey: "system",
414
427
  content: `[Compressed history]\n${segment.compressed}`,
415
428
  messageType: "text",
416
429
  eventId: `compressed-${segment.fromEventId}-${segment.toEventId}`,
417
430
  timestamp: segment.createdAt / 1000,
418
- role: "system",
431
+ role: "user",
419
432
  });
420
433
 
421
434
  // Skip the compressed range
@@ -431,6 +444,18 @@ export function applySegmentsToEntries(
431
444
  return result;
432
445
  }
433
446
 
447
+ /**
448
+ * Estimate the character count for a single conversation entry.
449
+ * Accounts for both text content and tool payloads (toolData).
450
+ */
451
+ function estimateEntryChars(entry: ConversationEntry): number {
452
+ let chars = entry.content.length;
453
+ if (entry.toolData && entry.toolData.length > 0) {
454
+ chars += JSON.stringify(entry.toolData).length;
455
+ }
456
+ return chars;
457
+ }
458
+
434
459
  /**
435
460
  * Estimate token count for conversation entries using rough heuristic (chars/4).
436
461
  * This is faster than actual tokenization and sufficient for compression decisions.
@@ -442,18 +467,35 @@ export function applySegmentsToEntries(
442
467
  * @returns Estimated token count
443
468
  */
444
469
  export function estimateTokensFromEntries(entries: ConversationEntry[]): number {
445
- const totalChars = entries.reduce((sum, entry) => {
446
- let entryChars = entry.content.length;
470
+ const totalChars = entries.reduce((sum, entry) => sum + estimateEntryChars(entry), 0);
471
+ return Math.ceil(totalChars / 4);
472
+ }
473
+
474
+ /**
475
+ * Compute how many trailing entries fit within a token budget.
476
+ * Walks entries backwards, accumulating estimated tokens, and stops when the budget is exhausted.
477
+ *
478
+ * @param entries - All conversation entries
479
+ * @param tokenBudget - Maximum tokens the trailing window should contain
480
+ * @returns Number of trailing entries that fit within the budget (minimum 1)
481
+ */
482
+ export function computeTokenAwareWindowSize(
483
+ entries: ConversationEntry[],
484
+ tokenBudget: number
485
+ ): number {
486
+ let accumulatedTokens = 0;
487
+ let count = 0;
447
488
 
448
- // Account for tool payloads (tool-call/tool-result)
449
- if (entry.toolData && entry.toolData.length > 0) {
450
- const toolDataSize = JSON.stringify(entry.toolData).length;
451
- entryChars += toolDataSize;
489
+ for (let i = entries.length - 1; i >= 0; i--) {
490
+ const entryTokens = Math.ceil(estimateEntryChars(entries[i]) / 4);
491
+ if (accumulatedTokens + entryTokens > tokenBudget && count > 0) {
492
+ break;
452
493
  }
494
+ accumulatedTokens += entryTokens;
495
+ count++;
496
+ }
453
497
 
454
- return sum + entryChars;
455
- }, 0);
456
- return Math.ceil(totalChars / 4);
498
+ return Math.max(1, count);
457
499
  }
458
500
 
459
501
  /**
@@ -521,11 +563,14 @@ export function truncateSlidingWindowEntries(
521
563
  const kept = entries.slice(-windowSize);
522
564
 
523
565
  // Add a synthetic entry indicating truncation
566
+ // NOTE: Explicit role: "user" because Anthropic requires all system
567
+ // messages at the top of the prompt, not interleaved with user/assistant turns.
524
568
  const truncationEntry: ConversationEntry = {
525
569
  pubkey: "system",
526
570
  content: `[Earlier messages truncated. Showing last ${windowSize} messages.]`,
527
571
  messageType: "text",
528
572
  timestamp: currentTimestamp / 1000,
573
+ role: "user",
529
574
  };
530
575
 
531
576
  return [truncationEntry, ...kept];
@@ -575,12 +620,58 @@ export function createFallbackSegmentForEntries(
575
620
  }
576
621
  }
577
622
 
623
+ // Ensure the boundary doesn't cut in the middle of a tool-call/tool-result pair.
624
+ // Collect tool-calls in [0, toEntry.index] that have no matching result in the same range.
625
+ const pendingCallIds = new Set<string>();
626
+ for (let i = 0; i <= toEntry.index; i++) {
627
+ const entry = entries[i];
628
+ if (entry.messageType === "tool-call" && entry.toolData) {
629
+ for (const part of entry.toolData as ToolCallPart[]) {
630
+ pendingCallIds.add(part.toolCallId);
631
+ }
632
+ } else if (entry.messageType === "tool-result" && entry.toolData) {
633
+ for (const part of entry.toolData as ToolResultPart[]) {
634
+ pendingCallIds.delete(part.toolCallId);
635
+ }
636
+ }
637
+ }
638
+
639
+ // If there are unresolved tool-calls, their results live just after the boundary.
640
+ // Extend the segment past those orphaned tool-results so they are not left dangling.
641
+ if (pendingCallIds.size > 0) {
642
+ let i = toEntry.index + 1;
643
+ while (i < entries.length) {
644
+ const entry = entries[i];
645
+ if (
646
+ entry.messageType === "tool-result" &&
647
+ entry.toolData &&
648
+ (entry.toolData as ToolResultPart[]).some((p) => pendingCallIds.has(p.toolCallId))
649
+ ) {
650
+ for (const p of entry.toolData as ToolResultPart[]) {
651
+ pendingCallIds.delete(p.toolCallId);
652
+ }
653
+ i++;
654
+ } else {
655
+ break;
656
+ }
657
+ }
658
+ // Find the next entry with an eventId at or after position i to use as the new boundary.
659
+ for (let j = i; j < entries.length; j++) {
660
+ if (entries[j].eventId) {
661
+ toEntry = { entry: entries[j], index: j };
662
+ break;
663
+ }
664
+ }
665
+ // If no eventId is found we keep the original toEntry; MessageBuilder will skip
666
+ // any remaining orphaned tool-results as a safety net.
667
+ }
668
+
578
669
  const toEventId = toEntry.entry.eventId!;
579
670
 
580
671
  return {
581
672
  fromEventId,
582
673
  toEventId,
583
- compressed: `[Truncated ${truncateCount} earlier messages due to compression failure]`,
674
+ compressed: `[Truncated ${toEntry.index + 1} earlier messages due to compression failure]`,
584
675
  createdAt: Date.now(),
585
676
  model: "fallback-truncation",
586
677
  };
@@ -192,8 +192,6 @@ export interface MetaModelVariant {
192
192
  description?: string;
193
193
  /** Optional additional system prompt to inject when this variant is active */
194
194
  systemPrompt?: string;
195
- /** Priority tier for conflict resolution (higher number = higher priority) */
196
- tier?: number;
197
195
  }
198
196
 
199
197
  /**
@@ -207,8 +205,6 @@ export interface MetaModelConfiguration {
207
205
  variants: Record<string, MetaModelVariant>;
208
206
  /** Default variant to use when no keyword matches */
209
207
  default: string;
210
- /** Optional description shown in system prompt preamble */
211
- description?: string;
212
208
  }
213
209
 
214
210
  /**
@@ -258,7 +254,6 @@ export const MetaModelVariantSchema = z.object({
258
254
  keywords: z.array(z.string()).optional(),
259
255
  description: z.string().optional(),
260
256
  systemPrompt: z.string().optional(),
261
- tier: z.number().optional(),
262
257
  });
263
258
 
264
259
  /**
@@ -268,7 +263,6 @@ export const MetaModelConfigurationSchema = z.object({
268
263
  provider: z.literal("meta"),
269
264
  variants: z.record(z.string(), MetaModelVariantSchema),
270
265
  default: z.string(),
271
- description: z.string().optional(),
272
266
  });
273
267
 
274
268
  /**
@@ -92,6 +92,19 @@ export class AgentDispatchService {
92
92
  error: formatAnyError(error),
93
93
  eventId: event.id,
94
94
  });
95
+ logger.writeToWarnLog({
96
+ timestamp: new Date().toISOString(),
97
+ level: "error",
98
+ component: "AgentDispatchService",
99
+ message: "Failed to route incoming reply",
100
+ context: {
101
+ eventId: event.id,
102
+ eventKind: event.kind,
103
+ pubkey: event.pubkey,
104
+ },
105
+ error: formatAnyError(error),
106
+ stack: error instanceof Error ? error.stack : undefined,
107
+ });
95
108
  } finally {
96
109
  span.end();
97
110
  }
@@ -372,6 +385,46 @@ export class AgentDispatchService {
372
385
  "ral.is_streaming": activeRal.isStreaming,
373
386
  });
374
387
  }
388
+
389
+ // IMMEDIATE MARKER UPDATE: Update delegation markers in ConversationStore
390
+ // right away, BEFORE the debounce. This ensures that if the executor's
391
+ // supervision re-engages and recompiles messages during the debounce window,
392
+ // it sees "DELEGATION COMPLETED" instead of stale "DELEGATION IN PROGRESS".
393
+ // The debounce still handles re-execution triggering and clearCompletedDelegations.
394
+ if (activeRal) {
395
+ const completedDelegations = ralRegistry.getConversationCompletedDelegations(
396
+ delegationTarget.agent.pubkey,
397
+ delegationTarget.conversationId,
398
+ activeRal.ralNumber
399
+ );
400
+
401
+ const parentStore = ConversationStore.get(delegationTarget.conversationId);
402
+ if (parentStore && completedDelegations.length > 0) {
403
+ let markersUpdated = 0;
404
+ for (const completion of completedDelegations) {
405
+ const updated = parentStore.updateDelegationMarker(
406
+ completion.delegationConversationId,
407
+ {
408
+ status: completion.status,
409
+ completedAt: completion.completedAt,
410
+ abortReason: completion.status === "aborted" ? completion.abortReason : undefined,
411
+ }
412
+ );
413
+ if (updated) markersUpdated++;
414
+ }
415
+
416
+ if (markersUpdated > 0) {
417
+ await parentStore.save();
418
+ }
419
+
420
+ span.addEvent("dispatch.markers_updated_before_debounce", {
421
+ "ral.number": activeRal.ralNumber,
422
+ "delegation.completed_count": completedDelegations.length,
423
+ "markers.updated_count": markersUpdated,
424
+ });
425
+ }
426
+ }
427
+
375
428
  const debounceKey = `${delegationTarget.agent.pubkey}:${delegationTarget.conversationId}`;
376
429
  const debounceSequence = await this.waitForDelegationDebounce(debounceKey, span);
377
430
  if (this.delegationDebounceSequence.get(debounceKey) !== debounceSequence) {
@@ -535,6 +588,19 @@ export class AgentDispatchService {
535
588
  code: SpanStatusCode.ERROR,
536
589
  message: (error as Error).message,
537
590
  });
591
+ logger.writeToWarnLog({
592
+ timestamp: new Date().toISOString(),
593
+ level: "error",
594
+ component: "AgentDispatchService",
595
+ message: "Delegation routing execution failed",
596
+ context: {
597
+ agentSlug: delegationTarget.agent.slug,
598
+ conversationId: delegationTarget.conversationId,
599
+ triggerEventId: event.id,
600
+ },
601
+ error: error instanceof Error ? error.message : String(error),
602
+ stack: error instanceof Error ? error.stack : undefined,
603
+ });
538
604
  throw error;
539
605
  } finally {
540
606
  span.end();
@@ -705,6 +771,19 @@ export class AgentDispatchService {
705
771
  code: SpanStatusCode.ERROR,
706
772
  message: (error as Error).message,
707
773
  });
774
+ logger.writeToWarnLog({
775
+ timestamp: new Date().toISOString(),
776
+ level: "error",
777
+ component: "AgentDispatchService",
778
+ message: "Agent execution failed during dispatch",
779
+ context: {
780
+ agentSlug: targetAgent.slug,
781
+ conversationId,
782
+ triggerEventId: event.id,
783
+ },
784
+ error: error instanceof Error ? error.message : String(error),
785
+ stack: error instanceof Error ? error.stack : undefined,
786
+ });
708
787
  throw error;
709
788
  } finally {
710
789
  agentSpan.end();
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { InterventionPublisher } from "@/nostr/InterventionPublisher";
4
+ import { NDKKind } from "@/nostr/kinds";
4
5
  import { config } from "@/services/ConfigService";
5
6
  import { PubkeyService } from "@/services/PubkeyService";
6
7
  import { getTrustPubkeyService } from "@/services/trust-pubkeys/TrustPubkeyService";
@@ -76,7 +77,8 @@ interface NotifiedEntry {
76
77
 
77
78
  /**
78
79
  * Persisted state for InterventionService.
79
- * Stored in ~/.tenex/intervention_state_<projectId>.json (project-scoped)
80
+ * Stored in ~/.tenex/intervention_state_<stateScope>.json (project-scoped).
81
+ * For NIP-33 project coordinates, stateScope is the project's dTag.
80
82
  */
81
83
  interface InterventionState {
82
84
  pending: PendingIntervention[];
@@ -176,12 +178,70 @@ export class InterventionService {
176
178
 
177
179
  /**
178
180
  * Get the state file path for a given project.
179
- * State files are scoped by project ID.
181
+ * State files are scoped by dTag when projectId is a NIP-33 coordinate.
180
182
  */
181
183
  private getStateFilePath(projectId: string): string {
184
+ const stateScope = this.getStateScope(projectId);
185
+ return path.join(this.configDir, `intervention_state_${stateScope}.json`);
186
+ }
187
+
188
+ /**
189
+ * Derive stable state scope from a project identifier.
190
+ * Project coordinates use format: "<kind>:<authorPubkey>:<dTag>".
191
+ */
192
+ private getStateScope(projectId: string): string {
193
+ const parts = projectId.split(":");
194
+ const isProjectCoordinate = parts.length >= 3 && parts[0] === String(NDKKind.Project);
195
+ if (!isProjectCoordinate) {
196
+ return projectId;
197
+ }
198
+
199
+ const dTag = parts.slice(2).join(":");
200
+ return dTag || projectId;
201
+ }
202
+
203
+ /**
204
+ * Legacy path used before dTag-scoped filenames.
205
+ * Returns null when projectId already represents the state scope.
206
+ */
207
+ private getLegacyStateFilePath(projectId: string): string | null {
208
+ const stateScope = this.getStateScope(projectId);
209
+ if (stateScope === projectId) {
210
+ return null;
211
+ }
182
212
  return path.join(this.configDir, `intervention_state_${projectId}.json`);
183
213
  }
184
214
 
215
+ /**
216
+ * Read state file from canonical location, with fallback to legacy path.
217
+ */
218
+ private async readStateFile(projectId: string): Promise<{ data: string; loadedFromLegacyPath: boolean } | null> {
219
+ const stateFilePath = this.getStateFilePath(projectId);
220
+ try {
221
+ const data = await fs.readFile(stateFilePath, "utf-8");
222
+ return { data, loadedFromLegacyPath: false };
223
+ } catch (error: unknown) {
224
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ const legacyStateFilePath = this.getLegacyStateFilePath(projectId);
230
+ if (!legacyStateFilePath) {
231
+ return null;
232
+ }
233
+
234
+ try {
235
+ const data = await fs.readFile(legacyStateFilePath, "utf-8");
236
+ return { data, loadedFromLegacyPath: true };
237
+ } catch (error: unknown) {
238
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
239
+ return null;
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+
185
245
  /**
186
246
  * Get the singleton instance of InterventionService.
187
247
  */
@@ -996,8 +1056,6 @@ export class InterventionService {
996
1056
  * Load persisted state from disk for the given project.
997
1057
  */
998
1058
  private async loadState(projectId: string): Promise<void> {
999
- const stateFilePath = this.getStateFilePath(projectId);
1000
-
1001
1059
  // Cancel all active timers from the previous project before clearing state.
1002
1060
  // Without this, timers from a previous project continue running and fire
1003
1061
  // against the new project's data, causing duplicate notifications and
@@ -1014,7 +1072,15 @@ export class InterventionService {
1014
1072
  this.triggeringConversations.clear();
1015
1073
 
1016
1074
  try {
1017
- const data = await fs.readFile(stateFilePath, "utf-8");
1075
+ const stateFile = await this.readStateFile(projectId);
1076
+ if (!stateFile) {
1077
+ logger.debug("No existing intervention state file, starting fresh", {
1078
+ projectId: projectId.substring(0, 12),
1079
+ });
1080
+ return;
1081
+ }
1082
+
1083
+ const data = stateFile.data;
1018
1084
  const state = JSON.parse(data) as InterventionState;
1019
1085
 
1020
1086
  for (const pending of state.pending) {
@@ -1039,13 +1105,20 @@ export class InterventionService {
1039
1105
  projectId: projectId.substring(0, 12),
1040
1106
  pendingCount: this.pendingInterventions.size,
1041
1107
  notifiedCount: this.notifiedConversations.size,
1108
+ loadedFromLegacyPath: stateFile.loadedFromLegacyPath,
1042
1109
  });
1043
1110
 
1044
1111
  trace.getActiveSpan()?.addEvent("intervention.state_loaded", {
1045
1112
  "intervention.project_id": projectId.substring(0, 12),
1046
1113
  "intervention.pending_count": this.pendingInterventions.size,
1047
1114
  "intervention.notified_count": this.notifiedConversations.size,
1115
+ "intervention.loaded_from_legacy_path": stateFile.loadedFromLegacyPath,
1048
1116
  });
1117
+
1118
+ // Persist back using canonical (dTag-scoped) filename after legacy load.
1119
+ if (stateFile.loadedFromLegacyPath) {
1120
+ this.saveState();
1121
+ }
1049
1122
  } catch (error: unknown) {
1050
1123
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
1051
1124
  // No existing file, starting fresh - this is expected
@@ -89,7 +89,7 @@ export class Nip46SigningService {
89
89
  isEnabled(): boolean {
90
90
  try {
91
91
  const cfg = config.getConfig();
92
- return cfg.nip46?.enabled === true;
92
+ return cfg.nip46?.enabled !== false;
93
93
  } catch {
94
94
  return false;
95
95
  }
@@ -233,6 +233,19 @@ export class Nip46SigningService {
233
233
  durationMs,
234
234
  });
235
235
 
236
+ logger.writeToWarnLog({
237
+ timestamp: new Date().toISOString(),
238
+ level: "error",
239
+ component: "Nip46SigningService",
240
+ message: "NIP-46 signer connection failed",
241
+ context: {
242
+ ownerPubkey: ownerPubkey.substring(0, 12),
243
+ durationMs,
244
+ },
245
+ error: errorMsg,
246
+ stack: error instanceof Error ? error.stack : undefined,
247
+ });
248
+
236
249
  this.signingLog.log({
237
250
  op: "sign_error",
238
251
  requestId,
@@ -430,6 +443,22 @@ export class Nip46SigningService {
430
443
  durationMs,
431
444
  });
432
445
 
446
+ logger.writeToWarnLog({
447
+ timestamp: new Date().toISOString(),
448
+ level: "error",
449
+ component: "Nip46SigningService",
450
+ message: `NIP-46 signing failed after ${maxRetries} retries`,
451
+ context: {
452
+ ownerPubkey: ownerPubkey.substring(0, 12),
453
+ eventKind: event.kind,
454
+ retries: maxRetries,
455
+ durationMs,
456
+ isTimeout: errorMsg.includes("timed out"),
457
+ },
458
+ error: errorMsg,
459
+ stack: lastError?.stack,
460
+ });
461
+
433
462
  return { outcome: "failed", reason: errorMsg };
434
463
  }
435
464
 
@@ -101,9 +101,10 @@ export function resolveProjectManager(
101
101
  }
102
102
  }
103
103
 
104
- throw new Error(
105
- `Project Manager agent not found. PM agent (eventId: ${pmEventId}) not loaded in registry.`
106
- );
104
+ logger.warn("PM agent designated in project tags not loaded in registry yet, falling through", {
105
+ pmEventId,
106
+ loadedEventIds: Array.from(agents.values()).map(a => a.eventId),
107
+ });
107
108
  }
108
109
 
109
110
  // Step 5: Fallback to first agent from project tags
@@ -121,9 +122,10 @@ export function resolveProjectManager(
121
122
  }
122
123
  }
123
124
 
124
- throw new Error(
125
- `Project Manager agent not found. First agent (eventId: ${pmEventId}) not loaded in registry.`
126
- );
125
+ logger.warn("First agent from project tags not loaded in registry yet, falling through", {
126
+ pmEventId,
127
+ loadedEventIds: Array.from(agents.values()).map(a => a.eventId),
128
+ });
127
129
  }
128
130
 
129
131
  // Step 6: No agent tags in project, use first from registry if any exist