nemoris 0.1.0

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.
Files changed (223) hide show
  1. package/.env.example +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +209 -0
  4. package/SECURITY.md +119 -0
  5. package/bin/nemoris +46 -0
  6. package/config/agents/agent.toml.example +28 -0
  7. package/config/agents/default.toml +22 -0
  8. package/config/agents/orchestrator.toml +18 -0
  9. package/config/delivery.toml +73 -0
  10. package/config/embeddings.toml +5 -0
  11. package/config/identity/default-purpose.md +1 -0
  12. package/config/identity/default-soul.md +3 -0
  13. package/config/identity/orchestrator-purpose.md +1 -0
  14. package/config/identity/orchestrator-soul.md +1 -0
  15. package/config/improvement-targets.toml +15 -0
  16. package/config/jobs/heartbeat-check.toml +30 -0
  17. package/config/jobs/memory-rollup.toml +46 -0
  18. package/config/jobs/workspace-health.toml +63 -0
  19. package/config/mcp.toml +16 -0
  20. package/config/output-contracts.toml +17 -0
  21. package/config/peers.toml +32 -0
  22. package/config/peers.toml.example +32 -0
  23. package/config/policies/memory-default.toml +10 -0
  24. package/config/policies/memory-heartbeat.toml +5 -0
  25. package/config/policies/memory-ops.toml +10 -0
  26. package/config/policies/tools-heartbeat-minimal.toml +8 -0
  27. package/config/policies/tools-interactive-safe.toml +8 -0
  28. package/config/policies/tools-ops-bounded.toml +8 -0
  29. package/config/policies/tools-orchestrator.toml +7 -0
  30. package/config/providers/anthropic.toml +15 -0
  31. package/config/providers/ollama.toml +5 -0
  32. package/config/providers/openai-codex.toml +9 -0
  33. package/config/providers/openrouter.toml +5 -0
  34. package/config/router.toml +22 -0
  35. package/config/runtime.toml +114 -0
  36. package/config/skills/self-improvement.toml +15 -0
  37. package/config/skills/telegram-onboarding-spec.md +240 -0
  38. package/config/skills/workspace-monitor.toml +15 -0
  39. package/config/task-router.toml +42 -0
  40. package/install.sh +50 -0
  41. package/package.json +90 -0
  42. package/src/auth/auth-profiles.js +169 -0
  43. package/src/auth/openai-codex-oauth.js +285 -0
  44. package/src/battle.js +449 -0
  45. package/src/cli/help.js +265 -0
  46. package/src/cli/output-filter.js +49 -0
  47. package/src/cli/runtime-control.js +704 -0
  48. package/src/cli-main.js +2763 -0
  49. package/src/cli.js +78 -0
  50. package/src/config/loader.js +332 -0
  51. package/src/config/schema-validator.js +214 -0
  52. package/src/config/toml-lite.js +8 -0
  53. package/src/daemon/action-handlers.js +71 -0
  54. package/src/daemon/healing-tick.js +87 -0
  55. package/src/daemon/health-probes.js +90 -0
  56. package/src/daemon/notifier.js +57 -0
  57. package/src/daemon/nurse.js +218 -0
  58. package/src/daemon/repair-log.js +106 -0
  59. package/src/daemon/rule-staging.js +90 -0
  60. package/src/daemon/rules.js +29 -0
  61. package/src/daemon/telegram-commands.js +54 -0
  62. package/src/daemon/updater.js +85 -0
  63. package/src/jobs/job-runner.js +78 -0
  64. package/src/mcp/consumer.js +129 -0
  65. package/src/memory/active-recall.js +171 -0
  66. package/src/memory/backend-manager.js +97 -0
  67. package/src/memory/backends/file-backend.js +38 -0
  68. package/src/memory/backends/qmd-backend.js +219 -0
  69. package/src/memory/embedding-guards.js +24 -0
  70. package/src/memory/embedding-index.js +118 -0
  71. package/src/memory/embedding-service.js +179 -0
  72. package/src/memory/file-index.js +177 -0
  73. package/src/memory/memory-signature.js +5 -0
  74. package/src/memory/memory-store.js +648 -0
  75. package/src/memory/retrieval-planner.js +66 -0
  76. package/src/memory/scoring.js +145 -0
  77. package/src/memory/simhash.js +78 -0
  78. package/src/memory/sqlite-active-store.js +824 -0
  79. package/src/memory/write-policy.js +36 -0
  80. package/src/onboarding/aliases.js +33 -0
  81. package/src/onboarding/auth/api-key.js +224 -0
  82. package/src/onboarding/auth/ollama-detect.js +42 -0
  83. package/src/onboarding/clack-prompter.js +77 -0
  84. package/src/onboarding/doctor.js +530 -0
  85. package/src/onboarding/lock.js +42 -0
  86. package/src/onboarding/model-catalog.js +344 -0
  87. package/src/onboarding/phases/auth.js +589 -0
  88. package/src/onboarding/phases/build.js +130 -0
  89. package/src/onboarding/phases/choose.js +82 -0
  90. package/src/onboarding/phases/detect.js +98 -0
  91. package/src/onboarding/phases/hatch.js +216 -0
  92. package/src/onboarding/phases/identity.js +79 -0
  93. package/src/onboarding/phases/ollama.js +345 -0
  94. package/src/onboarding/phases/scaffold.js +99 -0
  95. package/src/onboarding/phases/telegram.js +377 -0
  96. package/src/onboarding/phases/validate.js +204 -0
  97. package/src/onboarding/phases/verify.js +206 -0
  98. package/src/onboarding/platform.js +482 -0
  99. package/src/onboarding/status-bar.js +95 -0
  100. package/src/onboarding/templates.js +794 -0
  101. package/src/onboarding/toml-writer.js +38 -0
  102. package/src/onboarding/tui.js +250 -0
  103. package/src/onboarding/uninstall.js +153 -0
  104. package/src/onboarding/wizard.js +499 -0
  105. package/src/providers/anthropic.js +168 -0
  106. package/src/providers/base.js +247 -0
  107. package/src/providers/circuit-breaker.js +136 -0
  108. package/src/providers/ollama.js +163 -0
  109. package/src/providers/openai-codex.js +149 -0
  110. package/src/providers/openrouter.js +136 -0
  111. package/src/providers/registry.js +36 -0
  112. package/src/providers/router.js +16 -0
  113. package/src/runtime/bootstrap-cache.js +47 -0
  114. package/src/runtime/capabilities-prompt.js +25 -0
  115. package/src/runtime/completion-ping.js +99 -0
  116. package/src/runtime/config-validator.js +121 -0
  117. package/src/runtime/context-ledger.js +360 -0
  118. package/src/runtime/cutover-readiness.js +42 -0
  119. package/src/runtime/daemon.js +729 -0
  120. package/src/runtime/delivery-ack.js +195 -0
  121. package/src/runtime/delivery-adapters/local-file.js +41 -0
  122. package/src/runtime/delivery-adapters/openclaw-cli.js +94 -0
  123. package/src/runtime/delivery-adapters/openclaw-peer.js +98 -0
  124. package/src/runtime/delivery-adapters/shadow.js +13 -0
  125. package/src/runtime/delivery-adapters/standalone-http.js +98 -0
  126. package/src/runtime/delivery-adapters/telegram.js +104 -0
  127. package/src/runtime/delivery-adapters/tui.js +128 -0
  128. package/src/runtime/delivery-manager.js +807 -0
  129. package/src/runtime/delivery-store.js +168 -0
  130. package/src/runtime/dependency-health.js +118 -0
  131. package/src/runtime/envelope.js +114 -0
  132. package/src/runtime/evaluation.js +1089 -0
  133. package/src/runtime/exec-approvals.js +216 -0
  134. package/src/runtime/executor.js +500 -0
  135. package/src/runtime/failure-ping.js +67 -0
  136. package/src/runtime/flows.js +83 -0
  137. package/src/runtime/guards.js +45 -0
  138. package/src/runtime/handoff.js +51 -0
  139. package/src/runtime/identity-cache.js +28 -0
  140. package/src/runtime/improvement-engine.js +109 -0
  141. package/src/runtime/improvement-harness.js +581 -0
  142. package/src/runtime/input-sanitiser.js +72 -0
  143. package/src/runtime/interaction-contract.js +347 -0
  144. package/src/runtime/lane-readiness.js +226 -0
  145. package/src/runtime/migration.js +323 -0
  146. package/src/runtime/model-resolution.js +78 -0
  147. package/src/runtime/network.js +64 -0
  148. package/src/runtime/notification-store.js +97 -0
  149. package/src/runtime/notifier.js +256 -0
  150. package/src/runtime/orchestrator.js +53 -0
  151. package/src/runtime/orphan-reaper.js +41 -0
  152. package/src/runtime/output-contract-schema.js +139 -0
  153. package/src/runtime/output-contract-validator.js +439 -0
  154. package/src/runtime/peer-readiness.js +69 -0
  155. package/src/runtime/peer-registry.js +133 -0
  156. package/src/runtime/pilot-status.js +108 -0
  157. package/src/runtime/prompt-builder.js +261 -0
  158. package/src/runtime/provider-attempt.js +582 -0
  159. package/src/runtime/report-fallback.js +71 -0
  160. package/src/runtime/result-normalizer.js +183 -0
  161. package/src/runtime/retention.js +74 -0
  162. package/src/runtime/review.js +244 -0
  163. package/src/runtime/route-job.js +15 -0
  164. package/src/runtime/run-store.js +38 -0
  165. package/src/runtime/schedule.js +88 -0
  166. package/src/runtime/scheduler-state.js +434 -0
  167. package/src/runtime/scheduler.js +656 -0
  168. package/src/runtime/session-compactor.js +182 -0
  169. package/src/runtime/session-search.js +155 -0
  170. package/src/runtime/slack-inbound.js +249 -0
  171. package/src/runtime/ssrf.js +102 -0
  172. package/src/runtime/status-aggregator.js +330 -0
  173. package/src/runtime/task-contract.js +140 -0
  174. package/src/runtime/task-packet.js +107 -0
  175. package/src/runtime/task-router.js +140 -0
  176. package/src/runtime/telegram-inbound.js +1565 -0
  177. package/src/runtime/token-counter.js +134 -0
  178. package/src/runtime/token-estimator.js +59 -0
  179. package/src/runtime/tool-loop.js +200 -0
  180. package/src/runtime/transport-server.js +311 -0
  181. package/src/runtime/tui-server.js +411 -0
  182. package/src/runtime/ulid.js +44 -0
  183. package/src/security/ssrf-check.js +197 -0
  184. package/src/setup.js +369 -0
  185. package/src/shadow/bridge.js +303 -0
  186. package/src/skills/loader.js +84 -0
  187. package/src/tools/catalog.json +49 -0
  188. package/src/tools/cli-delegate.js +44 -0
  189. package/src/tools/mcp-client.js +106 -0
  190. package/src/tools/micro/cancel-task.js +6 -0
  191. package/src/tools/micro/complete-task.js +6 -0
  192. package/src/tools/micro/fail-task.js +6 -0
  193. package/src/tools/micro/http-fetch.js +74 -0
  194. package/src/tools/micro/index.js +36 -0
  195. package/src/tools/micro/lcm-recall.js +60 -0
  196. package/src/tools/micro/list-dir.js +17 -0
  197. package/src/tools/micro/list-skills.js +46 -0
  198. package/src/tools/micro/load-skill.js +38 -0
  199. package/src/tools/micro/memory-search.js +45 -0
  200. package/src/tools/micro/read-file.js +11 -0
  201. package/src/tools/micro/session-search.js +54 -0
  202. package/src/tools/micro/shell-exec.js +43 -0
  203. package/src/tools/micro/trigger-job.js +79 -0
  204. package/src/tools/micro/web-search.js +58 -0
  205. package/src/tools/micro/workspace-paths.js +39 -0
  206. package/src/tools/micro/write-file.js +14 -0
  207. package/src/tools/micro/write-memory.js +41 -0
  208. package/src/tools/registry.js +348 -0
  209. package/src/tools/tool-result-contract.js +36 -0
  210. package/src/tui/chat.js +835 -0
  211. package/src/tui/renderer.js +175 -0
  212. package/src/tui/socket-client.js +217 -0
  213. package/src/utils/canonical-json.js +29 -0
  214. package/src/utils/compaction.js +30 -0
  215. package/src/utils/env-loader.js +5 -0
  216. package/src/utils/errors.js +80 -0
  217. package/src/utils/fs.js +101 -0
  218. package/src/utils/ids.js +5 -0
  219. package/src/utils/model-context-limits.js +30 -0
  220. package/src/utils/token-budget.js +74 -0
  221. package/src/utils/usage-cost.js +25 -0
  222. package/src/utils/usage-metrics.js +14 -0
  223. package/vendor/smol-toml-1.5.2.tgz +0 -0
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Telegram onboarding sub-phase — optional, runs inside Build (Phase 3).
3
+ *
4
+ * Steps: token validation → chat_id discovery (daemon-aware) → mode selection
5
+ * → config write → smoke test with failure handling.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { createRequire } from "node:module";
11
+ import { getMe, whoami } from "../../runtime/telegram-inbound.js";
12
+ import { writeEnvFile } from "../auth/api-key.js";
13
+ import { isDaemonRunning, setDaemonEnv } from "../platform.js";
14
+ import { writeTomlSection } from "../toml-writer.js";
15
+ import {
16
+ confirm, prompt, promptSecret, select, waitForEnter,
17
+ bold, green, red, yellow, dim, cyan, progressLine,
18
+ } from "../tui.js";
19
+
20
+ export const TELEGRAM_START_INSTRUCTION =
21
+ "Before continuing, open Telegram, search for your new bot, and send it /start — otherwise it won't be able to receive messages.";
22
+
23
+ // ── Helpers ──────────────────────────────────────────────────────
24
+
25
+ export function stripAnsi(str) {
26
+ // eslint-disable-next-line no-control-regex
27
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
28
+ }
29
+
30
+ export function buildTelegramToml({ botTokenEnv, pollingMode, webhookUrl, operatorChatId, authorizedChatIds, defaultAgent }) {
31
+ const authIds = (authorizedChatIds || []).map((id) => `"${id}"`).join(", ");
32
+ return [
33
+ "",
34
+ "[telegram]",
35
+ `bot_token_env = "${botTokenEnv}"`,
36
+ `polling_mode = ${pollingMode}`,
37
+ `webhook_url = "${webhookUrl || ""}"`,
38
+ `operator_chat_id = "${operatorChatId || ""}"`,
39
+ `authorized_chat_ids = [${authIds}]`,
40
+ `default_agent = "${defaultAgent}"`,
41
+ "",
42
+ ].join("\n");
43
+ }
44
+
45
+ export function patchRuntimeToml(installDir, telegramToml) {
46
+ const runtimePath = path.join(installDir, "config", "runtime.toml");
47
+ writeTomlSection(runtimePath, "telegram", telegramToml);
48
+ }
49
+
50
+ async function sendTestMessage(token, chatId, text, { fetchImpl = globalThis.fetch } = {}) {
51
+ try {
52
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
53
+ const res = await fetchImpl(url, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({ chat_id: chatId, text }),
57
+ });
58
+ const body = await res.json();
59
+ if (!body.ok) return { ok: false, error: `${res.status}: ${body.description || "unknown error"}` };
60
+ return { ok: true };
61
+ } catch (error) {
62
+ return { ok: false, error: error.message };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Discover chat_id when the daemon is running — polls active.db for a
68
+ * telegram interactive job created after `since` (ISO string).
69
+ */
70
+ function discoverChatIdFromStore(installDir, since, { timeoutMs = 120_000, intervalMs = 2000 } = {}) {
71
+ const dbPath = path.join(installDir, "state", "active.db");
72
+ return new Promise((resolve) => {
73
+ const deadline = Date.now() + timeoutMs;
74
+
75
+ const tick = () => {
76
+ try {
77
+ // Lazy-require to avoid hard dep on better-sqlite3 at module level
78
+ const require = createRequire(import.meta.url);
79
+ const Database = require("better-sqlite3");
80
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
81
+ const row = db.prepare(
82
+ "SELECT chat_id FROM interactive_jobs WHERE source = 'telegram' AND created_at > ? ORDER BY created_at DESC LIMIT 1"
83
+ ).get(since);
84
+ db.close();
85
+ if (row?.chat_id) return resolve(row.chat_id);
86
+ } catch {
87
+ // DB may not exist yet or table missing — keep polling
88
+ }
89
+
90
+ if (Date.now() >= deadline) return resolve(null);
91
+ setTimeout(tick, intervalMs);
92
+ };
93
+ tick();
94
+ });
95
+ }
96
+
97
+ // ── Main Phase ───────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Run the Telegram onboarding sub-phase (interactive).
101
+ *
102
+ * @param {{ installDir: string, agentId: string, nonInteractive?: boolean, skipGate?: boolean }} options
103
+ * @returns {Promise<{ configured: boolean, verified: boolean, botUsername?: string }>}
104
+ */
105
+ export async function runTelegramPhase({ installDir, agentId, nonInteractive = false, skipGate = false, tui = {} }) {
106
+ const botTokenEnv = "NEMORIS_TELEGRAM_BOT_TOKEN";
107
+ const confirmImpl = tui.confirm || confirm;
108
+ const promptImpl = tui.prompt || prompt;
109
+ const promptSecretImpl = tui.promptSecret || promptSecret;
110
+ const selectImpl = tui.select || select;
111
+ const waitForEnterImpl = tui.waitForEnter || waitForEnter;
112
+ const redImpl = tui.red || red;
113
+ const yellowImpl = tui.yellow || yellow;
114
+ const dimImpl = tui.dim || dim;
115
+ const cyanImpl = tui.cyan || cyan;
116
+ const progressLineImpl = tui.progressLine || progressLine;
117
+
118
+ // ── Non-interactive mode ───────────────────────────────────────
119
+ if (nonInteractive) {
120
+ const token = process.env[botTokenEnv];
121
+ if (!token || process.env.NEMORIS_SKIP_TELEGRAM === "true") {
122
+ return { configured: false, verified: false };
123
+ }
124
+
125
+ const me = await getMe(token);
126
+ if (!me.ok) return { configured: false, verified: false };
127
+
128
+ const chatId = process.env.NEMORIS_TELEGRAM_CHAT_ID || "";
129
+ const mode = process.env.NEMORIS_TELEGRAM_MODE || "polling";
130
+ const webhookUrl = process.env.NEMORIS_TELEGRAM_WEBHOOK_URL || "";
131
+
132
+ // Security: empty authorized_chat_ids with no chat_id means any sender is accepted.
133
+ // Require NEMORIS_ALLOW_ANY_CHAT=true to opt in to this open configuration.
134
+ if (!chatId && process.env.NEMORIS_ALLOW_ANY_CHAT !== "true") {
135
+ console.error("NEMORIS_TELEGRAM_CHAT_ID not set. Set it, or set NEMORIS_ALLOW_ANY_CHAT=true to allow any sender.");
136
+ return { configured: false, verified: false };
137
+ }
138
+
139
+ const authorizedChatIds = chatId ? [chatId] : [];
140
+
141
+ writeEnvFile(installDir, { [botTokenEnv]: token });
142
+ patchRuntimeToml(installDir, buildTelegramToml({
143
+ botTokenEnv, pollingMode: mode === "polling", webhookUrl,
144
+ operatorChatId: chatId, authorizedChatIds, defaultAgent: agentId,
145
+ }));
146
+
147
+ return { configured: true, verified: false, botUsername: me.username, botToken: token, operatorChatId: chatId };
148
+ }
149
+
150
+ // ── Interactive mode ───────────────────────────────────────────
151
+
152
+ // Gate
153
+ if (!skipGate) {
154
+ const gate = await confirmImpl(`Connect via ${cyanImpl("Telegram")}? This lets you message your agent from your phone.`, true);
155
+ if (!gate) {
156
+ return { configured: false, verified: false };
157
+ }
158
+ }
159
+
160
+ // Step 1: Bot token
161
+ let token = null;
162
+ let botUsername = null;
163
+
164
+ console.log(`\n ${dimImpl("Need a bot token? Open Telegram → search @BotFather → send /newbot → follow the steps.")}`);
165
+ console.log(` ${dimImpl("After creating your bot, open it in Telegram and tap Start before coming back here.")}`);
166
+ console.log(` ${dimImpl("Your input will be hidden as you type — just paste and press Enter.")}\n`);
167
+
168
+ while (!token) {
169
+ const raw = await promptSecretImpl(`Your bot token`);
170
+ if (!raw) {
171
+ console.log(` ${dimImpl("Skipping Telegram — you can add it later with `nemoris setup telegram`.")}`);
172
+ return { configured: false, verified: false };
173
+ }
174
+
175
+ const me = await getMe(raw);
176
+ if (me.ok) {
177
+ token = raw;
178
+ botUsername = me.username;
179
+ console.log(progressLineImpl("Token validated", `@${me.username}`));
180
+ console.log(` ${dimImpl(TELEGRAM_START_INSTRUCTION)}`);
181
+ } else {
182
+ console.log(` ${redImpl("✗")} That token didn't work — double-check it in BotFather and try again.`);
183
+ console.log(` ${dimImpl("Try again or press Enter to skip.")}`);
184
+ }
185
+ }
186
+
187
+ // Write token to .env + daemon env
188
+ writeEnvFile(installDir, { [botTokenEnv]: token });
189
+ await setDaemonEnv(botTokenEnv, token);
190
+
191
+ // Step 2: Chat ID discovery
192
+ let chatId = null;
193
+ const daemonRunning = isDaemonRunning();
194
+
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);
200
+
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
+ }
208
+ } 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
+ }
222
+ }
223
+
224
+ const authorizedChatIds = chatId ? [chatId] : [];
225
+
226
+ // Step 3: Delivery mode
227
+ const mode = await selectImpl("Delivery mode:", [
228
+ { label: `Long polling ${dimImpl("(recommended — no tunnel needed)")}`, value: "polling" },
229
+ { label: `Webhook — for servers with a public address (advanced)`, value: "webhook" },
230
+ ]);
231
+
232
+ let webhookUrl = "";
233
+ if (mode === "webhook") {
234
+ webhookUrl = await promptImpl("Public webhook URL", "");
235
+ if (webhookUrl) {
236
+ const { registerWebhook } = await import("../../runtime/telegram-inbound.js");
237
+ const reg = await registerWebhook(token, webhookUrl);
238
+ if (reg.ok) {
239
+ console.log(progressLineImpl("Webhook registered", `${webhookUrl}/telegram/webhook`));
240
+ } else {
241
+ console.log(` ${redImpl("✗")} Webhook registration failed — make sure the URL is publicly reachable.`);
242
+ console.log(` ${dimImpl("Falling back to polling mode.")}`);
243
+ webhookUrl = "";
244
+ }
245
+ }
246
+ }
247
+
248
+ const pollingMode = !webhookUrl;
249
+
250
+ // Step 4: Write config
251
+ patchRuntimeToml(installDir, buildTelegramToml({
252
+ botTokenEnv, pollingMode, webhookUrl,
253
+ operatorChatId: chatId || "", authorizedChatIds, defaultAgent: agentId,
254
+ }));
255
+
256
+ // Store chat_id in state for later phases (e.g. hatch)
257
+ const result = { configured: true, verified: false, botUsername, botToken: token, operatorChatId: chatId || "" };
258
+
259
+ console.log(progressLineImpl("Telegram configured", webhookUrl ? "webhook" : "long-poll"));
260
+
261
+ // Step 5: Smoke test (with failure handling)
262
+ if (!chatId) {
263
+ console.log(` ${yellowImpl("!")} No chat ID — skipping test message.`);
264
+ return result;
265
+ }
266
+
267
+ let verified = false;
268
+ let smokeAttempt = true;
269
+
270
+ while (smokeAttempt) {
271
+ console.log(progressLineImpl("Sending test message", chatId));
272
+ const smokeResult = await sendTestMessage(token, chatId, "Nemoris is running. Send me a message and I'll get to work.");
273
+
274
+ if (smokeResult.ok) {
275
+ console.log(progressLineImpl("Telegram connected", `message delivered to ${chatId}`));
276
+ verified = true;
277
+ smokeAttempt = false;
278
+ } else {
279
+ console.log(` ${redImpl("✗")} Delivery failed: ${smokeResult.error}\n`);
280
+ const choice = await selectImpl("What next?", [
281
+ { label: "Retry", value: "retry" },
282
+ { label: `Skip — finish setup without verification`, value: "skip" },
283
+ { label: "Re-enter bot token", value: "retoken" },
284
+ ]);
285
+
286
+ if (choice === "retry") continue;
287
+ if (choice === "skip") {
288
+ smokeAttempt = false;
289
+ }
290
+ if (choice === "retoken") {
291
+ // Recursive — restart from Step 1
292
+ return runTelegramPhase({ installDir, agentId, nonInteractive, skipGate: true });
293
+ }
294
+ }
295
+ }
296
+
297
+ return { ...result, verified };
298
+ }
299
+
300
+ /**
301
+ * Send the agent's "hatch" moment message to Telegram.
302
+ *
303
+ * When providerConfig and identity content are supplied, generates a
304
+ * personalised AI greeting via the provider stack. Falls back to the
305
+ * static greeting on provider failure or when no config is available.
306
+ *
307
+ * @param {{ token: string, chatId: string, agentName?: string, userName?: string, installDir?: string, agentId?: string, userGoal?: string, soulContent?: string, purposeContent?: string, providerConfig?: object, fetchImpl?: Function }} opts
308
+ * @returns {Promise<boolean>}
309
+ */
310
+ export async function sendHatchMessage({
311
+ token,
312
+ chatId,
313
+ agentName,
314
+ userName,
315
+ installDir = null,
316
+ agentId = null,
317
+ userGoal = "",
318
+ soulContent = "",
319
+ purposeContent = "",
320
+ providerConfig = null,
321
+ migrated = false,
322
+ fetchImpl = globalThis.fetch,
323
+ }) {
324
+ const name = stripAnsi(userName || "there");
325
+ const agent = stripAnsi(agentName || "your agent");
326
+
327
+ // Attempt provider-generated greeting
328
+ let text = null;
329
+ if (providerConfig) {
330
+ try {
331
+ const { generateGreeting } = await import("./hatch.js");
332
+ const result = await generateGreeting({
333
+ agentName: agent,
334
+ userName: name,
335
+ userGoal,
336
+ soulContent,
337
+ purposeContent,
338
+ providerConfig,
339
+ migrated,
340
+ });
341
+ if (result.fromProvider) {
342
+ text = result.greeting;
343
+ }
344
+ } catch {
345
+ // Fall through to static greeting
346
+ }
347
+ }
348
+
349
+ // Static fallback — tone matches migrated vs fresh
350
+ if (!text) {
351
+ const { staticGreeting } = await import("./hatch.js");
352
+ text = staticGreeting(agent, name, migrated);
353
+ }
354
+
355
+ // Write birth fact to memory (best-effort, non-blocking)
356
+ if (installDir && agentId) {
357
+ try {
358
+ const { writeBirthFact } = await import("./hatch.js");
359
+ await writeBirthFact({ installDir, agentId, agentName: agent, userName: name, migrated });
360
+ } catch {
361
+ // Non-fatal — don't fail the Telegram send
362
+ }
363
+ }
364
+
365
+ // Send to Telegram
366
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
367
+ try {
368
+ const res = await fetchImpl(url, {
369
+ method: "POST",
370
+ headers: { "Content-Type": "application/json" },
371
+ body: JSON.stringify({ chat_id: chatId, text }),
372
+ });
373
+ return res.ok;
374
+ } catch {
375
+ return false;
376
+ }
377
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Validate phase — checks that the scaffolded config directory is well-formed.
3
+ *
4
+ * Loads config files that are present, runs schema validation, checks that
5
+ * state directories exist and are writable, and probes any configured
6
+ * providers via their adapter healthCheck(). A freshly scaffolded directory
7
+ * has no providers and no router.toml (written later by the hatch phase), so
8
+ * those sections are treated as optional at this stage.
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { readText, listFilesRecursive } from "../../utils/fs.js";
14
+ import { parseToml } from "../../config/toml-lite.js";
15
+ import { validateAllConfigs } from "../../config/schema-validator.js";
16
+
17
+ // State directories that must exist and be writable after scaffold
18
+ const STATE_DIRS = ["state", path.join("state", "memory")];
19
+
20
+ /**
21
+ * Normalise TOML snake_case keys to camelCase (mirrors ConfigLoader behaviour).
22
+ * @param {unknown} value
23
+ * @returns {unknown}
24
+ */
25
+ function normalizeKeys(value) {
26
+ if (Array.isArray(value)) return value.map(normalizeKeys);
27
+ if (!value || typeof value !== "object") return value;
28
+
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(value)) {
31
+ const norm = k.replace(/_([a-z])/g, (_, l) => l.toUpperCase());
32
+ out[norm] = normalizeKeys(v);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Load all TOML files in a directory into an id-keyed map.
39
+ * Returns an empty object if the directory is absent.
40
+ *
41
+ * @param {string} dirPath
42
+ * @returns {Promise<Record<string, object>>}
43
+ */
44
+ async function loadDirAsMap(dirPath) {
45
+ if (!fs.existsSync(dirPath)) return {};
46
+
47
+ const files = (await listFilesRecursive(dirPath)).filter((f) => f.endsWith(".toml"));
48
+ const entries = [];
49
+ for (const filePath of files) {
50
+ const parsed = normalizeKeys(parseToml(await readText(filePath, "")));
51
+ const id = parsed.id || path.basename(filePath, ".toml");
52
+ entries.push([id, { ...parsed, __filePath: filePath }]);
53
+ }
54
+ return Object.fromEntries(entries);
55
+ }
56
+
57
+ /**
58
+ * Check that state directories exist and are writable.
59
+ *
60
+ * @param {string} installDir
61
+ * @returns {{ ready: boolean, missing: string[] }}
62
+ */
63
+ function checkStateDirs(installDir) {
64
+ const missing = [];
65
+ for (const rel of STATE_DIRS) {
66
+ const absPath = path.join(installDir, rel);
67
+ if (!fs.existsSync(absPath)) {
68
+ missing.push(rel);
69
+ continue;
70
+ }
71
+ try {
72
+ fs.accessSync(absPath, fs.constants.W_OK);
73
+ } catch {
74
+ missing.push(rel);
75
+ }
76
+ }
77
+ return { ready: missing.length === 0, missing };
78
+ }
79
+
80
+ /**
81
+ * Probe a single provider via its adapter's healthCheck(), if available.
82
+ *
83
+ * @param {string} id
84
+ * @param {object} providerConfig
85
+ * @returns {Promise<{ id: string, healthy: boolean, error?: string }>}
86
+ */
87
+ async function probeProvider(id, providerConfig) {
88
+ const adapterName = providerConfig.adapter || providerConfig.type;
89
+ if (!adapterName) {
90
+ return { id, healthy: false, error: "no adapter or type configured" };
91
+ }
92
+
93
+ try {
94
+ // Attempt to dynamically load the adapter module
95
+ const adapterPath = new URL(
96
+ `../../providers/adapters/${adapterName}.js`,
97
+ import.meta.url
98
+ );
99
+ const mod = await import(adapterPath.href);
100
+ const AdapterClass = mod.default || mod[Object.keys(mod)[0]];
101
+
102
+ if (typeof AdapterClass?.prototype?.healthCheck === "function") {
103
+ const instance = new AdapterClass(providerConfig);
104
+ await instance.healthCheck();
105
+ return { id, healthy: true };
106
+ }
107
+
108
+ // Static healthCheck
109
+ if (typeof AdapterClass?.healthCheck === "function") {
110
+ await AdapterClass.healthCheck(providerConfig);
111
+ return { id, healthy: true };
112
+ }
113
+
114
+ // No healthCheck available — treat as healthy (unchecked)
115
+ return { id, healthy: true };
116
+ } catch (err) {
117
+ return { id, healthy: false, error: err.message };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Validate a scaffolded nemoris installation directory.
123
+ *
124
+ * @param {string} installDir Absolute path to the installation root.
125
+ * @returns {Promise<{
126
+ * configValid: boolean,
127
+ * errors: string[],
128
+ * stateDirsReady: boolean,
129
+ * providerHealth: Array<{ id: string, healthy: boolean, error?: string }>,
130
+ * }>}
131
+ */
132
+ export async function validateScaffold(installDir) {
133
+ const configDir = path.join(installDir, "config");
134
+
135
+ // ── 1. Load config sections that are present ──────────────────────────────
136
+
137
+ const [agents, jobs, providers] = await Promise.all([
138
+ loadDirAsMap(path.join(configDir, "agents")),
139
+ loadDirAsMap(path.join(configDir, "jobs")),
140
+ loadDirAsMap(path.join(configDir, "providers")),
141
+ ]);
142
+
143
+ // Runtime — required by schema validator; will produce errors if missing
144
+ const runtimeRaw = normalizeKeys(
145
+ parseToml(await readText(path.join(configDir, "runtime.toml"), ""))
146
+ );
147
+ const runtime = Object.keys(runtimeRaw).length > 0 ? runtimeRaw : null;
148
+
149
+ // Router — optional at scaffold stage (written during hatch phase)
150
+ const routerFilePath = path.join(configDir, "router.toml");
151
+ let router = null;
152
+ if (fs.existsSync(routerFilePath)) {
153
+ const raw = parseToml(await readText(routerFilePath, ""));
154
+ const lanes = {};
155
+ for (const [laneName, value] of Object.entries(raw.lanes || {})) {
156
+ lanes[laneName] = {
157
+ primary: value.primary,
158
+ fallback: value.fallback,
159
+ manualBump: value.manual_bump,
160
+ };
161
+ }
162
+ if (Object.keys(lanes).length > 0) {
163
+ router = lanes;
164
+ }
165
+ }
166
+
167
+ // ── 2. Build a minimal config object and run schema validation ────────────
168
+
169
+ const config = {
170
+ agents,
171
+ jobs,
172
+ providers,
173
+ ...(runtime ? { runtime } : {}),
174
+ ...(router ? { router } : {}),
175
+ };
176
+
177
+ const validationResult = validateAllConfigs(config, configDir);
178
+
179
+ // router.toml is written during the hatch phase (after providers are
180
+ // configured). At scaffold+identity stage it is not yet present, so we
181
+ // treat the router-missing error as non-fatal when no router is loaded.
182
+ const errors = router
183
+ ? validationResult.errors
184
+ : validationResult.errors.filter((e) => !e.includes("router config is missing entirely"));
185
+
186
+ const ok = errors.length === 0;
187
+
188
+ // ── 3. Check state directories ────────────────────────────────────────────
189
+
190
+ const { ready: stateDirsReady } = checkStateDirs(installDir);
191
+
192
+ // ── 4. Probe configured providers ─────────────────────────────────────────
193
+
194
+ const providerHealth = await Promise.all(
195
+ Object.entries(providers).map(([id, cfg]) => probeProvider(id, cfg))
196
+ );
197
+
198
+ return {
199
+ configValid: ok,
200
+ errors,
201
+ stateDirsReady,
202
+ providerHealth,
203
+ };
204
+ }