@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,294 +1,1626 @@
1
+ import { execFile, spawn } from "node:child_process";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { ensureDirectory } from "@/lib/fs";
5
+ import { agentStorage } from "@/agents/AgentStorage";
6
+ import { installAgentFromNostrEvent } from "@/agents/agent-installer";
7
+ import { detectOpenClawStateDir, readOpenClawCredentials, readOpenClawAgents } from "@/commands/agent/import/openclaw-reader";
8
+ import { NDKAgentDefinition } from "@/events/NDKAgentDefinition";
9
+ import { LLMConfigEditor } from "@/llm/LLMConfigEditor";
10
+ import { ensureCacheLoaded, getModelInfo } from "@/llm/utils/models-dev-cache";
11
+ import { PROVIDER_IDS } from "@/llm/providers/provider-ids";
12
+ import { runProviderSetup } from "@/llm/utils/provider-setup";
13
+ import type { AnyLLMConfiguration, TenexLLMs, TenexProviders } from "@/services/config/types";
14
+ import { isMetaModelConfiguration } from "@/services/config/types";
4
15
  import { config } from "@/services/ConfigService";
5
- import { logger } from "@/utils/logger";
6
- import NDK, { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
16
+ import { type EmbeddingConfig, EmbeddingProviderFactory } from "@/services/rag/EmbeddingProviderFactory";
17
+ import { ImageGenerationService, OPENROUTER_IMAGE_MODELS, ASPECT_RATIOS, IMAGE_SIZES, type ImageConfig } from "@/services/image/ImageGenerationService";
18
+ import { inquirerTheme } from "@/utils/cli-theme";
19
+ import * as display from "./display";
20
+ import { createPrompt, useState, useKeypress, usePrefix, makeTheme, isUpKey, isDownKey, isEnterKey, isBackspaceKey } from "@inquirer/core";
21
+ import { cursorHide } from "@inquirer/ansi";
22
+ import NDK, {
23
+ NDKEvent,
24
+ NDKPrivateKeySigner,
25
+ NDKProject,
26
+ NDKRelayAuthPolicies,
27
+ type NDKSubscription,
28
+ } from "@nostr-dev-kit/ndk";
29
+ import chalk from "chalk";
7
30
  import { Command } from "commander";
8
31
  import inquirer from "inquirer";
32
+ import { nip19 } from "nostr-tools";
9
33
 
10
- export const onboardingCommand = new Command("init")
11
- .description("Initial setup wizard for TENEX")
12
- .action(async () => {
13
- try {
14
- console.log("\nWelcome to TENEX! Let's get you set up.\n");
15
-
16
- // Load existing configuration
17
- const globalPath = config.getGlobalPath();
18
- await ensureDirectory(globalPath);
19
- const existingConfig = await config.loadTenexConfig(globalPath);
20
-
21
- // Step 1: Manage whitelisted pubkeys
22
- let whitelistedPubkeys = [...(existingConfig.whitelistedPubkeys || [])];
23
-
24
- // Create temporary NDK instance for fetching users
25
- const tempNdk = new NDK({
26
- explicitRelayUrls: [
27
- "wss://relay.damus.io",
28
- "wss://nos.lol",
29
- "wss://relay.nostr.band",
30
- ],
31
- });
32
- await tempNdk.connect();
33
-
34
- let managingPubkeys = true;
35
- while (managingPubkeys) {
36
- // If no pubkeys, go directly to adding one
37
- if (whitelistedPubkeys.length === 0) {
38
- const { userIdentifier } = await inquirer.prompt([
39
- {
40
- type: "input",
41
- name: "userIdentifier",
42
- message: "Enter npub, nprofile, or NIP-05 identifier to whitelist:",
43
- validate: (input: string) => {
44
- if (!input || input.trim().length === 0) {
45
- return "Please enter a valid identifier";
46
- }
47
- return true;
48
- },
49
- },
50
- ]);
51
-
52
- try {
53
- const user = await tempNdk.getUser({ npub: userIdentifier.trim() });
54
- if (!user?.pubkey) {
55
- console.log("❌ Failed to fetch user. Please try again.\n");
56
- } else {
57
- whitelistedPubkeys.push(user.pubkey);
58
- console.log(`✓ Added pubkey: ${user.pubkey}\n`);
59
- }
60
- } catch {
61
- console.log(
62
- "❌ Failed to fetch user. Please verify the identifier is correct.\n"
63
- );
34
+ type RelayItem =
35
+ | { type: "choice"; name: string; value: string; description?: string }
36
+ | { type: "input" };
37
+
38
+ const relayPrompt = createPrompt<string, {
39
+ message: string;
40
+ items: RelayItem[];
41
+ inputPrefix?: string;
42
+ inputPlaceholder?: string;
43
+ validate?: (url: string) => true | string;
44
+ }>((config, done) => {
45
+ const { items, inputPrefix = "wss://", inputPlaceholder = "Type a relay URL", validate } = config;
46
+ const theme = makeTheme(inquirerTheme);
47
+ const [active, setActive] = useState(0);
48
+ const [inputValue, setInputValue] = useState("");
49
+ const [status, setStatus] = useState<"idle" | "done">("idle");
50
+ const [error, setError] = useState<string | undefined>();
51
+ const prefix = usePrefix({ status, theme });
52
+
53
+ useKeypress((key, rl) => {
54
+ rl.clearLine(0);
55
+
56
+ if (isEnterKey(key)) {
57
+ const item = items[active];
58
+ if (item.type === "input") {
59
+ const fullUrl = inputPrefix + inputValue;
60
+ if (validate) {
61
+ const result = validate(fullUrl);
62
+ if (result !== true) {
63
+ setError(result);
64
+ return;
64
65
  }
66
+ }
67
+ setStatus("done");
68
+ done(fullUrl);
69
+ } else {
70
+ setStatus("done");
71
+ done(item.value);
72
+ }
73
+ } else if (isUpKey(key) || isDownKey(key)) {
74
+ setError(undefined);
75
+ const offset = isUpKey(key) ? -1 : 1;
76
+ let next = active + offset;
77
+ if (next < 0) next = 0;
78
+ if (next >= items.length) next = items.length - 1;
79
+ setActive(next);
80
+ } else if (items[active].type === "input") {
81
+ setError(undefined);
82
+ if (isBackspaceKey(key)) {
83
+ setInputValue(inputValue.slice(0, -1));
84
+ } else {
85
+ const ch = (key as unknown as { sequence?: string }).sequence;
86
+ if (ch && !key.ctrl && ch.length === 1 && ch.charCodeAt(0) >= 32) {
87
+ setInputValue(inputValue + ch);
88
+ }
89
+ }
90
+ }
91
+ });
92
+
93
+ const message = theme.style.message(config.message, status);
94
+
95
+ if (status === "done") {
96
+ const item = items[active];
97
+ const answer = item.type === "input" ? inputPrefix + inputValue : item.name;
98
+ return `${prefix} ${message} ${theme.style.answer(answer)}`;
99
+ }
100
+
101
+ const lines = items.map((item, i) => {
102
+ const isActive = i === active;
103
+ const cursor = isActive ? theme.icon.cursor : " ";
104
+
105
+ if (item.type === "input") {
106
+ const label = `${cursor} ${inputPlaceholder}`;
107
+ const typedUrl = inputPrefix + inputValue;
108
+ const desc = isActive ? ` ${chalk.gray(typedUrl)}` : "";
109
+ return isActive ? theme.style.highlight(label) + desc : label;
110
+ }
111
+
112
+ const label = `${cursor} ${item.name}`;
113
+ const desc = item.description ? ` ${chalk.gray(item.description)}` : "";
114
+ return isActive ? theme.style.highlight(label) + desc : label + desc;
115
+ });
116
+
117
+ const errorLine = error ? "\n" + chalk.red(error) : "";
118
+ return `${prefix} ${message}\n${lines.join("\n")}${errorLine}`;
119
+ });
120
+
121
+ function decodeToPubkey(identifier: string): string {
122
+ if (/^[a-f0-9]{64}$/i.test(identifier)) {
123
+ return identifier;
124
+ }
125
+ const decoded = nip19.decode(identifier);
126
+ switch (decoded.type) {
127
+ case "npub":
128
+ return decoded.data;
129
+ case "nprofile":
130
+ return decoded.data.pubkey;
131
+ default:
132
+ throw new Error(`Unsupported identifier type: ${decoded.type}`);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Roles that can be assigned to specific LLM configurations.
138
+ * Each role falls back to the "default" configuration when not explicitly set.
139
+ */
140
+ type LLMRoleKey = "default" | "summarization" | "supervision" | "search" | "promptCompilation" | "compression";
141
+
142
+ const MODEL_ROLES: Array<{ key: LLMRoleKey; label: string; recommendation: string }> = [
143
+ { key: "default", label: "Default", recommendation: "The default model all agents get — pick your best all-rounder" },
144
+ { key: "summarization", label: "Summarization", recommendation: "Used for conversation metadata (summaries, titles) — choose a cheap model with a large context window" },
145
+ { key: "supervision", label: "Supervision", recommendation: "Evaluates agent work and decides next steps — choose a model with strong reasoning" },
146
+ { key: "search", label: "Search", recommendation: "Powers search queries — choose a web-connected model like Perplexity Sonar, or leave as default" },
147
+ { key: "promptCompilation", label: "Prompt Compilation", recommendation: "Distills lessons into system prompts — choose a smart model with a large context window" },
148
+ { key: "compression", label: "Compression", recommendation: "Compresses conversation history to fit context — choose a cheap model with a large context window" },
149
+ ];
150
+
151
+ /**
152
+ * Score and auto-select the best config for each role based on models.dev metadata.
153
+ * Skips meta model configs (no single model to look up).
154
+ * Falls back to defaultConfig for any role it can't score.
155
+ */
156
+ function autoSelectRoles(
157
+ llmsConfig: TenexLLMs,
158
+ configNames: string[],
159
+ ): void {
160
+ // Build scored config list: { name, cost, context }
161
+ interface ScoredConfig {
162
+ name: string;
163
+ inputCost: number;
164
+ contextWindow: number;
165
+ }
166
+
167
+ const scored: ScoredConfig[] = [];
168
+ for (const name of configNames) {
169
+ const cfg = llmsConfig.configurations[name] as AnyLLMConfiguration;
170
+ if (isMetaModelConfiguration(cfg)) continue;
171
+
172
+ const info = getModelInfo(cfg.provider, cfg.model);
173
+ if (!info?.cost || !info?.limit?.context) continue;
174
+
175
+ scored.push({
176
+ name,
177
+ inputCost: info.cost.input,
178
+ contextWindow: info.limit.context,
179
+ });
180
+ }
181
+
182
+ if (scored.length === 0) return;
183
+
184
+ // Helper: find the config that minimizes inputCost among those with context >= threshold
185
+ const cheapestWithContext = (minContext: number): string | undefined => {
186
+ const eligible = scored.filter((c) => c.contextWindow >= minContext);
187
+ if (eligible.length === 0) return undefined;
188
+ eligible.sort((a, b) => a.inputCost - b.inputCost);
189
+ return eligible[0].name;
190
+ };
191
+
192
+ // Helper: find the most expensive config (proxy for strongest reasoning)
193
+ const mostExpensive = (minContext?: number): string | undefined => {
194
+ const eligible = minContext ? scored.filter((c) => c.contextWindow >= minContext) : scored;
195
+ if (eligible.length === 0) return undefined;
196
+ eligible.sort((a, b) => b.inputCost - a.inputCost);
197
+ return eligible[0].name;
198
+ };
199
+
200
+ // Summarization: cheap + large context (>= 100K)
201
+ const summarization = cheapestWithContext(100_000);
202
+ if (summarization) llmsConfig.summarization = summarization;
203
+
204
+ // Compression: cheapest with largest context window
205
+ const compression = cheapestWithContext(0);
206
+ if (compression) llmsConfig.compression = compression;
207
+
208
+ // Supervision: most expensive (strongest reasoning)
209
+ const supervision = mostExpensive();
210
+ if (supervision) llmsConfig.supervision = supervision;
211
+
212
+ // Prompt Compilation: most expensive with large context (>= 100K)
213
+ const promptCompilation = mostExpensive(100_000);
214
+ if (promptCompilation) llmsConfig.promptCompilation = promptCompilation;
215
+
216
+ // Search: prefer models with "sonar" in the model ID (Perplexity via OpenRouter)
217
+ const sonarConfig = configNames.find((name) => {
218
+ const cfg = llmsConfig.configurations[name] as AnyLLMConfiguration;
219
+ if (isMetaModelConfiguration(cfg)) return false;
220
+ return cfg.model.toLowerCase().includes("sonar");
221
+ });
222
+ if (sonarConfig) llmsConfig.search = sonarConfig;
223
+ }
224
+
225
+ /**
226
+ * Run the model role assignment step.
227
+ * If only one configuration exists, auto-assigns all roles.
228
+ * If multiple exist, auto-selects based on models.dev metadata then shows
229
+ * a rich two-line menu for manual overrides.
230
+ */
231
+ async function runRoleAssignment(): Promise<void> {
232
+ const globalPath = config.getGlobalPath();
233
+ const llmsConfig = await config.loadTenexLLMs(globalPath);
234
+ const configNames = Object.keys(llmsConfig.configurations);
235
+
236
+ if (configNames.length === 0) {
237
+ display.hint("No model configurations found. Skipping role assignment.");
238
+ display.context("Run tenex setup llm to configure models first.");
239
+ return;
240
+ }
241
+
242
+ if (configNames.length === 1) {
243
+ const name = configNames[0];
244
+ llmsConfig.default = name;
245
+ await config.saveGlobalLLMs(llmsConfig);
246
+ display.success(`All roles assigned to "${name}"`);
247
+ return;
248
+ }
249
+
250
+ // Load models.dev metadata for auto-selection scoring
251
+ await ensureCacheLoaded();
252
+
253
+ const defaultConfig = llmsConfig.default || configNames[0];
254
+
255
+ // Ensure all roles start with the default config
256
+ for (const role of MODEL_ROLES) {
257
+ if (!llmsConfig[role.key]) {
258
+ llmsConfig[role.key] = defaultConfig;
259
+ }
260
+ }
261
+
262
+ // Auto-select roles using models.dev cost/context metadata
263
+ autoSelectRoles(llmsConfig, configNames);
264
+
265
+ // Multiple configurations — show role menu, enter to pick model
266
+ display.blank();
267
+
268
+ const labelWidth = Math.max(...MODEL_ROLES.map((r) => r.label.length));
269
+
270
+ // Build config choices with models.dev metadata once
271
+ const configChoices = configNames.map((name) => {
272
+ const cfg = llmsConfig.configurations[name] as AnyLLMConfiguration;
273
+ if (isMetaModelConfiguration(cfg)) {
274
+ const variantCount = Object.keys(cfg.variants).length;
275
+ return { name: `${name} ${chalk.dim(`(multi-modal, ${variantCount} variants)`)}`, value: name };
276
+ }
277
+ const info = getModelInfo(cfg.provider, cfg.model);
278
+ const parts: string[] = [];
279
+ if (info?.limit?.context) {
280
+ parts.push(`${Math.round(info.limit.context / 1000)}K ctx`);
281
+ }
282
+ if (info?.cost) {
283
+ parts.push(`$${info.cost.input}/M in`);
284
+ }
285
+ const meta = parts.length > 0 ? ` ${chalk.dim(parts.join(" · "))}` : "";
286
+ return { name: `${name}${meta}`, value: name };
287
+ });
288
+
289
+ const roleCount = MODEL_ROLES.length;
290
+ const doneIndex = roleCount;
291
+
292
+ type RoleMenuResult =
293
+ | { action: "edit"; roleKey: LLMRoleKey }
294
+ | { action: "done" };
295
+
296
+ const roleMenuPrompt = createPrompt<RoleMenuResult, {
297
+ message: string;
298
+ roles: typeof MODEL_ROLES;
299
+ assignments: Record<string, string>;
300
+ }>((promptConfig, done) => {
301
+ const theme = makeTheme(inquirerTheme);
302
+ const prefix = usePrefix({ status: "idle", theme });
303
+ const [active, setActive] = useState(0);
304
+ const itemCount = roleCount + 1; // roles + Done
305
+
306
+ useKeypress((key, rl) => {
307
+ rl.clearLine(0);
308
+ if (isUpKey(key)) {
309
+ setActive(Math.max(0, active - 1));
310
+ } else if (isDownKey(key)) {
311
+ setActive(Math.min(itemCount - 1, active + 1));
312
+ } else if (isEnterKey(key)) {
313
+ if (active < roleCount) {
314
+ const role = promptConfig.roles[active]!;
315
+ done({ action: "edit", roleKey: role.key });
65
316
  } else {
66
- // Show existing pubkeys with option to add new or continue
67
- const choices = [
68
- ...whitelistedPubkeys.map((pk, idx) => ({
69
- name: `${idx + 1}. ${pk}`,
70
- value: `remove:${pk}`,
71
- })),
72
- { name: "➕ Add new pubkey", value: "add" },
73
- { name: "✓ Continue", value: "done" },
74
- ];
75
-
76
- const { action } = await inquirer.prompt([
77
- {
78
- type: "select",
79
- name: "action",
80
- message: "Whitelisted pubkeys (select to remove, or add new):",
81
- choices,
82
- },
83
- ]);
84
-
85
- if (action === "done") {
86
- managingPubkeys = false;
87
- } else if (action === "add") {
88
- const { userIdentifier } = await inquirer.prompt([
89
- {
90
- type: "input",
91
- name: "userIdentifier",
92
- message: "Enter npub, nprofile, or NIP-05 identifier:",
93
- validate: (input: string) => {
94
- if (!input || input.trim().length === 0) {
95
- return "Please enter a valid identifier";
96
- }
97
- return true;
98
- },
99
- },
100
- ]);
317
+ done({ action: "done" });
318
+ }
319
+ }
320
+ });
321
+
322
+ const cursor = chalk.hex("#FFC107")("›");
323
+ const lines: string[] = [];
324
+ lines.push(`${prefix} ${theme.style.message(promptConfig.message, "idle")}`);
325
+ lines.push("");
326
+
327
+ for (let i = 0; i < roleCount; i++) {
328
+ const role = promptConfig.roles[i]!;
329
+ const assigned = promptConfig.assignments[role.key] || defaultConfig;
330
+ const isActive = i === active;
331
+ const pfx = isActive ? `${cursor} ` : " ";
332
+ const label = role.label.padEnd(labelWidth);
333
+ const hint = isActive
334
+ ? chalk.hex("#FFC107").dim(role.recommendation)
335
+ : chalk.ansi256(240)(role.recommendation);
336
+ lines.push(`${pfx}${chalk.bold(label)} ${chalk.dim(assigned)}`);
337
+ lines.push(` ${hint}`);
338
+ }
339
+
340
+ lines.push(` ${"─".repeat(40)}`);
341
+ const donePfx = active === doneIndex ? `${cursor} ` : " ";
342
+ lines.push(`${donePfx}${display.doneLabel()}`);
343
+
344
+ const helpParts = [
345
+ `${chalk.bold("↑↓")} ${chalk.dim("navigate")}`,
346
+ `${chalk.bold("⏎")} ${chalk.dim("change")}`,
347
+ ];
348
+ lines.push(chalk.dim(` ${helpParts.join(chalk.dim(" • "))}`));
349
+
350
+ return `${lines.join("\n")}${cursorHide}`;
351
+ });
352
+
353
+ while (true) {
354
+ const assignments: Record<string, string> = {};
355
+ for (const role of MODEL_ROLES) {
356
+ assignments[role.key] = (llmsConfig[role.key] as string) || defaultConfig;
357
+ }
358
+
359
+ const result = await roleMenuPrompt({
360
+ message: "Model roles",
361
+ roles: MODEL_ROLES,
362
+ assignments,
363
+ });
364
+
365
+ if (result.action === "done") break;
366
+
367
+ const role = MODEL_ROLES.find((r) => r.key === result.roleKey)!;
368
+ const currentValue = assignments[result.roleKey]!;
369
+
370
+ const { config: picked } = await inquirer.prompt([{
371
+ type: "select",
372
+ name: "config",
373
+ message: `${role.label}:`,
374
+ choices: configChoices,
375
+ default: currentValue,
376
+ theme: inquirerTheme,
377
+ }]);
378
+
379
+ llmsConfig[result.roleKey] = picked;
380
+ }
381
+
382
+ await config.saveGlobalLLMs(llmsConfig);
383
+ display.success("Model roles saved");
384
+ }
385
+
386
+ /**
387
+ * Auto-select and confirm embedding model based on available providers.
388
+ * Priority: OpenAI → OpenRouter → Local Transformers
389
+ */
390
+ async function runEmbeddingSetup(providers: TenexProviders): Promise<void> {
391
+ const configuredProviders = Object.keys(providers.providers);
392
+ const existing = await EmbeddingProviderFactory.loadConfiguration({ scope: "global" });
393
+
394
+ // Auto-pick the best default
395
+ let defaultProvider: string;
396
+ let defaultModel: string;
397
+ if (configuredProviders.includes(PROVIDER_IDS.OPENAI)) {
398
+ defaultProvider = PROVIDER_IDS.OPENAI;
399
+ defaultModel = "text-embedding-3-small";
400
+ } else if (configuredProviders.includes(PROVIDER_IDS.OPENROUTER)) {
401
+ defaultProvider = PROVIDER_IDS.OPENROUTER;
402
+ defaultModel = "openai/text-embedding-3-small";
403
+ } else {
404
+ defaultProvider = "local";
405
+ defaultModel = "Xenova/all-MiniLM-L6-v2";
406
+ }
407
+
408
+ // Use existing config if present, otherwise use auto-picked default
409
+ const provider = existing?.provider || defaultProvider;
410
+ const model = existing?.model || defaultModel;
411
+
412
+ const providerLabel = provider === "local" ? "Local Transformers"
413
+ : provider === PROVIDER_IDS.OPENAI ? "OpenAI"
414
+ : provider === PROVIDER_IDS.OPENROUTER ? "OpenRouter"
415
+ : provider;
416
+
417
+ display.context(`Recommended: ${providerLabel} / ${model}`);
418
+ display.blank();
419
+
420
+ const { action } = await inquirer.prompt([{
421
+ type: "select",
422
+ name: "action",
423
+ message: "Embedding model",
424
+ choices: [
425
+ { name: `Use ${providerLabel} / ${model}`, value: "accept" },
426
+ { name: "Choose a different model", value: "change" },
427
+ ],
428
+ theme: inquirerTheme,
429
+ }]);
430
+
431
+ if (action === "accept") {
432
+ await EmbeddingProviderFactory.saveConfiguration({ provider, model }, "global");
433
+ display.success(`Embeddings: ${providerLabel} / ${model}`);
434
+ return;
435
+ }
436
+
437
+ // Full provider + model selection (reuse logic from embed.ts)
438
+ const providerChoices: Array<{ name: string; value: string }> = [
439
+ { name: "Local Transformers (runs on your machine)", value: "local" },
440
+ ];
441
+ if (configuredProviders.includes(PROVIDER_IDS.OPENAI)) {
442
+ providerChoices.push({ name: "OpenAI", value: PROVIDER_IDS.OPENAI });
443
+ }
444
+ if (configuredProviders.includes(PROVIDER_IDS.OPENROUTER)) {
445
+ providerChoices.push({ name: "OpenRouter", value: PROVIDER_IDS.OPENROUTER });
446
+ }
447
+
448
+ const { chosenProvider } = await inquirer.prompt([{
449
+ type: "select",
450
+ name: "chosenProvider",
451
+ message: "Embedding provider",
452
+ choices: providerChoices,
453
+ default: provider,
454
+ theme: inquirerTheme,
455
+ }]);
456
+
457
+ let chosenModel: string;
458
+ if (chosenProvider === "local") {
459
+ const { localModel } = await inquirer.prompt([{
460
+ type: "select",
461
+ name: "localModel",
462
+ message: "Local embedding model",
463
+ choices: [
464
+ { name: "all-MiniLM-L6-v2 (fast, good for general use)", value: "Xenova/all-MiniLM-L6-v2" },
465
+ { name: "all-mpnet-base-v2 (larger, better quality)", value: "Xenova/all-mpnet-base-v2" },
466
+ { name: "paraphrase-multilingual-MiniLM-L12-v2 (multilingual)", value: "Xenova/paraphrase-multilingual-MiniLM-L12-v2" },
467
+ ],
468
+ default: "Xenova/all-MiniLM-L6-v2",
469
+ theme: inquirerTheme,
470
+ }]);
471
+ chosenModel = localModel;
472
+ } else {
473
+ const models = chosenProvider === PROVIDER_IDS.OPENAI
474
+ ? [
475
+ { name: "text-embedding-3-small (fast, good quality)", value: "text-embedding-3-small" },
476
+ { name: "text-embedding-3-large (slower, best quality)", value: "text-embedding-3-large" },
477
+ ]
478
+ : [
479
+ { name: "openai/text-embedding-3-small", value: "openai/text-embedding-3-small" },
480
+ { name: "openai/text-embedding-3-large", value: "openai/text-embedding-3-large" },
481
+ ];
482
+ const { apiModel } = await inquirer.prompt([{
483
+ type: "select",
484
+ name: "apiModel",
485
+ message: "Embedding model",
486
+ choices: models,
487
+ theme: inquirerTheme,
488
+ }]);
489
+ chosenModel = apiModel;
490
+ }
491
+
492
+ const embeddingConfig: EmbeddingConfig = { provider: chosenProvider, model: chosenModel };
493
+ await EmbeddingProviderFactory.saveConfiguration(embeddingConfig, "global");
494
+ display.success(`Embeddings: ${chosenProvider} / ${chosenModel}`);
495
+ }
496
+
497
+ /**
498
+ * Auto-select and confirm image generation model.
499
+ * Only available when OpenRouter is configured.
500
+ */
501
+ async function runImageGenSetup(providers: TenexProviders): Promise<void> {
502
+ if (!providers.providers[PROVIDER_IDS.OPENROUTER]?.apiKey) {
503
+ display.hint("Image generation requires OpenRouter. Skipping.");
504
+ display.context("Run tenex setup providers to add OpenRouter, then tenex setup image.");
505
+ return;
506
+ }
507
+
508
+ const existing = await ImageGenerationService.loadConfiguration({ scope: "global" });
509
+ const defaultModel = existing?.model || "black-forest-labs/flux.2-pro";
510
+ const modelInfo = OPENROUTER_IMAGE_MODELS.find((m) => m.value === defaultModel);
511
+ const modelLabel = modelInfo ? modelInfo.name : defaultModel;
512
+
513
+ display.context(`Recommended: ${modelLabel}`);
514
+ display.blank();
515
+
516
+ const { action } = await inquirer.prompt([{
517
+ type: "select",
518
+ name: "action",
519
+ message: "Image generation model",
520
+ choices: [
521
+ { name: `Use ${modelLabel} (${defaultModel})`, value: "accept" },
522
+ { name: "Choose a different model", value: "change" },
523
+ { name: "Skip image generation", value: "skip" },
524
+ ],
525
+ theme: inquirerTheme,
526
+ }]);
527
+
528
+ if (action === "skip") {
529
+ display.hint("Skipped. Run tenex setup image later to configure.");
530
+ return;
531
+ }
532
+
533
+ let selectedModel = defaultModel;
534
+ let selectedRatio = existing?.defaultAspectRatio || "1:1";
535
+ let selectedSize = existing?.defaultImageSize || "2K";
536
+
537
+ if (action === "change") {
538
+ const modelChoices = OPENROUTER_IMAGE_MODELS.map((m) => ({
539
+ name: `${m.name} — ${m.description}`,
540
+ value: m.value,
541
+ }));
542
+
543
+ const { model } = await inquirer.prompt([{
544
+ type: "select",
545
+ name: "model",
546
+ message: "Image generation model",
547
+ choices: modelChoices,
548
+ default: defaultModel,
549
+ theme: inquirerTheme,
550
+ }]);
551
+ selectedModel = model;
552
+
553
+ const { aspectRatio } = await inquirer.prompt([{
554
+ type: "select",
555
+ name: "aspectRatio",
556
+ message: "Default aspect ratio",
557
+ choices: ASPECT_RATIOS.map((r) => ({ name: r, value: r })),
558
+ default: selectedRatio,
559
+ theme: inquirerTheme,
560
+ }]);
561
+ selectedRatio = aspectRatio;
562
+
563
+ const { imageSize } = await inquirer.prompt([{
564
+ type: "select",
565
+ name: "imageSize",
566
+ message: "Default image size",
567
+ choices: IMAGE_SIZES.map((s) => ({ name: s, value: s })),
568
+ default: selectedSize,
569
+ theme: inquirerTheme,
570
+ }]);
571
+ selectedSize = imageSize;
572
+ }
573
+
574
+ const imageConfig: ImageConfig = {
575
+ provider: "openrouter",
576
+ model: selectedModel,
577
+ defaultAspectRatio: selectedRatio,
578
+ defaultImageSize: selectedSize,
579
+ };
580
+ await ImageGenerationService.saveConfiguration(imageConfig, "global");
581
+
582
+ const savedModelInfo = OPENROUTER_IMAGE_MODELS.find((m) => m.value === selectedModel);
583
+ display.success(`Image generation: ${savedModelInfo?.name || selectedModel}`);
584
+ }
585
+
586
+ // ─── LLM Config Seeding ──────────────────────────────────────────────────────
587
+
588
+ /**
589
+ * Seed default LLM configurations based on which providers are available.
590
+ * Only runs when there are zero existing configurations.
591
+ *
592
+ * Priority: Anthropic if present, then OpenAI.
593
+ * Creates a meta-model "Auto" config when Anthropic is available.
594
+ */
595
+ async function seedDefaultLLMConfigs(providers: TenexProviders): Promise<void> {
596
+ const globalPath = config.getGlobalPath();
597
+ const llmsConfig = await config.loadTenexLLMs(globalPath);
598
+
599
+ if (Object.keys(llmsConfig.configurations).length > 0) return;
600
+
601
+ const connected = Object.keys(providers.providers);
602
+ const hasAnthropic = connected.includes(PROVIDER_IDS.ANTHROPIC);
603
+
604
+ if (hasAnthropic) {
605
+ llmsConfig.configurations["Sonnet"] = {
606
+ provider: PROVIDER_IDS.ANTHROPIC,
607
+ model: "claude-sonnet-4-6",
608
+ };
609
+ llmsConfig.configurations["Opus"] = {
610
+ provider: PROVIDER_IDS.ANTHROPIC,
611
+ model: "claude-opus-4-6",
612
+ };
613
+ llmsConfig.configurations["Auto"] = {
614
+ provider: "meta",
615
+ variants: {
616
+ fast: {
617
+ model: "Sonnet",
618
+ keywords: ["quick", "fast"],
619
+ description: "Fast, lightweight tasks",
620
+ },
621
+ powerful: {
622
+ model: "Opus",
623
+ keywords: ["think", "ultrathink", "ponder"],
624
+ description: "Most capable, complex reasoning",
625
+ },
626
+ },
627
+ default: "fast",
628
+ };
629
+ llmsConfig.default = "Auto";
630
+ }
631
+
632
+ if (connected.includes(PROVIDER_IDS.OPENAI)) {
633
+ llmsConfig.configurations["GPT-4o"] = {
634
+ provider: PROVIDER_IDS.OPENAI,
635
+ model: "gpt-4o",
636
+ };
637
+ if (!llmsConfig.default) {
638
+ llmsConfig.default = "GPT-4o";
639
+ }
640
+ }
641
+
642
+ if (Object.keys(llmsConfig.configurations).length > 0) {
643
+ await config.saveGlobalLLMs(llmsConfig);
644
+ for (const [name, cfg] of Object.entries(llmsConfig.configurations)) {
645
+ const detail = cfg.provider === "meta" ? "meta-model" : `${cfg.provider}/${(cfg as { model: string }).model}`;
646
+ display.success(`Seeded: ${name} (${detail})`);
647
+ }
648
+ }
649
+ }
650
+
651
+ // ─── Provider Auto-Detection ─────────────────────────────────────────────────
652
+
653
+ /**
654
+ * Check if a command exists on the system.
655
+ */
656
+ function commandExists(cmd: string): Promise<boolean> {
657
+ return new Promise((resolve) => {
658
+ execFile("/bin/sh", ["-c", `command -v ${cmd}`], (err) => {
659
+ resolve(!err);
660
+ });
661
+ });
662
+ }
663
+
664
+ /**
665
+ * Check if Ollama is reachable at localhost:11434.
666
+ */
667
+ async function ollamaReachable(): Promise<boolean> {
668
+ try {
669
+ const response = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
670
+ return response.ok;
671
+ } catch {
672
+ return false;
673
+ }
674
+ }
675
+
676
+ interface DetectionResult {
677
+ providers: TenexProviders;
678
+ openClawStateDir: string | null;
679
+ detectedSources: string[];
680
+ claudeCliDetected: boolean;
681
+ }
682
+
683
+ /**
684
+ * Auto-detect provider credentials from environment variables, local commands,
685
+ * Ollama, and OpenClaw installations. Merges into existing providers.
686
+ * Pass a pre-detected openClawStateDir to avoid redundant filesystem checks.
687
+ */
688
+ async function autoDetectProviders(existing: TenexProviders, preDetectedOpenClawDir?: string | null): Promise<DetectionResult> {
689
+ const providers = { ...existing, providers: { ...existing.providers } };
690
+ const detectedSources: string[] = [];
691
+
692
+ // 1. Detect local CLI commands
693
+ const [hasClaude, hasCodex] = await Promise.all([
694
+ commandExists("claude"),
695
+ commandExists("codex"),
696
+ ]);
697
+
698
+ if (hasCodex && !providers.providers[PROVIDER_IDS.CODEX_APP_SERVER]) {
699
+ providers.providers[PROVIDER_IDS.CODEX_APP_SERVER] = { apiKey: "none" };
700
+ detectedSources.push("Codex CLI (codex-app-server)");
701
+ }
702
+
703
+ // 2. Detect Ollama
704
+ if (!providers.providers[PROVIDER_IDS.OLLAMA]) {
705
+ if (await ollamaReachable()) {
706
+ providers.providers[PROVIDER_IDS.OLLAMA] = { apiKey: "http://localhost:11434" };
707
+ detectedSources.push("Ollama (localhost:11434)");
708
+ }
709
+ }
710
+
711
+ // 3. Environment variable API keys
712
+ const envMap: Array<{ envVar: string; providerId: string; label: string }> = [
713
+ { envVar: "ANTHROPIC_API_KEY", providerId: PROVIDER_IDS.ANTHROPIC, label: "Anthropic (from ANTHROPIC_API_KEY)" },
714
+ { envVar: "OPENAI_API_KEY", providerId: PROVIDER_IDS.OPENAI, label: "OpenAI (from OPENAI_API_KEY)" },
715
+ { envVar: "OPENROUTER_API_KEY", providerId: PROVIDER_IDS.OPENROUTER, label: "OpenRouter (from OPENROUTER_API_KEY)" },
716
+ ];
717
+ for (const { envVar, providerId, label } of envMap) {
718
+ const value = process.env[envVar];
719
+ if (value && !providers.providers[providerId]) {
720
+ providers.providers[providerId] = { apiKey: value };
721
+ detectedSources.push(label);
722
+ }
723
+ }
724
+
725
+ // 4. Anthropic OAuth setup-token
726
+ const authToken = process.env.ANTHROPIC_AUTH_TOKEN;
727
+ if (authToken?.startsWith("sk-ant-oat") && !providers.providers[PROVIDER_IDS.ANTHROPIC]) {
728
+ providers.providers[PROVIDER_IDS.ANTHROPIC] = { apiKey: authToken };
729
+ detectedSources.push("Anthropic (from ANTHROPIC_AUTH_TOKEN)");
730
+ }
731
+
732
+ // 5. OpenClaw credentials
733
+ const openClawStateDir = preDetectedOpenClawDir !== undefined
734
+ ? preDetectedOpenClawDir
735
+ : await detectOpenClawStateDir();
736
+ if (openClawStateDir) {
737
+ const credentials = await readOpenClawCredentials(openClawStateDir);
738
+ for (const cred of credentials) {
739
+ if (!providers.providers[cred.provider]) {
740
+ providers.providers[cred.provider] = { apiKey: cred.apiKey };
741
+ detectedSources.push(`${cred.provider} (from OpenClaw)`);
742
+ }
743
+ }
744
+ }
745
+
746
+ return { providers, openClawStateDir, detectedSources, claudeCliDetected: hasClaude };
747
+ }
748
+
749
+ function buildProviderHints(detection: DetectionResult): Record<string, string> {
750
+ const hints: Record<string, string> = {};
751
+ if (detection.claudeCliDetected && !detection.providers.providers[PROVIDER_IDS.ANTHROPIC]) {
752
+ hints[PROVIDER_IDS.ANTHROPIC] = "via claude setup-token";
753
+ }
754
+ return hints;
755
+ }
756
+
757
+ // ─── Nostr Agent Discovery Types ─────────────────────────────────────────────
758
+
759
+ interface FetchedTeam {
760
+ id: string;
761
+ title: string;
762
+ description: string;
763
+ agentEventIds: string[];
764
+ }
765
+
766
+ interface FetchedAgent {
767
+ id: string;
768
+ name: string;
769
+ role: string;
770
+ description: string;
771
+ event: NDKEvent;
772
+ }
773
+
774
+ interface FetchResults {
775
+ teams: FetchedTeam[];
776
+ agents: FetchedAgent[];
777
+ }
778
+
779
+ function agentsForTeam(results: FetchResults, team: FetchedTeam): FetchedAgent[] {
780
+ const agentIndex = new Map(results.agents.map((a) => [a.id, a]));
781
+ return team.agentEventIds
782
+ .map((eid) => agentIndex.get(eid))
783
+ .filter((a): a is FetchedAgent => a !== undefined);
784
+ }
785
+
786
+ // ─── Streaming Agent Discovery ──────────────────────────────────────────────
787
+
788
+ interface AgentDiscovery {
789
+ ndk: NDK;
790
+ subscription: NDKSubscription;
791
+ events: Map<string, NDKEvent>;
792
+ initialSync: Promise<void>;
793
+ startedAtMs: number | null;
794
+ }
795
+
796
+ function startAgentDiscovery(relays: string[], signer?: NDKPrivateKeySigner): AgentDiscovery {
797
+ const ndk = new NDK({ explicitRelayUrls: relays, enableOutboxModel: false });
798
+
799
+ if (signer) {
800
+ ndk.signer = signer;
801
+ ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk, signer });
802
+ }
803
+
804
+ const events = new Map<string, NDKEvent>();
805
+ const TEAM_KIND = 34199;
806
+ let initialSyncResolved = false;
807
+ let resolveInitialSync: (() => void) | null = null;
808
+ const initialSync = new Promise<void>((resolve) => {
809
+ resolveInitialSync = resolve;
810
+ });
811
+
812
+ const markInitialSyncComplete = (): void => {
813
+ if (initialSyncResolved) return;
814
+ initialSyncResolved = true;
815
+ resolveInitialSync?.();
816
+ };
817
+
818
+ const subscription = ndk.subscribe(
819
+ { kinds: [...NDKAgentDefinition.kinds, TEAM_KIND] as number[] },
820
+ { closeOnEose: false },
821
+ {
822
+ onEvent: (event: NDKEvent) => { events.set(event.id, event); },
823
+ onEose: markInitialSyncComplete,
824
+ onClose: markInitialSyncComplete,
825
+ },
826
+ );
827
+
828
+ return { ndk, subscription, events, initialSync, startedAtMs: null };
829
+ }
830
+
831
+ function connectAgentDiscovery(discovery: AgentDiscovery): void {
832
+ discovery.startedAtMs = Date.now();
833
+ // Fire-and-forget — NDK handles reconnection and the subscription queues until connected.
834
+ // Swallow connection errors to avoid unhandled rejections in background setup flow.
835
+ void discovery.ndk.connect().catch(() => {});
836
+ }
837
+
838
+ async function waitForAgentDiscovery(discovery: AgentDiscovery, timeoutMs = 3_000): Promise<void> {
839
+ const startedAtMs = discovery.startedAtMs ?? Date.now();
840
+ const elapsedMs = Date.now() - startedAtMs;
841
+ const remainingMs = Math.max(0, timeoutMs - elapsedMs);
842
+ if (remainingMs === 0) return;
843
+
844
+ await Promise.race([
845
+ discovery.initialSync,
846
+ new Promise<void>((resolve) => setTimeout(resolve, remainingMs)),
847
+ ]);
848
+ }
101
849
 
850
+ // ─── Project & Agents Step ───────────────────────────────────────────────────
851
+
852
+ /**
853
+ * Stop the streaming subscription and resolve accumulated events into
854
+ * typed agents and teams with deduplication.
855
+ */
856
+ function resolveAgentDiscovery(discovery: AgentDiscovery): FetchResults {
857
+ discovery.subscription.stop();
858
+
859
+ const TEAM_KIND = 34199;
860
+ const teams: FetchedTeam[] = [];
861
+ const agents: FetchedAgent[] = [];
862
+
863
+ for (const event of discovery.events.values()) {
864
+ const kind = event.kind;
865
+
866
+ if (kind === TEAM_KIND) {
867
+ const title = event.tagValue("title") || "";
868
+ if (!title) continue;
869
+ const description = event.content || event.tagValue("description") || "";
870
+ const agentEventIds = event.tags
871
+ .filter((t: string[]) => t[0] === "e" && t[1])
872
+ .map((t: string[]) => t[1]);
873
+ teams.push({ id: event.id, title, description, agentEventIds });
874
+ } else if (kind !== undefined && NDKAgentDefinition.kinds.includes(kind)) {
875
+ const name = event.tagValue("title") || "Unnamed Agent";
876
+ const role = event.tagValue("role") || "";
877
+ const description = event.tagValue("description") || event.content || "";
878
+ agents.push({ id: event.id, name, role, description, event });
879
+ }
880
+ }
881
+
882
+ // Dedup teams by title (keep first)
883
+ const seenTeamTitles = new Set<string>();
884
+ const dedupedTeams = teams.filter((t) => {
885
+ if (seenTeamTitles.has(t.title)) return false;
886
+ seenTeamTitles.add(t.title);
887
+ return true;
888
+ });
889
+
890
+ // Dedup agents by pubkey+d-tag (keep newest)
891
+ const latestAgents = new Map<string, FetchedAgent>();
892
+ const noDtagAgents: FetchedAgent[] = [];
893
+ for (const agent of agents) {
894
+ const dTag = agent.event.tagValue("d") || "";
895
+ if (!dTag) {
896
+ noDtagAgents.push(agent);
897
+ continue;
898
+ }
899
+ const key = `${agent.event.pubkey}:${dTag}`;
900
+ const existing = latestAgents.get(key);
901
+ if (!existing || (agent.event.created_at || 0) > (existing.event.created_at || 0)) {
902
+ latestAgents.set(key, agent);
903
+ }
904
+ }
905
+ const dedupedAgents = [...Array.from(latestAgents.values()), ...noDtagAgents];
906
+
907
+ return { teams: dedupedTeams, agents: dedupedAgents };
908
+ }
909
+
910
+ /**
911
+ * Run the Project & Agents onboarding step.
912
+ *
913
+ * Replicates the Rust TUI's step_first_project_and_agents:
914
+ * 1. Import OpenClaw agents (if detected)
915
+ * 2. Ask about creating a Meta project
916
+ * 3. Discover/select Nostr teams and individual agents
917
+ * 4. Install selected agents locally (best-effort)
918
+ * 5. Publish kind 31933 project event with final ["agent", "<event-id>"] tags
919
+ */
920
+ async function runProjectAndAgentsStep(
921
+ discovery: AgentDiscovery,
922
+ userPrivateKeyHex: string,
923
+ openClawStateDir: string | null,
924
+ ): Promise<boolean> {
925
+ const { ndk } = discovery;
926
+ const discoveryReady = waitForAgentDiscovery(discovery);
927
+
928
+ // ── Part A: OpenClaw agents (if detected) ───────────────────────────────
929
+ let installedCount = 0;
930
+ const selectedNostrAgentEventIds = new Set<string>();
931
+ let openClawImportInFlight = false;
932
+ let openClawImportPromise: Promise<{
933
+ importedCount: number;
934
+ stdout: string;
935
+ stderr: string;
936
+ failed: boolean;
937
+ }> | null = null;
938
+
939
+ const waitForOpenClawImportIfNeeded = async (): Promise<void> => {
940
+ if (!openClawImportPromise) return;
941
+
942
+ if (openClawImportInFlight) {
943
+ display.context("Waiting for OpenClaw import to finish...");
944
+ display.blank();
945
+ }
946
+
947
+ const result = await openClawImportPromise;
948
+ openClawImportPromise = null;
949
+ openClawImportInFlight = false;
950
+
951
+ if (result.stdout) process.stdout.write(result.stdout);
952
+ if (result.stderr) process.stderr.write(result.stderr);
953
+
954
+ if (result.failed) {
955
+ display.context("OpenClaw import encountered an issue — check daemon logs.");
956
+ } else if (result.importedCount > 0) {
957
+ installedCount += result.importedCount;
958
+ display.success(`Imported ${result.importedCount} OpenClaw agent(s).`);
959
+ }
960
+
961
+ display.blank();
962
+ };
963
+
964
+ if (openClawStateDir) {
965
+ const openClawAgents = await readOpenClawAgents(openClawStateDir);
966
+
967
+ if (openClawAgents.length > 0) {
968
+ display.hint("Found your OpenClaw agents:");
969
+ display.blank();
970
+
971
+ const { selected } = await inquirer.prompt([{
972
+ type: "checkbox",
973
+ name: "selected",
974
+ message: "Import your OpenClaw agents? (space to toggle, enter to confirm)",
975
+ choices: openClawAgents.map((a) => ({
976
+ name: chalk.ansi256(214)(a.id),
977
+ value: a.id,
978
+ checked: true,
979
+ })),
980
+ theme: inquirerTheme,
981
+ }]);
982
+
983
+ if (selected.length > 0) {
984
+ display.context("Importing OpenClaw agents in background while setup continues...");
985
+ display.blank();
986
+
987
+ const slugsArg = (selected as string[]).join(",");
988
+ openClawImportInFlight = true;
989
+ openClawImportPromise = new Promise((resolve) => {
990
+ const selectedCount = selected.length;
991
+ const binPath = process.argv[1];
992
+ execFile(process.argv[0], [binPath, "agent", "import", "openclaw", "--slugs", slugsArg], (err, stdout, stderr) => {
993
+ resolve({
994
+ importedCount: err ? 0 : selectedCount,
995
+ stdout: stdout ?? "",
996
+ stderr: stderr ?? "",
997
+ failed: !!err,
998
+ });
999
+ });
1000
+ });
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ // ── Part B: Ask about Meta project ──────────────────────────────────────
1006
+ display.context(
1007
+ "Projects organize what your agents work on. We suggest starting with a\n" +
1008
+ "\"Meta\" project — a command center where agents track everything else.",
1009
+ );
1010
+ display.blank();
1011
+
1012
+ const { createMeta } = await inquirer.prompt([{
1013
+ type: "confirm",
1014
+ name: "createMeta",
1015
+ message: "Create a Meta project?",
1016
+ default: true,
1017
+ theme: inquirerTheme,
1018
+ }]);
1019
+
1020
+ if (!createMeta) {
1021
+ discovery.subscription.stop();
1022
+ await waitForOpenClawImportIfNeeded();
1023
+
1024
+ if (installedCount > 0) {
1025
+ display.blank();
1026
+ display.success(`${installedCount} agent(s) ready.`);
1027
+ }
1028
+ display.blank();
1029
+ display.context("Sure thing. You can create projects anytime from the dashboard.");
1030
+ return false;
1031
+ }
1032
+
1033
+ await discoveryReady;
1034
+ const fetchResults = resolveAgentDiscovery(discovery);
1035
+ const hasNostrAgents = fetchResults.agents.length > 0;
1036
+
1037
+ // ── Part C: Nostr agents (team + individual selection) ──────────────────
1038
+ display.blank();
1039
+ display.context("Pick a pre-built agent team or choose individual agents.");
1040
+ display.blank();
1041
+
1042
+ if (!hasNostrAgents) {
1043
+ display.context("No Nostr agents available right now.");
1044
+ display.hint("You can browse and hire agents later from the dashboard.");
1045
+ } else {
1046
+ const results = fetchResults;
1047
+
1048
+ while (true) {
1049
+ // Only show teams that still have unselected agents
1050
+ const availableTeams = results.teams.filter((team) =>
1051
+ agentsForTeam(results, team).some((a) => !selectedNostrAgentEventIds.has(a.id)),
1052
+ );
1053
+
1054
+ const hasRemainingAgents = results.agents.some(
1055
+ (a) => !selectedNostrAgentEventIds.has(a.id),
1056
+ );
1057
+
1058
+ // Nothing left to offer
1059
+ if (availableTeams.length === 0 && !hasRemainingAgents) break;
1060
+
1061
+ // Build menu choices
1062
+ const menuChoices: Array<{ name: string; value: string }> = [];
1063
+
1064
+ // Team entries
1065
+ for (const team of availableTeams) {
1066
+ const agentCount = agentsForTeam(results, team)
1067
+ .filter((a) => !selectedNostrAgentEventIds.has(a.id)).length;
1068
+ const label = team.description
1069
+ ? `${team.title} — ${team.description} (${agentCount} agents)`
1070
+ : `${team.title} (${agentCount} agents)`;
1071
+ menuChoices.push({ name: label, value: `team:${team.id}` });
1072
+ }
1073
+
1074
+ // "Add individual agents" entry
1075
+ if (hasRemainingAgents) {
1076
+ menuChoices.push({ name: "Add individual agents", value: "__individual__" });
1077
+ }
1078
+
1079
+ // "Done" entry
1080
+ menuChoices.push({ name: "Done", value: "__done__" });
1081
+
1082
+ const { selection } = await inquirer.prompt([{
1083
+ type: "select",
1084
+ name: "selection",
1085
+ message: "Add agents",
1086
+ choices: menuChoices,
1087
+ theme: inquirerTheme,
1088
+ }]);
1089
+
1090
+ if (selection === "__done__") break;
1091
+
1092
+ if (selection === "__individual__") {
1093
+ // Individual agent multi-select
1094
+ const remaining = results.agents.filter(
1095
+ (a) => !selectedNostrAgentEventIds.has(a.id),
1096
+ );
1097
+
1098
+ const { selected } = await inquirer.prompt([{
1099
+ type: "checkbox",
1100
+ name: "selected",
1101
+ message: "Select agents (space to toggle, enter to confirm)",
1102
+ choices: remaining.map((a) => {
1103
+ const label = a.role
1104
+ ? `${a.name.padEnd(20)} ${a.role} — ${a.description}`
1105
+ : `${a.name.padEnd(20)} ${a.description}`;
1106
+ return { name: label, value: a.id };
1107
+ }),
1108
+ theme: inquirerTheme,
1109
+ }]);
1110
+
1111
+ if ((selected as string[]).length > 0) {
1112
+ const selectedAgents = remaining.filter((a) => (selected as string[]).includes(a.id));
1113
+ for (const agent of selectedAgents) {
1114
+ selectedNostrAgentEventIds.add(agent.id);
1115
+ }
1116
+
1117
+ let installedNow = 0;
1118
+ for (const agent of selectedAgents) {
102
1119
  try {
103
- const user = await tempNdk.getUser({ npub: userIdentifier.trim() });
104
- if (!user?.pubkey) {
105
- console.log("❌ Failed to fetch user. Please try again.\n");
106
- } else if (whitelistedPubkeys.includes(user.pubkey)) {
107
- console.log("⚠️ Pubkey already in whitelist\n");
108
- } else {
109
- whitelistedPubkeys.push(user.pubkey);
110
- console.log(`✓ Added pubkey: ${user.pubkey}\n`);
111
- }
112
- } catch {
113
- console.log(
114
- "❌ Failed to fetch user. Please verify the identifier is correct.\n"
115
- );
1120
+ await installAgentFromNostrEvent(agent.event, undefined, ndk);
1121
+ installedNow++;
1122
+ installedCount++;
1123
+ } catch (err) {
1124
+ display.context(`Failed to install "${agent.name}": ${err instanceof Error ? err.message : String(err)}`);
116
1125
  }
117
- } else if (action.startsWith("remove:")) {
118
- const pubkeyToRemove = action.replace("remove:", "");
119
- whitelistedPubkeys = whitelistedPubkeys.filter(
120
- (pk) => pk !== pubkeyToRemove
121
- );
122
- console.log("✓ Removed pubkey\n");
1126
+ }
1127
+
1128
+ display.blank();
1129
+ const names = selectedAgents.map((a) => a.name).join(", ");
1130
+ display.success(`Added ${selectedAgents.length} agent tag(s): ${names}`);
1131
+ if (installedNow !== selectedAgents.length) {
1132
+ display.hint(`Installed ${installedNow}/${selectedAgents.length} locally. Remaining agents will load from project tags.`);
123
1133
  }
124
1134
  }
1135
+ continue;
125
1136
  }
126
1137
 
127
- // Disconnect temporary NDK
128
- if (tempNdk.pool?.relays) {
129
- for (const relay of tempNdk.pool.relays.values()) {
130
- relay.disconnect();
131
- }
1138
+ // Team selected
1139
+ const teamId = selection.replace("team:", "");
1140
+ const team = results.teams.find((t) => t.id === teamId);
1141
+ if (!team) continue;
1142
+
1143
+ const teamAgents = agentsForTeam(results, team)
1144
+ .filter((a) => !selectedNostrAgentEventIds.has(a.id));
1145
+
1146
+ if (teamAgents.length === 0) continue;
1147
+
1148
+ display.blank();
1149
+ display.hint(`Agents in ${team.title}:`);
1150
+ for (const a of teamAgents) {
1151
+ console.log(` ${chalk.ansi256(117)("●")} ${chalk.bold(a.name.padEnd(20))} ${chalk.dim(a.role)}`);
132
1152
  }
133
1153
 
134
- if (whitelistedPubkeys.length === 0) {
135
- logger.error("At least one whitelisted pubkey is required.");
136
- process.exit(1);
1154
+ for (const agent of teamAgents) {
1155
+ selectedNostrAgentEventIds.add(agent.id);
137
1156
  }
138
1157
 
139
- // Step 2: Generate or use existing private key for TENEX
140
- let tenexPrivateKey = existingConfig.tenexPrivateKey;
141
- if (!tenexPrivateKey) {
142
- const signer = NDKPrivateKeySigner.generate();
143
- tenexPrivateKey = signer.privateKey;
144
- if (!tenexPrivateKey) {
145
- logger.error("Failed to generate private key");
146
- process.exit(1);
1158
+ let installedNow = 0;
1159
+ for (const agent of teamAgents) {
1160
+ try {
1161
+ await installAgentFromNostrEvent(agent.event, undefined, ndk);
1162
+ installedNow++;
1163
+ installedCount++;
1164
+ } catch (err) {
1165
+ display.context(`Failed to install "${agent.name}": ${err instanceof Error ? err.message : String(err)}`);
147
1166
  }
148
1167
  }
149
1168
 
150
- // Step 3: Ask for projects base directory
151
- const defaultProjectsBase =
152
- existingConfig.projectsBase || path.join(os.homedir(), "tenex");
153
- const { projectsBase } = await inquirer.prompt([
1169
+ display.blank();
1170
+ const names = teamAgents.map((a) => a.name).join(", ");
1171
+ display.success(`Team "${team.title}" added (${teamAgents.length} agent tag(s)): ${names}`);
1172
+ if (installedNow !== teamAgents.length) {
1173
+ display.hint(`Installed ${installedNow}/${teamAgents.length} locally. Remaining agents will load from project tags.`);
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ // ── Part D: Publish kind 31933 project event ──────────────────────────
1179
+ // The daemon handles directory creation, git init, and agent loading on boot.
1180
+ // We just publish the event with agent tags — the daemon discovers it from relays.
1181
+ try {
1182
+ const signer = new NDKPrivateKeySigner(userPrivateKeyHex);
1183
+ ndk.signer = signer;
1184
+ ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk, signer });
1185
+
1186
+ const project = new NDKProject(ndk);
1187
+ project.dTag = "meta";
1188
+ project.title = "Meta";
1189
+ project.tags.push(["client", "tenex-setup"]);
1190
+
1191
+ for (const eid of selectedNostrAgentEventIds) {
1192
+ project.tags.push(["agent", eid]);
1193
+ }
1194
+
1195
+ await project.sign();
1196
+ await project.publish();
1197
+
1198
+ display.success("Published \"Meta\" project to relays.");
1199
+
1200
+ // Give relays a moment to propagate
1201
+ await new Promise((r) => setTimeout(r, 2_000));
1202
+ } catch (error) {
1203
+ const message = error instanceof Error ? error.message : String(error);
1204
+ display.context(`Could not publish project event (${message}) — the daemon will pick it up later.`);
1205
+ }
1206
+
1207
+ await waitForOpenClawImportIfNeeded();
1208
+
1209
+ // Locally associate non-Nostr agents (e.g. OpenClaw imports) with the meta project.
1210
+ // These don't have event IDs so they aren't referenced in the project event's agent tags;
1211
+ // the daemon needs the local storage association to find them.
1212
+ await agentStorage.initialize();
1213
+ const allStoredAgents = await agentStorage.getAllAgents();
1214
+ for (const agent of allStoredAgents) {
1215
+ if (agent.eventId) continue; // Nostr agents are associated via project event tags
1216
+ const signer = new NDKPrivateKeySigner(agent.nsec);
1217
+ await agentStorage.addAgentToProject(signer.pubkey, "meta");
1218
+ }
1219
+
1220
+ if (installedCount > 0) {
1221
+ display.blank();
1222
+ display.success(`${installedCount} agent(s) ready.`);
1223
+ }
1224
+
1225
+ display.blank();
1226
+ display.success("Created \"Meta\" project.");
1227
+ return true;
1228
+ }
1229
+
1230
+ interface OnboardingOptions {
1231
+ pubkey?: string[];
1232
+ localRelayUrl?: string;
1233
+ json?: boolean;
1234
+ }
1235
+
1236
+ async function startDaemonFromSetup(metaProjectCreated: boolean): Promise<never> {
1237
+ const entrypoint = process.argv[1];
1238
+ if (!entrypoint) {
1239
+ throw new Error("Cannot determine TENEX CLI entrypoint for daemon startup");
1240
+ }
1241
+
1242
+ const isWrapperEntrypoint =
1243
+ entrypoint.endsWith("wrapper.ts") || entrypoint.endsWith("daemon-wrapper.cjs");
1244
+
1245
+ const daemonArgs = isWrapperEntrypoint
1246
+ ? [...(metaProjectCreated ? ["--boot", "meta"] : [])]
1247
+ : ["daemon", ...(metaProjectCreated ? ["--boot", "meta"] : [])];
1248
+
1249
+ const child = spawn(process.argv[0], [entrypoint, ...daemonArgs], {
1250
+ stdio: "inherit",
1251
+ env: process.env,
1252
+ });
1253
+
1254
+ const exitCode = await new Promise<number>((resolve, reject) => {
1255
+ child.on("error", reject);
1256
+ child.on("close", (code) => resolve(code ?? 1));
1257
+ });
1258
+
1259
+ process.exit(exitCode);
1260
+ }
1261
+
1262
+ /**
1263
+ * Full onboarding flow — identity, relay, providers, models, project & agents.
1264
+ */
1265
+ async function runOnboarding(options: OnboardingOptions): Promise<void> {
1266
+ const jsonMode = options.json === true;
1267
+ const globalPath = config.getGlobalPath();
1268
+ await ensureDirectory(globalPath);
1269
+ const existingConfig = await config.loadTenexConfig(globalPath);
1270
+
1271
+ // Quick OpenClaw detection so we can compute total steps upfront
1272
+ const earlyOpenClawDir = await detectOpenClawStateDir();
1273
+ // Steps: Identity, Communication, Providers, Models, Roles, Embeddings, Image Gen, Project & Agents
1274
+ const totalSteps = 8;
1275
+
1276
+ // Welcome banner + Step 1: Identity
1277
+ if (!jsonMode) {
1278
+ display.welcome();
1279
+ display.step(1, totalSteps, "Identity");
1280
+ display.context("Your identity is how your agents know you, and how others can reach you.");
1281
+ display.blank();
1282
+ }
1283
+
1284
+ let whitelistedPubkeys: string[];
1285
+ let generatedNsec: string | undefined;
1286
+ let userPrivateKeyHex: string | undefined;
1287
+ let newIdentityUsername: string | undefined;
1288
+ let metaProjectCreated = false;
1289
+
1290
+ if (options.pubkey) {
1291
+ whitelistedPubkeys = options.pubkey.map((pk) => decodeToPubkey(pk.trim()));
1292
+ } else {
1293
+ const { identityChoice } = await inquirer.prompt([
1294
+ {
1295
+ type: "select",
1296
+ name: "identityChoice",
1297
+ message: "How do you want to set up your identity?",
1298
+ choices: [
1299
+ { name: "Create a new identity", value: "create" },
1300
+ { name: "I have an existing one (import nsec)", value: "import" },
1301
+ ],
1302
+ theme: inquirerTheme,
1303
+ },
1304
+ ]);
1305
+
1306
+ if (identityChoice === "create") {
1307
+ const randomName = generateRandomUsername();
1308
+ const { username } = await inquirer.prompt([
154
1309
  {
155
1310
  type: "input",
156
- name: "projectsBase",
157
- message: "Where should TENEX store your projects?",
158
- default: defaultProjectsBase,
1311
+ name: "username",
1312
+ message: "Choose a username (this is how agents and other nostr users will see you)",
1313
+ default: randomName,
1314
+ validate: (input: string) => {
1315
+ if (!input.trim()) return "Username is required";
1316
+ if (input.trim().length < 2) return "Username must be at least 2 characters";
1317
+ return true;
1318
+ },
1319
+ theme: inquirerTheme,
159
1320
  },
160
1321
  ]);
161
1322
 
162
- // Step 4: Manage relays
163
- let relays =
164
- existingConfig.relays && existingConfig.relays.length > 0
165
- ? [...existingConfig.relays]
166
- : ["wss://tenex.chat"];
167
-
168
- let managingRelays = true;
169
- while (managingRelays) {
170
- // If no relays, go directly to adding one
171
- if (relays.length === 0) {
172
- const { relayUrl } = await inquirer.prompt([
173
- {
174
- type: "input",
175
- name: "relayUrl",
176
- message: "Enter relay URL (ws:// or wss://):",
177
- validate: (input: string) => {
178
- if (!input || input.trim().length === 0) {
179
- return "Please enter a valid relay URL";
180
- }
181
- try {
182
- const url = new URL(input.trim());
183
- if (url.protocol !== "ws:" && url.protocol !== "wss:") {
184
- return "URL must use ws:// or wss:// protocol";
185
- }
186
- return true;
187
- } catch {
188
- return "Invalid URL format";
189
- }
190
- },
191
- },
192
- ]);
193
-
194
- relays.push(relayUrl.trim());
195
- console.log(`✓ Added relay: ${relayUrl.trim()}\n`);
196
- } else {
197
- // Show existing relays with option to add new or continue
198
- const choices = [
199
- ...relays.map((relay, idx) => ({
200
- name: `${idx + 1}. ${relay}`,
201
- value: `remove:${relay}`,
202
- })),
203
- { name: "➕ Add new relay", value: "add" },
204
- { name: "✓ Continue", value: "done" },
205
- ];
206
-
207
- const { action } = await inquirer.prompt([
208
- {
209
- type: "select",
210
- name: "action",
211
- message: "Relay URLs (select to remove, or add new):",
212
- choices,
213
- },
214
- ]);
215
-
216
- if (action === "done") {
217
- managingRelays = false;
218
- } else if (action === "add") {
219
- const { relayUrl } = await inquirer.prompt([
220
- {
221
- type: "input",
222
- name: "relayUrl",
223
- message: "Enter relay URL (ws:// or wss://):",
224
- validate: (input: string) => {
225
- if (!input || input.trim().length === 0) {
226
- return "Please enter a valid relay URL";
227
- }
228
- try {
229
- const url = new URL(input.trim());
230
- if (url.protocol !== "ws:" && url.protocol !== "wss:") {
231
- return "URL must use ws:// or wss:// protocol";
232
- }
233
- return true;
234
- } catch {
235
- return "Invalid URL format";
236
- }
237
- },
238
- },
239
- ]);
240
-
241
- const trimmedUrl = relayUrl.trim();
242
- if (relays.includes(trimmedUrl)) {
243
- console.log("⚠️ Relay already in list\n");
244
- } else {
245
- relays.push(trimmedUrl);
246
- console.log(`✓ Added relay: ${trimmedUrl}\n`);
1323
+ const signer = NDKPrivateKeySigner.generate();
1324
+ if (!signer.privateKey) throw new Error("Failed to generate private key");
1325
+ const privkey = signer.privateKey;
1326
+ const user = await signer.user();
1327
+ const pubkey = user.pubkey;
1328
+ const npub = nip19.npubEncode(pubkey);
1329
+ const nsec = nip19.nsecEncode(Buffer.from(privkey, "hex"));
1330
+
1331
+ whitelistedPubkeys = [pubkey];
1332
+ generatedNsec = nsec;
1333
+ userPrivateKeyHex = privkey;
1334
+ newIdentityUsername = username.trim();
1335
+
1336
+ if (!jsonMode) {
1337
+ display.blank();
1338
+ display.success("Identity created");
1339
+ display.blank();
1340
+ display.summaryLine("username", newIdentityUsername!);
1341
+ display.summaryLine("npub", npub);
1342
+ display.summaryLine("nsec", nsec);
1343
+ display.blank();
1344
+ display.hint("Save your nsec somewhere safe. You won't be able to recover it.");
1345
+ display.blank();
1346
+ }
1347
+ } else {
1348
+ const { nsecInput } = await inquirer.prompt([
1349
+ {
1350
+ type: "password",
1351
+ name: "nsecInput",
1352
+ message: "Paste your nsec (hidden)",
1353
+ mask: "*",
1354
+ validate: (input: string) => {
1355
+ if (!input.trim()) return "nsec is required";
1356
+ try {
1357
+ const decoded = nip19.decode(input.trim());
1358
+ if (decoded.type !== "nsec") return "Invalid nsec";
1359
+ return true;
1360
+ } catch {
1361
+ return "Invalid nsec format";
247
1362
  }
248
- } else if (action.startsWith("remove:")) {
249
- const relayToRemove = action.replace("remove:", "");
250
- relays = relays.filter((r) => r !== relayToRemove);
251
- console.log("✓ Removed relay\n");
252
- }
253
- }
1363
+ },
1364
+ theme: inquirerTheme,
1365
+ },
1366
+ ]);
1367
+
1368
+ const decoded = nip19.decode(nsecInput.trim());
1369
+ const privkeyBytes = decoded.data as unknown as Uint8Array;
1370
+ const privkeyHex = Buffer.from(privkeyBytes).toString("hex");
1371
+ const signer = new NDKPrivateKeySigner(privkeyHex);
1372
+ const user = await signer.user();
1373
+ const pubkey = user.pubkey;
1374
+ const npub = nip19.npubEncode(pubkey);
1375
+
1376
+ whitelistedPubkeys = [pubkey];
1377
+ userPrivateKeyHex = privkeyHex;
1378
+
1379
+ if (!jsonMode) {
1380
+ display.blank();
1381
+ display.success("Identity imported");
1382
+ display.summaryLine("npub", npub);
1383
+ display.blank();
254
1384
  }
1385
+ }
1386
+ }
255
1387
 
256
- if (relays.length === 0) {
257
- logger.warn("No relays configured, adding default relay: wss://tenex.chat");
258
- relays = ["wss://tenex.chat"];
1388
+ // Daemon private key (auto-generated, no UI)
1389
+ let tenexPrivateKey = existingConfig.tenexPrivateKey;
1390
+ if (!tenexPrivateKey) {
1391
+ const signer = NDKPrivateKeySigner.generate();
1392
+ tenexPrivateKey = signer.privateKey;
1393
+ if (!tenexPrivateKey) {
1394
+ if (jsonMode) {
1395
+ console.log(JSON.stringify({ error: "Failed to generate daemon key" }));
1396
+ } else {
1397
+ console.error(chalk.red("Failed to generate daemon key"));
259
1398
  }
1399
+ process.exit(1);
1400
+ }
1401
+ }
1402
+
1403
+ // Projects directory (default ~/tenex)
1404
+ const projectsBase = existingConfig.projectsBase || path.join(os.homedir(), "tenex");
260
1405
 
261
- // Save configuration
262
- const newConfig = {
263
- ...existingConfig,
264
- whitelistedPubkeys,
265
- tenexPrivateKey,
266
- projectsBase: path.resolve(projectsBase),
267
- relays,
268
- };
1406
+ // Step 2: Communication
1407
+ if (!jsonMode) {
1408
+ display.step(2, totalSteps, "Communication");
1409
+ display.context("Choose a relay for your agents to communicate through.");
1410
+ display.blank();
1411
+ }
269
1412
 
270
- await config.saveGlobalConfig(newConfig);
1413
+ const relayItems: RelayItem[] = [];
271
1414
 
272
- // Create projects directory
273
- await ensureDirectory(path.resolve(projectsBase));
1415
+ // When provided, prefer local relay by default (first selected item in relayPrompt).
1416
+ if (options.localRelayUrl) {
1417
+ relayItems.push({
1418
+ type: "choice",
1419
+ name: "Local relay",
1420
+ value: options.localRelayUrl,
1421
+ description: options.localRelayUrl,
1422
+ });
1423
+ }
274
1424
 
275
- console.log("\n✓ TENEX setup complete!\n");
276
- console.log("Configuration saved:");
277
- console.log(
278
- ` • Whitelisted pubkeys (${whitelistedPubkeys.length}): ${whitelistedPubkeys.join(", ")}`
1425
+ relayItems.push(
1426
+ { type: "choice", name: "TENEX Community Relay", value: "wss://tenex.chat", description: "wss://tenex.chat" },
1427
+ { type: "input" },
1428
+ );
1429
+
1430
+ const relay = await relayPrompt({
1431
+ message: "Relay",
1432
+ items: relayItems,
1433
+ validate: (url: string) => {
1434
+ try {
1435
+ const parsed = new URL(url);
1436
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
1437
+ return "URL must use ws:// or wss:// protocol";
1438
+ }
1439
+ if (!parsed.hostname || !parsed.hostname.includes(".")) {
1440
+ return "Enter a relay hostname";
1441
+ }
1442
+ return true;
1443
+ } catch {
1444
+ return "Invalid URL format";
1445
+ }
1446
+ },
1447
+ });
1448
+
1449
+ const relays = [relay];
1450
+
1451
+ // Start agent discovery early — NDK connects and streams events in the
1452
+ // background while the user configures providers, models, etc. (steps 3-7).
1453
+ // By step 8, agents have already accumulated.
1454
+ const agentDiscovery = startAgentDiscovery(
1455
+ relays,
1456
+ userPrivateKeyHex ? new NDKPrivateKeySigner(userPrivateKeyHex) : undefined,
1457
+ );
1458
+ connectAgentDiscovery(agentDiscovery);
1459
+
1460
+ // Publish kind:0 profile for new identity (fire-and-forget)
1461
+ if (newIdentityUsername && userPrivateKeyHex) {
1462
+ const userSigner = new NDKPrivateKeySigner(userPrivateKeyHex);
1463
+ const pubkey = whitelistedPubkeys[0];
1464
+ const avatarFamilies = ["lorelei", "miniavs", "dylan", "pixel-art", "rings", "avataaars"];
1465
+ const familyIndex = Number.parseInt(pubkey.substring(0, 8), 16) % avatarFamilies.length;
1466
+ const avatarStyle = avatarFamilies[familyIndex];
1467
+ const avatarUrl = `https://api.dicebear.com/7.x/${avatarStyle}/png?seed=${pubkey}`;
1468
+
1469
+ const profileEvent = new NDKEvent(agentDiscovery.ndk, {
1470
+ kind: 0,
1471
+ content: JSON.stringify({
1472
+ name: newIdentityUsername,
1473
+ picture: avatarUrl,
1474
+ }),
1475
+ });
1476
+ profileEvent.sign(userSigner).then(() => {
1477
+ profileEvent.publish().catch(() => {});
1478
+ }).catch(() => {});
1479
+ }
1480
+
1481
+ // Save configuration
1482
+ const newConfig = {
1483
+ ...existingConfig,
1484
+ whitelistedPubkeys,
1485
+ tenexPrivateKey,
1486
+ projectsBase: path.resolve(projectsBase),
1487
+ relays,
1488
+ };
1489
+
1490
+ await config.saveGlobalConfig(newConfig);
1491
+ await ensureDirectory(path.resolve(projectsBase));
1492
+
1493
+ // Auto-detect providers from env vars, local commands, Ollama, and OpenClaw
1494
+ const existingProviders = await config.loadTenexProviders(globalPath);
1495
+ const detection = await autoDetectProviders(existingProviders, earlyOpenClawDir);
1496
+
1497
+ if (detection.detectedSources.length > 0) {
1498
+ for (const source of detection.detectedSources) {
1499
+ display.success(`Detected: ${source}`);
1500
+ }
1501
+ display.blank();
1502
+ }
1503
+
1504
+ // Step 3: Providers
1505
+ display.step(3, totalSteps, "AI Providers");
1506
+ display.context("Connect the AI services your agents will use. You need at least one.");
1507
+ display.blank();
1508
+
1509
+ const providerHints = buildProviderHints(detection);
1510
+ const updatedProviders = await runProviderSetup(detection.providers, { providerHints });
1511
+ await config.saveGlobalProviders(updatedProviders);
1512
+ display.success("Provider credentials saved");
1513
+
1514
+ // Step 4: Models
1515
+ if (Object.keys(updatedProviders.providers).length > 0) {
1516
+ await seedDefaultLLMConfigs(updatedProviders);
1517
+
1518
+ display.step(4, totalSteps, "Models");
1519
+ display.context("Configure which models your agents will use.");
1520
+ display.blank();
1521
+
1522
+ const llmEditor = new LLMConfigEditor();
1523
+ await llmEditor.showMainMenu();
1524
+
1525
+ // Step 5: Model Roles
1526
+ display.step(5, totalSteps, "Model Roles");
1527
+ await runRoleAssignment();
1528
+
1529
+ // Step 6: Embeddings
1530
+ display.step(6, totalSteps, "Embeddings");
1531
+ display.context("Choose an embedding model for semantic search and RAG.");
1532
+ display.blank();
1533
+ await runEmbeddingSetup(updatedProviders);
1534
+
1535
+ // Step 7: Image Generation
1536
+ display.step(7, totalSteps, "Image Generation");
1537
+ display.context("Configure image generation for your agents.");
1538
+ display.blank();
1539
+ await runImageGenSetup(updatedProviders);
1540
+
1541
+ // Step 8: Project & Agents
1542
+ if (userPrivateKeyHex) {
1543
+ display.step(8, totalSteps, "Project & Agents");
1544
+ metaProjectCreated = await runProjectAndAgentsStep(
1545
+ agentDiscovery,
1546
+ userPrivateKeyHex,
1547
+ detection.openClawStateDir,
279
1548
  );
280
- console.log(` • Projects directory: ${path.resolve(projectsBase)}`);
281
- console.log(` • Relays: ${relays.join(", ")}`);
282
- console.log("\nYou can now start using TENEX!\n");
1549
+ } else {
1550
+ agentDiscovery.subscription.stop();
1551
+ }
1552
+ } else {
1553
+ agentDiscovery.subscription.stop();
1554
+ display.blank();
1555
+ display.hint("Skipping model configuration (no providers configured)");
1556
+ display.context("Run tenex setup providers and tenex setup llm later to configure models.");
1557
+ display.blank();
1558
+ }
1559
+
1560
+ // Final summary
1561
+ if (jsonMode) {
1562
+ const output: Record<string, unknown> = {
1563
+ npub: nip19.npubEncode(whitelistedPubkeys[0]),
1564
+ pubkey: whitelistedPubkeys[0],
1565
+ projectsBase: path.resolve(projectsBase),
1566
+ relays,
1567
+ };
1568
+ if (generatedNsec) {
1569
+ output.nsec = generatedNsec;
1570
+ }
1571
+ console.log(JSON.stringify(output, null, 2));
1572
+ } else {
1573
+ display.setupComplete();
1574
+ display.summaryLine("Identity", nip19.npubEncode(whitelistedPubkeys[0]));
1575
+ if (generatedNsec) {
1576
+ display.summaryLine("nsec", generatedNsec);
1577
+ }
1578
+ display.summaryLine("Projects", path.resolve(projectsBase));
1579
+ display.summaryLine("Relays", relays.join(", "));
1580
+ display.blank();
1581
+ display.context(metaProjectCreated
1582
+ ? "Starting daemon with auto-boot for the Meta project..."
1583
+ : "Starting daemon...");
1584
+ display.blank();
283
1585
 
284
- process.exit(0);
1586
+ await startDaemonFromSetup(metaProjectCreated);
1587
+ }
1588
+
1589
+ process.exit(0);
1590
+ }
1591
+
1592
+ const ADJECTIVES = [
1593
+ "swift", "bright", "calm", "bold", "keen", "warm", "wild", "cool", "fair", "glad",
1594
+ "brave", "clever", "deft", "eager", "fierce", "gentle", "happy", "jolly", "kind", "lively",
1595
+ "mighty", "noble", "plucky", "quick", "sharp", "steady", "true", "vivid", "witty", "zesty",
1596
+ ];
1597
+
1598
+ const NOUNS = [
1599
+ "fox", "owl", "bear", "wolf", "hawk", "deer", "lynx", "crow", "hare", "wren",
1600
+ "otter", "raven", "crane", "finch", "panda", "tiger", "eagle", "cobra", "bison", "whale",
1601
+ "badger", "falcon", "heron", "robin", "viper", "squid", "gecko", "moose", "stork", "manta",
1602
+ ];
1603
+
1604
+ function generateRandomUsername(): string {
1605
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
1606
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
1607
+ return `${adj}-${noun}`;
1608
+ }
1609
+
1610
+ export const onboardingCommand = new Command("init")
1611
+ .description("Initial setup wizard for TENEX")
1612
+ .option("--pubkey <pubkeys...>", "Pubkeys to whitelist (npub, nprofile, or hex)")
1613
+ .option("--local-relay-url <url>", "URL of a running local relay to offer as an option")
1614
+ .option("--json", "Output configuration as JSON")
1615
+ .action(async (options: OnboardingOptions) => {
1616
+ try {
1617
+ await runOnboarding(options);
285
1618
  } catch (error: unknown) {
286
- // Handle SIGINT (Ctrl+C) gracefully
287
1619
  const errorMessage = error instanceof Error ? error.message : String(error);
288
1620
  if (errorMessage?.includes("SIGINT") || errorMessage?.includes("force closed")) {
289
1621
  process.exit(0);
290
1622
  }
291
- logger.error(`Setup failed: ${error}`);
1623
+ console.error(chalk.red(`Setup failed: ${error}`));
292
1624
  process.exit(1);
293
1625
  }
294
1626
  });