@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.
- package/README.md +5 -1
- package/dist/daemon-wrapper.cjs +47 -0
- package/dist/index.js +59268 -0
- package/dist/wrapper.js +171 -0
- package/package.json +19 -27
- package/src/agents/AgentRegistry.ts +9 -7
- package/src/agents/AgentStorage.ts +24 -1
- package/src/agents/agent-installer.ts +6 -0
- package/src/agents/agent-loader.ts +7 -2
- package/src/agents/constants.ts +10 -2
- package/src/agents/execution/AgentExecutor.ts +35 -6
- package/src/agents/execution/StreamCallbacks.ts +53 -13
- package/src/agents/execution/StreamExecutionHandler.ts +110 -16
- package/src/agents/execution/StreamSetup.ts +19 -9
- package/src/agents/execution/ToolEventHandlers.ts +112 -0
- package/src/agents/role-categories.ts +53 -0
- package/src/agents/types/runtime.ts +7 -0
- package/src/agents/types/storage.ts +7 -0
- package/src/commands/agent/import/openclaw-distiller.ts +63 -7
- package/src/commands/agent/import/openclaw-reader.ts +54 -0
- package/src/commands/agent/import/openclaw.ts +120 -29
- package/src/commands/agent/index.ts +83 -2
- package/src/commands/setup/display.ts +123 -0
- package/src/commands/setup/embed.ts +13 -13
- package/src/commands/setup/global-system-prompt.ts +15 -17
- package/src/commands/setup/image.ts +17 -20
- package/src/commands/setup/interactive.ts +37 -20
- package/src/commands/setup/llm.ts +12 -7
- package/src/commands/setup/onboarding.ts +1580 -248
- package/src/commands/setup/providers.ts +3 -3
- package/src/conversations/ConversationStore.ts +23 -2
- package/src/conversations/MessageBuilder.ts +51 -73
- package/src/conversations/formatters/utils/conversation-transcript-formatter.ts +425 -0
- package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +40 -98
- package/src/conversations/search/embeddings/ConversationIndexingJob.ts +40 -52
- package/src/conversations/services/ConversationSummarizer.ts +1 -2
- package/src/conversations/types.ts +11 -0
- package/src/daemon/Daemon.ts +78 -57
- package/src/daemon/ProjectRuntime.ts +6 -12
- package/src/daemon/SubscriptionManager.ts +13 -0
- package/src/daemon/index.ts +0 -1
- package/src/event-handler/index.ts +1 -0
- package/src/index.ts +20 -1
- package/src/llm/ChunkHandler.ts +1 -1
- package/src/llm/FinishHandler.ts +28 -4
- package/src/llm/LLMConfigEditor.ts +218 -106
- package/src/llm/index.ts +0 -4
- package/src/llm/meta/MetaModelResolver.ts +3 -18
- package/src/llm/middleware/message-sanitizer.ts +153 -0
- package/src/llm/providers/ollama-models.ts +0 -38
- package/src/llm/service.ts +50 -15
- package/src/llm/types.ts +0 -12
- package/src/llm/utils/ConfigurationManager.ts +88 -465
- package/src/llm/utils/ConfigurationTester.ts +42 -185
- package/src/llm/utils/ModelSelector.ts +156 -92
- package/src/llm/utils/ProviderConfigUI.ts +10 -141
- package/src/llm/utils/models-dev-cache.ts +102 -23
- package/src/llm/utils/provider-select-prompt.ts +284 -0
- package/src/llm/utils/provider-setup.ts +81 -34
- package/src/llm/utils/variant-list-prompt.ts +361 -0
- package/src/nostr/AgentEventDecoder.ts +1 -0
- package/src/nostr/AgentEventEncoder.ts +37 -0
- package/src/nostr/AgentProfilePublisher.ts +13 -0
- package/src/nostr/AgentPublisher.ts +26 -0
- package/src/nostr/kinds.ts +1 -0
- package/src/nostr/ndkClient.ts +4 -1
- package/src/nostr/types.ts +12 -0
- package/src/prompts/fragments/25-rag-instructions.ts +22 -21
- package/src/prompts/fragments/31-agents-md-guidance.ts +7 -21
- package/src/prompts/fragments/index.ts +2 -0
- package/src/prompts/utils/systemPromptBuilder.ts +18 -28
- package/src/services/AgentDefinitionMonitor.ts +8 -0
- package/src/services/ConfigService.ts +34 -0
- package/src/services/PubkeyService.ts +7 -1
- package/src/services/compression/CompressionService.ts +133 -74
- package/src/services/compression/compression-utils.ts +110 -19
- package/src/services/config/types.ts +0 -6
- package/src/services/dispatch/AgentDispatchService.ts +79 -0
- package/src/services/intervention/InterventionService.ts +78 -5
- package/src/services/nip46/Nip46SigningService.ts +30 -1
- package/src/services/projects/ProjectContext.ts +8 -6
- package/src/services/rag/RAGCollectionRegistry.ts +199 -0
- package/src/services/rag/RAGDatabaseService.ts +2 -7
- package/src/services/rag/RAGOperations.ts +25 -45
- package/src/services/rag/RAGService.ts +0 -31
- package/src/services/rag/RagSubscriptionService.ts +71 -122
- package/src/services/rag/rag-utils.ts +13 -0
- package/src/services/ral/RALRegistry.ts +25 -184
- package/src/services/reports/ReportEmbeddingService.ts +63 -113
- package/src/services/search/UnifiedSearchService.ts +115 -4
- package/src/services/search/index.ts +1 -0
- package/src/services/search/projectFilter.ts +20 -4
- package/src/services/search/providers/ConversationSearchProvider.ts +1 -0
- package/src/services/search/providers/GenericCollectionSearchProvider.ts +81 -0
- package/src/services/search/providers/LessonSearchProvider.ts +1 -8
- package/src/services/search/providers/ReportSearchProvider.ts +1 -0
- package/src/services/search/types.ts +24 -3
- package/src/services/trust-pubkeys/SystemPubkeyListService.ts +148 -0
- package/src/services/trust-pubkeys/TrustPubkeyService.ts +70 -9
- package/src/telemetry/setup.ts +2 -13
- package/src/tools/implementations/ask.ts +3 -3
- package/src/tools/implementations/conversation_get.ts +28 -268
- package/src/tools/implementations/fs_grep.ts +6 -6
- package/src/tools/implementations/fs_read.ts +2 -0
- package/src/tools/implementations/fs_write.ts +2 -0
- package/src/tools/implementations/learn.ts +38 -50
- package/src/tools/implementations/rag_add_documents.ts +6 -4
- package/src/tools/implementations/rag_create_collection.ts +37 -4
- package/src/tools/implementations/rag_delete_collection.ts +9 -0
- package/src/tools/implementations/{search.ts → rag_search.ts} +31 -25
- package/src/tools/registry.ts +7 -8
- package/src/tools/types.ts +11 -2
- package/src/tools/utils/transcript-args.ts +13 -0
- package/src/utils/cli-theme.ts +13 -0
- package/src/utils/logger.ts +55 -0
- package/src/utils/metadataKeys.ts +17 -0
- package/src/utils/sqlEscaping.ts +39 -0
- package/src/wrapper.ts +7 -3
- package/dist/src/index.js +0 -46790
- package/dist/tenex-backend-wrapper.cjs +0 -3
- package/src/agents/execution/constants.ts +0 -16
- package/src/agents/execution/index.ts +0 -3
- package/src/agents/index.ts +0 -4
- package/src/commands/agent.ts +0 -235
- package/src/conversations/formatters/DelegationXmlFormatter.ts +0 -64
- package/src/conversations/formatters/index.ts +0 -9
- package/src/conversations/index.ts +0 -2
- package/src/conversations/utils/content-utils.ts +0 -69
- package/src/daemon/UnixSocketTransport.ts +0 -318
- package/src/event-handler/newConversation.ts +0 -165
- package/src/events/NDKProjectStatus.ts +0 -384
- package/src/events/index.ts +0 -4
- package/src/lib/json-parser.ts +0 -30
- package/src/llm/RecordingState.ts +0 -37
- package/src/llm/StreamPublisher.ts +0 -40
- package/src/llm/middleware/flight-recorder.ts +0 -188
- package/src/llm/utils/claudeCodePromptCompiler.ts +0 -141
- package/src/nostr/constants.ts +0 -38
- package/src/prompts/core/index.ts +0 -3
- package/src/prompts/index.ts +0 -21
- package/src/services/image/index.ts +0 -12
- package/src/services/status/index.ts +0 -11
- package/src/telemetry/diagnostics.ts +0 -27
- package/src/tools/implementations/rag_query.ts +0 -107
- package/src/types/index.ts +0 -46
- package/src/utils/agentFetcher.ts +0 -107
- package/src/utils/conversation-utils.ts +0 -1
- 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 {
|
|
6
|
-
import
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
process.exit(1);
|
|
1154
|
+
for (const agent of teamAgents) {
|
|
1155
|
+
selectedNostrAgentEventIds.add(agent.id);
|
|
137
1156
|
}
|
|
138
1157
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
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: "
|
|
157
|
-
message: "
|
|
158
|
-
default:
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
1413
|
+
const relayItems: RelayItem[] = [];
|
|
271
1414
|
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1623
|
+
console.error(chalk.red(`Setup failed: ${error}`));
|
|
292
1624
|
process.exit(1);
|
|
293
1625
|
}
|
|
294
1626
|
});
|