nemoris 0.1.2 → 0.1.3
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/config/delivery.toml +3 -3
- package/config/runtime.toml +2 -2
- package/config/skills/telegram-onboarding-spec.md +4 -4
- package/package.json +1 -1
- package/src/onboarding/doctor.js +78 -0
- package/src/onboarding/lock.js +13 -0
- package/src/onboarding/model-catalog-writer.js +83 -0
- package/src/onboarding/model-catalog.js +16 -1
- package/src/onboarding/phases/auth.js +102 -22
- package/src/onboarding/wizard.js +135 -31
package/config/delivery.toml
CHANGED
|
@@ -13,7 +13,7 @@ adapter = "openclaw_cli"
|
|
|
13
13
|
enabled = true
|
|
14
14
|
channel = "telegram"
|
|
15
15
|
account_id = "default"
|
|
16
|
-
chat_id = "
|
|
16
|
+
chat_id = "YOUR_CHAT_ID"
|
|
17
17
|
silent = true
|
|
18
18
|
dry_run = true
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ adapter = "openclaw_cli"
|
|
|
22
22
|
enabled = false
|
|
23
23
|
channel = "telegram"
|
|
24
24
|
account_id = "default"
|
|
25
|
-
chat_id = "
|
|
25
|
+
chat_id = "YOUR_CHAT_ID"
|
|
26
26
|
silent = true
|
|
27
27
|
dry_run = false
|
|
28
28
|
|
|
@@ -43,7 +43,7 @@ adapter = "telegram"
|
|
|
43
43
|
enabled = false
|
|
44
44
|
channel = "telegram"
|
|
45
45
|
account_id = "default"
|
|
46
|
-
chat_id = "
|
|
46
|
+
chat_id = "YOUR_CHAT_ID"
|
|
47
47
|
bot_token_env = "NEMORIS_TELEGRAM_BOT_TOKEN"
|
|
48
48
|
|
|
49
49
|
[profiles.standalone_operator]
|
package/config/runtime.toml
CHANGED
|
@@ -102,8 +102,8 @@ allowed_failure_classes = ["timeout", "provider_loading"]
|
|
|
102
102
|
bot_token_env = "NEMORIS_TELEGRAM_BOT_TOKEN"
|
|
103
103
|
polling_mode = true
|
|
104
104
|
webhook_url = ""
|
|
105
|
-
operator_chat_id = "
|
|
106
|
-
authorized_chat_ids = ["
|
|
105
|
+
operator_chat_id = "YOUR_CHAT_ID"
|
|
106
|
+
authorized_chat_ids = ["YOUR_CHAT_ID"]
|
|
107
107
|
default_agent = "main"
|
|
108
108
|
|
|
109
109
|
[slack]
|
|
@@ -63,7 +63,7 @@ Check if daemon is running:
|
|
|
63
63
|
```
|
|
64
64
|
Daemon is running. Send any message to @kodi_nemoris_bot on Telegram...
|
|
65
65
|
Waiting for your message...
|
|
66
|
-
✓ Found you chat_id:
|
|
66
|
+
✓ Found you chat_id: YOUR_CHAT_ID, @leeUsername
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
- Poll the SQLite state store (`state/active.db`) for the first interactive job row where `source = 'telegram'`
|
|
@@ -77,7 +77,7 @@ This avoids any conflict with the daemon's getUpdates polling connection. No `de
|
|
|
77
77
|
|
|
78
78
|
```
|
|
79
79
|
Send any message to @kodi_nemoris_bot on Telegram, then press Enter...
|
|
80
|
-
✓ Found you chat_id:
|
|
80
|
+
✓ Found you chat_id: YOUR_CHAT_ID, @leeUsername
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
- Reuses existing `whoami(botToken)` function (deleteWebhook → getUpdates → extract chat)
|
|
@@ -110,8 +110,8 @@ Writes the `[telegram]` section to `config/runtime.toml`:
|
|
|
110
110
|
bot_token_env = "NEMORIS_TELEGRAM_BOT_TOKEN"
|
|
111
111
|
polling_mode = true
|
|
112
112
|
webhook_url = ""
|
|
113
|
-
operator_chat_id = "
|
|
114
|
-
authorized_chat_ids = ["
|
|
113
|
+
operator_chat_id = "YOUR_CHAT_ID"
|
|
114
|
+
authorized_chat_ids = ["YOUR_CHAT_ID"]
|
|
115
115
|
default_agent = "kodi"
|
|
116
116
|
```
|
|
117
117
|
|
package/package.json
CHANGED
package/src/onboarding/doctor.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
9
10
|
import os from "node:os";
|
|
10
11
|
import { execFile } from "node:child_process";
|
|
11
12
|
import { promisify } from "node:util";
|
|
@@ -528,3 +529,80 @@ export async function runDoctor(
|
|
|
528
529
|
|
|
529
530
|
return results;
|
|
530
531
|
}
|
|
532
|
+
|
|
533
|
+
export function quickValidateConfig(installDir) {
|
|
534
|
+
const errors = [];
|
|
535
|
+
const suggestions = [];
|
|
536
|
+
const configDir = path.join(installDir, "config");
|
|
537
|
+
|
|
538
|
+
if (!fs.existsSync(path.join(configDir, "runtime.toml"))) {
|
|
539
|
+
errors.push("Missing config/runtime.toml");
|
|
540
|
+
suggestions.push("Run `nemoris setup` to regenerate config files.");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!fs.existsSync(path.join(configDir, "router.toml"))) {
|
|
544
|
+
errors.push("Missing config/router.toml");
|
|
545
|
+
suggestions.push("Run `nemoris setup` to regenerate the router.");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const providersDir = path.join(configDir, "providers");
|
|
549
|
+
if (!fs.existsSync(providersDir)) {
|
|
550
|
+
errors.push("No provider configs found");
|
|
551
|
+
suggestions.push("Run `nemoris setup` to configure a provider.");
|
|
552
|
+
} else {
|
|
553
|
+
const files = fs.readdirSync(providersDir).filter((f) => f.endsWith(".toml"));
|
|
554
|
+
if (files.length === 0) {
|
|
555
|
+
errors.push("No provider configs found in config/providers/");
|
|
556
|
+
suggestions.push("Run `nemoris setup` to configure a provider.");
|
|
557
|
+
}
|
|
558
|
+
for (const file of files) {
|
|
559
|
+
try {
|
|
560
|
+
const content = fs.readFileSync(path.join(providersDir, file), "utf8");
|
|
561
|
+
const authRefMatch = content.match(/auth_ref\s*=\s*"([^"]+)"/);
|
|
562
|
+
if (authRefMatch) {
|
|
563
|
+
const authRef = authRefMatch[1];
|
|
564
|
+
if (!authRef.startsWith("env:") && !authRef.startsWith("profile:")) {
|
|
565
|
+
errors.push(`Invalid auth_ref in ${file}: "${authRef}" (must start with env: or profile:)`);
|
|
566
|
+
suggestions.push(`Fix auth_ref in config/providers/${file}`);
|
|
567
|
+
} else if (authRef.startsWith("env:")) {
|
|
568
|
+
const envName = authRef.slice(4);
|
|
569
|
+
if (!process.env[envName]) {
|
|
570
|
+
errors.push(`Missing env var ${envName} referenced by ${file}`);
|
|
571
|
+
suggestions.push(`Set ${envName} in your .env file or environment.`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
errors.push(`Cannot read ${file}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!fs.existsSync(path.join(configDir, "identity"))) {
|
|
582
|
+
errors.push("Missing config/identity/ directory");
|
|
583
|
+
suggestions.push("Run `nemoris setup` to create identity files.");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return { valid: errors.length === 0, errors, suggestions };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function suggestRepairs(results) {
|
|
590
|
+
const repairs = [];
|
|
591
|
+
for (const error of results.errors) {
|
|
592
|
+
if (error.includes("No provider configs")) {
|
|
593
|
+
repairs.push({ error, fix: "nemoris setup", description: "Run setup to configure a provider" });
|
|
594
|
+
} else if (error.includes("Missing config/runtime.toml")) {
|
|
595
|
+
repairs.push({ error, fix: "nemoris setup", description: "Run setup to regenerate config files" });
|
|
596
|
+
} else if (error.includes("Missing config/router.toml")) {
|
|
597
|
+
repairs.push({ error, fix: "nemoris setup", description: "Run setup to regenerate the router" });
|
|
598
|
+
} else if (error.includes("Invalid auth_ref")) {
|
|
599
|
+
repairs.push({ error, fix: "Edit the provider TOML file", description: "Fix auth_ref to use env: or profile: prefix" });
|
|
600
|
+
} else if (error.includes("Missing env var")) {
|
|
601
|
+
const envVar = error.match(/Missing env var (\S+)/)?.[1] || "the referenced variable";
|
|
602
|
+
repairs.push({ error, fix: `Set ${envVar} in .env`, description: `Add ${envVar}=your_key to your .env file` });
|
|
603
|
+
} else if (error.includes("Missing config/identity")) {
|
|
604
|
+
repairs.push({ error, fix: "nemoris setup", description: "Run setup to create identity files" });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return repairs;
|
|
608
|
+
}
|
package/src/onboarding/lock.js
CHANGED
|
@@ -32,6 +32,19 @@ export function readLock(installDir) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function writeWizardMetadata(installDir, metadata) {
|
|
36
|
+
const lock = readLock(installDir) || {};
|
|
37
|
+
const updated = {
|
|
38
|
+
...lock,
|
|
39
|
+
lastRunAt: metadata.lastRunAt || new Date().toISOString(),
|
|
40
|
+
lastRunVersion: metadata.lastRunVersion || "",
|
|
41
|
+
lastRunCommand: metadata.lastRunCommand || "setup",
|
|
42
|
+
lastRunMode: metadata.lastRunMode || "quickstart",
|
|
43
|
+
lastRunFlow: metadata.lastRunFlow || "interactive",
|
|
44
|
+
};
|
|
45
|
+
writeLock(installDir, updated);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
export function deleteLock(installDir) {
|
|
36
49
|
const p = lockPath(installDir);
|
|
37
50
|
try {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes the model catalog to state/models.json.
|
|
6
|
+
* @param {string} installDir
|
|
7
|
+
* @param {string} agentId
|
|
8
|
+
* @param {Array<object>} models
|
|
9
|
+
*/
|
|
10
|
+
export function writeModelsCatalog(installDir, agentId, models) {
|
|
11
|
+
const stateDir = path.join(installDir, "state");
|
|
12
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
13
|
+
const catalogPath = path.join(stateDir, "models.json");
|
|
14
|
+
const catalog = {
|
|
15
|
+
version: 1,
|
|
16
|
+
agentId,
|
|
17
|
+
updatedAt: new Date().toISOString(),
|
|
18
|
+
models: models.map((m) => ({
|
|
19
|
+
id: String(m.id || ""),
|
|
20
|
+
provider: String(m.provider || ""),
|
|
21
|
+
displayName: String(m.displayName || m.name || m.id || ""),
|
|
22
|
+
contextWindow: typeof m.contextWindow === "number" && m.contextWindow > 0 ? m.contextWindow : undefined,
|
|
23
|
+
reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
|
|
24
|
+
inputTypes: Array.isArray(m.inputTypes) ? m.inputTypes : undefined,
|
|
25
|
+
costTier: m.costTier || undefined,
|
|
26
|
+
role: m.role || undefined,
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
const content = JSON.stringify(catalog, null, 2) + "\n";
|
|
30
|
+
const tempPath = `${catalogPath}.${process.pid}.${Date.now()}.tmp`;
|
|
31
|
+
fs.writeFileSync(tempPath, content, { encoding: "utf8", mode: 0o600 });
|
|
32
|
+
fs.renameSync(tempPath, catalogPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the model catalog from state/models.json.
|
|
37
|
+
* @param {string} installDir
|
|
38
|
+
* @returns {{ version: number, agentId: string, updatedAt: string, models: Array<object> } | null}
|
|
39
|
+
*/
|
|
40
|
+
export function readModelsCatalog(installDir) {
|
|
41
|
+
const catalogPath = path.join(installDir, "state", "models.json");
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(catalogPath, "utf8");
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (!parsed || !Array.isArray(parsed.models)) return null;
|
|
46
|
+
return parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merges curated preset metadata with selected model IDs.
|
|
54
|
+
* @param {Array<object>} presets - curated presets with { id, label, description }
|
|
55
|
+
* @param {Array<object>} fetchedModels - fetched models with { id, contextWindow?, name? }
|
|
56
|
+
* @param {Array<string>} selectedIds - user-selected model IDs
|
|
57
|
+
* @param {string} provider - provider name
|
|
58
|
+
* @returns {Array<object>} enriched model entries
|
|
59
|
+
*/
|
|
60
|
+
export function mergeModelMetadata(presets, fetchedModels, selectedIds, provider) {
|
|
61
|
+
const presetMap = new Map(presets.map((p) => [p.id, p]));
|
|
62
|
+
const fetchedMap = new Map(fetchedModels.map((m) => [m.id, m]));
|
|
63
|
+
const MODEL_ROLE_ORDER = ["cheap_interactive", "fallback", "manual_bump"];
|
|
64
|
+
|
|
65
|
+
return selectedIds.map((id, index) => {
|
|
66
|
+
const preset = presetMap.get(id);
|
|
67
|
+
const fetched = fetchedMap.get(id);
|
|
68
|
+
const displayId = id
|
|
69
|
+
.replace(/^openrouter\//, "")
|
|
70
|
+
.replace(/^openai-codex\//, "")
|
|
71
|
+
.replace(/^anthropic\//, "");
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
provider,
|
|
76
|
+
displayName: preset?.label || fetched?.name || displayId,
|
|
77
|
+
contextWindow: fetched?.contextWindow || undefined,
|
|
78
|
+
reasoning: fetched?.reasoning || undefined,
|
|
79
|
+
inputTypes: fetched?.inputTypes || undefined,
|
|
80
|
+
role: MODEL_ROLE_ORDER[index] || MODEL_ROLE_ORDER.at(-1),
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -169,20 +169,31 @@ export function detectProviderOptions({ env = process.env, ollamaResult = { ok:
|
|
|
169
169
|
{
|
|
170
170
|
value: "anthropic",
|
|
171
171
|
label: "Anthropic",
|
|
172
|
-
hint: anthropic.value ? "API key found in env" : "API key
|
|
172
|
+
hint: anthropic.value ? "API key found in env" : "Setup token or API key",
|
|
173
173
|
detected: Boolean(anthropic.value),
|
|
174
|
+
authMethods: [
|
|
175
|
+
{ value: "token", label: "Setup Token", hint: "From `claude setup-token`" },
|
|
176
|
+
{ value: "api_key", label: "API Key", hint: "From console.anthropic.com" },
|
|
177
|
+
],
|
|
174
178
|
},
|
|
175
179
|
{
|
|
176
180
|
value: "openai",
|
|
177
181
|
label: "OpenAI",
|
|
178
182
|
hint: openai.value ? "API key found in env" : "API key or ChatGPT OAuth",
|
|
179
183
|
detected: Boolean(openai.value),
|
|
184
|
+
authMethods: [
|
|
185
|
+
{ value: "api_key", label: "API Key", hint: "From platform.openai.com" },
|
|
186
|
+
{ value: "oauth", label: "ChatGPT OAuth", hint: "Browser login, refreshable token" },
|
|
187
|
+
],
|
|
180
188
|
},
|
|
181
189
|
{
|
|
182
190
|
value: "openrouter",
|
|
183
191
|
label: "OpenRouter",
|
|
184
192
|
hint: openrouter.value ? "API key found" : "One key, 100+ models — openrouter.ai",
|
|
185
193
|
detected: Boolean(openrouter.value),
|
|
194
|
+
authMethods: [
|
|
195
|
+
{ value: "api_key", label: "API Key", hint: "From openrouter.ai/keys" },
|
|
196
|
+
],
|
|
186
197
|
},
|
|
187
198
|
{
|
|
188
199
|
value: "ollama",
|
|
@@ -191,12 +202,16 @@ export function detectProviderOptions({ env = process.env, ollamaResult = { ok:
|
|
|
191
202
|
? `Local, free — ${ollamaResult.models?.length || 0} models installed`
|
|
192
203
|
: "Local, free — not detected",
|
|
193
204
|
detected: Boolean(ollamaResult.ok),
|
|
205
|
+
authMethods: [
|
|
206
|
+
{ value: "local", label: "Local", hint: "No auth needed" },
|
|
207
|
+
],
|
|
194
208
|
},
|
|
195
209
|
{
|
|
196
210
|
value: "skip",
|
|
197
211
|
label: "Skip for now",
|
|
198
212
|
hint: "Finish setup without provider auth.",
|
|
199
213
|
detected: false,
|
|
214
|
+
authMethods: [],
|
|
200
215
|
},
|
|
201
216
|
];
|
|
202
217
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveOpenAICodexAccess,
|
|
25
25
|
} from "../../auth/openai-codex-oauth.js";
|
|
26
26
|
import { getAuthProfile, resolveAuthProfilesPath } from "../../auth/auth-profiles.js";
|
|
27
|
+
import { writeModelsCatalog, mergeModelMetadata } from "../model-catalog-writer.js";
|
|
27
28
|
|
|
28
29
|
const PROVIDER_CONFIGS = {
|
|
29
30
|
anthropic: {
|
|
@@ -177,7 +178,26 @@ function providerDisplayName(provider) {
|
|
|
177
178
|
return provider;
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
const OPENAI_CONTEXT_WINDOWS = {
|
|
182
|
+
"gpt-4.1": 1000000,
|
|
183
|
+
"gpt-4o": 128000,
|
|
184
|
+
"gpt-4o-mini": 128000,
|
|
185
|
+
"o4-mini": 200000,
|
|
186
|
+
"o3": 200000,
|
|
187
|
+
"o3-mini": 200000,
|
|
188
|
+
"o1": 200000,
|
|
189
|
+
"o1-mini": 128000,
|
|
190
|
+
"gpt-4-turbo": 128000,
|
|
191
|
+
"gpt-3.5-turbo": 16385,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function formatContextWindow(tokens) {
|
|
195
|
+
if (!tokens || tokens <= 0) return "";
|
|
196
|
+
if (tokens >= 1000000) return `ctx ${(tokens / 1000000).toFixed(0)}M`;
|
|
197
|
+
return `ctx ${Math.round(tokens / 1000).toFixed(0)}k`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function fetchProviderModels(provider, key, { fetchImpl = globalThis.fetch } = {}) {
|
|
181
201
|
const targets = {
|
|
182
202
|
anthropic: {
|
|
183
203
|
url: "https://api.anthropic.com/v1/models",
|
|
@@ -203,50 +223,90 @@ async function fetchProviderModelIds(provider, key, { fetchImpl = globalThis.fet
|
|
|
203
223
|
signal: AbortSignal.timeout(10000),
|
|
204
224
|
});
|
|
205
225
|
const data = await response.json();
|
|
206
|
-
if (!response.ok)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
return (PROVIDER_MODEL_PRESETS[provider] || []).map((item) => ({
|
|
228
|
+
id: item.id,
|
|
229
|
+
name: item.label,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
const items = Array.isArray(data?.data) ? data.data.filter(Boolean) : [];
|
|
233
|
+
return items.map((item) => {
|
|
234
|
+
const rawId = item.id;
|
|
235
|
+
const prefixedId = ensureProviderModelPrefix(provider, rawId);
|
|
236
|
+
let contextWindow;
|
|
237
|
+
let name;
|
|
238
|
+
let reasoning;
|
|
239
|
+
|
|
240
|
+
if (provider === "anthropic") {
|
|
241
|
+
name = item.display_name || undefined;
|
|
242
|
+
contextWindow = item.context_window || undefined;
|
|
243
|
+
} else if (provider === "openrouter") {
|
|
244
|
+
name = item.name || undefined;
|
|
245
|
+
contextWindow = item.context_length || undefined;
|
|
246
|
+
} else if (provider === "openai") {
|
|
247
|
+
name = undefined;
|
|
248
|
+
contextWindow = OPENAI_CONTEXT_WINDOWS[rawId] || undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
id: prefixedId,
|
|
253
|
+
contextWindow,
|
|
254
|
+
name,
|
|
255
|
+
reasoning,
|
|
256
|
+
};
|
|
257
|
+
});
|
|
211
258
|
} catch {
|
|
212
|
-
return PROVIDER_MODEL_PRESETS[provider]
|
|
259
|
+
return (PROVIDER_MODEL_PRESETS[provider] || []).map((item) => ({
|
|
260
|
+
id: item.id,
|
|
261
|
+
name: item.label,
|
|
262
|
+
}));
|
|
213
263
|
}
|
|
214
264
|
}
|
|
215
265
|
|
|
216
266
|
async function buildProviderSelectionOptions(provider, key, { fetchImpl = globalThis.fetch } = {}) {
|
|
217
267
|
const curated = PROVIDER_MODEL_PRESETS[provider] || [];
|
|
218
|
-
const
|
|
219
|
-
const
|
|
268
|
+
const fetchedModels = await fetchProviderModels(provider, key, { fetchImpl });
|
|
269
|
+
const fetchedIds = new Set(fetchedModels.map((m) => m.id));
|
|
270
|
+
const fetchedMap = new Map(fetchedModels.map((m) => [m.id, m]));
|
|
220
271
|
|
|
221
272
|
// Curated models that exist in the fetched list (fall back to all curated if fetch failed)
|
|
222
|
-
const curatedAvailable =
|
|
223
|
-
? curated.filter((item) =>
|
|
273
|
+
const curatedAvailable = fetchedModels.length > 0
|
|
274
|
+
? curated.filter((item) => fetchedIds.has(item.id))
|
|
224
275
|
: curated;
|
|
225
276
|
const curatedIds = new Set(curatedAvailable.map((item) => item.id));
|
|
226
277
|
|
|
227
278
|
// Remaining fetched models not already shown as curated
|
|
228
|
-
const extra =
|
|
229
|
-
.filter((
|
|
230
|
-
.map((
|
|
231
|
-
const displayId = id
|
|
279
|
+
const extra = fetchedModels
|
|
280
|
+
.filter((m) => !curatedIds.has(m.id))
|
|
281
|
+
.map((m) => {
|
|
282
|
+
const displayId = m.id
|
|
232
283
|
.replace(/^openrouter\//, "")
|
|
233
284
|
.replace(/^openai-codex\//, "")
|
|
234
285
|
.replace(/^anthropic\//, "");
|
|
235
|
-
|
|
286
|
+
const ctxStr = formatContextWindow(m.contextWindow);
|
|
287
|
+
const desc = ctxStr ? `available from provider \u00b7 ${ctxStr}` : "available from provider";
|
|
288
|
+
return { value: m.id, label: displayId, description: desc };
|
|
236
289
|
});
|
|
237
290
|
|
|
238
|
-
|
|
239
|
-
...curatedAvailable.map((item) =>
|
|
291
|
+
const options = [
|
|
292
|
+
...curatedAvailable.map((item) => {
|
|
293
|
+
const fetched = fetchedMap.get(item.id);
|
|
294
|
+
const ctxStr = fetched ? formatContextWindow(fetched.contextWindow) : "";
|
|
295
|
+
const desc = ctxStr ? `${item.description} \u00b7 ${ctxStr}` : item.description;
|
|
296
|
+
return { value: item.id, label: item.label, description: desc };
|
|
297
|
+
}),
|
|
240
298
|
...extra,
|
|
241
299
|
{ value: "__custom__", label: "Enter a different model name...", description: "Use a specific model id not shown in the list." },
|
|
242
300
|
];
|
|
301
|
+
|
|
302
|
+
return { options, fetchedModels };
|
|
243
303
|
}
|
|
244
304
|
|
|
245
305
|
async function promptForProviderModels(provider, key, tui, { fetchImpl = globalThis.fetch } = {}) {
|
|
246
306
|
const { select, prompt, dim, cyan } = tui;
|
|
247
|
-
if (!select) return [];
|
|
307
|
+
if (!select) return { chosen: [], fetchedModels: [] };
|
|
248
308
|
|
|
249
|
-
const options = await buildProviderSelectionOptions(provider, key, { fetchImpl });
|
|
309
|
+
const { options, fetchedModels } = await buildProviderSelectionOptions(provider, key, { fetchImpl });
|
|
250
310
|
const chosen = [];
|
|
251
311
|
const manualOptionValue = "__custom__";
|
|
252
312
|
const defaultModelValue = options.find((item) => !String(item.value).startsWith("__"))?.value || "";
|
|
@@ -296,7 +356,7 @@ async function promptForProviderModels(provider, key, tui, { fetchImpl = globalT
|
|
|
296
356
|
}
|
|
297
357
|
}
|
|
298
358
|
|
|
299
|
-
return chosen;
|
|
359
|
+
return { chosen, fetchedModels };
|
|
300
360
|
}
|
|
301
361
|
|
|
302
362
|
export function writeProviderConfigs(installDir, providerInput) {
|
|
@@ -538,11 +598,31 @@ export async function runAuthPhase(installDir, options = {}) {
|
|
|
538
598
|
providerFlags.openai = true;
|
|
539
599
|
}
|
|
540
600
|
|
|
601
|
+
const fetchedModelsCache = {};
|
|
541
602
|
if (tui) {
|
|
542
603
|
for (const provider of ["openrouter", "anthropic", "openai"]) {
|
|
543
604
|
const providerToken = providerSecrets[provider];
|
|
544
605
|
if (!providerToken) continue;
|
|
545
|
-
|
|
606
|
+
const { chosen, fetchedModels } = await promptForProviderModels(provider, providerToken, tui, { fetchImpl });
|
|
607
|
+
selectedModels[provider] = chosen;
|
|
608
|
+
fetchedModelsCache[provider] = fetchedModels;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Persist model catalog with metadata
|
|
613
|
+
const allCatalogModels = [];
|
|
614
|
+
for (const [provider, modelIds] of Object.entries(selectedModels)) {
|
|
615
|
+
if (!modelIds?.length) continue;
|
|
616
|
+
const presets = PROVIDER_MODEL_PRESETS[provider] || [];
|
|
617
|
+
const fetched = fetchedModelsCache[provider] || [];
|
|
618
|
+
const enriched = mergeModelMetadata(presets, fetched, modelIds, provider);
|
|
619
|
+
allCatalogModels.push(...enriched);
|
|
620
|
+
}
|
|
621
|
+
if (allCatalogModels.length > 0) {
|
|
622
|
+
try {
|
|
623
|
+
writeModelsCatalog(installDir, "default", allCatalogModels);
|
|
624
|
+
} catch {
|
|
625
|
+
// Non-fatal — catalog is optional
|
|
546
626
|
}
|
|
547
627
|
}
|
|
548
628
|
|
package/src/onboarding/wizard.js
CHANGED
|
@@ -17,6 +17,7 @@ import { verify } from "./phases/verify.js";
|
|
|
17
17
|
import { createClackPrompter, SetupCancelledError } from "./clack-prompter.js";
|
|
18
18
|
import { detectPreferredProvider, detectProviderOptions, summarizeSelectedModels } from "./model-catalog.js";
|
|
19
19
|
import { isDaemonRunning, loadDaemon, writeDaemonUnit } from "./platform.js";
|
|
20
|
+
import { writeWizardMetadata } from "./lock.js";
|
|
20
21
|
|
|
21
22
|
const MIN_NODE_MAJOR = 22;
|
|
22
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -63,6 +64,43 @@ function readRuntimeConfig(installDir) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function readExistingIdentity(installDir) {
|
|
68
|
+
const result = { userName: "", agentName: "", agentId: "", provider: "" };
|
|
69
|
+
try {
|
|
70
|
+
const agentsDir = path.join(installDir, "config", "agents");
|
|
71
|
+
if (fs.existsSync(agentsDir)) {
|
|
72
|
+
const files = fs.readdirSync(agentsDir).filter((f) => f.endsWith(".toml"));
|
|
73
|
+
if (files.length > 0) {
|
|
74
|
+
const agentToml = parseToml(fs.readFileSync(path.join(agentsDir, files[0]), "utf8"));
|
|
75
|
+
result.agentId = agentToml?.id || files[0].replace(/\.toml$/, "");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const identityDir = path.join(installDir, "config", "identity");
|
|
79
|
+
if (fs.existsSync(identityDir)) {
|
|
80
|
+
const soulFiles = fs.readdirSync(identityDir).filter((f) => f.endsWith("-soul.md"));
|
|
81
|
+
for (const file of soulFiles) {
|
|
82
|
+
const content = fs.readFileSync(path.join(identityDir, file), "utf8");
|
|
83
|
+
const nameMatch = content.match(/^# Soul — (.+)$/m);
|
|
84
|
+
if (nameMatch) result.agentName = nameMatch[1].trim();
|
|
85
|
+
const operatorMatch = content.match(/^Operator:\s*(.+)$/m);
|
|
86
|
+
if (operatorMatch) result.userName = operatorMatch[1].trim();
|
|
87
|
+
if (result.agentName) break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const providersDir = path.join(installDir, "config", "providers");
|
|
91
|
+
if (fs.existsSync(providersDir)) {
|
|
92
|
+
const providerFiles = fs.readdirSync(providersDir).filter((f) => f.endsWith(".toml") && f !== "ollama.toml");
|
|
93
|
+
if (providerFiles.length > 0) {
|
|
94
|
+
const name = providerFiles[0].replace(/\.toml$/, "").replace(/-codex$/, "");
|
|
95
|
+
result.provider = name === "openai" ? "openai" : name;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Non-fatal — fall back to defaults
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
function summarizeExistingConfig(installDir) {
|
|
67
105
|
const runtime = readRuntimeConfig(installDir);
|
|
68
106
|
const providersDir = path.join(installDir, "config", "providers");
|
|
@@ -184,16 +222,22 @@ async function runInteractiveWizard({
|
|
|
184
222
|
await prompter.intro("Nemoris setup");
|
|
185
223
|
|
|
186
224
|
await prompter.note([
|
|
187
|
-
"
|
|
225
|
+
"Nemoris is a personal AI agent runtime.",
|
|
226
|
+
"",
|
|
227
|
+
"Before continuing, please understand:",
|
|
188
228
|
"",
|
|
189
|
-
"
|
|
190
|
-
"It can
|
|
229
|
+
" Your agent can execute system commands if tools are enabled.",
|
|
230
|
+
" It can read and write files within its workspace directory.",
|
|
231
|
+
" It can make network requests to configured providers.",
|
|
232
|
+
" A malicious prompt could trick it into unsafe actions.",
|
|
191
233
|
"",
|
|
192
|
-
"
|
|
193
|
-
"
|
|
234
|
+
"Recommended baseline:",
|
|
235
|
+
" - Keep API keys out of the agent's reachable workspace.",
|
|
236
|
+
" - Use the strongest available model for tool-enabled agents.",
|
|
237
|
+
" - Review agent actions in the run log regularly.",
|
|
238
|
+
" - Do not expose the daemon to untrusted networks.",
|
|
194
239
|
"",
|
|
195
|
-
"
|
|
196
|
-
"Do not expose to the internet without hardening first.",
|
|
240
|
+
"More: https://github.com/amzer24/nemoris#security",
|
|
197
241
|
].join("\n"), "Security");
|
|
198
242
|
|
|
199
243
|
const proceed = await prompter.confirm({
|
|
@@ -264,12 +308,13 @@ async function runInteractiveWizard({
|
|
|
264
308
|
const detection = await detect(installDir);
|
|
265
309
|
await scaffold({ installDir });
|
|
266
310
|
|
|
311
|
+
const existing = readExistingIdentity(installDir);
|
|
267
312
|
const userName = await prompter.text({
|
|
268
313
|
message: "Your name",
|
|
269
|
-
initialValue: process.env.NEMORIS_USER_NAME || process.env.USER || "",
|
|
314
|
+
initialValue: existing.userName || process.env.NEMORIS_USER_NAME || process.env.USER || "",
|
|
270
315
|
placeholder: os.userInfo().username || "",
|
|
271
316
|
});
|
|
272
|
-
const defaultAgentName = process.env.NEMORIS_AGENT_NAME || resolveDefaultAgentName();
|
|
317
|
+
const defaultAgentName = existing.agentName || process.env.NEMORIS_AGENT_NAME || resolveDefaultAgentName();
|
|
273
318
|
const agentName = await prompter.text({
|
|
274
319
|
message: "What should your agent be called?",
|
|
275
320
|
initialValue: defaultAgentName,
|
|
@@ -294,12 +339,30 @@ async function runInteractiveWizard({
|
|
|
294
339
|
label: option.label,
|
|
295
340
|
hint: option.hint,
|
|
296
341
|
})),
|
|
297
|
-
initialValue: detectPreferredProvider({
|
|
342
|
+
initialValue: existing.provider || detectPreferredProvider({
|
|
298
343
|
env: process.env,
|
|
299
344
|
ollamaResult: detection.ollama ? { ok: true } : { ok: false },
|
|
300
345
|
}),
|
|
301
346
|
});
|
|
302
347
|
|
|
348
|
+
// Select auth method if provider has multiple options
|
|
349
|
+
let selectedAuthMethod = "api_key";
|
|
350
|
+
const providerOptions = detectProviderOptions({
|
|
351
|
+
env: process.env,
|
|
352
|
+
ollamaResult: detection.ollama ? { ok: true, models: detection.ollama.models } : { ok: false, models: [] },
|
|
353
|
+
});
|
|
354
|
+
const selectedProvider = providerOptions.find((o) => o.value === provider);
|
|
355
|
+
if (selectedProvider?.authMethods?.length > 1) {
|
|
356
|
+
selectedAuthMethod = await prompter.select({
|
|
357
|
+
message: `${selectedProvider.label} auth method:`,
|
|
358
|
+
options: selectedProvider.authMethods.map((m) => ({
|
|
359
|
+
value: m.value,
|
|
360
|
+
label: m.label,
|
|
361
|
+
hint: m.hint,
|
|
362
|
+
})),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
303
366
|
let authResult = { providers: [], providerFlags: {}, selectedModels: {} };
|
|
304
367
|
let telegramResult = { configured: false, verified: false };
|
|
305
368
|
let ollamaResult = { configured: false, verified: false, models: [] };
|
|
@@ -314,7 +377,7 @@ async function runInteractiveWizard({
|
|
|
314
377
|
ollamaResult: detection.ollama ? { ok: true, models: detection.ollama.models } : { ok: false, models: [] },
|
|
315
378
|
},
|
|
316
379
|
providerOrder: [provider],
|
|
317
|
-
|
|
380
|
+
authMethod: selectedAuthMethod,
|
|
318
381
|
});
|
|
319
382
|
authSpin.stop("Provider configured");
|
|
320
383
|
}
|
|
@@ -376,27 +439,55 @@ async function runInteractiveWizard({
|
|
|
376
439
|
installShellCompletion();
|
|
377
440
|
}
|
|
378
441
|
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
]
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
442
|
+
const summaryLines = [];
|
|
443
|
+
|
|
444
|
+
// Providers
|
|
445
|
+
const providerNames = [];
|
|
446
|
+
if (authResult.providerFlags?.anthropic) providerNames.push("Anthropic");
|
|
447
|
+
if (authResult.providerFlags?.openai) providerNames.push("OpenAI");
|
|
448
|
+
if (authResult.providerFlags?.openrouter) providerNames.push("OpenRouter");
|
|
449
|
+
if (ollamaResult.configured || authResult.providerFlags?.ollama) providerNames.push("Ollama");
|
|
450
|
+
summaryLines.push(`Providers: ${providerNames.length > 0 ? providerNames.join(", ") : "none configured"}`);
|
|
451
|
+
|
|
452
|
+
// Models
|
|
453
|
+
for (const [prov, models] of Object.entries(authResult.selectedModels || {})) {
|
|
454
|
+
if (!models?.length) continue;
|
|
455
|
+
const stripped = models.map((m) => m.replace(/^(openrouter|openai-codex|anthropic)\//, ""));
|
|
456
|
+
summaryLines.push(` ${prov}: ${stripped.join(", ")}`);
|
|
457
|
+
}
|
|
388
458
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
"
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
459
|
+
// Telegram
|
|
460
|
+
if (telegramResult.verified) {
|
|
461
|
+
summaryLines.push("Telegram: connected");
|
|
462
|
+
} else if (telegramResult.configured) {
|
|
463
|
+
summaryLines.push("Telegram: configured (not verified)");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Ollama
|
|
467
|
+
if (ollamaResult.configured) {
|
|
468
|
+
summaryLines.push(`Ollama: ${ollamaResult.models?.length || 0} models`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Next steps
|
|
472
|
+
summaryLines.push("");
|
|
473
|
+
summaryLines.push("Next steps:");
|
|
474
|
+
summaryLines.push(" nemoris start Start the daemon");
|
|
475
|
+
summaryLines.push(" nemoris status Check runtime health");
|
|
476
|
+
summaryLines.push(" nemoris chat Open TUI chat");
|
|
477
|
+
|
|
478
|
+
await prompter.note(summaryLines.join("\n"), "Setup complete");
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
482
|
+
writeWizardMetadata(installDir, {
|
|
483
|
+
lastRunVersion: pkg.version || "",
|
|
484
|
+
lastRunCommand: "setup",
|
|
485
|
+
lastRunMode: flow === "manual" ? "manual" : "quickstart",
|
|
486
|
+
lastRunFlow: "interactive",
|
|
487
|
+
});
|
|
488
|
+
} catch {
|
|
489
|
+
// Non-fatal
|
|
490
|
+
}
|
|
400
491
|
|
|
401
492
|
await prompter.outro(
|
|
402
493
|
telegramResult.verified
|
|
@@ -471,6 +562,19 @@ async function runNonInteractiveWizard({
|
|
|
471
562
|
if (result.status === "warning") {
|
|
472
563
|
return 1;
|
|
473
564
|
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
568
|
+
writeWizardMetadata(installDir, {
|
|
569
|
+
lastRunVersion: pkg.version || "",
|
|
570
|
+
lastRunCommand: "setup",
|
|
571
|
+
lastRunMode: flow === "quickstart" ? "quickstart" : "manual",
|
|
572
|
+
lastRunFlow: "non-interactive",
|
|
573
|
+
});
|
|
574
|
+
} catch {
|
|
575
|
+
// Non-fatal
|
|
576
|
+
}
|
|
577
|
+
|
|
474
578
|
if (flow !== "quickstart" && buildResult.ollamaConfigured) {
|
|
475
579
|
// Keep manual mode deterministic in CI while preserving quickstart defaults.
|
|
476
580
|
return 0;
|