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,134 @@
|
|
|
1
|
+
import { estimateInvocationTokens } from "./token-estimator.js";
|
|
2
|
+
|
|
3
|
+
let jsTiktokenModulePromise = null;
|
|
4
|
+
|
|
5
|
+
function normalizeModelId(modelId) {
|
|
6
|
+
return String(modelId || "").toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractOpenAiTokenizerModel(modelId) {
|
|
10
|
+
const normalized = normalizeModelId(modelId);
|
|
11
|
+
const candidates = [
|
|
12
|
+
"gpt-4.1-mini",
|
|
13
|
+
"gpt-4.1",
|
|
14
|
+
"gpt-4o-mini",
|
|
15
|
+
"gpt-4o",
|
|
16
|
+
"o3-mini",
|
|
17
|
+
"o3",
|
|
18
|
+
"o1-mini",
|
|
19
|
+
"o1",
|
|
20
|
+
"gpt-4-turbo",
|
|
21
|
+
"gpt-4",
|
|
22
|
+
"gpt-3.5-turbo"
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (normalized.includes(candidate)) {
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderInvocationText(invocation) {
|
|
35
|
+
const sections = [];
|
|
36
|
+
if (invocation?.system) {
|
|
37
|
+
sections.push(`system\n${invocation.system}`);
|
|
38
|
+
}
|
|
39
|
+
for (const message of invocation?.messages || []) {
|
|
40
|
+
sections.push(`${message.role || "user"}\n${message.content || ""}`);
|
|
41
|
+
}
|
|
42
|
+
return sections.join("\n\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadJsTiktoken() {
|
|
46
|
+
if (!jsTiktokenModulePromise) {
|
|
47
|
+
jsTiktokenModulePromise = import("js-tiktoken").catch(() => null);
|
|
48
|
+
}
|
|
49
|
+
return jsTiktokenModulePromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function countWithApproximateTokenizer(modelName, invocation) {
|
|
53
|
+
const rendered = renderInvocationText(invocation);
|
|
54
|
+
const textTokens = Math.max(1, Math.ceil(rendered.length / 4));
|
|
55
|
+
const framing = invocation?.messages?.length ? invocation.messages.length * 6 : 0;
|
|
56
|
+
return {
|
|
57
|
+
total: textTokens + framing,
|
|
58
|
+
mode: "model_specific",
|
|
59
|
+
source: `js-tiktoken:${modelName}`,
|
|
60
|
+
details: {
|
|
61
|
+
textTokens,
|
|
62
|
+
framing,
|
|
63
|
+
fallback: "approximate_model_specific",
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function countWithJsTiktoken(modelName, invocation) {
|
|
69
|
+
const jsTiktoken = await loadJsTiktoken();
|
|
70
|
+
if (!jsTiktoken?.encodingForModel) {
|
|
71
|
+
return countWithApproximateTokenizer(modelName, invocation);
|
|
72
|
+
}
|
|
73
|
+
const encoder = jsTiktoken.encodingForModel(modelName);
|
|
74
|
+
const rendered = renderInvocationText(invocation);
|
|
75
|
+
const textTokens = encoder.encode(rendered).length;
|
|
76
|
+
const framing = invocation?.messages?.length ? invocation.messages.length * 6 : 0;
|
|
77
|
+
return {
|
|
78
|
+
total: textTokens + framing,
|
|
79
|
+
mode: "model_specific",
|
|
80
|
+
source: `js-tiktoken:${modelName}`,
|
|
81
|
+
details: {
|
|
82
|
+
textTokens,
|
|
83
|
+
framing
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class TokenCounter {
|
|
89
|
+
async countInvocation({ providerId, invocation, adapter }) {
|
|
90
|
+
if (adapter && typeof adapter.countTokens === "function") {
|
|
91
|
+
try {
|
|
92
|
+
const total = await adapter.countTokens(invocation);
|
|
93
|
+
if (Number.isFinite(total) && total > 0) {
|
|
94
|
+
return {
|
|
95
|
+
total,
|
|
96
|
+
mode: "exact",
|
|
97
|
+
source: `${providerId}:count_tokens`,
|
|
98
|
+
details: {
|
|
99
|
+
providerId
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const fallback = this.countHeuristic(invocation);
|
|
105
|
+
return {
|
|
106
|
+
...fallback,
|
|
107
|
+
details: {
|
|
108
|
+
...fallback.details,
|
|
109
|
+
fallbackReason: error.message
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const tokenizerModel = extractOpenAiTokenizerModel(invocation?.model);
|
|
116
|
+
if (tokenizerModel) {
|
|
117
|
+
try {
|
|
118
|
+
return await countWithJsTiktoken(tokenizerModel, invocation);
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return this.countHeuristic(invocation);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
countHeuristic(invocation) {
|
|
126
|
+
const estimate = estimateInvocationTokens(invocation);
|
|
127
|
+
return {
|
|
128
|
+
total: estimate.total,
|
|
129
|
+
mode: "heuristic",
|
|
130
|
+
source: "multi_strategy_estimator",
|
|
131
|
+
details: estimate
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function countMatches(text, pattern) {
|
|
2
|
+
return (text.match(pattern) || []).length;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function estimateTextTokens(text) {
|
|
6
|
+
const raw = String(text || "");
|
|
7
|
+
if (!raw) {
|
|
8
|
+
return {
|
|
9
|
+
total: 0,
|
|
10
|
+
strategies: {
|
|
11
|
+
charBased: 0,
|
|
12
|
+
wordBased: 0,
|
|
13
|
+
denseText: 0,
|
|
14
|
+
cjkAware: 0
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const words = raw.trim().split(/\s+/).filter(Boolean).length;
|
|
20
|
+
const punctuation = countMatches(raw, /[{}[\]():;,.]/g);
|
|
21
|
+
const operators = countMatches(raw, /[=+\-/*<>|&]/g);
|
|
22
|
+
const quotes = countMatches(raw, /["'`]/g);
|
|
23
|
+
const newlines = countMatches(raw, /\n/g);
|
|
24
|
+
const cjk = countMatches(raw, /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu);
|
|
25
|
+
const symbolRatio = raw.length ? (punctuation + operators + quotes) / raw.length : 0;
|
|
26
|
+
|
|
27
|
+
const charBased = Math.ceil(raw.length / 4);
|
|
28
|
+
const wordBased = Math.ceil(words * 1.28 + punctuation * 0.18 + operators * 0.2 + newlines * 0.12);
|
|
29
|
+
const denseDivisor = symbolRatio >= 0.12 ? 3.1 : 3.5;
|
|
30
|
+
const denseText = Math.ceil(raw.length / denseDivisor + quotes * 0.08);
|
|
31
|
+
const cjkAware = cjk ? Math.ceil(cjk * 1.15 + (raw.length - cjk) / 4.4) : 0;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
total: Math.max(charBased, wordBased, denseText, cjkAware),
|
|
35
|
+
strategies: {
|
|
36
|
+
charBased,
|
|
37
|
+
wordBased,
|
|
38
|
+
denseText,
|
|
39
|
+
cjkAware
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function estimateInvocationTokens(invocation) {
|
|
45
|
+
const system = estimateTextTokens(invocation?.system || "");
|
|
46
|
+
const messages = (invocation?.messages || []).map((message) => ({
|
|
47
|
+
role: message.role || "user",
|
|
48
|
+
...estimateTextTokens(message.content || "")
|
|
49
|
+
}));
|
|
50
|
+
const messageTotal = messages.reduce((sum, message) => sum + message.total, 0);
|
|
51
|
+
const framing = invocation?.messages?.length ? invocation.messages.length * 6 : 0;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
total: system.total + messageTotal + framing,
|
|
55
|
+
system,
|
|
56
|
+
messages,
|
|
57
|
+
framing
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { ExecApprovalGate } from "./exec-approvals.js";
|
|
2
|
+
|
|
3
|
+
export function parseToolUseBlocks(response) {
|
|
4
|
+
if (!response?.content || !Array.isArray(response.content)) return [];
|
|
5
|
+
return response.content
|
|
6
|
+
.filter((block) => block.type === "tool_use")
|
|
7
|
+
.map((block) => ({ id: block.id, name: block.name, input: block.input || {} }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatToolResults(results) {
|
|
11
|
+
return [{
|
|
12
|
+
role: "user",
|
|
13
|
+
content: results.map((result) => ({
|
|
14
|
+
type: "tool_result",
|
|
15
|
+
tool_use_id: result.toolUseId,
|
|
16
|
+
content: result.output,
|
|
17
|
+
...(result.isError ? { is_error: true } : {}),
|
|
18
|
+
})),
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildPendingApprovalOutput(results) {
|
|
23
|
+
if (results.length === 1) {
|
|
24
|
+
return results[0].output;
|
|
25
|
+
}
|
|
26
|
+
return [
|
|
27
|
+
"Multiple tool calls are waiting for approval before I can continue:",
|
|
28
|
+
...results.map((result) => `- ${result.output}`),
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function executeToolCalls(toolCalls, registry, policy, agentConfig = null, options = {}) {
|
|
33
|
+
const allowed = new Set(policy.allowed || []);
|
|
34
|
+
const blocked = new Set(policy.blocked || []);
|
|
35
|
+
const approvalGate = options.approvalGate || new ExecApprovalGate({ sendFn: async () => {} });
|
|
36
|
+
|
|
37
|
+
const promises = toolCalls.map(async (call) => {
|
|
38
|
+
if (policy.default === "deny" && !allowed.has(call.name)) {
|
|
39
|
+
return {
|
|
40
|
+
toolUseId: call.id,
|
|
41
|
+
name: call.name,
|
|
42
|
+
args: call.input,
|
|
43
|
+
output: `Tool '${call.name}' is not allowed by agent policy.`,
|
|
44
|
+
isError: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (blocked.has(call.name)) {
|
|
48
|
+
return {
|
|
49
|
+
toolUseId: call.id,
|
|
50
|
+
name: call.name,
|
|
51
|
+
args: call.input,
|
|
52
|
+
output: `Tool '${call.name}' is blocked by agent policy.`,
|
|
53
|
+
isError: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (approvalGate.requiresApproval(call.name, call.input, agentConfig)) {
|
|
58
|
+
const approvalRequest = approvalGate.requestApproval({
|
|
59
|
+
toolName: call.name,
|
|
60
|
+
toolInput: call.input,
|
|
61
|
+
jobId: options.jobId || "unknown",
|
|
62
|
+
agentId: options.agentId || agentConfig?.id || "unknown",
|
|
63
|
+
skipNotify: options.skipApprovalNotification === true,
|
|
64
|
+
});
|
|
65
|
+
const approvalId = approvalRequest?.approvalId || null;
|
|
66
|
+
void approvalRequest.catch(() => {});
|
|
67
|
+
return {
|
|
68
|
+
toolUseId: call.id,
|
|
69
|
+
name: call.name,
|
|
70
|
+
args: call.input,
|
|
71
|
+
output: approvalId
|
|
72
|
+
? `Approval required for ${call.name}. Awaiting /exec_approve ${approvalId} or /exec_deny ${approvalId}.`
|
|
73
|
+
: `Approval required for ${call.name}. Use /exec_approve or /exec_deny.`,
|
|
74
|
+
isError: false,
|
|
75
|
+
pendingApproval: true,
|
|
76
|
+
approvalId,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = await registry.executeTool(call.name, call.input);
|
|
81
|
+
if (result.error) {
|
|
82
|
+
return {
|
|
83
|
+
toolUseId: call.id,
|
|
84
|
+
name: call.name,
|
|
85
|
+
args: call.input,
|
|
86
|
+
output: result.error,
|
|
87
|
+
isError: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
toolUseId: call.id,
|
|
92
|
+
name: call.name,
|
|
93
|
+
args: call.input,
|
|
94
|
+
output: result.output,
|
|
95
|
+
isError: false,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return Promise.allSettled(promises).then((settled) =>
|
|
100
|
+
settled.map((entry) => entry.status === "fulfilled"
|
|
101
|
+
? entry.value
|
|
102
|
+
: {
|
|
103
|
+
toolUseId: "unknown",
|
|
104
|
+
name: "unknown",
|
|
105
|
+
args: {},
|
|
106
|
+
output: `Internal error: ${entry.reason}`,
|
|
107
|
+
isError: true,
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function runToolLoop({
|
|
113
|
+
initialResponse,
|
|
114
|
+
adapter,
|
|
115
|
+
invocation,
|
|
116
|
+
registry,
|
|
117
|
+
policy,
|
|
118
|
+
agentConfig = null,
|
|
119
|
+
approvalGate = null,
|
|
120
|
+
options = {}
|
|
121
|
+
}) {
|
|
122
|
+
if (!options.enabled) {
|
|
123
|
+
return {
|
|
124
|
+
finalResponse: initialResponse,
|
|
125
|
+
toolResults: [],
|
|
126
|
+
iterationCount: 0
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let currentResponse = initialResponse;
|
|
131
|
+
let toolMessages = [];
|
|
132
|
+
let toolResults = [];
|
|
133
|
+
let toolCallCount = 0;
|
|
134
|
+
let iterationCount = 0;
|
|
135
|
+
const maxToolCalls = options.maxToolCalls || 20;
|
|
136
|
+
const maxIterations = options.maxIterations || Number.POSITIVE_INFINITY;
|
|
137
|
+
|
|
138
|
+
while (true) {
|
|
139
|
+
const toolCalls = parseToolUseBlocks(currentResponse);
|
|
140
|
+
if (toolCalls.length === 0) break;
|
|
141
|
+
if (iterationCount >= maxIterations) break;
|
|
142
|
+
|
|
143
|
+
toolCallCount += toolCalls.length;
|
|
144
|
+
if (toolCallCount > maxToolCalls) break;
|
|
145
|
+
iterationCount += 1;
|
|
146
|
+
|
|
147
|
+
const results = await executeToolCalls(toolCalls, registry, policy, agentConfig, {
|
|
148
|
+
approvalGate,
|
|
149
|
+
jobId: options.jobId,
|
|
150
|
+
agentId: options.agentId,
|
|
151
|
+
skipApprovalNotification: options.skipApprovalNotification === true,
|
|
152
|
+
});
|
|
153
|
+
toolResults = [...toolResults, ...results];
|
|
154
|
+
|
|
155
|
+
const pendingApprovals = results.filter((entry) => entry.pendingApproval);
|
|
156
|
+
if (pendingApprovals.length > 0) {
|
|
157
|
+
return {
|
|
158
|
+
finalResponse: currentResponse,
|
|
159
|
+
toolResults,
|
|
160
|
+
iterationCount,
|
|
161
|
+
pendingApprovalResult: {
|
|
162
|
+
summary: "Tool execution paused pending approval.",
|
|
163
|
+
output: buildPendingApprovalOutput(pendingApprovals),
|
|
164
|
+
nextActions: [
|
|
165
|
+
"Approve or deny the pending exec action from Telegram, then retry the request if needed."
|
|
166
|
+
],
|
|
167
|
+
toolCalls: toolCalls.map((call, index) => ({
|
|
168
|
+
...call,
|
|
169
|
+
output: results[index]?.output,
|
|
170
|
+
pendingApproval: results[index]?.pendingApproval || false,
|
|
171
|
+
approvalId: results[index]?.approvalId || null,
|
|
172
|
+
})),
|
|
173
|
+
pendingApproval: true,
|
|
174
|
+
raw: currentResponse,
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const formattedResults = formatToolResults(results);
|
|
180
|
+
const continuationMessages = [
|
|
181
|
+
...invocation.messages,
|
|
182
|
+
...toolMessages,
|
|
183
|
+
{ role: "assistant", content: currentResponse.content },
|
|
184
|
+
...formattedResults,
|
|
185
|
+
];
|
|
186
|
+
toolMessages = [
|
|
187
|
+
...toolMessages,
|
|
188
|
+
{ role: "assistant", content: currentResponse.content },
|
|
189
|
+
...formattedResults,
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
currentResponse = await adapter.invoke({ ...invocation, messages: continuationMessages });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
finalResponse: currentResponse,
|
|
197
|
+
toolResults,
|
|
198
|
+
iterationCount
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +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
|
+
}
|