nemoris 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.env.example +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +209 -0
  4. package/SECURITY.md +119 -0
  5. package/bin/nemoris +46 -0
  6. package/config/agents/agent.toml.example +28 -0
  7. package/config/agents/default.toml +22 -0
  8. package/config/agents/orchestrator.toml +18 -0
  9. package/config/delivery.toml +73 -0
  10. package/config/embeddings.toml +5 -0
  11. package/config/identity/default-purpose.md +1 -0
  12. package/config/identity/default-soul.md +3 -0
  13. package/config/identity/orchestrator-purpose.md +1 -0
  14. package/config/identity/orchestrator-soul.md +1 -0
  15. package/config/improvement-targets.toml +15 -0
  16. package/config/jobs/heartbeat-check.toml +30 -0
  17. package/config/jobs/memory-rollup.toml +46 -0
  18. package/config/jobs/workspace-health.toml +63 -0
  19. package/config/mcp.toml +16 -0
  20. package/config/output-contracts.toml +17 -0
  21. package/config/peers.toml +32 -0
  22. package/config/peers.toml.example +32 -0
  23. package/config/policies/memory-default.toml +10 -0
  24. package/config/policies/memory-heartbeat.toml +5 -0
  25. package/config/policies/memory-ops.toml +10 -0
  26. package/config/policies/tools-heartbeat-minimal.toml +8 -0
  27. package/config/policies/tools-interactive-safe.toml +8 -0
  28. package/config/policies/tools-ops-bounded.toml +8 -0
  29. package/config/policies/tools-orchestrator.toml +7 -0
  30. package/config/providers/anthropic.toml +15 -0
  31. package/config/providers/ollama.toml +5 -0
  32. package/config/providers/openai-codex.toml +9 -0
  33. package/config/providers/openrouter.toml +5 -0
  34. package/config/router.toml +22 -0
  35. package/config/runtime.toml +114 -0
  36. package/config/skills/self-improvement.toml +15 -0
  37. package/config/skills/telegram-onboarding-spec.md +240 -0
  38. package/config/skills/workspace-monitor.toml +15 -0
  39. package/config/task-router.toml +42 -0
  40. package/install.sh +50 -0
  41. package/package.json +90 -0
  42. package/src/auth/auth-profiles.js +169 -0
  43. package/src/auth/openai-codex-oauth.js +285 -0
  44. package/src/battle.js +449 -0
  45. package/src/cli/help.js +265 -0
  46. package/src/cli/output-filter.js +49 -0
  47. package/src/cli/runtime-control.js +704 -0
  48. package/src/cli-main.js +2763 -0
  49. package/src/cli.js +78 -0
  50. package/src/config/loader.js +332 -0
  51. package/src/config/schema-validator.js +214 -0
  52. package/src/config/toml-lite.js +8 -0
  53. package/src/daemon/action-handlers.js +71 -0
  54. package/src/daemon/healing-tick.js +87 -0
  55. package/src/daemon/health-probes.js +90 -0
  56. package/src/daemon/notifier.js +57 -0
  57. package/src/daemon/nurse.js +218 -0
  58. package/src/daemon/repair-log.js +106 -0
  59. package/src/daemon/rule-staging.js +90 -0
  60. package/src/daemon/rules.js +29 -0
  61. package/src/daemon/telegram-commands.js +54 -0
  62. package/src/daemon/updater.js +85 -0
  63. package/src/jobs/job-runner.js +78 -0
  64. package/src/mcp/consumer.js +129 -0
  65. package/src/memory/active-recall.js +171 -0
  66. package/src/memory/backend-manager.js +97 -0
  67. package/src/memory/backends/file-backend.js +38 -0
  68. package/src/memory/backends/qmd-backend.js +219 -0
  69. package/src/memory/embedding-guards.js +24 -0
  70. package/src/memory/embedding-index.js +118 -0
  71. package/src/memory/embedding-service.js +179 -0
  72. package/src/memory/file-index.js +177 -0
  73. package/src/memory/memory-signature.js +5 -0
  74. package/src/memory/memory-store.js +648 -0
  75. package/src/memory/retrieval-planner.js +66 -0
  76. package/src/memory/scoring.js +145 -0
  77. package/src/memory/simhash.js +78 -0
  78. package/src/memory/sqlite-active-store.js +824 -0
  79. package/src/memory/write-policy.js +36 -0
  80. package/src/onboarding/aliases.js +33 -0
  81. package/src/onboarding/auth/api-key.js +224 -0
  82. package/src/onboarding/auth/ollama-detect.js +42 -0
  83. package/src/onboarding/clack-prompter.js +77 -0
  84. package/src/onboarding/doctor.js +530 -0
  85. package/src/onboarding/lock.js +42 -0
  86. package/src/onboarding/model-catalog.js +344 -0
  87. package/src/onboarding/phases/auth.js +589 -0
  88. package/src/onboarding/phases/build.js +130 -0
  89. package/src/onboarding/phases/choose.js +82 -0
  90. package/src/onboarding/phases/detect.js +98 -0
  91. package/src/onboarding/phases/hatch.js +216 -0
  92. package/src/onboarding/phases/identity.js +79 -0
  93. package/src/onboarding/phases/ollama.js +345 -0
  94. package/src/onboarding/phases/scaffold.js +99 -0
  95. package/src/onboarding/phases/telegram.js +377 -0
  96. package/src/onboarding/phases/validate.js +204 -0
  97. package/src/onboarding/phases/verify.js +206 -0
  98. package/src/onboarding/platform.js +482 -0
  99. package/src/onboarding/status-bar.js +95 -0
  100. package/src/onboarding/templates.js +794 -0
  101. package/src/onboarding/toml-writer.js +38 -0
  102. package/src/onboarding/tui.js +250 -0
  103. package/src/onboarding/uninstall.js +153 -0
  104. package/src/onboarding/wizard.js +499 -0
  105. package/src/providers/anthropic.js +168 -0
  106. package/src/providers/base.js +247 -0
  107. package/src/providers/circuit-breaker.js +136 -0
  108. package/src/providers/ollama.js +163 -0
  109. package/src/providers/openai-codex.js +149 -0
  110. package/src/providers/openrouter.js +136 -0
  111. package/src/providers/registry.js +36 -0
  112. package/src/providers/router.js +16 -0
  113. package/src/runtime/bootstrap-cache.js +47 -0
  114. package/src/runtime/capabilities-prompt.js +25 -0
  115. package/src/runtime/completion-ping.js +99 -0
  116. package/src/runtime/config-validator.js +121 -0
  117. package/src/runtime/context-ledger.js +360 -0
  118. package/src/runtime/cutover-readiness.js +42 -0
  119. package/src/runtime/daemon.js +729 -0
  120. package/src/runtime/delivery-ack.js +195 -0
  121. package/src/runtime/delivery-adapters/local-file.js +41 -0
  122. package/src/runtime/delivery-adapters/openclaw-cli.js +94 -0
  123. package/src/runtime/delivery-adapters/openclaw-peer.js +98 -0
  124. package/src/runtime/delivery-adapters/shadow.js +13 -0
  125. package/src/runtime/delivery-adapters/standalone-http.js +98 -0
  126. package/src/runtime/delivery-adapters/telegram.js +104 -0
  127. package/src/runtime/delivery-adapters/tui.js +128 -0
  128. package/src/runtime/delivery-manager.js +807 -0
  129. package/src/runtime/delivery-store.js +168 -0
  130. package/src/runtime/dependency-health.js +118 -0
  131. package/src/runtime/envelope.js +114 -0
  132. package/src/runtime/evaluation.js +1089 -0
  133. package/src/runtime/exec-approvals.js +216 -0
  134. package/src/runtime/executor.js +500 -0
  135. package/src/runtime/failure-ping.js +67 -0
  136. package/src/runtime/flows.js +83 -0
  137. package/src/runtime/guards.js +45 -0
  138. package/src/runtime/handoff.js +51 -0
  139. package/src/runtime/identity-cache.js +28 -0
  140. package/src/runtime/improvement-engine.js +109 -0
  141. package/src/runtime/improvement-harness.js +581 -0
  142. package/src/runtime/input-sanitiser.js +72 -0
  143. package/src/runtime/interaction-contract.js +347 -0
  144. package/src/runtime/lane-readiness.js +226 -0
  145. package/src/runtime/migration.js +323 -0
  146. package/src/runtime/model-resolution.js +78 -0
  147. package/src/runtime/network.js +64 -0
  148. package/src/runtime/notification-store.js +97 -0
  149. package/src/runtime/notifier.js +256 -0
  150. package/src/runtime/orchestrator.js +53 -0
  151. package/src/runtime/orphan-reaper.js +41 -0
  152. package/src/runtime/output-contract-schema.js +139 -0
  153. package/src/runtime/output-contract-validator.js +439 -0
  154. package/src/runtime/peer-readiness.js +69 -0
  155. package/src/runtime/peer-registry.js +133 -0
  156. package/src/runtime/pilot-status.js +108 -0
  157. package/src/runtime/prompt-builder.js +261 -0
  158. package/src/runtime/provider-attempt.js +582 -0
  159. package/src/runtime/report-fallback.js +71 -0
  160. package/src/runtime/result-normalizer.js +183 -0
  161. package/src/runtime/retention.js +74 -0
  162. package/src/runtime/review.js +244 -0
  163. package/src/runtime/route-job.js +15 -0
  164. package/src/runtime/run-store.js +38 -0
  165. package/src/runtime/schedule.js +88 -0
  166. package/src/runtime/scheduler-state.js +434 -0
  167. package/src/runtime/scheduler.js +656 -0
  168. package/src/runtime/session-compactor.js +182 -0
  169. package/src/runtime/session-search.js +155 -0
  170. package/src/runtime/slack-inbound.js +249 -0
  171. package/src/runtime/ssrf.js +102 -0
  172. package/src/runtime/status-aggregator.js +330 -0
  173. package/src/runtime/task-contract.js +140 -0
  174. package/src/runtime/task-packet.js +107 -0
  175. package/src/runtime/task-router.js +140 -0
  176. package/src/runtime/telegram-inbound.js +1565 -0
  177. package/src/runtime/token-counter.js +134 -0
  178. package/src/runtime/token-estimator.js +59 -0
  179. package/src/runtime/tool-loop.js +200 -0
  180. package/src/runtime/transport-server.js +311 -0
  181. package/src/runtime/tui-server.js +411 -0
  182. package/src/runtime/ulid.js +44 -0
  183. package/src/security/ssrf-check.js +197 -0
  184. package/src/setup.js +369 -0
  185. package/src/shadow/bridge.js +303 -0
  186. package/src/skills/loader.js +84 -0
  187. package/src/tools/catalog.json +49 -0
  188. package/src/tools/cli-delegate.js +44 -0
  189. package/src/tools/mcp-client.js +106 -0
  190. package/src/tools/micro/cancel-task.js +6 -0
  191. package/src/tools/micro/complete-task.js +6 -0
  192. package/src/tools/micro/fail-task.js +6 -0
  193. package/src/tools/micro/http-fetch.js +74 -0
  194. package/src/tools/micro/index.js +36 -0
  195. package/src/tools/micro/lcm-recall.js +60 -0
  196. package/src/tools/micro/list-dir.js +17 -0
  197. package/src/tools/micro/list-skills.js +46 -0
  198. package/src/tools/micro/load-skill.js +38 -0
  199. package/src/tools/micro/memory-search.js +45 -0
  200. package/src/tools/micro/read-file.js +11 -0
  201. package/src/tools/micro/session-search.js +54 -0
  202. package/src/tools/micro/shell-exec.js +43 -0
  203. package/src/tools/micro/trigger-job.js +79 -0
  204. package/src/tools/micro/web-search.js +58 -0
  205. package/src/tools/micro/workspace-paths.js +39 -0
  206. package/src/tools/micro/write-file.js +14 -0
  207. package/src/tools/micro/write-memory.js +41 -0
  208. package/src/tools/registry.js +348 -0
  209. package/src/tools/tool-result-contract.js +36 -0
  210. package/src/tui/chat.js +835 -0
  211. package/src/tui/renderer.js +175 -0
  212. package/src/tui/socket-client.js +217 -0
  213. package/src/utils/canonical-json.js +29 -0
  214. package/src/utils/compaction.js +30 -0
  215. package/src/utils/env-loader.js +5 -0
  216. package/src/utils/errors.js +80 -0
  217. package/src/utils/fs.js +101 -0
  218. package/src/utils/ids.js +5 -0
  219. package/src/utils/model-context-limits.js +30 -0
  220. package/src/utils/token-budget.js +74 -0
  221. package/src/utils/usage-cost.js +25 -0
  222. package/src/utils/usage-metrics.js +14 -0
  223. package/vendor/smol-toml-1.5.2.tgz +0 -0
@@ -0,0 +1,411 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { TelegramInboundHandler, resolveStatusModel } from "./telegram-inbound.js";
6
+
7
+ const SESSION_PREFIX = "tui";
8
+ const SESSION_REGISTRY = new Map();
9
+ const SOCKET_STATE = new WeakMap();
10
+ let nextJobCounter = 1;
11
+
12
+ function safeUnlink(filePath) {
13
+ try {
14
+ fs.unlinkSync(filePath);
15
+ } catch (error) {
16
+ if (error?.code !== "ENOENT") throw error;
17
+ }
18
+ }
19
+
20
+ function normalizeText(value) {
21
+ return typeof value === "string" ? value : String(value ?? "");
22
+ }
23
+
24
+ function writeNdjson(socket, payload) {
25
+ if (!socket || socket.destroyed || !socket.writable) {
26
+ return false;
27
+ }
28
+ socket.write(`${JSON.stringify(payload)}\n`);
29
+ return true;
30
+ }
31
+
32
+ function resolveRequestedAgent(runtime, requestedAgent, fallbackAgent) {
33
+ const configuredAgents = new Set(Object.keys(runtime?.agents || {}));
34
+ if (requestedAgent && configuredAgents.has(requestedAgent)) {
35
+ return requestedAgent;
36
+ }
37
+ if (fallbackAgent && configuredAgents.has(fallbackAgent)) {
38
+ return fallbackAgent;
39
+ }
40
+ const firstConfigured = configuredAgents.values().next().value;
41
+ return firstConfigured || requestedAgent || fallbackAgent || "main";
42
+ }
43
+
44
+ function resolveApprovalId(gate, payload = {}) {
45
+ if (payload.approvalId) {
46
+ return String(payload.approvalId);
47
+ }
48
+ if (!payload.jobId) {
49
+ return null;
50
+ }
51
+ const pending = gate.getPending().find((entry) => entry.jobId === payload.jobId);
52
+ return pending?.approvalId || null;
53
+ }
54
+
55
+ export function resolveTuiSocketPath(stateRoot) {
56
+ if (process.platform === "win32") {
57
+ return "\\\\.\\pipe\\nemoris-daemon";
58
+ }
59
+ return path.join(stateRoot, "daemon.sock");
60
+ }
61
+
62
+ export function buildTuiSessionKey(sessionId) {
63
+ return `${SESSION_PREFIX}:${sessionId}`;
64
+ }
65
+
66
+ export function registerTuiSession(sessionKey, socket, meta = {}) {
67
+ const existing = SESSION_REGISTRY.get(sessionKey);
68
+ if (existing?.socket && existing.socket !== socket) {
69
+ existing.socket.destroy();
70
+ }
71
+ SESSION_REGISTRY.set(sessionKey, { socket, meta });
72
+ SOCKET_STATE.set(socket, { sessionKey, ...meta });
73
+ }
74
+
75
+ export function unregisterTuiSession(socket) {
76
+ const state = SOCKET_STATE.get(socket);
77
+ if (!state?.sessionKey) {
78
+ return;
79
+ }
80
+ const current = SESSION_REGISTRY.get(state.sessionKey);
81
+ if (current?.socket === socket) {
82
+ SESSION_REGISTRY.delete(state.sessionKey);
83
+ }
84
+ SOCKET_STATE.delete(socket);
85
+ }
86
+
87
+ export function getRegisteredTuiSession(sessionKey) {
88
+ return SESSION_REGISTRY.get(sessionKey) || null;
89
+ }
90
+
91
+ export function sendTuiEvent(sessionKey, payload) {
92
+ const entry = SESSION_REGISTRY.get(sessionKey);
93
+ if (!entry) {
94
+ return false;
95
+ }
96
+ return writeNdjson(entry.socket, payload);
97
+ }
98
+
99
+ export function resetTuiSessionRegistry() {
100
+ for (const { socket } of SESSION_REGISTRY.values()) {
101
+ if (socket && !socket.destroyed) {
102
+ socket.destroy();
103
+ }
104
+ }
105
+ SESSION_REGISTRY.clear();
106
+ }
107
+
108
+ export class TuiServer {
109
+ constructor({
110
+ stateStore,
111
+ stateRoot,
112
+ runtime,
113
+ contextLedger = null,
114
+ execApprovalGate = null,
115
+ logger = console,
116
+ } = {}) {
117
+ this.stateStore = stateStore;
118
+ this.stateRoot = stateRoot;
119
+ this.runtime = runtime || {};
120
+ this.contextLedger = contextLedger;
121
+ this.execApprovalGate = execApprovalGate || null;
122
+ this.logger = logger;
123
+ this.server = null;
124
+ this.socketPath = resolveTuiSocketPath(stateRoot);
125
+ this.defaultAgent = resolveRequestedAgent(
126
+ runtime,
127
+ runtime?.runtime?.telegram?.defaultAgent,
128
+ "main",
129
+ );
130
+ const agentNames = Object.fromEntries(
131
+ Object.entries(runtime?.agents || {}).map(([agentId, config]) => [agentId, config?.name || agentId]),
132
+ );
133
+ this.commandHandler = new TelegramInboundHandler({
134
+ stateStore,
135
+ telegramConfig: {
136
+ defaultAgent: this.defaultAgent,
137
+ operatorChatId: "",
138
+ authorizedChatIds: [],
139
+ pollingMode: false,
140
+ sessionLabelPrefix: SESSION_PREFIX,
141
+ },
142
+ availablePeers: Object.keys(runtime?.agents || {}),
143
+ contextLedger,
144
+ agentNames,
145
+ routerConfig: runtime?.router || null,
146
+ agentConfigs: runtime?.agents || null,
147
+ execApprovalGate,
148
+ logger,
149
+ });
150
+ }
151
+
152
+ setExecApprovalGate(execApprovalGate) {
153
+ this.execApprovalGate = execApprovalGate || null;
154
+ this.commandHandler._execApprovalGate = execApprovalGate || null;
155
+ }
156
+
157
+ async start() {
158
+ if (this.server) {
159
+ return { socketPath: this.socketPath };
160
+ }
161
+ if (process.platform !== "win32") {
162
+ safeUnlink(this.socketPath);
163
+ }
164
+ this.server = net.createServer((socket) => this._handleConnection(socket));
165
+ await new Promise((resolve, reject) => {
166
+ const onError = (error) => {
167
+ this.server?.off("listening", onListening);
168
+ reject(error);
169
+ };
170
+ const onListening = () => {
171
+ this.server?.off("error", onError);
172
+ resolve();
173
+ };
174
+ this.server.once("error", onError);
175
+ this.server.once("listening", onListening);
176
+ this.server.listen(this.socketPath);
177
+ });
178
+ return { socketPath: this.socketPath };
179
+ }
180
+
181
+ async close() {
182
+ if (!this.server) {
183
+ return;
184
+ }
185
+ const server = this.server;
186
+ this.server = null;
187
+ await new Promise((resolve) => server.close(() => resolve()));
188
+ if (process.platform !== "win32") {
189
+ safeUnlink(this.socketPath);
190
+ }
191
+ }
192
+
193
+ async buildStatusPayload(sessionKey) {
194
+ const session = this.stateStore.getChatSession(sessionKey);
195
+ const usage = this.stateStore.db?.prepare(`
196
+ SELECT
197
+ COALESCE(SUM(tokens_in + tokens_out), 0) as tokens,
198
+ COALESCE(SUM(cost_usd), 0) as cost
199
+ FROM usage_log
200
+ WHERE session_id = ?
201
+ `).get(sessionKey) || { tokens: 0, cost: 0 };
202
+ return {
203
+ type: "status",
204
+ sessionKey,
205
+ agent: session?.agent_id || this.defaultAgent,
206
+ model: resolveStatusModel(session, {
207
+ routerConfig: this.runtime?.router || null,
208
+ agentConfigs: this.runtime?.agents || null,
209
+ }) || "default",
210
+ tokens: usage.tokens || 0,
211
+ cost: usage.cost || 0,
212
+ connected: true,
213
+ };
214
+ }
215
+
216
+ _handleConnection(socket) {
217
+ socket.setEncoding("utf8");
218
+ let buffer = "";
219
+
220
+ socket.on("data", async (chunk) => {
221
+ buffer += chunk;
222
+ const lines = buffer.split("\n");
223
+ buffer = lines.pop() || "";
224
+ for (const rawLine of lines) {
225
+ const line = rawLine.trim();
226
+ if (!line) continue;
227
+ let payload;
228
+ try {
229
+ payload = JSON.parse(line);
230
+ } catch {
231
+ this._send(socket, { type: "error", message: "Invalid JSON payload.", code: "INVALID_JSON" });
232
+ continue;
233
+ }
234
+ try {
235
+ await this._handlePayload(socket, payload);
236
+ } catch (error) {
237
+ this.logger.error?.(`[tui-server] ${error.message}`);
238
+ this._send(socket, { type: "error", message: error.message, code: "TUI_SERVER_ERROR" });
239
+ }
240
+ }
241
+ });
242
+
243
+ socket.on("close", () => unregisterTuiSession(socket));
244
+ socket.on("error", () => unregisterTuiSession(socket));
245
+ }
246
+
247
+ _send(socket, payload) {
248
+ writeNdjson(socket, payload);
249
+ }
250
+
251
+ _getSessionKey(socket) {
252
+ return SOCKET_STATE.get(socket)?.sessionKey || null;
253
+ }
254
+
255
+ _ensureSession(sessionKey, payload = {}) {
256
+ let session = this.stateStore.getChatSession(sessionKey);
257
+ const requestedAgent = resolveRequestedAgent(this.runtime, payload.agent, session?.agent_id || this.defaultAgent);
258
+
259
+ if (!session) {
260
+ this.stateStore.bindChatSession({
261
+ chatId: sessionKey,
262
+ agentId: requestedAgent,
263
+ boundBy: "default",
264
+ });
265
+ session = this.stateStore.getChatSession(sessionKey);
266
+ } else if (payload.agent && session.agent_id !== requestedAgent) {
267
+ this.stateStore.bindChatSession({
268
+ chatId: sessionKey,
269
+ agentId: requestedAgent,
270
+ boundBy: "user",
271
+ });
272
+ session = this.stateStore.getChatSession(sessionKey);
273
+ }
274
+
275
+ if (payload.model !== undefined) {
276
+ this.stateStore.db.prepare("UPDATE chat_sessions SET model_override = ? WHERE chat_id = ?")
277
+ .run(payload.model || null, sessionKey);
278
+ session = this.stateStore.getChatSession(sessionKey);
279
+ }
280
+
281
+ return session;
282
+ }
283
+
284
+ async _handlePayload(socket, payload) {
285
+ switch (payload?.type) {
286
+ case "hello":
287
+ await this._handleHello(socket, payload);
288
+ return;
289
+ case "user_message":
290
+ await this._handleText(socket, normalizeText(payload.text), payload);
291
+ return;
292
+ case "slash_command":
293
+ await this._handleText(socket, normalizeText(payload.command || payload.text), { ...payload, forceSlash: true });
294
+ return;
295
+ case "approval":
296
+ await this._handleApproval(socket, payload);
297
+ return;
298
+ case "abort":
299
+ await this._handleAbort(socket);
300
+ return;
301
+ case "ping":
302
+ this._send(socket, { type: "pong" });
303
+ return;
304
+ default:
305
+ this._send(socket, { type: "error", message: `Unsupported message type ${payload?.type || "(missing)"}.`, code: "UNSUPPORTED_TYPE" });
306
+ }
307
+ }
308
+
309
+ async _handleHello(socket, payload) {
310
+ const sessionId = normalizeText(payload.sessionId || `${process.pid}-${Date.now()}`);
311
+ const sessionKey = buildTuiSessionKey(sessionId);
312
+ registerTuiSession(sessionKey, socket, { sessionId });
313
+ this._ensureSession(sessionKey, payload);
314
+ this._send(socket, await this.buildStatusPayload(sessionKey));
315
+ }
316
+
317
+ async _handleText(socket, text, payload = {}) {
318
+ const sessionKey = this._getSessionKey(socket);
319
+ if (!sessionKey) {
320
+ this._send(socket, { type: "error", message: "Session not initialized. Send a hello payload first.", code: "SESSION_REQUIRED" });
321
+ return;
322
+ }
323
+
324
+ const cleanText = text.trim();
325
+ if (!cleanText) {
326
+ return;
327
+ }
328
+
329
+ const session = this._ensureSession(sessionKey, payload);
330
+ const shouldRouteAsSlash = payload.forceSlash || cleanText.startsWith("/");
331
+ if (shouldRouteAsSlash) {
332
+ const commandText = cleanText.startsWith("/") ? cleanText : `/${cleanText}`;
333
+ const commandResult = await this.commandHandler._handleCommand(commandText, sessionKey);
334
+ if (commandResult) {
335
+ this._send(socket, {
336
+ type: "agent_response",
337
+ text: commandResult.reply,
338
+ command: commandResult.command,
339
+ jobId: null,
340
+ });
341
+ this._send(socket, await this.buildStatusPayload(sessionKey));
342
+ return;
343
+ }
344
+ }
345
+
346
+ const available = session?.agent_id && this.runtime?.agents?.[session.agent_id];
347
+ if (!available) {
348
+ this._send(socket, { type: "error", message: `Agent ${session?.agent_id || "unknown"} is not available.`, code: "AGENT_UNAVAILABLE" });
349
+ return;
350
+ }
351
+
352
+ const jobId = `tui-${Date.now()}-${nextJobCounter++}`;
353
+ this.stateStore.enqueueInteractiveJob({
354
+ jobId,
355
+ agentId: session.agent_id,
356
+ input: cleanText,
357
+ source: SESSION_PREFIX,
358
+ chatId: sessionKey,
359
+ });
360
+ this._send(socket, {
361
+ type: "status",
362
+ ...(await this.buildStatusPayload(sessionKey)),
363
+ queued: true,
364
+ jobId,
365
+ });
366
+ }
367
+
368
+ async _handleApproval(socket, payload) {
369
+ const gate = this.commandHandler.getExecApprovalGate();
370
+ const approvalId = resolveApprovalId(gate, payload);
371
+ const approved = String(payload.action || "approve").toLowerCase() !== "deny";
372
+ const sessionKey = this._getSessionKey(socket);
373
+ if (!approvalId) {
374
+ this._send(socket, {
375
+ type: "agent_response",
376
+ text: "No pending approval found for this request.",
377
+ command: approved ? "exec_approve" : "exec_deny",
378
+ });
379
+ return;
380
+ }
381
+ const resolved = gate.resolve(approvalId, approved, SESSION_PREFIX);
382
+ this._send(socket, {
383
+ type: "agent_response",
384
+ text: resolved
385
+ ? `${approved ? "Approved" : "Denied"} exec action ${approvalId}.`
386
+ : `No pending approval found with ID ${approvalId}.`,
387
+ command: approved ? "exec_approve" : "exec_deny",
388
+ });
389
+ if (sessionKey) {
390
+ this._send(socket, await this.buildStatusPayload(sessionKey));
391
+ }
392
+ }
393
+
394
+ async _handleAbort(socket) {
395
+ const sessionKey = this._getSessionKey(socket);
396
+ if (!sessionKey) {
397
+ this._send(socket, { type: "error", message: "Session not initialized.", code: "SESSION_REQUIRED" });
398
+ return;
399
+ }
400
+ const result = await this.commandHandler._handleCommand("/stop", sessionKey);
401
+ if (result) {
402
+ this._send(socket, {
403
+ type: "agent_response",
404
+ text: result.reply,
405
+ command: result.command,
406
+ jobId: null,
407
+ });
408
+ this._send(socket, await this.buildStatusPayload(sessionKey));
409
+ }
410
+ }
411
+ }
@@ -0,0 +1,44 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4
+
5
+ let lastTime = 0;
6
+ let lastRandom = new Uint8Array(10);
7
+
8
+ export function ulid() {
9
+ let now = Date.now();
10
+ if (now === lastTime) {
11
+ // Increment random component for monotonicity within same millisecond
12
+ for (let i = 9; i >= 0; i--) {
13
+ if (lastRandom[i] < 255) { lastRandom[i]++; break; }
14
+ lastRandom[i] = 0;
15
+ }
16
+ } else {
17
+ lastTime = now;
18
+ randomBytes(10).copy(lastRandom);
19
+ }
20
+
21
+ let str = "";
22
+ // Encode 48-bit timestamp → 10 Crockford Base32 chars
23
+ for (let i = 9; i >= 0; i--) {
24
+ str = ENCODING[now & 31] + str;
25
+ now = Math.floor(now / 32);
26
+ }
27
+ // Encode 80-bit random → 16 Crockford Base32 chars
28
+ // Process as two 40-bit halves, each → 8 chars
29
+ const rand = lastRandom;
30
+ for (let half = 0; half < 2; half++) {
31
+ const off = half * 5;
32
+ const hi = (rand[off] << 12) | (rand[off + 1] << 4) | (rand[off + 2] >> 4);
33
+ const lo = ((rand[off + 2] & 0xF) << 16) | (rand[off + 3] << 8) | rand[off + 4];
34
+ str += ENCODING[(hi >> 15) & 31];
35
+ str += ENCODING[(hi >> 10) & 31];
36
+ str += ENCODING[(hi >> 5) & 31];
37
+ str += ENCODING[hi & 31];
38
+ str += ENCODING[(lo >> 15) & 31];
39
+ str += ENCODING[(lo >> 10) & 31];
40
+ str += ENCODING[(lo >> 5) & 31];
41
+ str += ENCODING[lo & 31];
42
+ }
43
+ return str;
44
+ }
@@ -0,0 +1,197 @@
1
+ import net from "node:net";
2
+ import { lookup } from "node:dns/promises";
3
+
4
+ const IPV4_BITS = 32;
5
+ const IPV6_BITS = 128;
6
+ const BLOCKED_HOSTNAME_RE = /^(localhost|localhost\.localdomain|local|internal)$/i;
7
+ const LOOPBACK_HOSTNAME_RE = /^(localhost|localhost\.localdomain)$/i;
8
+
9
+ const BLOCKED_IPV4_RANGES = [
10
+ rangeFromPrefix("0.0.0.0", 8, IPV4_BITS),
11
+ rangeFromPrefix("10.0.0.0", 8, IPV4_BITS),
12
+ rangeFromPrefix("100.64.0.0", 10, IPV4_BITS),
13
+ rangeFromPrefix("127.0.0.0", 8, IPV4_BITS),
14
+ rangeFromPrefix("169.254.0.0", 16, IPV4_BITS),
15
+ rangeFromPrefix("172.16.0.0", 12, IPV4_BITS),
16
+ rangeFromPrefix("192.168.0.0", 16, IPV4_BITS),
17
+ rangeFromPrefix("198.18.0.0", 15, IPV4_BITS),
18
+ ];
19
+
20
+ const LOOPBACK_IPV4_RANGES = [
21
+ rangeFromPrefix("127.0.0.0", 8, IPV4_BITS),
22
+ ];
23
+
24
+ const BLOCKED_IPV6_RANGES = [
25
+ rangeFromPrefix("::", 128, IPV6_BITS),
26
+ rangeFromPrefix("::1", 128, IPV6_BITS),
27
+ rangeFromPrefix("fc00::", 7, IPV6_BITS),
28
+ rangeFromPrefix("fe80::", 10, IPV6_BITS),
29
+ rangeFromPrefix("ff00::", 8, IPV6_BITS),
30
+ ];
31
+
32
+ const LOOPBACK_IPV6_RANGES = [
33
+ rangeFromPrefix("::1", 128, IPV6_BITS),
34
+ ];
35
+
36
+ export const OUTBOUND_ADDRESS_POLICY = {
37
+ BLOCK_PRIVATE: "block-private",
38
+ REQUIRE_LOOPBACK: "require-loopback",
39
+ };
40
+
41
+ function normaliseAddress(address) {
42
+ if (typeof address !== "string") return "";
43
+ if (address.startsWith("[") && address.endsWith("]")) {
44
+ return address.slice(1, -1);
45
+ }
46
+ return address;
47
+ }
48
+
49
+ function ipv4ToBigInt(address) {
50
+ return normaliseAddress(address)
51
+ .split(".")
52
+ .reduce((value, octet) => (value << 8n) + BigInt(Number(octet)), 0n);
53
+ }
54
+
55
+ function expandEmbeddedIpv4(address) {
56
+ if (!address.includes(".")) return address;
57
+ const lastColon = address.lastIndexOf(":");
58
+ const ipv4Value = ipv4ToBigInt(address.slice(lastColon + 1));
59
+ const upper = Number((ipv4Value >> 16n) & 0xffffn).toString(16);
60
+ const lower = Number(ipv4Value & 0xffffn).toString(16);
61
+ return `${address.slice(0, lastColon)}:${upper}:${lower}`;
62
+ }
63
+
64
+ function ipv6ToBigInt(address) {
65
+ const expanded = expandEmbeddedIpv4(normaliseAddress(address).toLowerCase());
66
+ const hasCompression = expanded.includes("::");
67
+ const [head, tail = ""] = expanded.split("::");
68
+ const headParts = head ? head.split(":").filter(Boolean) : [];
69
+ const tailParts = tail ? tail.split(":").filter(Boolean) : [];
70
+ const missingParts = hasCompression ? 8 - (headParts.length + tailParts.length) : 0;
71
+ const parts = hasCompression
72
+ ? [...headParts, ...Array(missingParts).fill("0"), ...tailParts]
73
+ : expanded.split(":");
74
+
75
+ if (parts.length !== 8) {
76
+ throw new Error(`Invalid IPv6 address: ${address}`);
77
+ }
78
+
79
+ return parts.reduce((value, part) => (value << 16n) + BigInt(parseInt(part || "0", 16)), 0n);
80
+ }
81
+
82
+ function rangeFromPrefix(address, prefixLength, totalBits) {
83
+ const rawValue = totalBits === IPV4_BITS ? ipv4ToBigInt(address) : ipv6ToBigInt(address);
84
+ const shift = BigInt(totalBits - prefixLength);
85
+ const maxValue = (1n << BigInt(totalBits)) - 1n;
86
+ const mask = shift === 0n ? maxValue : maxValue ^ ((1n << shift) - 1n);
87
+ const start = rawValue & mask;
88
+ const end = start | (maxValue ^ mask);
89
+ return { start, end };
90
+ }
91
+
92
+ function isAddressInRanges(address, ranges) {
93
+ const normalised = normaliseAddress(address);
94
+ const family = net.isIP(normalised);
95
+ if (family === 4) {
96
+ const value = ipv4ToBigInt(normalised);
97
+ return ranges.some((range) => value >= range.start && value <= range.end);
98
+ }
99
+ if (family === 6) {
100
+ const value = ipv6ToBigInt(normalised);
101
+ return ranges.some((range) => value >= range.start && value <= range.end);
102
+ }
103
+ return false;
104
+ }
105
+
106
+ export function isBlockedIpAddress(address) {
107
+ return isAddressInRanges(address, BLOCKED_IPV4_RANGES.concat(BLOCKED_IPV6_RANGES));
108
+ }
109
+
110
+ export function isLoopbackIpAddress(address) {
111
+ return isAddressInRanges(address, LOOPBACK_IPV4_RANGES.concat(LOOPBACK_IPV6_RANGES));
112
+ }
113
+
114
+ function policyRejectsHostname(hostname, { addressPolicy, allowLoopbackAddresses }) {
115
+ if (addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK) {
116
+ return !LOOPBACK_HOSTNAME_RE.test(hostname);
117
+ }
118
+ if (allowLoopbackAddresses && LOOPBACK_HOSTNAME_RE.test(hostname)) {
119
+ return false;
120
+ }
121
+ return BLOCKED_HOSTNAME_RE.test(hostname);
122
+ }
123
+
124
+ function policyRejectsAddress(address, { addressPolicy, allowLoopbackAddresses }) {
125
+ if (addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK) {
126
+ return !isLoopbackIpAddress(address);
127
+ }
128
+ if (allowLoopbackAddresses && isLoopbackIpAddress(address)) {
129
+ return false;
130
+ }
131
+ return isBlockedIpAddress(address);
132
+ }
133
+
134
+ export async function inspectOutboundUrl(rawUrl, {
135
+ lookupImpl = lookup,
136
+ allowedProtocols = ["http:", "https:"],
137
+ invalidUrlMessage = "Invalid URL.",
138
+ invalidProtocolMessage = "Only http:// and https:// URLs are allowed.",
139
+ privateAddressMessage = "Target resolves to a private/reserved IP address.",
140
+ loopbackOnlyMessage = "Target must resolve to a loopback address.",
141
+ addressPolicy = OUTBOUND_ADDRESS_POLICY.BLOCK_PRIVATE,
142
+ allowLoopbackAddresses = false,
143
+ } = {}) {
144
+ let parsedUrl;
145
+ try {
146
+ parsedUrl = rawUrl instanceof URL ? rawUrl : new URL(rawUrl);
147
+ } catch {
148
+ return { ok: false, reason: invalidUrlMessage };
149
+ }
150
+
151
+ if (!allowedProtocols.includes(parsedUrl.protocol)) {
152
+ return { ok: false, reason: invalidProtocolMessage, parsedUrl };
153
+ }
154
+
155
+ const hostname = parsedUrl.hostname;
156
+ if (!hostname) {
157
+ return {
158
+ ok: false,
159
+ reason: addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK ? loopbackOnlyMessage : privateAddressMessage,
160
+ parsedUrl
161
+ };
162
+ }
163
+
164
+ if (policyRejectsHostname(hostname, { addressPolicy, allowLoopbackAddresses })) {
165
+ return {
166
+ ok: false,
167
+ reason: addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK ? loopbackOnlyMessage : privateAddressMessage,
168
+ parsedUrl
169
+ };
170
+ }
171
+
172
+ if (net.isIP(normaliseAddress(hostname)) && policyRejectsAddress(hostname, { addressPolicy, allowLoopbackAddresses })) {
173
+ return {
174
+ ok: false,
175
+ reason: addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK ? loopbackOnlyMessage : privateAddressMessage,
176
+ parsedUrl
177
+ };
178
+ }
179
+
180
+ try {
181
+ const addresses = await lookupImpl(normaliseAddress(hostname), { all: true, verbatim: true });
182
+ if (addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK) {
183
+ if (!addresses.length || addresses.some(({ address }) => !isLoopbackIpAddress(address))) {
184
+ return { ok: false, reason: loopbackOnlyMessage, parsedUrl };
185
+ }
186
+ } else if (addresses.some(({ address }) => policyRejectsAddress(address, { addressPolicy, allowLoopbackAddresses }))) {
187
+ return { ok: false, reason: privateAddressMessage, parsedUrl };
188
+ }
189
+ } catch {
190
+ if (addressPolicy === OUTBOUND_ADDRESS_POLICY.REQUIRE_LOOPBACK) {
191
+ return { ok: false, reason: loopbackOnlyMessage, parsedUrl };
192
+ }
193
+ // Allow downstream fetch errors to surface if DNS resolution fails here.
194
+ }
195
+
196
+ return { ok: true, parsedUrl };
197
+ }