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,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
+ }
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const PHASES = ["detect", "choose", "build", "validate", "verify"];
5
+
6
+ const LOCK_FILENAME = "nemoris.lock";
7
+ const OLD_PHASE_NAMES = ["scaffold", "identity", "auth", "hatch"];
8
+
9
+ function lockPath(installDir) {
10
+ return path.join(installDir, LOCK_FILENAME);
11
+ }
12
+
13
+ export function writeLock(installDir, state) {
14
+ fs.writeFileSync(lockPath(installDir), JSON.stringify(state, null, 2), "utf8");
15
+ }
16
+
17
+ export function readLock(installDir) {
18
+ const p = lockPath(installDir);
19
+ try {
20
+ const raw = fs.readFileSync(p, "utf8");
21
+ const parsed = JSON.parse(raw);
22
+ const completed = parsed.completed || [];
23
+ const isOldFormat = completed.some((name) => OLD_PHASE_NAMES.includes(name));
24
+ if (isOldFormat) {
25
+ console.log(" Previous partial install detected — restarting with new wizard.");
26
+ try { fs.unlinkSync(p); } catch {}
27
+ return null;
28
+ }
29
+ return parsed;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function deleteLock(installDir) {
36
+ const p = lockPath(installDir);
37
+ try {
38
+ fs.unlinkSync(p);
39
+ } catch {
40
+ // ignore if already gone
41
+ }
42
+ }