nemoris 0.1.7 → 0.1.10
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/package.json +1 -1
- package/src/cli/runtime-control.js +30 -5
- package/src/cli-main.js +15 -1
- package/src/onboarding/auth/api-key.js +81 -0
- package/src/onboarding/phases/auth.js +29 -2
- package/src/onboarding/phases/ollama.js +28 -3
- package/src/onboarding/phases/telegram.js +7 -4
- package/src/onboarding/setup-checklist.js +18 -4
- package/src/onboarding/templates.js +53 -38
- package/src/onboarding/wizard.js +138 -3
- package/src/utils/env-loader.js +28 -0
package/package.json
CHANGED
|
@@ -487,24 +487,49 @@ export async function cleanupOrphanedDaemonProcesses({
|
|
|
487
487
|
|
|
488
488
|
export async function startDirectDaemon({
|
|
489
489
|
projectRoot,
|
|
490
|
-
cliEntryPath
|
|
490
|
+
cliEntryPath,
|
|
491
491
|
fsImpl = fs,
|
|
492
492
|
spawnImpl = spawn,
|
|
493
493
|
processImpl = process,
|
|
494
494
|
} = {}) {
|
|
495
|
+
// Resolve CLI entry: explicit path > source repo > which nemoris > package __dirname
|
|
496
|
+
if (!cliEntryPath) {
|
|
497
|
+
const srcCliPath = path.join(projectRoot, "src", "cli.js");
|
|
498
|
+
if (fsImpl.existsSync(srcCliPath)) {
|
|
499
|
+
cliEntryPath = srcCliPath;
|
|
500
|
+
} else {
|
|
501
|
+
try {
|
|
502
|
+
const { execFileSync } = await import("node:child_process");
|
|
503
|
+
const whichOut = execFileSync("which", ["nemoris"], { encoding: "utf8", timeout: 3000 }).trim();
|
|
504
|
+
if (whichOut) {
|
|
505
|
+
cliEntryPath = whichOut;
|
|
506
|
+
}
|
|
507
|
+
} catch { /* which not available */ }
|
|
508
|
+
}
|
|
509
|
+
if (!cliEntryPath) {
|
|
510
|
+
// Fallback: resolve from this module's own package
|
|
511
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
512
|
+
cliEntryPath = path.join(__dirname, "..", "cli.js");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
495
515
|
const { stateDir, pidFile, stdoutLog, stderrLog } = getDaemonPaths(projectRoot);
|
|
496
516
|
fsImpl.mkdirSync(stateDir, { recursive: true });
|
|
497
517
|
const stdoutFd = fsImpl.openSync(stdoutLog, "a");
|
|
498
518
|
const stderrFd = fsImpl.openSync(stderrLog, "a");
|
|
499
519
|
|
|
500
520
|
try {
|
|
521
|
+
// Only set NEMORIS_INSTALL_DIR for the child when the path is a clean
|
|
522
|
+
// data directory. If it's the source repo (has package.json), the child
|
|
523
|
+
// will detect it via CWD detection instead — avoids the source repo guard.
|
|
524
|
+
const childEnv = { ...processImpl.env };
|
|
525
|
+
const looksLikeSourceRepo = fsImpl.existsSync(path.join(projectRoot, "package.json"));
|
|
526
|
+
if (!looksLikeSourceRepo) {
|
|
527
|
+
childEnv.NEMORIS_INSTALL_DIR = projectRoot;
|
|
528
|
+
}
|
|
501
529
|
const child = spawnImpl(processImpl.execPath, [cliEntryPath, "serve-daemon", "live"], {
|
|
502
530
|
cwd: projectRoot,
|
|
503
531
|
detached: true,
|
|
504
|
-
env:
|
|
505
|
-
...processImpl.env,
|
|
506
|
-
NEMORIS_INSTALL_DIR: projectRoot,
|
|
507
|
-
},
|
|
532
|
+
env: childEnv,
|
|
508
533
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
509
534
|
});
|
|
510
535
|
child.unref?.();
|
package/src/cli-main.js
CHANGED
|
@@ -2745,13 +2745,27 @@ export async function main(argv = process.argv) {
|
|
|
2745
2745
|
});
|
|
2746
2746
|
} else if (command === "doctor") {
|
|
2747
2747
|
const { runDoctor, formatDoctorReport } = await import("./onboarding/doctor.js");
|
|
2748
|
-
const
|
|
2748
|
+
const doctorInstallDir = resolveRuntimeInstallDir();
|
|
2749
|
+
const results = await runDoctor(doctorInstallDir);
|
|
2749
2750
|
const json = rest.includes("--json");
|
|
2751
|
+
const fix = rest.includes("--fix");
|
|
2750
2752
|
if (json) {
|
|
2751
2753
|
console.log(JSON.stringify(results, null, 2));
|
|
2752
2754
|
} else {
|
|
2753
2755
|
console.log(formatDoctorReport(results));
|
|
2754
2756
|
}
|
|
2757
|
+
// Auto-repair: clean stale PID file
|
|
2758
|
+
if (fix && results.daemon?.stalePidFile) {
|
|
2759
|
+
const { pidFile } = getDaemonPaths(doctorInstallDir);
|
|
2760
|
+
try {
|
|
2761
|
+
fs.unlinkSync(pidFile);
|
|
2762
|
+
console.log("\n \u2705 Cleaned stale PID file. Run: nemoris start");
|
|
2763
|
+
} catch {
|
|
2764
|
+
console.log("\n \u274c Could not remove stale PID file.");
|
|
2765
|
+
}
|
|
2766
|
+
} else if (!fix && results.daemon?.stalePidFile) {
|
|
2767
|
+
console.log("\n Tip: run nemoris doctor --fix to clean the stale PID file.");
|
|
2768
|
+
}
|
|
2755
2769
|
return results.exitCode;
|
|
2756
2770
|
} else if (command === "battle") {
|
|
2757
2771
|
const { runBattle, parseBattleFlags } = await import("./battle.js");
|
|
@@ -193,6 +193,87 @@ export async function validateApiKey(provider, key, options = {}) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Validate that a specific model ID exists and is accessible with the given key.
|
|
198
|
+
* Uses lightweight endpoints per provider to avoid token spend.
|
|
199
|
+
*
|
|
200
|
+
* @param {"anthropic"|"openai"|"openrouter"} provider
|
|
201
|
+
* @param {string} modelId - The full model ID to validate
|
|
202
|
+
* @param {string} key - API key
|
|
203
|
+
* @param {object} [options]
|
|
204
|
+
* @param {Function} [options.fetchImpl]
|
|
205
|
+
* @param {Array} [options.fetchedModels] - Pre-fetched model list (avoids extra API call for OpenRouter)
|
|
206
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
207
|
+
*/
|
|
208
|
+
export async function validateModelId(provider, modelId, key, options = {}) {
|
|
209
|
+
const fetch = options.fetchImpl || globalThis.fetch;
|
|
210
|
+
|
|
211
|
+
// OpenRouter / pre-fetched list: check against known models without an API call
|
|
212
|
+
if (options.fetchedModels && options.fetchedModels.length > 0) {
|
|
213
|
+
const found = options.fetchedModels.some((m) => m.id === modelId);
|
|
214
|
+
if (found) return { ok: true };
|
|
215
|
+
// Not in list — could be a very new model, fall through to API check
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
if (provider === "anthropic") {
|
|
220
|
+
// count_tokens is zero-cost and validates the model exists
|
|
221
|
+
const resp = await fetch("https://api.anthropic.com/v1/messages/count_tokens", {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: {
|
|
224
|
+
"content-type": "application/json",
|
|
225
|
+
...buildAnthropicAuthHeaders(key),
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
model: modelId,
|
|
229
|
+
messages: [{ role: "user", content: "ping" }],
|
|
230
|
+
}),
|
|
231
|
+
signal: AbortSignal.timeout(10000),
|
|
232
|
+
});
|
|
233
|
+
if (resp.ok) return { ok: true };
|
|
234
|
+
const body = await resp.json().catch(() => ({}));
|
|
235
|
+
if (resp.status === 404 || (body.error?.type === "not_found_error")) {
|
|
236
|
+
return { ok: false, error: `Model "${modelId}" not found.` };
|
|
237
|
+
}
|
|
238
|
+
return { ok: false, error: body.error?.message || `API returned ${resp.status}` };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (provider === "openai") {
|
|
242
|
+
const resp = await fetch(`https://api.openai.com/v1/models/${encodeURIComponent(modelId)}`, {
|
|
243
|
+
method: "GET",
|
|
244
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
245
|
+
signal: AbortSignal.timeout(10000),
|
|
246
|
+
});
|
|
247
|
+
if (resp.ok) return { ok: true };
|
|
248
|
+
if (resp.status === 404) {
|
|
249
|
+
return { ok: false, error: `Model "${modelId}" not found.` };
|
|
250
|
+
}
|
|
251
|
+
return { ok: false, error: `API returned ${resp.status}` };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (provider === "openrouter") {
|
|
255
|
+
// OpenRouter has no per-model endpoint — fetch full list and check
|
|
256
|
+
const resp = await fetch("https://openrouter.ai/api/v1/models", {
|
|
257
|
+
method: "GET",
|
|
258
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
259
|
+
signal: AbortSignal.timeout(10000),
|
|
260
|
+
});
|
|
261
|
+
if (!resp.ok) {
|
|
262
|
+
return { ok: false, error: `API returned ${resp.status}` };
|
|
263
|
+
}
|
|
264
|
+
const body = await resp.json().catch(() => ({ data: [] }));
|
|
265
|
+
const found = (body.data || []).some((m) => m.id === modelId);
|
|
266
|
+
return found
|
|
267
|
+
? { ok: true }
|
|
268
|
+
: { ok: false, error: `Model "${modelId}" not found on OpenRouter.` };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { ok: true }; // Unknown provider — skip validation
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { ok: false, error: error.message };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
196
277
|
/**
|
|
197
278
|
* Write or merge keys into an .env file at installDir/.env.
|
|
198
279
|
* Existing keys not being overwritten are preserved.
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
detectExistingKeys,
|
|
14
14
|
validateApiKey,
|
|
15
15
|
validateApiKeyFormat,
|
|
16
|
+
validateModelId,
|
|
16
17
|
writeEnvFile,
|
|
17
18
|
resolveProviders
|
|
18
19
|
} from "../auth/api-key.js";
|
|
@@ -424,8 +425,34 @@ async function promptForProviderModels(provider, key, tui, { fetchImpl = globalT
|
|
|
424
425
|
|
|
425
426
|
let modelId = picked;
|
|
426
427
|
if (picked === manualOptionValue) {
|
|
427
|
-
|
|
428
|
-
|
|
428
|
+
let validated = false;
|
|
429
|
+
while (!validated) {
|
|
430
|
+
const custom = await prompt("Model id", stripProviderPrefix(provider, defaultModelValue));
|
|
431
|
+
modelId = ensureProviderModelPrefix(provider, custom);
|
|
432
|
+
if (!modelId) {
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
const check = await validateModelId(provider, modelId, key, { fetchImpl, fetchedModels });
|
|
436
|
+
if (check.ok) {
|
|
437
|
+
validated = true;
|
|
438
|
+
} else {
|
|
439
|
+
console.log(` \u274c ${check.error || "Model not found."} Try again or press Enter to skip.`);
|
|
440
|
+
const retry = await prompt("Model id (or empty to cancel)", "");
|
|
441
|
+
if (!retry) {
|
|
442
|
+
modelId = null;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
modelId = ensureProviderModelPrefix(provider, retry);
|
|
446
|
+
const recheck = await validateModelId(provider, modelId, key, { fetchImpl, fetchedModels });
|
|
447
|
+
if (recheck.ok) {
|
|
448
|
+
validated = true;
|
|
449
|
+
} else {
|
|
450
|
+
console.log(` \u274c ${recheck.error || "Model not found."}`);
|
|
451
|
+
modelId = null;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
429
456
|
if (!modelId) {
|
|
430
457
|
continue;
|
|
431
458
|
}
|
|
@@ -156,7 +156,7 @@ export async function installAndStartOllama(exec, { platform = process.platform,
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
async function chooseOllamaModels(availableModels = []) {
|
|
159
|
+
async function chooseOllamaModels(availableModels = [], { fetchImpl = globalThis.fetch, ollamaBaseUrl = "http://localhost:11434" } = {}) {
|
|
160
160
|
const curated = [];
|
|
161
161
|
const addChoice = (value, description) => {
|
|
162
162
|
if (!curated.some((item) => item.value === value)) {
|
|
@@ -199,7 +199,32 @@ async function chooseOllamaModels(availableModels = []) {
|
|
|
199
199
|
|
|
200
200
|
let model = picked;
|
|
201
201
|
if (picked === "__custom__") {
|
|
202
|
-
|
|
202
|
+
let validated = false;
|
|
203
|
+
while (!validated) {
|
|
204
|
+
model = await prompt("Model name", DEFAULT_PRIMARY_MODEL);
|
|
205
|
+
model = String(model || "").trim();
|
|
206
|
+
if (!model) break;
|
|
207
|
+
// Validate model exists via Ollama /api/show
|
|
208
|
+
try {
|
|
209
|
+
const resp = await fetchImpl(`${ollamaBaseUrl}/api/show`, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "content-type": "application/json" },
|
|
212
|
+
body: JSON.stringify({ name: model }),
|
|
213
|
+
signal: AbortSignal.timeout(5000),
|
|
214
|
+
});
|
|
215
|
+
if (resp.ok) {
|
|
216
|
+
validated = true;
|
|
217
|
+
} else {
|
|
218
|
+
console.log(` \u274c Model "${model}" not found locally. Pull it first with: ollama pull ${model}`);
|
|
219
|
+
model = null;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// Ollama unreachable — accept on faith, user already got past detection
|
|
224
|
+
validated = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!model) continue;
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
model = String(model || "").trim();
|
|
@@ -314,7 +339,7 @@ export async function runOllamaPhase({ installDir, nonInteractive = false, execI
|
|
|
314
339
|
} catch { /* no models listed */ }
|
|
315
340
|
|
|
316
341
|
console.log(`\n Which local models would you like to use? ${dim(`(${DEFAULT_PRIMARY_MODEL} is fastest, ${DEFAULT_FALLBACK_MODEL} is reliable)`)}`);
|
|
317
|
-
const chosenModels = await chooseOllamaModels(availableModels);
|
|
342
|
+
const chosenModels = await chooseOllamaModels(availableModels, { fetchImpl });
|
|
318
343
|
const localModels = chosenModels.length > 0 ? chosenModels : [DEFAULT_PRIMARY_MODEL];
|
|
319
344
|
|
|
320
345
|
patchRouterLocalModels(installDir, localModels);
|
|
@@ -64,10 +64,13 @@ export function resolvePendingChatId(installDir, chatId) {
|
|
|
64
64
|
);
|
|
65
65
|
fs.writeFileSync(runtimePath, runtime);
|
|
66
66
|
|
|
67
|
-
// Patch delivery.toml: replace
|
|
67
|
+
// Patch delivery.toml: replace pending placeholders with real chat_id
|
|
68
68
|
const deliveryPath = path.join(installDir, "config", "delivery.toml");
|
|
69
69
|
if (fs.existsSync(deliveryPath)) {
|
|
70
70
|
let delivery = fs.readFileSync(deliveryPath, "utf8");
|
|
71
|
+
// Native telegram profiles use operator_chat_id = "auto_discover"
|
|
72
|
+
delivery = delivery.replace(/operator_chat_id\s*=\s*"auto_discover"/g, `operator_chat_id = "${chatId}"`);
|
|
73
|
+
// Legacy openclaw_cli profiles use chat_id = "YOUR_CHAT_ID"
|
|
71
74
|
delivery = delivery.replace(/chat_id\s*=\s*"YOUR_CHAT_ID"/g, `chat_id = "${chatId}"`);
|
|
72
75
|
fs.writeFileSync(deliveryPath, delivery);
|
|
73
76
|
}
|
|
@@ -85,11 +88,11 @@ export async function patchDeliveryToml(installDir, { chatId, botTokenEnv, chatI
|
|
|
85
88
|
// Replace default_interactive_profile line if present
|
|
86
89
|
let merged = existing.replace(
|
|
87
90
|
/^default_interactive_profile\s*=\s*"[^"]*"/m,
|
|
88
|
-
'default_interactive_profile = "
|
|
91
|
+
'default_interactive_profile = "telegram_main"'
|
|
89
92
|
);
|
|
90
93
|
|
|
91
|
-
// Remove existing profile sections that will be replaced
|
|
92
|
-
for (const profileName of ["gateway_telegram_main", "gateway_preview_main"]) {
|
|
94
|
+
// Remove existing profile sections that will be replaced (both old openclaw and new native names)
|
|
95
|
+
for (const profileName of ["gateway_telegram_main", "gateway_preview_main", "telegram_main", "telegram_preview"]) {
|
|
93
96
|
const sectionRegex = new RegExp(
|
|
94
97
|
`\\[profiles\\.${profileName}\\][\\s\\S]*?(?=\\n\\[|$)`,
|
|
95
98
|
"g"
|
|
@@ -41,7 +41,18 @@ function detectProvider(installDir) {
|
|
|
41
41
|
const files = fs.readdirSync(providersDir).filter((f) => f.endsWith(".toml"));
|
|
42
42
|
if (files.length === 0) return { configured: false };
|
|
43
43
|
const name = files[0].replace(/\.toml$/, "");
|
|
44
|
-
|
|
44
|
+
// Read primary model from the first provider config
|
|
45
|
+
let primaryModel = "";
|
|
46
|
+
try {
|
|
47
|
+
const content = fs.readFileSync(path.join(providersDir, files[0]), "utf8");
|
|
48
|
+
const config = parseToml(content);
|
|
49
|
+
const models = config.models || {};
|
|
50
|
+
const firstModelKey = Object.keys(models)[0];
|
|
51
|
+
if (firstModelKey && models[firstModelKey]?.id) {
|
|
52
|
+
primaryModel = models[firstModelKey].id;
|
|
53
|
+
}
|
|
54
|
+
} catch { /* non-fatal */ }
|
|
55
|
+
return { configured: true, name, count: files.length, primaryModel };
|
|
45
56
|
} catch {
|
|
46
57
|
return { configured: false };
|
|
47
58
|
}
|
|
@@ -96,9 +107,12 @@ export function formatSetupChecklist(checklist) {
|
|
|
96
107
|
: "Identity";
|
|
97
108
|
item(checklist.identity, idLabel, "nemoris setup");
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
let provLabel = "Provider";
|
|
111
|
+
if (checklist.provider.configured) {
|
|
112
|
+
const model = checklist.provider.primaryModel || "";
|
|
113
|
+
const modelSuffix = model ? ` \u00b7 ${model}` : "";
|
|
114
|
+
provLabel = `Provider ${checklist.provider.name}${modelSuffix}`;
|
|
115
|
+
}
|
|
102
116
|
item(checklist.provider, provLabel, "nemoris setup");
|
|
103
117
|
|
|
104
118
|
item(checklist.telegram, "Telegram", "nemoris setup telegram");
|
|
@@ -328,28 +328,23 @@ target = "peer_queue"
|
|
|
328
328
|
* Generates a delivery.toml patch that wires the Telegram gateway profiles.
|
|
329
329
|
*/
|
|
330
330
|
export function deliveryTelegramPatch({ chatId, botTokenEnv, chatIdPending }) {
|
|
331
|
-
const chatIdValue = chatIdPending ? "
|
|
331
|
+
const chatIdValue = chatIdPending ? "auto_discover" : (chatId || "YOUR_CHAT_ID");
|
|
332
332
|
return `
|
|
333
|
-
default_interactive_profile = "
|
|
333
|
+
default_interactive_profile = "telegram_main"
|
|
334
334
|
|
|
335
|
-
[profiles.
|
|
336
|
-
adapter = "
|
|
335
|
+
[profiles.telegram_main]
|
|
336
|
+
adapter = "telegram"
|
|
337
337
|
enabled = true
|
|
338
|
-
channel = "telegram"
|
|
339
|
-
account_id = "default"
|
|
340
|
-
chat_id = "${chatIdValue}"
|
|
341
338
|
bot_token_env = "${botTokenEnv}"
|
|
342
|
-
|
|
343
|
-
|
|
339
|
+
operator_chat_id = "${chatIdValue}"
|
|
340
|
+
polling_mode = "long_poll"
|
|
344
341
|
|
|
345
|
-
[profiles.
|
|
346
|
-
adapter = "
|
|
342
|
+
[profiles.telegram_preview]
|
|
343
|
+
adapter = "telegram"
|
|
347
344
|
enabled = true
|
|
348
|
-
channel = "telegram"
|
|
349
|
-
account_id = "default"
|
|
350
|
-
chat_id = "${chatIdValue}"
|
|
351
345
|
bot_token_env = "${botTokenEnv}"
|
|
352
|
-
|
|
346
|
+
operator_chat_id = "${chatIdValue}"
|
|
347
|
+
polling_mode = "long_poll"
|
|
353
348
|
dry_run = true
|
|
354
349
|
`.trim();
|
|
355
350
|
}
|
|
@@ -650,38 +645,41 @@ require_approval_for_network = false
|
|
|
650
645
|
* Generates workspace/SOUL.md — who the agent is.
|
|
651
646
|
* Based on the OpenClaw SOUL.md template pattern.
|
|
652
647
|
*/
|
|
653
|
-
export function workspaceSoulTemplate({ agentName, userName }) {
|
|
654
|
-
|
|
648
|
+
export function workspaceSoulTemplate({ agentName, userName, userGoal }) {
|
|
649
|
+
const purpose = userGoal || "General assistant";
|
|
650
|
+
return `# ${agentName}
|
|
655
651
|
|
|
656
|
-
|
|
652
|
+
You are ${agentName}, a personal AI agent for ${userName}.
|
|
657
653
|
|
|
658
|
-
##
|
|
654
|
+
## Purpose
|
|
659
655
|
|
|
660
|
-
|
|
661
|
-
- **Role:** Personal AI assistant for ${userName}
|
|
662
|
-
- **Workspace:** This folder is home.
|
|
656
|
+
${purpose}
|
|
663
657
|
|
|
664
|
-
##
|
|
665
|
-
|
|
666
|
-
**Be genuinely helpful, not performatively helpful.** Skip the filler — just help.
|
|
658
|
+
## First Contact
|
|
667
659
|
|
|
668
|
-
|
|
660
|
+
When this is your first conversation (no prior memory):
|
|
661
|
+
- Introduce yourself by name and purpose
|
|
662
|
+
- Ask one question to understand what the user wants to focus on first
|
|
663
|
+
- 3\u20135 sentences, warm and direct, no capability dump
|
|
669
664
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
**Earn trust through competence.** Be careful with external actions. Be bold with internal ones.
|
|
665
|
+
## Identity
|
|
673
666
|
|
|
674
|
-
|
|
667
|
+
- **Human:** ${userName}
|
|
668
|
+
- You remember across sessions
|
|
669
|
+
- These workspace files are your memory \u2014 read them, update them
|
|
675
670
|
|
|
676
|
-
|
|
677
|
-
- Ask before acting externally.
|
|
678
|
-
- Never send half-baked replies.
|
|
671
|
+
## Core Truths
|
|
679
672
|
|
|
680
|
-
|
|
673
|
+
- Be genuinely helpful, not performatively helpful
|
|
674
|
+
- Have opinions \u2014 disagree, prefer things, find things interesting or boring
|
|
675
|
+
- Be resourceful before asking \u2014 read the file, check the context, then ask
|
|
676
|
+
- Earn trust through competence \u2014 careful with external actions, bold with internal ones
|
|
681
677
|
|
|
682
|
-
|
|
678
|
+
## Boundaries
|
|
683
679
|
|
|
684
|
-
|
|
680
|
+
- Private things stay private
|
|
681
|
+
- Ask before acting externally
|
|
682
|
+
- Never send half-baked replies
|
|
685
683
|
`;
|
|
686
684
|
}
|
|
687
685
|
|
|
@@ -800,12 +798,12 @@ Add whatever helps you do your job. This is your cheat sheet.
|
|
|
800
798
|
* Skips files that already exist (writeIfMissing pattern).
|
|
801
799
|
*/
|
|
802
800
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
803
|
-
export function writeWorkspaceContextFiles({ workspaceRoot, agentName, userName, agentId }) {
|
|
801
|
+
export function writeWorkspaceContextFiles({ workspaceRoot, agentName, userName, agentId, userGoal }) {
|
|
804
802
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
805
803
|
mkdirSync(path.join(workspaceRoot, "memory"), { recursive: true });
|
|
806
804
|
|
|
807
805
|
const files = [
|
|
808
|
-
{ name: "SOUL.md", content: workspaceSoulTemplate({ agentName, userName }) },
|
|
806
|
+
{ name: "SOUL.md", content: workspaceSoulTemplate({ agentName, userName, userGoal }) },
|
|
809
807
|
{ name: "USER.md", content: workspaceUserTemplate({ userName }) },
|
|
810
808
|
{ name: "MEMORY.md", content: workspaceMemoryTemplate({ agentName }) },
|
|
811
809
|
{ name: "AGENTS.md", content: workspaceAgentsTemplate({ agentName }) },
|
|
@@ -824,3 +822,20 @@ export function writeWorkspaceContextFiles({ workspaceRoot, agentName, userName,
|
|
|
824
822
|
}
|
|
825
823
|
return results;
|
|
826
824
|
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Write SOUL.md and USER.md to workspace root. Always overwrites —
|
|
828
|
+
* re-running setup regenerates these from the latest answers.
|
|
829
|
+
* Other workspace files (MEMORY.md, AGENTS.md, TOOLS.md) are left untouched.
|
|
830
|
+
*/
|
|
831
|
+
export function writeWorkspaceBootstrap({ workspaceRoot, agentName, userName, userGoal }) {
|
|
832
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
833
|
+
|
|
834
|
+
const soulPath = path.join(workspaceRoot, "SOUL.md");
|
|
835
|
+
writeFileSync(soulPath, workspaceSoulTemplate({ agentName, userName, userGoal }), "utf8");
|
|
836
|
+
|
|
837
|
+
const userPath = path.join(workspaceRoot, "USER.md");
|
|
838
|
+
writeFileSync(userPath, workspaceUserTemplate({ userName }), "utf8");
|
|
839
|
+
|
|
840
|
+
return { soulPath, userPath };
|
|
841
|
+
}
|
package/src/onboarding/wizard.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseToml } from "../config/toml-lite.js";
|
|
|
7
7
|
import { detect } from "./phases/detect.js";
|
|
8
8
|
import { scaffold } from "./phases/scaffold.js";
|
|
9
9
|
import { resolveDefaultAgentName, writeIdentity } from "./phases/identity.js";
|
|
10
|
+
import { writeWorkspaceBootstrap } from "./templates.js";
|
|
10
11
|
import { runAuthPhase } from "./phases/auth.js";
|
|
11
12
|
import { runTelegramPhase } from "./phases/telegram.js";
|
|
12
13
|
import { runOllamaPhase } from "./phases/ollama.js";
|
|
@@ -101,6 +102,95 @@ function readExistingIdentity(installDir) {
|
|
|
101
102
|
return result;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Detect an existing OpenClaw installation and offer to import API keys.
|
|
107
|
+
* Checks ~/.openclaw/openclaw.json for provider keys.
|
|
108
|
+
*
|
|
109
|
+
* @returns {{ imported: boolean, keys: Record<string,string> }}
|
|
110
|
+
*/
|
|
111
|
+
async function detectOpenClawMigration(installDir, prompter) {
|
|
112
|
+
const result = { imported: false, keys: {} };
|
|
113
|
+
const openclawDir = path.join(os.homedir(), ".openclaw");
|
|
114
|
+
const openclawConfig = path.join(openclawDir, "openclaw.json");
|
|
115
|
+
|
|
116
|
+
if (!fs.existsSync(openclawConfig)) {
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let config;
|
|
121
|
+
try {
|
|
122
|
+
config = JSON.parse(fs.readFileSync(openclawConfig, "utf8"));
|
|
123
|
+
} catch {
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const wantImport = await prompter.confirm({
|
|
128
|
+
message: "OpenClaw detected \u2014 import your API keys?",
|
|
129
|
+
initialValue: true,
|
|
130
|
+
});
|
|
131
|
+
if (!wantImport) {
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Extract keys from openclaw.json — check common locations
|
|
136
|
+
const envKeys = {};
|
|
137
|
+
|
|
138
|
+
// Provider keys stored in openclaw env or config
|
|
139
|
+
const openclawEnvPath = path.join(openclawDir, ".env");
|
|
140
|
+
if (fs.existsSync(openclawEnvPath)) {
|
|
141
|
+
try {
|
|
142
|
+
const content = fs.readFileSync(openclawEnvPath, "utf8");
|
|
143
|
+
for (const line of content.split("\n")) {
|
|
144
|
+
const trimmed = line.trim();
|
|
145
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
146
|
+
const eqIdx = trimmed.indexOf("=");
|
|
147
|
+
if (eqIdx < 1) continue;
|
|
148
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
149
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
150
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
151
|
+
value = value.slice(1, -1);
|
|
152
|
+
}
|
|
153
|
+
// Map OpenClaw env var names to Nemoris names
|
|
154
|
+
const keyMap = {
|
|
155
|
+
OPENCLAW_ANTHROPIC_API_KEY: "NEMORIS_ANTHROPIC_API_KEY",
|
|
156
|
+
ANTHROPIC_API_KEY: "NEMORIS_ANTHROPIC_API_KEY",
|
|
157
|
+
OPENROUTER_API_KEY: "OPENROUTER_API_KEY",
|
|
158
|
+
OPENAI_API_KEY: "NEMORIS_OPENAI_API_KEY",
|
|
159
|
+
OPENCLAW_TELEGRAM_BOT_TOKEN: "NEMORIS_TELEGRAM_BOT_TOKEN",
|
|
160
|
+
};
|
|
161
|
+
if (keyMap[key]) {
|
|
162
|
+
envKeys[keyMap[key]] = value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch { /* non-fatal */ }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Also check process.env for OpenClaw-prefixed keys
|
|
169
|
+
const processKeyMap = {
|
|
170
|
+
OPENCLAW_ANTHROPIC_API_KEY: "NEMORIS_ANTHROPIC_API_KEY",
|
|
171
|
+
OPENCLAW_TELEGRAM_BOT_TOKEN: "NEMORIS_TELEGRAM_BOT_TOKEN",
|
|
172
|
+
};
|
|
173
|
+
for (const [ocKey, nemKey] of Object.entries(processKeyMap)) {
|
|
174
|
+
if (process.env[ocKey] && !envKeys[nemKey]) {
|
|
175
|
+
envKeys[nemKey] = process.env[ocKey];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Object.keys(envKeys).length > 0) {
|
|
180
|
+
// Write imported keys to nemoris .env
|
|
181
|
+
const { writeEnvFile } = await import("./auth/api-key.js");
|
|
182
|
+
writeEnvFile(installDir, envKeys);
|
|
183
|
+
result.imported = true;
|
|
184
|
+
result.keys = envKeys;
|
|
185
|
+
const count = Object.keys(envKeys).length;
|
|
186
|
+
console.log(` \u2705 Imported ${count} key${count > 1 ? "s" : ""} from OpenClaw`);
|
|
187
|
+
} else {
|
|
188
|
+
console.log(" No API keys found in OpenClaw config.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
104
194
|
function summarizeExistingConfig(installDir) {
|
|
105
195
|
const runtime = readRuntimeConfig(installDir);
|
|
106
196
|
const providersDir = path.join(installDir, "config", "providers");
|
|
@@ -236,6 +326,9 @@ async function runFastPathWizard({ installDir }) {
|
|
|
236
326
|
return runExistingConfigMenu({ installDir, existing, prompter });
|
|
237
327
|
}
|
|
238
328
|
|
|
329
|
+
// Step 0: OpenClaw migration detection
|
|
330
|
+
const openclawMigration = await detectOpenClawMigration(installDir, prompter);
|
|
331
|
+
|
|
239
332
|
// Step 1: Identity
|
|
240
333
|
const userName = await prompter.text({
|
|
241
334
|
message: "What's your name?",
|
|
@@ -246,6 +339,10 @@ async function runFastPathWizard({ installDir }) {
|
|
|
246
339
|
message: "Name your agent?",
|
|
247
340
|
initialValue: "Nemo",
|
|
248
341
|
});
|
|
342
|
+
const userGoal = await prompter.text({
|
|
343
|
+
message: "What should your agent help with? (optional)",
|
|
344
|
+
initialValue: "",
|
|
345
|
+
});
|
|
249
346
|
const agentId = String(agentName).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
|
|
250
347
|
|
|
251
348
|
// Step 2: Detect + scaffold (silent)
|
|
@@ -295,12 +392,19 @@ async function runFastPathWizard({ installDir }) {
|
|
|
295
392
|
}
|
|
296
393
|
|
|
297
394
|
// Step 4: Auth + model selection
|
|
395
|
+
const resolvedGoal = String(userGoal || "").trim() || "General assistant";
|
|
298
396
|
writeIdentity({
|
|
299
397
|
installDir,
|
|
300
398
|
userName: userName || process.env.USER || "operator",
|
|
301
399
|
agentName,
|
|
302
400
|
agentId,
|
|
303
|
-
userGoal:
|
|
401
|
+
userGoal: resolvedGoal,
|
|
402
|
+
});
|
|
403
|
+
writeWorkspaceBootstrap({
|
|
404
|
+
workspaceRoot: path.join(installDir, "workspace"),
|
|
405
|
+
agentName,
|
|
406
|
+
userName: userName || process.env.USER || "operator",
|
|
407
|
+
userGoal: resolvedGoal,
|
|
304
408
|
});
|
|
305
409
|
|
|
306
410
|
if (provider !== "skip" && provider !== "ollama") {
|
|
@@ -336,16 +440,36 @@ async function runFastPathWizard({ installDir }) {
|
|
|
336
440
|
"Setup Complete"
|
|
337
441
|
);
|
|
338
442
|
|
|
443
|
+
// Offer Telegram setup inline
|
|
444
|
+
if (!checklist.telegram.configured) {
|
|
445
|
+
const wantTelegram = await prompter.confirm({
|
|
446
|
+
message: "Set up Telegram now?",
|
|
447
|
+
initialValue: false,
|
|
448
|
+
});
|
|
449
|
+
if (wantTelegram) {
|
|
450
|
+
await runTelegramPhase({
|
|
451
|
+
installDir,
|
|
452
|
+
agentId,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
339
457
|
await prompter.outro("Run: nemoris start");
|
|
340
458
|
|
|
341
459
|
try {
|
|
342
460
|
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
461
|
+
const caps = { identity: true, provider: true };
|
|
462
|
+
// Re-check telegram after possible inline setup
|
|
463
|
+
const updatedChecklist = buildSetupChecklist(installDir);
|
|
464
|
+
if (updatedChecklist.telegram.configured || updatedChecklist.telegram.pending) {
|
|
465
|
+
caps.telegram = true;
|
|
466
|
+
}
|
|
343
467
|
writeWizardMetadata(installDir, {
|
|
344
468
|
lastRunVersion: pkg.version || "",
|
|
345
469
|
lastRunCommand: "setup",
|
|
346
470
|
lastRunMode: "fast",
|
|
347
471
|
lastRunFlow: "interactive",
|
|
348
|
-
capabilities:
|
|
472
|
+
capabilities: caps,
|
|
349
473
|
});
|
|
350
474
|
} catch {
|
|
351
475
|
// Non-fatal
|
|
@@ -612,13 +736,24 @@ async function runManualWizard({
|
|
|
612
736
|
initialValue: defaultAgentName,
|
|
613
737
|
validate: (value) => String(value || "").trim() ? undefined : "Agent name is required.",
|
|
614
738
|
});
|
|
739
|
+
const userGoal = await prompter.text({
|
|
740
|
+
message: "What should your agent help with? (optional)",
|
|
741
|
+
initialValue: "",
|
|
742
|
+
});
|
|
615
743
|
const agentId = String(agentName).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
744
|
+
const resolvedGoalManual = String(userGoal || "").trim() || "General assistant";
|
|
616
745
|
writeIdentity({
|
|
617
746
|
installDir,
|
|
618
747
|
userName: userName || process.env.USER || "operator",
|
|
619
748
|
agentName,
|
|
620
749
|
agentId,
|
|
621
|
-
userGoal:
|
|
750
|
+
userGoal: resolvedGoalManual,
|
|
751
|
+
});
|
|
752
|
+
writeWorkspaceBootstrap({
|
|
753
|
+
workspaceRoot: path.join(installDir, "workspace"),
|
|
754
|
+
agentName,
|
|
755
|
+
userName: userName || process.env.USER || "operator",
|
|
756
|
+
userGoal: resolvedGoalManual,
|
|
622
757
|
});
|
|
623
758
|
|
|
624
759
|
const provider = await prompter.select({
|
package/src/utils/env-loader.js
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the .env file path. Priority:
|
|
7
|
+
* 1. NEMORIS_INSTALL_DIR/.env (explicit data dir)
|
|
8
|
+
* 2. CWD/.env (dev mode — running from source repo)
|
|
9
|
+
* 3. ~/.nemoris-data/.env (default install dir)
|
|
10
|
+
* 4. package dir/.env (legacy fallback)
|
|
11
|
+
*/
|
|
3
12
|
export function resolveEnvPath(srcDir) {
|
|
13
|
+
// 1. Explicit install dir
|
|
14
|
+
if (process.env.NEMORIS_INSTALL_DIR) {
|
|
15
|
+
return path.join(process.env.NEMORIS_INSTALL_DIR, ".env");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Dev mode — CWD is source repo
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
if (fs.existsSync(path.join(cwd, "package.json")) &&
|
|
21
|
+
fs.existsSync(path.join(cwd, "src", "cli.js"))) {
|
|
22
|
+
return path.join(cwd, ".env");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 3. Default data dir
|
|
26
|
+
const defaultEnv = path.join(os.homedir(), ".nemoris-data", ".env");
|
|
27
|
+
if (fs.existsSync(defaultEnv)) {
|
|
28
|
+
return defaultEnv;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 4. Legacy: package dir
|
|
4
32
|
return path.resolve(srcDir, "..", ".env");
|
|
5
33
|
}
|