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,183 @@
1
+ import { renderContractOutput } from "./output-contract-validator.js";
2
+ import { estimateCost } from "../utils/usage-cost.js";
3
+ import { extractUsageMetrics } from "../utils/usage-metrics.js";
4
+
5
+ export function synthesizeDryRun(plan, modelId) {
6
+ const memoryCount = plan.packet.layers.memory.length;
7
+ const summary = `Dry-run shadow execution for ${plan.job.id} prepared on ${modelId} with ${memoryCount} relevant memory items.`;
8
+ const output = [
9
+ `Job ${plan.job.id} would run on lane ${plan.job.modelLane} using model ${modelId}.`,
10
+ `Workspace: ${plan.agent.workspaceRoot || "unknown"}.`,
11
+ `Tools: ${plan.packet.layers.tools.join(", ") || "none"}.`,
12
+ `Memory items included: ${memoryCount}.`
13
+ ].join(" ");
14
+
15
+ return {
16
+ summary,
17
+ output,
18
+ nextActions: ["switch to provider mode for a real shadow run", "compare with live cron output"]
19
+ };
20
+ }
21
+
22
+ export function formatOutputContract(contract) {
23
+ const lines = [];
24
+ if (contract.format) lines.push(`- format: ${contract.format}`);
25
+ if (contract.requiredSections?.length) {
26
+ lines.push(`- required sections: ${contract.requiredSections.join(", ")}`);
27
+ }
28
+ if (contract.styleHints?.length) {
29
+ lines.push(`- style hints: ${contract.styleHints.join("; ")}`);
30
+ }
31
+ if (contract.profile?.sectionStyle) {
32
+ lines.push(`- section style: ${contract.profile.sectionStyle}`);
33
+ }
34
+ if (contract.profile?.requireStatus) {
35
+ lines.push("- requires status: yes");
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+
40
+ export function formatReportGuidance(reportGuidance) {
41
+ const lines = [];
42
+ if (reportGuidance.focus?.length) {
43
+ lines.push(`- focus: ${reportGuidance.focus.join("; ")}`);
44
+ }
45
+ if (reportGuidance.qualityChecks?.length) {
46
+ lines.push(`- quality checks: ${reportGuidance.qualityChecks.join("; ")}`);
47
+ }
48
+ if (reportGuidance.avoid?.length) {
49
+ lines.push(`- avoid: ${reportGuidance.avoid.join("; ")}`);
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export function normalizeNextActions(nextActions = []) {
55
+ return (nextActions || [])
56
+ .map((item) => {
57
+ if (typeof item === "string") return item.trim();
58
+ if (item && typeof item === "object") {
59
+ const action = String(item.action || "").trim();
60
+ const details = String(item.details || item.summary || "").trim();
61
+ return [action, details].filter(Boolean).join(": ");
62
+ }
63
+ return String(item || "").trim();
64
+ })
65
+ .filter(Boolean);
66
+ }
67
+
68
+ export function hasOnlyShadowImportEvidence(plan) {
69
+ const memory = plan?.packet?.layers?.memory || [];
70
+ if (!memory.length) return false;
71
+ return memory.every((item) => ["shadow_import", "artifact_summary"].includes(item.category));
72
+ }
73
+
74
+ export function normalizeResultForContract(plan, result, options = {}) {
75
+ const contract = plan?.packet?.layers?.outputContract || null;
76
+ if (!contract || options.mode !== "provider") {
77
+ return {
78
+ ...result,
79
+ nextActions: normalizeNextActions(result.nextActions || [])
80
+ };
81
+ }
82
+
83
+ const originalOutput = result.output;
84
+ const canonicalOutput = renderContractOutput(contract, originalOutput, {
85
+ shadowOnlyEvidence: contract.format === "structured_rollup" && hasOnlyShadowImportEvidence(plan),
86
+ status: result.summary
87
+ });
88
+
89
+ return {
90
+ ...result,
91
+ output: canonicalOutput,
92
+ outputStructured: originalOutput,
93
+ nextActions: normalizeNextActions(result.nextActions || [])
94
+ };
95
+ }
96
+
97
+ export function buildOutputTemplate(contract) {
98
+ if (!contract) return null;
99
+ if (contract.profile?.templateLines?.length) {
100
+ return contract.profile.templateLines.join("\n");
101
+ }
102
+
103
+ if (contract.format === "bulleted_briefing") {
104
+ return [
105
+ "Status: <one-line status>",
106
+ "- Calendar: <brief update or None>",
107
+ "- Issues: <brief update or None>",
108
+ "- Weather: <brief update or None>"
109
+ ].join("\n");
110
+ }
111
+
112
+ if (contract.format === "structured_rollup") {
113
+ return [
114
+ "## Inbox",
115
+ "- <brief update or None>",
116
+ "",
117
+ "## Projects",
118
+ "- <brief update or None>",
119
+ "",
120
+ "## Backlog",
121
+ "- <brief update or None>",
122
+ "",
123
+ "## Update",
124
+ "- <brief update or None>"
125
+ ].join("\n");
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ function normalizeProviderResponse(rawResponse, adapter) {
132
+ if (typeof adapter?.normalizeResponse === "function") {
133
+ return adapter.normalizeResponse(rawResponse);
134
+ }
135
+ return {
136
+ summary: "Provider returned unnormalized data.",
137
+ output: rawResponse,
138
+ nextActions: [],
139
+ reasoning: null,
140
+ raw: rawResponse
141
+ };
142
+ }
143
+
144
+ export async function normalizeResult({ plan, rawResponse, toolResults = [], usage, options = {} }) {
145
+ const mode = options.mode || "dry-run";
146
+ let result = null;
147
+
148
+ if (options.pendingApprovalResult) {
149
+ result = options.pendingApprovalResult;
150
+ } else if (mode === "dry-run") {
151
+ result = synthesizeDryRun(plan, options.modelId);
152
+ } else {
153
+ result = normalizeProviderResponse(rawResponse, options.adapter);
154
+ result = normalizeResultForContract(plan, result, { mode });
155
+ }
156
+
157
+ const usageMetrics = usage || extractUsageMetrics(rawResponse || {});
158
+ const costUsd = options.modelId ? estimateCost(options.modelId, usageMetrics.tokensIn, usageMetrics.tokensOut) : null;
159
+
160
+ if (!options.pendingApprovalResult && mode === "provider" && rawResponse && typeof options.logUsage === "function") {
161
+ try {
162
+ await options.logUsage({
163
+ plan,
164
+ providerId: options.providerId,
165
+ modelId: options.modelId,
166
+ usage: usageMetrics,
167
+ costUsd,
168
+ rawResponse,
169
+ toolResults,
170
+ sessionId: options.sessionId || plan.job.chat_id || "unknown"
171
+ });
172
+ } catch (err) {
173
+ console.error(JSON.stringify({ service: "usage_logging", error: err.message, jobId: plan.job.id }));
174
+ }
175
+ }
176
+
177
+ return {
178
+ result,
179
+ usage: usageMetrics,
180
+ costUsd,
181
+ cached: Boolean(usageMetrics.cacheIn || usageMetrics.cacheCreation)
182
+ };
183
+ }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { listFilesRecursive, readJson, removePath } from "../utils/fs.js";
3
+
4
+ const DAY_MS = 24 * 60 * 60 * 1000;
5
+
6
+ function inferTimestampFromPath(filePath) {
7
+ const base = path.basename(filePath, ".json");
8
+ const candidate = base.replace(/-/g, ":").replace(/(\d{4}):(\d{2}):(\d{2})T/, "$1-$2-$3T").replace(/:(\d{3})$/, ".$1");
9
+ const parsed = new Date(candidate);
10
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
11
+ }
12
+
13
+ function normalizeTimestamp(item) {
14
+ return item.timestamp || item.receivedAt || inferTimestampFromPath(item.filePath) || "1970-01-01T00:00:00.000Z";
15
+ }
16
+
17
+ function isJsonFile(filePath) {
18
+ return filePath.endsWith(".json");
19
+ }
20
+
21
+ export function buildRetentionPolicy(config = {}, defaults = {}) {
22
+ return {
23
+ ttlDays: Number(config.ttlDays ?? defaults.ttlDays ?? 30),
24
+ maxFilesPerBucket: Number(config.maxFilesPerBucket ?? defaults.maxFilesPerBucket ?? 1000)
25
+ };
26
+ }
27
+
28
+ export async function pruneJsonBuckets(rootDir, policy, options = {}) {
29
+ const now = options.now ? new Date(options.now) : new Date();
30
+ const ttlCutoff = now.getTime() - policy.ttlDays * DAY_MS;
31
+ const excluded = new Set((options.excludePaths || []).map((filePath) => path.resolve(filePath)));
32
+ const files = (await listFilesRecursive(rootDir)).filter((filePath) => isJsonFile(filePath) && !excluded.has(path.resolve(filePath)));
33
+ const buckets = new Map();
34
+
35
+ for (const filePath of files) {
36
+ const bucket = path.dirname(filePath);
37
+ if (!buckets.has(bucket)) buckets.set(bucket, []);
38
+ const data = await readJson(filePath, null);
39
+ buckets.get(bucket).push({
40
+ filePath,
41
+ timestamp: normalizeTimestamp({
42
+ ...(data || {}),
43
+ filePath
44
+ })
45
+ });
46
+ }
47
+
48
+ const removed = [];
49
+ for (const entries of buckets.values()) {
50
+ entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
51
+ const keep = new Set();
52
+
53
+ for (let index = 0; index < entries.length; index += 1) {
54
+ const item = entries[index];
55
+ const expired = new Date(item.timestamp).getTime() < ttlCutoff;
56
+ const overflow = index >= policy.maxFilesPerBucket;
57
+ if (!expired && !overflow) {
58
+ keep.add(item.filePath);
59
+ }
60
+ }
61
+
62
+ for (const item of entries) {
63
+ if (keep.has(item.filePath)) continue;
64
+ await removePath(item.filePath);
65
+ removed.push(item.filePath);
66
+ }
67
+ }
68
+
69
+ return {
70
+ rootDir,
71
+ removedCount: removed.length,
72
+ removed
73
+ };
74
+ }
@@ -0,0 +1,244 @@
1
+ import path from "node:path";
2
+ import { listFilesRecursive, readJson } from "../utils/fs.js";
3
+ import { SchedulerStateStore } from "./scheduler-state.js";
4
+ import { NotificationStore } from "./notification-store.js";
5
+ import { DeliveryStore } from "./delivery-store.js";
6
+
7
+ function sortDescending(items) {
8
+ return [...items].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
9
+ }
10
+
11
+ function inferTimestampFromPath(filePath) {
12
+ const base = path.basename(filePath, ".json");
13
+ const candidate = base.replace(/-/g, ":").replace(/(\d{4}):(\d{2}):(\d{2})T/, "$1-$2-$3T").replace(/:(\d{3})$/, ".$1");
14
+ const parsed = new Date(candidate);
15
+ return Number.isNaN(parsed.getTime()) ? "1970-01-01T00:00:00.000Z" : parsed.toISOString();
16
+ }
17
+
18
+ function expandRelatedNotificationFiles(baseFiles, notifications) {
19
+ const related = new Set(baseFiles);
20
+ let changed = true;
21
+ while (changed) {
22
+ changed = false;
23
+ for (const item of notifications) {
24
+ if (!related.has(item.filePath)) continue;
25
+ for (const generated of item.generatedNotificationFiles || []) {
26
+ if (related.has(generated)) continue;
27
+ related.add(generated);
28
+ changed = true;
29
+ }
30
+ }
31
+ }
32
+ return related;
33
+ }
34
+
35
+ function summarizeInteractionState(runData, notifications = [], deliveries = []) {
36
+ const interaction = runData?.interaction || null;
37
+ const ackNotification = notifications.find((item) => item.stage === "ack") || null;
38
+ const completionNotification = notifications.find((item) => item.stage === "completion") || null;
39
+ const handoffNotification = notifications.find((item) => item.stage === "handoff") || null;
40
+ const followUpNotification = notifications.find((item) => item.stage === "follow_up") || null;
41
+ const handoffDelivery = handoffNotification
42
+ ? deliveries.find((item) => item.notificationFilePath === handoffNotification.filePath) || null
43
+ : null;
44
+ const dedupeStatuses = deliveries.map((item) => item.delivery?.status || null);
45
+ const generatedNotificationFiles = new Set(followUpNotification?.generatedNotificationFiles || []);
46
+ const followUpDeliveries = deliveries.filter((item) => generatedNotificationFiles.has(item.notificationFilePath));
47
+ const ackQueued = Boolean(ackNotification);
48
+ const completionQueued = Boolean(completionNotification);
49
+ const handoffQueued = Boolean(handoffNotification);
50
+ const deliveryEvidenceRequired = !interaction?.yield?.required && (ackQueued || completionQueued || handoffQueued);
51
+ const deliveryReceiptCount = deliveries.length;
52
+
53
+ return {
54
+ ackRequired: interaction?.ack?.required ?? false,
55
+ ackQueued,
56
+ completionRequired: interaction?.completion?.required ?? false,
57
+ completionQueued,
58
+ handoffRequired: interaction?.handoff?.required ?? false,
59
+ handoffQueued,
60
+ yielded: interaction?.yield?.required ?? false,
61
+ yieldSignal: interaction?.yield?.signal || null,
62
+ followUpQueued: Boolean(followUpNotification),
63
+ followUpState: followUpNotification?.followUpState || followUpNotification?.yieldState || null,
64
+ followUpConsumed: followUpNotification?.status === "consumed" || followUpNotification?.yieldState === "consumed" || followUpNotification?.followUpState === "consumed",
65
+ followUpTarget: followUpNotification?.targetSurface || interaction?.yield?.targetSurface || null,
66
+ followUpCompleted: Boolean(followUpDeliveries.length),
67
+ followUpExpired: followUpNotification?.followUpState === "expired",
68
+ followUpEscalated: followUpNotification?.followUpState === "escalated",
69
+ followUpEscalationFilePath: followUpNotification?.escalationNotificationFilePath || null,
70
+ handoffPendingChoice: handoffNotification?.handoffState === "pending" || handoffNotification?.status === "awaiting_choice",
71
+ handoffState: handoffNotification?.handoffState || null,
72
+ handoffExpired: handoffNotification?.handoffState === "expired",
73
+ handoffEscalated: handoffNotification?.handoffState === "escalated",
74
+ handoffBlocked: handoffNotification?.handoffState === "blocked" || handoffNotification?.status === "blocked",
75
+ handoffChosen: Boolean(handoffNotification?.chosenPeer?.peerId),
76
+ handoffChosenPeerId: handoffNotification?.chosenPeer?.peerId || null,
77
+ handoffChosenBy: handoffNotification?.chosenBy || null,
78
+ handoffDelivered: Boolean(handoffDelivery),
79
+ handoffDeliveryState: handoffDelivery?.delivery?.status || null,
80
+ handoffReceiptFilePath: handoffDelivery?.filePath || null,
81
+ handoffBlockedReason: handoffNotification?.blockedReason || null,
82
+ handoffEscalationFilePath: handoffNotification?.escalationNotificationFilePath || null,
83
+ deliveryEvidenceRequired,
84
+ deliveryEvidenceHealthy: !deliveryEvidenceRequired || deliveryReceiptCount > 0,
85
+ deliveryReceiptCount,
86
+ deliveryDeduped: dedupeStatuses.includes("duplicate_prevented"),
87
+ deliveryRetried: deliveries.some((item) => Number(item.attempt) > 1),
88
+ deliveryUncertain: dedupeStatuses.includes("delivery_uncertain") || dedupeStatuses.includes("uncertain")
89
+ };
90
+ }
91
+
92
+ export class RunReviewer {
93
+ constructor({ stateRoot }) {
94
+ this.stateRoot = stateRoot;
95
+ this.runRoot = path.join(stateRoot, "runs");
96
+ this.schedulerState = new SchedulerStateStore({ rootDir: path.join(stateRoot, "scheduler") });
97
+ this.notificationStore = new NotificationStore({ rootDir: path.join(stateRoot, "notifications") });
98
+ this.deliveryStore = new DeliveryStore({ rootDir: path.join(stateRoot, "deliveries") });
99
+ }
100
+
101
+ async listRecentRuns(limit = 10) {
102
+ const files = (await listFilesRecursive(this.runRoot)).filter((filePath) => filePath.endsWith(".json"));
103
+ const runs = [];
104
+
105
+ const [notifications, deliveries] = await Promise.all([
106
+ this.notificationStore.listAll(),
107
+ this.deliveryStore.listAll()
108
+ ]);
109
+
110
+ for (const filePath of files) {
111
+ const data = await readJson(filePath, null);
112
+ if (!data) continue;
113
+ const relatedNotificationFiles = expandRelatedNotificationFiles(data.notificationFiles || [], notifications);
114
+ const runNotifications = notifications.filter((item) => relatedNotificationFiles.has(item.filePath));
115
+ const runDeliveries = deliveries.filter((item) => relatedNotificationFiles.has(item.notificationFilePath));
116
+ const interactionState = summarizeInteractionState(data, runNotifications, runDeliveries);
117
+ runs.push({
118
+ filePath,
119
+ jobId: path.basename(path.dirname(filePath)),
120
+ timestamp: data.timestamp || inferTimestampFromPath(filePath),
121
+ mode: data.mode || null,
122
+ providerId: data.providerId || null,
123
+ modelId: data.modelId || null,
124
+ summary: data.result?.summary || data.summary || null,
125
+ output: data.result?.output || data.output || null,
126
+ result: data.result || null,
127
+ plan: data.plan || null,
128
+ fallback: data.fallback || null,
129
+ notificationFiles: data.notificationFiles || [],
130
+ retrievalMeta: data.plan?.packet?.layers?.retrievalMeta || null,
131
+ retrievedMemory: data.plan?.packet?.layers?.memory || [],
132
+ interaction: data.interaction || null,
133
+ completionSignal: data.interaction?.completion?.signal || null,
134
+ completionTarget: data.interaction?.completion?.target || null,
135
+ completionMessage: data.interaction?.completion?.message || null,
136
+ ackQueued: interactionState.ackQueued,
137
+ completionQueued: interactionState.completionQueued,
138
+ handoffQueued: interactionState.handoffQueued,
139
+ handoffSignal: data.interaction?.handoff?.signal || null,
140
+ handoffTarget: data.interaction?.handoff?.target || null,
141
+ handoffSuggestionCount: data.interaction?.handoff?.suggestions?.length || 0,
142
+ yielded: interactionState.yielded,
143
+ yieldSignal: interactionState.yieldSignal,
144
+ followUpState: interactionState.followUpState,
145
+ followUpQueued: interactionState.followUpQueued,
146
+ followUpConsumed: interactionState.followUpConsumed,
147
+ followUpTarget: interactionState.followUpTarget,
148
+ followUpCompleted: interactionState.followUpCompleted,
149
+ followUpExpired: interactionState.followUpExpired,
150
+ followUpEscalated: interactionState.followUpEscalated,
151
+ followUpEscalationFilePath: interactionState.followUpEscalationFilePath,
152
+ handoffChosenPeerId: interactionState.handoffChosenPeerId,
153
+ handoffChosenBy: interactionState.handoffChosenBy,
154
+ handoffDelivered: interactionState.handoffDelivered,
155
+ handoffDeliveryState: interactionState.handoffDeliveryState,
156
+ handoffState: interactionState.handoffState,
157
+ handoffBlockedReason: interactionState.handoffBlockedReason,
158
+ deliveryEvidenceRequired: interactionState.deliveryEvidenceRequired,
159
+ deliveryEvidenceHealthy: interactionState.deliveryEvidenceHealthy,
160
+ deliveryReceiptCount: interactionState.deliveryReceiptCount,
161
+ deliveryDeduped: interactionState.deliveryDeduped,
162
+ deliveryRetried: interactionState.deliveryRetried,
163
+ deliveryUncertain: interactionState.deliveryUncertain,
164
+ fallbackAllowed: Boolean(data.fallback?.allowed),
165
+ fallbackAttempted: Boolean(data.fallback?.attempted),
166
+ fallbackSucceeded: Boolean(data.fallback?.success),
167
+ fallbackTrigger: data.fallback?.trigger || null,
168
+ fallbackSourceLane: data.fallback?.sourceLane || null,
169
+ fallbackFinalSourceLane: data.fallback?.finalSourceLane || null,
170
+ fallbackFinalProviderId: data.fallback?.finalProviderId || null,
171
+ fallbackFinalModelId: data.fallback?.finalModelId || null,
172
+ fallbackBlockedReason: data.fallback?.blockedReason || null,
173
+ retrievalHealth: data.plan?.packet?.layers?.retrievalMeta?.embeddingHealth || null,
174
+ retrievalDegradedMode: data.plan?.packet?.layers?.retrievalMeta?.embeddingQueryMode === "lexical_fallback",
175
+ status: data.result ? "ok" : data.lastStatus || null
176
+ });
177
+ }
178
+
179
+ return sortDescending(runs).slice(0, limit);
180
+ }
181
+
182
+ async schedulerOverview() {
183
+ const state = await this.schedulerState.load();
184
+ return Object.entries(state.jobs || {}).map(([jobId, jobState]) => ({
185
+ jobId,
186
+ ...jobState
187
+ }));
188
+ }
189
+
190
+ async review(limit = 10) {
191
+ const [recentRuns, scheduler, recentNotifications, recentDeliveries] = await Promise.all([
192
+ this.listRecentRuns(limit),
193
+ this.schedulerOverview(),
194
+ this.notificationStore.listRecent(limit),
195
+ this.deliveryStore.listRecent(limit)
196
+ ]);
197
+ const maintenance = await this.schedulerState.getMeta("maintenance", null);
198
+ const handoffs = recentNotifications
199
+ .filter((item) => item.stage === "handoff")
200
+ .map((item) => {
201
+ const receipt = recentDeliveries.find((delivery) => delivery.notificationFilePath === item.filePath) || null;
202
+ return {
203
+ filePath: item.filePath,
204
+ jobId: item.jobId,
205
+ timestamp: item.timestamp,
206
+ status: item.status,
207
+ handoffState: item.handoffState || null,
208
+ signal: item.signal,
209
+ suggestedPeerCount: item.suggestedPeers?.length || 0,
210
+ chosenPeerId: item.chosenPeer?.peerId || null,
211
+ chosenBy: item.chosenBy || null,
212
+ deliveryStatus: receipt?.delivery?.status || null,
213
+ receiptFilePath: receipt?.filePath || null,
214
+ blockedReason: item.blockedReason || null,
215
+ escalationNotificationFilePath: item.escalationNotificationFilePath || null
216
+ };
217
+ });
218
+ const followUps = recentNotifications
219
+ .filter((item) => item.stage === "follow_up")
220
+ .map((item) => ({
221
+ filePath: item.filePath,
222
+ jobId: item.jobId,
223
+ timestamp: item.timestamp,
224
+ status: item.status,
225
+ yieldState: item.yieldState || null,
226
+ followUpState: item.followUpState || null,
227
+ signal: item.signal || null,
228
+ targetSurface: item.targetSurface || null,
229
+ consumedAt: item.consumedAt || null,
230
+ expiredAt: item.expiredAt || null,
231
+ escalationNotificationFilePath: item.escalationNotificationFilePath || null,
232
+ generatedNotificationFiles: item.generatedNotificationFiles || []
233
+ }));
234
+ return {
235
+ scheduler,
236
+ maintenance,
237
+ recentRuns,
238
+ recentNotifications,
239
+ recentDeliveries,
240
+ recentHandoffs: handoffs,
241
+ recentFollowUps: followUps
242
+ };
243
+ }
244
+ }
@@ -0,0 +1,15 @@
1
+ export async function routeJob({ jobId, mode, orchestrator, executor, skipOrchestrator = false, shadowImport = true, idempotencyKey = null }) {
2
+ if (orchestrator && !skipOrchestrator) {
3
+ const route = await orchestrator.route(jobId);
4
+ if (route.agentId) {
5
+ const result = await executor.executeJob(jobId, { mode, shadowImport, targetAgent: route.agentId, idempotencyKey });
6
+ return { ...result, routedVia: "orchestrator", route };
7
+ }
8
+ // Escalation — no agent could handle it
9
+ return { routedVia: "escalation", route, filePath: null, notificationFiles: [] };
10
+ }
11
+
12
+ // Direct execution (backwards-compatible)
13
+ const result = await executor.executeJob(jobId, { mode, shadowImport, idempotencyKey });
14
+ return { ...result, routedVia: "direct" };
15
+ }
@@ -0,0 +1,38 @@
1
+ import path from "node:path";
2
+ import { ensureDir, writeJson } from "../utils/fs.js";
3
+ import { buildRetentionPolicy, pruneJsonBuckets } from "./retention.js";
4
+ import { createRuntimeId } from "../utils/ids.js";
5
+
6
+ function stamp() {
7
+ return new Date().toISOString().replace(/[:.]/g, "-");
8
+ }
9
+
10
+ export class RunStore {
11
+ constructor({ rootDir, retention = {} }) {
12
+ this.rootDir = rootDir;
13
+ this.setRetentionPolicy(retention);
14
+ }
15
+
16
+ setRetentionPolicy(retention = {}) {
17
+ this.retentionPolicy = buildRetentionPolicy(retention, {
18
+ ttlDays: 30,
19
+ maxFilesPerBucket: 2000
20
+ });
21
+ }
22
+
23
+ async saveRun(jobId, run) {
24
+ const runDir = path.join(this.rootDir, jobId);
25
+ await ensureDir(runDir);
26
+ const filePath = path.join(runDir, `${stamp()}.json`);
27
+ await writeJson(filePath, {
28
+ runId: run.runId || createRuntimeId("run"),
29
+ ...run
30
+ });
31
+ await pruneJsonBuckets(this.rootDir, this.retentionPolicy);
32
+ return filePath;
33
+ }
34
+
35
+ async prune(options = {}) {
36
+ return pruneJsonBuckets(this.rootDir, this.retentionPolicy, options);
37
+ }
38
+ }
@@ -0,0 +1,88 @@
1
+ function pad(value) {
2
+ return String(value).padStart(2, "0");
3
+ }
4
+
5
+ function cloneDate(date) {
6
+ return new Date(date.getTime());
7
+ }
8
+
9
+ export function parseEveryMinutes(trigger) {
10
+ const match = /^every:(\d+)(m|h)$/.exec(String(trigger || ""));
11
+ if (!match) return null;
12
+ const amount = Number(match[1]);
13
+ const unit = match[2];
14
+ return unit === "h" ? amount * 60 : amount;
15
+ }
16
+
17
+ export function computeNextRun(trigger, now = new Date()) {
18
+ const everyMinutes = parseEveryMinutes(trigger);
19
+ if (everyMinutes != null) {
20
+ return new Date(now.getTime() + everyMinutes * 60 * 1000);
21
+ }
22
+
23
+ if (trigger === "hourly") {
24
+ const next = cloneDate(now);
25
+ next.setUTCMinutes(0, 0, 0);
26
+ next.setUTCHours(next.getUTCHours() + 1);
27
+ return next;
28
+ }
29
+
30
+ const dailyMatch = /^daily:(\d{2}):(\d{2})$/.exec(String(trigger || ""));
31
+ if (dailyMatch) {
32
+ const [, hh, mm] = dailyMatch;
33
+ const next = cloneDate(now);
34
+ next.setUTCHours(Number(hh), Number(mm), 0, 0);
35
+ if (next.getTime() <= now.getTime()) {
36
+ next.setUTCDate(next.getUTCDate() + 1);
37
+ }
38
+ return next;
39
+ }
40
+
41
+ throw new Error(`Unsupported trigger: ${trigger}`);
42
+ }
43
+
44
+ export function computeRunSlot(trigger, now = new Date()) {
45
+ const everyMinutes = parseEveryMinutes(trigger);
46
+ if (everyMinutes != null) {
47
+ const slotMs = everyMinutes * 60 * 1000;
48
+ return new Date(Math.floor(now.getTime() / slotMs) * slotMs);
49
+ }
50
+
51
+ if (trigger === "hourly") {
52
+ const slot = cloneDate(now);
53
+ slot.setUTCMinutes(0, 0, 0);
54
+ return slot;
55
+ }
56
+
57
+ const dailyMatch = /^daily:(\d{2}):(\d{2})$/.exec(String(trigger || ""));
58
+ if (dailyMatch) {
59
+ const slot = cloneDate(now);
60
+ slot.setUTCHours(0, 0, 0, 0);
61
+ return slot;
62
+ }
63
+
64
+ throw new Error(`Unsupported trigger: ${trigger}`);
65
+ }
66
+
67
+ export function isDue(trigger, lastRunAt, nextRunAt, now = new Date()) {
68
+ if (nextRunAt) {
69
+ return new Date(nextRunAt).getTime() <= now.getTime();
70
+ }
71
+ if (!lastRunAt) return true;
72
+ return new Date(lastRunAt).getTime() < computeNextRun(trigger, new Date(lastRunAt)).getTime() &&
73
+ computeNextRun(trigger, new Date(lastRunAt)).getTime() <= now.getTime();
74
+ }
75
+
76
+ export function formatRunState(state) {
77
+ return {
78
+ lastRunAt: state.lastRunAt || null,
79
+ nextRunAt: state.nextRunAt || null,
80
+ lastStatus: state.lastStatus || null,
81
+ lastRunFile: state.lastRunFile || null,
82
+ consecutiveFailures: state.consecutiveFailures || 0
83
+ };
84
+ }
85
+
86
+ export function iso(date) {
87
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.000Z`;
88
+ }