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.
- package/.env.example +49 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/SECURITY.md +119 -0
- package/bin/nemoris +46 -0
- package/config/agents/agent.toml.example +28 -0
- package/config/agents/default.toml +22 -0
- package/config/agents/orchestrator.toml +18 -0
- package/config/delivery.toml +73 -0
- package/config/embeddings.toml +5 -0
- package/config/identity/default-purpose.md +1 -0
- package/config/identity/default-soul.md +3 -0
- package/config/identity/orchestrator-purpose.md +1 -0
- package/config/identity/orchestrator-soul.md +1 -0
- package/config/improvement-targets.toml +15 -0
- package/config/jobs/heartbeat-check.toml +30 -0
- package/config/jobs/memory-rollup.toml +46 -0
- package/config/jobs/workspace-health.toml +63 -0
- package/config/mcp.toml +16 -0
- package/config/output-contracts.toml +17 -0
- package/config/peers.toml +32 -0
- package/config/peers.toml.example +32 -0
- package/config/policies/memory-default.toml +10 -0
- package/config/policies/memory-heartbeat.toml +5 -0
- package/config/policies/memory-ops.toml +10 -0
- package/config/policies/tools-heartbeat-minimal.toml +8 -0
- package/config/policies/tools-interactive-safe.toml +8 -0
- package/config/policies/tools-ops-bounded.toml +8 -0
- package/config/policies/tools-orchestrator.toml +7 -0
- package/config/providers/anthropic.toml +15 -0
- package/config/providers/ollama.toml +5 -0
- package/config/providers/openai-codex.toml +9 -0
- package/config/providers/openrouter.toml +5 -0
- package/config/router.toml +22 -0
- package/config/runtime.toml +114 -0
- package/config/skills/self-improvement.toml +15 -0
- package/config/skills/telegram-onboarding-spec.md +240 -0
- package/config/skills/workspace-monitor.toml +15 -0
- package/config/task-router.toml +42 -0
- package/install.sh +50 -0
- package/package.json +90 -0
- package/src/auth/auth-profiles.js +169 -0
- package/src/auth/openai-codex-oauth.js +285 -0
- package/src/battle.js +449 -0
- package/src/cli/help.js +265 -0
- package/src/cli/output-filter.js +49 -0
- package/src/cli/runtime-control.js +704 -0
- package/src/cli-main.js +2763 -0
- package/src/cli.js +78 -0
- package/src/config/loader.js +332 -0
- package/src/config/schema-validator.js +214 -0
- package/src/config/toml-lite.js +8 -0
- package/src/daemon/action-handlers.js +71 -0
- package/src/daemon/healing-tick.js +87 -0
- package/src/daemon/health-probes.js +90 -0
- package/src/daemon/notifier.js +57 -0
- package/src/daemon/nurse.js +218 -0
- package/src/daemon/repair-log.js +106 -0
- package/src/daemon/rule-staging.js +90 -0
- package/src/daemon/rules.js +29 -0
- package/src/daemon/telegram-commands.js +54 -0
- package/src/daemon/updater.js +85 -0
- package/src/jobs/job-runner.js +78 -0
- package/src/mcp/consumer.js +129 -0
- package/src/memory/active-recall.js +171 -0
- package/src/memory/backend-manager.js +97 -0
- package/src/memory/backends/file-backend.js +38 -0
- package/src/memory/backends/qmd-backend.js +219 -0
- package/src/memory/embedding-guards.js +24 -0
- package/src/memory/embedding-index.js +118 -0
- package/src/memory/embedding-service.js +179 -0
- package/src/memory/file-index.js +177 -0
- package/src/memory/memory-signature.js +5 -0
- package/src/memory/memory-store.js +648 -0
- package/src/memory/retrieval-planner.js +66 -0
- package/src/memory/scoring.js +145 -0
- package/src/memory/simhash.js +78 -0
- package/src/memory/sqlite-active-store.js +824 -0
- package/src/memory/write-policy.js +36 -0
- package/src/onboarding/aliases.js +33 -0
- package/src/onboarding/auth/api-key.js +224 -0
- package/src/onboarding/auth/ollama-detect.js +42 -0
- package/src/onboarding/clack-prompter.js +77 -0
- package/src/onboarding/doctor.js +530 -0
- package/src/onboarding/lock.js +42 -0
- package/src/onboarding/model-catalog.js +344 -0
- package/src/onboarding/phases/auth.js +589 -0
- package/src/onboarding/phases/build.js +130 -0
- package/src/onboarding/phases/choose.js +82 -0
- package/src/onboarding/phases/detect.js +98 -0
- package/src/onboarding/phases/hatch.js +216 -0
- package/src/onboarding/phases/identity.js +79 -0
- package/src/onboarding/phases/ollama.js +345 -0
- package/src/onboarding/phases/scaffold.js +99 -0
- package/src/onboarding/phases/telegram.js +377 -0
- package/src/onboarding/phases/validate.js +204 -0
- package/src/onboarding/phases/verify.js +206 -0
- package/src/onboarding/platform.js +482 -0
- package/src/onboarding/status-bar.js +95 -0
- package/src/onboarding/templates.js +794 -0
- package/src/onboarding/toml-writer.js +38 -0
- package/src/onboarding/tui.js +250 -0
- package/src/onboarding/uninstall.js +153 -0
- package/src/onboarding/wizard.js +499 -0
- package/src/providers/anthropic.js +168 -0
- package/src/providers/base.js +247 -0
- package/src/providers/circuit-breaker.js +136 -0
- package/src/providers/ollama.js +163 -0
- package/src/providers/openai-codex.js +149 -0
- package/src/providers/openrouter.js +136 -0
- package/src/providers/registry.js +36 -0
- package/src/providers/router.js +16 -0
- package/src/runtime/bootstrap-cache.js +47 -0
- package/src/runtime/capabilities-prompt.js +25 -0
- package/src/runtime/completion-ping.js +99 -0
- package/src/runtime/config-validator.js +121 -0
- package/src/runtime/context-ledger.js +360 -0
- package/src/runtime/cutover-readiness.js +42 -0
- package/src/runtime/daemon.js +729 -0
- package/src/runtime/delivery-ack.js +195 -0
- package/src/runtime/delivery-adapters/local-file.js +41 -0
- package/src/runtime/delivery-adapters/openclaw-cli.js +94 -0
- package/src/runtime/delivery-adapters/openclaw-peer.js +98 -0
- package/src/runtime/delivery-adapters/shadow.js +13 -0
- package/src/runtime/delivery-adapters/standalone-http.js +98 -0
- package/src/runtime/delivery-adapters/telegram.js +104 -0
- package/src/runtime/delivery-adapters/tui.js +128 -0
- package/src/runtime/delivery-manager.js +807 -0
- package/src/runtime/delivery-store.js +168 -0
- package/src/runtime/dependency-health.js +118 -0
- package/src/runtime/envelope.js +114 -0
- package/src/runtime/evaluation.js +1089 -0
- package/src/runtime/exec-approvals.js +216 -0
- package/src/runtime/executor.js +500 -0
- package/src/runtime/failure-ping.js +67 -0
- package/src/runtime/flows.js +83 -0
- package/src/runtime/guards.js +45 -0
- package/src/runtime/handoff.js +51 -0
- package/src/runtime/identity-cache.js +28 -0
- package/src/runtime/improvement-engine.js +109 -0
- package/src/runtime/improvement-harness.js +581 -0
- package/src/runtime/input-sanitiser.js +72 -0
- package/src/runtime/interaction-contract.js +347 -0
- package/src/runtime/lane-readiness.js +226 -0
- package/src/runtime/migration.js +323 -0
- package/src/runtime/model-resolution.js +78 -0
- package/src/runtime/network.js +64 -0
- package/src/runtime/notification-store.js +97 -0
- package/src/runtime/notifier.js +256 -0
- package/src/runtime/orchestrator.js +53 -0
- package/src/runtime/orphan-reaper.js +41 -0
- package/src/runtime/output-contract-schema.js +139 -0
- package/src/runtime/output-contract-validator.js +439 -0
- package/src/runtime/peer-readiness.js +69 -0
- package/src/runtime/peer-registry.js +133 -0
- package/src/runtime/pilot-status.js +108 -0
- package/src/runtime/prompt-builder.js +261 -0
- package/src/runtime/provider-attempt.js +582 -0
- package/src/runtime/report-fallback.js +71 -0
- package/src/runtime/result-normalizer.js +183 -0
- package/src/runtime/retention.js +74 -0
- package/src/runtime/review.js +244 -0
- package/src/runtime/route-job.js +15 -0
- package/src/runtime/run-store.js +38 -0
- package/src/runtime/schedule.js +88 -0
- package/src/runtime/scheduler-state.js +434 -0
- package/src/runtime/scheduler.js +656 -0
- package/src/runtime/session-compactor.js +182 -0
- package/src/runtime/session-search.js +155 -0
- package/src/runtime/slack-inbound.js +249 -0
- package/src/runtime/ssrf.js +102 -0
- package/src/runtime/status-aggregator.js +330 -0
- package/src/runtime/task-contract.js +140 -0
- package/src/runtime/task-packet.js +107 -0
- package/src/runtime/task-router.js +140 -0
- package/src/runtime/telegram-inbound.js +1565 -0
- package/src/runtime/token-counter.js +134 -0
- package/src/runtime/token-estimator.js +59 -0
- package/src/runtime/tool-loop.js +200 -0
- package/src/runtime/transport-server.js +311 -0
- package/src/runtime/tui-server.js +411 -0
- package/src/runtime/ulid.js +44 -0
- package/src/security/ssrf-check.js +197 -0
- package/src/setup.js +369 -0
- package/src/shadow/bridge.js +303 -0
- package/src/skills/loader.js +84 -0
- package/src/tools/catalog.json +49 -0
- package/src/tools/cli-delegate.js +44 -0
- package/src/tools/mcp-client.js +106 -0
- package/src/tools/micro/cancel-task.js +6 -0
- package/src/tools/micro/complete-task.js +6 -0
- package/src/tools/micro/fail-task.js +6 -0
- package/src/tools/micro/http-fetch.js +74 -0
- package/src/tools/micro/index.js +36 -0
- package/src/tools/micro/lcm-recall.js +60 -0
- package/src/tools/micro/list-dir.js +17 -0
- package/src/tools/micro/list-skills.js +46 -0
- package/src/tools/micro/load-skill.js +38 -0
- package/src/tools/micro/memory-search.js +45 -0
- package/src/tools/micro/read-file.js +11 -0
- package/src/tools/micro/session-search.js +54 -0
- package/src/tools/micro/shell-exec.js +43 -0
- package/src/tools/micro/trigger-job.js +79 -0
- package/src/tools/micro/web-search.js +58 -0
- package/src/tools/micro/workspace-paths.js +39 -0
- package/src/tools/micro/write-file.js +14 -0
- package/src/tools/micro/write-memory.js +41 -0
- package/src/tools/registry.js +348 -0
- package/src/tools/tool-result-contract.js +36 -0
- package/src/tui/chat.js +835 -0
- package/src/tui/renderer.js +175 -0
- package/src/tui/socket-client.js +217 -0
- package/src/utils/canonical-json.js +29 -0
- package/src/utils/compaction.js +30 -0
- package/src/utils/env-loader.js +5 -0
- package/src/utils/errors.js +80 -0
- package/src/utils/fs.js +101 -0
- package/src/utils/ids.js +5 -0
- package/src/utils/model-context-limits.js +30 -0
- package/src/utils/token-budget.js +74 -0
- package/src/utils/usage-cost.js +25 -0
- package/src/utils/usage-metrics.js +14 -0
- 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
|
+
}
|