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
package/src/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3
+ process.stdout.write = (chunk, ...args) => {
4
+ const text = typeof chunk === "string"
5
+ ? chunk
6
+ : Buffer.isBuffer(chunk)
7
+ ? chunk.toString("utf8")
8
+ : null;
9
+
10
+ if (text === null) {
11
+ return originalStdoutWrite(chunk, ...args);
12
+ }
13
+
14
+ const filtered = text
15
+ .split(/\r?\n/)
16
+ .filter((line) => !line.startsWith("[openclaw/patch]"))
17
+ .join("\n");
18
+
19
+ if (!filtered) {
20
+ return true;
21
+ }
22
+
23
+ const nextChunk = Buffer.isBuffer(chunk) ? Buffer.from(filtered, "utf8") : filtered;
24
+ return originalStdoutWrite(nextChunk, ...args);
25
+ };
26
+
27
+ const [{ default: path }, { default: fs }, { fileURLToPath }, { resolveEnvPath }] = await Promise.all([
28
+ import("node:path"),
29
+ import("node:fs"),
30
+ import("node:url"),
31
+ import("./utils/env-loader.js"),
32
+ ]);
33
+
34
+ function printVersionAndExit() {
35
+ const arg = process.argv[2];
36
+ if (arg === "--version" || arg === "-v" || arg === "version") {
37
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
38
+ const { version } = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
39
+ console.log(version);
40
+ process.exit(0);
41
+ }
42
+ }
43
+
44
+ const ENV_BLOCKLIST = new Set(["NODE_OPTIONS", "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES"]);
45
+
46
+ function loadParentEnv(envPath) {
47
+ try {
48
+ const content = fs.readFileSync(envPath, "utf8");
49
+ for (const line of content.split("\n")) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed || trimmed.startsWith("#")) continue;
52
+ const eqIdx = trimmed.indexOf("=");
53
+ if (eqIdx < 1) continue;
54
+ const key = trimmed.slice(0, eqIdx).trim();
55
+ if (ENV_BLOCKLIST.has(key)) continue;
56
+ if (process.env[key] !== undefined) continue;
57
+ let value = trimmed.slice(eqIdx + 1).trim();
58
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
59
+ value = value.slice(1, -1);
60
+ }
61
+ process.env[key] = value;
62
+ }
63
+ } catch {
64
+ // .env file is optional
65
+ }
66
+ }
67
+
68
+ printVersionAndExit();
69
+
70
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
71
+ const parentEnvPath = resolveEnvPath(__dirname);
72
+ loadParentEnv(parentEnvPath);
73
+
74
+ const { main } = await import("./cli-main.js");
75
+ const exitCode = await main(process.argv);
76
+ if (typeof exitCode === "number") {
77
+ process.exitCode = exitCode;
78
+ }
@@ -0,0 +1,332 @@
1
+ import path from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { listFilesRecursive, readText } from "../utils/fs.js";
4
+ import { parseToml } from "./toml-lite.js";
5
+ import { validateRuntimeConfig } from "../runtime/config-validator.js";
6
+ import { validateOrThrow, SCHEMAS } from "./schema-validator.js";
7
+
8
+ const PATH_KEYS = new Set(["workspaceRoot", "soulRef", "purposeRef", "userRef"]);
9
+
10
+ async function loadDirectoryAsMap(dirPath, projectRoot, keyField = "id", { schema } = {}) {
11
+ const files = (await listFilesRecursive(dirPath)).filter((filePath) => filePath.endsWith(".toml"));
12
+ const entries = [];
13
+
14
+ for (const filePath of files) {
15
+ const parsed = resolveConfigPaths(normalizeKeys(parseToml(await readText(filePath, ""))), {
16
+ projectRoot
17
+ });
18
+ if (schema) {
19
+ validateOrThrow(parsed, schema, filePath);
20
+ }
21
+ const id = parsed[keyField] || path.basename(filePath, ".toml");
22
+ entries.push([id, { ...parsed, __filePath: filePath }]);
23
+ }
24
+
25
+ return Object.fromEntries(entries);
26
+ }
27
+
28
+ function normalizeKeys(value) {
29
+ if (Array.isArray(value)) {
30
+ return value.map((item) => normalizeKeys(item));
31
+ }
32
+
33
+ if (!value || typeof value !== "object") {
34
+ return value;
35
+ }
36
+
37
+ const normalized = {};
38
+ for (const [key, child] of Object.entries(value)) {
39
+ const normalizedKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
40
+ normalized[normalizedKey] = normalizeKeys(child);
41
+ }
42
+ return normalized;
43
+ }
44
+
45
+ export function expandEnvRefs(value) {
46
+ return String(value || "").replace(/\$\{([^}]+)\}|\$([A-Z0-9_]+)/gi, (_match, braced, simple) => {
47
+ const envName = braced || simple;
48
+ return process.env[envName] || "";
49
+ });
50
+ }
51
+
52
+ function resolveEnvValue(value) {
53
+ if (typeof value === "string" && value.startsWith("env:")) {
54
+ return process.env[value.slice(4)] || "";
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export function resolvePathLike(value) {
60
+ if (typeof value !== "string") return value;
61
+ const expanded = expandEnvRefs(value);
62
+ if (!expanded) return expanded;
63
+ if (expanded === "~") return homedir();
64
+ if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
65
+ return path.normalize(homedir() + expanded.slice(1));
66
+ }
67
+ if (path.isAbsolute(expanded)) return path.normalize(expanded);
68
+ return expanded;
69
+ }
70
+
71
+ function resolveConfigPaths(value, { projectRoot, parentKey = null } = {}) {
72
+ if (Array.isArray(value)) {
73
+ return value.map((item) => resolveConfigPaths(item, { projectRoot, parentKey }));
74
+ }
75
+
76
+ if (typeof value === "string") {
77
+ if (PATH_KEYS.has(parentKey)) {
78
+ const resolvedPath = resolvePathLike(value);
79
+ if (!resolvedPath || path.isAbsolute(resolvedPath)) {
80
+ return resolvedPath;
81
+ }
82
+ return path.resolve(projectRoot, resolvedPath);
83
+ }
84
+ return expandEnvRefs(value);
85
+ }
86
+
87
+ if (!value || typeof value !== "object") {
88
+ return value;
89
+ }
90
+
91
+ const resolved = {};
92
+ for (const [key, child] of Object.entries(value)) {
93
+ resolved[key] = resolveConfigPaths(child, { projectRoot, parentKey: key });
94
+ }
95
+ return resolved;
96
+ }
97
+
98
+ export class ConfigLoader {
99
+ constructor({ rootDir }) {
100
+ this.rootDir = rootDir;
101
+ this.projectRoot = path.dirname(rootDir);
102
+ }
103
+
104
+ async loadRouter() {
105
+ const filePath = path.join(this.rootDir, "router.toml");
106
+ const parsed = parseToml(await readText(filePath, ""));
107
+ const lanes = {};
108
+ for (const [laneName, rawValue] of Object.entries(parsed.lanes || {})) {
109
+ const value = normalizeKeys(rawValue);
110
+ lanes[laneName] = {
111
+ primary: value.primary,
112
+ fallbackModels: value.fallbackModels || [],
113
+ fallback: value.fallback,
114
+ manualBump: value.manualBump
115
+ };
116
+ }
117
+ validateOrThrow(lanes, SCHEMAS.router, filePath);
118
+ return lanes;
119
+ }
120
+
121
+ async loadProviders() {
122
+ return loadDirectoryAsMap(path.join(this.rootDir, "providers"), this.projectRoot, "id", { schema: SCHEMAS.provider });
123
+ }
124
+
125
+ async loadEmbeddings() {
126
+ return normalizeKeys(parseToml(await readText(path.join(this.rootDir, "embeddings.toml"), "")));
127
+ }
128
+
129
+ async loadRuntimeSettings() {
130
+ const filePath = path.join(this.rootDir, "runtime.toml");
131
+ const parsed = normalizeKeys(parseToml(await readText(filePath, "")));
132
+ validateOrThrow(parsed, SCHEMAS.runtime, filePath);
133
+ const configuredMaxJobsPerTick = parsed.concurrency?.maxJobsPerTick ?? parsed.concurrency?.maxConcurrentJobs;
134
+ return {
135
+ safety: parsed.safety || {},
136
+ concurrency: {
137
+ ...parsed.concurrency,
138
+ maxJobsPerTick: configuredMaxJobsPerTick ?? 2
139
+ },
140
+ retention: {
141
+ runs: parsed.retention?.runs || {},
142
+ notifications: parsed.retention?.notifications || {},
143
+ deliveries: parsed.retention?.deliveries || {},
144
+ transportInbox: parsed.retention?.transportInbox || {}
145
+ },
146
+ retrieval: {
147
+ lexicalWeight: parsed.retrieval?.lexicalWeight ?? 0.36,
148
+ embeddingWeight: parsed.retrieval?.embeddingWeight ?? 0.3,
149
+ recencyWeight: parsed.retrieval?.recencyWeight ?? 0.14,
150
+ salienceWeight: parsed.retrieval?.salienceWeight ?? 0.14,
151
+ typeWeight: parsed.retrieval?.typeWeight ?? 0.06,
152
+ semanticRescueBonus: parsed.retrieval?.semanticRescueBonus ?? 0.06,
153
+ shadowSnapshotPenalty: parsed.retrieval?.shadowSnapshotPenalty ?? 0.12
154
+ },
155
+ memoryLocks: {
156
+ ttlMs: parsed.memoryLocks?.ttlMs ?? 15000,
157
+ retryDelayMs: parsed.memoryLocks?.retryDelayMs ?? 25,
158
+ maxRetries: parsed.memoryLocks?.maxRetries ?? 40
159
+ },
160
+ handoffs: {
161
+ pendingTimeoutMinutes: parsed.handoffs?.pendingTimeoutMinutes ?? 120,
162
+ escalateOnExpiry: parsed.handoffs?.escalateOnExpiry ?? true,
163
+ escalationDeliveryProfile: parsed.handoffs?.escalationDeliveryProfile || null
164
+ },
165
+ followUps: {
166
+ pendingTimeoutMinutes: parsed.followUps?.pendingTimeoutMinutes ?? 120,
167
+ escalateOnExpiry: parsed.followUps?.escalateOnExpiry ?? true,
168
+ escalationDeliveryProfile: parsed.followUps?.escalationDeliveryProfile || null,
169
+ completionTtlMultiplier: parsed.followUps?.completionTtlMultiplier ?? 2
170
+ },
171
+ yields: {
172
+ enabled: parsed.yields?.enabled ?? true,
173
+ defaultTargetSurface: parsed.yields?.defaultTargetSurface || "operator_review"
174
+ },
175
+ delivery: {
176
+ preventResendOnUncertain: parsed.delivery?.preventResendOnUncertain ?? true,
177
+ retryOnFailure: parsed.delivery?.retryOnFailure ?? false
178
+ },
179
+ maintenance: {
180
+ walCheckpointThresholdBytes: parsed.maintenance?.walCheckpointThresholdBytes ?? 64 * 1024 * 1024,
181
+ pruneOnTick: parsed.maintenance?.pruneOnTick ?? true,
182
+ sweepPendingHandoffsOnTick: parsed.maintenance?.sweepPendingHandoffsOnTick ?? true,
183
+ sweepPendingFollowUpsOnTick: parsed.maintenance?.sweepPendingFollowUpsOnTick ?? true
184
+ },
185
+ network: {
186
+ dnsResultOrder: parsed.network?.dnsResultOrder || "system",
187
+ connectTimeoutMs: parsed.network?.connectTimeoutMs ?? 10000,
188
+ readTimeoutMs: parsed.network?.readTimeoutMs ?? 30000,
189
+ retryBudget: parsed.network?.retryBudget ?? 1,
190
+ circuitBreakerThreshold: parsed.network?.circuitBreakerThreshold ?? 3
191
+ },
192
+ bootstrapCache: {
193
+ enabled: parsed.bootstrapCache?.enabled ?? true,
194
+ identityTtlMs: parsed.bootstrapCache?.identityTtlMs ?? 300000
195
+ },
196
+ reportFallback: {
197
+ enabled: parsed.reportFallback?.enabled ?? false,
198
+ lane: parsed.reportFallback?.lane || "report_fallback_lowcost",
199
+ allowedJobIds: parsed.reportFallback?.allowedJobIds || ["workspace-health"],
200
+ allowedFailureClasses: parsed.reportFallback?.allowedFailureClasses || ["timeout", "provider_loading"]
201
+ },
202
+ shutdown: {
203
+ drainTimeoutMs: parsed.shutdown?.drainTimeoutMs ?? 15000,
204
+ transportShutdownTimeoutMs: parsed.shutdown?.transportShutdownTimeoutMs ?? 5000
205
+ },
206
+ circuitBreaker: {
207
+ failureThreshold: parsed.circuitBreaker?.failureThreshold ?? 5,
208
+ resetTimeoutSeconds: parsed.circuitBreaker?.resetTimeoutSeconds ?? 30,
209
+ halfOpenMaxProbes: parsed.circuitBreaker?.halfOpenMaxProbes ?? 1,
210
+ transientCodes: parsed.circuitBreaker?.transientCodes ?? [408, 429, 500, 502, 503, 504]
211
+ },
212
+ extensions: {
213
+ implicitWorkspaceAutoload: parsed.extensions?.implicitWorkspaceAutoload ?? false,
214
+ requireExplicitTrust: parsed.extensions?.requireExplicitTrust ?? true,
215
+ trustedRoots: parsed.extensions?.trustedRoots || []
216
+ },
217
+ telegram: {
218
+ botTokenEnv: parsed.telegram?.botTokenEnv || "NEMORIS_TELEGRAM_BOT_TOKEN",
219
+ pollingMode: parsed.telegram?.pollingMode ?? false,
220
+ webhookUrl: parsed.telegram?.webhookUrl || "",
221
+ operatorChatId: parsed.telegram?.operatorChatId || "",
222
+ authorizedChatIds: parsed.telegram?.authorizedChatIds || [],
223
+ defaultAgent: parsed.telegram?.defaultAgent || "nemo",
224
+ },
225
+ slack: {
226
+ enabled: parsed.slack?.enabled ?? false,
227
+ botToken: resolveEnvValue(parsed.slack?.botToken || ""),
228
+ signingSecret: resolveEnvValue(parsed.slack?.signingSecret || ""),
229
+ eventsPath: parsed.slack?.eventsPath || "/slack/events",
230
+ slashPath: parsed.slack?.slashPath || "/slack/slash",
231
+ },
232
+ };
233
+ }
234
+
235
+ async loadDelivery() {
236
+ return normalizeKeys(parseToml(await readText(path.join(this.rootDir, "delivery.toml"), "")));
237
+ }
238
+
239
+ async loadTaskRouter() {
240
+ return normalizeKeys(parseToml(await readText(path.join(this.rootDir, "task-router.toml"), "")));
241
+ }
242
+
243
+ async loadOutputContracts() {
244
+ return normalizeKeys(parseToml(await readText(path.join(this.rootDir, "output-contracts.toml"), "")));
245
+ }
246
+
247
+ async loadImprovementTargets() {
248
+ return normalizeKeys(parseToml(await readText(path.join(this.rootDir, "improvement-targets.toml"), "")));
249
+ }
250
+
251
+ async loadPeers() {
252
+ return normalizeKeys(parseToml(await readText(path.join(this.rootDir, "peers.toml"), "")));
253
+ }
254
+
255
+ async loadAgents() {
256
+ return loadDirectoryAsMap(path.join(this.rootDir, "agents"), this.projectRoot, "id", { schema: SCHEMAS.agent });
257
+ }
258
+
259
+ async loadJobs() {
260
+ return loadDirectoryAsMap(path.join(this.rootDir, "jobs"), this.projectRoot, "id", { schema: SCHEMAS.job });
261
+ }
262
+
263
+ async loadPolicies() {
264
+ return loadDirectoryAsMap(path.join(this.rootDir, "policies"), this.projectRoot);
265
+ }
266
+
267
+ async loadMcp() {
268
+ const filePath = path.join(this.rootDir, "mcp.toml");
269
+ try {
270
+ const raw = await readText(filePath, "");
271
+ if (!raw.trim()) return { servers: {} };
272
+ const parsed = normalizeKeys(parseToml(raw));
273
+ const servers = {};
274
+ for (const [id, serverConfig] of Object.entries(parsed.servers || {})) {
275
+ if (serverConfig.enabled === false) continue;
276
+ servers[id] = {
277
+ command: serverConfig.command,
278
+ args: serverConfig.args || [],
279
+ env: serverConfig.env || {},
280
+ timeout: serverConfig.timeout || 30000,
281
+ };
282
+ }
283
+ return { servers };
284
+ } catch (err) {
285
+ if (err.code === "ENOENT" || err.message?.includes("ENOENT")) return { servers: {} };
286
+ throw err;
287
+ }
288
+ }
289
+
290
+ async loadAll(options = {}) {
291
+ const [router, providers, agents, jobs, policies, runtime, taskRouter, delivery, peers, outputContracts, improvementTargets, mcp] = await Promise.all([
292
+ this.loadRouter(),
293
+ this.loadProviders(),
294
+ this.loadAgents(),
295
+ this.loadJobs(),
296
+ this.loadPolicies(),
297
+ this.loadRuntimeSettings(),
298
+ this.loadTaskRouter(),
299
+ this.loadDelivery(),
300
+ this.loadPeers(),
301
+ this.loadOutputContracts(),
302
+ this.loadImprovementTargets(),
303
+ this.loadMcp().catch((err) => {
304
+ if (err.code !== "ENOENT" && !err.message?.includes("ENOENT")) {
305
+ console.warn(`[config] mcp.toml parse error: ${err.message}`);
306
+ }
307
+ return { servers: {} };
308
+ })
309
+ ]);
310
+
311
+ const config = {
312
+ router,
313
+ providers,
314
+ agents,
315
+ jobs,
316
+ policies,
317
+ runtime,
318
+ taskRouter,
319
+ delivery,
320
+ peers,
321
+ outputContracts,
322
+ improvementTargets: improvementTargets.targets || {},
323
+ mcp,
324
+ embeddings: await this.loadEmbeddings()
325
+ };
326
+
327
+ if (!options?.skipValidation) {
328
+ await validateRuntimeConfig(config);
329
+ }
330
+ return config;
331
+ }
332
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * TOML config schema validation — validates parsed config objects against
3
+ * expected schemas at load time so that missing/malformed fields fail loudly
4
+ * instead of silently degrading at runtime.
5
+ */
6
+
7
+ import { formatRuntimeError, RuntimeError } from "../utils/errors.js";
8
+
9
+ // ── Schema definitions ──────────────────────────────────────────────
10
+
11
+ const AGENT_SCHEMA = {
12
+ label: "agent",
13
+ required: {
14
+ id: "string",
15
+ primaryLane: "string",
16
+ memoryPolicy: "string",
17
+ toolPolicy: "string"
18
+ }
19
+ };
20
+
21
+ const JOB_SCHEMA = {
22
+ label: "job",
23
+ required: {
24
+ id: "string",
25
+ modelLane: "string"
26
+ },
27
+ atLeastOne: [["schedule", "trigger"]]
28
+ };
29
+
30
+ const PROVIDER_SCHEMA = {
31
+ label: "provider",
32
+ required: {
33
+ id: "string"
34
+ },
35
+ atLeastOne: [["adapter", "type"]]
36
+ };
37
+
38
+ const RUNTIME_SCHEMA = {
39
+ label: "runtime",
40
+ requiredSections: {
41
+ safety: {
42
+ contextTokens: "number"
43
+ }
44
+ }
45
+ };
46
+
47
+ const ROUTER_SCHEMA = {
48
+ label: "router",
49
+ minLanes: 1
50
+ };
51
+
52
+ export const SCHEMAS = {
53
+ agent: AGENT_SCHEMA,
54
+ job: JOB_SCHEMA,
55
+ provider: PROVIDER_SCHEMA,
56
+ runtime: RUNTIME_SCHEMA,
57
+ router: ROUTER_SCHEMA
58
+ };
59
+
60
+ // ── Validation engine ───────────────────────────────────────────────
61
+
62
+ export class ConfigSchemaError extends Error {
63
+ constructor(message, errors) {
64
+ super(message);
65
+ this.name = "ConfigSchemaError";
66
+ this.validationErrors = errors;
67
+ }
68
+ }
69
+
70
+ function typeLabel(value) {
71
+ if (value === null || value === undefined) return "missing";
72
+ if (Array.isArray(value)) return "array";
73
+ return typeof value;
74
+ }
75
+
76
+ function checkType(value, expectedType) {
77
+ if (value === null || value === undefined) return false;
78
+ if (expectedType === "array") return Array.isArray(value);
79
+ return typeof value === expectedType;
80
+ }
81
+
82
+ /**
83
+ * Validate a parsed config object against a schema definition.
84
+ *
85
+ * @param {object} config – the parsed (and key-normalised) config object
86
+ * @param {object} schema – one of the SCHEMAS entries
87
+ * @param {string} filePath – file path for error messages
88
+ * @returns {{ ok: boolean, errors: string[] }}
89
+ */
90
+ export function validateConfig(config, schema, filePath) {
91
+ const errors = [];
92
+ const ctx = filePath || schema.label || "config";
93
+
94
+ if (!config || typeof config !== "object") {
95
+ errors.push(`${ctx}: config is ${typeLabel(config)}, expected an object`);
96
+ return { ok: false, errors };
97
+ }
98
+
99
+ // Check required fields
100
+ if (schema.required) {
101
+ for (const [field, expectedType] of Object.entries(schema.required)) {
102
+ if (!(field in config) || config[field] === undefined || config[field] === null || config[field] === "") {
103
+ errors.push(`${ctx}: missing required field "${field}"`);
104
+ } else if (!checkType(config[field], expectedType)) {
105
+ errors.push(
106
+ `${ctx}: field "${field}" should be ${expectedType}, got ${typeLabel(config[field])}`
107
+ );
108
+ }
109
+ }
110
+ }
111
+
112
+ // Check at-least-one groups (e.g. schedule OR trigger)
113
+ if (schema.atLeastOne) {
114
+ for (const group of schema.atLeastOne) {
115
+ const present = group.filter((field) => field in config && config[field] !== undefined && config[field] !== null && config[field] !== "");
116
+ if (present.length === 0) {
117
+ errors.push(`${ctx}: must have at least one of: ${group.join(", ")}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Check required sections (nested objects with typed sub-fields)
123
+ if (schema.requiredSections) {
124
+ for (const [section, subFields] of Object.entries(schema.requiredSections)) {
125
+ if (!(section in config) || !config[section] || typeof config[section] !== "object") {
126
+ errors.push(`${ctx}: missing required section [${section}]`);
127
+ } else {
128
+ for (const [subField, expectedType] of Object.entries(subFields)) {
129
+ const value = config[section][subField];
130
+ if (value === undefined || value === null) {
131
+ errors.push(`${ctx}: missing required field "${section}.${subField}"`);
132
+ } else if (!checkType(value, expectedType)) {
133
+ errors.push(
134
+ `${ctx}: field "${section}.${subField}" should be ${expectedType}, got ${typeLabel(value)}`
135
+ );
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ // Router-specific: must have at least N lane definitions
143
+ if (schema.minLanes !== undefined) {
144
+ const laneCount = Object.keys(config).length;
145
+ if (laneCount < schema.minLanes) {
146
+ errors.push(`${ctx}: must define at least ${schema.minLanes} lane(s), found ${laneCount}`);
147
+ }
148
+ }
149
+
150
+ return { ok: errors.length === 0, errors };
151
+ }
152
+
153
+ /**
154
+ * Validate and throw if invalid — convenience wrapper used by the config loader.
155
+ */
156
+ export function validateOrThrow(config, schema, filePath) {
157
+ const result = validateConfig(config, schema, filePath);
158
+ if (!result.ok) {
159
+ const runtimeError = new RuntimeError(
160
+ `Config schema validation failed for ${filePath || schema.label}`,
161
+ { category: "config", context: { filePath, schema: schema.label, errorCount: result.errors.length }, recoverable: false }
162
+ );
163
+ const formatted = formatRuntimeError(runtimeError, { details: result.errors.join("; ") });
164
+ throw new ConfigSchemaError(formatted, result.errors);
165
+ }
166
+ return config;
167
+ }
168
+
169
+ /**
170
+ * Validate all config sections in a full loadAll() result.
171
+ * Returns { ok, errors[] } for CLI / test consumption.
172
+ */
173
+ export function validateAllConfigs(config, rootDir) {
174
+ const allErrors = [];
175
+
176
+ // Agents
177
+ for (const [id, agent] of Object.entries(config.agents || {})) {
178
+ const filePath = agent.__filePath || `${rootDir}/agents/${id}.toml`;
179
+ const result = validateConfig(agent, AGENT_SCHEMA, filePath);
180
+ allErrors.push(...result.errors);
181
+ }
182
+
183
+ // Jobs
184
+ for (const [id, job] of Object.entries(config.jobs || {})) {
185
+ const filePath = job.__filePath || `${rootDir}/jobs/${id}.toml`;
186
+ const result = validateConfig(job, JOB_SCHEMA, filePath);
187
+ allErrors.push(...result.errors);
188
+ }
189
+
190
+ // Providers
191
+ for (const [id, provider] of Object.entries(config.providers || {})) {
192
+ const filePath = provider.__filePath || `${rootDir}/providers/${id}.toml`;
193
+ const result = validateConfig(provider, PROVIDER_SCHEMA, filePath);
194
+ allErrors.push(...result.errors);
195
+ }
196
+
197
+ // Runtime
198
+ if (config.runtime) {
199
+ const result = validateConfig(config.runtime, RUNTIME_SCHEMA, `${rootDir}/runtime.toml`);
200
+ allErrors.push(...result.errors);
201
+ } else {
202
+ allErrors.push(`${rootDir}/runtime.toml: runtime config is missing entirely`);
203
+ }
204
+
205
+ // Router
206
+ if (config.router) {
207
+ const result = validateConfig(config.router, ROUTER_SCHEMA, `${rootDir}/router.toml`);
208
+ allErrors.push(...result.errors);
209
+ } else {
210
+ allErrors.push(`${rootDir}/router.toml: router config is missing entirely`);
211
+ }
212
+
213
+ return { ok: allErrors.length === 0, errors: allErrors };
214
+ }
@@ -0,0 +1,8 @@
1
+ import { parse } from "smol-toml";
2
+
3
+ export function parseToml(source) {
4
+ if (!source || !String(source).trim()) {
5
+ return {};
6
+ }
7
+ return parse(String(source));
8
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Factory that creates an action executor wired to real system components.
3
+ * Returns an async function: (actionName, context) => boolean (success).
4
+ */
5
+ export function createActionHandler({ getBreaker, mcpConsumer, stateStore, telegramSendFn, lastFailedMessage }) {
6
+ const handlers = {
7
+ async circuit_break(ctx) {
8
+ const breaker = getBreaker(ctx.provider);
9
+ if (breaker) breaker.recordFailure(ctx.code || 500);
10
+ return true;
11
+ },
12
+ async retry_then_break(ctx) {
13
+ // Retry once, then circuit break
14
+ const breaker = getBreaker(ctx.provider);
15
+ if (breaker) breaker.recordFailure(ctx.code || 408);
16
+ return true;
17
+ },
18
+ async restart(ctx) {
19
+ if (!mcpConsumer) return false;
20
+ await mcpConsumer.ensureRunning(ctx.server);
21
+ return true;
22
+ },
23
+ async kill_restart(ctx) {
24
+ if (!mcpConsumer) return false;
25
+ await mcpConsumer.shutdown(ctx.server);
26
+ await mcpConsumer.ensureRunning(ctx.server);
27
+ return true;
28
+ },
29
+ async compact() {
30
+ // Trigger is handled by existing maintenance cycle — just return true
31
+ return true;
32
+ },
33
+ async rebuild_index() {
34
+ return true; // Existing embedding rebuild runs in maintenance
35
+ },
36
+ async reap() {
37
+ try {
38
+ const { reapOrphanedJobs } = await import("../runtime/orphan-reaper.js");
39
+ reapOrphanedJobs(stateStore);
40
+ } catch {
41
+ // stateStore may be a stub in tests or unavailable — best-effort
42
+ }
43
+ return true;
44
+ },
45
+ async retry_backoff(ctx) {
46
+ if (!telegramSendFn) return false;
47
+ const delays = [1000, 2000, 4000]; // 3 retries with exponential backoff
48
+ for (const delay of delays) {
49
+ await new Promise(r => setTimeout(r, delay));
50
+ const result = await telegramSendFn(lastFailedMessage || "Retry");
51
+ if (result?.ok) return true;
52
+ }
53
+ return false;
54
+ },
55
+ async resend(ctx) {
56
+ if (!telegramSendFn || !lastFailedMessage) return false;
57
+ const result = await telegramSendFn(lastFailedMessage);
58
+ return !!result?.ok;
59
+ }
60
+ };
61
+
62
+ return async (actionName, context) => {
63
+ const handler = handlers[actionName];
64
+ if (!handler) return false;
65
+ try {
66
+ return await handler(context || {});
67
+ } catch {
68
+ return false;
69
+ }
70
+ };
71
+ }