nemoris 0.1.4 → 0.1.5

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.4",
3
+ "version": "0.1.5",
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",
package/src/cli-main.js CHANGED
@@ -840,7 +840,7 @@ async function serveDaemon(mode = "dry-run", intervalMs = "30000", installDir =
840
840
  const execApprovalGate = handler.getExecApprovalGate();
841
841
  localDaemon.executor.setExecApprovalGate(execApprovalGate);
842
842
  localExecutor.setExecApprovalGate(execApprovalGate);
843
- telegramPoller = startTelegramPolling({ botToken: token, handler });
843
+ telegramPoller = startTelegramPolling({ botToken: token, handler, projectRoot: root });
844
844
  await stateStore.setMeta("telegram_inbound", { status: "READY", mode: "polling" });
845
845
  console.log(JSON.stringify({ service: "telegram_inbound", status: "READY", mode: "polling" }));
846
846
  } else if (token && telegramConfig.webhookUrl) {
@@ -2684,6 +2684,16 @@ export async function main(argv = process.argv) {
2684
2684
  await showModelsOverview(resolveRuntimeInstallDir());
2685
2685
  } else if (command === "setup" && rest[0] === "telegram") {
2686
2686
  await setupTelegram();
2687
+ } else if (command === "setup" && rest[0] === "provider") {
2688
+ const { runProviderSubWizard } = await import("./onboarding/wizard.js");
2689
+ return runProviderSubWizard({
2690
+ installDir: resolveRuntimeInstallDir(),
2691
+ });
2692
+ } else if (command === "setup" && rest[0] === "ollama") {
2693
+ const { runOllamaSubWizard } = await import("./onboarding/wizard.js");
2694
+ return runOllamaSubWizard({
2695
+ installDir: resolveRuntimeInstallDir(),
2696
+ });
2687
2697
  } else if (command === "telegram" && rest[0] === "whoami") {
2688
2698
  await telegramWhoami();
2689
2699
  } else if (command === "uninstall") {
@@ -2698,6 +2708,7 @@ export async function main(argv = process.argv) {
2698
2708
  installDir: resolveRuntimeInstallDir(),
2699
2709
  nonInteractive,
2700
2710
  advanced,
2711
+ manual: rest.includes("--manual"),
2701
2712
  acceptRisk: rest.includes("--accept-risk"),
2702
2713
  flow: parseFlagValue(rest, "--flow"),
2703
2714
  anthropicKey: parseFlagValue(rest, "--anthropic-key"),
@@ -2713,6 +2724,7 @@ export async function main(argv = process.argv) {
2713
2724
  installDir: resolveRuntimeInstallDir(),
2714
2725
  nonInteractive,
2715
2726
  advanced,
2727
+ manual: rest.includes("--manual"),
2716
2728
  acceptRisk: rest.includes("--accept-risk"),
2717
2729
  flow: parseFlagValue(rest, "--flow"),
2718
2730
  anthropicKey: parseFlagValue(rest, "--anthropic-key"),
@@ -33,16 +33,17 @@ export function readLock(installDir) {
33
33
  }
34
34
 
35
35
  export function writeWizardMetadata(installDir, metadata) {
36
- const lock = readLock(installDir) || {};
37
- const updated = {
38
- ...lock,
36
+ const existing = readLock(installDir) || {};
37
+ const state = {
38
+ ...existing,
39
39
  lastRunAt: metadata.lastRunAt || new Date().toISOString(),
40
40
  lastRunVersion: metadata.lastRunVersion || "",
41
41
  lastRunCommand: metadata.lastRunCommand || "setup",
42
42
  lastRunMode: metadata.lastRunMode || "quickstart",
43
43
  lastRunFlow: metadata.lastRunFlow || "interactive",
44
+ capabilities: { ...(existing.capabilities || {}), ...(metadata.capabilities || {}) },
44
45
  };
45
- writeLock(installDir, updated);
46
+ writeLock(installDir, state);
46
47
  }
47
48
 
48
49
  export function deleteLock(installDir) {
@@ -12,6 +12,7 @@ import { getMe, whoami } from "../../runtime/telegram-inbound.js";
12
12
  import { writeEnvFile } from "../auth/api-key.js";
13
13
  import { isDaemonRunning, setDaemonEnv } from "../platform.js";
14
14
  import { writeTomlSection } from "../toml-writer.js";
15
+ import { deliveryTelegramPatch } from "../templates.js";
15
16
  import {
16
17
  confirm, prompt, promptSecret, select, waitForEnter,
17
18
  bold, green, red, yellow, dim, cyan, progressLine,
@@ -47,6 +48,87 @@ export function patchRuntimeToml(installDir, telegramToml) {
47
48
  writeTomlSection(runtimePath, "telegram", telegramToml);
48
49
  }
49
50
 
51
+ /**
52
+ * Called at runtime when the daemon receives the first inbound Telegram
53
+ * message and chat_id_pending is set. Writes the discovered chat_id
54
+ * back to runtime.toml and delivery.toml, clearing the pending flag.
55
+ */
56
+ export function resolvePendingChatId(installDir, chatId) {
57
+ // Patch runtime.toml: add operator_chat_id, remove chat_id_pending
58
+ const runtimePath = path.join(installDir, "config", "runtime.toml");
59
+ let runtime = fs.readFileSync(runtimePath, "utf8");
60
+ runtime = runtime.replace(/chat_id_pending\s*=\s*true\n?/, "");
61
+ runtime = runtime.replace(
62
+ /(\[telegram\][^\[]*)/s,
63
+ (section) => section.trimEnd() + `\noperator_chat_id = "${chatId}"\n`
64
+ );
65
+ fs.writeFileSync(runtimePath, runtime);
66
+
67
+ // Patch delivery.toml: replace YOUR_CHAT_ID with real chat_id
68
+ const deliveryPath = path.join(installDir, "config", "delivery.toml");
69
+ if (fs.existsSync(deliveryPath)) {
70
+ let delivery = fs.readFileSync(deliveryPath, "utf8");
71
+ delivery = delivery.replace(/chat_id\s*=\s*"YOUR_CHAT_ID"/g, `chat_id = "${chatId}"`);
72
+ fs.writeFileSync(deliveryPath, delivery);
73
+ }
74
+ }
75
+
76
+ export async function patchDeliveryToml(installDir, { chatId, botTokenEnv, chatIdPending }) {
77
+ const deliveryPath = path.join(installDir, "config", "delivery.toml");
78
+ let existing = "";
79
+ try {
80
+ existing = fs.readFileSync(deliveryPath, "utf8");
81
+ } catch { /* no existing file */ }
82
+
83
+ const patch = deliveryTelegramPatch({ chatId, botTokenEnv, chatIdPending });
84
+
85
+ // Replace default_interactive_profile line if present
86
+ let merged = existing.replace(
87
+ /^default_interactive_profile\s*=\s*"[^"]*"/m,
88
+ 'default_interactive_profile = "gateway_telegram_main"'
89
+ );
90
+
91
+ // Remove existing profile sections that will be replaced
92
+ for (const profileName of ["gateway_telegram_main", "gateway_preview_main"]) {
93
+ const sectionRegex = new RegExp(
94
+ `\\[profiles\\.${profileName}\\][\\s\\S]*?(?=\\n\\[|$)`,
95
+ "g"
96
+ );
97
+ merged = merged.replace(sectionRegex, "");
98
+ }
99
+
100
+ // Append patch
101
+ merged = merged.trimEnd() + "\n\n" + patch + "\n";
102
+
103
+ fs.writeFileSync(deliveryPath, merged);
104
+ }
105
+
106
+ export async function discoverChatIdFromUpdates(token, options = {}) {
107
+ const { timeoutSeconds = 30, fetchImpl = globalThis.fetch } = options;
108
+ const deadline = Date.now() + timeoutSeconds * 1000;
109
+ let offset = 0;
110
+
111
+ while (Date.now() < deadline) {
112
+ const remaining = Math.max(1, Math.ceil((deadline - Date.now()) / 1000));
113
+ const pollSeconds = Math.min(remaining, 5);
114
+ const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=${pollSeconds}`;
115
+ try {
116
+ const resp = await fetchImpl(url);
117
+ const data = await resp.json();
118
+ if (data.ok && data.result?.length > 0) {
119
+ for (const update of data.result) {
120
+ offset = update.update_id + 1;
121
+ const chatId = update.message?.chat?.id;
122
+ if (chatId) return String(chatId);
123
+ }
124
+ }
125
+ } catch {
126
+ await new Promise((r) => setTimeout(r, 1000));
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
50
132
  async function sendTestMessage(token, chatId, text, { fetchImpl = globalThis.fetch } = {}) {
51
133
  try {
52
134
  const url = `https://api.telegram.org/bot${token}/sendMessage`;
@@ -177,7 +259,8 @@ export async function runTelegramPhase({ installDir, agentId, nonInteractive = f
177
259
  token = raw;
178
260
  botUsername = me.username;
179
261
  console.log(progressLineImpl("Token validated", `@${me.username}`));
180
- console.log(` ${dimImpl(TELEGRAM_START_INSTRUCTION)}`);
262
+ console.log(`\n ${dimImpl("Open this link to start your bot:")}`);
263
+ console.log(` ${cyanImpl(`https://t.me/${me.username}?start=setup`)}\n`);
181
264
  } else {
182
265
  console.log(` ${redImpl("✗")} That token didn't work — double-check it in BotFather and try again.`);
183
266
  console.log(` ${dimImpl("Try again or press Enter to skip.")}`);
@@ -188,37 +271,20 @@ export async function runTelegramPhase({ installDir, agentId, nonInteractive = f
188
271
  writeEnvFile(installDir, { [botTokenEnv]: token });
189
272
  await setDaemonEnv(botTokenEnv, token);
190
273
 
191
- // Step 2: Chat ID discovery
274
+ // Step 2: Chat ID discovery via Telegram getUpdates (30s poll)
192
275
  let chatId = null;
193
- const daemonRunning = isDaemonRunning();
276
+ const fetchImpl = tui.fetchImpl || globalThis.fetch;
194
277
 
195
- if (daemonRunning) {
196
- // Path A: daemon is running read from state store
197
- console.log(`\n Open Telegram, find ${cyanImpl(`@${botUsername}`)}, send it any message — then press Enter.`);
198
- const since = new Date().toISOString();
199
- chatId = await discoverChatIdFromStore(installDir, since);
278
+ console.log(` ${dimImpl("Waiting up to 30 seconds for your message...")}`);
279
+ chatId = await discoverChatIdFromUpdates(token, { timeoutSeconds: 30, fetchImpl });
200
280
 
201
- if (chatId) {
202
- console.log(progressLineImpl("Found you", `chat_id: ${chatId}`));
203
- } else {
204
- console.log(` ${yellowImpl("!")} Timed out waiting for a message. Try opening Telegram and sending a message to @${botUsername} first.`);
205
- const manual = await promptImpl("Enter chat_id manually (or press Enter to skip)", "");
206
- chatId = manual || null;
207
- }
281
+ if (chatId) {
282
+ console.log(progressLineImpl("Found you", `chat_id: ${chatId}`));
208
283
  } else {
209
- // Path B: daemon not running — use whoami probe
210
- await waitForEnterImpl(`Open Telegram, find ${cyanImpl(`@${botUsername}`)}, send it a message anything works. Then come back and press Enter.`);
211
- const result = await whoami(token);
212
- if (result) {
213
- chatId = String(result.chatId);
214
- const label = result.username ? `chat_id: ${chatId}, @${result.username}` : `chat_id: ${chatId}`;
215
- console.log(progressLineImpl("Found you", label));
216
- } else {
217
- console.log(` ${yellowImpl("!")} No messages found.`);
218
- console.log(` ${dimImpl("The bot may not have received your message yet.")}`);
219
- const manual = await promptImpl("Enter chat_id manually (or press Enter to skip)", "");
220
- chatId = manual || null;
221
- }
284
+ console.log(` ${yellowImpl("!")} No message received within 30 seconds.`);
285
+ console.log(` ${dimImpl("You can finish setup now and add your chat_id later.")}`);
286
+ const manual = await promptImpl("Enter chat_id manually (or press Enter to skip)", "");
287
+ chatId = manual || null;
222
288
  }
223
289
 
224
290
  const authorizedChatIds = chatId ? [chatId] : [];
@@ -253,6 +319,13 @@ export async function runTelegramPhase({ installDir, agentId, nonInteractive = f
253
319
  operatorChatId: chatId || "", authorizedChatIds, defaultAgent: agentId,
254
320
  }));
255
321
 
322
+ // Wire delivery.toml so the telegram profile is enabled
323
+ await patchDeliveryToml(installDir, {
324
+ chatId: chatId || null,
325
+ botTokenEnv,
326
+ chatIdPending: !chatId,
327
+ });
328
+
256
329
  // Store chat_id in state for later phases (e.g. hatch)
257
330
  const result = { configured: true, verified: false, botUsername, botToken: token, operatorChatId: chatId || "" };
258
331
 
@@ -260,7 +333,7 @@ export async function runTelegramPhase({ installDir, agentId, nonInteractive = f
260
333
 
261
334
  // Step 5: Smoke test (with failure handling)
262
335
  if (!chatId) {
263
- console.log(` ${yellowImpl("!")} No chat ID — skipping test message.`);
336
+ console.log(` ${yellowImpl("!")} No chat ID — skipping test message. You can set it later in config/delivery.toml.`);
264
337
  return result;
265
338
  }
266
339
 
@@ -0,0 +1,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseToml } from "../config/toml-lite.js";
4
+
5
+ /**
6
+ * Scans an install directory and returns a map of which capabilities
7
+ * are configured, pending, or unconfigured.
8
+ *
9
+ * @param {string} installDir - Path to the nemoris install directory
10
+ * @returns {{ identity, provider, telegram, ollama }} checklist
11
+ */
12
+ export function buildSetupChecklist(installDir) {
13
+ return {
14
+ identity: detectIdentity(installDir),
15
+ provider: detectProvider(installDir),
16
+ telegram: detectTelegram(installDir),
17
+ ollama: detectOllama(installDir),
18
+ };
19
+ }
20
+
21
+ function detectIdentity(installDir) {
22
+ try {
23
+ const agentsDir = path.join(installDir, "config", "agents");
24
+ if (!fs.existsSync(agentsDir)) return { configured: false };
25
+ const files = fs.readdirSync(agentsDir).filter((f) => f.endsWith(".toml"));
26
+ if (files.length === 0) return { configured: false };
27
+ const agentId = files[0].replace(/\.toml$/, "");
28
+ const soulDir = path.join(installDir, "config", "identity");
29
+ const hasSoul = fs.existsSync(soulDir) &&
30
+ fs.readdirSync(soulDir).some((f) => f.endsWith("-soul.md"));
31
+ return { configured: hasSoul, agentId };
32
+ } catch {
33
+ return { configured: false };
34
+ }
35
+ }
36
+
37
+ function detectProvider(installDir) {
38
+ try {
39
+ const providersDir = path.join(installDir, "config", "providers");
40
+ if (!fs.existsSync(providersDir)) return { configured: false };
41
+ const files = fs.readdirSync(providersDir).filter((f) => f.endsWith(".toml"));
42
+ if (files.length === 0) return { configured: false };
43
+ const name = files[0].replace(/\.toml$/, "");
44
+ return { configured: true, name, count: files.length };
45
+ } catch {
46
+ return { configured: false };
47
+ }
48
+ }
49
+
50
+ function detectTelegram(installDir) {
51
+ try {
52
+ const runtimePath = path.join(installDir, "config", "runtime.toml");
53
+ if (!fs.existsSync(runtimePath)) return { configured: false };
54
+ const content = fs.readFileSync(runtimePath, "utf8");
55
+ const config = parseToml(content);
56
+ const tg = config.telegram;
57
+ if (!tg || !tg.bot_token_env) return { configured: false };
58
+ if (tg.chat_id_pending) return { configured: false, pending: true };
59
+ if (tg.operator_chat_id) return { configured: true };
60
+ return { configured: false, pending: true };
61
+ } catch {
62
+ return { configured: false };
63
+ }
64
+ }
65
+
66
+ function detectOllama(installDir) {
67
+ try {
68
+ const providersDir = path.join(installDir, "config", "providers");
69
+ if (!fs.existsSync(providersDir)) return { configured: false };
70
+ const ollamaPath = path.join(providersDir, "ollama.toml");
71
+ return { configured: fs.existsSync(ollamaPath) };
72
+ } catch {
73
+ return { configured: false };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Format checklist for display in `nemoris status`.
79
+ * @param {object} checklist - from buildSetupChecklist()
80
+ * @returns {string} formatted text block
81
+ */
82
+ export function formatSetupChecklist(checklist) {
83
+ const lines = ["Setup"];
84
+ const item = (check, label, hint) => {
85
+ if (check.configured) {
86
+ lines.push(` ✅ ${label}`);
87
+ } else if (check.pending) {
88
+ lines.push(` ⚠️ ${label.padEnd(16)} pending auto-discovery`);
89
+ } else {
90
+ lines.push(` ◻ ${label.padEnd(16)} ${hint}`);
91
+ }
92
+ };
93
+
94
+ const idLabel = checklist.identity.configured
95
+ ? `Identity ${checklist.identity.agentId}`
96
+ : "Identity";
97
+ item(checklist.identity, idLabel, "nemoris setup");
98
+
99
+ const provLabel = checklist.provider.configured
100
+ ? `Provider ${checklist.provider.name}${checklist.provider.count > 1 ? ` (+${checklist.provider.count - 1})` : ""}`
101
+ : "Provider";
102
+ item(checklist.provider, provLabel, "nemoris setup");
103
+
104
+ item(checklist.telegram, "Telegram", "nemoris setup telegram");
105
+ item(checklist.ollama, "Local models", "nemoris setup ollama");
106
+
107
+ return lines.join("\n");
108
+ }
@@ -322,6 +322,38 @@ target = "peer_queue"
322
322
  `;
323
323
  }
324
324
 
325
+ // ── Delivery Telegram Patch ──────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Generates a delivery.toml patch that wires the Telegram gateway profiles.
329
+ */
330
+ export function deliveryTelegramPatch({ chatId, botTokenEnv, chatIdPending }) {
331
+ const chatIdValue = chatIdPending ? "YOUR_CHAT_ID" : (chatId || "YOUR_CHAT_ID");
332
+ return `
333
+ default_interactive_profile = "gateway_telegram_main"
334
+
335
+ [profiles.gateway_telegram_main]
336
+ adapter = "openclaw_cli"
337
+ enabled = true
338
+ channel = "telegram"
339
+ account_id = "default"
340
+ chat_id = "${chatIdValue}"
341
+ bot_token_env = "${botTokenEnv}"
342
+ silent = true
343
+ dry_run = false
344
+
345
+ [profiles.gateway_preview_main]
346
+ adapter = "openclaw_cli"
347
+ enabled = true
348
+ channel = "telegram"
349
+ account_id = "default"
350
+ chat_id = "${chatIdValue}"
351
+ bot_token_env = "${botTokenEnv}"
352
+ silent = true
353
+ dry_run = true
354
+ `.trim();
355
+ }
356
+
325
357
  // ── Peers ────────────────────────────────────────────────────────────────────
326
358
 
327
359
  /**
@@ -197,9 +197,259 @@ async function launchChat() {
197
197
  });
198
198
  }
199
199
 
200
+ async function runFastPathWizard({ installDir }) {
201
+ const prompter = createClackPrompter();
202
+
203
+ await prompter.intro("nemoris setup");
204
+
205
+ // D2 security gate — brief consent
206
+ await prompter.note(
207
+ "This agent will have access to your filesystem and network.\nRun with --security-details for full risk disclosure.",
208
+ "Security"
209
+ );
210
+ const consent = await prompter.confirm({
211
+ message: "Continue?",
212
+ initialValue: true,
213
+ });
214
+ if (!consent) {
215
+ await prompter.cancel("Setup cancelled.");
216
+ return 0;
217
+ }
218
+
219
+ // Existing config? → delegate to update menu (wired in Task 7, for now just exit)
220
+ const existing = readExistingIdentity(installDir);
221
+ if (existing.agentId && fs.existsSync(path.join(installDir, "config", "runtime.toml"))) {
222
+ return runExistingConfigMenu({ installDir, existing, prompter });
223
+ }
224
+
225
+ // Step 1: Identity
226
+ const userName = await prompter.text({
227
+ message: "What's your name?",
228
+ initialValue: process.env.USER || "",
229
+ validate: (v) => (v.trim() ? undefined : "Name required"),
230
+ });
231
+ const agentName = await prompter.text({
232
+ message: "Name your agent?",
233
+ initialValue: "Nemo",
234
+ });
235
+ const agentId = String(agentName).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
236
+
237
+ // Step 2: Detect + scaffold (silent)
238
+ const detection = await detect(installDir);
239
+ await scaffold({ installDir });
240
+
241
+ // Step 3: Provider selection
242
+ const providerOptions = detectProviderOptions({
243
+ env: process.env,
244
+ ollamaResult: detection.ollama ? { ok: true, models: detection.ollama.models } : { ok: false, models: [] },
245
+ });
246
+ let provider;
247
+ if (providerOptions.length === 1) {
248
+ provider = providerOptions[0].value;
249
+ } else if (providerOptions.length > 1) {
250
+ provider = await prompter.select({
251
+ message: "Pick a provider",
252
+ options: providerOptions.map((o) => ({
253
+ value: o.value,
254
+ label: o.label,
255
+ hint: o.hint,
256
+ })),
257
+ });
258
+ } else {
259
+ provider = await prompter.select({
260
+ message: "Pick a provider",
261
+ options: [
262
+ { value: "anthropic", label: "Anthropic" },
263
+ { value: "openai", label: "OpenAI" },
264
+ { value: "openrouter", label: "OpenRouter" },
265
+ ],
266
+ });
267
+ }
268
+
269
+ // Step 4: Auth + model selection
270
+ writeIdentity({
271
+ installDir,
272
+ userName: userName || process.env.USER || "operator",
273
+ agentName,
274
+ agentId,
275
+ userGoal: "build software",
276
+ });
277
+
278
+ if (provider !== "skip" && provider !== "ollama") {
279
+ await runAuthPhase(installDir, {
280
+ tui: createLegacyPromptAdapter(prompter),
281
+ detectionCache: {
282
+ rawKeys: detection.apiKeys,
283
+ ollamaResult: detection.ollama ? { ok: true, models: detection.ollama.models } : { ok: false, models: [] },
284
+ },
285
+ providerOrder: [provider],
286
+ });
287
+ }
288
+
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
295
+ }
296
+
297
+ await prompter.outro(`Agent "${agentName}" is ready.\n Run: nemoris chat`);
298
+
299
+ try {
300
+ const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
301
+ writeWizardMetadata(installDir, {
302
+ lastRunVersion: pkg.version || "",
303
+ lastRunCommand: "setup",
304
+ lastRunMode: "fast",
305
+ lastRunFlow: "interactive",
306
+ capabilities: { identity: true, provider: true },
307
+ });
308
+ } catch {
309
+ // Non-fatal
310
+ }
311
+
312
+ return 0;
313
+ }
314
+
315
+ async function runExistingConfigMenu({ installDir, existing, prompter }) {
316
+ const { buildSetupChecklist } = await import("./setup-checklist.js");
317
+
318
+ const action = await prompter.select({
319
+ message: `Agent "${existing.agentId || existing.agentName}" is already configured.`,
320
+ options: [
321
+ { value: "update_provider", label: "Update provider or model" },
322
+ { value: "add_capability", label: "Add a capability" },
323
+ { value: "reset", label: "Reset everything" },
324
+ { value: "exit", label: "Exit" },
325
+ ],
326
+ });
327
+
328
+ if (action === "update_provider") {
329
+ return runProviderSubWizard({ installDir });
330
+ }
331
+ if (action === "add_capability") {
332
+ const checklist = buildSetupChecklist(installDir);
333
+ const unconfigured = [];
334
+ if (!checklist.telegram.configured) unconfigured.push({ value: "telegram", label: "Telegram" });
335
+ if (!checklist.ollama.configured) unconfigured.push({ value: "ollama", label: "Local models (Ollama)" });
336
+ if (unconfigured.length === 0) {
337
+ await prompter.outro("All capabilities are configured!");
338
+ return;
339
+ }
340
+ const capability = await prompter.select({ message: "What would you like to add?", options: unconfigured });
341
+ if (capability === "telegram") {
342
+ return runTelegramPhase({ installDir, agentId: existing.agentId });
343
+ }
344
+ if (capability === "ollama") {
345
+ return runOllamaSubWizard({ installDir });
346
+ }
347
+ }
348
+ if (action === "reset") {
349
+ const scope = await prompter.select({
350
+ message: "What should be reset?",
351
+ options: [
352
+ { value: "config", label: "Config only" },
353
+ { value: "config+state", label: "Config + state" },
354
+ { value: "", label: "Everything" },
355
+ ],
356
+ });
357
+ resetInstallArtifacts(installDir, scope);
358
+ await prompter.outro("Reset complete. Run: nemoris setup");
359
+ return;
360
+ }
361
+ // exit — do nothing
362
+ await prompter.outro("No changes made.");
363
+ }
364
+
365
+ export async function runProviderSubWizard({ installDir }) {
366
+ const prompter = createClackPrompter();
367
+ await prompter.intro("Add a provider");
368
+
369
+ const detection = await detect(installDir);
370
+ const options = detectProviderOptions({
371
+ env: process.env,
372
+ ollamaResult: detection.ollama ? { ok: true, models: detection.ollama.models } : { ok: false, models: [] },
373
+ });
374
+
375
+ // Filter out already-configured providers
376
+ const providersDir = path.join(installDir, "config", "providers");
377
+ const existingProviders = fs.existsSync(providersDir)
378
+ ? fs.readdirSync(providersDir).filter((f) => f.endsWith(".toml")).map((f) => f.replace(/\.toml$/, ""))
379
+ : [];
380
+ const filtered = options.filter((o) => !existingProviders.includes(o.value));
381
+
382
+ if (filtered.length === 0) {
383
+ await prompter.outro("All detected providers are already configured.");
384
+ return;
385
+ }
386
+
387
+ const provider = await prompter.select({ message: "Which provider?", options: filtered });
388
+
389
+ // Reuse existing auth phase
390
+ if (provider !== "skip" && provider !== "ollama") {
391
+ await runAuthPhase(installDir, {
392
+ tui: createLegacyPromptAdapter(prompter),
393
+ detectionCache: {
394
+ rawKeys: detection.apiKeys,
395
+ ollamaResult: detection.ollama ? { ok: true, models: detection.ollama.models } : { ok: false, models: [] },
396
+ },
397
+ providerOrder: [provider],
398
+ });
399
+ }
400
+
401
+ try {
402
+ const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
403
+ writeWizardMetadata(installDir, {
404
+ lastRunVersion: pkg.version || "",
405
+ lastRunCommand: "setup provider",
406
+ lastRunMode: "fast",
407
+ lastRunFlow: "interactive",
408
+ capabilities: { [`provider_${provider}`]: true },
409
+ });
410
+ } catch {
411
+ // Non-fatal
412
+ }
413
+
414
+ await prompter.outro(`${provider} configured. Run: nemoris status`);
415
+ }
416
+
417
+ export async function runOllamaSubWizard({ installDir }) {
418
+ const prompter = createClackPrompter();
419
+ await prompter.intro("Add local models (Ollama)");
420
+
421
+ await runOllamaPhase({ installDir });
422
+
423
+ try {
424
+ const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
425
+ writeWizardMetadata(installDir, {
426
+ lastRunVersion: pkg.version || "",
427
+ lastRunCommand: "setup ollama",
428
+ lastRunMode: "fast",
429
+ lastRunFlow: "interactive",
430
+ capabilities: { ollama: true },
431
+ });
432
+ } catch {
433
+ // Non-fatal
434
+ }
435
+
436
+ await prompter.outro("Ollama configured. Run: nemoris status");
437
+ }
438
+
200
439
  async function runInteractiveWizard({
201
440
  installDir,
202
441
  flowOverride = null,
442
+ manual = false,
443
+ }) {
444
+ if (manual) {
445
+ return runManualWizard({ installDir, flowOverride });
446
+ }
447
+ return runFastPathWizard({ installDir });
448
+ }
449
+
450
+ async function runManualWizard({
451
+ installDir,
452
+ flowOverride = null,
203
453
  }) {
204
454
  const prompter = createClackPrompter();
205
455
 
@@ -587,6 +837,7 @@ export async function runWizard({
587
837
  nonInteractive = false,
588
838
  acceptRisk = false,
589
839
  flow = null,
840
+ manual = false,
590
841
  anthropicKey = null,
591
842
  openaiKey = null,
592
843
  openrouterKey = null,
@@ -609,6 +860,7 @@ export async function runWizard({
609
860
  : await runInteractiveWizard({
610
861
  installDir,
611
862
  flowOverride: flow,
863
+ manual,
612
864
  });
613
865
  } catch (error) {
614
866
  if (error instanceof SetupCancelledError) {
@@ -7,6 +7,7 @@ import { PeerRegistry } from "./peer-registry.js";
7
7
  import { SchedulerStateStore } from "./scheduler-state.js";
8
8
  import { computeNextRun } from "./schedule.js";
9
9
  import { ProviderRegistry } from "../providers/registry.js";
10
+ import { buildSetupChecklist, formatSetupChecklist } from "../onboarding/setup-checklist.js";
10
11
 
11
12
  function _safeCount(arr) {
12
13
  return Array.isArray(arr) ? arr.length : 0;
@@ -245,7 +246,8 @@ export async function buildRuntimeStatus(options = {}) {
245
246
  providers,
246
247
  delivery: deliveryStatus,
247
248
  peers,
248
- runtime: runtimeStats
249
+ runtime: runtimeStats,
250
+ _installDir: projectRoot
249
251
  };
250
252
  }
251
253
 
@@ -279,6 +281,15 @@ export function formatRuntimeStatus(status) {
279
281
  }
280
282
  lines.push("");
281
283
 
284
+ if (status._installDir) {
285
+ const checklist = buildSetupChecklist(status._installDir);
286
+ const allConfigured = Object.values(checklist).every((c) => c.configured);
287
+ if (!allConfigured) {
288
+ lines.push(formatSetupChecklist(checklist));
289
+ lines.push("");
290
+ }
291
+ }
292
+
282
293
  // Agents
283
294
  lines.push(`--- Agents (${status.agents.length}) ---`);
284
295
  for (const agent of status.agents) {
@@ -466,12 +466,23 @@ export async function whoami(botToken, { fetchImpl = globalThis.fetch, webhookUr
466
466
  *
467
467
  * Returns a stop() function that cleanly terminates the loop.
468
468
  */
469
- export function startTelegramPolling({ botToken, handler, logger = console, fetchImpl = globalThis.fetch }) {
469
+ export function startTelegramPolling({ botToken, handler, logger = console, fetchImpl = globalThis.fetch, projectRoot = null }) {
470
470
  const api = (method) => `https://api.telegram.org/bot${botToken}/${method}`;
471
471
  let running = true;
472
472
  let offset = 0;
473
473
  const stateStore = handler?.store || null;
474
474
 
475
+ // One-shot flag: true when runtime.toml has chat_id_pending = true
476
+ let pendingChatIdDiscovery = false;
477
+ if (projectRoot) {
478
+ try {
479
+ const rtp = path.join(projectRoot, "config", "runtime.toml");
480
+ if (fs.existsSync(rtp) && fs.readFileSync(rtp, "utf8").includes("chat_id_pending = true")) {
481
+ pendingChatIdDiscovery = true;
482
+ }
483
+ } catch (_) { /* ignore — best effort */ }
484
+ }
485
+
475
486
  const persistOffset = async (nextOffset) => {
476
487
  if (!stateStore || typeof stateStore.setMeta !== "function") {
477
488
  return;
@@ -529,6 +540,26 @@ export function startTelegramPolling({ botToken, handler, logger = console, fetc
529
540
  const chatId = update?.message?.chat?.id;
530
541
  const messageId = update?.message?.message_id;
531
542
 
543
+ // Auto-discover chat_id when onboarding left chat_id_pending = true
544
+ if (chatId && pendingChatIdDiscovery) {
545
+ try {
546
+ const { resolvePendingChatId } = await import("../onboarding/phases/telegram.js");
547
+ resolvePendingChatId(projectRoot, String(chatId));
548
+ pendingChatIdDiscovery = false;
549
+ logger.info(JSON.stringify({
550
+ service: "telegram_polling",
551
+ event: "chat_id_auto_discovered",
552
+ chatId: String(chatId),
553
+ }));
554
+ } catch (err) {
555
+ logger.error(JSON.stringify({
556
+ service: "telegram_polling",
557
+ event: "chat_id_discovery_error",
558
+ error: err.message,
559
+ }));
560
+ }
561
+ }
562
+
532
563
  // React 👀 on enqueue (job accepted, processing starting)
533
564
  if (result?.action === "enqueued" && chatId && messageId) {
534
565
  fetchImpl(`https://api.telegram.org/bot${botToken}/setMessageReaction`, {