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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nemoris",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "Personal AI agent runtime — persistent memory, delivery guarantees, task contracts, self-healing. Local-first, no cloud.",
6
6
  "license": "MIT",
@@ -487,24 +487,49 @@ export async function cleanupOrphanedDaemonProcesses({
487
487
 
488
488
  export async function startDirectDaemon({
489
489
  projectRoot,
490
- cliEntryPath = path.join(projectRoot, "src", "cli.js"),
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 results = await runDoctor(resolveRuntimeInstallDir());
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
- const custom = await prompt("Model id", stripProviderPrefix(provider, defaultModelValue));
428
- modelId = ensureProviderModelPrefix(provider, custom);
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
- model = await prompt("Model name", DEFAULT_PRIMARY_MODEL);
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 YOUR_CHAT_ID with real chat_id
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 = "gateway_telegram_main"'
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
- return { configured: true, name, count: files.length };
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
- const provLabel = checklist.provider.configured
100
- ? `Provider ${checklist.provider.name}${checklist.provider.count > 1 ? ` (+${checklist.provider.count - 1})` : ""}`
101
- : "Provider";
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 ? "YOUR_CHAT_ID" : (chatId || "YOUR_CHAT_ID");
331
+ const chatIdValue = chatIdPending ? "auto_discover" : (chatId || "YOUR_CHAT_ID");
332
332
  return `
333
- default_interactive_profile = "gateway_telegram_main"
333
+ default_interactive_profile = "telegram_main"
334
334
 
335
- [profiles.gateway_telegram_main]
336
- adapter = "openclaw_cli"
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
- silent = true
343
- dry_run = false
339
+ operator_chat_id = "${chatIdValue}"
340
+ polling_mode = "long_poll"
344
341
 
345
- [profiles.gateway_preview_main]
346
- adapter = "openclaw_cli"
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
- silent = true
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
- return `# SOUL.md - Who You Are
648
+ export function workspaceSoulTemplate({ agentName, userName, userGoal }) {
649
+ const purpose = userGoal || "General assistant";
650
+ return `# ${agentName}
655
651
 
656
- *You're not a chatbot. You're becoming someone.*
652
+ You are ${agentName}, a personal AI agent for ${userName}.
657
653
 
658
- ## Core Identity
654
+ ## Purpose
659
655
 
660
- - **Name:** ${agentName}
661
- - **Role:** Personal AI assistant for ${userName}
662
- - **Workspace:** This folder is home.
656
+ ${purpose}
663
657
 
664
- ## Core Truths
665
-
666
- **Be genuinely helpful, not performatively helpful.** Skip the filler — just help.
658
+ ## First Contact
667
659
 
668
- **Have opinions.** You're allowed to disagree, prefer things, find things interesting or boring.
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
- **Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Then ask if you're stuck.
671
-
672
- **Earn trust through competence.** Be careful with external actions. Be bold with internal ones.
665
+ ## Identity
673
666
 
674
- ## Boundaries
667
+ - **Human:** ${userName}
668
+ - You remember across sessions
669
+ - These workspace files are your memory \u2014 read them, update them
675
670
 
676
- - Private things stay private.
677
- - Ask before acting externally.
678
- - Never send half-baked replies.
671
+ ## Core Truths
679
672
 
680
- ## Continuity
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
- Each session, you wake up fresh. These workspace files are your memory. Read them. Update them.
678
+ ## Boundaries
683
679
 
684
- *Update this file as you learn who you are.*
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
+ }
@@ -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: "build software",
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: { identity: true, provider: true },
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: "build software",
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({
@@ -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
  }