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,1565 @@
|
|
|
1
|
+
import { clearCache } from "./identity-cache.js";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { getContextLimit } from "../utils/model-context-limits.js";
|
|
8
|
+
import { inspectOutboundUrl } from "../security/ssrf-check.js";
|
|
9
|
+
import { handleApproveCommand, handleClearRepairsCommand, handleHealthCommand } from "../daemon/telegram-commands.js";
|
|
10
|
+
import { ExecApprovalGate } from "./exec-approvals.js";
|
|
11
|
+
import { resolveModelOverride, resolveSessionStatusModel } from "./model-resolution.js";
|
|
12
|
+
|
|
13
|
+
const PRE_AUTH_COMMANDS = new Set(["/start", "/help"]);
|
|
14
|
+
const OPERATOR_ONLY_COMMANDS = new Set(["/logs"]);
|
|
15
|
+
const THINK_LEVELS = ["off", "low", "medium", "high", "default"];
|
|
16
|
+
const FOCUS_LEVELS = ["deep", "planning", "off"];
|
|
17
|
+
const CALLBACK_PREFIX = {
|
|
18
|
+
modelList: "mdl:list:",
|
|
19
|
+
modelSet: "mdl:set:",
|
|
20
|
+
switchSet: "swt:set:",
|
|
21
|
+
thinkSet: "thk:set:",
|
|
22
|
+
focusSet: "fcs:set:",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function chunkButtons(items, size = 2) {
|
|
26
|
+
const rows = [];
|
|
27
|
+
for (let index = 0; index < items.length; index += size) {
|
|
28
|
+
rows.push(items.slice(index, index + size));
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseCallbackData(data) {
|
|
34
|
+
const value = String(data || "");
|
|
35
|
+
if (value.startsWith(CALLBACK_PREFIX.modelList)) {
|
|
36
|
+
return { type: "model_list", providerId: value.slice(CALLBACK_PREFIX.modelList.length) };
|
|
37
|
+
}
|
|
38
|
+
if (value.startsWith(CALLBACK_PREFIX.modelSet)) {
|
|
39
|
+
const rest = value.slice(CALLBACK_PREFIX.modelSet.length);
|
|
40
|
+
const [providerId, indexText] = rest.split(":");
|
|
41
|
+
const index = Number.parseInt(indexText, 10);
|
|
42
|
+
if (!providerId || !Number.isInteger(index)) return null;
|
|
43
|
+
return { type: "model_set", providerId, index };
|
|
44
|
+
}
|
|
45
|
+
if (value.startsWith(CALLBACK_PREFIX.switchSet)) {
|
|
46
|
+
return { type: "switch_set", agentId: value.slice(CALLBACK_PREFIX.switchSet.length) };
|
|
47
|
+
}
|
|
48
|
+
if (value.startsWith(CALLBACK_PREFIX.thinkSet)) {
|
|
49
|
+
return { type: "think_set", level: value.slice(CALLBACK_PREFIX.thinkSet.length) };
|
|
50
|
+
}
|
|
51
|
+
if (value.startsWith(CALLBACK_PREFIX.focusSet)) {
|
|
52
|
+
return { type: "focus_set", mode: value.slice(CALLBACK_PREFIX.focusSet.length) };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const TELEGRAM_HELP_SHORT = [
|
|
57
|
+
"/switch <agent> — Switch active agent",
|
|
58
|
+
"/who — Show current agent",
|
|
59
|
+
"/model <tier> — Switch model (sonnet, haiku, opus, default)",
|
|
60
|
+
"/status — Show agent and session status",
|
|
61
|
+
"/usage — Show token usage by model",
|
|
62
|
+
"/clear — Clear conversation context",
|
|
63
|
+
"/compact — Compact conversation context",
|
|
64
|
+
"/search <query> — Search past conversations",
|
|
65
|
+
"/cost — Show spending: today, this week, this month",
|
|
66
|
+
"/version — Show version and uptime",
|
|
67
|
+
"/help — Show this message (/help all for advanced commands)",
|
|
68
|
+
];
|
|
69
|
+
const TELEGRAM_HELP_ALL = [
|
|
70
|
+
...TELEGRAM_HELP_SHORT.slice(0, 6),
|
|
71
|
+
"/stop — Stop current run",
|
|
72
|
+
"/think <level> — Set thinking: off | low | medium | high | default",
|
|
73
|
+
TELEGRAM_HELP_SHORT[6],
|
|
74
|
+
"/agents — List available agents",
|
|
75
|
+
"/inbox — Show recent inter-agent messages",
|
|
76
|
+
"/focus <mode> — Set focus mode (deep, planning, off)",
|
|
77
|
+
"/logs [n] — Tail last N daemon log lines (default 20)",
|
|
78
|
+
TELEGRAM_HELP_SHORT[7],
|
|
79
|
+
"/restart [reason] — Restart the daemon",
|
|
80
|
+
TELEGRAM_HELP_SHORT[8],
|
|
81
|
+
TELEGRAM_HELP_SHORT[9],
|
|
82
|
+
"/transfer — Migrate configuration from OpenClaw",
|
|
83
|
+
"/approve <id> — Approve a held update or staged rule",
|
|
84
|
+
"/health — Show self-healing system health",
|
|
85
|
+
"/clear_repairs — Acknowledge all unresolved repairs",
|
|
86
|
+
"/exec_approve <id> — Approve a pending exec action",
|
|
87
|
+
"/exec_deny <id> — Deny a pending exec action",
|
|
88
|
+
"/help — Show this message",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Telegram Inbound Handler — validates, deduplicates, authorizes, and enqueues
|
|
93
|
+
* incoming Telegram webhook updates as interactive jobs.
|
|
94
|
+
*
|
|
95
|
+
* Critical invariant: handleUpdate() is synchronous. All DB operations are
|
|
96
|
+
* synchronous SQLite calls. The webhook endpoint must return 200 within 5s
|
|
97
|
+
* or Telegram will retry — no async work before the response.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
const MAX_TURNS = 10;
|
|
101
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
|
|
103
|
+
export function resolveRuntimeMode(pollingMode) {
|
|
104
|
+
return pollingMode === true ? "polling" : (pollingMode || "unknown");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resolveStatusModel(session, { routerConfig = null, agentConfigs = null } = {}) {
|
|
108
|
+
return resolveSessionStatusModel(session, { routerConfig, agentConfigs });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function appendTurn(contextJson, role, content, imageRef = null) {
|
|
112
|
+
let turns = [];
|
|
113
|
+
if (contextJson) {
|
|
114
|
+
try {
|
|
115
|
+
turns = JSON.parse(contextJson);
|
|
116
|
+
if (!Array.isArray(turns)) turns = [];
|
|
117
|
+
} catch {
|
|
118
|
+
turns = [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const turn = { role, content, timestamp: new Date().toISOString() };
|
|
122
|
+
if (imageRef) turn.imageRef = imageRef;
|
|
123
|
+
turns.push(turn);
|
|
124
|
+
if (turns.length > MAX_TURNS) {
|
|
125
|
+
turns = turns.slice(turns.length - MAX_TURNS);
|
|
126
|
+
}
|
|
127
|
+
return JSON.stringify(turns);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const DOWNLOAD_TIMEOUT_MS = 10000;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Download a Telegram file by file_id, returning base64-encoded data.
|
|
134
|
+
* Returns null on any failure (network, auth, file expired).
|
|
135
|
+
*
|
|
136
|
+
* @param {string} fileId - Telegram file_id
|
|
137
|
+
* @param {string} botToken - Bot API token
|
|
138
|
+
* @param {{ fetchImpl?: Function, lookupImpl?: Function }} deps - Injectable dependencies
|
|
139
|
+
* @returns {Promise<{ base64: string, mediaType: string } | null>}
|
|
140
|
+
*/
|
|
141
|
+
export async function downloadTelegramFile(fileId, botToken, deps = {}) {
|
|
142
|
+
const fetchFn = deps.fetchImpl || globalThis.fetch;
|
|
143
|
+
const lookupImpl = deps.lookupImpl;
|
|
144
|
+
try {
|
|
145
|
+
// Step 1: Get file path from Telegram API
|
|
146
|
+
const getFileUrl = `https://api.telegram.org/bot${botToken}/getFile?file_id=${fileId}`;
|
|
147
|
+
const metaResponse = await fetchFn(getFileUrl, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
148
|
+
if (!metaResponse.ok) {
|
|
149
|
+
console.warn(`[telegram-file] getFile failed: HTTP ${metaResponse.status}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const meta = await metaResponse.json();
|
|
153
|
+
if (!meta.ok || !meta.result?.file_path) {
|
|
154
|
+
console.warn(`[telegram-file] getFile returned no file_path`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Step 2: Download the binary file
|
|
159
|
+
const fileUrl = `https://api.telegram.org/file/bot${botToken}/${meta.result.file_path}`;
|
|
160
|
+
// Telegram's file_path comes from a previous API response, not local config.
|
|
161
|
+
// Re-check the resolved download host so a tampered path cannot redirect the
|
|
162
|
+
// bot into loopback, link-local, or RFC1918 destinations.
|
|
163
|
+
const inspection = await inspectOutboundUrl(fileUrl, {
|
|
164
|
+
lookupImpl,
|
|
165
|
+
privateAddressMessage: "Telegram file download blocked — target resolves to a private/reserved IP address.",
|
|
166
|
+
});
|
|
167
|
+
if (!inspection.ok) {
|
|
168
|
+
console.warn(`[telegram-file] ${inspection.reason}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const fileResponse = await fetchFn(fileUrl, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
172
|
+
if (!fileResponse.ok) {
|
|
173
|
+
console.warn(`[telegram-file] file download failed: HTTP ${fileResponse.status}`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Step 3: Convert to base64
|
|
178
|
+
const arrayBuffer = await fileResponse.arrayBuffer();
|
|
179
|
+
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
|
180
|
+
|
|
181
|
+
// Telegram compresses all message.photo to JPEG; revisit for document/sticker support
|
|
182
|
+
return { base64, mediaType: "image/jpeg" };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.warn(`[telegram-file] download failed: ${err.message}`);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const TELEGRAM_MAX_LENGTH = 4096;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Convert common markdown patterns to Telegram HTML.
|
|
193
|
+
* Telegram HTML supports: <b>, <i>, <code>, <pre>, <a href="">
|
|
194
|
+
* We must escape &, <, > in plain text regions first.
|
|
195
|
+
*/
|
|
196
|
+
export function markdownToTelegramHtml(text) {
|
|
197
|
+
if (!text) return text;
|
|
198
|
+
|
|
199
|
+
// Step 1: protect code blocks (``` ... ```) from inline processing
|
|
200
|
+
const codeBlocks = [];
|
|
201
|
+
let result = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, _lang, code) => {
|
|
202
|
+
const idx = codeBlocks.length;
|
|
203
|
+
codeBlocks.push(`<pre><code>${escapeHtml(code.trimEnd())}</code></pre>`);
|
|
204
|
+
return `\x00CODE${idx}\x00`;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Step 2: protect inline code (`...`)
|
|
208
|
+
const inlineCodes = [];
|
|
209
|
+
result = result.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
210
|
+
const idx = inlineCodes.length;
|
|
211
|
+
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
|
|
212
|
+
return `\x00INLINE${idx}\x00`;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Step 3: escape HTML in remaining text
|
|
216
|
+
result = escapeHtml(result);
|
|
217
|
+
|
|
218
|
+
// Step 4: apply markdown patterns (on already-escaped text)
|
|
219
|
+
// Links: [text](url) → <a href="url">text</a>
|
|
220
|
+
result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2">$1</a>');
|
|
221
|
+
// Bold: **text** or __text__
|
|
222
|
+
result = result.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
|
223
|
+
result = result.replace(/__([^_\n]+)__/g, '<b>$1</b>');
|
|
224
|
+
// Italic: *text* or _text_ (single, not double)
|
|
225
|
+
result = result.replace(/\*([^*\n]+)\*/g, '<i>$1</i>');
|
|
226
|
+
result = result.replace(/_([^_\n]+)_/g, '<i>$1</i>');
|
|
227
|
+
|
|
228
|
+
// Step 5: restore protected blocks
|
|
229
|
+
// eslint-disable-next-line no-control-regex
|
|
230
|
+
result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeBlocks[i]);
|
|
231
|
+
// eslint-disable-next-line no-control-regex
|
|
232
|
+
result = result.replace(/\x00INLINE(\d+)\x00/g, (_, i) => inlineCodes[i]);
|
|
233
|
+
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function escapeHtml(str) {
|
|
238
|
+
return str
|
|
239
|
+
.replace(/&/g, "&")
|
|
240
|
+
.replace(/</g, "<")
|
|
241
|
+
.replace(/>/g, ">");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function checkpointSqliteWal(db) {
|
|
245
|
+
if (!db || typeof db.exec !== "function") return false;
|
|
246
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function emitStructuredLog(logger, level, payload) {
|
|
251
|
+
const message = JSON.stringify(payload);
|
|
252
|
+
if (logger && typeof logger[level] === "function") {
|
|
253
|
+
logger[level](message);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (logger && typeof logger.log === "function") {
|
|
257
|
+
logger.log(message);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
console.log(message);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function formatTelegramHelp(showAll = false) {
|
|
264
|
+
if (showAll) {
|
|
265
|
+
return TELEGRAM_HELP_ALL.join("\n");
|
|
266
|
+
}
|
|
267
|
+
return TELEGRAM_HELP_SHORT.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function splitTelegramMessage(text) {
|
|
271
|
+
if (text.length <= TELEGRAM_MAX_LENGTH) return [text];
|
|
272
|
+
|
|
273
|
+
const chunks = [];
|
|
274
|
+
let remaining = text;
|
|
275
|
+
|
|
276
|
+
while (remaining.length > TELEGRAM_MAX_LENGTH) {
|
|
277
|
+
let splitAt = remaining.lastIndexOf("\n\n", TELEGRAM_MAX_LENGTH);
|
|
278
|
+
if (splitAt <= 0) splitAt = remaining.lastIndexOf("\n", TELEGRAM_MAX_LENGTH);
|
|
279
|
+
if (splitAt <= 0) splitAt = TELEGRAM_MAX_LENGTH;
|
|
280
|
+
|
|
281
|
+
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
|
282
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (remaining.length > 0) chunks.push(remaining);
|
|
286
|
+
return chunks;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function sendSplitTelegramText(fetchImpl, url, chatId, text) {
|
|
290
|
+
const chunks = splitTelegramMessage(text);
|
|
291
|
+
for (const chunk of chunks) {
|
|
292
|
+
await fetchImpl(url, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "Content-Type": "application/json" },
|
|
295
|
+
body: JSON.stringify({ chat_id: chatId, text: chunk }),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Parse an inter-session message header to extract source identity.
|
|
302
|
+
* Returns null if the text is not an inter-session message.
|
|
303
|
+
*/
|
|
304
|
+
export function parseInterSessionHeader(text) {
|
|
305
|
+
const match = text.match(/\[Inter-session message\]\s+sourceSession=([\w:.-]+)/);
|
|
306
|
+
if (!match) return null;
|
|
307
|
+
const sourceSession = match[1];
|
|
308
|
+
// e.g. "agent:content:telegram:direct:7781763328"
|
|
309
|
+
const agentMatch = sourceSession.match(/^agent:(\w+):/);
|
|
310
|
+
return {
|
|
311
|
+
sourceType: 'agent',
|
|
312
|
+
replySessionKey: sourceSession,
|
|
313
|
+
sourceAgentId: agentMatch ? agentMatch[1] : null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Strip the [Inter-session message] header line from message text.
|
|
319
|
+
*/
|
|
320
|
+
export function stripInterSessionHeader(text) {
|
|
321
|
+
return text.replace(/^\[Inter-session message\]\s+sourceSession=\S+\s*/m, "").trim();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function processInteractiveHandoffs(_stateStore) {
|
|
325
|
+
// TODO: Sub-project 2 — scan pending handoff notifications where source === "telegram",
|
|
326
|
+
// rebind chat_sessions, send confirmation to user, handle validation/circular detection.
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Register slash commands with Telegram. Called once on startup after token validation.
|
|
331
|
+
* Never throws — logs and returns { ok: false } on any failure so startup is never blocked.
|
|
332
|
+
*/
|
|
333
|
+
export async function setupBotCommands(botToken, { fetchImpl = globalThis.fetch, logger = console } = {}) {
|
|
334
|
+
const commands = [
|
|
335
|
+
{ command: "switch", description: "Switch who you're talking to" },
|
|
336
|
+
{ command: "who", description: "Show who you're currently talking to" },
|
|
337
|
+
{ command: "model", description: "Change AI model speed/quality — /model sonnet | haiku | opus | default" },
|
|
338
|
+
{ command: "status", description: "Show system status and today's usage" },
|
|
339
|
+
{ command: "usage", description: "Show token usage breakdown by model" },
|
|
340
|
+
{ command: "clear", description: "Start a fresh conversation" },
|
|
341
|
+
{ command: "stop", description: "Cancel what's happening now" },
|
|
342
|
+
{ command: "think", description: "Set thinking level: /think off | low | medium | high" },
|
|
343
|
+
{ command: "compact", description: "Free up memory (keeps last 4 turns)" },
|
|
344
|
+
{ command: "agents", description: "List available agents" },
|
|
345
|
+
{ command: "inbox", description: "Show recent inter-agent messages" },
|
|
346
|
+
{ command: "focus", description: "Set focus mode: /focus [deep|planning|off]" },
|
|
347
|
+
{ command: "logs", description: "Tail last N daemon log lines (default 20)" },
|
|
348
|
+
{ command: "search", description: "Search past conversations — /search <query>" },
|
|
349
|
+
{ command: "restart", description: "Restart Nemoris" },
|
|
350
|
+
{ command: "version", description: "Show version and uptime" },
|
|
351
|
+
{ command: "transfer", description: "Migrate configuration from OpenClaw" },
|
|
352
|
+
{ command: "approve", description: "Approve a held update or staged rule — /approve <id>" },
|
|
353
|
+
{ command: "health", description: "Show self-healing system health" },
|
|
354
|
+
{ command: "clear_repairs", description: "Acknowledge all unresolved repairs" },
|
|
355
|
+
{ command: "cost", description: "Show spending: today, this week, this month" },
|
|
356
|
+
{ command: "exec_approve", description: "Approve a pending exec action — /exec_approve <id>" },
|
|
357
|
+
{ command: "exec_deny", description: "Deny a pending exec action — /exec_deny <id>" },
|
|
358
|
+
{ command: "help", description: "Show available commands" },
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const res = await fetchImpl(`https://api.telegram.org/bot${botToken}/setMyCommands`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "content-type": "application/json" },
|
|
365
|
+
body: JSON.stringify({ commands }),
|
|
366
|
+
});
|
|
367
|
+
const body = await res.json();
|
|
368
|
+
if (!body.ok) {
|
|
369
|
+
logger.warn(JSON.stringify({ service: "telegram_setup", event: "setMyCommands_failed", error: body.description }));
|
|
370
|
+
return { ok: false, error: body.description };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true };
|
|
373
|
+
} catch (error) {
|
|
374
|
+
logger.warn(JSON.stringify({ service: "telegram_setup", event: "setMyCommands_error", error: error.message }));
|
|
375
|
+
return { ok: false, error: error.message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Validate a bot token by calling getMe. Lightweight — no side effects.
|
|
381
|
+
* Returns { ok, username, firstName, error }.
|
|
382
|
+
*/
|
|
383
|
+
export async function getMe(botToken, { fetchImpl = globalThis.fetch } = {}) {
|
|
384
|
+
try {
|
|
385
|
+
const res = await fetchImpl(`https://api.telegram.org/bot${botToken}/getMe`, { method: "GET" });
|
|
386
|
+
const body = await res.json();
|
|
387
|
+
if (!body.ok) return { ok: false, error: body.description || "getMe failed" };
|
|
388
|
+
return { ok: true, username: body.result.username, firstName: body.result.first_name };
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return { ok: false, error: error.message };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Register the webhook URL with Telegram. Does not throw on failure —
|
|
396
|
+
* returns { ok: boolean, error?: string } so the caller can set a status flag.
|
|
397
|
+
*/
|
|
398
|
+
export async function registerWebhook(botToken, publicUrl, { fetchImpl = globalThis.fetch } = {}) {
|
|
399
|
+
try {
|
|
400
|
+
const url = `https://api.telegram.org/bot${botToken}/setWebhook`;
|
|
401
|
+
// SSRF note: publicUrl is forwarded to Telegram in JSON only. Nemoris does
|
|
402
|
+
// not probe or fetch the operator callback URL on the server side.
|
|
403
|
+
const webhookBody = { url: `${publicUrl}/telegram/webhook` };
|
|
404
|
+
const webhookSecret = process.env.NEMORIS_TELEGRAM_WEBHOOK_SECRET;
|
|
405
|
+
if (webhookSecret) {
|
|
406
|
+
webhookBody.secret_token = webhookSecret;
|
|
407
|
+
}
|
|
408
|
+
const response = await fetchImpl(url, {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "content-type": "application/json" },
|
|
411
|
+
body: JSON.stringify(webhookBody),
|
|
412
|
+
});
|
|
413
|
+
const raw = await response.text();
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
return { ok: false, error: `setWebhook failed (${response.status}): ${raw}` };
|
|
416
|
+
}
|
|
417
|
+
return { ok: true };
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return { ok: false, error: error.message };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get the chat info of the most recent message sender. Used during setup.
|
|
425
|
+
* Temporarily removes the webhook, calls getUpdates, then re-registers.
|
|
426
|
+
*/
|
|
427
|
+
export async function whoami(botToken, { fetchImpl = globalThis.fetch, webhookUrl = null } = {}) {
|
|
428
|
+
const api = (method) => `https://api.telegram.org/bot${botToken}/${method}`;
|
|
429
|
+
|
|
430
|
+
await fetchImpl(api("deleteWebhook"), { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
431
|
+
|
|
432
|
+
const updatesRes = await fetchImpl(api("getUpdates"), {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: { "content-type": "application/json" },
|
|
435
|
+
body: JSON.stringify({ limit: 1, offset: -1 }),
|
|
436
|
+
});
|
|
437
|
+
const updatesBody = JSON.parse(await updatesRes.text());
|
|
438
|
+
|
|
439
|
+
if (webhookUrl) {
|
|
440
|
+
// Same SSRF note as registerWebhook: webhookUrl is serialized back to
|
|
441
|
+
// Telegram and never fetched by Nemoris.
|
|
442
|
+
await fetchImpl(api("setWebhook"), {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: { "content-type": "application/json" },
|
|
445
|
+
body: JSON.stringify({ url: `${webhookUrl}/telegram/webhook` }),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const updates = updatesBody?.result || [];
|
|
450
|
+
if (updates.length === 0) return null;
|
|
451
|
+
|
|
452
|
+
const chat = updates[0]?.message?.chat;
|
|
453
|
+
if (!chat) return null;
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
chatId: chat.id,
|
|
457
|
+
firstName: chat.first_name || "",
|
|
458
|
+
username: chat.username || "",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Long-polling loop: calls getUpdates with a 30s timeout, feeds each
|
|
464
|
+
* update through handler.handleUpdate(), and advances the offset.
|
|
465
|
+
* No webhook or tunnel required — ideal for home-machine deployment.
|
|
466
|
+
*
|
|
467
|
+
* Returns a stop() function that cleanly terminates the loop.
|
|
468
|
+
*/
|
|
469
|
+
export function startTelegramPolling({ botToken, handler, logger = console, fetchImpl = globalThis.fetch }) {
|
|
470
|
+
const api = (method) => `https://api.telegram.org/bot${botToken}/${method}`;
|
|
471
|
+
let running = true;
|
|
472
|
+
let offset = 0;
|
|
473
|
+
const stateStore = handler?.store || null;
|
|
474
|
+
|
|
475
|
+
const persistOffset = async (nextOffset) => {
|
|
476
|
+
if (!stateStore || typeof stateStore.setMeta !== "function") {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
await stateStore.setMeta("telegram_polling_cursor", { offset: nextOffset });
|
|
481
|
+
} catch (err) {
|
|
482
|
+
logger.error(JSON.stringify({ service: "telegram_polling", event: "offset_persist_error", error: err.message }));
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const poll = async () => {
|
|
487
|
+
if (stateStore && typeof stateStore.getMeta === "function") {
|
|
488
|
+
try {
|
|
489
|
+
const cursor = await stateStore.getMeta("telegram_polling_cursor", null);
|
|
490
|
+
const persistedOffset = Number(cursor?.offset || 0);
|
|
491
|
+
if (Number.isInteger(persistedOffset) && persistedOffset > 0) {
|
|
492
|
+
offset = persistedOffset;
|
|
493
|
+
}
|
|
494
|
+
} catch (err) {
|
|
495
|
+
logger.error(JSON.stringify({ service: "telegram_polling", event: "offset_restore_error", error: err.message }));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Clear any existing webhook so Telegram sends updates via getUpdates
|
|
500
|
+
try {
|
|
501
|
+
await fetchImpl(api("deleteWebhook"), {
|
|
502
|
+
method: "POST",
|
|
503
|
+
headers: { "content-type": "application/json" },
|
|
504
|
+
body: JSON.stringify({ drop_pending_updates: false }),
|
|
505
|
+
});
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.error(JSON.stringify({ service: "telegram_polling", event: "deleteWebhook_failed", error: err.message }));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Register slash commands — never blocks startup on failure
|
|
511
|
+
await setupBotCommands(botToken, { fetchImpl, logger });
|
|
512
|
+
|
|
513
|
+
while (running) {
|
|
514
|
+
try {
|
|
515
|
+
const res = await fetchImpl(api("getUpdates"), {
|
|
516
|
+
method: "POST",
|
|
517
|
+
headers: { "content-type": "application/json" },
|
|
518
|
+
body: JSON.stringify({ offset, timeout: 30, allowed_updates: ["message"] }),
|
|
519
|
+
signal: AbortSignal.timeout(35000), // 30s long poll + 5s buffer
|
|
520
|
+
});
|
|
521
|
+
const body = await res.json();
|
|
522
|
+
const updates = body?.result || [];
|
|
523
|
+
|
|
524
|
+
for (const update of updates) {
|
|
525
|
+
offset = update.update_id + 1;
|
|
526
|
+
await persistOffset(offset);
|
|
527
|
+
try {
|
|
528
|
+
const result = await handler.handleUpdate(update);
|
|
529
|
+
const chatId = update?.message?.chat?.id;
|
|
530
|
+
const messageId = update?.message?.message_id;
|
|
531
|
+
|
|
532
|
+
// React 👀 on enqueue (job accepted, processing starting)
|
|
533
|
+
if (result?.action === "enqueued" && chatId && messageId) {
|
|
534
|
+
fetchImpl(`https://api.telegram.org/bot${botToken}/setMessageReaction`, {
|
|
535
|
+
method: "POST",
|
|
536
|
+
headers: { "Content-Type": "application/json" },
|
|
537
|
+
body: JSON.stringify({ chat_id: chatId, message_id: messageId, reaction: [{ type: "emoji", emoji: "👀" }] }),
|
|
538
|
+
}).catch(() => {}); // reactions are best-effort
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Send command replies immediately (slash commands don't go through the drain loop)
|
|
542
|
+
if (result?.action === "command" && result?.reply) {
|
|
543
|
+
if (chatId) {
|
|
544
|
+
await sendSplitTelegramText(
|
|
545
|
+
fetchImpl,
|
|
546
|
+
`https://api.telegram.org/bot${botToken}/sendMessage`,
|
|
547
|
+
chatId,
|
|
548
|
+
result.reply,
|
|
549
|
+
).catch(err => logger.error(JSON.stringify({ service: "telegram_polling", event: "command_reply_error", error: err.message })));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Send rejection replies (e.g. edited messages)
|
|
553
|
+
if (result?.action === "rejected" && result?.reply) {
|
|
554
|
+
const replyChatId = chatId || update?.edited_message?.chat?.id;
|
|
555
|
+
if (replyChatId) {
|
|
556
|
+
await sendSplitTelegramText(
|
|
557
|
+
fetchImpl,
|
|
558
|
+
`https://api.telegram.org/bot${botToken}/sendMessage`,
|
|
559
|
+
replyChatId,
|
|
560
|
+
result.reply,
|
|
561
|
+
).catch(err => logger.error(JSON.stringify({ service: "telegram_polling", event: "rejection_reply_error", error: err.message })));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Send queued ack when user sends a message while a job is running
|
|
565
|
+
if (result?.queuedAck) {
|
|
566
|
+
if (chatId) {
|
|
567
|
+
await sendSplitTelegramText(
|
|
568
|
+
fetchImpl,
|
|
569
|
+
`https://api.telegram.org/bot${botToken}/sendMessage`,
|
|
570
|
+
chatId,
|
|
571
|
+
result.queuedAck,
|
|
572
|
+
).catch(err => logger.error(JSON.stringify({ service: "telegram_polling", event: "queued_ack_error", error: err.message })));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch (err) {
|
|
576
|
+
logger.error(JSON.stringify({ service: "telegram_polling", event: "handleUpdate_error", error: err.message }));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
} catch (err) {
|
|
580
|
+
if (!running) break;
|
|
581
|
+
// Network error or timeout — back off briefly then retry
|
|
582
|
+
logger.error(JSON.stringify({ service: "telegram_polling", event: "poll_error", error: err.message }));
|
|
583
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const promise = poll();
|
|
589
|
+
return {
|
|
590
|
+
stop: () => { running = false; return promise; },
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export class TelegramInboundHandler {
|
|
595
|
+
constructor({
|
|
596
|
+
stateStore,
|
|
597
|
+
telegramConfig,
|
|
598
|
+
availablePeers = [],
|
|
599
|
+
agentHealthCheck = null,
|
|
600
|
+
logger = console,
|
|
601
|
+
onRestart = null,
|
|
602
|
+
contextLedger = null,
|
|
603
|
+
agentNames = {},
|
|
604
|
+
routerConfig = null,
|
|
605
|
+
agentConfigs = null,
|
|
606
|
+
providerConfigs = null,
|
|
607
|
+
deps = null,
|
|
608
|
+
execApprovalGate = null,
|
|
609
|
+
stopPolling = null,
|
|
610
|
+
}) {
|
|
611
|
+
this.store = stateStore;
|
|
612
|
+
this.config = telegramConfig;
|
|
613
|
+
this.availablePeers = availablePeers;
|
|
614
|
+
this.agentHealthCheck = agentHealthCheck;
|
|
615
|
+
this.logger = logger;
|
|
616
|
+
this.onRestart = onRestart;
|
|
617
|
+
this.contextLedger = contextLedger;
|
|
618
|
+
this.agentNames = agentNames;
|
|
619
|
+
this.routerConfig = routerConfig;
|
|
620
|
+
this.agentConfigs = agentConfigs;
|
|
621
|
+
this.providerConfigs = providerConfigs;
|
|
622
|
+
this.deps = deps || {};
|
|
623
|
+
this.operatorChatId = String(telegramConfig?.operatorChatId || "");
|
|
624
|
+
this.botToken = telegramConfig?.botTokenEnv ? process.env[telegramConfig.botTokenEnv] || null : null;
|
|
625
|
+
this._execApprovalGate = execApprovalGate || null;
|
|
626
|
+
this.stopPolling = stopPolling || onRestart || null;
|
|
627
|
+
// Rate limiter: Map<chatId, { count, windowStart }>
|
|
628
|
+
this._rateLimitMax = Number(process.env.NEMORIS_RATE_LIMIT_MAX ?? 10);
|
|
629
|
+
this._rateLimitWindowMs = Number(process.env.NEMORIS_RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
630
|
+
this._rateBuckets = new Map();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
setHealingDeps({ repairLog, ruleStaging, queueNurseJob }) {
|
|
634
|
+
this.repairLog = repairLog;
|
|
635
|
+
this.ruleStaging = ruleStaging;
|
|
636
|
+
this.queueNurseJob = queueNurseJob;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
_checkRateLimit(chatId) {
|
|
640
|
+
const now = Date.now();
|
|
641
|
+
const bucket = this._rateBuckets.get(chatId);
|
|
642
|
+
if (!bucket || now - bucket.windowStart >= this._rateLimitWindowMs) {
|
|
643
|
+
this._rateBuckets.set(chatId, { count: 1, windowStart: now });
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
if (bucket.count >= this._rateLimitMax) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
bucket.count += 1;
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
_ensureSession(chatId) {
|
|
654
|
+
let session = this.store.getChatSession(chatId);
|
|
655
|
+
if (!session && this._isAuthorized(chatId)) {
|
|
656
|
+
this.store.bindChatSession({
|
|
657
|
+
chatId,
|
|
658
|
+
agentId: this.config.defaultAgent,
|
|
659
|
+
boundBy: "default",
|
|
660
|
+
});
|
|
661
|
+
session = this.store.getChatSession(chatId);
|
|
662
|
+
}
|
|
663
|
+
return session;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
_setSessionModel(chatId, modelOverride) {
|
|
667
|
+
this.store.db.prepare("UPDATE chat_sessions SET model_override = ? WHERE chat_id = ?")
|
|
668
|
+
.run(modelOverride, chatId);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
_setSessionThink(chatId, thinkMode) {
|
|
672
|
+
this.store.db.prepare("UPDATE chat_sessions SET think_mode = ? WHERE chat_id = ?")
|
|
673
|
+
.run(thinkMode, chatId);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
_setSessionFocus(chatId, focusMode) {
|
|
677
|
+
this.store.db.prepare("UPDATE chat_sessions SET focus_mode = ? WHERE chat_id = ?")
|
|
678
|
+
.run(focusMode, chatId);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
_buildModelCatalog() {
|
|
682
|
+
const byProvider = new Map();
|
|
683
|
+
const addModel = (providerId, modelId) => {
|
|
684
|
+
if (!providerId || !modelId) return;
|
|
685
|
+
if (!byProvider.has(providerId)) {
|
|
686
|
+
byProvider.set(providerId, new Set());
|
|
687
|
+
}
|
|
688
|
+
byProvider.get(providerId).add(modelId);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
for (const [providerId, providerConfig] of Object.entries(this.providerConfigs || {})) {
|
|
692
|
+
for (const model of Object.values(providerConfig?.models || {})) {
|
|
693
|
+
if (model?.id) addModel(providerId, model.id);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
for (const lane of Object.values(this.routerConfig || {})) {
|
|
698
|
+
for (const modelId of [lane?.primary, lane?.manualBump, lane?.fallback]) {
|
|
699
|
+
if (typeof modelId !== "string" || !modelId.includes("/")) continue;
|
|
700
|
+
addModel(modelId.split("/", 1)[0], modelId);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return new Map(
|
|
705
|
+
[...byProvider.entries()].map(([providerId, models]) => [
|
|
706
|
+
providerId,
|
|
707
|
+
[...models].sort((left, right) => left.localeCompare(right)),
|
|
708
|
+
])
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
_buildProviderPickerReply() {
|
|
713
|
+
const catalog = this._buildModelCatalog();
|
|
714
|
+
const buttons = [...catalog.keys()].sort().map((providerId) => ({
|
|
715
|
+
text: `${providerId} (${catalog.get(providerId)?.length || 0})`,
|
|
716
|
+
callback_data: `${CALLBACK_PREFIX.modelList}${providerId}`,
|
|
717
|
+
}));
|
|
718
|
+
return {
|
|
719
|
+
action: "command",
|
|
720
|
+
command: "model",
|
|
721
|
+
reply: "Select a provider:",
|
|
722
|
+
replyMarkup: { inline_keyboard: chunkButtons(buttons, 2) },
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
_buildModelListReply(providerId, session) {
|
|
727
|
+
const models = this._buildModelCatalog().get(providerId) || [];
|
|
728
|
+
const buttons = models.map((modelId, index) => ({
|
|
729
|
+
text: session?.model_override === modelId ? `${modelId} ✓` : modelId,
|
|
730
|
+
callback_data: `${CALLBACK_PREFIX.modelSet}${providerId}:${index}`,
|
|
731
|
+
}));
|
|
732
|
+
return {
|
|
733
|
+
action: "command",
|
|
734
|
+
command: "model",
|
|
735
|
+
reply: `Select a model from ${providerId}:`,
|
|
736
|
+
replyMarkup: { inline_keyboard: chunkButtons(buttons, 1) },
|
|
737
|
+
editMessage: true,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
_handleInlineCallback(chatId, callbackData) {
|
|
742
|
+
const session = this._ensureSession(chatId);
|
|
743
|
+
if (!session || !callbackData) {
|
|
744
|
+
return { action: "ignored", reason: "unknown_callback" };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (callbackData.type === "model_list") {
|
|
748
|
+
return this._buildModelListReply(callbackData.providerId, session);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (callbackData.type === "model_set") {
|
|
752
|
+
const models = this._buildModelCatalog().get(callbackData.providerId) || [];
|
|
753
|
+
const modelId = models[callbackData.index];
|
|
754
|
+
if (!modelId) {
|
|
755
|
+
return { action: "ignored", reason: "unknown_model_choice" };
|
|
756
|
+
}
|
|
757
|
+
this._setSessionModel(chatId, modelId);
|
|
758
|
+
return {
|
|
759
|
+
action: "command",
|
|
760
|
+
command: "model",
|
|
761
|
+
reply: `Model set to ${modelId} for this session.`,
|
|
762
|
+
editMessage: true,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (callbackData.type === "switch_set") {
|
|
767
|
+
if (!this.availablePeers.includes(callbackData.agentId)) {
|
|
768
|
+
return { action: "ignored", reason: "unknown_agent_choice" };
|
|
769
|
+
}
|
|
770
|
+
this.store.bindChatSession({ chatId, agentId: callbackData.agentId, boundBy: "user" });
|
|
771
|
+
return {
|
|
772
|
+
action: "command",
|
|
773
|
+
command: "switch",
|
|
774
|
+
reply: `Switched to ${callbackData.agentId}.`,
|
|
775
|
+
editMessage: true,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (callbackData.type === "think_set" && THINK_LEVELS.includes(callbackData.level)) {
|
|
780
|
+
const value = callbackData.level === "default" ? null : callbackData.level;
|
|
781
|
+
this._setSessionThink(chatId, value);
|
|
782
|
+
return {
|
|
783
|
+
action: "command",
|
|
784
|
+
command: "think",
|
|
785
|
+
reply: `Thinking mode set to: ${callbackData.level === "default" ? "default (off)" : callbackData.level}`,
|
|
786
|
+
editMessage: true,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (callbackData.type === "focus_set" && FOCUS_LEVELS.includes(callbackData.mode)) {
|
|
791
|
+
const value = callbackData.mode === "off" ? null : callbackData.mode;
|
|
792
|
+
this._setSessionFocus(chatId, value);
|
|
793
|
+
return {
|
|
794
|
+
action: "command",
|
|
795
|
+
command: "focus",
|
|
796
|
+
reply: `Focus mode set to: ${callbackData.mode}`,
|
|
797
|
+
editMessage: true,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return { action: "ignored", reason: "unknown_callback" };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async handleUpdate(update) {
|
|
805
|
+
if (update?.callback_query) {
|
|
806
|
+
const callbackQuery = update.callback_query;
|
|
807
|
+
const chatId = String(callbackQuery?.message?.chat?.id || "");
|
|
808
|
+
if (!chatId) {
|
|
809
|
+
return { action: "ignored", reason: "no_callback_chat" };
|
|
810
|
+
}
|
|
811
|
+
if (!this._isAuthorized(chatId)) {
|
|
812
|
+
return { action: "rejected", reason: "unauthorized", reply: "❌ Unauthorized." };
|
|
813
|
+
}
|
|
814
|
+
return this._handleInlineCallback(chatId, parseCallbackData(callbackQuery.data));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// 1a. Detect edited messages — reply with guidance, don't enqueue
|
|
818
|
+
if (update?.edited_message) {
|
|
819
|
+
return {
|
|
820
|
+
action: "rejected",
|
|
821
|
+
reason: "edited_message",
|
|
822
|
+
reply: "I don't re-process edited messages — send a new message.",
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 1b. Validate: must have message with text or photo
|
|
827
|
+
const message = update?.message;
|
|
828
|
+
if (!message) {
|
|
829
|
+
return { action: "ignored", reason: "no_message" };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const text = message.text || message.caption || null;
|
|
833
|
+
const photo = message.photo;
|
|
834
|
+
|
|
835
|
+
if (!text && !photo) {
|
|
836
|
+
return { action: "ignored", reason: "no_content" };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const chatId = String(message.chat.id);
|
|
840
|
+
const messageId = message.message_id;
|
|
841
|
+
const jobId = `telegram-${chatId}-${messageId}`;
|
|
842
|
+
|
|
843
|
+
// 2. Deduplicate
|
|
844
|
+
if (this.store.interactiveJobExists(jobId)) {
|
|
845
|
+
return { action: "deduplicated", jobId };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
let session = this._ensureSession(chatId);
|
|
849
|
+
|
|
850
|
+
// 3. Parse commands (stub — expanded in Task 8)
|
|
851
|
+
if (text && text.startsWith("/")) {
|
|
852
|
+
const commandName = text.trim().split(/\s+/, 1)[0].toLowerCase();
|
|
853
|
+
if (!this._isAuthorized(chatId) && !PRE_AUTH_COMMANDS.has(commandName)) {
|
|
854
|
+
this.logger.warn(`[telegram-inbound] Unauthorized chat_id: ${chatId} attempted ${commandName}`);
|
|
855
|
+
return { action: "rejected", reason: "unauthorized", reply: "❌ Unauthorized." };
|
|
856
|
+
}
|
|
857
|
+
if (OPERATOR_ONLY_COMMANDS.has(commandName) && chatId !== String(this.config.operatorChatId)) {
|
|
858
|
+
return { action: "command", command: commandName.slice(1), reply: "❌ This command is operator-only." };
|
|
859
|
+
}
|
|
860
|
+
const commandResult = await this._handleCommand(text, chatId);
|
|
861
|
+
if (commandResult) return commandResult;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// 3b. Detect inter-session message header
|
|
865
|
+
const interSession = text ? parseInterSessionHeader(text) : null;
|
|
866
|
+
const cleanInput = interSession ? stripInterSessionHeader(text) : (text || "[Photo]");
|
|
867
|
+
|
|
868
|
+
// 4. Look up or create session
|
|
869
|
+
session = session || this.store.getChatSession(chatId);
|
|
870
|
+
|
|
871
|
+
// 6. Reject unauthorized
|
|
872
|
+
if (!session) {
|
|
873
|
+
this.logger.warn(`[telegram-inbound] Unauthorized chat_id: ${chatId}`);
|
|
874
|
+
return { action: "rejected", reason: "unauthorized" };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const agentId = session.agent_id;
|
|
878
|
+
|
|
879
|
+
// 7. Check agent availability
|
|
880
|
+
if (this.agentHealthCheck) {
|
|
881
|
+
const health = this.agentHealthCheck(agentId);
|
|
882
|
+
if (!health.available) {
|
|
883
|
+
this.logger.warn(`[telegram-inbound] Agent unavailable: ${agentId} — ${health.reason}`);
|
|
884
|
+
return {
|
|
885
|
+
action: "agent_unavailable",
|
|
886
|
+
agentId,
|
|
887
|
+
reason: health.reason,
|
|
888
|
+
reply: `Agent ${agentId} is no longer available. Use /switch to choose another.`,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// 8. Rate limit check
|
|
894
|
+
if (!this._checkRateLimit(chatId)) {
|
|
895
|
+
this.logger.warn(`[telegram-inbound] Rate limit exceeded for chat_id: ${chatId}`);
|
|
896
|
+
return {
|
|
897
|
+
action: "rate_limited",
|
|
898
|
+
chatId,
|
|
899
|
+
reply: "Slow down — max 10 messages per minute.",
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// 9. Check for running job — send queued ack if busy
|
|
904
|
+
let queuedAck;
|
|
905
|
+
try {
|
|
906
|
+
const runningJob = this.store.db.prepare(
|
|
907
|
+
"SELECT job_id FROM interactive_jobs WHERE chat_id = ? AND status = 'running' LIMIT 1"
|
|
908
|
+
).get(chatId);
|
|
909
|
+
if (runningJob) {
|
|
910
|
+
queuedAck = "On it — finishing current task first, you're queued 🔄";
|
|
911
|
+
}
|
|
912
|
+
} catch (_) {}
|
|
913
|
+
|
|
914
|
+
// 10. Extract image refs from photo array (largest = last element)
|
|
915
|
+
let imageRefs = null;
|
|
916
|
+
if (photo && photo.length > 0) {
|
|
917
|
+
const largest = photo[photo.length - 1];
|
|
918
|
+
imageRefs = JSON.stringify([{
|
|
919
|
+
fileId: largest.file_id,
|
|
920
|
+
mediaType: "image/jpeg",
|
|
921
|
+
}]);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 11. Enqueue job
|
|
925
|
+
this.store.enqueueInteractiveJob({
|
|
926
|
+
jobId, agentId, input: cleanInput, source: "telegram", chatId,
|
|
927
|
+
imageRefs,
|
|
928
|
+
sourceType: interSession?.sourceType || 'human',
|
|
929
|
+
replySessionKey: interSession?.replySessionKey || null,
|
|
930
|
+
sourceAgentId: interSession?.sourceAgentId || null,
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
return { action: "enqueued", jobId, agentId, chatId, queuedAck };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
_isAuthorized(chatId) {
|
|
937
|
+
if (String(this.config.operatorChatId) === chatId) return true;
|
|
938
|
+
const authorized = this.config.authorizedChatIds || [];
|
|
939
|
+
return authorized.map(String).includes(chatId);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async _handleCommand(text, chatId) {
|
|
943
|
+
const parts = text.trim().split(/\s+/);
|
|
944
|
+
const cmd = parts[0].toLowerCase();
|
|
945
|
+
|
|
946
|
+
if (cmd === "/start") {
|
|
947
|
+
return {
|
|
948
|
+
action: "command",
|
|
949
|
+
command: "start",
|
|
950
|
+
reply: "Welcome to Nemoris. Use /help to see the available commands.",
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// /version works without a session
|
|
955
|
+
if (cmd === "/version") {
|
|
956
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url)));
|
|
957
|
+
const uptimeSecs = Math.floor(process.uptime());
|
|
958
|
+
const h = Math.floor(uptimeSecs / 3600);
|
|
959
|
+
const m = Math.floor((uptimeSecs % 3600) / 60);
|
|
960
|
+
const s = uptimeSecs % 60;
|
|
961
|
+
const uptimeStr = h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
962
|
+
return { action: "command", command: "version", reply: `Nemoris v${pkg.version} • uptime ${uptimeStr}` };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// /logs works without a session
|
|
966
|
+
if (cmd === "/logs") {
|
|
967
|
+
const lineCount = Math.min(Math.max(parseInt(parts[1], 10) || 20, 1), 100);
|
|
968
|
+
const logPath = path.join(this.store.rootDir, "daemon.out.log");
|
|
969
|
+
try {
|
|
970
|
+
const content = fs.readFileSync(logPath, "utf-8");
|
|
971
|
+
const lines = content.split("\n").filter(Boolean);
|
|
972
|
+
const tail = lines.slice(-lineCount);
|
|
973
|
+
if (tail.length === 0) {
|
|
974
|
+
return { action: "command", command: "logs", reply: "Log file is empty." };
|
|
975
|
+
}
|
|
976
|
+
let output = tail.join("\n");
|
|
977
|
+
if (output.length > 4000) {
|
|
978
|
+
output = output.slice(-4000);
|
|
979
|
+
output = "…" + output.slice(output.indexOf("\n") + 1);
|
|
980
|
+
}
|
|
981
|
+
return { action: "command", command: "logs", reply: output };
|
|
982
|
+
} catch (err) {
|
|
983
|
+
if (err.code === "ENOENT") {
|
|
984
|
+
return { action: "command", command: "logs", reply: "No log file found. Is the daemon running?" };
|
|
985
|
+
}
|
|
986
|
+
return { action: "command", command: "logs", reply: `Failed to read logs: ${err.message}` };
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Commands require an existing session
|
|
991
|
+
const session = this.store.getChatSession(chatId);
|
|
992
|
+
if (!session) return null; // falls through to authorization check
|
|
993
|
+
|
|
994
|
+
switch (cmd) {
|
|
995
|
+
case "/switch": {
|
|
996
|
+
const targetAgent = parts[1];
|
|
997
|
+
if (!targetAgent) {
|
|
998
|
+
return {
|
|
999
|
+
action: "command",
|
|
1000
|
+
command: "switch",
|
|
1001
|
+
reply: "Select an agent:",
|
|
1002
|
+
replyMarkup: {
|
|
1003
|
+
inline_keyboard: chunkButtons(
|
|
1004
|
+
this.availablePeers.map((agentId) => ({
|
|
1005
|
+
text: agentId,
|
|
1006
|
+
callback_data: `${CALLBACK_PREFIX.switchSet}${agentId}`,
|
|
1007
|
+
})),
|
|
1008
|
+
2
|
|
1009
|
+
),
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
if (!this.availablePeers.includes(targetAgent)) {
|
|
1014
|
+
return { action: "command", command: "switch", reply: `Unknown agent "${targetAgent}". Use /agents to see available agents.` };
|
|
1015
|
+
}
|
|
1016
|
+
this.store.bindChatSession({ chatId, agentId: targetAgent, boundBy: "user" });
|
|
1017
|
+
return { action: "command", command: "switch", reply: `Switched to ${targetAgent}.` };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
case "/model": {
|
|
1021
|
+
const requestedModel = parts[1];
|
|
1022
|
+
const tier = requestedModel?.toLowerCase();
|
|
1023
|
+
const validTiers = ["sonnet", "haiku", "opus", "default"];
|
|
1024
|
+
if (!requestedModel) {
|
|
1025
|
+
return this._buildProviderPickerReply();
|
|
1026
|
+
}
|
|
1027
|
+
if (!requestedModel.includes("/") && !validTiers.includes(tier)) {
|
|
1028
|
+
return { action: "command", command: "model", reply: "Usage: /model <tier-or-model-id>\nAvailable tiers: sonnet, haiku, opus, default" };
|
|
1029
|
+
}
|
|
1030
|
+
const resolvedModel = requestedModel.includes("/")
|
|
1031
|
+
? requestedModel
|
|
1032
|
+
: resolveModelOverride(tier, { routerConfig: this.routerConfig });
|
|
1033
|
+
|
|
1034
|
+
this._setSessionModel(chatId, resolvedModel);
|
|
1035
|
+
const displayName = resolvedModel || "default";
|
|
1036
|
+
return { action: "command", command: "model", reply: `Model set to ${displayName} for this session.` };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
case "/who": {
|
|
1040
|
+
const method = session.bound_by === "default" ? "Default"
|
|
1041
|
+
: session.bound_by === "user" ? "You switched"
|
|
1042
|
+
: session.bound_by === "agent_handoff" ? `Handed off by ${session.previous_agent_id || "unknown"}`
|
|
1043
|
+
: session.bound_by;
|
|
1044
|
+
const resolvedModelOverride = resolveModelOverride(session.model_override, { routerConfig: this.routerConfig });
|
|
1045
|
+
const modelDisplay = resolvedModelOverride ? ` [model: ${resolvedModelOverride}]` : "";
|
|
1046
|
+
const displayName = this.agentNames?.[session.agent_id] || session.agent_id;
|
|
1047
|
+
return { action: "command", command: "who", reply: `Current agent: ${displayName}${modelDisplay} (${method})` };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
case "/status": {
|
|
1051
|
+
const lines = [];
|
|
1052
|
+
|
|
1053
|
+
// Line 1: Agent + Model
|
|
1054
|
+
const modelDisplay = resolveStatusModel(session, {
|
|
1055
|
+
routerConfig: this.routerConfig,
|
|
1056
|
+
agentConfigs: this.agentConfigs,
|
|
1057
|
+
});
|
|
1058
|
+
lines.push(`🤖 Agent: ${session.agent_id} · Model: ${modelDisplay}`);
|
|
1059
|
+
|
|
1060
|
+
// Line 2: Auth — last used provider from usage_log
|
|
1061
|
+
let authLine = "🔑 Auth: unknown";
|
|
1062
|
+
try {
|
|
1063
|
+
const lastRow = this.store.db.prepare(
|
|
1064
|
+
"SELECT provider FROM usage_log ORDER BY ts DESC LIMIT 1"
|
|
1065
|
+
).get();
|
|
1066
|
+
if (lastRow) authLine = `🔑 Auth: ${lastRow.provider}:direct`;
|
|
1067
|
+
} catch { /* ignore */ }
|
|
1068
|
+
lines.push(authLine);
|
|
1069
|
+
|
|
1070
|
+
if (this.store.db) {
|
|
1071
|
+
try {
|
|
1072
|
+
const startOfToday = new Date();
|
|
1073
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
1074
|
+
const ts = startOfToday.getTime();
|
|
1075
|
+
|
|
1076
|
+
// Line 3: Cache hit (today's data from usage_log)
|
|
1077
|
+
const cacheRow = this.store.db.prepare(`
|
|
1078
|
+
SELECT
|
|
1079
|
+
sum(cache_in) as total_cache_in,
|
|
1080
|
+
sum(tokens_in) as total_tokens_in
|
|
1081
|
+
FROM usage_log WHERE ts >= ?
|
|
1082
|
+
`).get(ts);
|
|
1083
|
+
if (cacheRow && (cacheRow.total_cache_in > 0 || cacheRow.total_tokens_in > 0)) {
|
|
1084
|
+
const cIn = cacheRow.total_cache_in || 0;
|
|
1085
|
+
const tIn = cacheRow.total_tokens_in || 0;
|
|
1086
|
+
const hitPct = (cIn + tIn) > 0 ? Math.round((cIn / (cIn + tIn)) * 100) : 0;
|
|
1087
|
+
lines.push(`🗄 Cache: ${hitPct}% hit · ${Math.round(cIn / 1000)}k cached, ${Math.round(tIn / 1000)}k new`);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Line 4: Context window (session lifetime) + Compactions
|
|
1091
|
+
const sessionId = chatId;
|
|
1092
|
+
const ctxRow = this.store.db.prepare(`
|
|
1093
|
+
SELECT sum(tokens_in + tokens_out + coalesce(cache_in,0)) as ctx_tokens
|
|
1094
|
+
FROM usage_log WHERE session_id = ?
|
|
1095
|
+
`).get(sessionId);
|
|
1096
|
+
const ctxTokens = ctxRow?.ctx_tokens || 0;
|
|
1097
|
+
const ctxLimit = getContextLimit(modelDisplay);
|
|
1098
|
+
const ctxPct = ctxLimit > 0 ? Math.round((ctxTokens / ctxLimit) * 100) : 0;
|
|
1099
|
+
let compactCount = 0;
|
|
1100
|
+
try {
|
|
1101
|
+
if (this.contextLedger?.db) {
|
|
1102
|
+
const cRow = this.contextLedger.db.prepare(
|
|
1103
|
+
"SELECT COUNT(*) as cnt FROM context_summaries WHERE session_id = ?"
|
|
1104
|
+
).get(sessionId);
|
|
1105
|
+
compactCount = cRow?.cnt || 0;
|
|
1106
|
+
}
|
|
1107
|
+
} catch { /* context_summaries may not exist */ }
|
|
1108
|
+
lines.push(`📚 Context: ${Math.round(ctxTokens / 1000)}k/${Math.round(ctxLimit / 1000)}k (${ctxPct}%) · 🧹 Compactions: ${compactCount}`);
|
|
1109
|
+
|
|
1110
|
+
// Today usage
|
|
1111
|
+
const todayRow = this.store.db.prepare(`
|
|
1112
|
+
SELECT count(*) as turns, sum(tokens_in + tokens_out) as total_tokens, sum(cost_usd) as total_cost
|
|
1113
|
+
FROM usage_log WHERE ts >= ?
|
|
1114
|
+
`).get(ts);
|
|
1115
|
+
if (todayRow && todayRow.turns > 0) {
|
|
1116
|
+
const cost = (todayRow.total_cost || 0).toFixed(2);
|
|
1117
|
+
const tok = Math.round((todayRow.total_tokens || 0) / 1000);
|
|
1118
|
+
lines.push(`📊 Today: ${todayRow.turns} turns · ~${tok}k tokens · ~$${cost}`);
|
|
1119
|
+
}
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
this.logger.error(`[telegram-inbound] /status query failed: ${err.message}`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Line 5: Session + Focus
|
|
1126
|
+
const focusDisplay = session.focus_mode || "off";
|
|
1127
|
+
const sessionLabelPrefix = this.config.sessionLabelPrefix || "telegram";
|
|
1128
|
+
lines.push(`🧵 Session: ${sessionLabelPrefix}:${chatId} · Focus: ${focusDisplay}`);
|
|
1129
|
+
|
|
1130
|
+
// Line 6: Runtime + Queue
|
|
1131
|
+
const runtimeMode = resolveRuntimeMode(this.config.pollingMode);
|
|
1132
|
+
let queueCount = 0;
|
|
1133
|
+
try {
|
|
1134
|
+
const qRow = this.store.db.prepare(
|
|
1135
|
+
"SELECT COUNT(*) as cnt FROM interactive_jobs WHERE status = 'queued'"
|
|
1136
|
+
).get();
|
|
1137
|
+
queueCount = qRow?.cnt || 0;
|
|
1138
|
+
} catch { /* ignore */ }
|
|
1139
|
+
lines.push(`⚙️ Runtime: ${runtimeMode} · Queue: ${queueCount} pending`);
|
|
1140
|
+
|
|
1141
|
+
return { action: "command", command: "status", reply: lines.join("\n") };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
case "/usage": {
|
|
1145
|
+
if (!this.store.db) {
|
|
1146
|
+
return { action: "command", command: "usage", reply: "Usage data unavailable." };
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
const startOfToday = new Date();
|
|
1150
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
1151
|
+
const ts = startOfToday.getTime();
|
|
1152
|
+
|
|
1153
|
+
const todayRows = this.store.db.prepare(`
|
|
1154
|
+
SELECT model, count(*) as turns, sum(tokens_in + tokens_out) as total_tokens, sum(cost_usd) as total_cost
|
|
1155
|
+
FROM usage_log WHERE ts >= ?
|
|
1156
|
+
GROUP BY model ORDER BY total_tokens DESC
|
|
1157
|
+
`).all(ts);
|
|
1158
|
+
|
|
1159
|
+
const allRows = this.store.db.prepare(`
|
|
1160
|
+
SELECT model, count(*) as turns, sum(tokens_in + tokens_out) as total_tokens, sum(cost_usd) as total_cost
|
|
1161
|
+
FROM usage_log
|
|
1162
|
+
GROUP BY model ORDER BY total_tokens DESC
|
|
1163
|
+
`).all();
|
|
1164
|
+
|
|
1165
|
+
const fmt = (rows) => rows.length === 0
|
|
1166
|
+
? " (no data)"
|
|
1167
|
+
: rows.map(r => {
|
|
1168
|
+
const tok = Math.round((r.total_tokens || 0) / 1000);
|
|
1169
|
+
const cost = (r.total_cost || 0).toFixed(2);
|
|
1170
|
+
return ` ${r.model}: ${r.turns} turns · ${tok}k tokens · $${cost}`;
|
|
1171
|
+
}).join("\n");
|
|
1172
|
+
|
|
1173
|
+
const reply = [
|
|
1174
|
+
"📊 Usage — Today",
|
|
1175
|
+
fmt(todayRows),
|
|
1176
|
+
"",
|
|
1177
|
+
"📊 Usage — All time",
|
|
1178
|
+
fmt(allRows),
|
|
1179
|
+
].join("\n");
|
|
1180
|
+
|
|
1181
|
+
return { action: "command", command: "usage", reply };
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
this.logger.error(`[telegram-inbound] /usage failed: ${err.message}`);
|
|
1184
|
+
return { action: "command", command: "usage", reply: "Failed to load usage data. Try /restart if this keeps happening." };
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
case "/clear": {
|
|
1189
|
+
this.store.db.prepare(
|
|
1190
|
+
"UPDATE chat_sessions SET conversation_context = NULL WHERE chat_id = ?"
|
|
1191
|
+
).run(chatId);
|
|
1192
|
+
return { action: "command", command: "clear", reply: "Context cleared. Fresh start 🧹" };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
case "/stop": {
|
|
1196
|
+
const runningJobs = this.store.db.prepare(
|
|
1197
|
+
"SELECT job_id FROM interactive_jobs WHERE status = 'running' AND chat_id = ?"
|
|
1198
|
+
).all(chatId);
|
|
1199
|
+
if (runningJobs.length === 0) {
|
|
1200
|
+
return { action: "command", command: "stop", reply: "No active run to stop." };
|
|
1201
|
+
}
|
|
1202
|
+
const ids = runningJobs.map(r => r.job_id);
|
|
1203
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1204
|
+
this.store.db.prepare(
|
|
1205
|
+
`UPDATE interactive_jobs SET status = 'failed' WHERE job_id IN (${placeholders})`
|
|
1206
|
+
).run(...ids);
|
|
1207
|
+
return { action: "command", command: "stop", reply: `Stopping current run... ✋ (${ids.length} job(s) cancelled)` };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
case "/think": {
|
|
1211
|
+
const level = parts[1]?.toLowerCase();
|
|
1212
|
+
if (!level) {
|
|
1213
|
+
return {
|
|
1214
|
+
action: "command",
|
|
1215
|
+
command: "think",
|
|
1216
|
+
reply: "Select a thinking level:",
|
|
1217
|
+
replyMarkup: {
|
|
1218
|
+
inline_keyboard: chunkButtons(
|
|
1219
|
+
THINK_LEVELS.map((value) => ({
|
|
1220
|
+
text: value,
|
|
1221
|
+
callback_data: `${CALLBACK_PREFIX.thinkSet}${value}`,
|
|
1222
|
+
})),
|
|
1223
|
+
2
|
|
1224
|
+
),
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
if (!THINK_LEVELS.includes(level)) {
|
|
1229
|
+
return { action: "command", command: "think", reply: "Usage: /think off | low | medium | high | default" };
|
|
1230
|
+
}
|
|
1231
|
+
const thinkValue = level === "default" ? null : level;
|
|
1232
|
+
this._setSessionThink(chatId, thinkValue);
|
|
1233
|
+
const display = level === "default" ? "default (off)" : level;
|
|
1234
|
+
return { action: "command", command: "think", reply: `Thinking mode set to: ${display}` };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
case "/focus": {
|
|
1238
|
+
const mode = parts[1]?.toLowerCase();
|
|
1239
|
+
if (!mode) {
|
|
1240
|
+
return {
|
|
1241
|
+
action: "command",
|
|
1242
|
+
command: "focus",
|
|
1243
|
+
reply: "Select a focus mode:",
|
|
1244
|
+
replyMarkup: {
|
|
1245
|
+
inline_keyboard: chunkButtons(
|
|
1246
|
+
FOCUS_LEVELS.map((value) => ({
|
|
1247
|
+
text: value,
|
|
1248
|
+
callback_data: `${CALLBACK_PREFIX.focusSet}${value}`,
|
|
1249
|
+
})),
|
|
1250
|
+
2
|
|
1251
|
+
),
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
if (!FOCUS_LEVELS.includes(mode)) {
|
|
1256
|
+
return { action: "command", command: "focus", reply: "Usage: /focus deep | planning | off" };
|
|
1257
|
+
}
|
|
1258
|
+
const focusValue = mode === "off" ? null : mode;
|
|
1259
|
+
this._setSessionFocus(chatId, focusValue);
|
|
1260
|
+
return { action: "command", command: "focus", reply: `Focus mode set to: ${mode}` };
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
case "/compact": {
|
|
1264
|
+
const existing = session.conversation_context;
|
|
1265
|
+
let turns = [];
|
|
1266
|
+
try {
|
|
1267
|
+
turns = existing ? JSON.parse(existing) : [];
|
|
1268
|
+
if (!Array.isArray(turns)) turns = [];
|
|
1269
|
+
} catch { turns = []; }
|
|
1270
|
+
|
|
1271
|
+
if (turns.length <= 4) {
|
|
1272
|
+
return { action: "command", command: "compact", reply: "Context already compact — nothing to do." };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const beforeChars = (existing || "").length;
|
|
1276
|
+
const kept = turns.slice(-4);
|
|
1277
|
+
const compacted = JSON.stringify(kept);
|
|
1278
|
+
const afterChars = compacted.length;
|
|
1279
|
+
|
|
1280
|
+
this.store.db.prepare("UPDATE chat_sessions SET conversation_context = ? WHERE chat_id = ?")
|
|
1281
|
+
.run(compacted, chatId);
|
|
1282
|
+
|
|
1283
|
+
const beforeK = Math.round(beforeChars / 4 / 1000);
|
|
1284
|
+
const afterK = Math.round(afterChars / 4 / 1000);
|
|
1285
|
+
return {
|
|
1286
|
+
action: "command",
|
|
1287
|
+
command: "compact",
|
|
1288
|
+
reply: `Compacted 🗜️ — context reduced from ~${beforeK}k to ~${afterK}k tokens (kept last 4 turns)`,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
case "/agents": {
|
|
1293
|
+
const list = this.availablePeers.length > 0
|
|
1294
|
+
? this.availablePeers.join(", ")
|
|
1295
|
+
: "No agents configured";
|
|
1296
|
+
const lines = [`Available agents: ${list}`];
|
|
1297
|
+
try {
|
|
1298
|
+
const recentAgentJobs = this.store.getRecentAgentJobs(5);
|
|
1299
|
+
if (recentAgentJobs.length > 0) {
|
|
1300
|
+
lines.push("\nRecent inter-agent messages:");
|
|
1301
|
+
for (const j of recentAgentJobs) {
|
|
1302
|
+
const preview = String(j.input || "").slice(0, 40).replace(/\n/g, " ");
|
|
1303
|
+
const time = j.created_at ? new Date(j.created_at).toTimeString().slice(0, 5) : "?";
|
|
1304
|
+
const statusIcon = j.status === "succeeded" ? "✅" : j.status === "failed" ? "❌" : "⏳";
|
|
1305
|
+
lines.push(`📨 ${j.source_agent_id || "?"} → ${j.agent_id}: "${preview}..." (${time}, ${statusIcon})`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
} catch { /* ignore — observability only */ }
|
|
1309
|
+
return { action: "command", command: "agents", reply: lines.join("\n") };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
case "/inbox": {
|
|
1313
|
+
const lines = ["📨 Inter-agent messages"];
|
|
1314
|
+
try {
|
|
1315
|
+
const jobs = this.store.getRecentAgentJobs(10);
|
|
1316
|
+
if (jobs.length === 0) {
|
|
1317
|
+
lines.push("\nNo pending agent messages.");
|
|
1318
|
+
} else {
|
|
1319
|
+
for (const j of jobs) {
|
|
1320
|
+
const preview = String(j.input || "").slice(0, 80).replace(/\n/g, " ");
|
|
1321
|
+
const time = j.created_at ? new Date(j.created_at).toTimeString().slice(0, 5) : "?";
|
|
1322
|
+
const statusLabel = j.status === "succeeded" ? "✅ Replied" : j.status === "failed" ? "❌ Failed" : "⏳ Pending";
|
|
1323
|
+
lines.push(`\nFrom: ${j.source_agent_id || "unknown"} — ${time}`);
|
|
1324
|
+
lines.push(`"${preview}"`);
|
|
1325
|
+
lines.push(`Status: ${statusLabel}`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
lines.push("\nFailed to load inbox data.");
|
|
1330
|
+
this.logger.error(`[telegram-inbound] /inbox failed: ${err.message}`);
|
|
1331
|
+
}
|
|
1332
|
+
return { action: "command", command: "inbox", reply: lines.join("\n") };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
case "/restart": {
|
|
1336
|
+
const reason = parts.slice(1).join(" ").trim() || "telegram:/restart";
|
|
1337
|
+
clearCache();
|
|
1338
|
+
try {
|
|
1339
|
+
checkpointSqliteWal(this.store.db);
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
this.logger.error?.(`[telegram-inbound] /restart checkpoint failed: ${error.message}`);
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
this.stopPolling?.();
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
this.logger.error?.(`[telegram-inbound] /restart stopPolling failed: ${error.message}`);
|
|
1347
|
+
}
|
|
1348
|
+
try {
|
|
1349
|
+
const processImpl = this.deps?.processImpl || process;
|
|
1350
|
+
const spawnImpl = this.deps?.spawnImpl || spawn;
|
|
1351
|
+
const installDir = processImpl.env.NEMORIS_INSTALL_DIR || path.resolve(__dirname, "../..");
|
|
1352
|
+
const cliPath = path.join(installDir, "src", "cli.js");
|
|
1353
|
+
const child = spawnImpl(processImpl.execPath, [cliPath, "serve-daemon", "live"], {
|
|
1354
|
+
cwd: installDir,
|
|
1355
|
+
detached: true,
|
|
1356
|
+
stdio: "ignore",
|
|
1357
|
+
env: {
|
|
1358
|
+
...processImpl.env,
|
|
1359
|
+
NEMORIS_INSTALL_DIR: installDir,
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
child.unref?.();
|
|
1363
|
+
const stateDir = path.join(installDir, "state");
|
|
1364
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
1365
|
+
fs.writeFileSync(path.join(stateDir, "daemon.pid"), `${child.pid}\n`);
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
this.logger.error?.(`[telegram-inbound] /restart respawn failed: ${error.message}`);
|
|
1368
|
+
return { action: "command", command: "restart", reply: `Restart failed: ${error.message}` };
|
|
1369
|
+
}
|
|
1370
|
+
emitStructuredLog(this.logger, "info", {
|
|
1371
|
+
service: "telegram_inbound",
|
|
1372
|
+
event: "restart_requested",
|
|
1373
|
+
reason,
|
|
1374
|
+
chatId,
|
|
1375
|
+
});
|
|
1376
|
+
const processImpl = this.deps?.processImpl || process;
|
|
1377
|
+
setTimeout(() => processImpl.exit(0), 1000);
|
|
1378
|
+
return { action: "command", command: "restart", reply: "Restarting... back in a moment." };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
case "/transfer": {
|
|
1382
|
+
// Async — kicks off migration and responds with result summary
|
|
1383
|
+
try {
|
|
1384
|
+
const { runMigration } = await import("./migration.js");
|
|
1385
|
+
const liveRoot = process.env.NEMORIS_LIVE_ROOT;
|
|
1386
|
+
if (!liveRoot) {
|
|
1387
|
+
return { action: "command", command: "transfer", reply: "NEMORIS_LIVE_ROOT is not set. Point it at your OpenClaw directory first." };
|
|
1388
|
+
}
|
|
1389
|
+
const result = await runMigration({ installDir: process.cwd(), liveRoot, dryRun: false });
|
|
1390
|
+
const summary = [
|
|
1391
|
+
`✅ Migration complete`,
|
|
1392
|
+
`Agents: ${result.agentsMigrated.length} imported, ${result.agentsSkipped.length} skipped`,
|
|
1393
|
+
`Cron jobs: ${result.cronJobsMigrated.length} imported, ${result.cronJobsSkipped.length} skipped`,
|
|
1394
|
+
result.memoryImported > 0 ? `Memory: ${result.memoryImported} entries imported` : null,
|
|
1395
|
+
result.memorySkipped ? `Memory: skipped (already imported)` : null,
|
|
1396
|
+
result.workspaceDocsImported > 0 ? `Workspace docs: ${result.workspaceDocsImported} copied` : null,
|
|
1397
|
+
result.warnings.length ? `⚠️ Warnings: ${result.warnings.join("; ")}` : null,
|
|
1398
|
+
result.launchctlHint ? `\n${result.launchctlHint}` : null,
|
|
1399
|
+
].filter(Boolean).join("\n");
|
|
1400
|
+
return { action: "command", command: "transfer", reply: summary };
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
return { action: "command", command: "transfer", reply: `❌ Migration failed: ${err.message}` };
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
case "/cost": {
|
|
1407
|
+
if (!this.store.db) {
|
|
1408
|
+
return { action: "command", command: "cost", reply: "Cost data unavailable." };
|
|
1409
|
+
}
|
|
1410
|
+
try {
|
|
1411
|
+
const now = new Date();
|
|
1412
|
+
const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0);
|
|
1413
|
+
const startOfWeek = new Date(now); startOfWeek.setDate(now.getDate() - now.getDay()); startOfWeek.setHours(0, 0, 0, 0);
|
|
1414
|
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
1415
|
+
|
|
1416
|
+
const sumSince = (ts) => {
|
|
1417
|
+
const row = this.store.db.prepare(
|
|
1418
|
+
"SELECT COALESCE(SUM(cost_usd), 0) as total, COUNT(*) as turns FROM usage_log WHERE ts >= ?"
|
|
1419
|
+
).get(ts);
|
|
1420
|
+
return row;
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
const today = sumSince(startOfToday.getTime());
|
|
1424
|
+
const week = sumSince(startOfWeek.getTime());
|
|
1425
|
+
const month = sumSince(startOfMonth.getTime());
|
|
1426
|
+
|
|
1427
|
+
const fmt = (label, data) => `${label}: $${data.total.toFixed(2)} (${data.turns} turns)`;
|
|
1428
|
+
|
|
1429
|
+
const reply = [
|
|
1430
|
+
"💰 Spending",
|
|
1431
|
+
fmt(" Today", today),
|
|
1432
|
+
fmt(" This week", week),
|
|
1433
|
+
fmt(" This month", month),
|
|
1434
|
+
].join("\n");
|
|
1435
|
+
|
|
1436
|
+
return { action: "command", command: "cost", reply };
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
this.logger.error(`[telegram-inbound] /cost failed: ${err.message}`);
|
|
1439
|
+
return { action: "command", command: "cost", reply: "Failed to load cost data." };
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
case "/search": {
|
|
1444
|
+
const query = parts.slice(1).join(" ").trim();
|
|
1445
|
+
if (!query) {
|
|
1446
|
+
return { action: "command", command: "search", reply: "Usage: /search <query>\nExample: /search auth flow discussion" };
|
|
1447
|
+
}
|
|
1448
|
+
try {
|
|
1449
|
+
const { SessionSearch } = await import("./session-search.js");
|
|
1450
|
+
if (!this._sessionSearch && this.contextLedger) {
|
|
1451
|
+
this._sessionSearch = new SessionSearch(this.contextLedger);
|
|
1452
|
+
this._sessionSearch.ensureSchema();
|
|
1453
|
+
}
|
|
1454
|
+
if (!this._sessionSearch) {
|
|
1455
|
+
return { action: "command", command: "search", reply: "Search not available — context ledger not initialized." };
|
|
1456
|
+
}
|
|
1457
|
+
const results = this._sessionSearch.search(query, { limit: 5 });
|
|
1458
|
+
const allResults = [
|
|
1459
|
+
...results.events.map(e => ({ source: e.kind === "message_in" ? "You" : "Agent", snippet: e.snippet, ts: e.ts })),
|
|
1460
|
+
...results.summaries.map(s => ({ source: "Summary", snippet: s.snippet })),
|
|
1461
|
+
];
|
|
1462
|
+
if (allResults.length === 0) {
|
|
1463
|
+
return { action: "command", command: "search", reply: `No results for "${query}".` };
|
|
1464
|
+
}
|
|
1465
|
+
const lines = allResults.slice(0, 3).map((r, i) =>
|
|
1466
|
+
`${i + 1}. [${r.source}] ${r.snippet}`
|
|
1467
|
+
);
|
|
1468
|
+
const reply = [`🔍 Results for "${query}":`, ...lines].join("\n");
|
|
1469
|
+
return { action: "command", command: "search", reply };
|
|
1470
|
+
} catch (err) {
|
|
1471
|
+
this.logger.error(`[telegram-inbound] /search failed: ${err.message}`);
|
|
1472
|
+
return { action: "command", command: "search", reply: "Search failed. Try again." };
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
case "/help": {
|
|
1477
|
+
const showAll = parts[1]?.toLowerCase() === "all";
|
|
1478
|
+
return { action: "command", command: "help", reply: formatTelegramHelp(showAll) };
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
case "/approve": {
|
|
1482
|
+
const idStr = parts[1];
|
|
1483
|
+
const id = parseInt(idStr, 10);
|
|
1484
|
+
if (!idStr || isNaN(id)) {
|
|
1485
|
+
return { action: "command", command: "approve", reply: "Usage: /approve <id>" };
|
|
1486
|
+
}
|
|
1487
|
+
if (!this.repairLog || !this.ruleStaging) {
|
|
1488
|
+
return { action: "command", command: "approve", reply: "Self-healing system not available." };
|
|
1489
|
+
}
|
|
1490
|
+
const approveResult = handleApproveCommand(id, this.repairLog, this.ruleStaging);
|
|
1491
|
+
if (!approveResult.found) {
|
|
1492
|
+
return { action: "command", command: "approve", reply: `No held update or pending rule found with ID ${id}.` };
|
|
1493
|
+
}
|
|
1494
|
+
if (approveResult.needsNurseJob) {
|
|
1495
|
+
this.queueNurseJob?.();
|
|
1496
|
+
return { action: "command", command: "approve", reply: `Approved held update #${id}. Nurse job queued to apply it.` };
|
|
1497
|
+
}
|
|
1498
|
+
return { action: "command", command: "approve", reply: `Rule #${id} approved.` };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
case "/health": {
|
|
1502
|
+
if (!this.repairLog) {
|
|
1503
|
+
return { action: "command", command: "health", reply: "Self-healing system not available." };
|
|
1504
|
+
}
|
|
1505
|
+
const healthReport = handleHealthCommand(this.repairLog);
|
|
1506
|
+
return { action: "command", command: "health", reply: healthReport };
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
case "/clear-repairs":
|
|
1510
|
+
case "/clear_repairs": {
|
|
1511
|
+
if (!this.repairLog) {
|
|
1512
|
+
return { action: "command", command: "clear-repairs", reply: "Self-healing system not available." };
|
|
1513
|
+
}
|
|
1514
|
+
const clearResult = handleClearRepairsCommand(this.repairLog);
|
|
1515
|
+
return { action: "command", command: "clear-repairs", reply: clearResult };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
case "/exec_approve": {
|
|
1519
|
+
const approveId = parts[1];
|
|
1520
|
+
if (!approveId) {
|
|
1521
|
+
return { action: "command", command: "exec_approve", reply: "Usage: /exec_approve <id>" };
|
|
1522
|
+
}
|
|
1523
|
+
const gate = this._getExecApprovalGate();
|
|
1524
|
+
const resolved = gate.resolve(approveId, true, "operator");
|
|
1525
|
+
return { action: "command", command: "exec_approve", reply: resolved ? `Approved exec action ${approveId}.` : `No pending approval found with ID ${approveId}.` };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
case "/exec_deny": {
|
|
1529
|
+
const denyId = parts[1];
|
|
1530
|
+
if (!denyId) {
|
|
1531
|
+
return { action: "command", command: "exec_deny", reply: "Usage: /exec_deny <id>" };
|
|
1532
|
+
}
|
|
1533
|
+
const denyGate = this._getExecApprovalGate();
|
|
1534
|
+
const denied = denyGate.resolve(denyId, false, "operator");
|
|
1535
|
+
return { action: "command", command: "exec_deny", reply: denied ? `Denied exec action ${denyId}.` : `No pending approval found with ID ${denyId}.` };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
default:
|
|
1539
|
+
return null; // unknown /command — treat as normal message
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
_getExecApprovalGate() {
|
|
1544
|
+
if (!this._execApprovalGate) {
|
|
1545
|
+
const chatId = this.operatorChatId;
|
|
1546
|
+
const token = this.botToken;
|
|
1547
|
+
this._execApprovalGate = new ExecApprovalGate({
|
|
1548
|
+
sendFn: async (text) => {
|
|
1549
|
+
if (!chatId || !token) return;
|
|
1550
|
+
const fetchFn = this.deps?.fetchImpl || globalThis.fetch;
|
|
1551
|
+
await fetchFn(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
1552
|
+
method: "POST",
|
|
1553
|
+
headers: { "content-type": "application/json" },
|
|
1554
|
+
body: JSON.stringify({ chat_id: chatId, text }),
|
|
1555
|
+
});
|
|
1556
|
+
},
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
return this._execApprovalGate;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
getExecApprovalGate() {
|
|
1563
|
+
return this._getExecApprovalGate();
|
|
1564
|
+
}
|
|
1565
|
+
}
|