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,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
+ }