nemoris 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/.env.example +49 -49
  2. package/LICENSE +21 -21
  3. package/README.md +209 -209
  4. package/SECURITY.md +59 -119
  5. package/bin/nemoris +46 -46
  6. package/config/agents/agent.toml.example +28 -28
  7. package/config/agents/content.toml +23 -0
  8. package/config/agents/default.toml +22 -22
  9. package/config/agents/heartbeat.toml +35 -0
  10. package/config/agents/iris.toml +23 -0
  11. package/config/agents/lab.toml +23 -0
  12. package/config/agents/main.toml +45 -0
  13. package/config/agents/nemo.toml +21 -0
  14. package/config/agents/ops.toml +38 -0
  15. package/config/agents/orchestrator.toml +18 -18
  16. package/config/agents/revenue.toml +23 -0
  17. package/config/agents/testyboo.toml +19 -0
  18. package/config/delivery.toml +73 -73
  19. package/config/embeddings.toml +5 -5
  20. package/config/identity/content-purpose.md +11 -0
  21. package/config/identity/content-soul.md +45 -0
  22. package/config/identity/default-purpose.md +1 -1
  23. package/config/identity/default-soul.md +3 -3
  24. package/config/identity/heartbeat-purpose.md +9 -0
  25. package/config/identity/heartbeat-soul.md +16 -0
  26. package/config/identity/iris-purpose.md +17 -0
  27. package/config/identity/iris-soul.md +68 -0
  28. package/config/identity/lab-purpose.md +10 -0
  29. package/config/identity/lab-soul.md +38 -0
  30. package/config/identity/main-purpose.md +17 -0
  31. package/config/identity/main-soul.md +66 -0
  32. package/config/identity/main-user.md +22 -0
  33. package/config/identity/ops-purpose.md +9 -0
  34. package/config/identity/ops-soul.md +16 -0
  35. package/config/identity/orchestrator-purpose.md +1 -1
  36. package/config/identity/orchestrator-soul.md +1 -1
  37. package/config/identity/revenue-purpose.md +9 -0
  38. package/config/identity/revenue-soul.md +41 -0
  39. package/config/identity/testyboo-purpose.md +13 -0
  40. package/config/identity/testyboo-soul.md +20 -0
  41. package/config/improvement-targets.toml +15 -15
  42. package/config/jobs/heartbeat-check.toml +30 -30
  43. package/config/jobs/memory-rollup.toml +46 -46
  44. package/config/jobs/workspace-health.toml +63 -63
  45. package/config/mcp.toml +16 -16
  46. package/config/output-contracts.toml +17 -17
  47. package/config/peers.toml +32 -32
  48. package/config/peers.toml.example +32 -32
  49. package/config/policies/memory-default.toml +10 -10
  50. package/config/policies/memory-heartbeat.toml +5 -5
  51. package/config/policies/memory-ops.toml +10 -10
  52. package/config/policies/tools-heartbeat-minimal.toml +8 -8
  53. package/config/policies/tools-interactive-safe.toml +8 -8
  54. package/config/policies/tools-ops-bounded.toml +8 -8
  55. package/config/policies/tools-orchestrator.toml +7 -7
  56. package/config/providers/anthropic.toml +15 -15
  57. package/config/providers/ollama.toml +5 -5
  58. package/config/providers/openai-codex.toml +9 -9
  59. package/config/providers/openrouter.toml +5 -5
  60. package/config/router.toml +22 -22
  61. package/config/runtime.toml +114 -114
  62. package/config/skills/self-improvement.toml +15 -15
  63. package/config/skills/telegram-onboarding-spec.md +240 -240
  64. package/config/skills/workspace-monitor.toml +15 -15
  65. package/config/task-router.toml +42 -42
  66. package/install.sh +50 -50
  67. package/package.json +91 -90
  68. package/src/auth/auth-profiles.js +169 -169
  69. package/src/auth/openai-codex-oauth.js +285 -285
  70. package/src/battle.js +449 -449
  71. package/src/cli/help.js +265 -265
  72. package/src/cli/output-filter.js +49 -49
  73. package/src/cli/runtime-control.js +704 -704
  74. package/src/cli-main.js +2763 -2763
  75. package/src/cli.js +78 -78
  76. package/src/config/loader.js +332 -332
  77. package/src/config/schema-validator.js +214 -214
  78. package/src/config/toml-lite.js +8 -8
  79. package/src/daemon/action-handlers.js +71 -71
  80. package/src/daemon/healing-tick.js +87 -87
  81. package/src/daemon/health-probes.js +90 -90
  82. package/src/daemon/notifier.js +57 -57
  83. package/src/daemon/nurse.js +218 -218
  84. package/src/daemon/repair-log.js +106 -106
  85. package/src/daemon/rule-staging.js +90 -90
  86. package/src/daemon/rules.js +29 -29
  87. package/src/daemon/telegram-commands.js +54 -54
  88. package/src/daemon/updater.js +85 -85
  89. package/src/jobs/job-runner.js +78 -78
  90. package/src/mcp/consumer.js +129 -129
  91. package/src/memory/active-recall.js +171 -171
  92. package/src/memory/backend-manager.js +97 -97
  93. package/src/memory/backends/file-backend.js +38 -38
  94. package/src/memory/backends/qmd-backend.js +219 -219
  95. package/src/memory/embedding-guards.js +24 -24
  96. package/src/memory/embedding-index.js +118 -118
  97. package/src/memory/embedding-service.js +179 -179
  98. package/src/memory/file-index.js +177 -177
  99. package/src/memory/memory-signature.js +5 -5
  100. package/src/memory/memory-store.js +648 -648
  101. package/src/memory/retrieval-planner.js +66 -66
  102. package/src/memory/scoring.js +145 -145
  103. package/src/memory/simhash.js +78 -78
  104. package/src/memory/sqlite-active-store.js +824 -824
  105. package/src/memory/write-policy.js +36 -36
  106. package/src/onboarding/aliases.js +33 -33
  107. package/src/onboarding/auth/api-key.js +224 -224
  108. package/src/onboarding/auth/ollama-detect.js +42 -42
  109. package/src/onboarding/clack-prompter.js +77 -77
  110. package/src/onboarding/doctor.js +530 -530
  111. package/src/onboarding/lock.js +42 -42
  112. package/src/onboarding/model-catalog.js +344 -344
  113. package/src/onboarding/phases/auth.js +576 -589
  114. package/src/onboarding/phases/build.js +130 -130
  115. package/src/onboarding/phases/choose.js +82 -82
  116. package/src/onboarding/phases/detect.js +98 -98
  117. package/src/onboarding/phases/hatch.js +216 -216
  118. package/src/onboarding/phases/identity.js +79 -79
  119. package/src/onboarding/phases/ollama.js +345 -345
  120. package/src/onboarding/phases/scaffold.js +99 -99
  121. package/src/onboarding/phases/telegram.js +377 -377
  122. package/src/onboarding/phases/validate.js +204 -204
  123. package/src/onboarding/phases/verify.js +206 -206
  124. package/src/onboarding/platform.js +482 -482
  125. package/src/onboarding/status-bar.js +95 -95
  126. package/src/onboarding/templates.js +794 -794
  127. package/src/onboarding/toml-writer.js +38 -38
  128. package/src/onboarding/tui.js +250 -250
  129. package/src/onboarding/uninstall.js +153 -153
  130. package/src/onboarding/wizard.js +516 -499
  131. package/src/providers/anthropic.js +168 -168
  132. package/src/providers/base.js +247 -247
  133. package/src/providers/circuit-breaker.js +136 -136
  134. package/src/providers/ollama.js +163 -163
  135. package/src/providers/openai-codex.js +149 -149
  136. package/src/providers/openrouter.js +136 -136
  137. package/src/providers/registry.js +36 -36
  138. package/src/providers/router.js +16 -16
  139. package/src/runtime/bootstrap-cache.js +47 -47
  140. package/src/runtime/capabilities-prompt.js +25 -25
  141. package/src/runtime/completion-ping.js +99 -99
  142. package/src/runtime/config-validator.js +121 -121
  143. package/src/runtime/context-ledger.js +360 -360
  144. package/src/runtime/cutover-readiness.js +42 -42
  145. package/src/runtime/daemon.js +729 -729
  146. package/src/runtime/delivery-ack.js +195 -195
  147. package/src/runtime/delivery-adapters/local-file.js +41 -41
  148. package/src/runtime/delivery-adapters/openclaw-cli.js +94 -94
  149. package/src/runtime/delivery-adapters/openclaw-peer.js +98 -98
  150. package/src/runtime/delivery-adapters/shadow.js +13 -13
  151. package/src/runtime/delivery-adapters/standalone-http.js +98 -98
  152. package/src/runtime/delivery-adapters/telegram.js +104 -104
  153. package/src/runtime/delivery-adapters/tui.js +128 -128
  154. package/src/runtime/delivery-manager.js +807 -807
  155. package/src/runtime/delivery-store.js +168 -168
  156. package/src/runtime/dependency-health.js +118 -118
  157. package/src/runtime/envelope.js +114 -114
  158. package/src/runtime/evaluation.js +1089 -1089
  159. package/src/runtime/exec-approvals.js +216 -216
  160. package/src/runtime/executor.js +500 -500
  161. package/src/runtime/failure-ping.js +67 -67
  162. package/src/runtime/flows.js +83 -83
  163. package/src/runtime/guards.js +45 -45
  164. package/src/runtime/handoff.js +51 -51
  165. package/src/runtime/identity-cache.js +28 -28
  166. package/src/runtime/improvement-engine.js +109 -109
  167. package/src/runtime/improvement-harness.js +581 -581
  168. package/src/runtime/input-sanitiser.js +72 -72
  169. package/src/runtime/interaction-contract.js +347 -347
  170. package/src/runtime/lane-readiness.js +226 -226
  171. package/src/runtime/migration.js +323 -323
  172. package/src/runtime/model-resolution.js +78 -78
  173. package/src/runtime/network.js +64 -64
  174. package/src/runtime/notification-store.js +97 -97
  175. package/src/runtime/notifier.js +256 -256
  176. package/src/runtime/orchestrator.js +53 -53
  177. package/src/runtime/orphan-reaper.js +41 -41
  178. package/src/runtime/output-contract-schema.js +139 -139
  179. package/src/runtime/output-contract-validator.js +439 -439
  180. package/src/runtime/peer-readiness.js +69 -69
  181. package/src/runtime/peer-registry.js +133 -133
  182. package/src/runtime/pilot-status.js +108 -108
  183. package/src/runtime/prompt-builder.js +261 -261
  184. package/src/runtime/provider-attempt.js +582 -582
  185. package/src/runtime/report-fallback.js +71 -71
  186. package/src/runtime/result-normalizer.js +183 -183
  187. package/src/runtime/retention.js +74 -74
  188. package/src/runtime/review.js +244 -244
  189. package/src/runtime/route-job.js +15 -15
  190. package/src/runtime/run-store.js +38 -38
  191. package/src/runtime/schedule.js +88 -88
  192. package/src/runtime/scheduler-state.js +434 -434
  193. package/src/runtime/scheduler.js +656 -656
  194. package/src/runtime/session-compactor.js +182 -182
  195. package/src/runtime/session-search.js +155 -155
  196. package/src/runtime/slack-inbound.js +249 -249
  197. package/src/runtime/ssrf.js +102 -102
  198. package/src/runtime/status-aggregator.js +330 -330
  199. package/src/runtime/task-contract.js +140 -140
  200. package/src/runtime/task-packet.js +107 -107
  201. package/src/runtime/task-router.js +140 -140
  202. package/src/runtime/telegram-inbound.js +1565 -1565
  203. package/src/runtime/token-counter.js +134 -134
  204. package/src/runtime/token-estimator.js +59 -59
  205. package/src/runtime/tool-loop.js +200 -200
  206. package/src/runtime/transport-server.js +311 -311
  207. package/src/runtime/tui-server.js +411 -411
  208. package/src/runtime/ulid.js +44 -44
  209. package/src/security/ssrf-check.js +197 -197
  210. package/src/setup.js +369 -369
  211. package/src/shadow/bridge.js +303 -303
  212. package/src/skills/loader.js +84 -84
  213. package/src/tools/catalog.json +49 -49
  214. package/src/tools/cli-delegate.js +44 -44
  215. package/src/tools/mcp-client.js +106 -106
  216. package/src/tools/micro/cancel-task.js +6 -6
  217. package/src/tools/micro/complete-task.js +6 -6
  218. package/src/tools/micro/fail-task.js +6 -6
  219. package/src/tools/micro/http-fetch.js +74 -74
  220. package/src/tools/micro/index.js +36 -36
  221. package/src/tools/micro/lcm-recall.js +60 -60
  222. package/src/tools/micro/list-dir.js +17 -17
  223. package/src/tools/micro/list-skills.js +46 -46
  224. package/src/tools/micro/load-skill.js +38 -38
  225. package/src/tools/micro/memory-search.js +45 -45
  226. package/src/tools/micro/read-file.js +11 -11
  227. package/src/tools/micro/session-search.js +54 -54
  228. package/src/tools/micro/shell-exec.js +43 -43
  229. package/src/tools/micro/trigger-job.js +79 -79
  230. package/src/tools/micro/web-search.js +58 -58
  231. package/src/tools/micro/workspace-paths.js +39 -39
  232. package/src/tools/micro/write-file.js +14 -14
  233. package/src/tools/micro/write-memory.js +41 -41
  234. package/src/tools/registry.js +348 -348
  235. package/src/tools/tool-result-contract.js +36 -36
  236. package/src/tui/chat.js +835 -835
  237. package/src/tui/renderer.js +175 -175
  238. package/src/tui/socket-client.js +217 -217
  239. package/src/utils/canonical-json.js +29 -29
  240. package/src/utils/compaction.js +30 -30
  241. package/src/utils/env-loader.js +5 -5
  242. package/src/utils/errors.js +80 -80
  243. package/src/utils/fs.js +101 -101
  244. package/src/utils/ids.js +5 -5
  245. package/src/utils/model-context-limits.js +30 -30
  246. package/src/utils/token-budget.js +74 -74
  247. package/src/utils/usage-cost.js +25 -25
  248. package/src/utils/usage-metrics.js +14 -14
@@ -1,807 +1,807 @@
1
- import path from "node:path";
2
- import { ConfigLoader } from "../config/loader.js";
3
- import { NotificationStore } from "./notification-store.js";
4
- import { DeliveryStore } from "./delivery-store.js";
5
- import { ShadowDeliveryAdapter } from "./delivery-adapters/shadow.js";
6
- import { TelegramDeliveryAdapter } from "./delivery-adapters/telegram.js";
7
- import { OpenClawCliDeliveryAdapter } from "./delivery-adapters/openclaw-cli.js";
8
- import { OpenClawPeerDeliveryAdapter } from "./delivery-adapters/openclaw-peer.js";
9
- import { LocalFileDeliveryAdapter } from "./delivery-adapters/local-file.js";
10
- import { StandaloneHttpDeliveryAdapter } from "./delivery-adapters/standalone-http.js";
11
- import { TuiDeliveryAdapter } from "./delivery-adapters/tui.js";
12
- import { OpenClawShadowBridge } from "../shadow/bridge.js";
13
- import { PeerRegistry } from "./peer-registry.js";
14
- import { InteractionNotifier } from "./notifier.js";
15
- import { classifyNetworkFailure } from "./network.js";
16
- import { PeerReadinessProbe } from "./peer-readiness.js";
17
- import { detectInjectionPatterns } from "./input-sanitiser.js";
18
- import { createHash } from "node:crypto";
19
-
20
- const ADAPTERS = new Map([
21
- [ShadowDeliveryAdapter.adapterId, ShadowDeliveryAdapter],
22
- [TelegramDeliveryAdapter.adapterId, TelegramDeliveryAdapter],
23
- [OpenClawCliDeliveryAdapter.adapterId, OpenClawCliDeliveryAdapter],
24
- [OpenClawPeerDeliveryAdapter.adapterId, OpenClawPeerDeliveryAdapter],
25
- [LocalFileDeliveryAdapter.adapterId, LocalFileDeliveryAdapter],
26
- [StandaloneHttpDeliveryAdapter.adapterId, StandaloneHttpDeliveryAdapter],
27
- [TuiDeliveryAdapter.adapterId, TuiDeliveryAdapter]
28
- ]);
29
-
30
- function buildProfileMap(deliveryConfig) {
31
- return deliveryConfig?.profiles || {};
32
- }
33
-
34
- function normalizeProfileName(profileName) {
35
- return String(profileName || "").replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
36
- }
37
-
38
- function resolveNamedProfile(profiles, profileName) {
39
- if (!profileName) return null;
40
- return profiles[profileName] || profiles[normalizeProfileName(profileName)] || null;
41
- }
42
-
43
- function normalizeHandoffState(notification) {
44
- if (notification.handoffState) return notification.handoffState;
45
- if (notification.stage !== "handoff") return null;
46
- if (notification.status === "awaiting_choice") return "pending";
47
- if (notification.status === "ready" && notification.chosenPeer?.peerId) return "chosen";
48
- if (notification.status === "blocked") return "blocked";
49
- return null;
50
- }
51
-
52
- function normalizeFollowUpState(notification) {
53
- if (notification.followUpState) return notification.followUpState;
54
- if (notification.stage !== "follow_up") return null;
55
- if (notification.status === "pending") return "pending";
56
- if (notification.status === "consumed" || notification.yieldState === "consumed") return "consumed";
57
- if (notification.status === "expired") return "expired";
58
- if (notification.status === "blocked") return "blocked";
59
- return null;
60
- }
61
-
62
- function scanFollowUpPayload(notification) {
63
- if (!notification?.payload) return null;
64
- const payload = notification.payload;
65
- const textParts = [];
66
-
67
- if (typeof payload === "string") {
68
- textParts.push(payload);
69
- } else if (typeof payload === "object") {
70
- for (const value of Object.values(payload)) {
71
- if (typeof value === "string") textParts.push(value);
72
- else if (typeof value === "object" && value !== null) {
73
- for (const inner of Object.values(value)) {
74
- if (typeof inner === "string") textParts.push(inner);
75
- }
76
- }
77
- }
78
- }
79
-
80
- if (!textParts.length) return null;
81
- const combined = textParts.join("\n");
82
- return detectInjectionPatterns(combined);
83
- }
84
-
85
- function classifyDeliveryFailure(message) {
86
- const failure = classifyNetworkFailure(message, { surface: "delivery" });
87
- if (failure?.failureClass === "delivery_timeout") {
88
- return "uncertain";
89
- }
90
- const normalized = String(message || "").toLowerCase();
91
- if (!normalized) return "failed";
92
- return "failed";
93
- }
94
-
95
- export class DeliveryManager {
96
- constructor({ projectRoot, liveRoot, stateRoot, fetchImpl, execFileImpl, stateStore } = {}) {
97
- this.loader = new ConfigLoader({ rootDir: path.join(projectRoot, "config") });
98
- this.notificationStore = new NotificationStore({ rootDir: path.join(stateRoot, "notifications") });
99
- this.deliveryStore = new DeliveryStore({ rootDir: path.join(stateRoot, "deliveries") });
100
- this.notifier = new InteractionNotifier({ stateRoot });
101
- this.bridge = new OpenClawShadowBridge({ liveRoot });
102
- this.peerProbe = new PeerReadinessProbe({ liveRoot });
103
- this.liveRoot = liveRoot;
104
- this.stateRoot = stateRoot;
105
- this.fetchImpl = fetchImpl;
106
- this.execFileImpl = execFileImpl;
107
- this.stateStore = stateStore || null;
108
- }
109
-
110
- isStandaloneMode(runtime = null) {
111
- if (process.env.NEMORIS_STANDALONE === "1" || process.env.NEMORIS_STANDALONE === "true") {
112
- return true;
113
- }
114
- return Boolean(runtime?.delivery?.standaloneMode) || !this.liveRoot;
115
- }
116
-
117
- createAdapter(adapterId) {
118
- const Adapter = ADAPTERS.get(adapterId);
119
- if (!Adapter) {
120
- throw new Error(`Unknown delivery adapter ${adapterId}`);
121
- }
122
- return new Adapter({
123
- fetchImpl: this.fetchImpl,
124
- execFileImpl: this.execFileImpl,
125
- stateRoot: this.stateRoot,
126
- liveRoot: this.liveRoot
127
- });
128
- }
129
-
130
- resolveNotificationTarget(notification, runtime) {
131
- const peerRegistry = new PeerRegistry(runtime.peers);
132
- const standalone = this.isStandaloneMode(runtime);
133
- if (notification.target?.mode !== "peer_agent") {
134
- return {
135
- target: notification.target || null,
136
- profileName:
137
- notification.deliveryProfile ||
138
- (notification.target?.mode === "scheduler_log"
139
- ? runtime.delivery?.defaultSchedulerProfile
140
- : standalone
141
- ? runtime.delivery?.defaultInteractiveProfileStandalone || runtime.delivery?.defaultInteractiveProfile
142
- : runtime.delivery?.defaultInteractiveProfile)
143
- };
144
- }
145
-
146
- const resolved = peerRegistry.resolveTarget(notification.target);
147
- return {
148
- target: {
149
- mode: "peer_agent",
150
- peerId: resolved.peerId,
151
- sessionKey: resolved.sessionKey,
152
- label: resolved.label
153
- },
154
- profileName:
155
- notification.deliveryProfile ||
156
- resolved.deliveryProfile ||
157
- (standalone
158
- ? runtime.delivery?.defaultPeerProfileStandalone || runtime.delivery?.defaultInteractiveProfileStandalone
159
- : runtime.delivery?.defaultInteractiveProfile)
160
- };
161
- }
162
-
163
- resolveProfileEntry(deliveryConfig, profileName) {
164
- return resolveNamedProfile(buildProfileMap(deliveryConfig), profileName);
165
- }
166
-
167
- async listPending(mode = "shadow", limit = 10, notificationFiles = null) {
168
- const notifications = await this.notificationStore.listRecent(limit * 4);
169
- const pending = [];
170
- const fileFilter = notificationFiles?.length ? new Set(notificationFiles) : null;
171
-
172
- for (const notification of notifications) {
173
- if (fileFilter && !fileFilter.has(notification.filePath)) continue;
174
- if (notification.stage === "follow_up") continue;
175
- if (notification.status === "awaiting_choice" || normalizeHandoffState(notification) === "pending") continue;
176
- const existing = await this.deliveryStore.getDeliveryRecord(mode, notification.filePath, notification.dedupeKey || null);
177
- if (existing && (existing.status === "delivered" || existing.status === "uncertain" || existing.retryEligible === false)) {
178
- continue;
179
- }
180
- pending.push(notification);
181
- if (pending.length >= limit) break;
182
- }
183
-
184
- return pending;
185
- }
186
-
187
- async deliverPending({ mode = "shadow", limit = 10, notificationFiles = null } = {}) {
188
- const runtime = await this.loader.loadAll();
189
- this.notificationStore.setRetentionPolicy(runtime.runtime?.retention?.notifications || {});
190
- this.deliveryStore.setRetentionPolicy(runtime.runtime?.retention?.deliveries || {});
191
- this.notifier.configureRetention(runtime.runtime?.retention?.notifications || {});
192
- const profiles = buildProfileMap(runtime.delivery);
193
- const pending = await this.listPending(mode, limit, notificationFiles);
194
- const results = [];
195
- const requestedFiles = notificationFiles?.length ? new Set(notificationFiles) : null;
196
-
197
- if (requestedFiles) {
198
- for (const filePath of requestedFiles) {
199
- const notification = await this.notificationStore.getNotification(filePath);
200
- const existing = await this.deliveryStore.getDeliveryRecord(mode, filePath, notification?.dedupeKey || null);
201
- if (!existing) continue;
202
- const status =
203
- existing.status === "uncertain"
204
- ? "delivery_uncertain"
205
- : existing.retryEligible === false || existing.status === "delivered"
206
- ? "duplicate_prevented"
207
- : "previous_attempt";
208
- results.push({
209
- status,
210
- notificationFilePath: filePath,
211
- receiptFilePath: existing.receiptFilePath,
212
- deliveredAt: existing.deliveredAt,
213
- lastError: existing.lastError || null
214
- });
215
- }
216
- }
217
-
218
- for (const notification of pending) {
219
- try {
220
- if (notification.stage === "handoff" && notification.target?.mode === "peer_agent" && !notification.target?.peerId) {
221
- const updated = await this.notificationStore.updateNotification(notification.filePath, {
222
- status: "awaiting_choice",
223
- handoffState: "pending",
224
- updatedAt: new Date().toISOString()
225
- });
226
- results.push({
227
- status: "pending_choice",
228
- jobId: notification.jobId,
229
- stage: notification.stage,
230
- notificationFilePath: updated.filePath
231
- });
232
- continue;
233
- }
234
-
235
- if (notification.stage === "handoff" && !notification.target?.mode && (notification.suggestedPeers || []).length) {
236
- const updated = await this.notificationStore.updateNotification(notification.filePath, {
237
- status: "awaiting_choice",
238
- handoffState: "pending",
239
- updatedAt: new Date().toISOString()
240
- });
241
- results.push({
242
- status: "pending_choice",
243
- jobId: notification.jobId,
244
- stage: notification.stage,
245
- suggestedPeers: updated.suggestedPeers?.map((item) => item.peerId) || []
246
- });
247
- continue;
248
- }
249
-
250
- if (notification.stage === "handoff") {
251
- const preflight = await this.preflightHandoff(notification, runtime);
252
- if (!preflight.ok) {
253
- await this.notificationStore.updateNotification(notification.filePath, {
254
- status: "blocked",
255
- handoffState: "blocked",
256
- blockedReason: preflight.reason,
257
- updatedAt: new Date().toISOString()
258
- });
259
- results.push({
260
- status: "blocked",
261
- jobId: notification.jobId,
262
- stage: notification.stage,
263
- reason: preflight.reason
264
- });
265
- continue;
266
- }
267
- }
268
-
269
- const existing = await this.deliveryStore.getDeliveryRecord(mode, notification.filePath, notification.dedupeKey || null);
270
- if (existing?.status === "delivered") {
271
- results.push({
272
- status: "duplicate_prevented",
273
- jobId: notification.jobId,
274
- stage: notification.stage,
275
- notificationFilePath: notification.filePath,
276
- receiptFilePath: existing.receiptFilePath
277
- });
278
- continue;
279
- }
280
- if (existing?.status === "uncertain") {
281
- results.push({
282
- status: "delivery_uncertain",
283
- jobId: notification.jobId,
284
- stage: notification.stage,
285
- notificationFilePath: notification.filePath,
286
- receiptFilePath: existing.receiptFilePath,
287
- lastError: existing.lastError || null
288
- });
289
- continue;
290
- }
291
- if (existing?.status === "failed" && existing.retryEligible === false) {
292
- results.push({
293
- status: "retry_suppressed",
294
- jobId: notification.jobId,
295
- stage: notification.stage,
296
- notificationFilePath: notification.filePath,
297
- receiptFilePath: existing.receiptFilePath,
298
- lastError: existing.lastError || null
299
- });
300
- continue;
301
- }
302
-
303
- const resolvedTarget = this.resolveNotificationTarget(notification, runtime);
304
- const profileName = resolvedTarget.profileName;
305
- const profileConfig = await this.resolveProfileConfig(profileName, resolveNamedProfile(profiles, profileName) || {});
306
- const adapterId = mode === "shadow" ? "shadow" : profileConfig.adapter || "shadow";
307
- const adapter = this.createAdapter(adapterId);
308
- const delivery = await adapter.deliver(notification, {
309
- profileName,
310
- profileConfig,
311
- target: resolvedTarget.target
312
- });
313
-
314
- const receipt = {
315
- relatedNotificationId: notification.id || null,
316
- timestamp: new Date().toISOString(),
317
- mode,
318
- notificationFilePath: notification.filePath,
319
- jobId: notification.jobId,
320
- stage: notification.stage,
321
- target: resolvedTarget.target || null,
322
- deliveryProfile: profileName || null,
323
- adapter: adapterId,
324
- delivery
325
- };
326
- const receiptFilePath = await this.deliveryStore.saveReceipt(notification.jobId, receipt);
327
- await this.deliveryStore.markDelivered(mode, notification.filePath, receiptFilePath, {
328
- dedupeKey: notification.dedupeKey || null
329
- });
330
- if (notification.stage === "handoff") {
331
- await this.notificationStore.updateNotification(notification.filePath, {
332
- status: "delivered",
333
- handoffState: "delivered",
334
- lastReceiptFilePath: receiptFilePath,
335
- updatedAt: new Date().toISOString()
336
- });
337
- }
338
-
339
- results.push({
340
- status: "delivered",
341
- jobId: notification.jobId,
342
- stage: notification.stage,
343
- adapter: adapterId,
344
- receiptFilePath
345
- });
346
- } catch (error) {
347
- const failureClass = classifyDeliveryFailure(error.message);
348
- const receipt = {
349
- relatedNotificationId: notification.id || null,
350
- timestamp: new Date().toISOString(),
351
- mode,
352
- notificationFilePath: notification.filePath,
353
- jobId: notification.jobId,
354
- stage: notification.stage,
355
- target: notification.target || null,
356
- deliveryProfile: notification.deliveryProfile || null,
357
- adapter: null,
358
- delivery: {
359
- status: failureClass === "uncertain" ? "delivery_uncertain" : "delivery_failed",
360
- error: error.message
361
- }
362
- };
363
- const receiptFilePath = await this.deliveryStore.saveReceipt(notification.jobId, receipt);
364
- const retryEligible = runtime.runtime?.delivery?.retryOnFailure ?? false;
365
- if (failureClass === "uncertain") {
366
- await this.deliveryStore.markUncertain(mode, notification.filePath, receiptFilePath, {
367
- dedupeKey: notification.dedupeKey || null,
368
- error: error.message
369
- });
370
- } else {
371
- await this.deliveryStore.markFailed(mode, notification.filePath, receiptFilePath, {
372
- dedupeKey: notification.dedupeKey || null,
373
- error: error.message,
374
- retryEligible
375
- });
376
- }
377
- if (notification.stage === "handoff") {
378
- await this.notificationStore.updateNotification(notification.filePath, {
379
- status: failureClass === "uncertain" ? "uncertain" : "blocked",
380
- handoffState: failureClass === "uncertain" ? "blocked" : "blocked",
381
- blockedReason: error.message,
382
- deliveryUncertain: failureClass === "uncertain",
383
- updatedAt: new Date().toISOString()
384
- });
385
- }
386
-
387
- const notifyOnFailure = runtime.runtime?.delivery?.notifyOnFailure ?? true;
388
- if (failureClass !== "uncertain" && !retryEligible && notifyOnFailure) {
389
- const resolvedTarget = this.resolveNotificationTarget(notification, runtime);
390
- const escalationRecord = {
391
- event: "delivery_failure",
392
- profile: notification.deliveryProfile || resolvedTarget.profileName || null,
393
- target: resolvedTarget.target || notification.target || null,
394
- error: error.message,
395
- escalated: true
396
- };
397
- await this.notificationStore.saveNotification(notification.jobId, {
398
- timestamp: new Date().toISOString(),
399
- jobId: notification.jobId,
400
- stage: "delivery_failure_escalation",
401
- status: "ready",
402
- signal: "delivery_failure",
403
- deliveryProfile: runtime.runtime?.followUps?.escalationDeliveryProfile || runtime.delivery?.defaultInteractiveProfile || null,
404
- target: { mode: "same_thread" },
405
- message: `Delivery failed for ${notification.jobId} (stage: ${notification.stage}): ${error.message}`,
406
- sourceNotificationFilePath: notification.filePath,
407
- escalationRecord
408
- });
409
- }
410
-
411
- results.push({
412
- status: failureClass === "uncertain" ? "delivery_uncertain" : "error",
413
- jobId: notification.jobId,
414
- stage: notification.stage,
415
- error: error.message,
416
- receiptFilePath,
417
- escalated: failureClass !== "uncertain" && !retryEligible && (runtime.runtime?.delivery?.notifyOnFailure ?? true)
418
- });
419
- }
420
- }
421
-
422
- return {
423
- mode,
424
- pendingCount: pending.length,
425
- results
426
- };
427
- }
428
-
429
- async resolveProfileConfig(profileName, profileConfig = {}) {
430
- if (!profileConfig || !["telegram", "openclaw_cli"].includes(profileConfig.adapter)) {
431
- return profileConfig;
432
- }
433
-
434
- if (profileConfig.channel && profileConfig.channel !== "telegram") {
435
- return profileConfig;
436
- }
437
-
438
- const liveConfig = await this.bridge.loadConfig();
439
- const accountId = profileConfig.accountId || "default";
440
- const liveAccount = liveConfig.channels?.telegram?.accounts?.[accountId] || {};
441
- const directIds = Object.keys(liveAccount.direct || {});
442
- const allowFrom = liveAccount.allowFrom || liveConfig.channels?.telegram?.allowFrom || [];
443
- const fallbackChatId = directIds[0] || String(allowFrom[0] || "").replace(/^tg:/, "") || null;
444
-
445
- return {
446
- ...profileConfig,
447
- accountId,
448
- channel: profileConfig.channel || "telegram",
449
- chatId: profileConfig.chatId || fallbackChatId,
450
- botTokenEnv: profileConfig.botTokenEnv || liveAccount.botToken?.id || null
451
- };
452
- }
453
-
454
- async listPendingHandoffs(limit = 10) {
455
- const notifications = await this.notificationStore.listRecent(limit * 4);
456
- return notifications
457
- .filter((item) => item.stage === "handoff" && normalizeHandoffState(item) === "pending")
458
- .slice(0, limit);
459
- }
460
-
461
- async listPendingFollowUps(limit = 10) {
462
- const notifications = await this.notificationStore.listRecent(limit * 4);
463
- return notifications
464
- .filter((item) => item.stage === "follow_up" && normalizeFollowUpState(item) === "pending")
465
- .slice(0, limit);
466
- }
467
-
468
- async consumeFollowUp(notificationFilePath, options = {}) {
469
- const notification = await this.notificationStore.getNotification(notificationFilePath);
470
- if (notification) {
471
- const injectionScan = scanFollowUpPayload(notification);
472
- if (injectionScan?.flagged) {
473
- console.warn(`[Nemoris] security: Follow-up payload for job "${notification.jobId}" contains potential injection patterns: ${injectionScan.patterns.join(", ")}`);
474
- }
475
- }
476
- return this.notifier.consumeFollowUp(notificationFilePath, options);
477
- }
478
-
479
- async chooseHandoffPeer(notificationFilePath, peerId, options = {}) {
480
- const runtime = await this.loader.loadAll();
481
- const notification = await this.notificationStore.getNotification(notificationFilePath);
482
- if (!notification) {
483
- throw new Error(`Notification not found: ${notificationFilePath}`);
484
- }
485
- if (notification.stage !== "handoff") {
486
- throw new Error("Only handoff notifications can be assigned to a peer.");
487
- }
488
-
489
- const peerRegistry = new PeerRegistry(runtime.peers);
490
- const resolved = peerRegistry.resolveTarget({
491
- mode: "peer_agent",
492
- peerId
493
- });
494
- const profileName = options.deliveryProfile || notification.deliveryProfile || "peer_preview";
495
- const profile = this.resolveProfileEntry(runtime.delivery, profileName);
496
- if (!profile) {
497
- throw new Error(`Unknown delivery profile ${profileName}`);
498
- }
499
-
500
- const updated = await this.notificationStore.updateNotification(notificationFilePath, {
501
- status: "ready",
502
- handoffState: "chosen",
503
- target: {
504
- mode: "peer_agent",
505
- peerId: resolved.peerId,
506
- sessionKey: resolved.sessionKey,
507
- deliveryProfile: profileName
508
- },
509
- deliveryProfile: profileName,
510
- chosenPeer: {
511
- peerId: resolved.peerId,
512
- label: resolved.label,
513
- sessionKey: resolved.sessionKey
514
- },
515
- chosenBy: "manual_peer",
516
- chosenAt: new Date().toISOString(),
517
- blockedReason: null
518
- });
519
-
520
- return updated;
521
- }
522
-
523
- async chooseTopSuggestedPeer(notificationFilePath, options = {}) {
524
- const notification = await this.notificationStore.getNotification(notificationFilePath);
525
- if (!notification) {
526
- throw new Error(`Notification not found: ${notificationFilePath}`);
527
- }
528
- if (notification.stage !== "handoff") {
529
- throw new Error("Only handoff notifications can promote the top suggested peer.");
530
- }
531
- if (notification.status !== "awaiting_choice") {
532
- throw new Error("Top-suggestion promotion only works for handoffs awaiting choice.");
533
- }
534
-
535
- const suggestions = notification.suggestedPeers || [];
536
- if (!suggestions.length) {
537
- throw new Error("No suggested peers are available for this handoff.");
538
- }
539
-
540
- const topSuggestion = suggestions[0];
541
- const updated = await this.chooseHandoffPeer(notificationFilePath, topSuggestion.peerId, options);
542
- return this.notificationStore.updateNotification(updated.filePath, {
543
- chosenBy: "top_suggestion",
544
- chosenSuggestionRank: 1,
545
- chosenSuggestionReasons: topSuggestion.reasons || [],
546
- chosenSuggestionScore: topSuggestion.score ?? null
547
- });
548
- }
549
-
550
- async preflightHandoff(notification, runtime) {
551
- if (notification.stage !== "handoff") {
552
- return { ok: true };
553
- }
554
- if (!notification.target?.mode) {
555
- return { ok: false, reason: "handoff_target_missing" };
556
- }
557
- if (notification.target.mode !== "peer_agent") {
558
- return { ok: false, reason: `handoff_target_mode_not_allowed:${notification.target.mode}` };
559
- }
560
- const profileName = notification.deliveryProfile || runtime.delivery?.defaultPeerProfile || runtime.delivery?.defaultInteractiveProfile;
561
- const profile = this.resolveProfileEntry(runtime.delivery, profileName);
562
- if (!profile) {
563
- return { ok: false, reason: "handoff_peer_delivery_profile_invalid" };
564
- }
565
- try {
566
- const resolved = new PeerRegistry(runtime.peers).resolveTarget(notification.target);
567
- if (!resolved.sessionKey) {
568
- return { ok: false, reason: "handoff_peer_route_unresolvable" };
569
- }
570
- const readiness = await this.peerProbe.probe(runtime, resolved.peerId);
571
- if (!readiness.deliveryProfileValid) {
572
- return { ok: false, reason: "handoff_peer_delivery_profile_invalid" };
573
- }
574
- if (!readiness.routeResolvable) {
575
- return { ok: false, reason: "handoff_peer_route_unresolvable" };
576
- }
577
- if (!readiness.targetReachable && profile.dryRun !== true) {
578
- return { ok: false, reason: "handoff_peer_not_ready" };
579
- }
580
- return { ok: true };
581
- } catch (error) {
582
- return { ok: false, reason: error.message };
583
- }
584
- }
585
-
586
- async sweepPendingHandoffs(options = {}) {
587
- const runtime = await this.loader.loadAll();
588
- const config = runtime.runtime?.handoffs || {};
589
- const timeoutMinutes = Number(options.timeoutMinutes ?? config.pendingTimeoutMinutes ?? 120);
590
- const escalateOnExpiry = options.escalateOnExpiry ?? config.escalateOnExpiry ?? true;
591
- const escalationDeliveryProfile =
592
- options.escalationDeliveryProfile ||
593
- config.escalationDeliveryProfile ||
594
- runtime.delivery?.defaultInteractiveProfile ||
595
- null;
596
- const now = new Date(options.now || Date.now());
597
- const cutoff = now.getTime() - timeoutMinutes * 60 * 1000;
598
- const notifications = await this.notificationStore.listAll();
599
- const pending = notifications.filter((item) => item.stage === "handoff" && normalizeHandoffState(item) === "pending");
600
- const results = [];
601
-
602
- for (const notification of pending) {
603
- const ts = new Date(notification.timestamp || 0).getTime();
604
- if (!ts || ts >= cutoff) continue;
605
- const expired = await this.notificationStore.updateNotification(notification.filePath, {
606
- status: "expired",
607
- handoffState: "expired",
608
- expiredAt: now.toISOString(),
609
- updatedAt: now.toISOString()
610
- });
611
- let escalationFilePath = null;
612
- if (escalateOnExpiry) {
613
- escalationFilePath = await this.notificationStore.saveNotification(notification.jobId, {
614
- timestamp: now.toISOString(),
615
- jobId: notification.jobId,
616
- stage: "handoff_escalation",
617
- status: "ready",
618
- handoffState: "escalated",
619
- deliveryProfile: escalationDeliveryProfile,
620
- signal: `${notification.signal || "handoff"}_expired`,
621
- target: { mode: "same_thread" },
622
- message: `Handoff for ${notification.jobId} expired without operator choice.`,
623
- sourceNotificationId: notification.id || null,
624
- sourceNotificationFilePath: notification.filePath
625
- });
626
- await this.notificationStore.updateNotification(notification.filePath, {
627
- handoffState: "escalated",
628
- escalationNotificationFilePath: escalationFilePath,
629
- updatedAt: now.toISOString()
630
- });
631
- }
632
- results.push({
633
- jobId: notification.jobId,
634
- notificationFilePath: expired.filePath,
635
- status: escalateOnExpiry ? "escalated" : "expired",
636
- escalationNotificationFilePath: escalationFilePath
637
- });
638
- }
639
-
640
- return {
641
- timestamp: now.toISOString(),
642
- pendingCount: pending.length,
643
- expiredCount: results.length,
644
- results
645
- };
646
- }
647
-
648
- computeFollowUpDepth(notification, allNotifications) {
649
- let depth = 0;
650
- let current = notification;
651
- const seen = new Set();
652
- while (current?.sourceFollowUpFilePath) {
653
- if (seen.has(current.sourceFollowUpFilePath)) break;
654
- seen.add(current.sourceFollowUpFilePath);
655
- depth += 1;
656
- current = allNotifications.find((n) => n.filePath === current.sourceFollowUpFilePath) || null;
657
- }
658
- return depth;
659
- }
660
-
661
- async sweepPendingFollowUps(options = {}) {
662
- const runtime = await this.loader.loadAll();
663
- const config = runtime.runtime?.followUps || {};
664
- const timeoutMinutes = Number(options.timeoutMinutes ?? config.pendingTimeoutMinutes ?? 120);
665
- const maxFollowUpDepth = Number(options.maxFollowUpDepth ?? config.maxFollowUpDepth ?? 5);
666
- const escalateOnExpiry = options.escalateOnExpiry ?? config.escalateOnExpiry ?? true;
667
- const escalationDeliveryProfile =
668
- options.escalationDeliveryProfile ||
669
- config.escalationDeliveryProfile ||
670
- runtime.delivery?.defaultInteractiveProfile ||
671
- null;
672
- const now = new Date(options.now || Date.now());
673
- const cutoff = now.getTime() - timeoutMinutes * 60 * 1000;
674
- const notifications = await this.notificationStore.listAll();
675
- const pending = notifications.filter((item) => item.stage === "follow_up" && normalizeFollowUpState(item) === "pending");
676
- const results = [];
677
-
678
- for (const notification of pending) {
679
- const injectionScan = scanFollowUpPayload(notification);
680
- if (injectionScan?.flagged) {
681
- console.warn(`[Nemoris] security: Follow-up payload for job "${notification.jobId}" contains potential injection patterns: ${injectionScan.patterns.join(", ")}`);
682
- }
683
-
684
- const depth = this.computeFollowUpDepth(notification, notifications);
685
- if (depth >= maxFollowUpDepth) {
686
- console.warn(`Follow-up chain halted: max depth ${depth} exceeded for job ${notification.jobId}`);
687
- await this.notificationStore.updateNotification(notification.filePath, {
688
- status: "expired",
689
- followUpState: "depth_exceeded",
690
- yieldState: notification.yieldState || "pending",
691
- expiredAt: now.toISOString(),
692
- updatedAt: now.toISOString(),
693
- depthExceeded: true,
694
- chainDepth: depth,
695
- maxFollowUpDepth
696
- });
697
- results.push({
698
- jobId: notification.jobId,
699
- notificationFilePath: notification.filePath,
700
- status: "depth_exceeded",
701
- chainDepth: depth,
702
- maxFollowUpDepth
703
- });
704
- continue;
705
- }
706
-
707
- // Scoped idempotency check
708
- const followUpIdentifier = notification.followUpId || notification.sourceFollowUpId;
709
- if (this.stateStore && notification.parentIdempotencyKey && followUpIdentifier) {
710
- const scopedKey = createHash("sha256")
711
- .update(`${notification.parentIdempotencyKey}:${followUpIdentifier}`)
712
- .digest("hex")
713
- .slice(0, 32);
714
-
715
- notification._scopedKey = scopedKey;
716
-
717
- const existing = this.stateStore.getFollowUpCompletion(scopedKey);
718
- if (existing?.status === "succeeded") {
719
- console.log(`[Nemoris] follow-up ${followUpIdentifier} already completed in this slot, skipping`);
720
- results.push({
721
- jobId: notification.jobId,
722
- notificationFilePath: notification.filePath,
723
- status: "idempotent_skip",
724
- scopedKey
725
- });
726
- continue;
727
- }
728
- if (existing?.status === "failed") {
729
- this.stateStore.clearFollowUpCompletion(scopedKey);
730
- }
731
- }
732
-
733
- const ts = new Date(notification.timestamp || 0).getTime();
734
- if (!ts || ts >= cutoff) continue;
735
- const expired = await this.notificationStore.updateNotification(notification.filePath, {
736
- status: "expired",
737
- followUpState: "expired",
738
- yieldState: notification.yieldState || "pending",
739
- expiredAt: now.toISOString(),
740
- updatedAt: now.toISOString()
741
- });
742
-
743
- let escalationFilePath = null;
744
- if (escalateOnExpiry) {
745
- escalationFilePath = await this.notificationStore.saveNotification(notification.jobId, {
746
- id: null,
747
- timestamp: now.toISOString(),
748
- jobId: notification.jobId,
749
- stage: "follow_up_escalation",
750
- status: "ready",
751
- followUpState: "escalated",
752
- deliveryProfile: escalationDeliveryProfile,
753
- signal: `${notification.signal || "follow_up"}_expired`,
754
- target: { mode: "same_thread" },
755
- message: `Follow-up for ${notification.jobId} expired before it was consumed.`,
756
- sourceNotificationId: notification.id || null,
757
- sourceNotificationFilePath: notification.filePath,
758
- sourceFollowUpId: notification.followUpId || null
759
- });
760
- await this.notificationStore.updateNotification(notification.filePath, {
761
- followUpState: "escalated",
762
- escalationNotificationFilePath: escalationFilePath,
763
- updatedAt: now.toISOString()
764
- });
765
- }
766
-
767
- results.push({
768
- jobId: notification.jobId,
769
- notificationFilePath: expired.filePath,
770
- status: escalateOnExpiry ? "escalated" : "expired",
771
- escalationNotificationFilePath: escalationFilePath
772
- });
773
- }
774
-
775
- return {
776
- timestamp: now.toISOString(),
777
- pendingCount: pending.length,
778
- expiredCount: results.length,
779
- results
780
- };
781
- }
782
-
783
- async sendTestDelivery({ profileName, mode = "shadow", message }) {
784
- const runtime = await this.loader.loadAll();
785
- const profileConfig = await this.resolveProfileConfig(profileName, this.resolveProfileEntry(runtime.delivery, profileName) || {});
786
- if (!profileConfig.adapter) {
787
- throw new Error(`Unknown delivery profile ${profileName}`);
788
- }
789
-
790
- const adapterId = mode === "shadow" ? "shadow" : profileConfig.adapter;
791
- const adapter = this.createAdapter(adapterId);
792
- return adapter.deliver(
793
- {
794
- jobId: "delivery-test",
795
- stage: "test",
796
- target: {
797
- mode: profileConfig.channel || "same_thread"
798
- },
799
- message
800
- },
801
- {
802
- profileName,
803
- profileConfig
804
- }
805
- );
806
- }
807
- }
1
+ import path from "node:path";
2
+ import { ConfigLoader } from "../config/loader.js";
3
+ import { NotificationStore } from "./notification-store.js";
4
+ import { DeliveryStore } from "./delivery-store.js";
5
+ import { ShadowDeliveryAdapter } from "./delivery-adapters/shadow.js";
6
+ import { TelegramDeliveryAdapter } from "./delivery-adapters/telegram.js";
7
+ import { OpenClawCliDeliveryAdapter } from "./delivery-adapters/openclaw-cli.js";
8
+ import { OpenClawPeerDeliveryAdapter } from "./delivery-adapters/openclaw-peer.js";
9
+ import { LocalFileDeliveryAdapter } from "./delivery-adapters/local-file.js";
10
+ import { StandaloneHttpDeliveryAdapter } from "./delivery-adapters/standalone-http.js";
11
+ import { TuiDeliveryAdapter } from "./delivery-adapters/tui.js";
12
+ import { OpenClawShadowBridge } from "../shadow/bridge.js";
13
+ import { PeerRegistry } from "./peer-registry.js";
14
+ import { InteractionNotifier } from "./notifier.js";
15
+ import { classifyNetworkFailure } from "./network.js";
16
+ import { PeerReadinessProbe } from "./peer-readiness.js";
17
+ import { detectInjectionPatterns } from "./input-sanitiser.js";
18
+ import { createHash } from "node:crypto";
19
+
20
+ const ADAPTERS = new Map([
21
+ [ShadowDeliveryAdapter.adapterId, ShadowDeliveryAdapter],
22
+ [TelegramDeliveryAdapter.adapterId, TelegramDeliveryAdapter],
23
+ [OpenClawCliDeliveryAdapter.adapterId, OpenClawCliDeliveryAdapter],
24
+ [OpenClawPeerDeliveryAdapter.adapterId, OpenClawPeerDeliveryAdapter],
25
+ [LocalFileDeliveryAdapter.adapterId, LocalFileDeliveryAdapter],
26
+ [StandaloneHttpDeliveryAdapter.adapterId, StandaloneHttpDeliveryAdapter],
27
+ [TuiDeliveryAdapter.adapterId, TuiDeliveryAdapter]
28
+ ]);
29
+
30
+ function buildProfileMap(deliveryConfig) {
31
+ return deliveryConfig?.profiles || {};
32
+ }
33
+
34
+ function normalizeProfileName(profileName) {
35
+ return String(profileName || "").replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
36
+ }
37
+
38
+ function resolveNamedProfile(profiles, profileName) {
39
+ if (!profileName) return null;
40
+ return profiles[profileName] || profiles[normalizeProfileName(profileName)] || null;
41
+ }
42
+
43
+ function normalizeHandoffState(notification) {
44
+ if (notification.handoffState) return notification.handoffState;
45
+ if (notification.stage !== "handoff") return null;
46
+ if (notification.status === "awaiting_choice") return "pending";
47
+ if (notification.status === "ready" && notification.chosenPeer?.peerId) return "chosen";
48
+ if (notification.status === "blocked") return "blocked";
49
+ return null;
50
+ }
51
+
52
+ function normalizeFollowUpState(notification) {
53
+ if (notification.followUpState) return notification.followUpState;
54
+ if (notification.stage !== "follow_up") return null;
55
+ if (notification.status === "pending") return "pending";
56
+ if (notification.status === "consumed" || notification.yieldState === "consumed") return "consumed";
57
+ if (notification.status === "expired") return "expired";
58
+ if (notification.status === "blocked") return "blocked";
59
+ return null;
60
+ }
61
+
62
+ function scanFollowUpPayload(notification) {
63
+ if (!notification?.payload) return null;
64
+ const payload = notification.payload;
65
+ const textParts = [];
66
+
67
+ if (typeof payload === "string") {
68
+ textParts.push(payload);
69
+ } else if (typeof payload === "object") {
70
+ for (const value of Object.values(payload)) {
71
+ if (typeof value === "string") textParts.push(value);
72
+ else if (typeof value === "object" && value !== null) {
73
+ for (const inner of Object.values(value)) {
74
+ if (typeof inner === "string") textParts.push(inner);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ if (!textParts.length) return null;
81
+ const combined = textParts.join("\n");
82
+ return detectInjectionPatterns(combined);
83
+ }
84
+
85
+ function classifyDeliveryFailure(message) {
86
+ const failure = classifyNetworkFailure(message, { surface: "delivery" });
87
+ if (failure?.failureClass === "delivery_timeout") {
88
+ return "uncertain";
89
+ }
90
+ const normalized = String(message || "").toLowerCase();
91
+ if (!normalized) return "failed";
92
+ return "failed";
93
+ }
94
+
95
+ export class DeliveryManager {
96
+ constructor({ projectRoot, liveRoot, stateRoot, fetchImpl, execFileImpl, stateStore } = {}) {
97
+ this.loader = new ConfigLoader({ rootDir: path.join(projectRoot, "config") });
98
+ this.notificationStore = new NotificationStore({ rootDir: path.join(stateRoot, "notifications") });
99
+ this.deliveryStore = new DeliveryStore({ rootDir: path.join(stateRoot, "deliveries") });
100
+ this.notifier = new InteractionNotifier({ stateRoot });
101
+ this.bridge = new OpenClawShadowBridge({ liveRoot });
102
+ this.peerProbe = new PeerReadinessProbe({ liveRoot });
103
+ this.liveRoot = liveRoot;
104
+ this.stateRoot = stateRoot;
105
+ this.fetchImpl = fetchImpl;
106
+ this.execFileImpl = execFileImpl;
107
+ this.stateStore = stateStore || null;
108
+ }
109
+
110
+ isStandaloneMode(runtime = null) {
111
+ if (process.env.NEMORIS_STANDALONE === "1" || process.env.NEMORIS_STANDALONE === "true") {
112
+ return true;
113
+ }
114
+ return Boolean(runtime?.delivery?.standaloneMode) || !this.liveRoot;
115
+ }
116
+
117
+ createAdapter(adapterId) {
118
+ const Adapter = ADAPTERS.get(adapterId);
119
+ if (!Adapter) {
120
+ throw new Error(`Unknown delivery adapter ${adapterId}`);
121
+ }
122
+ return new Adapter({
123
+ fetchImpl: this.fetchImpl,
124
+ execFileImpl: this.execFileImpl,
125
+ stateRoot: this.stateRoot,
126
+ liveRoot: this.liveRoot
127
+ });
128
+ }
129
+
130
+ resolveNotificationTarget(notification, runtime) {
131
+ const peerRegistry = new PeerRegistry(runtime.peers);
132
+ const standalone = this.isStandaloneMode(runtime);
133
+ if (notification.target?.mode !== "peer_agent") {
134
+ return {
135
+ target: notification.target || null,
136
+ profileName:
137
+ notification.deliveryProfile ||
138
+ (notification.target?.mode === "scheduler_log"
139
+ ? runtime.delivery?.defaultSchedulerProfile
140
+ : standalone
141
+ ? runtime.delivery?.defaultInteractiveProfileStandalone || runtime.delivery?.defaultInteractiveProfile
142
+ : runtime.delivery?.defaultInteractiveProfile)
143
+ };
144
+ }
145
+
146
+ const resolved = peerRegistry.resolveTarget(notification.target);
147
+ return {
148
+ target: {
149
+ mode: "peer_agent",
150
+ peerId: resolved.peerId,
151
+ sessionKey: resolved.sessionKey,
152
+ label: resolved.label
153
+ },
154
+ profileName:
155
+ notification.deliveryProfile ||
156
+ resolved.deliveryProfile ||
157
+ (standalone
158
+ ? runtime.delivery?.defaultPeerProfileStandalone || runtime.delivery?.defaultInteractiveProfileStandalone
159
+ : runtime.delivery?.defaultInteractiveProfile)
160
+ };
161
+ }
162
+
163
+ resolveProfileEntry(deliveryConfig, profileName) {
164
+ return resolveNamedProfile(buildProfileMap(deliveryConfig), profileName);
165
+ }
166
+
167
+ async listPending(mode = "shadow", limit = 10, notificationFiles = null) {
168
+ const notifications = await this.notificationStore.listRecent(limit * 4);
169
+ const pending = [];
170
+ const fileFilter = notificationFiles?.length ? new Set(notificationFiles) : null;
171
+
172
+ for (const notification of notifications) {
173
+ if (fileFilter && !fileFilter.has(notification.filePath)) continue;
174
+ if (notification.stage === "follow_up") continue;
175
+ if (notification.status === "awaiting_choice" || normalizeHandoffState(notification) === "pending") continue;
176
+ const existing = await this.deliveryStore.getDeliveryRecord(mode, notification.filePath, notification.dedupeKey || null);
177
+ if (existing && (existing.status === "delivered" || existing.status === "uncertain" || existing.retryEligible === false)) {
178
+ continue;
179
+ }
180
+ pending.push(notification);
181
+ if (pending.length >= limit) break;
182
+ }
183
+
184
+ return pending;
185
+ }
186
+
187
+ async deliverPending({ mode = "shadow", limit = 10, notificationFiles = null } = {}) {
188
+ const runtime = await this.loader.loadAll();
189
+ this.notificationStore.setRetentionPolicy(runtime.runtime?.retention?.notifications || {});
190
+ this.deliveryStore.setRetentionPolicy(runtime.runtime?.retention?.deliveries || {});
191
+ this.notifier.configureRetention(runtime.runtime?.retention?.notifications || {});
192
+ const profiles = buildProfileMap(runtime.delivery);
193
+ const pending = await this.listPending(mode, limit, notificationFiles);
194
+ const results = [];
195
+ const requestedFiles = notificationFiles?.length ? new Set(notificationFiles) : null;
196
+
197
+ if (requestedFiles) {
198
+ for (const filePath of requestedFiles) {
199
+ const notification = await this.notificationStore.getNotification(filePath);
200
+ const existing = await this.deliveryStore.getDeliveryRecord(mode, filePath, notification?.dedupeKey || null);
201
+ if (!existing) continue;
202
+ const status =
203
+ existing.status === "uncertain"
204
+ ? "delivery_uncertain"
205
+ : existing.retryEligible === false || existing.status === "delivered"
206
+ ? "duplicate_prevented"
207
+ : "previous_attempt";
208
+ results.push({
209
+ status,
210
+ notificationFilePath: filePath,
211
+ receiptFilePath: existing.receiptFilePath,
212
+ deliveredAt: existing.deliveredAt,
213
+ lastError: existing.lastError || null
214
+ });
215
+ }
216
+ }
217
+
218
+ for (const notification of pending) {
219
+ try {
220
+ if (notification.stage === "handoff" && notification.target?.mode === "peer_agent" && !notification.target?.peerId) {
221
+ const updated = await this.notificationStore.updateNotification(notification.filePath, {
222
+ status: "awaiting_choice",
223
+ handoffState: "pending",
224
+ updatedAt: new Date().toISOString()
225
+ });
226
+ results.push({
227
+ status: "pending_choice",
228
+ jobId: notification.jobId,
229
+ stage: notification.stage,
230
+ notificationFilePath: updated.filePath
231
+ });
232
+ continue;
233
+ }
234
+
235
+ if (notification.stage === "handoff" && !notification.target?.mode && (notification.suggestedPeers || []).length) {
236
+ const updated = await this.notificationStore.updateNotification(notification.filePath, {
237
+ status: "awaiting_choice",
238
+ handoffState: "pending",
239
+ updatedAt: new Date().toISOString()
240
+ });
241
+ results.push({
242
+ status: "pending_choice",
243
+ jobId: notification.jobId,
244
+ stage: notification.stage,
245
+ suggestedPeers: updated.suggestedPeers?.map((item) => item.peerId) || []
246
+ });
247
+ continue;
248
+ }
249
+
250
+ if (notification.stage === "handoff") {
251
+ const preflight = await this.preflightHandoff(notification, runtime);
252
+ if (!preflight.ok) {
253
+ await this.notificationStore.updateNotification(notification.filePath, {
254
+ status: "blocked",
255
+ handoffState: "blocked",
256
+ blockedReason: preflight.reason,
257
+ updatedAt: new Date().toISOString()
258
+ });
259
+ results.push({
260
+ status: "blocked",
261
+ jobId: notification.jobId,
262
+ stage: notification.stage,
263
+ reason: preflight.reason
264
+ });
265
+ continue;
266
+ }
267
+ }
268
+
269
+ const existing = await this.deliveryStore.getDeliveryRecord(mode, notification.filePath, notification.dedupeKey || null);
270
+ if (existing?.status === "delivered") {
271
+ results.push({
272
+ status: "duplicate_prevented",
273
+ jobId: notification.jobId,
274
+ stage: notification.stage,
275
+ notificationFilePath: notification.filePath,
276
+ receiptFilePath: existing.receiptFilePath
277
+ });
278
+ continue;
279
+ }
280
+ if (existing?.status === "uncertain") {
281
+ results.push({
282
+ status: "delivery_uncertain",
283
+ jobId: notification.jobId,
284
+ stage: notification.stage,
285
+ notificationFilePath: notification.filePath,
286
+ receiptFilePath: existing.receiptFilePath,
287
+ lastError: existing.lastError || null
288
+ });
289
+ continue;
290
+ }
291
+ if (existing?.status === "failed" && existing.retryEligible === false) {
292
+ results.push({
293
+ status: "retry_suppressed",
294
+ jobId: notification.jobId,
295
+ stage: notification.stage,
296
+ notificationFilePath: notification.filePath,
297
+ receiptFilePath: existing.receiptFilePath,
298
+ lastError: existing.lastError || null
299
+ });
300
+ continue;
301
+ }
302
+
303
+ const resolvedTarget = this.resolveNotificationTarget(notification, runtime);
304
+ const profileName = resolvedTarget.profileName;
305
+ const profileConfig = await this.resolveProfileConfig(profileName, resolveNamedProfile(profiles, profileName) || {});
306
+ const adapterId = mode === "shadow" ? "shadow" : profileConfig.adapter || "shadow";
307
+ const adapter = this.createAdapter(adapterId);
308
+ const delivery = await adapter.deliver(notification, {
309
+ profileName,
310
+ profileConfig,
311
+ target: resolvedTarget.target
312
+ });
313
+
314
+ const receipt = {
315
+ relatedNotificationId: notification.id || null,
316
+ timestamp: new Date().toISOString(),
317
+ mode,
318
+ notificationFilePath: notification.filePath,
319
+ jobId: notification.jobId,
320
+ stage: notification.stage,
321
+ target: resolvedTarget.target || null,
322
+ deliveryProfile: profileName || null,
323
+ adapter: adapterId,
324
+ delivery
325
+ };
326
+ const receiptFilePath = await this.deliveryStore.saveReceipt(notification.jobId, receipt);
327
+ await this.deliveryStore.markDelivered(mode, notification.filePath, receiptFilePath, {
328
+ dedupeKey: notification.dedupeKey || null
329
+ });
330
+ if (notification.stage === "handoff") {
331
+ await this.notificationStore.updateNotification(notification.filePath, {
332
+ status: "delivered",
333
+ handoffState: "delivered",
334
+ lastReceiptFilePath: receiptFilePath,
335
+ updatedAt: new Date().toISOString()
336
+ });
337
+ }
338
+
339
+ results.push({
340
+ status: "delivered",
341
+ jobId: notification.jobId,
342
+ stage: notification.stage,
343
+ adapter: adapterId,
344
+ receiptFilePath
345
+ });
346
+ } catch (error) {
347
+ const failureClass = classifyDeliveryFailure(error.message);
348
+ const receipt = {
349
+ relatedNotificationId: notification.id || null,
350
+ timestamp: new Date().toISOString(),
351
+ mode,
352
+ notificationFilePath: notification.filePath,
353
+ jobId: notification.jobId,
354
+ stage: notification.stage,
355
+ target: notification.target || null,
356
+ deliveryProfile: notification.deliveryProfile || null,
357
+ adapter: null,
358
+ delivery: {
359
+ status: failureClass === "uncertain" ? "delivery_uncertain" : "delivery_failed",
360
+ error: error.message
361
+ }
362
+ };
363
+ const receiptFilePath = await this.deliveryStore.saveReceipt(notification.jobId, receipt);
364
+ const retryEligible = runtime.runtime?.delivery?.retryOnFailure ?? false;
365
+ if (failureClass === "uncertain") {
366
+ await this.deliveryStore.markUncertain(mode, notification.filePath, receiptFilePath, {
367
+ dedupeKey: notification.dedupeKey || null,
368
+ error: error.message
369
+ });
370
+ } else {
371
+ await this.deliveryStore.markFailed(mode, notification.filePath, receiptFilePath, {
372
+ dedupeKey: notification.dedupeKey || null,
373
+ error: error.message,
374
+ retryEligible
375
+ });
376
+ }
377
+ if (notification.stage === "handoff") {
378
+ await this.notificationStore.updateNotification(notification.filePath, {
379
+ status: failureClass === "uncertain" ? "uncertain" : "blocked",
380
+ handoffState: failureClass === "uncertain" ? "blocked" : "blocked",
381
+ blockedReason: error.message,
382
+ deliveryUncertain: failureClass === "uncertain",
383
+ updatedAt: new Date().toISOString()
384
+ });
385
+ }
386
+
387
+ const notifyOnFailure = runtime.runtime?.delivery?.notifyOnFailure ?? true;
388
+ if (failureClass !== "uncertain" && !retryEligible && notifyOnFailure) {
389
+ const resolvedTarget = this.resolveNotificationTarget(notification, runtime);
390
+ const escalationRecord = {
391
+ event: "delivery_failure",
392
+ profile: notification.deliveryProfile || resolvedTarget.profileName || null,
393
+ target: resolvedTarget.target || notification.target || null,
394
+ error: error.message,
395
+ escalated: true
396
+ };
397
+ await this.notificationStore.saveNotification(notification.jobId, {
398
+ timestamp: new Date().toISOString(),
399
+ jobId: notification.jobId,
400
+ stage: "delivery_failure_escalation",
401
+ status: "ready",
402
+ signal: "delivery_failure",
403
+ deliveryProfile: runtime.runtime?.followUps?.escalationDeliveryProfile || runtime.delivery?.defaultInteractiveProfile || null,
404
+ target: { mode: "same_thread" },
405
+ message: `Delivery failed for ${notification.jobId} (stage: ${notification.stage}): ${error.message}`,
406
+ sourceNotificationFilePath: notification.filePath,
407
+ escalationRecord
408
+ });
409
+ }
410
+
411
+ results.push({
412
+ status: failureClass === "uncertain" ? "delivery_uncertain" : "error",
413
+ jobId: notification.jobId,
414
+ stage: notification.stage,
415
+ error: error.message,
416
+ receiptFilePath,
417
+ escalated: failureClass !== "uncertain" && !retryEligible && (runtime.runtime?.delivery?.notifyOnFailure ?? true)
418
+ });
419
+ }
420
+ }
421
+
422
+ return {
423
+ mode,
424
+ pendingCount: pending.length,
425
+ results
426
+ };
427
+ }
428
+
429
+ async resolveProfileConfig(profileName, profileConfig = {}) {
430
+ if (!profileConfig || !["telegram", "openclaw_cli"].includes(profileConfig.adapter)) {
431
+ return profileConfig;
432
+ }
433
+
434
+ if (profileConfig.channel && profileConfig.channel !== "telegram") {
435
+ return profileConfig;
436
+ }
437
+
438
+ const liveConfig = await this.bridge.loadConfig();
439
+ const accountId = profileConfig.accountId || "default";
440
+ const liveAccount = liveConfig.channels?.telegram?.accounts?.[accountId] || {};
441
+ const directIds = Object.keys(liveAccount.direct || {});
442
+ const allowFrom = liveAccount.allowFrom || liveConfig.channels?.telegram?.allowFrom || [];
443
+ const fallbackChatId = directIds[0] || String(allowFrom[0] || "").replace(/^tg:/, "") || null;
444
+
445
+ return {
446
+ ...profileConfig,
447
+ accountId,
448
+ channel: profileConfig.channel || "telegram",
449
+ chatId: profileConfig.chatId || fallbackChatId,
450
+ botTokenEnv: profileConfig.botTokenEnv || liveAccount.botToken?.id || null
451
+ };
452
+ }
453
+
454
+ async listPendingHandoffs(limit = 10) {
455
+ const notifications = await this.notificationStore.listRecent(limit * 4);
456
+ return notifications
457
+ .filter((item) => item.stage === "handoff" && normalizeHandoffState(item) === "pending")
458
+ .slice(0, limit);
459
+ }
460
+
461
+ async listPendingFollowUps(limit = 10) {
462
+ const notifications = await this.notificationStore.listRecent(limit * 4);
463
+ return notifications
464
+ .filter((item) => item.stage === "follow_up" && normalizeFollowUpState(item) === "pending")
465
+ .slice(0, limit);
466
+ }
467
+
468
+ async consumeFollowUp(notificationFilePath, options = {}) {
469
+ const notification = await this.notificationStore.getNotification(notificationFilePath);
470
+ if (notification) {
471
+ const injectionScan = scanFollowUpPayload(notification);
472
+ if (injectionScan?.flagged) {
473
+ console.warn(`[Nemoris] security: Follow-up payload for job "${notification.jobId}" contains potential injection patterns: ${injectionScan.patterns.join(", ")}`);
474
+ }
475
+ }
476
+ return this.notifier.consumeFollowUp(notificationFilePath, options);
477
+ }
478
+
479
+ async chooseHandoffPeer(notificationFilePath, peerId, options = {}) {
480
+ const runtime = await this.loader.loadAll();
481
+ const notification = await this.notificationStore.getNotification(notificationFilePath);
482
+ if (!notification) {
483
+ throw new Error(`Notification not found: ${notificationFilePath}`);
484
+ }
485
+ if (notification.stage !== "handoff") {
486
+ throw new Error("Only handoff notifications can be assigned to a peer.");
487
+ }
488
+
489
+ const peerRegistry = new PeerRegistry(runtime.peers);
490
+ const resolved = peerRegistry.resolveTarget({
491
+ mode: "peer_agent",
492
+ peerId
493
+ });
494
+ const profileName = options.deliveryProfile || notification.deliveryProfile || "peer_preview";
495
+ const profile = this.resolveProfileEntry(runtime.delivery, profileName);
496
+ if (!profile) {
497
+ throw new Error(`Unknown delivery profile ${profileName}`);
498
+ }
499
+
500
+ const updated = await this.notificationStore.updateNotification(notificationFilePath, {
501
+ status: "ready",
502
+ handoffState: "chosen",
503
+ target: {
504
+ mode: "peer_agent",
505
+ peerId: resolved.peerId,
506
+ sessionKey: resolved.sessionKey,
507
+ deliveryProfile: profileName
508
+ },
509
+ deliveryProfile: profileName,
510
+ chosenPeer: {
511
+ peerId: resolved.peerId,
512
+ label: resolved.label,
513
+ sessionKey: resolved.sessionKey
514
+ },
515
+ chosenBy: "manual_peer",
516
+ chosenAt: new Date().toISOString(),
517
+ blockedReason: null
518
+ });
519
+
520
+ return updated;
521
+ }
522
+
523
+ async chooseTopSuggestedPeer(notificationFilePath, options = {}) {
524
+ const notification = await this.notificationStore.getNotification(notificationFilePath);
525
+ if (!notification) {
526
+ throw new Error(`Notification not found: ${notificationFilePath}`);
527
+ }
528
+ if (notification.stage !== "handoff") {
529
+ throw new Error("Only handoff notifications can promote the top suggested peer.");
530
+ }
531
+ if (notification.status !== "awaiting_choice") {
532
+ throw new Error("Top-suggestion promotion only works for handoffs awaiting choice.");
533
+ }
534
+
535
+ const suggestions = notification.suggestedPeers || [];
536
+ if (!suggestions.length) {
537
+ throw new Error("No suggested peers are available for this handoff.");
538
+ }
539
+
540
+ const topSuggestion = suggestions[0];
541
+ const updated = await this.chooseHandoffPeer(notificationFilePath, topSuggestion.peerId, options);
542
+ return this.notificationStore.updateNotification(updated.filePath, {
543
+ chosenBy: "top_suggestion",
544
+ chosenSuggestionRank: 1,
545
+ chosenSuggestionReasons: topSuggestion.reasons || [],
546
+ chosenSuggestionScore: topSuggestion.score ?? null
547
+ });
548
+ }
549
+
550
+ async preflightHandoff(notification, runtime) {
551
+ if (notification.stage !== "handoff") {
552
+ return { ok: true };
553
+ }
554
+ if (!notification.target?.mode) {
555
+ return { ok: false, reason: "handoff_target_missing" };
556
+ }
557
+ if (notification.target.mode !== "peer_agent") {
558
+ return { ok: false, reason: `handoff_target_mode_not_allowed:${notification.target.mode}` };
559
+ }
560
+ const profileName = notification.deliveryProfile || runtime.delivery?.defaultPeerProfile || runtime.delivery?.defaultInteractiveProfile;
561
+ const profile = this.resolveProfileEntry(runtime.delivery, profileName);
562
+ if (!profile) {
563
+ return { ok: false, reason: "handoff_peer_delivery_profile_invalid" };
564
+ }
565
+ try {
566
+ const resolved = new PeerRegistry(runtime.peers).resolveTarget(notification.target);
567
+ if (!resolved.sessionKey) {
568
+ return { ok: false, reason: "handoff_peer_route_unresolvable" };
569
+ }
570
+ const readiness = await this.peerProbe.probe(runtime, resolved.peerId);
571
+ if (!readiness.deliveryProfileValid) {
572
+ return { ok: false, reason: "handoff_peer_delivery_profile_invalid" };
573
+ }
574
+ if (!readiness.routeResolvable) {
575
+ return { ok: false, reason: "handoff_peer_route_unresolvable" };
576
+ }
577
+ if (!readiness.targetReachable && profile.dryRun !== true) {
578
+ return { ok: false, reason: "handoff_peer_not_ready" };
579
+ }
580
+ return { ok: true };
581
+ } catch (error) {
582
+ return { ok: false, reason: error.message };
583
+ }
584
+ }
585
+
586
+ async sweepPendingHandoffs(options = {}) {
587
+ const runtime = await this.loader.loadAll();
588
+ const config = runtime.runtime?.handoffs || {};
589
+ const timeoutMinutes = Number(options.timeoutMinutes ?? config.pendingTimeoutMinutes ?? 120);
590
+ const escalateOnExpiry = options.escalateOnExpiry ?? config.escalateOnExpiry ?? true;
591
+ const escalationDeliveryProfile =
592
+ options.escalationDeliveryProfile ||
593
+ config.escalationDeliveryProfile ||
594
+ runtime.delivery?.defaultInteractiveProfile ||
595
+ null;
596
+ const now = new Date(options.now || Date.now());
597
+ const cutoff = now.getTime() - timeoutMinutes * 60 * 1000;
598
+ const notifications = await this.notificationStore.listAll();
599
+ const pending = notifications.filter((item) => item.stage === "handoff" && normalizeHandoffState(item) === "pending");
600
+ const results = [];
601
+
602
+ for (const notification of pending) {
603
+ const ts = new Date(notification.timestamp || 0).getTime();
604
+ if (!ts || ts >= cutoff) continue;
605
+ const expired = await this.notificationStore.updateNotification(notification.filePath, {
606
+ status: "expired",
607
+ handoffState: "expired",
608
+ expiredAt: now.toISOString(),
609
+ updatedAt: now.toISOString()
610
+ });
611
+ let escalationFilePath = null;
612
+ if (escalateOnExpiry) {
613
+ escalationFilePath = await this.notificationStore.saveNotification(notification.jobId, {
614
+ timestamp: now.toISOString(),
615
+ jobId: notification.jobId,
616
+ stage: "handoff_escalation",
617
+ status: "ready",
618
+ handoffState: "escalated",
619
+ deliveryProfile: escalationDeliveryProfile,
620
+ signal: `${notification.signal || "handoff"}_expired`,
621
+ target: { mode: "same_thread" },
622
+ message: `Handoff for ${notification.jobId} expired without operator choice.`,
623
+ sourceNotificationId: notification.id || null,
624
+ sourceNotificationFilePath: notification.filePath
625
+ });
626
+ await this.notificationStore.updateNotification(notification.filePath, {
627
+ handoffState: "escalated",
628
+ escalationNotificationFilePath: escalationFilePath,
629
+ updatedAt: now.toISOString()
630
+ });
631
+ }
632
+ results.push({
633
+ jobId: notification.jobId,
634
+ notificationFilePath: expired.filePath,
635
+ status: escalateOnExpiry ? "escalated" : "expired",
636
+ escalationNotificationFilePath: escalationFilePath
637
+ });
638
+ }
639
+
640
+ return {
641
+ timestamp: now.toISOString(),
642
+ pendingCount: pending.length,
643
+ expiredCount: results.length,
644
+ results
645
+ };
646
+ }
647
+
648
+ computeFollowUpDepth(notification, allNotifications) {
649
+ let depth = 0;
650
+ let current = notification;
651
+ const seen = new Set();
652
+ while (current?.sourceFollowUpFilePath) {
653
+ if (seen.has(current.sourceFollowUpFilePath)) break;
654
+ seen.add(current.sourceFollowUpFilePath);
655
+ depth += 1;
656
+ current = allNotifications.find((n) => n.filePath === current.sourceFollowUpFilePath) || null;
657
+ }
658
+ return depth;
659
+ }
660
+
661
+ async sweepPendingFollowUps(options = {}) {
662
+ const runtime = await this.loader.loadAll();
663
+ const config = runtime.runtime?.followUps || {};
664
+ const timeoutMinutes = Number(options.timeoutMinutes ?? config.pendingTimeoutMinutes ?? 120);
665
+ const maxFollowUpDepth = Number(options.maxFollowUpDepth ?? config.maxFollowUpDepth ?? 5);
666
+ const escalateOnExpiry = options.escalateOnExpiry ?? config.escalateOnExpiry ?? true;
667
+ const escalationDeliveryProfile =
668
+ options.escalationDeliveryProfile ||
669
+ config.escalationDeliveryProfile ||
670
+ runtime.delivery?.defaultInteractiveProfile ||
671
+ null;
672
+ const now = new Date(options.now || Date.now());
673
+ const cutoff = now.getTime() - timeoutMinutes * 60 * 1000;
674
+ const notifications = await this.notificationStore.listAll();
675
+ const pending = notifications.filter((item) => item.stage === "follow_up" && normalizeFollowUpState(item) === "pending");
676
+ const results = [];
677
+
678
+ for (const notification of pending) {
679
+ const injectionScan = scanFollowUpPayload(notification);
680
+ if (injectionScan?.flagged) {
681
+ console.warn(`[Nemoris] security: Follow-up payload for job "${notification.jobId}" contains potential injection patterns: ${injectionScan.patterns.join(", ")}`);
682
+ }
683
+
684
+ const depth = this.computeFollowUpDepth(notification, notifications);
685
+ if (depth >= maxFollowUpDepth) {
686
+ console.warn(`Follow-up chain halted: max depth ${depth} exceeded for job ${notification.jobId}`);
687
+ await this.notificationStore.updateNotification(notification.filePath, {
688
+ status: "expired",
689
+ followUpState: "depth_exceeded",
690
+ yieldState: notification.yieldState || "pending",
691
+ expiredAt: now.toISOString(),
692
+ updatedAt: now.toISOString(),
693
+ depthExceeded: true,
694
+ chainDepth: depth,
695
+ maxFollowUpDepth
696
+ });
697
+ results.push({
698
+ jobId: notification.jobId,
699
+ notificationFilePath: notification.filePath,
700
+ status: "depth_exceeded",
701
+ chainDepth: depth,
702
+ maxFollowUpDepth
703
+ });
704
+ continue;
705
+ }
706
+
707
+ // Scoped idempotency check
708
+ const followUpIdentifier = notification.followUpId || notification.sourceFollowUpId;
709
+ if (this.stateStore && notification.parentIdempotencyKey && followUpIdentifier) {
710
+ const scopedKey = createHash("sha256")
711
+ .update(`${notification.parentIdempotencyKey}:${followUpIdentifier}`)
712
+ .digest("hex")
713
+ .slice(0, 32);
714
+
715
+ notification._scopedKey = scopedKey;
716
+
717
+ const existing = this.stateStore.getFollowUpCompletion(scopedKey);
718
+ if (existing?.status === "succeeded") {
719
+ console.log(`[Nemoris] follow-up ${followUpIdentifier} already completed in this slot, skipping`);
720
+ results.push({
721
+ jobId: notification.jobId,
722
+ notificationFilePath: notification.filePath,
723
+ status: "idempotent_skip",
724
+ scopedKey
725
+ });
726
+ continue;
727
+ }
728
+ if (existing?.status === "failed") {
729
+ this.stateStore.clearFollowUpCompletion(scopedKey);
730
+ }
731
+ }
732
+
733
+ const ts = new Date(notification.timestamp || 0).getTime();
734
+ if (!ts || ts >= cutoff) continue;
735
+ const expired = await this.notificationStore.updateNotification(notification.filePath, {
736
+ status: "expired",
737
+ followUpState: "expired",
738
+ yieldState: notification.yieldState || "pending",
739
+ expiredAt: now.toISOString(),
740
+ updatedAt: now.toISOString()
741
+ });
742
+
743
+ let escalationFilePath = null;
744
+ if (escalateOnExpiry) {
745
+ escalationFilePath = await this.notificationStore.saveNotification(notification.jobId, {
746
+ id: null,
747
+ timestamp: now.toISOString(),
748
+ jobId: notification.jobId,
749
+ stage: "follow_up_escalation",
750
+ status: "ready",
751
+ followUpState: "escalated",
752
+ deliveryProfile: escalationDeliveryProfile,
753
+ signal: `${notification.signal || "follow_up"}_expired`,
754
+ target: { mode: "same_thread" },
755
+ message: `Follow-up for ${notification.jobId} expired before it was consumed.`,
756
+ sourceNotificationId: notification.id || null,
757
+ sourceNotificationFilePath: notification.filePath,
758
+ sourceFollowUpId: notification.followUpId || null
759
+ });
760
+ await this.notificationStore.updateNotification(notification.filePath, {
761
+ followUpState: "escalated",
762
+ escalationNotificationFilePath: escalationFilePath,
763
+ updatedAt: now.toISOString()
764
+ });
765
+ }
766
+
767
+ results.push({
768
+ jobId: notification.jobId,
769
+ notificationFilePath: expired.filePath,
770
+ status: escalateOnExpiry ? "escalated" : "expired",
771
+ escalationNotificationFilePath: escalationFilePath
772
+ });
773
+ }
774
+
775
+ return {
776
+ timestamp: now.toISOString(),
777
+ pendingCount: pending.length,
778
+ expiredCount: results.length,
779
+ results
780
+ };
781
+ }
782
+
783
+ async sendTestDelivery({ profileName, mode = "shadow", message }) {
784
+ const runtime = await this.loader.loadAll();
785
+ const profileConfig = await this.resolveProfileConfig(profileName, this.resolveProfileEntry(runtime.delivery, profileName) || {});
786
+ if (!profileConfig.adapter) {
787
+ throw new Error(`Unknown delivery profile ${profileName}`);
788
+ }
789
+
790
+ const adapterId = mode === "shadow" ? "shadow" : profileConfig.adapter;
791
+ const adapter = this.createAdapter(adapterId);
792
+ return adapter.deliver(
793
+ {
794
+ jobId: "delivery-test",
795
+ stage: "test",
796
+ target: {
797
+ mode: profileConfig.channel || "same_thread"
798
+ },
799
+ message
800
+ },
801
+ {
802
+ profileName,
803
+ profileConfig
804
+ }
805
+ );
806
+ }
807
+ }