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.
- package/.env.example +49 -49
- package/LICENSE +21 -21
- package/README.md +209 -209
- package/SECURITY.md +59 -119
- package/bin/nemoris +46 -46
- package/config/agents/agent.toml.example +28 -28
- package/config/agents/content.toml +23 -0
- package/config/agents/default.toml +22 -22
- package/config/agents/heartbeat.toml +35 -0
- package/config/agents/iris.toml +23 -0
- package/config/agents/lab.toml +23 -0
- package/config/agents/main.toml +45 -0
- package/config/agents/nemo.toml +21 -0
- package/config/agents/ops.toml +38 -0
- package/config/agents/orchestrator.toml +18 -18
- package/config/agents/revenue.toml +23 -0
- package/config/agents/testyboo.toml +19 -0
- package/config/delivery.toml +73 -73
- package/config/embeddings.toml +5 -5
- package/config/identity/content-purpose.md +11 -0
- package/config/identity/content-soul.md +45 -0
- package/config/identity/default-purpose.md +1 -1
- package/config/identity/default-soul.md +3 -3
- package/config/identity/heartbeat-purpose.md +9 -0
- package/config/identity/heartbeat-soul.md +16 -0
- package/config/identity/iris-purpose.md +17 -0
- package/config/identity/iris-soul.md +68 -0
- package/config/identity/lab-purpose.md +10 -0
- package/config/identity/lab-soul.md +38 -0
- package/config/identity/main-purpose.md +17 -0
- package/config/identity/main-soul.md +66 -0
- package/config/identity/main-user.md +22 -0
- package/config/identity/ops-purpose.md +9 -0
- package/config/identity/ops-soul.md +16 -0
- package/config/identity/orchestrator-purpose.md +1 -1
- package/config/identity/orchestrator-soul.md +1 -1
- package/config/identity/revenue-purpose.md +9 -0
- package/config/identity/revenue-soul.md +41 -0
- package/config/identity/testyboo-purpose.md +13 -0
- package/config/identity/testyboo-soul.md +20 -0
- package/config/improvement-targets.toml +15 -15
- package/config/jobs/heartbeat-check.toml +30 -30
- package/config/jobs/memory-rollup.toml +46 -46
- package/config/jobs/workspace-health.toml +63 -63
- package/config/mcp.toml +16 -16
- package/config/output-contracts.toml +17 -17
- package/config/peers.toml +32 -32
- package/config/peers.toml.example +32 -32
- package/config/policies/memory-default.toml +10 -10
- package/config/policies/memory-heartbeat.toml +5 -5
- package/config/policies/memory-ops.toml +10 -10
- package/config/policies/tools-heartbeat-minimal.toml +8 -8
- package/config/policies/tools-interactive-safe.toml +8 -8
- package/config/policies/tools-ops-bounded.toml +8 -8
- package/config/policies/tools-orchestrator.toml +7 -7
- package/config/providers/anthropic.toml +15 -15
- package/config/providers/ollama.toml +5 -5
- package/config/providers/openai-codex.toml +9 -9
- package/config/providers/openrouter.toml +5 -5
- package/config/router.toml +22 -22
- package/config/runtime.toml +114 -114
- package/config/skills/self-improvement.toml +15 -15
- package/config/skills/telegram-onboarding-spec.md +240 -240
- package/config/skills/workspace-monitor.toml +15 -15
- package/config/task-router.toml +42 -42
- package/install.sh +50 -50
- package/package.json +91 -90
- package/src/auth/auth-profiles.js +169 -169
- package/src/auth/openai-codex-oauth.js +285 -285
- package/src/battle.js +449 -449
- package/src/cli/help.js +265 -265
- package/src/cli/output-filter.js +49 -49
- package/src/cli/runtime-control.js +704 -704
- package/src/cli-main.js +2763 -2763
- package/src/cli.js +78 -78
- package/src/config/loader.js +332 -332
- package/src/config/schema-validator.js +214 -214
- package/src/config/toml-lite.js +8 -8
- package/src/daemon/action-handlers.js +71 -71
- package/src/daemon/healing-tick.js +87 -87
- package/src/daemon/health-probes.js +90 -90
- package/src/daemon/notifier.js +57 -57
- package/src/daemon/nurse.js +218 -218
- package/src/daemon/repair-log.js +106 -106
- package/src/daemon/rule-staging.js +90 -90
- package/src/daemon/rules.js +29 -29
- package/src/daemon/telegram-commands.js +54 -54
- package/src/daemon/updater.js +85 -85
- package/src/jobs/job-runner.js +78 -78
- package/src/mcp/consumer.js +129 -129
- package/src/memory/active-recall.js +171 -171
- package/src/memory/backend-manager.js +97 -97
- package/src/memory/backends/file-backend.js +38 -38
- package/src/memory/backends/qmd-backend.js +219 -219
- package/src/memory/embedding-guards.js +24 -24
- package/src/memory/embedding-index.js +118 -118
- package/src/memory/embedding-service.js +179 -179
- package/src/memory/file-index.js +177 -177
- package/src/memory/memory-signature.js +5 -5
- package/src/memory/memory-store.js +648 -648
- package/src/memory/retrieval-planner.js +66 -66
- package/src/memory/scoring.js +145 -145
- package/src/memory/simhash.js +78 -78
- package/src/memory/sqlite-active-store.js +824 -824
- package/src/memory/write-policy.js +36 -36
- package/src/onboarding/aliases.js +33 -33
- package/src/onboarding/auth/api-key.js +224 -224
- package/src/onboarding/auth/ollama-detect.js +42 -42
- package/src/onboarding/clack-prompter.js +77 -77
- package/src/onboarding/doctor.js +530 -530
- package/src/onboarding/lock.js +42 -42
- package/src/onboarding/model-catalog.js +344 -344
- package/src/onboarding/phases/auth.js +576 -589
- package/src/onboarding/phases/build.js +130 -130
- package/src/onboarding/phases/choose.js +82 -82
- package/src/onboarding/phases/detect.js +98 -98
- package/src/onboarding/phases/hatch.js +216 -216
- package/src/onboarding/phases/identity.js +79 -79
- package/src/onboarding/phases/ollama.js +345 -345
- package/src/onboarding/phases/scaffold.js +99 -99
- package/src/onboarding/phases/telegram.js +377 -377
- package/src/onboarding/phases/validate.js +204 -204
- package/src/onboarding/phases/verify.js +206 -206
- package/src/onboarding/platform.js +482 -482
- package/src/onboarding/status-bar.js +95 -95
- package/src/onboarding/templates.js +794 -794
- package/src/onboarding/toml-writer.js +38 -38
- package/src/onboarding/tui.js +250 -250
- package/src/onboarding/uninstall.js +153 -153
- package/src/onboarding/wizard.js +516 -499
- package/src/providers/anthropic.js +168 -168
- package/src/providers/base.js +247 -247
- package/src/providers/circuit-breaker.js +136 -136
- package/src/providers/ollama.js +163 -163
- package/src/providers/openai-codex.js +149 -149
- package/src/providers/openrouter.js +136 -136
- package/src/providers/registry.js +36 -36
- package/src/providers/router.js +16 -16
- package/src/runtime/bootstrap-cache.js +47 -47
- package/src/runtime/capabilities-prompt.js +25 -25
- package/src/runtime/completion-ping.js +99 -99
- package/src/runtime/config-validator.js +121 -121
- package/src/runtime/context-ledger.js +360 -360
- package/src/runtime/cutover-readiness.js +42 -42
- package/src/runtime/daemon.js +729 -729
- package/src/runtime/delivery-ack.js +195 -195
- package/src/runtime/delivery-adapters/local-file.js +41 -41
- package/src/runtime/delivery-adapters/openclaw-cli.js +94 -94
- package/src/runtime/delivery-adapters/openclaw-peer.js +98 -98
- package/src/runtime/delivery-adapters/shadow.js +13 -13
- package/src/runtime/delivery-adapters/standalone-http.js +98 -98
- package/src/runtime/delivery-adapters/telegram.js +104 -104
- package/src/runtime/delivery-adapters/tui.js +128 -128
- package/src/runtime/delivery-manager.js +807 -807
- package/src/runtime/delivery-store.js +168 -168
- package/src/runtime/dependency-health.js +118 -118
- package/src/runtime/envelope.js +114 -114
- package/src/runtime/evaluation.js +1089 -1089
- package/src/runtime/exec-approvals.js +216 -216
- package/src/runtime/executor.js +500 -500
- package/src/runtime/failure-ping.js +67 -67
- package/src/runtime/flows.js +83 -83
- package/src/runtime/guards.js +45 -45
- package/src/runtime/handoff.js +51 -51
- package/src/runtime/identity-cache.js +28 -28
- package/src/runtime/improvement-engine.js +109 -109
- package/src/runtime/improvement-harness.js +581 -581
- package/src/runtime/input-sanitiser.js +72 -72
- package/src/runtime/interaction-contract.js +347 -347
- package/src/runtime/lane-readiness.js +226 -226
- package/src/runtime/migration.js +323 -323
- package/src/runtime/model-resolution.js +78 -78
- package/src/runtime/network.js +64 -64
- package/src/runtime/notification-store.js +97 -97
- package/src/runtime/notifier.js +256 -256
- package/src/runtime/orchestrator.js +53 -53
- package/src/runtime/orphan-reaper.js +41 -41
- package/src/runtime/output-contract-schema.js +139 -139
- package/src/runtime/output-contract-validator.js +439 -439
- package/src/runtime/peer-readiness.js +69 -69
- package/src/runtime/peer-registry.js +133 -133
- package/src/runtime/pilot-status.js +108 -108
- package/src/runtime/prompt-builder.js +261 -261
- package/src/runtime/provider-attempt.js +582 -582
- package/src/runtime/report-fallback.js +71 -71
- package/src/runtime/result-normalizer.js +183 -183
- package/src/runtime/retention.js +74 -74
- package/src/runtime/review.js +244 -244
- package/src/runtime/route-job.js +15 -15
- package/src/runtime/run-store.js +38 -38
- package/src/runtime/schedule.js +88 -88
- package/src/runtime/scheduler-state.js +434 -434
- package/src/runtime/scheduler.js +656 -656
- package/src/runtime/session-compactor.js +182 -182
- package/src/runtime/session-search.js +155 -155
- package/src/runtime/slack-inbound.js +249 -249
- package/src/runtime/ssrf.js +102 -102
- package/src/runtime/status-aggregator.js +330 -330
- package/src/runtime/task-contract.js +140 -140
- package/src/runtime/task-packet.js +107 -107
- package/src/runtime/task-router.js +140 -140
- package/src/runtime/telegram-inbound.js +1565 -1565
- package/src/runtime/token-counter.js +134 -134
- package/src/runtime/token-estimator.js +59 -59
- package/src/runtime/tool-loop.js +200 -200
- package/src/runtime/transport-server.js +311 -311
- package/src/runtime/tui-server.js +411 -411
- package/src/runtime/ulid.js +44 -44
- package/src/security/ssrf-check.js +197 -197
- package/src/setup.js +369 -369
- package/src/shadow/bridge.js +303 -303
- package/src/skills/loader.js +84 -84
- package/src/tools/catalog.json +49 -49
- package/src/tools/cli-delegate.js +44 -44
- package/src/tools/mcp-client.js +106 -106
- package/src/tools/micro/cancel-task.js +6 -6
- package/src/tools/micro/complete-task.js +6 -6
- package/src/tools/micro/fail-task.js +6 -6
- package/src/tools/micro/http-fetch.js +74 -74
- package/src/tools/micro/index.js +36 -36
- package/src/tools/micro/lcm-recall.js +60 -60
- package/src/tools/micro/list-dir.js +17 -17
- package/src/tools/micro/list-skills.js +46 -46
- package/src/tools/micro/load-skill.js +38 -38
- package/src/tools/micro/memory-search.js +45 -45
- package/src/tools/micro/read-file.js +11 -11
- package/src/tools/micro/session-search.js +54 -54
- package/src/tools/micro/shell-exec.js +43 -43
- package/src/tools/micro/trigger-job.js +79 -79
- package/src/tools/micro/web-search.js +58 -58
- package/src/tools/micro/workspace-paths.js +39 -39
- package/src/tools/micro/write-file.js +14 -14
- package/src/tools/micro/write-memory.js +41 -41
- package/src/tools/registry.js +348 -348
- package/src/tools/tool-result-contract.js +36 -36
- package/src/tui/chat.js +835 -835
- package/src/tui/renderer.js +175 -175
- package/src/tui/socket-client.js +217 -217
- package/src/utils/canonical-json.js +29 -29
- package/src/utils/compaction.js +30 -30
- package/src/utils/env-loader.js +5 -5
- package/src/utils/errors.js +80 -80
- package/src/utils/fs.js +101 -101
- package/src/utils/ids.js +5 -5
- package/src/utils/model-context-limits.js +30 -30
- package/src/utils/token-budget.js +74 -74
- package/src/utils/usage-cost.js +25 -25
- package/src/utils/usage-metrics.js +14 -14
|
@@ -1,311 +1,311 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { ensureDir, listFilesRecursive, readJson, writeJson } from "../utils/fs.js";
|
|
4
|
-
import { buildRetentionPolicy, pruneJsonBuckets } from "./retention.js";
|
|
5
|
-
import { TelegramInboundHandler } from "./telegram-inbound.js";
|
|
6
|
-
import { SlackInboundHandler } from "./slack-inbound.js";
|
|
7
|
-
|
|
8
|
-
function timestampKey() {
|
|
9
|
-
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function sendJson(response, statusCode, body) {
|
|
13
|
-
response.writeHead(statusCode, {
|
|
14
|
-
"content-type": "application/json"
|
|
15
|
-
});
|
|
16
|
-
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function readJsonBody(request, { maxBodyBytes = 1024 * 1024, timeoutMs = 5000 } = {}) {
|
|
20
|
-
const chunks = [];
|
|
21
|
-
let total = 0;
|
|
22
|
-
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Request body timed out")), timeoutMs));
|
|
23
|
-
const reader = (async () => {
|
|
24
|
-
for await (const chunk of request) {
|
|
25
|
-
total += chunk.length;
|
|
26
|
-
if (total > maxBodyBytes) {
|
|
27
|
-
throw new Error("Request body too large");
|
|
28
|
-
}
|
|
29
|
-
chunks.push(chunk);
|
|
30
|
-
}
|
|
31
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
32
|
-
return raw ? JSON.parse(raw) : {};
|
|
33
|
-
})();
|
|
34
|
-
return Promise.race([reader, timeout]);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function readRawBody(request, { maxBodyBytes = 1024 * 1024, timeoutMs = 5000 } = {}) {
|
|
38
|
-
const chunks = [];
|
|
39
|
-
let total = 0;
|
|
40
|
-
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Request body timed out")), timeoutMs));
|
|
41
|
-
const reader = (async () => {
|
|
42
|
-
for await (const chunk of request) {
|
|
43
|
-
total += chunk.length;
|
|
44
|
-
if (total > maxBodyBytes) {
|
|
45
|
-
throw new Error("Request body too large");
|
|
46
|
-
}
|
|
47
|
-
chunks.push(chunk);
|
|
48
|
-
}
|
|
49
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
50
|
-
})();
|
|
51
|
-
return Promise.race([reader, timeout]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export class StandaloneTransportServer {
|
|
55
|
-
constructor({ stateRoot, authToken = null, retention = {}, maxBodyBytes = 1024 * 1024, requestTimeoutMs = 5000, allowedTargetModes = null, rateLimit = {} } = {}) {
|
|
56
|
-
this.stateRoot = stateRoot;
|
|
57
|
-
this.authToken = authToken;
|
|
58
|
-
this.server = null;
|
|
59
|
-
this.shuttingDown = false;
|
|
60
|
-
this.retentionPolicy = buildRetentionPolicy(retention, {
|
|
61
|
-
ttlDays: 7,
|
|
62
|
-
maxFilesPerBucket: 1000
|
|
63
|
-
});
|
|
64
|
-
this.maxBodyBytes = maxBodyBytes;
|
|
65
|
-
this.requestTimeoutMs = requestTimeoutMs;
|
|
66
|
-
this.allowedTargetModes = new Set(allowedTargetModes || ["operator", "same_thread", "peer_agent", "scheduler_log"]);
|
|
67
|
-
this.rateLimit = {
|
|
68
|
-
windowMs: Number(rateLimit.windowMs ?? 60_000),
|
|
69
|
-
maxRequests: Number(rateLimit.maxRequests ?? 120)
|
|
70
|
-
};
|
|
71
|
-
this.rateBuckets = new Map();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
get transportRoot() {
|
|
75
|
-
return path.join(this.stateRoot, "transport");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
attachTelegramInbound({
|
|
79
|
-
stateStore,
|
|
80
|
-
telegramConfig,
|
|
81
|
-
availablePeers = [],
|
|
82
|
-
contextLedger = null,
|
|
83
|
-
agentNames = {},
|
|
84
|
-
routerConfig = null,
|
|
85
|
-
agentConfigs = null,
|
|
86
|
-
}) {
|
|
87
|
-
this.telegramHandler = new TelegramInboundHandler({
|
|
88
|
-
stateStore,
|
|
89
|
-
telegramConfig,
|
|
90
|
-
availablePeers,
|
|
91
|
-
contextLedger,
|
|
92
|
-
agentNames,
|
|
93
|
-
routerConfig,
|
|
94
|
-
agentConfigs,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
attachSlackInbound({ stateStore, slackConfig, availablePeers = [] }) {
|
|
99
|
-
this.slackHandler = new SlackInboundHandler({ stateStore, slackConfig, availablePeers });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async handle(request, response) {
|
|
103
|
-
try {
|
|
104
|
-
if (this.shuttingDown) {
|
|
105
|
-
sendJson(response, 503, {
|
|
106
|
-
ok: false,
|
|
107
|
-
error: "Server shutting down"
|
|
108
|
-
});
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (request.method === "GET" && request.url === "/health") {
|
|
113
|
-
const recentCount = (await listFilesRecursive(path.join(this.transportRoot, "inbox"))).length;
|
|
114
|
-
sendJson(response, 200, {
|
|
115
|
-
ok: true,
|
|
116
|
-
transport: "standalone_http",
|
|
117
|
-
recentMessages: recentCount
|
|
118
|
-
});
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Telegram webhook — must return 200 synchronously before any processing
|
|
123
|
-
if (request.method === "POST" && request.url === "/telegram/webhook") {
|
|
124
|
-
if (!this.telegramHandler) {
|
|
125
|
-
sendJson(response, 404, { ok: false, error: "Telegram inbound not configured" });
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
// Validate webhook secret if configured
|
|
129
|
-
const webhookSecret = process.env.NEMORIS_TELEGRAM_WEBHOOK_SECRET;
|
|
130
|
-
if (webhookSecret) {
|
|
131
|
-
const provided = request.headers["x-telegram-bot-api-secret-token"] || "";
|
|
132
|
-
if (provided !== webhookSecret) {
|
|
133
|
-
sendJson(response, 401, { ok: false, error: "Invalid webhook secret" });
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const update = await readJsonBody(request, {
|
|
138
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
139
|
-
timeoutMs: this.requestTimeoutMs,
|
|
140
|
-
});
|
|
141
|
-
const result = await this.telegramHandler.handleUpdate(update);
|
|
142
|
-
// Always return 200 to Telegram — regardless of outcome
|
|
143
|
-
sendJson(response, 200, { ok: true, ...result });
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Slack Events API
|
|
148
|
-
if (request.method === "POST" && request.url === "/slack/events") {
|
|
149
|
-
if (!this.slackHandler) {
|
|
150
|
-
sendJson(response, 404, { ok: false, error: "Slack inbound not configured" });
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
const rawBody = await readRawBody(request, {
|
|
154
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
155
|
-
timeoutMs: this.requestTimeoutMs,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
if (!this.slackHandler.verifySignature(request.headers, rawBody)) {
|
|
159
|
-
sendJson(response, 401, { ok: false, error: "Invalid signature" });
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const payload = JSON.parse(rawBody);
|
|
164
|
-
const result = this.slackHandler.handleEvent(payload);
|
|
165
|
-
|
|
166
|
-
// Handle URL verification challenge
|
|
167
|
-
if (result.action === "challenge") {
|
|
168
|
-
sendJson(response, 200, { challenge: result.challenge });
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Slack requires immediate 200 OK
|
|
173
|
-
sendJson(response, 200, { ok: true });
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Slack Slash Commands
|
|
178
|
-
if (request.method === "POST" && request.url === "/slack/slash") {
|
|
179
|
-
if (!this.slackHandler) {
|
|
180
|
-
sendJson(response, 404, { ok: false, error: "Slack inbound not configured" });
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const rawBody = await readRawBody(request, {
|
|
184
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
185
|
-
timeoutMs: this.requestTimeoutMs,
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
if (!this.slackHandler.verifySignature(request.headers, rawBody)) {
|
|
189
|
-
sendJson(response, 401, { ok: false, error: "Invalid signature" });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const params = new URLSearchParams(rawBody);
|
|
194
|
-
const payload = Object.fromEntries(params.entries());
|
|
195
|
-
const reply = this.slackHandler.handleSlashCommand(payload);
|
|
196
|
-
|
|
197
|
-
// Slack renders 200 response body as an inline message
|
|
198
|
-
response.writeHead(200, { "Content-Type": "text/plain" });
|
|
199
|
-
response.end(reply);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (request.method === "POST" && request.url === "/messages") {
|
|
204
|
-
if (this.authToken) {
|
|
205
|
-
const header = request.headers.authorization || "";
|
|
206
|
-
if (header !== `Bearer ${this.authToken}`) {
|
|
207
|
-
sendJson(response, 401, {
|
|
208
|
-
ok: false,
|
|
209
|
-
error: "Unauthorized"
|
|
210
|
-
});
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const rateKey = request.socket.remoteAddress || request.headers.authorization || "anonymous";
|
|
216
|
-
if (!this.allowRequest(rateKey)) {
|
|
217
|
-
sendJson(response, 429, {
|
|
218
|
-
ok: false,
|
|
219
|
-
error: "Rate limit exceeded"
|
|
220
|
-
});
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const payload = await readJsonBody(request, {
|
|
225
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
226
|
-
timeoutMs: this.requestTimeoutMs
|
|
227
|
-
});
|
|
228
|
-
const bucket = String(payload.target?.mode || "operator");
|
|
229
|
-
if (!this.allowedTargetModes.has(bucket)) {
|
|
230
|
-
sendJson(response, 400, {
|
|
231
|
-
ok: false,
|
|
232
|
-
error: "Invalid target mode"
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
const filePath = path.join(this.transportRoot, "inbox", bucket, `${timestampKey()}.json`);
|
|
237
|
-
await writeJson(filePath, {
|
|
238
|
-
receivedAt: new Date().toISOString(),
|
|
239
|
-
...payload
|
|
240
|
-
});
|
|
241
|
-
await pruneJsonBuckets(path.join(this.transportRoot, "inbox"), this.retentionPolicy);
|
|
242
|
-
sendJson(response, 202, {
|
|
243
|
-
ok: true,
|
|
244
|
-
stored: filePath,
|
|
245
|
-
bucket
|
|
246
|
-
});
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
sendJson(response, 404, {
|
|
251
|
-
ok: false,
|
|
252
|
-
error: "Not found"
|
|
253
|
-
});
|
|
254
|
-
} catch (error) {
|
|
255
|
-
if (String(error.message || "").includes("too large")) {
|
|
256
|
-
sendJson(response, 413, {
|
|
257
|
-
ok: false,
|
|
258
|
-
error: error.message
|
|
259
|
-
});
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
sendJson(response, 500, {
|
|
263
|
-
ok: false,
|
|
264
|
-
error: error.message
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async listen(port = 4318, host = "127.0.0.1") {
|
|
270
|
-
await ensureDir(this.transportRoot);
|
|
271
|
-
this.server = http.createServer((request, response) => {
|
|
272
|
-
this.handle(request, response);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
return new Promise((resolve, reject) => {
|
|
276
|
-
this.server.once("error", reject);
|
|
277
|
-
this.server.listen(port, host, () => {
|
|
278
|
-
const address = this.server.address();
|
|
279
|
-
resolve(address);
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
async close() {
|
|
285
|
-
if (!this.server) return;
|
|
286
|
-
this.shuttingDown = true;
|
|
287
|
-
await new Promise((resolve, reject) => {
|
|
288
|
-
this.server.close((error) => (error ? reject(error) : resolve()));
|
|
289
|
-
});
|
|
290
|
-
this.server = null;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async listInbox(limit = 20) {
|
|
294
|
-
const files = await listFilesRecursive(path.join(this.transportRoot, "inbox"));
|
|
295
|
-
const sorted = [...files].sort().reverse().slice(0, limit);
|
|
296
|
-
return Promise.all(sorted.map((filePath) => readJson(filePath, null)));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
allowRequest(key) {
|
|
300
|
-
const now = Date.now();
|
|
301
|
-
const windowStart = now - this.rateLimit.windowMs;
|
|
302
|
-
const current = (this.rateBuckets.get(key) || []).filter((timestamp) => timestamp >= windowStart);
|
|
303
|
-
if (current.length >= this.rateLimit.maxRequests) {
|
|
304
|
-
this.rateBuckets.set(key, current);
|
|
305
|
-
return false;
|
|
306
|
-
}
|
|
307
|
-
current.push(now);
|
|
308
|
-
this.rateBuckets.set(key, current);
|
|
309
|
-
return true;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureDir, listFilesRecursive, readJson, writeJson } from "../utils/fs.js";
|
|
4
|
+
import { buildRetentionPolicy, pruneJsonBuckets } from "./retention.js";
|
|
5
|
+
import { TelegramInboundHandler } from "./telegram-inbound.js";
|
|
6
|
+
import { SlackInboundHandler } from "./slack-inbound.js";
|
|
7
|
+
|
|
8
|
+
function timestampKey() {
|
|
9
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sendJson(response, statusCode, body) {
|
|
13
|
+
response.writeHead(statusCode, {
|
|
14
|
+
"content-type": "application/json"
|
|
15
|
+
});
|
|
16
|
+
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readJsonBody(request, { maxBodyBytes = 1024 * 1024, timeoutMs = 5000 } = {}) {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let total = 0;
|
|
22
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Request body timed out")), timeoutMs));
|
|
23
|
+
const reader = (async () => {
|
|
24
|
+
for await (const chunk of request) {
|
|
25
|
+
total += chunk.length;
|
|
26
|
+
if (total > maxBodyBytes) {
|
|
27
|
+
throw new Error("Request body too large");
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
32
|
+
return raw ? JSON.parse(raw) : {};
|
|
33
|
+
})();
|
|
34
|
+
return Promise.race([reader, timeout]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readRawBody(request, { maxBodyBytes = 1024 * 1024, timeoutMs = 5000 } = {}) {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Request body timed out")), timeoutMs));
|
|
41
|
+
const reader = (async () => {
|
|
42
|
+
for await (const chunk of request) {
|
|
43
|
+
total += chunk.length;
|
|
44
|
+
if (total > maxBodyBytes) {
|
|
45
|
+
throw new Error("Request body too large");
|
|
46
|
+
}
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
}
|
|
49
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
50
|
+
})();
|
|
51
|
+
return Promise.race([reader, timeout]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class StandaloneTransportServer {
|
|
55
|
+
constructor({ stateRoot, authToken = null, retention = {}, maxBodyBytes = 1024 * 1024, requestTimeoutMs = 5000, allowedTargetModes = null, rateLimit = {} } = {}) {
|
|
56
|
+
this.stateRoot = stateRoot;
|
|
57
|
+
this.authToken = authToken;
|
|
58
|
+
this.server = null;
|
|
59
|
+
this.shuttingDown = false;
|
|
60
|
+
this.retentionPolicy = buildRetentionPolicy(retention, {
|
|
61
|
+
ttlDays: 7,
|
|
62
|
+
maxFilesPerBucket: 1000
|
|
63
|
+
});
|
|
64
|
+
this.maxBodyBytes = maxBodyBytes;
|
|
65
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
66
|
+
this.allowedTargetModes = new Set(allowedTargetModes || ["operator", "same_thread", "peer_agent", "scheduler_log"]);
|
|
67
|
+
this.rateLimit = {
|
|
68
|
+
windowMs: Number(rateLimit.windowMs ?? 60_000),
|
|
69
|
+
maxRequests: Number(rateLimit.maxRequests ?? 120)
|
|
70
|
+
};
|
|
71
|
+
this.rateBuckets = new Map();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get transportRoot() {
|
|
75
|
+
return path.join(this.stateRoot, "transport");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
attachTelegramInbound({
|
|
79
|
+
stateStore,
|
|
80
|
+
telegramConfig,
|
|
81
|
+
availablePeers = [],
|
|
82
|
+
contextLedger = null,
|
|
83
|
+
agentNames = {},
|
|
84
|
+
routerConfig = null,
|
|
85
|
+
agentConfigs = null,
|
|
86
|
+
}) {
|
|
87
|
+
this.telegramHandler = new TelegramInboundHandler({
|
|
88
|
+
stateStore,
|
|
89
|
+
telegramConfig,
|
|
90
|
+
availablePeers,
|
|
91
|
+
contextLedger,
|
|
92
|
+
agentNames,
|
|
93
|
+
routerConfig,
|
|
94
|
+
agentConfigs,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
attachSlackInbound({ stateStore, slackConfig, availablePeers = [] }) {
|
|
99
|
+
this.slackHandler = new SlackInboundHandler({ stateStore, slackConfig, availablePeers });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async handle(request, response) {
|
|
103
|
+
try {
|
|
104
|
+
if (this.shuttingDown) {
|
|
105
|
+
sendJson(response, 503, {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: "Server shutting down"
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
113
|
+
const recentCount = (await listFilesRecursive(path.join(this.transportRoot, "inbox"))).length;
|
|
114
|
+
sendJson(response, 200, {
|
|
115
|
+
ok: true,
|
|
116
|
+
transport: "standalone_http",
|
|
117
|
+
recentMessages: recentCount
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Telegram webhook — must return 200 synchronously before any processing
|
|
123
|
+
if (request.method === "POST" && request.url === "/telegram/webhook") {
|
|
124
|
+
if (!this.telegramHandler) {
|
|
125
|
+
sendJson(response, 404, { ok: false, error: "Telegram inbound not configured" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Validate webhook secret if configured
|
|
129
|
+
const webhookSecret = process.env.NEMORIS_TELEGRAM_WEBHOOK_SECRET;
|
|
130
|
+
if (webhookSecret) {
|
|
131
|
+
const provided = request.headers["x-telegram-bot-api-secret-token"] || "";
|
|
132
|
+
if (provided !== webhookSecret) {
|
|
133
|
+
sendJson(response, 401, { ok: false, error: "Invalid webhook secret" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const update = await readJsonBody(request, {
|
|
138
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
139
|
+
timeoutMs: this.requestTimeoutMs,
|
|
140
|
+
});
|
|
141
|
+
const result = await this.telegramHandler.handleUpdate(update);
|
|
142
|
+
// Always return 200 to Telegram — regardless of outcome
|
|
143
|
+
sendJson(response, 200, { ok: true, ...result });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Slack Events API
|
|
148
|
+
if (request.method === "POST" && request.url === "/slack/events") {
|
|
149
|
+
if (!this.slackHandler) {
|
|
150
|
+
sendJson(response, 404, { ok: false, error: "Slack inbound not configured" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const rawBody = await readRawBody(request, {
|
|
154
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
155
|
+
timeoutMs: this.requestTimeoutMs,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!this.slackHandler.verifySignature(request.headers, rawBody)) {
|
|
159
|
+
sendJson(response, 401, { ok: false, error: "Invalid signature" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const payload = JSON.parse(rawBody);
|
|
164
|
+
const result = this.slackHandler.handleEvent(payload);
|
|
165
|
+
|
|
166
|
+
// Handle URL verification challenge
|
|
167
|
+
if (result.action === "challenge") {
|
|
168
|
+
sendJson(response, 200, { challenge: result.challenge });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Slack requires immediate 200 OK
|
|
173
|
+
sendJson(response, 200, { ok: true });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Slack Slash Commands
|
|
178
|
+
if (request.method === "POST" && request.url === "/slack/slash") {
|
|
179
|
+
if (!this.slackHandler) {
|
|
180
|
+
sendJson(response, 404, { ok: false, error: "Slack inbound not configured" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const rawBody = await readRawBody(request, {
|
|
184
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
185
|
+
timeoutMs: this.requestTimeoutMs,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!this.slackHandler.verifySignature(request.headers, rawBody)) {
|
|
189
|
+
sendJson(response, 401, { ok: false, error: "Invalid signature" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const params = new URLSearchParams(rawBody);
|
|
194
|
+
const payload = Object.fromEntries(params.entries());
|
|
195
|
+
const reply = this.slackHandler.handleSlashCommand(payload);
|
|
196
|
+
|
|
197
|
+
// Slack renders 200 response body as an inline message
|
|
198
|
+
response.writeHead(200, { "Content-Type": "text/plain" });
|
|
199
|
+
response.end(reply);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (request.method === "POST" && request.url === "/messages") {
|
|
204
|
+
if (this.authToken) {
|
|
205
|
+
const header = request.headers.authorization || "";
|
|
206
|
+
if (header !== `Bearer ${this.authToken}`) {
|
|
207
|
+
sendJson(response, 401, {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: "Unauthorized"
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rateKey = request.socket.remoteAddress || request.headers.authorization || "anonymous";
|
|
216
|
+
if (!this.allowRequest(rateKey)) {
|
|
217
|
+
sendJson(response, 429, {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: "Rate limit exceeded"
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const payload = await readJsonBody(request, {
|
|
225
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
226
|
+
timeoutMs: this.requestTimeoutMs
|
|
227
|
+
});
|
|
228
|
+
const bucket = String(payload.target?.mode || "operator");
|
|
229
|
+
if (!this.allowedTargetModes.has(bucket)) {
|
|
230
|
+
sendJson(response, 400, {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: "Invalid target mode"
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const filePath = path.join(this.transportRoot, "inbox", bucket, `${timestampKey()}.json`);
|
|
237
|
+
await writeJson(filePath, {
|
|
238
|
+
receivedAt: new Date().toISOString(),
|
|
239
|
+
...payload
|
|
240
|
+
});
|
|
241
|
+
await pruneJsonBuckets(path.join(this.transportRoot, "inbox"), this.retentionPolicy);
|
|
242
|
+
sendJson(response, 202, {
|
|
243
|
+
ok: true,
|
|
244
|
+
stored: filePath,
|
|
245
|
+
bucket
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
sendJson(response, 404, {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: "Not found"
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (String(error.message || "").includes("too large")) {
|
|
256
|
+
sendJson(response, 413, {
|
|
257
|
+
ok: false,
|
|
258
|
+
error: error.message
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
sendJson(response, 500, {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: error.message
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async listen(port = 4318, host = "127.0.0.1") {
|
|
270
|
+
await ensureDir(this.transportRoot);
|
|
271
|
+
this.server = http.createServer((request, response) => {
|
|
272
|
+
this.handle(request, response);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
this.server.once("error", reject);
|
|
277
|
+
this.server.listen(port, host, () => {
|
|
278
|
+
const address = this.server.address();
|
|
279
|
+
resolve(address);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async close() {
|
|
285
|
+
if (!this.server) return;
|
|
286
|
+
this.shuttingDown = true;
|
|
287
|
+
await new Promise((resolve, reject) => {
|
|
288
|
+
this.server.close((error) => (error ? reject(error) : resolve()));
|
|
289
|
+
});
|
|
290
|
+
this.server = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async listInbox(limit = 20) {
|
|
294
|
+
const files = await listFilesRecursive(path.join(this.transportRoot, "inbox"));
|
|
295
|
+
const sorted = [...files].sort().reverse().slice(0, limit);
|
|
296
|
+
return Promise.all(sorted.map((filePath) => readJson(filePath, null)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
allowRequest(key) {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const windowStart = now - this.rateLimit.windowMs;
|
|
302
|
+
const current = (this.rateBuckets.get(key) || []).filter((timestamp) => timestamp >= windowStart);
|
|
303
|
+
if (current.length >= this.rateLimit.maxRequests) {
|
|
304
|
+
this.rateBuckets.set(key, current);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
current.push(now);
|
|
308
|
+
this.rateBuckets.set(key, current);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|