nemoris 0.1.6 → 0.1.9

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.6",
3
+ "version": "0.1.9",
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
@@ -2450,7 +2450,17 @@ export async function main(argv = process.argv) {
2450
2450
  const { pidFile } = getDaemonPaths(installDir);
2451
2451
  const trackedPid = readTrackedPid(pidFile);
2452
2452
  const hasTrackedDaemon = isPidRunning(trackedPid);
2453
- const runningPids = hasTrackedDaemon ? [trackedPid] : await listServeDaemonPids();
2453
+ let runningPids = hasTrackedDaemon ? [trackedPid] : await listServeDaemonPids();
2454
+ // Fallback: check launchd/systemd service if no PID found
2455
+ if (runningPids.length === 0) {
2456
+ try {
2457
+ const { isDaemonRunning } = await import("./onboarding/platform.js");
2458
+ if (isDaemonRunning()) {
2459
+ // Service is running but PID file is missing — proceed anyway
2460
+ runningPids = [0];
2461
+ }
2462
+ } catch { /* platform check unavailable */ }
2463
+ }
2454
2464
  if (runningPids.length === 0) {
2455
2465
  writeOutput(process.stderr, "Daemon not running. Start with: nemoris start");
2456
2466
  process.exitCode = 1;
@@ -2735,13 +2745,27 @@ export async function main(argv = process.argv) {
2735
2745
  });
2736
2746
  } else if (command === "doctor") {
2737
2747
  const { runDoctor, formatDoctorReport } = await import("./onboarding/doctor.js");
2738
- const results = await runDoctor(resolveRuntimeInstallDir());
2748
+ const doctorInstallDir = resolveRuntimeInstallDir();
2749
+ const results = await runDoctor(doctorInstallDir);
2739
2750
  const json = rest.includes("--json");
2751
+ const fix = rest.includes("--fix");
2740
2752
  if (json) {
2741
2753
  console.log(JSON.stringify(results, null, 2));
2742
2754
  } else {
2743
2755
  console.log(formatDoctorReport(results));
2744
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
+ }
2745
2769
  return results.exitCode;
2746
2770
  } else if (command === "battle") {
2747
2771
  const { runBattle, parseBattleFlags } = await import("./battle.js");
@@ -78,37 +78,66 @@ const PROVIDER_MODEL_PRESETS = {
78
78
  },
79
79
  ],
80
80
  openrouter: [
81
- {
82
- key: "haiku",
83
- id: "openrouter/anthropic/claude-haiku-4-5",
84
- label: "Claude Haiku 4.5",
85
- description: "Lowest-cost OpenRouter default.",
86
- },
81
+ // Anthropic via OpenRouter
87
82
  {
88
83
  key: "sonnet",
89
84
  id: "openrouter/anthropic/claude-sonnet-4-6",
90
- label: "Claude Sonnet 4.6",
85
+ label: "Sonnet 4.6 (Anthropic)",
91
86
  description: "Strong general-purpose default.",
87
+ group: "Anthropic",
92
88
  },
93
89
  {
94
- key: "gpt4o",
95
- id: "openrouter/openai/gpt-4o",
96
- label: "GPT-4o",
97
- description: "Fast multimodal OpenAI route through OpenRouter.",
90
+ key: "haiku",
91
+ id: "openrouter/anthropic/claude-haiku-4-5",
92
+ label: "Haiku 4.5 (Anthropic)",
93
+ description: "Fastest and cheapest Anthropic model.",
94
+ group: "Anthropic",
98
95
  },
99
96
  {
100
97
  key: "opus",
101
98
  id: "openrouter/anthropic/claude-opus-4-6",
102
- label: "Claude Opus 4.6",
99
+ label: "Opus 4.6 (Anthropic)",
103
100
  description: "Highest quality. Use when Sonnet isn't enough.",
101
+ group: "Anthropic",
102
+ },
103
+ // OpenAI via OpenRouter
104
+ {
105
+ key: "gpt4o",
106
+ id: "openrouter/openai/gpt-4o",
107
+ label: "GPT-4o (OpenAI)",
108
+ description: "Fast multimodal OpenAI model.",
109
+ group: "OpenAI",
110
+ },
111
+ {
112
+ key: "gpt4omini",
113
+ id: "openrouter/openai/gpt-4o-mini",
114
+ label: "GPT-4o mini (OpenAI)",
115
+ description: "Cheapest OpenAI option.",
116
+ group: "OpenAI",
117
+ },
118
+ // Google via OpenRouter
119
+ {
120
+ key: "gemini31pro",
121
+ id: "openrouter/google/gemini-3.1-pro",
122
+ label: "Gemini 3.1 Pro (Google)",
123
+ description: "Frontier Google model, strong benchmarks.",
124
+ group: "Google",
125
+ },
126
+ // Meta via OpenRouter
127
+ {
128
+ key: "llama4scout",
129
+ id: "openrouter/meta-llama/llama-4-scout",
130
+ label: "Llama 4 Scout (Meta)",
131
+ description: "Open-weight Meta model.",
132
+ group: "Meta",
104
133
  },
105
134
  ],
106
135
  openai: [
107
136
  {
108
- key: "gpt41",
109
- id: "openai-codex/gpt-4.1",
110
- label: "GPT-4.1",
111
- description: "Latest large-context default.",
137
+ key: "gpt54",
138
+ id: "openai-codex/gpt-5.4",
139
+ label: "GPT-5.4",
140
+ description: "Latest flagship model.",
112
141
  },
113
142
  {
114
143
  key: "gpt4o",
@@ -116,18 +145,18 @@ const PROVIDER_MODEL_PRESETS = {
116
145
  label: "GPT-4o",
117
146
  description: "Fast multimodal fallback.",
118
147
  },
148
+ {
149
+ key: "gpt4omini",
150
+ id: "openai-codex/gpt-4o-mini",
151
+ label: "GPT-4o mini",
152
+ description: "Cheapest and fastest.",
153
+ },
119
154
  {
120
155
  key: "o4mini",
121
156
  id: "openai-codex/o4-mini",
122
157
  label: "o4-mini",
123
158
  description: "Efficient reasoning path.",
124
159
  },
125
- {
126
- key: "o3",
127
- id: "openai-codex/o3",
128
- label: "o3",
129
- description: "Most capable reasoning option.",
130
- },
131
160
  ],
132
161
  };
133
162
 
@@ -275,9 +304,26 @@ async function buildProviderSelectionOptions(provider, key, { fetchImpl = global
275
304
  : curated;
276
305
  const curatedIds = new Set(curatedAvailable.map((item) => item.id));
277
306
 
278
- // Remaining fetched models not already shown as curated
307
+ // Remaining fetched models not already shown as curated.
308
+ // For providers with huge catalogs (OpenRouter: 1000+ models), filter to
309
+ // well-known vendors and cap the list to avoid an unusable wall of options.
310
+ const KNOWN_VENDORS = new Set([
311
+ "anthropic", "openai", "google", "meta-llama", "mistralai",
312
+ "deepseek", "cohere", "qwen", "microsoft", "nvidia",
313
+ ]);
314
+ const MAX_EXTRA = 20;
279
315
  const extra = fetchedModels
280
- .filter((m) => !curatedIds.has(m.id))
316
+ .filter((m) => {
317
+ if (curatedIds.has(m.id)) return false;
318
+ // For large catalogs, only show models from known vendors
319
+ if (fetchedModels.length > 50) {
320
+ const rawId = m.id.replace(/^openrouter\//, "").replace(/^openai-codex\//, "").replace(/^anthropic\//, "");
321
+ const vendor = rawId.split("/")[0];
322
+ return KNOWN_VENDORS.has(vendor);
323
+ }
324
+ return true;
325
+ })
326
+ .slice(0, MAX_EXTRA)
281
327
  .map((m) => {
282
328
  const displayId = m.id
283
329
  .replace(/^openrouter\//, "")
@@ -288,25 +334,49 @@ async function buildProviderSelectionOptions(provider, key, { fetchImpl = global
288
334
  return { value: m.id, label: displayId, description: desc };
289
335
  });
290
336
 
337
+ // Detect new models matching known patterns that aren't in the curated list
338
+ const NEW_MODEL_PATTERNS = [
339
+ /claude-.*-[5-9]-/, // future Claude major versions
340
+ /gpt-[6-9]/, // future GPT major versions
341
+ /gpt-5\.[5-9]/, // future GPT-5.x minor versions
342
+ /gemini-[4-9]/, // future Gemini major versions
343
+ /llama-[5-9]/, // future Llama major versions
344
+ ];
345
+ const newModels = fetchedModels
346
+ .filter((m) => {
347
+ if (curatedIds.has(m.id)) return false;
348
+ const rawId = stripProviderPrefix(provider, m.id);
349
+ return NEW_MODEL_PATTERNS.some((p) => p.test(rawId));
350
+ })
351
+ .map((m) => {
352
+ const displayId = stripProviderPrefix(provider, m.id);
353
+ const ctxStr = formatContextWindow(m.contextWindow);
354
+ const desc = ctxStr ? `New model \u00b7 ${ctxStr}` : "New model";
355
+ return { value: m.id, label: `\u2605 ${m.name || displayId}`, description: desc };
356
+ });
357
+
291
358
  const options = [
359
+ ...newModels,
292
360
  ...curatedAvailable.map((item) => {
293
361
  const fetched = fetchedMap.get(item.id);
294
362
  const ctxStr = fetched ? formatContextWindow(fetched.contextWindow) : "";
295
363
  const desc = ctxStr ? `${item.description} \u00b7 ${ctxStr}` : item.description;
296
364
  return { value: item.id, label: item.label, description: desc };
297
365
  }),
298
- ...extra,
366
+ { value: "__show_all__", label: "\u2193 Show all models", description: `Browse all ${fetchedModels.length} models from provider.` },
299
367
  { value: "__custom__", label: "Enter a different model name...", description: "Use a specific model id not shown in the list." },
300
368
  ];
301
369
 
302
- return { options, fetchedModels };
370
+ // Extra models only shown when "Show all" is selected
371
+ return { options, fetchedModels, extra };
303
372
  }
304
373
 
305
374
  async function promptForProviderModels(provider, key, tui, { fetchImpl = globalThis.fetch } = {}) {
306
375
  const { select, prompt, dim, cyan } = tui;
307
376
  if (!select) return { chosen: [], fetchedModels: [] };
308
377
 
309
- const { options, fetchedModels } = await buildProviderSelectionOptions(provider, key, { fetchImpl });
378
+ const { options, fetchedModels, extra } = await buildProviderSelectionOptions(provider, key, { fetchImpl });
379
+ let activeOptions = options;
310
380
  const chosen = [];
311
381
  const manualOptionValue = "__custom__";
312
382
  const defaultModelValue = options.find((item) => !String(item.value).startsWith("__"))?.value || "";
@@ -315,7 +385,7 @@ async function promptForProviderModels(provider, key, tui, { fetchImpl = globalT
315
385
  console.log(` ${dim("Pick up to three models. The first is your default. The others are fallbacks when the default is slow or unavailable.")}`);
316
386
 
317
387
  while (chosen.length < 3) {
318
- const remaining = options.filter((item) => !chosen.includes(item.value));
388
+ const remaining = activeOptions.filter((item) => !chosen.includes(item.value));
319
389
  const pickerOptions = remaining.map((item) => ({
320
390
  label: item.label,
321
391
  value: item.value,
@@ -342,6 +412,16 @@ async function promptForProviderModels(provider, key, tui, { fetchImpl = globalT
342
412
  break;
343
413
  }
344
414
 
415
+ if (picked === "__show_all__") {
416
+ // Expand to full filtered list + custom entry
417
+ activeOptions = [
418
+ ...activeOptions.filter((o) => !o.value.startsWith("__")),
419
+ ...(extra || []),
420
+ { value: "__custom__", label: "Enter a different model name...", description: "Use a specific model id not shown in the list." },
421
+ ];
422
+ continue;
423
+ }
424
+
345
425
  let modelId = picked;
346
426
  if (picked === manualOptionValue) {
347
427
  const custom = await prompt("Model id", stripProviderPrefix(provider, defaultModelValue));
@@ -354,11 +354,18 @@ export function isDaemonRunning() {
354
354
 
355
355
  if (platform === "darwin") {
356
356
  try {
357
- const { status } = spawnSync(
357
+ const { status, stdout } = spawnSync(
358
358
  "launchctl", ["list", "ai.nemoris.daemon"],
359
- { timeout: 3000, stdio: "pipe" },
359
+ { timeout: 3000, stdio: "pipe", encoding: "utf8" },
360
360
  );
361
- return status === 0;
361
+ if (status !== 0) return false;
362
+ // Service is loaded — verify the process is actually alive.
363
+ // launchctl list <label> includes "PID" = <number> when running.
364
+ const pidMatch = String(stdout || "").match(/"PID"\s*=\s*(\d+)/);
365
+ if (!pidMatch) return false;
366
+ const pid = Number.parseInt(pidMatch[1], 10);
367
+ if (!pid || pid <= 0) return false;
368
+ try { process.kill(pid, 0); return true; } catch { return false; }
362
369
  } catch {
363
370
  return false;
364
371
  }
@@ -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");
@@ -200,6 +200,20 @@ async function launchChat() {
200
200
  async function runFastPathWizard({ installDir }) {
201
201
  const prompter = createClackPrompter();
202
202
 
203
+ // ASCII banner
204
+ const BRAND = "\x1b[38;2;45;212;191m";
205
+ const RESET = "\x1b[0m";
206
+ console.log([
207
+ "",
208
+ `${BRAND} ███╗ ██╗███████╗███╗ ███╗ ██████╗ ██████╗ ██╗███████╗${RESET}`,
209
+ `${BRAND} ████╗ ██║██╔════╝████╗ ████║██╔═══██╗██╔══██╗██║██╔════╝${RESET}`,
210
+ `${BRAND} ██╔██╗ ██║█████╗ ██╔████╔██║██║ ██║██████╔╝██║███████╗${RESET}`,
211
+ `${BRAND} ██║╚██╗██║██╔══╝ ██║╚██╔╝██║██║ ██║██╔══██╗██║╚════██║${RESET}`,
212
+ `${BRAND} ██║ ╚████║███████╗██║ ╚═╝ ██║╚██████╔╝██║ ██║██║███████║${RESET}`,
213
+ `${BRAND} ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝${RESET}`,
214
+ "",
215
+ ].join("\n"));
216
+
203
217
  await prompter.intro("nemoris setup");
204
218
 
205
219
  // D2 security gate — brief consent
@@ -266,6 +280,20 @@ async function runFastPathWizard({ installDir }) {
266
280
  });
267
281
  }
268
282
 
283
+ // Ask consent to use detected API key
284
+ const detectedKey = detection.apiKeys?.[provider];
285
+ if (detectedKey) {
286
+ const masked = detectedKey.slice(0, 6) + "..." + detectedKey.slice(-4);
287
+ const useDetected = await prompter.confirm({
288
+ message: `Found ${provider} key in env (${masked}) — use it?`,
289
+ initialValue: true,
290
+ });
291
+ if (!useDetected) {
292
+ // Clear detected key so auth phase will prompt for a new one
293
+ delete detection.apiKeys[provider];
294
+ }
295
+ }
296
+
269
297
  // Step 4: Auth + model selection
270
298
  writeIdentity({
271
299
  installDir,
@@ -286,24 +314,58 @@ async function runFastPathWizard({ installDir }) {
286
314
  });
287
315
  }
288
316
 
289
- // Auto-start daemon (best-effort)
290
- try {
291
- await writeDaemonUnit(installDir);
292
- await loadDaemon(installDir);
293
- } catch {
294
- // Daemon start failure is non-fatal in fast path
317
+ // Post-setup guidance
318
+ const { buildSetupChecklist, formatSetupChecklist } = await import("./setup-checklist.js");
319
+ const checklist = buildSetupChecklist(installDir);
320
+ const allConfigured = Object.values(checklist).every((c) => c.configured);
321
+
322
+ await prompter.note(
323
+ [
324
+ `Agent "${agentName}" is ready.`,
325
+ "",
326
+ "Next steps:",
327
+ " nemoris start Start the daemon",
328
+ " nemoris chat Open interactive chat",
329
+ ...(allConfigured ? [] : [
330
+ "",
331
+ "Optional:",
332
+ ...(!checklist.telegram.configured ? [" nemoris setup telegram Connect Telegram"] : []),
333
+ ...(!checklist.ollama.configured ? [" nemoris setup ollama Add local models"] : []),
334
+ ]),
335
+ ].join("\n"),
336
+ "Setup Complete"
337
+ );
338
+
339
+ // Offer Telegram setup inline
340
+ if (!checklist.telegram.configured) {
341
+ const wantTelegram = await prompter.confirm({
342
+ message: "Set up Telegram now?",
343
+ initialValue: false,
344
+ });
345
+ if (wantTelegram) {
346
+ await runTelegramPhase({
347
+ installDir,
348
+ agentId,
349
+ });
350
+ }
295
351
  }
296
352
 
297
- await prompter.outro(`Agent "${agentName}" is ready.\n Run: nemoris chat`);
353
+ await prompter.outro("Run: nemoris start");
298
354
 
299
355
  try {
300
356
  const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
357
+ const caps = { identity: true, provider: true };
358
+ // Re-check telegram after possible inline setup
359
+ const updatedChecklist = buildSetupChecklist(installDir);
360
+ if (updatedChecklist.telegram.configured || updatedChecklist.telegram.pending) {
361
+ caps.telegram = true;
362
+ }
301
363
  writeWizardMetadata(installDir, {
302
364
  lastRunVersion: pkg.version || "",
303
365
  lastRunCommand: "setup",
304
366
  lastRunMode: "fast",
305
367
  lastRunFlow: "interactive",
306
- capabilities: { identity: true, provider: true },
368
+ capabilities: caps,
307
369
  });
308
370
  } catch {
309
371
  // Non-fatal
@@ -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
  }