nemoris 0.1.0 → 0.1.2

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 (248) hide show
  1. package/.env.example +49 -49
  2. package/LICENSE +21 -21
  3. package/README.md +209 -209
  4. package/SECURITY.md +59 -119
  5. package/bin/nemoris +46 -46
  6. package/config/agents/agent.toml.example +28 -28
  7. package/config/agents/content.toml +23 -0
  8. package/config/agents/default.toml +22 -22
  9. package/config/agents/heartbeat.toml +35 -0
  10. package/config/agents/iris.toml +23 -0
  11. package/config/agents/lab.toml +23 -0
  12. package/config/agents/main.toml +45 -0
  13. package/config/agents/nemo.toml +21 -0
  14. package/config/agents/ops.toml +38 -0
  15. package/config/agents/orchestrator.toml +18 -18
  16. package/config/agents/revenue.toml +23 -0
  17. package/config/agents/testyboo.toml +19 -0
  18. package/config/delivery.toml +73 -73
  19. package/config/embeddings.toml +5 -5
  20. package/config/identity/content-purpose.md +11 -0
  21. package/config/identity/content-soul.md +45 -0
  22. package/config/identity/default-purpose.md +1 -1
  23. package/config/identity/default-soul.md +3 -3
  24. package/config/identity/heartbeat-purpose.md +9 -0
  25. package/config/identity/heartbeat-soul.md +16 -0
  26. package/config/identity/iris-purpose.md +17 -0
  27. package/config/identity/iris-soul.md +68 -0
  28. package/config/identity/lab-purpose.md +10 -0
  29. package/config/identity/lab-soul.md +38 -0
  30. package/config/identity/main-purpose.md +17 -0
  31. package/config/identity/main-soul.md +66 -0
  32. package/config/identity/main-user.md +22 -0
  33. package/config/identity/ops-purpose.md +9 -0
  34. package/config/identity/ops-soul.md +16 -0
  35. package/config/identity/orchestrator-purpose.md +1 -1
  36. package/config/identity/orchestrator-soul.md +1 -1
  37. package/config/identity/revenue-purpose.md +9 -0
  38. package/config/identity/revenue-soul.md +41 -0
  39. package/config/identity/testyboo-purpose.md +13 -0
  40. package/config/identity/testyboo-soul.md +20 -0
  41. package/config/improvement-targets.toml +15 -15
  42. package/config/jobs/heartbeat-check.toml +30 -30
  43. package/config/jobs/memory-rollup.toml +46 -46
  44. package/config/jobs/workspace-health.toml +63 -63
  45. package/config/mcp.toml +16 -16
  46. package/config/output-contracts.toml +17 -17
  47. package/config/peers.toml +32 -32
  48. package/config/peers.toml.example +32 -32
  49. package/config/policies/memory-default.toml +10 -10
  50. package/config/policies/memory-heartbeat.toml +5 -5
  51. package/config/policies/memory-ops.toml +10 -10
  52. package/config/policies/tools-heartbeat-minimal.toml +8 -8
  53. package/config/policies/tools-interactive-safe.toml +8 -8
  54. package/config/policies/tools-ops-bounded.toml +8 -8
  55. package/config/policies/tools-orchestrator.toml +7 -7
  56. package/config/providers/anthropic.toml +15 -15
  57. package/config/providers/ollama.toml +5 -5
  58. package/config/providers/openai-codex.toml +9 -9
  59. package/config/providers/openrouter.toml +5 -5
  60. package/config/router.toml +22 -22
  61. package/config/runtime.toml +114 -114
  62. package/config/skills/self-improvement.toml +15 -15
  63. package/config/skills/telegram-onboarding-spec.md +240 -240
  64. package/config/skills/workspace-monitor.toml +15 -15
  65. package/config/task-router.toml +42 -42
  66. package/install.sh +50 -50
  67. package/package.json +91 -90
  68. package/src/auth/auth-profiles.js +169 -169
  69. package/src/auth/openai-codex-oauth.js +285 -285
  70. package/src/battle.js +449 -449
  71. package/src/cli/help.js +265 -265
  72. package/src/cli/output-filter.js +49 -49
  73. package/src/cli/runtime-control.js +704 -704
  74. package/src/cli-main.js +2763 -2763
  75. package/src/cli.js +78 -78
  76. package/src/config/loader.js +332 -332
  77. package/src/config/schema-validator.js +214 -214
  78. package/src/config/toml-lite.js +8 -8
  79. package/src/daemon/action-handlers.js +71 -71
  80. package/src/daemon/healing-tick.js +87 -87
  81. package/src/daemon/health-probes.js +90 -90
  82. package/src/daemon/notifier.js +57 -57
  83. package/src/daemon/nurse.js +218 -218
  84. package/src/daemon/repair-log.js +106 -106
  85. package/src/daemon/rule-staging.js +90 -90
  86. package/src/daemon/rules.js +29 -29
  87. package/src/daemon/telegram-commands.js +54 -54
  88. package/src/daemon/updater.js +85 -85
  89. package/src/jobs/job-runner.js +78 -78
  90. package/src/mcp/consumer.js +129 -129
  91. package/src/memory/active-recall.js +171 -171
  92. package/src/memory/backend-manager.js +97 -97
  93. package/src/memory/backends/file-backend.js +38 -38
  94. package/src/memory/backends/qmd-backend.js +219 -219
  95. package/src/memory/embedding-guards.js +24 -24
  96. package/src/memory/embedding-index.js +118 -118
  97. package/src/memory/embedding-service.js +179 -179
  98. package/src/memory/file-index.js +177 -177
  99. package/src/memory/memory-signature.js +5 -5
  100. package/src/memory/memory-store.js +648 -648
  101. package/src/memory/retrieval-planner.js +66 -66
  102. package/src/memory/scoring.js +145 -145
  103. package/src/memory/simhash.js +78 -78
  104. package/src/memory/sqlite-active-store.js +824 -824
  105. package/src/memory/write-policy.js +36 -36
  106. package/src/onboarding/aliases.js +33 -33
  107. package/src/onboarding/auth/api-key.js +224 -224
  108. package/src/onboarding/auth/ollama-detect.js +42 -42
  109. package/src/onboarding/clack-prompter.js +77 -77
  110. package/src/onboarding/doctor.js +530 -530
  111. package/src/onboarding/lock.js +42 -42
  112. package/src/onboarding/model-catalog.js +344 -344
  113. package/src/onboarding/phases/auth.js +576 -589
  114. package/src/onboarding/phases/build.js +130 -130
  115. package/src/onboarding/phases/choose.js +82 -82
  116. package/src/onboarding/phases/detect.js +98 -98
  117. package/src/onboarding/phases/hatch.js +216 -216
  118. package/src/onboarding/phases/identity.js +79 -79
  119. package/src/onboarding/phases/ollama.js +345 -345
  120. package/src/onboarding/phases/scaffold.js +99 -99
  121. package/src/onboarding/phases/telegram.js +377 -377
  122. package/src/onboarding/phases/validate.js +204 -204
  123. package/src/onboarding/phases/verify.js +206 -206
  124. package/src/onboarding/platform.js +482 -482
  125. package/src/onboarding/status-bar.js +95 -95
  126. package/src/onboarding/templates.js +794 -794
  127. package/src/onboarding/toml-writer.js +38 -38
  128. package/src/onboarding/tui.js +250 -250
  129. package/src/onboarding/uninstall.js +153 -153
  130. package/src/onboarding/wizard.js +516 -499
  131. package/src/providers/anthropic.js +168 -168
  132. package/src/providers/base.js +247 -247
  133. package/src/providers/circuit-breaker.js +136 -136
  134. package/src/providers/ollama.js +163 -163
  135. package/src/providers/openai-codex.js +149 -149
  136. package/src/providers/openrouter.js +136 -136
  137. package/src/providers/registry.js +36 -36
  138. package/src/providers/router.js +16 -16
  139. package/src/runtime/bootstrap-cache.js +47 -47
  140. package/src/runtime/capabilities-prompt.js +25 -25
  141. package/src/runtime/completion-ping.js +99 -99
  142. package/src/runtime/config-validator.js +121 -121
  143. package/src/runtime/context-ledger.js +360 -360
  144. package/src/runtime/cutover-readiness.js +42 -42
  145. package/src/runtime/daemon.js +729 -729
  146. package/src/runtime/delivery-ack.js +195 -195
  147. package/src/runtime/delivery-adapters/local-file.js +41 -41
  148. package/src/runtime/delivery-adapters/openclaw-cli.js +94 -94
  149. package/src/runtime/delivery-adapters/openclaw-peer.js +98 -98
  150. package/src/runtime/delivery-adapters/shadow.js +13 -13
  151. package/src/runtime/delivery-adapters/standalone-http.js +98 -98
  152. package/src/runtime/delivery-adapters/telegram.js +104 -104
  153. package/src/runtime/delivery-adapters/tui.js +128 -128
  154. package/src/runtime/delivery-manager.js +807 -807
  155. package/src/runtime/delivery-store.js +168 -168
  156. package/src/runtime/dependency-health.js +118 -118
  157. package/src/runtime/envelope.js +114 -114
  158. package/src/runtime/evaluation.js +1089 -1089
  159. package/src/runtime/exec-approvals.js +216 -216
  160. package/src/runtime/executor.js +500 -500
  161. package/src/runtime/failure-ping.js +67 -67
  162. package/src/runtime/flows.js +83 -83
  163. package/src/runtime/guards.js +45 -45
  164. package/src/runtime/handoff.js +51 -51
  165. package/src/runtime/identity-cache.js +28 -28
  166. package/src/runtime/improvement-engine.js +109 -109
  167. package/src/runtime/improvement-harness.js +581 -581
  168. package/src/runtime/input-sanitiser.js +72 -72
  169. package/src/runtime/interaction-contract.js +347 -347
  170. package/src/runtime/lane-readiness.js +226 -226
  171. package/src/runtime/migration.js +323 -323
  172. package/src/runtime/model-resolution.js +78 -78
  173. package/src/runtime/network.js +64 -64
  174. package/src/runtime/notification-store.js +97 -97
  175. package/src/runtime/notifier.js +256 -256
  176. package/src/runtime/orchestrator.js +53 -53
  177. package/src/runtime/orphan-reaper.js +41 -41
  178. package/src/runtime/output-contract-schema.js +139 -139
  179. package/src/runtime/output-contract-validator.js +439 -439
  180. package/src/runtime/peer-readiness.js +69 -69
  181. package/src/runtime/peer-registry.js +133 -133
  182. package/src/runtime/pilot-status.js +108 -108
  183. package/src/runtime/prompt-builder.js +261 -261
  184. package/src/runtime/provider-attempt.js +582 -582
  185. package/src/runtime/report-fallback.js +71 -71
  186. package/src/runtime/result-normalizer.js +183 -183
  187. package/src/runtime/retention.js +74 -74
  188. package/src/runtime/review.js +244 -244
  189. package/src/runtime/route-job.js +15 -15
  190. package/src/runtime/run-store.js +38 -38
  191. package/src/runtime/schedule.js +88 -88
  192. package/src/runtime/scheduler-state.js +434 -434
  193. package/src/runtime/scheduler.js +656 -656
  194. package/src/runtime/session-compactor.js +182 -182
  195. package/src/runtime/session-search.js +155 -155
  196. package/src/runtime/slack-inbound.js +249 -249
  197. package/src/runtime/ssrf.js +102 -102
  198. package/src/runtime/status-aggregator.js +330 -330
  199. package/src/runtime/task-contract.js +140 -140
  200. package/src/runtime/task-packet.js +107 -107
  201. package/src/runtime/task-router.js +140 -140
  202. package/src/runtime/telegram-inbound.js +1565 -1565
  203. package/src/runtime/token-counter.js +134 -134
  204. package/src/runtime/token-estimator.js +59 -59
  205. package/src/runtime/tool-loop.js +200 -200
  206. package/src/runtime/transport-server.js +311 -311
  207. package/src/runtime/tui-server.js +411 -411
  208. package/src/runtime/ulid.js +44 -44
  209. package/src/security/ssrf-check.js +197 -197
  210. package/src/setup.js +369 -369
  211. package/src/shadow/bridge.js +303 -303
  212. package/src/skills/loader.js +84 -84
  213. package/src/tools/catalog.json +49 -49
  214. package/src/tools/cli-delegate.js +44 -44
  215. package/src/tools/mcp-client.js +106 -106
  216. package/src/tools/micro/cancel-task.js +6 -6
  217. package/src/tools/micro/complete-task.js +6 -6
  218. package/src/tools/micro/fail-task.js +6 -6
  219. package/src/tools/micro/http-fetch.js +74 -74
  220. package/src/tools/micro/index.js +36 -36
  221. package/src/tools/micro/lcm-recall.js +60 -60
  222. package/src/tools/micro/list-dir.js +17 -17
  223. package/src/tools/micro/list-skills.js +46 -46
  224. package/src/tools/micro/load-skill.js +38 -38
  225. package/src/tools/micro/memory-search.js +45 -45
  226. package/src/tools/micro/read-file.js +11 -11
  227. package/src/tools/micro/session-search.js +54 -54
  228. package/src/tools/micro/shell-exec.js +43 -43
  229. package/src/tools/micro/trigger-job.js +79 -79
  230. package/src/tools/micro/web-search.js +58 -58
  231. package/src/tools/micro/workspace-paths.js +39 -39
  232. package/src/tools/micro/write-file.js +14 -14
  233. package/src/tools/micro/write-memory.js +41 -41
  234. package/src/tools/registry.js +348 -348
  235. package/src/tools/tool-result-contract.js +36 -36
  236. package/src/tui/chat.js +835 -835
  237. package/src/tui/renderer.js +175 -175
  238. package/src/tui/socket-client.js +217 -217
  239. package/src/utils/canonical-json.js +29 -29
  240. package/src/utils/compaction.js +30 -30
  241. package/src/utils/env-loader.js +5 -5
  242. package/src/utils/errors.js +80 -80
  243. package/src/utils/fs.js +101 -101
  244. package/src/utils/ids.js +5 -5
  245. package/src/utils/model-context-limits.js +30 -30
  246. package/src/utils/token-budget.js +74 -74
  247. package/src/utils/usage-cost.js +25 -25
  248. package/src/utils/usage-metrics.js +14 -14
@@ -1,530 +1,530 @@
1
- /**
2
- * Nemoris Doctor — standalone health report command.
3
- *
4
- * Pure functions (checkPlatform, formatDoctorReport) are unit-tested.
5
- * runDoctor orchestrates all checks and is tested in integration.
6
- */
7
-
8
- import fs from "node:fs";
9
- import os from "node:os";
10
- import { execFile } from "node:child_process";
11
- import { promisify } from "node:util";
12
- import { getDaemonPaths, listServeDaemonPids } from "../cli/runtime-control.js";
13
-
14
- const execFileAsync = promisify(execFile);
15
-
16
- // ── Minimum supported Node.js major version ─────────────────────────
17
- const MIN_NODE_MAJOR = 22;
18
-
19
- // ── Platform check ───────────────────────────────────────────────────
20
-
21
- /**
22
- * Returns basic platform information.
23
- * @returns {{ os: string, arch: string, nodeVersion: string, nodeOk: boolean }}
24
- */
25
- export function checkPlatform() {
26
- const platform = os.platform();
27
- const arch = os.arch();
28
- const nodeVersion = process.version;
29
- const major = parseInt(nodeVersion.replace(/^v/, "").split(".")[0], 10);
30
- const nodeOk = major >= MIN_NODE_MAJOR;
31
-
32
- // Map os.platform() to a human-readable OS name
33
- const osNames = {
34
- darwin: "macOS",
35
- linux: "Linux",
36
- win32: "Windows",
37
- };
38
- const osDisplay = osNames[platform] || platform;
39
-
40
- return {
41
- os: osDisplay,
42
- arch,
43
- nodeVersion,
44
- nodeOk,
45
- };
46
- }
47
-
48
- // ── Dependency checks ────────────────────────────────────────────────
49
-
50
- /**
51
- * Checks for required and optional CLI dependencies.
52
- * @returns {Promise<Array<{ name: string, found: boolean, required: boolean, installHint: string }>>}
53
- */
54
- export async function checkDependencies() {
55
- const platform = os.platform();
56
- const whichCmd = platform === "win32" ? "where" : "which";
57
- const deps = [];
58
-
59
- async function check(name, required, hint) {
60
- try {
61
- await execFileAsync(whichCmd, [name], { timeout: 3000 });
62
- deps.push({ name, found: true, required, installHint: hint });
63
- } catch {
64
- deps.push({ name, found: false, required, installHint: hint });
65
- }
66
- }
67
-
68
- await check("git", true, "https://git-scm.com");
69
- await check("ollama", false, "https://ollama.ai");
70
- await check("gh", false, "https://cli.github.com (optional)");
71
-
72
- if (platform === "win32") {
73
- await check("pm2", false, "npm install -g pm2 (https://pm2.io)");
74
- }
75
-
76
- return deps;
77
- }
78
-
79
- // ── Format helpers ───────────────────────────────────────────────────
80
-
81
- function _stripAnsi(str) {
82
- // Strip ANSI escape codes for length calculations
83
- // eslint-disable-next-line no-control-regex
84
- return str.replace(/\x1b\[[0-9;]*m/g, "");
85
- }
86
-
87
- function checkIcon(ok) {
88
- return ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
89
- }
90
-
91
- function warnIcon() {
92
- return "\x1b[33m!\x1b[0m";
93
- }
94
-
95
- function bold(text) {
96
- return `\x1b[1m${text}\x1b[0m`;
97
- }
98
-
99
- function dim(text) {
100
- return `\x1b[2m${text}\x1b[0m`;
101
- }
102
-
103
- function formatProviderReason(status) {
104
- if (status === null || status === undefined || status === "") {
105
- return "";
106
- }
107
-
108
- if (typeof status === "number") {
109
- return `HTTP ${status}`;
110
- }
111
-
112
- const text = String(status).trim();
113
- const missingToken = text.match(/\(env:([A-Z0-9_]+)\)/);
114
- if (missingToken) {
115
- return `missing ${missingToken[1]}`;
116
- }
117
-
118
- return text;
119
- }
120
-
121
- function row(label, value, ok) {
122
- const icon = ok === null ? warnIcon() : checkIcon(ok);
123
- const labelPad = label.padEnd(16);
124
- const valuePad = (value || "").toString().padEnd(22);
125
- return ` ${labelPad}${valuePad}${icon}`;
126
- }
127
-
128
- // ── formatDoctorReport ───────────────────────────────────────────────
129
-
130
- /**
131
- * Renders a human-readable doctor report string.
132
- *
133
- * @param {{
134
- * platform: { os: string, arch: string, nodeVersion: string, nodeOk: boolean },
135
- * installDir: string,
136
- * configValid: boolean,
137
- * configErrors: string[],
138
- * providers: Array<{ id: string, healthy: boolean, status?: string }>,
139
- * lanes: Array<{ id: string, ready: boolean }>,
140
- * jobs: Array<{ id: string, healthy: boolean }>,
141
- * delivery: Array<{ id: string, ready: boolean }>,
142
- * daemon?: { pids?: number[], stalePidFile?: boolean },
143
- * suggestions: string[],
144
- * exitCode?: number,
145
- * }} results
146
- * @returns {string}
147
- */
148
- export function formatDoctorReport(results) {
149
- const {
150
- platform,
151
- installDir,
152
- configValid,
153
- configErrors = [],
154
- dependencies = [],
155
- providers = [],
156
- lanes = [],
157
- jobs = [],
158
- delivery = [],
159
- mcpServers = [],
160
- daemon = {},
161
- suggestions = [],
162
- } = results;
163
-
164
- const lines = [];
165
-
166
- // Header
167
- const headerLine = "─".repeat(40);
168
- lines.push(`\n${bold(`── Nemoris Doctor `)}${bold(headerLine.slice(19))}\n`);
169
-
170
- // Platform section
171
- const osLabel = `${platform.os} (${platform.arch})`;
172
- lines.push(row("Platform", osLabel, true));
173
- lines.push(row("Node.js", platform.nodeVersion, platform.nodeOk));
174
-
175
- // Install dir — shorten to ~ if in home
176
- const homeDir = os.homedir();
177
- const displayDir = installDir
178
- ? installDir.startsWith(homeDir)
179
- ? installDir.replace(homeDir, "~")
180
- : installDir
181
- : "(unknown)";
182
- lines.push(row("Install", displayDir, true));
183
-
184
- const allLanesReady = lanes.every((lane) => lane.ready);
185
-
186
- // Dependencies section
187
- if (dependencies.length > 0) {
188
- lines.push(`\n${bold(" Dependencies")}`);
189
- for (let i = 0; i < dependencies.length; i++) {
190
- const d = dependencies[i];
191
- const prefix = i === dependencies.length - 1 ? " └─" : " ├─";
192
- if (d.found) {
193
- lines.push(`${prefix} ${d.name.padEnd(14)} ${dim("found")} ${checkIcon(true)}`);
194
- } else if (d.required) {
195
- lines.push(`${prefix} ${d.name.padEnd(14)} \x1b[31mmissing\x1b[0m ${checkIcon(false)} ${dim(d.installHint)}`);
196
- } else {
197
- lines.push(`${prefix} ${d.name.padEnd(14)} ${dim("missing")} \x1b[33m!\x1b[0m ${dim(d.installHint)}`);
198
- }
199
- }
200
- }
201
-
202
- // Providers section
203
- if (providers.length > 0) {
204
- lines.push(`\n${bold(" Providers")}`);
205
- for (let i = 0; i < providers.length; i++) {
206
- const p = providers[i];
207
- const prefix = i === providers.length - 1 ? " └─" : " ├─";
208
- if (p.healthy) {
209
- lines.push(`${prefix} ${p.id.padEnd(14)} ${dim("healthy")} ${checkIcon(true)}`);
210
- } else if (allLanesReady) {
211
- const reasonText = formatProviderReason(p.status);
212
- const reason = reasonText ? ` ${dim(`(${reasonText})`)}` : "";
213
- lines.push(`${prefix} ${p.id.padEnd(14)} ${dim("not configured")} —${reason}`);
214
- } else {
215
- const reasonText = formatProviderReason(p.status);
216
- const reason = reasonText ? dim(`(${reasonText})`) : "";
217
- lines.push(`${prefix} ${p.id.padEnd(14)} \x1b[31munhealthy\x1b[0m ${checkIcon(false)} ${reason}`);
218
- }
219
- }
220
- }
221
-
222
- // Config section
223
- const configNote = configValid
224
- ? `${configErrors.length === 0 ? "all manifests valid" : `${configErrors.length} error(s)`}`
225
- : `${configErrors.length} error(s)`;
226
- lines.push(`\n${row("Config", configNote, configValid)}`);
227
-
228
- if (configErrors.length > 0) {
229
- for (const err of configErrors) {
230
- lines.push(` ${dim("• " + err)}`);
231
- }
232
- }
233
-
234
- // Lanes section
235
- if (lanes.length > 0) {
236
- lines.push(`\n${bold(" Lanes")}`);
237
- for (let i = 0; i < lanes.length; i++) {
238
- const l = lanes[i];
239
- const prefix = i === lanes.length - 1 ? " └─" : " ├─";
240
- lines.push(`${prefix} ${l.id.padEnd(30)} ${checkIcon(l.ready)}`);
241
- }
242
- }
243
-
244
- // Jobs section
245
- if (jobs.length > 0) {
246
- lines.push(`\n${bold(" Jobs")}`);
247
- for (let i = 0; i < jobs.length; i++) {
248
- const j = jobs[i];
249
- const prefix = i === jobs.length - 1 ? " └─" : " ├─";
250
- lines.push(`${prefix} ${j.id.padEnd(30)} ${checkIcon(j.healthy)}`);
251
- }
252
- }
253
-
254
- // Delivery section
255
- if (delivery.length > 0) {
256
- lines.push(`\n${bold(" Delivery")}`);
257
- for (let i = 0; i < delivery.length; i++) {
258
- const d = delivery[i];
259
- const prefix = i === delivery.length - 1 ? " └─" : " ├─";
260
- lines.push(`${prefix} ${d.id.padEnd(30)} ${checkIcon(d.ready)}`);
261
- }
262
- }
263
-
264
- // MCP Servers section
265
- if (mcpServers.length > 0) {
266
- lines.push(`\n${bold(" MCP Servers")}`);
267
- for (let i = 0; i < mcpServers.length; i++) {
268
- const s = mcpServers[i];
269
- const prefix = i === mcpServers.length - 1 ? " └─" : " ├─";
270
- if (s.alive) {
271
- lines.push(`${prefix} ${s.id.padEnd(14)} ${dim("running")} ${checkIcon(true)}`);
272
- } else {
273
- lines.push(`${prefix} ${s.id.padEnd(14)} ${dim("stopped")} ${checkIcon(false)}`);
274
- }
275
- }
276
- }
277
-
278
- if ((daemon.pids || []).length > 1 || daemon.stalePidFile) {
279
- lines.push(`\n${bold(" Daemon")}`);
280
- if ((daemon.pids || []).length > 1) {
281
- lines.push(` ⚠️ Multiple daemon processes detected (pids: ${daemon.pids.join(", ")}). Run nemoris stop && nemoris start to fix.`);
282
- }
283
- if (daemon.stalePidFile) {
284
- lines.push(" ⚠️ Stale PID file found. Daemon is not running.");
285
- }
286
- }
287
-
288
- // Overall status
289
- // An unhealthy provider is only critical if lanes can't resolve without it.
290
- // If all lanes are ready, unhealthy providers are warnings (unused/optional).
291
- const allDeliveryReady = delivery.every((d) => d.ready);
292
- const allJobsHealthy = jobs.every((j) => j.healthy);
293
- const allProviderHealthy = providers.every((p) => p.healthy);
294
- const overallHealthy = configValid && (allProviderHealthy || allLanesReady) && allDeliveryReady && allJobsHealthy;
295
-
296
- const suggCount = suggestions.length;
297
- const suggNote = suggCount > 0 ? ` (${suggCount} suggestion${suggCount === 1 ? "" : "s"})` : "";
298
- const overallLabel = overallHealthy ? bold("healthy") : bold("degraded");
299
- lines.push(`\n Overall: ${overallLabel}${suggNote}\n`);
300
-
301
- // Suggestions
302
- if (suggestions.length > 0) {
303
- for (const s of suggestions) {
304
- lines.push(` \x1b[33m💡\x1b[0m ${s}`);
305
- }
306
- lines.push("");
307
- }
308
-
309
- return lines.join("\n");
310
- }
311
-
312
- // ── runDoctor ────────────────────────────────────────────────────────
313
-
314
- /**
315
- * Orchestrates all health checks and returns a results object.
316
- *
317
- * @param {string} installDir — path to the nemoris install root
318
- * @param {{
319
- * execFileImpl?: Function,
320
- * fsImpl?: typeof import("node:fs"),
321
- * listServeDaemonPidsImpl?: Function,
322
- * }} options
323
- * @returns {Promise<object>} — results suitable for formatDoctorReport + exitCode
324
- */
325
- export async function runDoctor(
326
- installDir,
327
- {
328
- execFileImpl = execFileAsync,
329
- fsImpl = fs,
330
- listServeDaemonPidsImpl = listServeDaemonPids,
331
- } = {},
332
- ) {
333
- const results = {
334
- platform: checkPlatform(),
335
- installDir,
336
- configValid: true,
337
- configErrors: [],
338
- dependencies: [],
339
- providers: [],
340
- lanes: [],
341
- jobs: [],
342
- delivery: [],
343
- mcpServers: [],
344
- daemon: {
345
- pids: [],
346
- stalePidFile: false,
347
- },
348
- suggestions: [],
349
- exitCode: 0,
350
- };
351
-
352
- // ── 0. Dependency checks ──────────────────────────────────────────
353
- try {
354
- results.dependencies = await checkDependencies();
355
- } catch {
356
- // Dependency check failure is non-fatal
357
- }
358
-
359
- // ── 1. Config validation ─────────────────────────────────────────
360
- try {
361
- const { ConfigLoader } = await import("../config/loader.js");
362
- const { validateAllConfigs } = await import("../config/schema-validator.js");
363
- const configDir = `${installDir}/config`;
364
- const loader = new ConfigLoader({ rootDir: configDir });
365
- const config = await loader.loadAll();
366
- const validation = validateAllConfigs(config, configDir);
367
- results.configValid = validation.ok;
368
- results.configErrors = validation.errors || [];
369
- results._config = config;
370
- } catch (err) {
371
- // Gracefully degrade — config may not exist yet
372
- results.configValid = false;
373
- results.configErrors = [err.message || "Failed to load config"];
374
- results._config = null;
375
- }
376
-
377
- // ── 2. Provider health ───────────────────────────────────────────
378
- try {
379
- const { ProviderRegistry } = await import("../providers/registry.js");
380
- const config = results._config;
381
- if (config?.providers) {
382
- const registry = new ProviderRegistry({ fetchImpl: fetch });
383
- for (const [id, providerConfig] of Object.entries(config.providers)) {
384
- try {
385
- const adapter = registry.create(providerConfig);
386
- let healthy = true;
387
- let status = null;
388
- if (typeof adapter.healthCheck === "function") {
389
- const h = await adapter.healthCheck();
390
- healthy = Boolean(h.ok);
391
- status = h.status ?? null;
392
- }
393
- results.providers.push({ id, healthy, status });
394
- } catch (err) {
395
- results.providers.push({ id, healthy: false, status: err.message });
396
- }
397
- }
398
- }
399
- } catch {
400
- // Provider module unavailable — skip
401
- }
402
-
403
- // ── 3. Lane readiness ────────────────────────────────────────────
404
- try {
405
- const { buildLaneReadiness } = await import("../runtime/lane-readiness.js");
406
- const config = results._config;
407
- if (config?.router) {
408
- for (const [laneId] of Object.entries(config.router)) {
409
- try {
410
- const readiness = buildLaneReadiness({ jobId: laneId });
411
- results.lanes.push({ id: laneId, ready: readiness.ready !== false });
412
- } catch {
413
- results.lanes.push({ id: laneId, ready: false });
414
- }
415
- }
416
- }
417
- } catch {
418
- // Lane readiness module unavailable — skip
419
- }
420
-
421
- // ── 4. Job dependency health ─────────────────────────────────────
422
- try {
423
- const { DependencyHealth } = await import("../runtime/dependency-health.js");
424
- const config = results._config;
425
- if (config?.jobs) {
426
- const dh = new DependencyHealth({
427
- projectRoot: installDir,
428
- liveRoot: installDir,
429
- fetchImpl: fetch,
430
- execFileImpl,
431
- });
432
- for (const [jobId] of Object.entries(config.jobs)) {
433
- try {
434
- let healthy = true;
435
- if (typeof dh.forJob === "function") {
436
- const jobHealth = await dh.forJob(jobId);
437
- healthy = Boolean(jobHealth?.ok !== false);
438
- }
439
- results.jobs.push({ id: jobId, healthy });
440
- } catch {
441
- results.jobs.push({ id: jobId, healthy: false });
442
- }
443
- }
444
- }
445
- } catch {
446
- // Dependency health module unavailable — skip
447
- }
448
-
449
- // ── 5. Delivery channel readiness ───────────────────────────────
450
- try {
451
- const config = results._config;
452
- if (config?.delivery?.profiles) {
453
- for (const [id, profile] of Object.entries(config.delivery.profiles)) {
454
- // Basic check: profile exists and has an adapter
455
- const ready = Boolean(profile?.adapter);
456
- results.delivery.push({ id, ready });
457
- }
458
- }
459
- } catch {
460
- // Delivery config unavailable — skip
461
- }
462
-
463
- // ── 5b. MCP server health ────────────────────────────────────────────────────
464
- try {
465
- const config = results._config;
466
- if (config?.mcp?.servers) {
467
- for (const id of Object.keys(config.mcp.servers)) {
468
- // MCP servers are lazy — at doctor time they're not running.
469
- // Report them as configured but stopped (informational only).
470
- results.mcpServers.push({ id, alive: false });
471
- }
472
- }
473
- } catch {
474
- // MCP config unavailable — skip
475
- }
476
-
477
- // ── 5c. Daemon process health ────────────────────────────────────────────────
478
- try {
479
- const { pidFile } = getDaemonPaths(installDir);
480
- results.daemon.pids = await listServeDaemonPidsImpl({ execFileImpl });
481
- results.daemon.stalePidFile = results.daemon.pids.length === 0 && fsImpl.existsSync(pidFile);
482
- } catch {
483
- // Daemon process checks are best-effort only.
484
- }
485
-
486
- // ── 6. Suggestions ───────────────────────────────────────────────
487
- try {
488
- const config = results._config;
489
- // Suggest Telegram if no delivery configured
490
- if (!config?.delivery?.profiles || Object.keys(config.delivery?.profiles || {}).length === 0) {
491
- results.suggestions.push("Connect Telegram for delivery: nemoris channel add telegram");
492
- }
493
- // Suggest enabling embeddings if no embedding config
494
- if (config && !config.runtime?.embeddings?.enabled) {
495
- // Only suggest if runtime loaded but embeddings not explicitly enabled
496
- if (config.runtime && config.runtime.embeddings === undefined) {
497
- results.suggestions.push("Enable embeddings for semantic memory search: set runtime.embeddings.enabled = true");
498
- }
499
- }
500
- } catch {
501
- // Skip suggestions on error
502
- }
503
-
504
- // ── 7. Determine exit code ───────────────────────────────────────
505
- // Unhealthy providers are only critical if lanes can't resolve without them.
506
- const allLanesOk = results.lanes.every((l) => l.ready);
507
- const providerIssuesCritical = results.providers.some((p) => !p.healthy) && !allLanesOk;
508
- const hasCritical = !results.configValid ||
509
- providerIssuesCritical ||
510
- results.delivery.some((d) => !d.ready);
511
- const hasWarning = results.suggestions.length > 0 ||
512
- results.providers.some((p) => !p.healthy) ||
513
- results.jobs.some((j) => !j.healthy) ||
514
- results.lanes.some((l) => !l.ready) ||
515
- results.daemon.pids.length > 1 ||
516
- results.daemon.stalePidFile;
517
-
518
- if (hasCritical) {
519
- results.exitCode = 2;
520
- } else if (hasWarning) {
521
- results.exitCode = 1;
522
- } else {
523
- results.exitCode = 0;
524
- }
525
-
526
- // Clean up internal fields
527
- delete results._config;
528
-
529
- return results;
530
- }
1
+ /**
2
+ * Nemoris Doctor — standalone health report command.
3
+ *
4
+ * Pure functions (checkPlatform, formatDoctorReport) are unit-tested.
5
+ * runDoctor orchestrates all checks and is tested in integration.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import { execFile } from "node:child_process";
11
+ import { promisify } from "node:util";
12
+ import { getDaemonPaths, listServeDaemonPids } from "../cli/runtime-control.js";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ // ── Minimum supported Node.js major version ─────────────────────────
17
+ const MIN_NODE_MAJOR = 22;
18
+
19
+ // ── Platform check ───────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Returns basic platform information.
23
+ * @returns {{ os: string, arch: string, nodeVersion: string, nodeOk: boolean }}
24
+ */
25
+ export function checkPlatform() {
26
+ const platform = os.platform();
27
+ const arch = os.arch();
28
+ const nodeVersion = process.version;
29
+ const major = parseInt(nodeVersion.replace(/^v/, "").split(".")[0], 10);
30
+ const nodeOk = major >= MIN_NODE_MAJOR;
31
+
32
+ // Map os.platform() to a human-readable OS name
33
+ const osNames = {
34
+ darwin: "macOS",
35
+ linux: "Linux",
36
+ win32: "Windows",
37
+ };
38
+ const osDisplay = osNames[platform] || platform;
39
+
40
+ return {
41
+ os: osDisplay,
42
+ arch,
43
+ nodeVersion,
44
+ nodeOk,
45
+ };
46
+ }
47
+
48
+ // ── Dependency checks ────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Checks for required and optional CLI dependencies.
52
+ * @returns {Promise<Array<{ name: string, found: boolean, required: boolean, installHint: string }>>}
53
+ */
54
+ export async function checkDependencies() {
55
+ const platform = os.platform();
56
+ const whichCmd = platform === "win32" ? "where" : "which";
57
+ const deps = [];
58
+
59
+ async function check(name, required, hint) {
60
+ try {
61
+ await execFileAsync(whichCmd, [name], { timeout: 3000 });
62
+ deps.push({ name, found: true, required, installHint: hint });
63
+ } catch {
64
+ deps.push({ name, found: false, required, installHint: hint });
65
+ }
66
+ }
67
+
68
+ await check("git", true, "https://git-scm.com");
69
+ await check("ollama", false, "https://ollama.ai");
70
+ await check("gh", false, "https://cli.github.com (optional)");
71
+
72
+ if (platform === "win32") {
73
+ await check("pm2", false, "npm install -g pm2 (https://pm2.io)");
74
+ }
75
+
76
+ return deps;
77
+ }
78
+
79
+ // ── Format helpers ───────────────────────────────────────────────────
80
+
81
+ function _stripAnsi(str) {
82
+ // Strip ANSI escape codes for length calculations
83
+ // eslint-disable-next-line no-control-regex
84
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
85
+ }
86
+
87
+ function checkIcon(ok) {
88
+ return ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
89
+ }
90
+
91
+ function warnIcon() {
92
+ return "\x1b[33m!\x1b[0m";
93
+ }
94
+
95
+ function bold(text) {
96
+ return `\x1b[1m${text}\x1b[0m`;
97
+ }
98
+
99
+ function dim(text) {
100
+ return `\x1b[2m${text}\x1b[0m`;
101
+ }
102
+
103
+ function formatProviderReason(status) {
104
+ if (status === null || status === undefined || status === "") {
105
+ return "";
106
+ }
107
+
108
+ if (typeof status === "number") {
109
+ return `HTTP ${status}`;
110
+ }
111
+
112
+ const text = String(status).trim();
113
+ const missingToken = text.match(/\(env:([A-Z0-9_]+)\)/);
114
+ if (missingToken) {
115
+ return `missing ${missingToken[1]}`;
116
+ }
117
+
118
+ return text;
119
+ }
120
+
121
+ function row(label, value, ok) {
122
+ const icon = ok === null ? warnIcon() : checkIcon(ok);
123
+ const labelPad = label.padEnd(16);
124
+ const valuePad = (value || "").toString().padEnd(22);
125
+ return ` ${labelPad}${valuePad}${icon}`;
126
+ }
127
+
128
+ // ── formatDoctorReport ───────────────────────────────────────────────
129
+
130
+ /**
131
+ * Renders a human-readable doctor report string.
132
+ *
133
+ * @param {{
134
+ * platform: { os: string, arch: string, nodeVersion: string, nodeOk: boolean },
135
+ * installDir: string,
136
+ * configValid: boolean,
137
+ * configErrors: string[],
138
+ * providers: Array<{ id: string, healthy: boolean, status?: string }>,
139
+ * lanes: Array<{ id: string, ready: boolean }>,
140
+ * jobs: Array<{ id: string, healthy: boolean }>,
141
+ * delivery: Array<{ id: string, ready: boolean }>,
142
+ * daemon?: { pids?: number[], stalePidFile?: boolean },
143
+ * suggestions: string[],
144
+ * exitCode?: number,
145
+ * }} results
146
+ * @returns {string}
147
+ */
148
+ export function formatDoctorReport(results) {
149
+ const {
150
+ platform,
151
+ installDir,
152
+ configValid,
153
+ configErrors = [],
154
+ dependencies = [],
155
+ providers = [],
156
+ lanes = [],
157
+ jobs = [],
158
+ delivery = [],
159
+ mcpServers = [],
160
+ daemon = {},
161
+ suggestions = [],
162
+ } = results;
163
+
164
+ const lines = [];
165
+
166
+ // Header
167
+ const headerLine = "─".repeat(40);
168
+ lines.push(`\n${bold(`── Nemoris Doctor `)}${bold(headerLine.slice(19))}\n`);
169
+
170
+ // Platform section
171
+ const osLabel = `${platform.os} (${platform.arch})`;
172
+ lines.push(row("Platform", osLabel, true));
173
+ lines.push(row("Node.js", platform.nodeVersion, platform.nodeOk));
174
+
175
+ // Install dir — shorten to ~ if in home
176
+ const homeDir = os.homedir();
177
+ const displayDir = installDir
178
+ ? installDir.startsWith(homeDir)
179
+ ? installDir.replace(homeDir, "~")
180
+ : installDir
181
+ : "(unknown)";
182
+ lines.push(row("Install", displayDir, true));
183
+
184
+ const allLanesReady = lanes.every((lane) => lane.ready);
185
+
186
+ // Dependencies section
187
+ if (dependencies.length > 0) {
188
+ lines.push(`\n${bold(" Dependencies")}`);
189
+ for (let i = 0; i < dependencies.length; i++) {
190
+ const d = dependencies[i];
191
+ const prefix = i === dependencies.length - 1 ? " └─" : " ├─";
192
+ if (d.found) {
193
+ lines.push(`${prefix} ${d.name.padEnd(14)} ${dim("found")} ${checkIcon(true)}`);
194
+ } else if (d.required) {
195
+ lines.push(`${prefix} ${d.name.padEnd(14)} \x1b[31mmissing\x1b[0m ${checkIcon(false)} ${dim(d.installHint)}`);
196
+ } else {
197
+ lines.push(`${prefix} ${d.name.padEnd(14)} ${dim("missing")} \x1b[33m!\x1b[0m ${dim(d.installHint)}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Providers section
203
+ if (providers.length > 0) {
204
+ lines.push(`\n${bold(" Providers")}`);
205
+ for (let i = 0; i < providers.length; i++) {
206
+ const p = providers[i];
207
+ const prefix = i === providers.length - 1 ? " └─" : " ├─";
208
+ if (p.healthy) {
209
+ lines.push(`${prefix} ${p.id.padEnd(14)} ${dim("healthy")} ${checkIcon(true)}`);
210
+ } else if (allLanesReady) {
211
+ const reasonText = formatProviderReason(p.status);
212
+ const reason = reasonText ? ` ${dim(`(${reasonText})`)}` : "";
213
+ lines.push(`${prefix} ${p.id.padEnd(14)} ${dim("not configured")} —${reason}`);
214
+ } else {
215
+ const reasonText = formatProviderReason(p.status);
216
+ const reason = reasonText ? dim(`(${reasonText})`) : "";
217
+ lines.push(`${prefix} ${p.id.padEnd(14)} \x1b[31munhealthy\x1b[0m ${checkIcon(false)} ${reason}`);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Config section
223
+ const configNote = configValid
224
+ ? `${configErrors.length === 0 ? "all manifests valid" : `${configErrors.length} error(s)`}`
225
+ : `${configErrors.length} error(s)`;
226
+ lines.push(`\n${row("Config", configNote, configValid)}`);
227
+
228
+ if (configErrors.length > 0) {
229
+ for (const err of configErrors) {
230
+ lines.push(` ${dim("• " + err)}`);
231
+ }
232
+ }
233
+
234
+ // Lanes section
235
+ if (lanes.length > 0) {
236
+ lines.push(`\n${bold(" Lanes")}`);
237
+ for (let i = 0; i < lanes.length; i++) {
238
+ const l = lanes[i];
239
+ const prefix = i === lanes.length - 1 ? " └─" : " ├─";
240
+ lines.push(`${prefix} ${l.id.padEnd(30)} ${checkIcon(l.ready)}`);
241
+ }
242
+ }
243
+
244
+ // Jobs section
245
+ if (jobs.length > 0) {
246
+ lines.push(`\n${bold(" Jobs")}`);
247
+ for (let i = 0; i < jobs.length; i++) {
248
+ const j = jobs[i];
249
+ const prefix = i === jobs.length - 1 ? " └─" : " ├─";
250
+ lines.push(`${prefix} ${j.id.padEnd(30)} ${checkIcon(j.healthy)}`);
251
+ }
252
+ }
253
+
254
+ // Delivery section
255
+ if (delivery.length > 0) {
256
+ lines.push(`\n${bold(" Delivery")}`);
257
+ for (let i = 0; i < delivery.length; i++) {
258
+ const d = delivery[i];
259
+ const prefix = i === delivery.length - 1 ? " └─" : " ├─";
260
+ lines.push(`${prefix} ${d.id.padEnd(30)} ${checkIcon(d.ready)}`);
261
+ }
262
+ }
263
+
264
+ // MCP Servers section
265
+ if (mcpServers.length > 0) {
266
+ lines.push(`\n${bold(" MCP Servers")}`);
267
+ for (let i = 0; i < mcpServers.length; i++) {
268
+ const s = mcpServers[i];
269
+ const prefix = i === mcpServers.length - 1 ? " └─" : " ├─";
270
+ if (s.alive) {
271
+ lines.push(`${prefix} ${s.id.padEnd(14)} ${dim("running")} ${checkIcon(true)}`);
272
+ } else {
273
+ lines.push(`${prefix} ${s.id.padEnd(14)} ${dim("stopped")} ${checkIcon(false)}`);
274
+ }
275
+ }
276
+ }
277
+
278
+ if ((daemon.pids || []).length > 1 || daemon.stalePidFile) {
279
+ lines.push(`\n${bold(" Daemon")}`);
280
+ if ((daemon.pids || []).length > 1) {
281
+ lines.push(` ⚠️ Multiple daemon processes detected (pids: ${daemon.pids.join(", ")}). Run nemoris stop && nemoris start to fix.`);
282
+ }
283
+ if (daemon.stalePidFile) {
284
+ lines.push(" ⚠️ Stale PID file found. Daemon is not running.");
285
+ }
286
+ }
287
+
288
+ // Overall status
289
+ // An unhealthy provider is only critical if lanes can't resolve without it.
290
+ // If all lanes are ready, unhealthy providers are warnings (unused/optional).
291
+ const allDeliveryReady = delivery.every((d) => d.ready);
292
+ const allJobsHealthy = jobs.every((j) => j.healthy);
293
+ const allProviderHealthy = providers.every((p) => p.healthy);
294
+ const overallHealthy = configValid && (allProviderHealthy || allLanesReady) && allDeliveryReady && allJobsHealthy;
295
+
296
+ const suggCount = suggestions.length;
297
+ const suggNote = suggCount > 0 ? ` (${suggCount} suggestion${suggCount === 1 ? "" : "s"})` : "";
298
+ const overallLabel = overallHealthy ? bold("healthy") : bold("degraded");
299
+ lines.push(`\n Overall: ${overallLabel}${suggNote}\n`);
300
+
301
+ // Suggestions
302
+ if (suggestions.length > 0) {
303
+ for (const s of suggestions) {
304
+ lines.push(` \x1b[33m💡\x1b[0m ${s}`);
305
+ }
306
+ lines.push("");
307
+ }
308
+
309
+ return lines.join("\n");
310
+ }
311
+
312
+ // ── runDoctor ────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Orchestrates all health checks and returns a results object.
316
+ *
317
+ * @param {string} installDir — path to the nemoris install root
318
+ * @param {{
319
+ * execFileImpl?: Function,
320
+ * fsImpl?: typeof import("node:fs"),
321
+ * listServeDaemonPidsImpl?: Function,
322
+ * }} options
323
+ * @returns {Promise<object>} — results suitable for formatDoctorReport + exitCode
324
+ */
325
+ export async function runDoctor(
326
+ installDir,
327
+ {
328
+ execFileImpl = execFileAsync,
329
+ fsImpl = fs,
330
+ listServeDaemonPidsImpl = listServeDaemonPids,
331
+ } = {},
332
+ ) {
333
+ const results = {
334
+ platform: checkPlatform(),
335
+ installDir,
336
+ configValid: true,
337
+ configErrors: [],
338
+ dependencies: [],
339
+ providers: [],
340
+ lanes: [],
341
+ jobs: [],
342
+ delivery: [],
343
+ mcpServers: [],
344
+ daemon: {
345
+ pids: [],
346
+ stalePidFile: false,
347
+ },
348
+ suggestions: [],
349
+ exitCode: 0,
350
+ };
351
+
352
+ // ── 0. Dependency checks ──────────────────────────────────────────
353
+ try {
354
+ results.dependencies = await checkDependencies();
355
+ } catch {
356
+ // Dependency check failure is non-fatal
357
+ }
358
+
359
+ // ── 1. Config validation ─────────────────────────────────────────
360
+ try {
361
+ const { ConfigLoader } = await import("../config/loader.js");
362
+ const { validateAllConfigs } = await import("../config/schema-validator.js");
363
+ const configDir = `${installDir}/config`;
364
+ const loader = new ConfigLoader({ rootDir: configDir });
365
+ const config = await loader.loadAll();
366
+ const validation = validateAllConfigs(config, configDir);
367
+ results.configValid = validation.ok;
368
+ results.configErrors = validation.errors || [];
369
+ results._config = config;
370
+ } catch (err) {
371
+ // Gracefully degrade — config may not exist yet
372
+ results.configValid = false;
373
+ results.configErrors = [err.message || "Failed to load config"];
374
+ results._config = null;
375
+ }
376
+
377
+ // ── 2. Provider health ───────────────────────────────────────────
378
+ try {
379
+ const { ProviderRegistry } = await import("../providers/registry.js");
380
+ const config = results._config;
381
+ if (config?.providers) {
382
+ const registry = new ProviderRegistry({ fetchImpl: fetch });
383
+ for (const [id, providerConfig] of Object.entries(config.providers)) {
384
+ try {
385
+ const adapter = registry.create(providerConfig);
386
+ let healthy = true;
387
+ let status = null;
388
+ if (typeof adapter.healthCheck === "function") {
389
+ const h = await adapter.healthCheck();
390
+ healthy = Boolean(h.ok);
391
+ status = h.status ?? null;
392
+ }
393
+ results.providers.push({ id, healthy, status });
394
+ } catch (err) {
395
+ results.providers.push({ id, healthy: false, status: err.message });
396
+ }
397
+ }
398
+ }
399
+ } catch {
400
+ // Provider module unavailable — skip
401
+ }
402
+
403
+ // ── 3. Lane readiness ────────────────────────────────────────────
404
+ try {
405
+ const { buildLaneReadiness } = await import("../runtime/lane-readiness.js");
406
+ const config = results._config;
407
+ if (config?.router) {
408
+ for (const [laneId] of Object.entries(config.router)) {
409
+ try {
410
+ const readiness = buildLaneReadiness({ jobId: laneId });
411
+ results.lanes.push({ id: laneId, ready: readiness.ready !== false });
412
+ } catch {
413
+ results.lanes.push({ id: laneId, ready: false });
414
+ }
415
+ }
416
+ }
417
+ } catch {
418
+ // Lane readiness module unavailable — skip
419
+ }
420
+
421
+ // ── 4. Job dependency health ─────────────────────────────────────
422
+ try {
423
+ const { DependencyHealth } = await import("../runtime/dependency-health.js");
424
+ const config = results._config;
425
+ if (config?.jobs) {
426
+ const dh = new DependencyHealth({
427
+ projectRoot: installDir,
428
+ liveRoot: installDir,
429
+ fetchImpl: fetch,
430
+ execFileImpl,
431
+ });
432
+ for (const [jobId] of Object.entries(config.jobs)) {
433
+ try {
434
+ let healthy = true;
435
+ if (typeof dh.forJob === "function") {
436
+ const jobHealth = await dh.forJob(jobId);
437
+ healthy = Boolean(jobHealth?.ok !== false);
438
+ }
439
+ results.jobs.push({ id: jobId, healthy });
440
+ } catch {
441
+ results.jobs.push({ id: jobId, healthy: false });
442
+ }
443
+ }
444
+ }
445
+ } catch {
446
+ // Dependency health module unavailable — skip
447
+ }
448
+
449
+ // ── 5. Delivery channel readiness ───────────────────────────────
450
+ try {
451
+ const config = results._config;
452
+ if (config?.delivery?.profiles) {
453
+ for (const [id, profile] of Object.entries(config.delivery.profiles)) {
454
+ // Basic check: profile exists and has an adapter
455
+ const ready = Boolean(profile?.adapter);
456
+ results.delivery.push({ id, ready });
457
+ }
458
+ }
459
+ } catch {
460
+ // Delivery config unavailable — skip
461
+ }
462
+
463
+ // ── 5b. MCP server health ────────────────────────────────────────────────────
464
+ try {
465
+ const config = results._config;
466
+ if (config?.mcp?.servers) {
467
+ for (const id of Object.keys(config.mcp.servers)) {
468
+ // MCP servers are lazy — at doctor time they're not running.
469
+ // Report them as configured but stopped (informational only).
470
+ results.mcpServers.push({ id, alive: false });
471
+ }
472
+ }
473
+ } catch {
474
+ // MCP config unavailable — skip
475
+ }
476
+
477
+ // ── 5c. Daemon process health ────────────────────────────────────────────────
478
+ try {
479
+ const { pidFile } = getDaemonPaths(installDir);
480
+ results.daemon.pids = await listServeDaemonPidsImpl({ execFileImpl });
481
+ results.daemon.stalePidFile = results.daemon.pids.length === 0 && fsImpl.existsSync(pidFile);
482
+ } catch {
483
+ // Daemon process checks are best-effort only.
484
+ }
485
+
486
+ // ── 6. Suggestions ───────────────────────────────────────────────
487
+ try {
488
+ const config = results._config;
489
+ // Suggest Telegram if no delivery configured
490
+ if (!config?.delivery?.profiles || Object.keys(config.delivery?.profiles || {}).length === 0) {
491
+ results.suggestions.push("Connect Telegram for delivery: nemoris channel add telegram");
492
+ }
493
+ // Suggest enabling embeddings if no embedding config
494
+ if (config && !config.runtime?.embeddings?.enabled) {
495
+ // Only suggest if runtime loaded but embeddings not explicitly enabled
496
+ if (config.runtime && config.runtime.embeddings === undefined) {
497
+ results.suggestions.push("Enable embeddings for semantic memory search: set runtime.embeddings.enabled = true");
498
+ }
499
+ }
500
+ } catch {
501
+ // Skip suggestions on error
502
+ }
503
+
504
+ // ── 7. Determine exit code ───────────────────────────────────────
505
+ // Unhealthy providers are only critical if lanes can't resolve without them.
506
+ const allLanesOk = results.lanes.every((l) => l.ready);
507
+ const providerIssuesCritical = results.providers.some((p) => !p.healthy) && !allLanesOk;
508
+ const hasCritical = !results.configValid ||
509
+ providerIssuesCritical ||
510
+ results.delivery.some((d) => !d.ready);
511
+ const hasWarning = results.suggestions.length > 0 ||
512
+ results.providers.some((p) => !p.healthy) ||
513
+ results.jobs.some((j) => !j.healthy) ||
514
+ results.lanes.some((l) => !l.ready) ||
515
+ results.daemon.pids.length > 1 ||
516
+ results.daemon.stalePidFile;
517
+
518
+ if (hasCritical) {
519
+ results.exitCode = 2;
520
+ } else if (hasWarning) {
521
+ results.exitCode = 1;
522
+ } else {
523
+ results.exitCode = 0;
524
+ }
525
+
526
+ // Clean up internal fields
527
+ delete results._config;
528
+
529
+ return results;
530
+ }