@ch4p/cli 0.2.3 → 0.3.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/dist/agent-FJPYQ6XV.js +765 -0
- package/dist/canvas-2JNS7CMC.js +324 -0
- package/dist/canvas-FTJBNVCM.js +324 -0
- package/dist/canvas-TGYQHRYN.js +324 -0
- package/dist/chunk-2JQRW4PJ.js +2411 -0
- package/dist/chunk-MQKGGZAB.js +7736 -0
- package/dist/chunk-ODOKLIJK.js +2415 -0
- package/dist/chunk-YTUBP6WQ.js +2410 -0
- package/dist/gateway-BS2IUIQT.js +19 -0
- package/dist/gateway-J7WHUIK3.js +19 -0
- package/dist/gateway-JTQ2FYQZ.js +19 -0
- package/dist/index.js +4 -4
- package/dist/install-4JYHJURZ.js +378 -0
- package/package.json +17 -17
|
@@ -0,0 +1,2411 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GatewayServer,
|
|
3
|
+
LogChannel,
|
|
4
|
+
MessageRouter,
|
|
5
|
+
PairingManager,
|
|
6
|
+
Scheduler,
|
|
7
|
+
SessionManager
|
|
8
|
+
} from "./chunk-WYXCGS55.js";
|
|
9
|
+
import {
|
|
10
|
+
BlueBubblesChannel,
|
|
11
|
+
ChannelRegistry,
|
|
12
|
+
CliChannel,
|
|
13
|
+
DiscordChannel,
|
|
14
|
+
GoogleChatChannel,
|
|
15
|
+
IMessageChannel,
|
|
16
|
+
IrcChannel,
|
|
17
|
+
MacOSChannel,
|
|
18
|
+
MatrixChannel,
|
|
19
|
+
SignalChannel,
|
|
20
|
+
SlackChannel,
|
|
21
|
+
TeamsChannel,
|
|
22
|
+
TelegramChannel,
|
|
23
|
+
WebChatChannel,
|
|
24
|
+
WhatsAppChannel,
|
|
25
|
+
ZaloChannel,
|
|
26
|
+
ZaloPersonalChannel
|
|
27
|
+
} from "./chunk-3CYOOHMM.js";
|
|
28
|
+
import {
|
|
29
|
+
DeepgramSTT,
|
|
30
|
+
DefaultSecurityPolicy,
|
|
31
|
+
ElevenLabsTTS,
|
|
32
|
+
NativeEngine,
|
|
33
|
+
ProviderRegistry,
|
|
34
|
+
VoiceProcessor,
|
|
35
|
+
WhisperSTT,
|
|
36
|
+
buildSystemPrompt,
|
|
37
|
+
createClaudeCliEngine,
|
|
38
|
+
createCodexCliEngine,
|
|
39
|
+
createMemoryBackend,
|
|
40
|
+
createObserver
|
|
41
|
+
} from "./chunk-KLGE5YZR.js";
|
|
42
|
+
import {
|
|
43
|
+
LoadSkillTool,
|
|
44
|
+
ToolRegistry
|
|
45
|
+
} from "./chunk-MABLWEGE.js";
|
|
46
|
+
import {
|
|
47
|
+
SkillRegistry
|
|
48
|
+
} from "./chunk-6BURGD2Y.js";
|
|
49
|
+
import {
|
|
50
|
+
AgentLoop,
|
|
51
|
+
ContextManager,
|
|
52
|
+
FormatVerifier,
|
|
53
|
+
LLMVerifier,
|
|
54
|
+
Session,
|
|
55
|
+
ToolWorkerPool,
|
|
56
|
+
createAutoRecallHook,
|
|
57
|
+
createAutoSummarizeHook
|
|
58
|
+
} from "./chunk-UNF4S4CA.js";
|
|
59
|
+
import {
|
|
60
|
+
backoffDelay,
|
|
61
|
+
generateId
|
|
62
|
+
} from "./chunk-YSCX2QQQ.js";
|
|
63
|
+
import {
|
|
64
|
+
getCh4pDir,
|
|
65
|
+
getLogsDir,
|
|
66
|
+
loadConfig,
|
|
67
|
+
saveConfig
|
|
68
|
+
} from "./chunk-AORLXQHZ.js";
|
|
69
|
+
import {
|
|
70
|
+
BOLD,
|
|
71
|
+
DIM,
|
|
72
|
+
GREEN,
|
|
73
|
+
RED,
|
|
74
|
+
RESET,
|
|
75
|
+
TEAL,
|
|
76
|
+
YELLOW,
|
|
77
|
+
box,
|
|
78
|
+
kvRow
|
|
79
|
+
} from "./chunk-NMGPBPNU.js";
|
|
80
|
+
|
|
81
|
+
// src/commands/gateway.ts
|
|
82
|
+
import { createRequire } from "module";
|
|
83
|
+
|
|
84
|
+
// ../../packages/plugin-x402/dist/index.js
|
|
85
|
+
import { ethers } from "ethers";
|
|
86
|
+
var PUBLIC_PATHS = /* @__PURE__ */ new Set([
|
|
87
|
+
"/health",
|
|
88
|
+
"/.well-known/agent.json",
|
|
89
|
+
"/pair"
|
|
90
|
+
]);
|
|
91
|
+
var DEFAULT_ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
92
|
+
var DEFAULT_NETWORK = "base";
|
|
93
|
+
var DEFAULT_TIMEOUT = 300;
|
|
94
|
+
function pathMatches(urlPath, pattern) {
|
|
95
|
+
if (pattern === "*" || pattern === "/**") return true;
|
|
96
|
+
if (pattern.endsWith("/*")) {
|
|
97
|
+
const prefix = pattern.slice(0, -2);
|
|
98
|
+
return urlPath === prefix || urlPath.startsWith(prefix + "/");
|
|
99
|
+
}
|
|
100
|
+
return urlPath === pattern;
|
|
101
|
+
}
|
|
102
|
+
function extractPath(url) {
|
|
103
|
+
const qIdx = url.indexOf("?");
|
|
104
|
+
return qIdx >= 0 ? url.slice(0, qIdx) : url;
|
|
105
|
+
}
|
|
106
|
+
function send402(res, requirements) {
|
|
107
|
+
const body = JSON.stringify({
|
|
108
|
+
x402Version: 1,
|
|
109
|
+
error: "X402",
|
|
110
|
+
accepts: [requirements]
|
|
111
|
+
});
|
|
112
|
+
res.writeHead(402, {
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"Content-Length": Buffer.byteLength(body)
|
|
115
|
+
});
|
|
116
|
+
res.end(body);
|
|
117
|
+
}
|
|
118
|
+
function decodePaymentHeader(header) {
|
|
119
|
+
try {
|
|
120
|
+
const json = Buffer.from(header, "base64").toString("utf-8");
|
|
121
|
+
const parsed = JSON.parse(json);
|
|
122
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
123
|
+
const p = parsed;
|
|
124
|
+
if (p["x402Version"] !== 1) return null;
|
|
125
|
+
if (p["scheme"] !== "exact") return null;
|
|
126
|
+
if (typeof p["network"] !== "string") return null;
|
|
127
|
+
const payload = p["payload"];
|
|
128
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
129
|
+
const pl = payload;
|
|
130
|
+
if (typeof pl["signature"] !== "string") return null;
|
|
131
|
+
const auth = pl["authorization"];
|
|
132
|
+
if (typeof auth !== "object" || auth === null) return null;
|
|
133
|
+
const a = auth;
|
|
134
|
+
if (typeof a["from"] !== "string" || typeof a["to"] !== "string" || typeof a["value"] !== "string") {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function createX402Middleware(config) {
|
|
143
|
+
if (!config.enabled || !config.server) return null;
|
|
144
|
+
const serverCfg = config.server;
|
|
145
|
+
const asset = serverCfg.asset ?? DEFAULT_ASSET;
|
|
146
|
+
const network = serverCfg.network ?? DEFAULT_NETWORK;
|
|
147
|
+
const maxTimeoutSeconds = serverCfg.maxTimeoutSeconds ?? DEFAULT_TIMEOUT;
|
|
148
|
+
const description = serverCfg.description ?? "Payment required to access this gateway resource.";
|
|
149
|
+
const protectedPaths = serverCfg.protectedPaths ?? ["/*"];
|
|
150
|
+
return async (req, res) => {
|
|
151
|
+
const rawUrl = req.url ?? "/";
|
|
152
|
+
const urlPath = extractPath(rawUrl);
|
|
153
|
+
if (PUBLIC_PATHS.has(urlPath)) return false;
|
|
154
|
+
const isProtected = protectedPaths.some((p) => pathMatches(urlPath, p));
|
|
155
|
+
if (!isProtected) return false;
|
|
156
|
+
const matchedRoute = serverCfg.routes?.find((r) => pathMatches(urlPath, r.path));
|
|
157
|
+
const effectiveAmount = matchedRoute?.amount ?? serverCfg.amount;
|
|
158
|
+
const effectiveDescription = matchedRoute?.description ?? description;
|
|
159
|
+
const requirements = {
|
|
160
|
+
scheme: "exact",
|
|
161
|
+
network,
|
|
162
|
+
maxAmountRequired: effectiveAmount,
|
|
163
|
+
resource: urlPath,
|
|
164
|
+
description: effectiveDescription,
|
|
165
|
+
mimeType: "application/json",
|
|
166
|
+
payTo: serverCfg.payTo,
|
|
167
|
+
maxTimeoutSeconds,
|
|
168
|
+
asset,
|
|
169
|
+
extra: {}
|
|
170
|
+
};
|
|
171
|
+
const paymentHeader = req.headers["x-payment"];
|
|
172
|
+
if (!paymentHeader || typeof paymentHeader !== "string") {
|
|
173
|
+
send402(res, requirements);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const payment = decodePaymentHeader(paymentHeader);
|
|
177
|
+
if (!payment) {
|
|
178
|
+
send402(res, requirements);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (payment.network !== network) {
|
|
182
|
+
send402(res, requirements);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (serverCfg.verifyPayment) {
|
|
186
|
+
const allowed = await serverCfg.verifyPayment(payment, requirements);
|
|
187
|
+
if (!allowed) {
|
|
188
|
+
send402(res, requirements);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
req["_x402Authenticated"] = true;
|
|
193
|
+
return false;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
var PLACEHOLDER_SIG = "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
|
|
197
|
+
var X402PayTool = class {
|
|
198
|
+
name = "x402_pay";
|
|
199
|
+
description = "Generate an X-PAYMENT header for a resource that returned HTTP 402 Payment Required. Provide the 402 response body JSON and a payer wallet address. Returns the base64-encoded X-PAYMENT value to include when retrying the request. Full payment execution requires an IIdentityProvider with wallet signing support.";
|
|
200
|
+
weight = "lightweight";
|
|
201
|
+
parameters = {
|
|
202
|
+
type: "object",
|
|
203
|
+
properties: {
|
|
204
|
+
url: {
|
|
205
|
+
type: "string",
|
|
206
|
+
description: "The URL of the resource that returned 402 (used to match the requirement).",
|
|
207
|
+
minLength: 1
|
|
208
|
+
},
|
|
209
|
+
x402_response: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "The JSON body of the 402 response. Stringify the x402 error response object.",
|
|
212
|
+
minLength: 2
|
|
213
|
+
},
|
|
214
|
+
wallet_address: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "Payer wallet address (0x + 40 hex chars). If omitted, the identity provider wallet address is used when available.",
|
|
217
|
+
pattern: "^0x[0-9a-fA-F]{40}$"
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
required: ["url", "x402_response"],
|
|
221
|
+
additionalProperties: false
|
|
222
|
+
};
|
|
223
|
+
validate(args) {
|
|
224
|
+
if (typeof args !== "object" || args === null) {
|
|
225
|
+
return { valid: false, errors: ["Arguments must be an object."] };
|
|
226
|
+
}
|
|
227
|
+
const { url, x402_response, wallet_address } = args;
|
|
228
|
+
const errors = [];
|
|
229
|
+
if (typeof url !== "string" || url.trim().length === 0) {
|
|
230
|
+
errors.push("url must be a non-empty string.");
|
|
231
|
+
}
|
|
232
|
+
if (typeof x402_response !== "string" || x402_response.trim().length === 0) {
|
|
233
|
+
errors.push("x402_response must be a non-empty string.");
|
|
234
|
+
}
|
|
235
|
+
if (wallet_address !== void 0) {
|
|
236
|
+
if (typeof wallet_address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(wallet_address)) {
|
|
237
|
+
errors.push("wallet_address must be a valid Ethereum address (0x + 40 hex chars).");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return errors.length > 0 ? { valid: false, errors } : { valid: true };
|
|
241
|
+
}
|
|
242
|
+
async execute(args, context) {
|
|
243
|
+
const validation = this.validate(args);
|
|
244
|
+
if (!validation.valid) {
|
|
245
|
+
return {
|
|
246
|
+
success: false,
|
|
247
|
+
output: "",
|
|
248
|
+
error: `Invalid arguments: ${validation.errors.join(" ")}`
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const { url, x402_response, wallet_address } = args;
|
|
252
|
+
const x402Context = context;
|
|
253
|
+
let response;
|
|
254
|
+
try {
|
|
255
|
+
response = JSON.parse(x402_response);
|
|
256
|
+
} catch {
|
|
257
|
+
return { success: false, output: "", error: "x402_response is not valid JSON." };
|
|
258
|
+
}
|
|
259
|
+
if (!Array.isArray(response.accepts) || response.accepts.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
success: false,
|
|
262
|
+
output: "",
|
|
263
|
+
error: 'x402 response contains no payment requirements in "accepts".'
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const requirements = response.accepts.find((r) => r.scheme === "exact" && r.network === "base") ?? response.accepts.find((r) => r.scheme === "exact") ?? response.accepts[0];
|
|
267
|
+
const payer = wallet_address ?? x402Context.agentWalletAddress;
|
|
268
|
+
if (!payer) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
output: "",
|
|
272
|
+
error: "No wallet address available. Provide wallet_address or configure agentWalletAddress via toolContextExtensions in the agent runtime."
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const nowSecs = Math.floor(Date.now() / 1e3);
|
|
276
|
+
const randomBytes = new Uint8Array(32);
|
|
277
|
+
if (typeof globalThis.crypto !== "undefined") {
|
|
278
|
+
globalThis.crypto.getRandomValues(randomBytes);
|
|
279
|
+
} else {
|
|
280
|
+
console.warn("x402: crypto.getRandomValues unavailable; using insecure Math.random fallback for nonce.");
|
|
281
|
+
for (let i = 0; i < 32; i++) {
|
|
282
|
+
randomBytes[i] = Math.floor(Math.random() * 256);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const nonce = "0x" + Array.from(randomBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
286
|
+
const authorization = {
|
|
287
|
+
from: payer,
|
|
288
|
+
to: requirements.payTo,
|
|
289
|
+
value: requirements.maxAmountRequired,
|
|
290
|
+
validAfter: "0",
|
|
291
|
+
validBefore: String(nowSecs + requirements.maxTimeoutSeconds),
|
|
292
|
+
nonce
|
|
293
|
+
};
|
|
294
|
+
let signature;
|
|
295
|
+
let unsigned = false;
|
|
296
|
+
if (x402Context.x402Signer) {
|
|
297
|
+
try {
|
|
298
|
+
signature = await x402Context.x402Signer(authorization);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
output: "",
|
|
303
|
+
error: `Signing failed: ${err.message}`
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
signature = PLACEHOLDER_SIG;
|
|
308
|
+
unsigned = true;
|
|
309
|
+
}
|
|
310
|
+
const paymentPayload = {
|
|
311
|
+
x402Version: 1,
|
|
312
|
+
scheme: "exact",
|
|
313
|
+
network: requirements.network,
|
|
314
|
+
payload: { signature, authorization }
|
|
315
|
+
};
|
|
316
|
+
const headerValue = Buffer.from(JSON.stringify(paymentPayload)).toString("base64");
|
|
317
|
+
const lines = [
|
|
318
|
+
`Resource: ${url}`,
|
|
319
|
+
`Network: ${requirements.network}`,
|
|
320
|
+
`Amount: ${requirements.maxAmountRequired} (asset ${requirements.asset})`,
|
|
321
|
+
`Pay to: ${requirements.payTo}`,
|
|
322
|
+
`From: ${payer}`,
|
|
323
|
+
"",
|
|
324
|
+
"X-PAYMENT header value (add to your retry request):",
|
|
325
|
+
headerValue
|
|
326
|
+
];
|
|
327
|
+
if (unsigned) {
|
|
328
|
+
lines.push(
|
|
329
|
+
"",
|
|
330
|
+
"WARNING: Placeholder signature \u2014 cannot be used for real on-chain payments.",
|
|
331
|
+
"Configure an IIdentityProvider with a bound wallet to enable live signing."
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
output: lines.join("\n"),
|
|
337
|
+
metadata: {
|
|
338
|
+
headerValue,
|
|
339
|
+
network: requirements.network,
|
|
340
|
+
amount: requirements.maxAmountRequired,
|
|
341
|
+
payTo: requirements.payTo,
|
|
342
|
+
asset: requirements.asset,
|
|
343
|
+
unsigned
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
var KNOWN_TOKENS = {
|
|
349
|
+
base: {
|
|
350
|
+
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
351
|
+
chainId: 8453,
|
|
352
|
+
name: "USD Coin",
|
|
353
|
+
version: "2"
|
|
354
|
+
},
|
|
355
|
+
"base-sepolia": {
|
|
356
|
+
address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
357
|
+
chainId: 84532,
|
|
358
|
+
name: "USD Coin",
|
|
359
|
+
version: "2"
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
var TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
363
|
+
TransferWithAuthorization: [
|
|
364
|
+
{ name: "from", type: "address" },
|
|
365
|
+
{ name: "to", type: "address" },
|
|
366
|
+
{ name: "value", type: "uint256" },
|
|
367
|
+
{ name: "validAfter", type: "uint256" },
|
|
368
|
+
{ name: "validBefore", type: "uint256" },
|
|
369
|
+
{ name: "nonce", type: "bytes32" }
|
|
370
|
+
]
|
|
371
|
+
};
|
|
372
|
+
function assertValidPrivateKey(key) {
|
|
373
|
+
if (!/^0x[a-fA-F0-9]{64}$/.test(key)) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
"Invalid private key: expected a 0x-prefixed 64-character hex string (32 bytes)."
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function createEIP712Signer(privateKey, opts = {}) {
|
|
380
|
+
assertValidPrivateKey(privateKey);
|
|
381
|
+
const wallet = new ethers.Wallet(privateKey);
|
|
382
|
+
const domain = {
|
|
383
|
+
name: opts.tokenName ?? KNOWN_TOKENS.base.name,
|
|
384
|
+
version: opts.tokenVersion ?? KNOWN_TOKENS.base.version,
|
|
385
|
+
chainId: opts.chainId ?? KNOWN_TOKENS.base.chainId,
|
|
386
|
+
verifyingContract: opts.tokenAddress ?? KNOWN_TOKENS.base.address
|
|
387
|
+
};
|
|
388
|
+
return async (auth) => {
|
|
389
|
+
const message = {
|
|
390
|
+
from: auth.from,
|
|
391
|
+
to: auth.to,
|
|
392
|
+
value: BigInt(auth.value),
|
|
393
|
+
validAfter: BigInt(auth.validAfter),
|
|
394
|
+
validBefore: BigInt(auth.validBefore),
|
|
395
|
+
nonce: auth.nonce
|
|
396
|
+
};
|
|
397
|
+
return wallet.signTypedData(domain, TRANSFER_WITH_AUTHORIZATION_TYPES, message);
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function walletAddress(privateKey) {
|
|
401
|
+
assertValidPrivateKey(privateKey);
|
|
402
|
+
return new ethers.Wallet(privateKey).address;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/session-notes.ts
|
|
406
|
+
import { createHash } from "crypto";
|
|
407
|
+
import {
|
|
408
|
+
mkdirSync,
|
|
409
|
+
writeFileSync,
|
|
410
|
+
readdirSync,
|
|
411
|
+
readFileSync,
|
|
412
|
+
rmSync,
|
|
413
|
+
existsSync
|
|
414
|
+
} from "fs";
|
|
415
|
+
import { join } from "path";
|
|
416
|
+
var SessionNotes = class {
|
|
417
|
+
dir;
|
|
418
|
+
constructor(dataDir) {
|
|
419
|
+
this.dir = join(dataDir, "sessions");
|
|
420
|
+
mkdirSync(this.dir, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
keyToFile(contextKey) {
|
|
423
|
+
const hash = createHash("sha256").update(contextKey).digest("hex").slice(0, 16);
|
|
424
|
+
return join(this.dir, `${hash}.json`);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Upsert a note at the START of each agent run.
|
|
428
|
+
* Replaces any prior note for the same contextKey. `recentActivity` resets
|
|
429
|
+
* to empty so it only reflects the current run's progress.
|
|
430
|
+
*/
|
|
431
|
+
upsert(note) {
|
|
432
|
+
const path = this.keyToFile(note.contextKey);
|
|
433
|
+
writeFileSync(path, JSON.stringify(note, null, 2), "utf8");
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Append a brief snippet of agent progress AFTER each LLM turn.
|
|
437
|
+
* Capped at the last 3 entries; each snippet truncated to 200 chars.
|
|
438
|
+
* Silent no-op if the note file doesn't exist yet.
|
|
439
|
+
*/
|
|
440
|
+
appendActivity(contextKey, snippet) {
|
|
441
|
+
const path = this.keyToFile(contextKey);
|
|
442
|
+
if (!existsSync(path)) return;
|
|
443
|
+
try {
|
|
444
|
+
const note = JSON.parse(readFileSync(path, "utf8"));
|
|
445
|
+
note.recentActivity = [...note.recentActivity, snippet.slice(0, 200)].slice(-3);
|
|
446
|
+
writeFileSync(path, JSON.stringify(note, null, 2), "utf8");
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Delete the note on successful completion or idle eviction.
|
|
452
|
+
* Silent no-op if the file doesn't exist.
|
|
453
|
+
*/
|
|
454
|
+
delete(contextKey) {
|
|
455
|
+
try {
|
|
456
|
+
rmSync(this.keyToFile(contextKey));
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Return all notes younger than `maxAgeMs` (default 10 minutes).
|
|
462
|
+
* Used once on startup to discover sessions that need to be resumed.
|
|
463
|
+
* Skips unreadable / malformed files silently.
|
|
464
|
+
*/
|
|
465
|
+
loadRecent(maxAgeMs = 10 * 6e4) {
|
|
466
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
467
|
+
try {
|
|
468
|
+
const files = readdirSync(this.dir).filter((f) => f.endsWith(".json"));
|
|
469
|
+
const results = [];
|
|
470
|
+
for (const file of files) {
|
|
471
|
+
try {
|
|
472
|
+
const raw = readFileSync(join(this.dir, file), "utf8");
|
|
473
|
+
const note = JSON.parse(raw);
|
|
474
|
+
if (typeof note.requestAt === "number" && note.requestAt >= cutoff) {
|
|
475
|
+
results.push(note);
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return results;
|
|
481
|
+
} catch {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// ../../packages/tunnels/dist/index.js
|
|
488
|
+
import { spawn } from "child_process";
|
|
489
|
+
import { spawn as spawn2, execSync } from "child_process";
|
|
490
|
+
import { spawn as spawn3 } from "child_process";
|
|
491
|
+
var CloudflareTunnel = class {
|
|
492
|
+
id = "cloudflare";
|
|
493
|
+
process = null;
|
|
494
|
+
publicUrl = null;
|
|
495
|
+
active = false;
|
|
496
|
+
startedAt = null;
|
|
497
|
+
// -----------------------------------------------------------------------
|
|
498
|
+
// ITunnelProvider implementation
|
|
499
|
+
// -----------------------------------------------------------------------
|
|
500
|
+
async start(config) {
|
|
501
|
+
if (this.active) {
|
|
502
|
+
throw new Error("Cloudflare tunnel is already running");
|
|
503
|
+
}
|
|
504
|
+
const cfg = config;
|
|
505
|
+
const binaryPath = cfg.binaryPath ?? "cloudflared";
|
|
506
|
+
const protocol = cfg.protocol ?? "http";
|
|
507
|
+
const localUrl = `${protocol}://localhost:${config.port}`;
|
|
508
|
+
const args = ["tunnel"];
|
|
509
|
+
if (cfg.tunnelName) {
|
|
510
|
+
args.push("run", "--url", localUrl, cfg.tunnelName);
|
|
511
|
+
} else {
|
|
512
|
+
args.push("--url", localUrl);
|
|
513
|
+
}
|
|
514
|
+
return new Promise((resolve, reject) => {
|
|
515
|
+
const child = spawn(binaryPath, args, {
|
|
516
|
+
env: { ...process.env },
|
|
517
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
518
|
+
});
|
|
519
|
+
this.process = child;
|
|
520
|
+
let resolved = false;
|
|
521
|
+
let stderr = "";
|
|
522
|
+
child.stderr?.on("data", (data) => {
|
|
523
|
+
const text = data.toString();
|
|
524
|
+
stderr += text;
|
|
525
|
+
const urlMatch = text.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
|
|
526
|
+
if (urlMatch && !resolved) {
|
|
527
|
+
resolved = true;
|
|
528
|
+
this.publicUrl = urlMatch[1];
|
|
529
|
+
this.active = true;
|
|
530
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
531
|
+
resolve({
|
|
532
|
+
publicUrl: this.publicUrl,
|
|
533
|
+
provider: this.id,
|
|
534
|
+
startedAt: this.startedAt
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const namedMatch = text.match(/https:\/\/([a-z0-9-]+\.cfargotunnel\.com)/);
|
|
538
|
+
if (namedMatch && !resolved && cfg.tunnelName) {
|
|
539
|
+
resolved = true;
|
|
540
|
+
this.publicUrl = `https://${namedMatch[1]}`;
|
|
541
|
+
this.active = true;
|
|
542
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
543
|
+
resolve({
|
|
544
|
+
publicUrl: this.publicUrl,
|
|
545
|
+
provider: this.id,
|
|
546
|
+
startedAt: this.startedAt
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (!resolved) {
|
|
550
|
+
const fallback = text.match(/(https:\/\/[a-z0-9-]+\.[a-z0-9.-]*cloudflare[a-z]*\.[a-z]+)/);
|
|
551
|
+
if (fallback) {
|
|
552
|
+
resolved = true;
|
|
553
|
+
this.publicUrl = fallback[1];
|
|
554
|
+
this.active = true;
|
|
555
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
556
|
+
resolve({
|
|
557
|
+
publicUrl: this.publicUrl,
|
|
558
|
+
provider: this.id,
|
|
559
|
+
startedAt: this.startedAt
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
child.on("error", (err) => {
|
|
565
|
+
if (!resolved) {
|
|
566
|
+
resolved = true;
|
|
567
|
+
reject(new Error(`Failed to start cloudflared: ${err.message}`));
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
child.on("close", (code) => {
|
|
571
|
+
this.active = false;
|
|
572
|
+
if (!resolved) {
|
|
573
|
+
resolved = true;
|
|
574
|
+
reject(new Error(
|
|
575
|
+
`cloudflared exited with code ${code}${stderr ? ": " + stderr.slice(0, 500) : ""}`
|
|
576
|
+
));
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
setTimeout(() => {
|
|
580
|
+
if (!resolved) {
|
|
581
|
+
resolved = true;
|
|
582
|
+
try {
|
|
583
|
+
child.kill("SIGTERM");
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
reject(new Error("Cloudflare tunnel startup timed out"));
|
|
587
|
+
}
|
|
588
|
+
}, 3e4);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
async stop() {
|
|
592
|
+
if (this.process && !this.process.killed) {
|
|
593
|
+
this.process.kill("SIGTERM");
|
|
594
|
+
}
|
|
595
|
+
this.process = null;
|
|
596
|
+
this.publicUrl = null;
|
|
597
|
+
this.active = false;
|
|
598
|
+
}
|
|
599
|
+
isActive() {
|
|
600
|
+
return this.active;
|
|
601
|
+
}
|
|
602
|
+
getPublicUrl() {
|
|
603
|
+
return this.publicUrl;
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
var TailscaleTunnel = class {
|
|
607
|
+
id = "tailscale";
|
|
608
|
+
process = null;
|
|
609
|
+
publicUrl = null;
|
|
610
|
+
active = false;
|
|
611
|
+
startedAt = null;
|
|
612
|
+
// -----------------------------------------------------------------------
|
|
613
|
+
// ITunnelProvider implementation
|
|
614
|
+
// -----------------------------------------------------------------------
|
|
615
|
+
async start(config) {
|
|
616
|
+
if (this.active) {
|
|
617
|
+
throw new Error("Tailscale tunnel is already running");
|
|
618
|
+
}
|
|
619
|
+
const cfg = config;
|
|
620
|
+
const binaryPath = cfg.binaryPath ?? "tailscale";
|
|
621
|
+
const hostname = this.getTailscaleHostname(binaryPath);
|
|
622
|
+
if (!hostname) {
|
|
623
|
+
throw new Error("Could not determine Tailscale hostname. Is Tailscale running?");
|
|
624
|
+
}
|
|
625
|
+
this.publicUrl = `https://${hostname}:${config.port}`;
|
|
626
|
+
const args = ["funnel", String(config.port)];
|
|
627
|
+
if (cfg.background) {
|
|
628
|
+
args.push("--bg");
|
|
629
|
+
}
|
|
630
|
+
return new Promise((resolve, reject) => {
|
|
631
|
+
const child = spawn2(binaryPath, args, {
|
|
632
|
+
env: { ...process.env },
|
|
633
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
634
|
+
});
|
|
635
|
+
this.process = child;
|
|
636
|
+
let resolved = false;
|
|
637
|
+
let output = "";
|
|
638
|
+
const handler = (data) => {
|
|
639
|
+
const text = data.toString();
|
|
640
|
+
output += text;
|
|
641
|
+
if ((text.includes("Funnel started") || text.includes("https://") || text.includes("Available on the internet")) && !resolved) {
|
|
642
|
+
resolved = true;
|
|
643
|
+
this.active = true;
|
|
644
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
645
|
+
const urlMatch = text.match(/(https:\/\/[^\s]+)/);
|
|
646
|
+
if (urlMatch) {
|
|
647
|
+
this.publicUrl = urlMatch[1];
|
|
648
|
+
}
|
|
649
|
+
resolve({
|
|
650
|
+
publicUrl: this.publicUrl,
|
|
651
|
+
provider: this.id,
|
|
652
|
+
startedAt: this.startedAt
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
child.stdout?.on("data", handler);
|
|
657
|
+
child.stderr?.on("data", handler);
|
|
658
|
+
child.on("error", (err) => {
|
|
659
|
+
if (!resolved) {
|
|
660
|
+
resolved = true;
|
|
661
|
+
reject(new Error(`Failed to start tailscale funnel: ${err.message}`));
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
child.on("close", (code) => {
|
|
665
|
+
this.active = false;
|
|
666
|
+
if (cfg.background && code === 0 && !resolved) {
|
|
667
|
+
resolved = true;
|
|
668
|
+
this.active = true;
|
|
669
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
670
|
+
resolve({
|
|
671
|
+
publicUrl: this.publicUrl,
|
|
672
|
+
provider: this.id,
|
|
673
|
+
startedAt: this.startedAt
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (!resolved) {
|
|
678
|
+
resolved = true;
|
|
679
|
+
reject(new Error(
|
|
680
|
+
`tailscale funnel exited with code ${code}${output ? ": " + output.slice(0, 500) : ""}`
|
|
681
|
+
));
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
setTimeout(() => {
|
|
685
|
+
if (!resolved) {
|
|
686
|
+
resolved = true;
|
|
687
|
+
this.active = true;
|
|
688
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
689
|
+
resolve({
|
|
690
|
+
publicUrl: this.publicUrl,
|
|
691
|
+
provider: this.id,
|
|
692
|
+
startedAt: this.startedAt
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}, 15e3);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
async stop() {
|
|
699
|
+
if (this.process && !this.process.killed) {
|
|
700
|
+
this.process.kill("SIGTERM");
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
execSync("tailscale funnel off", { stdio: "ignore", timeout: 5e3 });
|
|
704
|
+
} catch {
|
|
705
|
+
}
|
|
706
|
+
this.process = null;
|
|
707
|
+
this.publicUrl = null;
|
|
708
|
+
this.active = false;
|
|
709
|
+
}
|
|
710
|
+
isActive() {
|
|
711
|
+
return this.active;
|
|
712
|
+
}
|
|
713
|
+
getPublicUrl() {
|
|
714
|
+
return this.publicUrl;
|
|
715
|
+
}
|
|
716
|
+
// -----------------------------------------------------------------------
|
|
717
|
+
// Private helpers
|
|
718
|
+
// -----------------------------------------------------------------------
|
|
719
|
+
getTailscaleHostname(binary) {
|
|
720
|
+
try {
|
|
721
|
+
const output = execSync(`${binary} status --json`, {
|
|
722
|
+
timeout: 5e3,
|
|
723
|
+
encoding: "utf8"
|
|
724
|
+
});
|
|
725
|
+
const status = JSON.parse(output);
|
|
726
|
+
const dnsName = status.Self?.DNSName;
|
|
727
|
+
return dnsName ? dnsName.replace(/\.$/, "") : null;
|
|
728
|
+
} catch {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var NgrokTunnel = class {
|
|
734
|
+
id = "ngrok";
|
|
735
|
+
process = null;
|
|
736
|
+
publicUrl = null;
|
|
737
|
+
active = false;
|
|
738
|
+
startedAt = null;
|
|
739
|
+
// -----------------------------------------------------------------------
|
|
740
|
+
// ITunnelProvider implementation
|
|
741
|
+
// -----------------------------------------------------------------------
|
|
742
|
+
async start(config) {
|
|
743
|
+
if (this.active) {
|
|
744
|
+
throw new Error("ngrok tunnel is already running");
|
|
745
|
+
}
|
|
746
|
+
const cfg = config;
|
|
747
|
+
const binaryPath = cfg.binaryPath ?? "ngrok";
|
|
748
|
+
const apiUrl = cfg.apiUrl ?? "http://127.0.0.1:4040";
|
|
749
|
+
const args = ["http", String(config.port)];
|
|
750
|
+
if (cfg.authToken) {
|
|
751
|
+
args.push("--authtoken", cfg.authToken);
|
|
752
|
+
}
|
|
753
|
+
if (cfg.subdomain) {
|
|
754
|
+
args.push("--subdomain", cfg.subdomain);
|
|
755
|
+
}
|
|
756
|
+
if (cfg.region) {
|
|
757
|
+
args.push("--region", cfg.region);
|
|
758
|
+
}
|
|
759
|
+
const child = spawn3(binaryPath, args, {
|
|
760
|
+
env: { ...process.env },
|
|
761
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
762
|
+
});
|
|
763
|
+
this.process = child;
|
|
764
|
+
child.on("error", () => {
|
|
765
|
+
this.active = false;
|
|
766
|
+
});
|
|
767
|
+
child.on("close", () => {
|
|
768
|
+
this.active = false;
|
|
769
|
+
});
|
|
770
|
+
const url = await this.waitForTunnel(apiUrl, 15e3);
|
|
771
|
+
if (!url) {
|
|
772
|
+
this.stop();
|
|
773
|
+
throw new Error("Could not retrieve ngrok public URL from local API");
|
|
774
|
+
}
|
|
775
|
+
this.publicUrl = url;
|
|
776
|
+
this.active = true;
|
|
777
|
+
this.startedAt = /* @__PURE__ */ new Date();
|
|
778
|
+
return {
|
|
779
|
+
publicUrl: this.publicUrl,
|
|
780
|
+
provider: this.id,
|
|
781
|
+
startedAt: this.startedAt
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
async stop() {
|
|
785
|
+
if (this.process && !this.process.killed) {
|
|
786
|
+
this.process.kill("SIGTERM");
|
|
787
|
+
}
|
|
788
|
+
this.process = null;
|
|
789
|
+
this.publicUrl = null;
|
|
790
|
+
this.active = false;
|
|
791
|
+
}
|
|
792
|
+
isActive() {
|
|
793
|
+
return this.active;
|
|
794
|
+
}
|
|
795
|
+
getPublicUrl() {
|
|
796
|
+
return this.publicUrl;
|
|
797
|
+
}
|
|
798
|
+
// -----------------------------------------------------------------------
|
|
799
|
+
// Private: poll ngrok local API
|
|
800
|
+
// -----------------------------------------------------------------------
|
|
801
|
+
/**
|
|
802
|
+
* Poll the ngrok local API to get the public tunnel URL.
|
|
803
|
+
* ngrok exposes tunnel info at http://127.0.0.1:4040/api/tunnels.
|
|
804
|
+
*/
|
|
805
|
+
async waitForTunnel(apiUrl, timeoutMs) {
|
|
806
|
+
const start = Date.now();
|
|
807
|
+
const pollInterval = 500;
|
|
808
|
+
while (Date.now() - start < timeoutMs) {
|
|
809
|
+
try {
|
|
810
|
+
const response = await fetch(`${apiUrl}/api/tunnels`);
|
|
811
|
+
if (response.ok) {
|
|
812
|
+
const data = await response.json();
|
|
813
|
+
const httpsTunnel = data.tunnels.find((t) => t.proto === "https");
|
|
814
|
+
const anyTunnel = data.tunnels[0];
|
|
815
|
+
const tunnel = httpsTunnel ?? anyTunnel;
|
|
816
|
+
if (tunnel?.public_url) {
|
|
817
|
+
return tunnel.public_url;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
}
|
|
822
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
823
|
+
}
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
function createTunnelProvider(name) {
|
|
828
|
+
switch (name) {
|
|
829
|
+
case "cloudflare":
|
|
830
|
+
return new CloudflareTunnel();
|
|
831
|
+
case "tailscale":
|
|
832
|
+
return new TailscaleTunnel();
|
|
833
|
+
case "ngrok":
|
|
834
|
+
return new NgrokTunnel();
|
|
835
|
+
default:
|
|
836
|
+
throw new Error(`Unknown tunnel provider: "${name}". Supported: cloudflare, tailscale, ngrok`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ../../packages/supervisor/dist/index.js
|
|
841
|
+
import { EventEmitter } from "events";
|
|
842
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
843
|
+
var DEFAULT_RESTART_POLICY = {
|
|
844
|
+
strategy: "one-for-one",
|
|
845
|
+
maxRestarts: 5,
|
|
846
|
+
windowMs: 6e4,
|
|
847
|
+
backoffBaseMs: 1e3,
|
|
848
|
+
backoffMaxMs: 3e4
|
|
849
|
+
};
|
|
850
|
+
var MAX_CRASH_HISTORY = 256;
|
|
851
|
+
var DEFAULT_HEALTH_CONFIG = {
|
|
852
|
+
heartbeatIntervalMs: 5e3,
|
|
853
|
+
missedThreshold: 3
|
|
854
|
+
};
|
|
855
|
+
var HealthMonitor = class extends EventEmitter {
|
|
856
|
+
config;
|
|
857
|
+
children = /* @__PURE__ */ new Map();
|
|
858
|
+
checkTimer = null;
|
|
859
|
+
constructor(config) {
|
|
860
|
+
super();
|
|
861
|
+
this.config = { ...DEFAULT_HEALTH_CONFIG, ...config };
|
|
862
|
+
}
|
|
863
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
864
|
+
/** Begin the periodic heartbeat check loop. */
|
|
865
|
+
start() {
|
|
866
|
+
if (this.checkTimer !== null) return;
|
|
867
|
+
this.checkTimer = setInterval(() => {
|
|
868
|
+
this.checkHeartbeats();
|
|
869
|
+
}, this.config.heartbeatIntervalMs);
|
|
870
|
+
this.checkTimer.unref();
|
|
871
|
+
}
|
|
872
|
+
/** Stop the periodic check loop and clear all tracked state. */
|
|
873
|
+
stop() {
|
|
874
|
+
if (this.checkTimer !== null) {
|
|
875
|
+
clearInterval(this.checkTimer);
|
|
876
|
+
this.checkTimer = null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/** Remove all tracked children and stop the timer. */
|
|
880
|
+
dispose() {
|
|
881
|
+
this.stop();
|
|
882
|
+
this.children.clear();
|
|
883
|
+
this.removeAllListeners();
|
|
884
|
+
}
|
|
885
|
+
// ── Child registration ───────────────────────────────────────────────
|
|
886
|
+
/** Register a new child for health tracking. Idempotent. */
|
|
887
|
+
registerChild(childId) {
|
|
888
|
+
if (this.children.has(childId)) return;
|
|
889
|
+
this.children.set(childId, {
|
|
890
|
+
lastHeartbeat: Date.now(),
|
|
891
|
+
missedCount: 0,
|
|
892
|
+
healthy: true,
|
|
893
|
+
crashHistory: []
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
/** Unregister a child and drop its state. */
|
|
897
|
+
unregisterChild(childId) {
|
|
898
|
+
this.children.delete(childId);
|
|
899
|
+
}
|
|
900
|
+
// ── Heartbeat API ────────────────────────────────────────────────────
|
|
901
|
+
/** Record a heartbeat for a child, resetting its missed count. */
|
|
902
|
+
recordHeartbeat(childId) {
|
|
903
|
+
const state = this.children.get(childId);
|
|
904
|
+
if (!state) return;
|
|
905
|
+
const wasPreviouslyUnhealthy = !state.healthy;
|
|
906
|
+
state.lastHeartbeat = Date.now();
|
|
907
|
+
state.missedCount = 0;
|
|
908
|
+
state.healthy = true;
|
|
909
|
+
if (wasPreviouslyUnhealthy) {
|
|
910
|
+
this.emit("healthy", childId);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/** Whether a specific child is currently considered healthy. */
|
|
914
|
+
isHealthy(childId) {
|
|
915
|
+
const state = this.children.get(childId);
|
|
916
|
+
if (!state) return false;
|
|
917
|
+
return state.healthy;
|
|
918
|
+
}
|
|
919
|
+
// ── Crash recording ──────────────────────────────────────────────────
|
|
920
|
+
/** Record a crash for a child. Appends to history and emits 'crashed'. */
|
|
921
|
+
recordCrash(childId, error, exitCode, signal) {
|
|
922
|
+
let state = this.children.get(childId);
|
|
923
|
+
if (!state) {
|
|
924
|
+
state = {
|
|
925
|
+
lastHeartbeat: 0,
|
|
926
|
+
missedCount: 0,
|
|
927
|
+
healthy: false,
|
|
928
|
+
crashHistory: []
|
|
929
|
+
};
|
|
930
|
+
this.children.set(childId, state);
|
|
931
|
+
}
|
|
932
|
+
state.healthy = false;
|
|
933
|
+
const record = {
|
|
934
|
+
childId,
|
|
935
|
+
timestamp: Date.now(),
|
|
936
|
+
error,
|
|
937
|
+
exitCode,
|
|
938
|
+
signal
|
|
939
|
+
};
|
|
940
|
+
state.crashHistory.push(record);
|
|
941
|
+
if (state.crashHistory.length > MAX_CRASH_HISTORY) {
|
|
942
|
+
state.crashHistory.shift();
|
|
943
|
+
}
|
|
944
|
+
this.emit("crashed", childId, error);
|
|
945
|
+
}
|
|
946
|
+
/** Notify the monitor that a child has been restarted. */
|
|
947
|
+
recordRestart(childId) {
|
|
948
|
+
const state = this.children.get(childId);
|
|
949
|
+
if (state) {
|
|
950
|
+
state.lastHeartbeat = Date.now();
|
|
951
|
+
state.missedCount = 0;
|
|
952
|
+
state.healthy = true;
|
|
953
|
+
}
|
|
954
|
+
this.emit("restarted", childId);
|
|
955
|
+
}
|
|
956
|
+
// ── Queries ──────────────────────────────────────────────────────────
|
|
957
|
+
/** Get the full crash history for a child. */
|
|
958
|
+
getCrashHistory(childId) {
|
|
959
|
+
return this.children.get(childId)?.crashHistory ?? [];
|
|
960
|
+
}
|
|
961
|
+
/** Returns true only when ALL registered children are healthy. */
|
|
962
|
+
getOverallHealth() {
|
|
963
|
+
if (this.children.size === 0) return true;
|
|
964
|
+
for (const state of this.children.values()) {
|
|
965
|
+
if (!state.healthy) return false;
|
|
966
|
+
}
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
/** Get the health state snapshot for a specific child. */
|
|
970
|
+
getChildHealth(childId) {
|
|
971
|
+
const state = this.children.get(childId);
|
|
972
|
+
if (!state) return void 0;
|
|
973
|
+
return { ...state, crashHistory: [...state.crashHistory] };
|
|
974
|
+
}
|
|
975
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
976
|
+
/** Runs on a timer to detect missed heartbeats. */
|
|
977
|
+
checkHeartbeats() {
|
|
978
|
+
const now = Date.now();
|
|
979
|
+
for (const [childId, state] of this.children) {
|
|
980
|
+
if (!state.healthy && state.missedCount >= this.config.missedThreshold) {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const elapsed = now - state.lastHeartbeat;
|
|
984
|
+
if (elapsed > this.config.heartbeatIntervalMs) {
|
|
985
|
+
state.missedCount += 1;
|
|
986
|
+
if (state.missedCount >= this.config.missedThreshold && state.healthy) {
|
|
987
|
+
state.healthy = false;
|
|
988
|
+
this.emit("unhealthy", childId, state.missedCount);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
var Supervisor = class extends EventEmitter2 {
|
|
995
|
+
children = [];
|
|
996
|
+
childIndex = /* @__PURE__ */ new Map();
|
|
997
|
+
policy;
|
|
998
|
+
health;
|
|
999
|
+
running = false;
|
|
1000
|
+
stopping = false;
|
|
1001
|
+
/** Tracks in-flight restart promises so we can await them during stop(). */
|
|
1002
|
+
pendingRestarts = /* @__PURE__ */ new Map();
|
|
1003
|
+
/** AbortController used to cancel pending backoff sleeps on shutdown. */
|
|
1004
|
+
shutdownController = null;
|
|
1005
|
+
constructor(policy, health) {
|
|
1006
|
+
super();
|
|
1007
|
+
this.policy = { ...DEFAULT_RESTART_POLICY, ...policy };
|
|
1008
|
+
this.health = health ?? new HealthMonitor();
|
|
1009
|
+
}
|
|
1010
|
+
// ── Queries ──────────────────────────────────────────────────────────
|
|
1011
|
+
get isRunning() {
|
|
1012
|
+
return this.running;
|
|
1013
|
+
}
|
|
1014
|
+
getChildState(id) {
|
|
1015
|
+
const state = this.childIndex.get(id);
|
|
1016
|
+
if (!state) return void 0;
|
|
1017
|
+
return { ...state, restartTimestamps: [...state.restartTimestamps] };
|
|
1018
|
+
}
|
|
1019
|
+
getChildren() {
|
|
1020
|
+
return this.children.map((s) => ({
|
|
1021
|
+
...s,
|
|
1022
|
+
restartTimestamps: [...s.restartTimestamps]
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
getHealthMonitor() {
|
|
1026
|
+
return this.health;
|
|
1027
|
+
}
|
|
1028
|
+
// ── Child management ─────────────────────────────────────────────────
|
|
1029
|
+
/**
|
|
1030
|
+
* Register a child specification. If the supervisor is already running the
|
|
1031
|
+
* child will be started immediately.
|
|
1032
|
+
*/
|
|
1033
|
+
async addChild(spec) {
|
|
1034
|
+
if (this.childIndex.has(spec.id)) {
|
|
1035
|
+
throw new Error(`Child "${spec.id}" is already registered`);
|
|
1036
|
+
}
|
|
1037
|
+
const state = {
|
|
1038
|
+
spec,
|
|
1039
|
+
handle: null,
|
|
1040
|
+
status: "stopped",
|
|
1041
|
+
restartCount: 0,
|
|
1042
|
+
restartTimestamps: []
|
|
1043
|
+
};
|
|
1044
|
+
this.children.push(state);
|
|
1045
|
+
this.childIndex.set(spec.id, state);
|
|
1046
|
+
this.health.registerChild(spec.id);
|
|
1047
|
+
if (this.running && !this.stopping) {
|
|
1048
|
+
await this.startChild(state);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Remove a child. Shuts the child down first if it is running.
|
|
1053
|
+
*/
|
|
1054
|
+
async removeChild(id) {
|
|
1055
|
+
const state = this.childIndex.get(id);
|
|
1056
|
+
if (!state) return;
|
|
1057
|
+
this.pendingRestarts.delete(id);
|
|
1058
|
+
if (state.handle && state.status === "running") {
|
|
1059
|
+
await this.stopChild(state);
|
|
1060
|
+
}
|
|
1061
|
+
const idx = this.children.indexOf(state);
|
|
1062
|
+
if (idx !== -1) this.children.splice(idx, 1);
|
|
1063
|
+
this.childIndex.delete(id);
|
|
1064
|
+
this.health.unregisterChild(id);
|
|
1065
|
+
}
|
|
1066
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
1067
|
+
/**
|
|
1068
|
+
* Start the supervisor and all registered children in order.
|
|
1069
|
+
*/
|
|
1070
|
+
async start() {
|
|
1071
|
+
if (this.running) return;
|
|
1072
|
+
this.running = true;
|
|
1073
|
+
this.stopping = false;
|
|
1074
|
+
this.shutdownController = new AbortController();
|
|
1075
|
+
this.health.start();
|
|
1076
|
+
for (const child of this.children) {
|
|
1077
|
+
if (this.stopping) break;
|
|
1078
|
+
await this.startChild(child);
|
|
1079
|
+
}
|
|
1080
|
+
this.emit("supervisor:started");
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Gracefully stop the supervisor and all children (reverse order).
|
|
1084
|
+
*/
|
|
1085
|
+
async stop() {
|
|
1086
|
+
if (!this.running) return;
|
|
1087
|
+
this.stopping = true;
|
|
1088
|
+
this.shutdownController?.abort();
|
|
1089
|
+
if (this.pendingRestarts.size > 0) {
|
|
1090
|
+
await Promise.allSettled([...this.pendingRestarts.values()]);
|
|
1091
|
+
}
|
|
1092
|
+
for (let i = this.children.length - 1; i >= 0; i--) {
|
|
1093
|
+
const child = this.children[i];
|
|
1094
|
+
if (child.status === "running" || child.status === "restarting") {
|
|
1095
|
+
await this.stopChild(child);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
this.health.stop();
|
|
1099
|
+
this.running = false;
|
|
1100
|
+
this.stopping = false;
|
|
1101
|
+
this.pendingRestarts.clear();
|
|
1102
|
+
this.shutdownController = null;
|
|
1103
|
+
this.emit("supervisor:stopped");
|
|
1104
|
+
}
|
|
1105
|
+
// ── Manual restart ───────────────────────────────────────────────────
|
|
1106
|
+
/**
|
|
1107
|
+
* Manually restart a specific child (e.g. triggered via admin API).
|
|
1108
|
+
*/
|
|
1109
|
+
async restartChild(id) {
|
|
1110
|
+
const state = this.childIndex.get(id);
|
|
1111
|
+
if (!state) throw new Error(`Unknown child "${id}"`);
|
|
1112
|
+
if (this.stopping) return;
|
|
1113
|
+
if (state.handle && state.status === "running") {
|
|
1114
|
+
await this.stopChild(state);
|
|
1115
|
+
}
|
|
1116
|
+
await this.startChild(state);
|
|
1117
|
+
}
|
|
1118
|
+
// ── Crash handling (called by subclasses) ────────────────────────────
|
|
1119
|
+
/**
|
|
1120
|
+
* Should be called by subclasses or child monitors when a child crashes.
|
|
1121
|
+
* Applies the configured restart strategy.
|
|
1122
|
+
*/
|
|
1123
|
+
handleChildCrash(childId, error) {
|
|
1124
|
+
if (this.stopping) return;
|
|
1125
|
+
const state = this.childIndex.get(childId);
|
|
1126
|
+
if (!state) return;
|
|
1127
|
+
state.status = "crashed";
|
|
1128
|
+
state.lastError = error;
|
|
1129
|
+
state.handle = null;
|
|
1130
|
+
this.health.recordCrash(childId, error);
|
|
1131
|
+
this.emit("child:crashed", childId, error);
|
|
1132
|
+
const effectivePolicy = {
|
|
1133
|
+
...this.policy,
|
|
1134
|
+
...state.spec.restartPolicy
|
|
1135
|
+
};
|
|
1136
|
+
const restartPromise = this.applyStrategy(effectivePolicy, state).catch((strategyError) => {
|
|
1137
|
+
if (strategyError instanceof Error && strategyError.message.startsWith("Max restarts")) {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
this.emit("error", strategyError);
|
|
1141
|
+
}).finally(() => {
|
|
1142
|
+
this.pendingRestarts.delete(childId);
|
|
1143
|
+
});
|
|
1144
|
+
this.pendingRestarts.set(childId, restartPromise);
|
|
1145
|
+
}
|
|
1146
|
+
// ── Strategy application ─────────────────────────────────────────────
|
|
1147
|
+
async applyStrategy(policy, crashedState) {
|
|
1148
|
+
if (this.stopping) return;
|
|
1149
|
+
switch (policy.strategy) {
|
|
1150
|
+
case "one-for-one":
|
|
1151
|
+
await this.restartWithBackoff(crashedState, policy);
|
|
1152
|
+
break;
|
|
1153
|
+
case "rest-for-one": {
|
|
1154
|
+
const idx = this.children.indexOf(crashedState);
|
|
1155
|
+
if (idx === -1) return;
|
|
1156
|
+
const toRestart = [];
|
|
1157
|
+
for (let i = this.children.length - 1; i > idx; i--) {
|
|
1158
|
+
const sibling = this.children[i];
|
|
1159
|
+
if (sibling.status === "running") {
|
|
1160
|
+
await this.stopChild(sibling);
|
|
1161
|
+
}
|
|
1162
|
+
toRestart.unshift(sibling);
|
|
1163
|
+
}
|
|
1164
|
+
await this.restartWithBackoff(crashedState, policy);
|
|
1165
|
+
for (const sibling of toRestart) {
|
|
1166
|
+
if (this.stopping) break;
|
|
1167
|
+
await this.startChild(sibling);
|
|
1168
|
+
}
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
case "one-for-all": {
|
|
1172
|
+
for (let i = this.children.length - 1; i >= 0; i--) {
|
|
1173
|
+
const child = this.children[i];
|
|
1174
|
+
if (child !== crashedState && child.status === "running") {
|
|
1175
|
+
await this.stopChild(child);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
for (const child of this.children) {
|
|
1179
|
+
if (this.stopping) break;
|
|
1180
|
+
if (child === crashedState) {
|
|
1181
|
+
await this.restartWithBackoff(child, policy);
|
|
1182
|
+
} else {
|
|
1183
|
+
await this.startChild(child);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// ── Backoff + restart ────────────────────────────────────────────────
|
|
1191
|
+
async restartWithBackoff(state, policy) {
|
|
1192
|
+
if (this.stopping) return;
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
state.restartTimestamps = state.restartTimestamps.filter(
|
|
1195
|
+
(ts) => now - ts < policy.windowMs
|
|
1196
|
+
);
|
|
1197
|
+
if (state.restartTimestamps.length >= policy.maxRestarts) {
|
|
1198
|
+
state.status = "crashed";
|
|
1199
|
+
this.emit(
|
|
1200
|
+
"supervisor:max_restarts_exceeded",
|
|
1201
|
+
state.spec.id,
|
|
1202
|
+
state.restartTimestamps.length,
|
|
1203
|
+
policy.windowMs
|
|
1204
|
+
);
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
`Max restarts (${policy.maxRestarts}) exceeded for "${state.spec.id}" within ${policy.windowMs}ms window`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
state.status = "restarting";
|
|
1210
|
+
state.restartCount += 1;
|
|
1211
|
+
state.restartTimestamps.push(now);
|
|
1212
|
+
const delay = backoffDelay(
|
|
1213
|
+
state.restartTimestamps.length - 1,
|
|
1214
|
+
policy.backoffBaseMs,
|
|
1215
|
+
policy.backoffMaxMs
|
|
1216
|
+
);
|
|
1217
|
+
try {
|
|
1218
|
+
await this.interruptibleSleep(delay);
|
|
1219
|
+
} catch {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (this.stopping) return;
|
|
1223
|
+
await this.startChild(state);
|
|
1224
|
+
this.health.recordRestart(state.spec.id);
|
|
1225
|
+
if (state.handle) {
|
|
1226
|
+
this.emit(
|
|
1227
|
+
"child:restarted",
|
|
1228
|
+
state.spec.id,
|
|
1229
|
+
state.handle,
|
|
1230
|
+
state.restartCount
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// ── Child start / stop primitives ────────────────────────────────────
|
|
1235
|
+
async startChild(state) {
|
|
1236
|
+
if (this.stopping) return;
|
|
1237
|
+
try {
|
|
1238
|
+
const handle = await state.spec.start();
|
|
1239
|
+
state.handle = handle;
|
|
1240
|
+
state.status = "running";
|
|
1241
|
+
this.emit("child:started", state.spec.id, handle);
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1244
|
+
state.status = "crashed";
|
|
1245
|
+
state.lastError = error;
|
|
1246
|
+
this.handleChildCrash(state.spec.id, error);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
async stopChild(state) {
|
|
1250
|
+
const { handle, spec } = state;
|
|
1251
|
+
if (!handle) {
|
|
1252
|
+
state.status = "stopped";
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
try {
|
|
1256
|
+
if (spec.shutdown) {
|
|
1257
|
+
await spec.shutdown(handle);
|
|
1258
|
+
} else {
|
|
1259
|
+
handle.kill();
|
|
1260
|
+
}
|
|
1261
|
+
} catch {
|
|
1262
|
+
try {
|
|
1263
|
+
handle.kill();
|
|
1264
|
+
} catch {
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
state.handle = null;
|
|
1268
|
+
state.status = "stopped";
|
|
1269
|
+
this.emit("child:stopped", spec.id);
|
|
1270
|
+
}
|
|
1271
|
+
// ── Utilities ────────────────────────────────────────────────────────
|
|
1272
|
+
/**
|
|
1273
|
+
* Sleep that can be interrupted by the shutdown controller.
|
|
1274
|
+
* Throws if aborted so the caller can bail out.
|
|
1275
|
+
*/
|
|
1276
|
+
interruptibleSleep(ms) {
|
|
1277
|
+
const controller = this.shutdownController;
|
|
1278
|
+
if (!controller || controller.signal.aborted) {
|
|
1279
|
+
return Promise.reject(new Error("Supervisor is shutting down"));
|
|
1280
|
+
}
|
|
1281
|
+
return new Promise((resolve, reject) => {
|
|
1282
|
+
const timer = setTimeout(() => {
|
|
1283
|
+
cleanup();
|
|
1284
|
+
resolve();
|
|
1285
|
+
}, ms);
|
|
1286
|
+
const onAbort = () => {
|
|
1287
|
+
clearTimeout(timer);
|
|
1288
|
+
cleanup();
|
|
1289
|
+
reject(new Error("Supervisor is shutting down"));
|
|
1290
|
+
};
|
|
1291
|
+
const cleanup = () => {
|
|
1292
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
1293
|
+
};
|
|
1294
|
+
controller.signal.addEventListener("abort", onAbort);
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
// src/agent-router.ts
|
|
1300
|
+
var AgentRouter = class {
|
|
1301
|
+
constructor(config) {
|
|
1302
|
+
this.config = config;
|
|
1303
|
+
const rules = config.routing?.rules ?? [];
|
|
1304
|
+
this.compiledRules = rules.map((rule) => {
|
|
1305
|
+
const channelPattern = rule.channel && rule.channel !== "*" ? new RegExp(`^${rule.channel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") : null;
|
|
1306
|
+
const matchPattern = rule.match ? new RegExp(rule.match, "i") : null;
|
|
1307
|
+
return { channelPattern, matchPattern, agent: rule.agent };
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
compiledRules;
|
|
1311
|
+
/**
|
|
1312
|
+
* Evaluate routing rules against an inbound message.
|
|
1313
|
+
*
|
|
1314
|
+
* @param msg The inbound message from any channel.
|
|
1315
|
+
* @param defaultSystemPrompt The system prompt built for the default agent.
|
|
1316
|
+
* @returns A RoutingDecision describing which agent to use.
|
|
1317
|
+
*/
|
|
1318
|
+
route(msg, defaultSystemPrompt) {
|
|
1319
|
+
const channelId = msg.channelId ?? "";
|
|
1320
|
+
const text = msg.text ?? "";
|
|
1321
|
+
const agents = this.config.routing?.agents ?? {};
|
|
1322
|
+
for (const rule of this.compiledRules) {
|
|
1323
|
+
if (rule.channelPattern && !rule.channelPattern.test(channelId)) {
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if (rule.matchPattern && !rule.matchPattern.test(text)) {
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const agentCfg = agents[rule.agent];
|
|
1330
|
+
if (!agentCfg) {
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
return this.buildDecision(rule.agent, agentCfg, defaultSystemPrompt);
|
|
1334
|
+
}
|
|
1335
|
+
return this.defaultDecision(defaultSystemPrompt);
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Return a decision for a named agent. Used by tests and direct lookups.
|
|
1339
|
+
*/
|
|
1340
|
+
routeToAgent(agentName, defaultSystemPrompt) {
|
|
1341
|
+
const agents = this.config.routing?.agents ?? {};
|
|
1342
|
+
const agentCfg = agents[agentName];
|
|
1343
|
+
if (!agentCfg) return this.defaultDecision(defaultSystemPrompt);
|
|
1344
|
+
return this.buildDecision(agentName, agentCfg, defaultSystemPrompt);
|
|
1345
|
+
}
|
|
1346
|
+
/** True when routing configuration is present and has at least one rule. */
|
|
1347
|
+
hasRules() {
|
|
1348
|
+
return (this.config.routing?.rules?.length ?? 0) > 0;
|
|
1349
|
+
}
|
|
1350
|
+
/** List all defined agent names (excluding "default"). */
|
|
1351
|
+
agentNames() {
|
|
1352
|
+
return Object.keys(this.config.routing?.agents ?? {});
|
|
1353
|
+
}
|
|
1354
|
+
// -------------------------------------------------------------------------
|
|
1355
|
+
// Private helpers
|
|
1356
|
+
// -------------------------------------------------------------------------
|
|
1357
|
+
buildDecision(agentName, agentCfg, defaultSystemPrompt) {
|
|
1358
|
+
return {
|
|
1359
|
+
agentName,
|
|
1360
|
+
systemPrompt: agentCfg.systemPrompt ?? defaultSystemPrompt,
|
|
1361
|
+
model: agentCfg.model,
|
|
1362
|
+
maxIterations: agentCfg.maxIterations ?? 20,
|
|
1363
|
+
toolExclude: agentCfg.toolExclude ?? []
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
defaultDecision(defaultSystemPrompt) {
|
|
1367
|
+
return {
|
|
1368
|
+
agentName: "default",
|
|
1369
|
+
systemPrompt: defaultSystemPrompt,
|
|
1370
|
+
model: void 0,
|
|
1371
|
+
maxIterations: 20,
|
|
1372
|
+
toolExclude: []
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
// src/commands/gateway.ts
|
|
1378
|
+
function createChannelInstance(channelName) {
|
|
1379
|
+
switch (channelName) {
|
|
1380
|
+
case "telegram":
|
|
1381
|
+
return new TelegramChannel();
|
|
1382
|
+
case "discord":
|
|
1383
|
+
return new DiscordChannel();
|
|
1384
|
+
case "slack":
|
|
1385
|
+
return new SlackChannel();
|
|
1386
|
+
case "cli":
|
|
1387
|
+
return new CliChannel();
|
|
1388
|
+
case "matrix":
|
|
1389
|
+
return new MatrixChannel();
|
|
1390
|
+
case "whatsapp":
|
|
1391
|
+
return new WhatsAppChannel();
|
|
1392
|
+
case "signal":
|
|
1393
|
+
return new SignalChannel();
|
|
1394
|
+
case "imessage":
|
|
1395
|
+
return new IMessageChannel();
|
|
1396
|
+
case "teams":
|
|
1397
|
+
return new TeamsChannel();
|
|
1398
|
+
case "zalo":
|
|
1399
|
+
return new ZaloChannel();
|
|
1400
|
+
case "zalo-personal":
|
|
1401
|
+
return new ZaloPersonalChannel();
|
|
1402
|
+
case "bluebubbles":
|
|
1403
|
+
return new BlueBubblesChannel();
|
|
1404
|
+
case "googlechat":
|
|
1405
|
+
return new GoogleChatChannel();
|
|
1406
|
+
case "webchat":
|
|
1407
|
+
return new WebChatChannel();
|
|
1408
|
+
case "irc":
|
|
1409
|
+
return new IrcChannel();
|
|
1410
|
+
case "macos":
|
|
1411
|
+
return new MacOSChannel();
|
|
1412
|
+
default:
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
function createGatewayEngine(config) {
|
|
1417
|
+
const engineId = config.engines?.default ?? "native";
|
|
1418
|
+
const engineConfig = config.engines?.available?.[engineId];
|
|
1419
|
+
if (engineId === "claude-cli") {
|
|
1420
|
+
try {
|
|
1421
|
+
return createClaudeCliEngine({
|
|
1422
|
+
command: engineConfig?.command ?? void 0,
|
|
1423
|
+
cwd: engineConfig?.cwd ?? void 0,
|
|
1424
|
+
timeout: engineConfig?.timeout ?? void 0
|
|
1425
|
+
});
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1428
|
+
console.warn(` ${YELLOW}\u26A0 ${engineId} engine failed to initialize: ${msg}${RESET}`);
|
|
1429
|
+
console.warn(` ${DIM}Falling back to native SDK engine.${RESET}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (engineId === "codex-cli") {
|
|
1433
|
+
try {
|
|
1434
|
+
return createCodexCliEngine({
|
|
1435
|
+
command: engineConfig?.command ?? void 0,
|
|
1436
|
+
cwd: engineConfig?.cwd ?? void 0,
|
|
1437
|
+
timeout: engineConfig?.timeout ?? void 0
|
|
1438
|
+
});
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1441
|
+
console.warn(` ${YELLOW}\u26A0 ${engineId} engine failed to initialize: ${msg}${RESET}`);
|
|
1442
|
+
console.warn(` ${DIM}Falling back to native SDK engine.${RESET}`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
const providerName = config.agent.provider;
|
|
1446
|
+
const providerConfig = config.providers?.[providerName];
|
|
1447
|
+
const apiKey = providerConfig?.apiKey;
|
|
1448
|
+
if (providerName !== "ollama" && (!apiKey || apiKey.trim().length === 0)) {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
try {
|
|
1452
|
+
const provider = ProviderRegistry.createProvider({
|
|
1453
|
+
id: providerName,
|
|
1454
|
+
type: providerName,
|
|
1455
|
+
...providerConfig
|
|
1456
|
+
});
|
|
1457
|
+
return new NativeEngine({ provider, defaultModel: config.agent.model });
|
|
1458
|
+
} catch {
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
var MAX_CONTEXTS = 500;
|
|
1463
|
+
var GATEWAY_CONTEXT_MAX_TOKENS = 32e3;
|
|
1464
|
+
var MAX_PENDING_PER_USER = 2;
|
|
1465
|
+
function buildSafeConfig(cfg) {
|
|
1466
|
+
return {
|
|
1467
|
+
agent: {
|
|
1468
|
+
model: cfg.agent.model,
|
|
1469
|
+
provider: cfg.agent.provider,
|
|
1470
|
+
thinkingLevel: cfg.agent.thinkingLevel
|
|
1471
|
+
},
|
|
1472
|
+
gateway: { requirePairing: cfg.gateway.requirePairing },
|
|
1473
|
+
memory: { autoSave: cfg.memory.autoSave },
|
|
1474
|
+
autonomy: { level: cfg.autonomy.level },
|
|
1475
|
+
observability: { logLevel: cfg.observability.logLevel },
|
|
1476
|
+
skills: { enabled: cfg.skills.enabled },
|
|
1477
|
+
tunnel: { provider: cfg.tunnel.provider }
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function applySafeUpdates(current, updates) {
|
|
1481
|
+
const result = { ...current };
|
|
1482
|
+
if (updates.agent && typeof updates.agent === "object") {
|
|
1483
|
+
const u = updates.agent;
|
|
1484
|
+
result.agent = {
|
|
1485
|
+
...current.agent,
|
|
1486
|
+
...u.model !== void 0 && { model: String(u.model) },
|
|
1487
|
+
...u.provider !== void 0 && { provider: String(u.provider) },
|
|
1488
|
+
...u.thinkingLevel !== void 0 && { thinkingLevel: u.thinkingLevel }
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
if (updates.gateway && typeof updates.gateway === "object") {
|
|
1492
|
+
const u = updates.gateway;
|
|
1493
|
+
result.gateway = {
|
|
1494
|
+
...current.gateway,
|
|
1495
|
+
...u.requirePairing !== void 0 && { requirePairing: Boolean(u.requirePairing) }
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
if (updates.memory && typeof updates.memory === "object") {
|
|
1499
|
+
const u = updates.memory;
|
|
1500
|
+
result.memory = {
|
|
1501
|
+
...current.memory,
|
|
1502
|
+
...u.autoSave !== void 0 && { autoSave: Boolean(u.autoSave) }
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
if (updates.autonomy && typeof updates.autonomy === "object") {
|
|
1506
|
+
const u = updates.autonomy;
|
|
1507
|
+
result.autonomy = {
|
|
1508
|
+
...current.autonomy,
|
|
1509
|
+
...u.level !== void 0 && { level: u.level }
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
if (updates.observability && typeof updates.observability === "object") {
|
|
1513
|
+
const u = updates.observability;
|
|
1514
|
+
result.observability = {
|
|
1515
|
+
...current.observability,
|
|
1516
|
+
...u.logLevel !== void 0 && { logLevel: u.logLevel }
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
if (updates.skills && typeof updates.skills === "object") {
|
|
1520
|
+
const u = updates.skills;
|
|
1521
|
+
result.skills = {
|
|
1522
|
+
...current.skills,
|
|
1523
|
+
...u.enabled !== void 0 && { enabled: Boolean(u.enabled) }
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
if (updates.tunnel && typeof updates.tunnel === "object") {
|
|
1527
|
+
const u = updates.tunnel;
|
|
1528
|
+
result.tunnel = {
|
|
1529
|
+
...current.tunnel,
|
|
1530
|
+
...u.provider !== void 0 && { provider: String(u.provider) }
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
return result;
|
|
1534
|
+
}
|
|
1535
|
+
async function gateway(args) {
|
|
1536
|
+
let config;
|
|
1537
|
+
try {
|
|
1538
|
+
config = loadConfig();
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1541
|
+
console.error(`
|
|
1542
|
+
${RED}Failed to load config:${RESET} ${message}`);
|
|
1543
|
+
console.error(` ${DIM}Run ${TEAL}ch4p onboard${DIM} to set up ch4p.${RESET}
|
|
1544
|
+
`);
|
|
1545
|
+
process.exitCode = 1;
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
let port = config.gateway.port;
|
|
1549
|
+
for (let i = 0; i < args.length; i++) {
|
|
1550
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
1551
|
+
const parsed = parseInt(args[i + 1], 10);
|
|
1552
|
+
if (!isNaN(parsed) && parsed > 0 && parsed <= 65535) {
|
|
1553
|
+
port = parsed;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const host = config.gateway.allowPublicBind ? "0.0.0.0" : "127.0.0.1";
|
|
1558
|
+
const requirePairing = config.gateway.requirePairing;
|
|
1559
|
+
const sessionManager = new SessionManager();
|
|
1560
|
+
const pairingManager = requirePairing ? new PairingManager() : void 0;
|
|
1561
|
+
const engine = createGatewayEngine(config);
|
|
1562
|
+
if (!engine) {
|
|
1563
|
+
const providerName = config.agent?.provider ?? "unknown";
|
|
1564
|
+
console.error(`
|
|
1565
|
+
${RED}No engine available.${RESET} Provider "${providerName}" has no API key.`);
|
|
1566
|
+
console.error(` ${DIM}Run ${TEAL}ch4p onboard${DIM} to configure a provider, or set the API key`);
|
|
1567
|
+
console.error(` ${DIM}in ${TEAL}~/.ch4p/.env${DIM} as ${TEAL}${providerName.toUpperCase()}_API_KEY${RESET}
|
|
1568
|
+
`);
|
|
1569
|
+
process.exitCode = 1;
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
let skillRegistry;
|
|
1573
|
+
try {
|
|
1574
|
+
if (config.skills?.enabled && config.skills?.paths?.length) {
|
|
1575
|
+
skillRegistry = SkillRegistry.createFromPaths(config.skills.paths);
|
|
1576
|
+
}
|
|
1577
|
+
} catch {
|
|
1578
|
+
}
|
|
1579
|
+
let memoryBackend;
|
|
1580
|
+
try {
|
|
1581
|
+
const memCfg = {
|
|
1582
|
+
backend: config.memory.backend,
|
|
1583
|
+
vectorWeight: config.memory.vectorWeight,
|
|
1584
|
+
keywordWeight: config.memory.keywordWeight,
|
|
1585
|
+
embeddingProvider: config.memory.embeddingProvider,
|
|
1586
|
+
openaiApiKey: config.providers?.openai?.apiKey || void 0
|
|
1587
|
+
};
|
|
1588
|
+
memoryBackend = createMemoryBackend(memCfg);
|
|
1589
|
+
} catch {
|
|
1590
|
+
}
|
|
1591
|
+
const hasMemory = !!memoryBackend;
|
|
1592
|
+
const hasSearch = !!(config.search?.enabled && config.search.apiKey);
|
|
1593
|
+
const defaultSystemPrompt = buildSystemPrompt({ hasMemory, hasSearch, skillRegistry });
|
|
1594
|
+
const defaultSessionConfig = {
|
|
1595
|
+
engineId: config.engines?.default ?? "native",
|
|
1596
|
+
model: config.agent.model,
|
|
1597
|
+
provider: config.agent.provider,
|
|
1598
|
+
systemPrompt: defaultSystemPrompt
|
|
1599
|
+
};
|
|
1600
|
+
let sharedVerifier;
|
|
1601
|
+
const vCfg = config.verification;
|
|
1602
|
+
if (vCfg?.enabled) {
|
|
1603
|
+
const formatOpts = { maxToolErrorRatio: vCfg.maxToolErrorRatio ?? 0.5 };
|
|
1604
|
+
if (vCfg.semantic && engine) {
|
|
1605
|
+
try {
|
|
1606
|
+
const providerName = config.agent.provider;
|
|
1607
|
+
const providerConfig = config.providers?.[providerName];
|
|
1608
|
+
const verifierProvider = ProviderRegistry.createProvider({
|
|
1609
|
+
id: `${providerName}-verifier`,
|
|
1610
|
+
type: providerName,
|
|
1611
|
+
...providerConfig
|
|
1612
|
+
});
|
|
1613
|
+
sharedVerifier = new LLMVerifier({ provider: verifierProvider, model: config.agent.model, formatOpts });
|
|
1614
|
+
} catch {
|
|
1615
|
+
sharedVerifier = new FormatVerifier(formatOpts);
|
|
1616
|
+
}
|
|
1617
|
+
} else {
|
|
1618
|
+
sharedVerifier = new FormatVerifier(formatOpts);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const agentRouter = new AgentRouter(config);
|
|
1622
|
+
if (agentRouter.hasRules()) {
|
|
1623
|
+
const agents = config.routing?.agents ?? {};
|
|
1624
|
+
const rules = config.routing?.rules ?? [];
|
|
1625
|
+
for (const rule of rules) {
|
|
1626
|
+
if (rule.agent && !agents[rule.agent]) {
|
|
1627
|
+
console.warn(
|
|
1628
|
+
` ${YELLOW}\u26A0 Routing rule references undefined agent "${rule.agent}" \u2014 rule will be skipped.${RESET}`
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
let agentRegistration;
|
|
1634
|
+
if (config.identity?.enabled) {
|
|
1635
|
+
agentRegistration = {
|
|
1636
|
+
type: "AgentRegistrationFile",
|
|
1637
|
+
name: "ch4p",
|
|
1638
|
+
description: "ch4p personal AI assistant",
|
|
1639
|
+
image: "",
|
|
1640
|
+
services: [],
|
|
1641
|
+
active: true,
|
|
1642
|
+
...config.identity.agentId ? { agentId: config.identity.agentId } : {},
|
|
1643
|
+
...config.identity.chainId ? { chainId: config.identity.chainId } : {}
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
const x402Cfg = config.x402;
|
|
1647
|
+
const x402Middleware = x402Cfg ? createX402Middleware(x402Cfg) : null;
|
|
1648
|
+
if (x402Cfg?.enabled && x402Cfg.client?.privateKey) {
|
|
1649
|
+
if (!/^0x[a-fA-F0-9]{64}$/.test(x402Cfg.client.privateKey)) {
|
|
1650
|
+
console.error(`
|
|
1651
|
+
${RED}x402.client.privateKey is invalid:${RESET} expected a 0x-prefixed 64-character hex string.`);
|
|
1652
|
+
console.error(` ${DIM}Set it via the X402_PRIVATE_KEY env var: ${TEAL}"privateKey": "\${X402_PRIVATE_KEY}"${RESET}
|
|
1653
|
+
`);
|
|
1654
|
+
process.exitCode = 1;
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const messageRouter = new MessageRouter(sessionManager, defaultSessionConfig);
|
|
1659
|
+
const obsCfg = {
|
|
1660
|
+
observers: config.observability.observers ?? ["console"],
|
|
1661
|
+
logLevel: config.observability.logLevel ?? "info",
|
|
1662
|
+
logPath: `${getLogsDir()}/gateway.jsonl`
|
|
1663
|
+
};
|
|
1664
|
+
const observer = createObserver(obsCfg);
|
|
1665
|
+
const _require = createRequire(import.meta.url);
|
|
1666
|
+
let workerPool;
|
|
1667
|
+
try {
|
|
1668
|
+
const workerScriptPath = _require.resolve("@ch4p/agent/worker");
|
|
1669
|
+
workerPool = new ToolWorkerPool({
|
|
1670
|
+
workerScript: workerScriptPath,
|
|
1671
|
+
maxWorkers: 4,
|
|
1672
|
+
taskTimeoutMs: 6e4
|
|
1673
|
+
});
|
|
1674
|
+
} catch {
|
|
1675
|
+
workerPool = void 0;
|
|
1676
|
+
}
|
|
1677
|
+
let voiceProcessor;
|
|
1678
|
+
const voiceCfg = config.voice;
|
|
1679
|
+
if (voiceCfg?.enabled) {
|
|
1680
|
+
try {
|
|
1681
|
+
const sttProvider = voiceCfg.stt.provider === "deepgram" ? new DeepgramSTT({ apiKey: voiceCfg.stt.apiKey ?? "" }) : new WhisperSTT({ apiKey: voiceCfg.stt.apiKey ?? "" });
|
|
1682
|
+
const ttsProvider = voiceCfg.tts.provider === "elevenlabs" ? new ElevenLabsTTS({ apiKey: voiceCfg.tts.apiKey ?? "", voiceId: voiceCfg.tts.voiceId }) : void 0;
|
|
1683
|
+
voiceProcessor = new VoiceProcessor({
|
|
1684
|
+
stt: sttProvider,
|
|
1685
|
+
tts: ttsProvider,
|
|
1686
|
+
config: voiceCfg
|
|
1687
|
+
});
|
|
1688
|
+
} catch {
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
let inFlightCount = 0;
|
|
1692
|
+
let drainResolve = null;
|
|
1693
|
+
function waitForDrain(timeoutMs = 3e4) {
|
|
1694
|
+
if (inFlightCount === 0) return Promise.resolve();
|
|
1695
|
+
return new Promise((resolve) => {
|
|
1696
|
+
const timer = setTimeout(() => {
|
|
1697
|
+
drainResolve = null;
|
|
1698
|
+
console.log(` ${YELLOW}\u26A0 Drain timeout after ${timeoutMs / 1e3}s \u2014 ${inFlightCount} message(s) still in flight${RESET}`);
|
|
1699
|
+
resolve();
|
|
1700
|
+
}, timeoutMs);
|
|
1701
|
+
drainResolve = () => {
|
|
1702
|
+
clearTimeout(timer);
|
|
1703
|
+
resolve();
|
|
1704
|
+
};
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
function trackInflight(delta) {
|
|
1708
|
+
inFlightCount += delta;
|
|
1709
|
+
if (inFlightCount <= 0 && drainResolve) {
|
|
1710
|
+
inFlightCount = 0;
|
|
1711
|
+
const cb = drainResolve;
|
|
1712
|
+
drainResolve = null;
|
|
1713
|
+
cb();
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
const conversationContexts = /* @__PURE__ */ new Map();
|
|
1717
|
+
const sessionNotes = new SessionNotes(getCh4pDir());
|
|
1718
|
+
const inFlightLoops = /* @__PURE__ */ new Map();
|
|
1719
|
+
const pendingMessages = /* @__PURE__ */ new Map();
|
|
1720
|
+
const rawWebhookHandlers = /* @__PURE__ */ new Map();
|
|
1721
|
+
const logChannel = new LogChannel({
|
|
1722
|
+
onResponse: () => {
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
const server = new GatewayServer({
|
|
1726
|
+
port,
|
|
1727
|
+
host,
|
|
1728
|
+
sessionManager,
|
|
1729
|
+
pairingManager,
|
|
1730
|
+
defaultSessionConfig,
|
|
1731
|
+
agentRegistration,
|
|
1732
|
+
preHandler: x402Middleware ?? void 0,
|
|
1733
|
+
onWebhook: (name, payload) => {
|
|
1734
|
+
const syntheticMsg = {
|
|
1735
|
+
id: generateId(16),
|
|
1736
|
+
channelId: `webhook:${name}`,
|
|
1737
|
+
from: { channelId: `webhook:${name}`, userId: payload.userId ?? "webhook" },
|
|
1738
|
+
text: payload.message,
|
|
1739
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1740
|
+
};
|
|
1741
|
+
handleInboundMessage({
|
|
1742
|
+
msg: syntheticMsg,
|
|
1743
|
+
channel: logChannel,
|
|
1744
|
+
router: messageRouter,
|
|
1745
|
+
engine,
|
|
1746
|
+
config,
|
|
1747
|
+
observer,
|
|
1748
|
+
conversationContexts,
|
|
1749
|
+
agentRouter,
|
|
1750
|
+
defaultSystemPrompt,
|
|
1751
|
+
memoryBackend,
|
|
1752
|
+
skillRegistry,
|
|
1753
|
+
voiceProcessor,
|
|
1754
|
+
onInflightChange: trackInflight,
|
|
1755
|
+
workerPool,
|
|
1756
|
+
inFlightLoops,
|
|
1757
|
+
pendingMessages,
|
|
1758
|
+
sharedVerifier
|
|
1759
|
+
});
|
|
1760
|
+
},
|
|
1761
|
+
onRawWebhook: (name, body) => {
|
|
1762
|
+
const handler = rawWebhookHandlers.get(name);
|
|
1763
|
+
if (!handler) return false;
|
|
1764
|
+
handler(body);
|
|
1765
|
+
return true;
|
|
1766
|
+
},
|
|
1767
|
+
onSteer: (sessionId, message) => {
|
|
1768
|
+
for (const entry of inFlightLoops.values()) {
|
|
1769
|
+
if (entry.loop.getSessionId() === sessionId) {
|
|
1770
|
+
entry.loop.steerEngine(message);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
},
|
|
1775
|
+
onGetConfig: () => buildSafeConfig(config),
|
|
1776
|
+
onSaveConfig: async (updates) => {
|
|
1777
|
+
config = applySafeUpdates(config, updates);
|
|
1778
|
+
saveConfig(config);
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
try {
|
|
1782
|
+
await server.start();
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1785
|
+
console.error(` ${RED}Failed to start gateway:${RESET} ${message}`);
|
|
1786
|
+
process.exitCode = 1;
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
const addr = server.getAddress();
|
|
1790
|
+
const bindDisplay = addr ? `${addr.host}:${addr.port}` : `${host}:${port}`;
|
|
1791
|
+
console.log("\n" + box("ch4p Gateway", [
|
|
1792
|
+
kvRow("Server", `${GREEN}${BOLD}listening${RESET} on ${bindDisplay}`),
|
|
1793
|
+
kvRow("Pairing", requirePairing ? `${GREEN}required${RESET}` : `${YELLOW}disabled${RESET}`),
|
|
1794
|
+
kvRow("Engine", engine ? engine.name : `${YELLOW}none (no API key)${RESET}`),
|
|
1795
|
+
kvRow("Memory", memoryBackend ? config.memory.backend : `${DIM}disabled${RESET}`),
|
|
1796
|
+
kvRow("Voice", voiceProcessor ? `${GREEN}enabled${RESET} (STT: ${voiceCfg?.stt.provider ?? "?"}, TTS: ${voiceCfg?.tts.provider ?? "none"})` : `${DIM}disabled${RESET}`),
|
|
1797
|
+
kvRow("Workers", workerPool ? `${GREEN}enabled${RESET} ${DIM}(max 4 threads)${RESET}` : `${DIM}inline (worker script not built)${RESET}`),
|
|
1798
|
+
kvRow("Identity", agentRegistration ? `${GREEN}enabled${RESET} (chain ${config.identity?.chainId ?? 8453})` : `${DIM}disabled${RESET}`),
|
|
1799
|
+
kvRow("x402", x402Cfg?.enabled ? `${GREEN}enabled${RESET} ${DIM}(${x402Cfg.server?.network ?? "base"})${RESET}` : `${DIM}disabled${RESET}`)
|
|
1800
|
+
]));
|
|
1801
|
+
console.log("");
|
|
1802
|
+
console.log(` ${DIM}Routes:${RESET}`);
|
|
1803
|
+
console.log(` ${DIM} GET /health - liveness probe${RESET}`);
|
|
1804
|
+
console.log(` ${DIM} GET /ready - readiness probe${RESET}`);
|
|
1805
|
+
if (agentRegistration) {
|
|
1806
|
+
console.log(` ${DIM} GET /.well-known/agent.json - agent discovery${RESET}`);
|
|
1807
|
+
}
|
|
1808
|
+
console.log(` ${DIM} POST /pair - exchange pairing code for token${RESET}`);
|
|
1809
|
+
console.log(` ${DIM} GET /sessions - list active sessions${RESET}`);
|
|
1810
|
+
console.log(` ${DIM} POST /sessions - create a new session${RESET}`);
|
|
1811
|
+
console.log(` ${DIM} GET /sessions/:id - get session details${RESET}`);
|
|
1812
|
+
console.log(` ${DIM} POST /sessions/:id/steer - inject message into session${RESET}`);
|
|
1813
|
+
console.log(` ${DIM} DELETE /sessions/:id - end a session${RESET}`);
|
|
1814
|
+
console.log("");
|
|
1815
|
+
const channelRegistry = new ChannelRegistry();
|
|
1816
|
+
const startedChannels = [];
|
|
1817
|
+
const channelNames = Object.keys(config.channels);
|
|
1818
|
+
const channelSupervisor = new Supervisor({ strategy: "one-for-one", maxRestarts: 5, windowMs: 6e4 });
|
|
1819
|
+
channelSupervisor.on("child:crashed", (childId, error) => {
|
|
1820
|
+
console.log(` ${YELLOW}\u26A0 Channel ${childId} crashed:${RESET} ${error.message}`);
|
|
1821
|
+
});
|
|
1822
|
+
channelSupervisor.on("child:restarted", (childId, _handle, attempt) => {
|
|
1823
|
+
console.log(` ${GREEN}\u2713${RESET} Channel ${childId} restarted ${DIM}(attempt ${attempt})${RESET}`);
|
|
1824
|
+
});
|
|
1825
|
+
channelSupervisor.on("supervisor:max_restarts_exceeded", (childId, count, windowMs) => {
|
|
1826
|
+
console.log(` ${RED}\u2717${RESET} Channel ${childId} exceeded max restarts (${count} in ${Math.round(windowMs / 1e3)}s)`);
|
|
1827
|
+
});
|
|
1828
|
+
if (channelNames.length > 0) {
|
|
1829
|
+
console.log(` ${BOLD}Channels:${RESET}`);
|
|
1830
|
+
for (const channelName of channelNames) {
|
|
1831
|
+
const channelCfg = config.channels[channelName];
|
|
1832
|
+
const channel = createChannelInstance(channelName);
|
|
1833
|
+
if (!channel) {
|
|
1834
|
+
console.log(` ${YELLOW}\u26A0 Unknown channel type: ${channelName}${RESET}`);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
await channel.start(channelCfg);
|
|
1839
|
+
channelRegistry.register(channel);
|
|
1840
|
+
startedChannels.push(channel);
|
|
1841
|
+
channel.onMessage((msg) => {
|
|
1842
|
+
handleInboundMessage({
|
|
1843
|
+
msg,
|
|
1844
|
+
channel,
|
|
1845
|
+
router: messageRouter,
|
|
1846
|
+
engine,
|
|
1847
|
+
config,
|
|
1848
|
+
observer,
|
|
1849
|
+
conversationContexts,
|
|
1850
|
+
agentRouter,
|
|
1851
|
+
defaultSystemPrompt,
|
|
1852
|
+
memoryBackend,
|
|
1853
|
+
skillRegistry,
|
|
1854
|
+
voiceProcessor,
|
|
1855
|
+
onInflightChange: trackInflight,
|
|
1856
|
+
workerPool,
|
|
1857
|
+
inFlightLoops,
|
|
1858
|
+
pendingMessages,
|
|
1859
|
+
sharedVerifier,
|
|
1860
|
+
sessionNotes
|
|
1861
|
+
});
|
|
1862
|
+
});
|
|
1863
|
+
if (channelName === "teams" && "handleIncomingActivity" in channel) {
|
|
1864
|
+
rawWebhookHandlers.set("teams", (body) => {
|
|
1865
|
+
try {
|
|
1866
|
+
const activity = JSON.parse(body);
|
|
1867
|
+
channel.handleIncomingActivity(activity);
|
|
1868
|
+
} catch {
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
if (channelName === "googlechat" && "handleIncomingEvent" in channel) {
|
|
1873
|
+
rawWebhookHandlers.set("googlechat", (body) => {
|
|
1874
|
+
try {
|
|
1875
|
+
const event = JSON.parse(body);
|
|
1876
|
+
channel.handleIncomingEvent(event);
|
|
1877
|
+
} catch {
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
let alive = true;
|
|
1882
|
+
await channelSupervisor.addChild({
|
|
1883
|
+
id: channelName,
|
|
1884
|
+
start: async () => {
|
|
1885
|
+
if (!alive) {
|
|
1886
|
+
await channel.start(channelCfg);
|
|
1887
|
+
}
|
|
1888
|
+
alive = true;
|
|
1889
|
+
return {
|
|
1890
|
+
id: channelName,
|
|
1891
|
+
kill: () => {
|
|
1892
|
+
alive = false;
|
|
1893
|
+
void channel.stop();
|
|
1894
|
+
},
|
|
1895
|
+
isAlive: () => alive
|
|
1896
|
+
};
|
|
1897
|
+
},
|
|
1898
|
+
shutdown: async () => {
|
|
1899
|
+
alive = false;
|
|
1900
|
+
await channel.stop();
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
console.log(` ${GREEN}\u2713${RESET} ${channelName} ${DIM}(${channel.name})${RESET}`);
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1906
|
+
console.log(` ${RED}\u2717${RESET} ${channelName}: ${errMsg}`);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
await channelSupervisor.start();
|
|
1910
|
+
console.log("");
|
|
1911
|
+
} else {
|
|
1912
|
+
console.log(` ${DIM}No channels configured. Add channels to ~/.ch4p/config.json.${RESET}`);
|
|
1913
|
+
console.log("");
|
|
1914
|
+
}
|
|
1915
|
+
let tunnel = null;
|
|
1916
|
+
const tunnelProvider = config.tunnel.provider;
|
|
1917
|
+
if (tunnelProvider && tunnelProvider !== "none") {
|
|
1918
|
+
try {
|
|
1919
|
+
tunnel = createTunnelProvider(tunnelProvider);
|
|
1920
|
+
const tunnelCfg = {
|
|
1921
|
+
...config.tunnel,
|
|
1922
|
+
port,
|
|
1923
|
+
localHost: host
|
|
1924
|
+
};
|
|
1925
|
+
const tunnelInfo = await tunnel.start(tunnelCfg);
|
|
1926
|
+
server.setTunnelUrl(tunnelInfo.publicUrl);
|
|
1927
|
+
console.log(` ${GREEN}${BOLD}Tunnel active${RESET} ${DIM}(${tunnelProvider})${RESET}`);
|
|
1928
|
+
console.log(` ${BOLD}Public URL${RESET} ${TEAL}${tunnelInfo.publicUrl}${RESET}`);
|
|
1929
|
+
console.log("");
|
|
1930
|
+
} catch (err) {
|
|
1931
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1932
|
+
console.log(` ${YELLOW}\u26A0 Tunnel failed to start:${RESET} ${errMsg}`);
|
|
1933
|
+
console.log(` ${DIM}Gateway is still accessible locally at ${bindDisplay}.${RESET}`);
|
|
1934
|
+
console.log("");
|
|
1935
|
+
tunnel = null;
|
|
1936
|
+
}
|
|
1937
|
+
} else {
|
|
1938
|
+
console.log(` ${BOLD}Tunnel${RESET} ${DIM}disabled${RESET}`);
|
|
1939
|
+
console.log("");
|
|
1940
|
+
}
|
|
1941
|
+
let scheduler;
|
|
1942
|
+
const schedulerCfg = config.scheduler;
|
|
1943
|
+
if (schedulerCfg?.enabled) {
|
|
1944
|
+
const jobs = schedulerCfg.jobs ?? [];
|
|
1945
|
+
if (jobs.length > 0) {
|
|
1946
|
+
scheduler = new Scheduler({
|
|
1947
|
+
onTrigger: (job) => {
|
|
1948
|
+
const syntheticMsg = {
|
|
1949
|
+
id: generateId(16),
|
|
1950
|
+
channelId: `cron:${job.name}`,
|
|
1951
|
+
from: { channelId: `cron:${job.name}`, userId: job.userId ?? "cron" },
|
|
1952
|
+
text: job.message,
|
|
1953
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1954
|
+
};
|
|
1955
|
+
handleInboundMessage({
|
|
1956
|
+
msg: syntheticMsg,
|
|
1957
|
+
channel: logChannel,
|
|
1958
|
+
router: messageRouter,
|
|
1959
|
+
engine,
|
|
1960
|
+
config,
|
|
1961
|
+
observer,
|
|
1962
|
+
conversationContexts,
|
|
1963
|
+
agentRouter,
|
|
1964
|
+
defaultSystemPrompt,
|
|
1965
|
+
memoryBackend,
|
|
1966
|
+
skillRegistry,
|
|
1967
|
+
voiceProcessor,
|
|
1968
|
+
onInflightChange: trackInflight,
|
|
1969
|
+
workerPool,
|
|
1970
|
+
inFlightLoops,
|
|
1971
|
+
pendingMessages,
|
|
1972
|
+
sharedVerifier
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
for (const job of jobs) {
|
|
1977
|
+
try {
|
|
1978
|
+
scheduler.addJob(job);
|
|
1979
|
+
console.log(` ${GREEN}\u2713${RESET} cron: ${job.name} ${DIM}(${job.schedule})${RESET}`);
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1982
|
+
console.log(` ${RED}\u2717${RESET} cron: ${job.name}: ${errMsg}`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
scheduler.start();
|
|
1986
|
+
console.log(` ${BOLD}Scheduler${RESET} ${GREEN}running${RESET} (${scheduler.size} job${scheduler.size === 1 ? "" : "s"})`);
|
|
1987
|
+
console.log("");
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
console.log(` ${BOLD}Webhooks${RESET} ${GREEN}enabled${RESET} ${DIM}(POST /webhooks/:name)${RESET}`);
|
|
1991
|
+
console.log("");
|
|
1992
|
+
if (requirePairing && pairingManager) {
|
|
1993
|
+
const code = pairingManager.generateCode("CLI startup");
|
|
1994
|
+
console.log(` ${BOLD}Initial pairing code:${RESET} ${TEAL}${BOLD}${code.code}${RESET}`);
|
|
1995
|
+
console.log(` ${DIM}Expires in 5 minutes. Use POST /pair to exchange for a token.${RESET}`);
|
|
1996
|
+
console.log("");
|
|
1997
|
+
}
|
|
1998
|
+
console.log(` ${DIM}Press Ctrl+C to stop.${RESET}
|
|
1999
|
+
`);
|
|
2000
|
+
const RESUME_MAX_AGE_MS = 10 * 6e4;
|
|
2001
|
+
void (async () => {
|
|
2002
|
+
await new Promise((r) => setTimeout(r, 5e3));
|
|
2003
|
+
const staleNotes = sessionNotes.loadRecent(RESUME_MAX_AGE_MS);
|
|
2004
|
+
if (staleNotes.length === 0) return;
|
|
2005
|
+
let resumed = 0;
|
|
2006
|
+
for (const note of staleNotes) {
|
|
2007
|
+
const ch = channelRegistry.get(note.channelId);
|
|
2008
|
+
if (!ch) continue;
|
|
2009
|
+
const preamble = note.recentActivity.length > 0 ? `[Resuming after gateway restart. Recent progress: ${note.recentActivity.join(" ")}]
|
|
2010
|
+
` : "[Resuming after gateway restart.]\n";
|
|
2011
|
+
const syntheticMsg = {
|
|
2012
|
+
id: `resume:${note.contextKey}:${Date.now()}`,
|
|
2013
|
+
channelId: note.channelId,
|
|
2014
|
+
from: {
|
|
2015
|
+
channelId: note.channelId,
|
|
2016
|
+
userId: note.userId || void 0,
|
|
2017
|
+
groupId: note.groupId || void 0,
|
|
2018
|
+
threadId: note.threadId || void 0
|
|
2019
|
+
},
|
|
2020
|
+
text: preamble + note.request,
|
|
2021
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2022
|
+
};
|
|
2023
|
+
handleInboundMessage({
|
|
2024
|
+
msg: syntheticMsg,
|
|
2025
|
+
channel: ch,
|
|
2026
|
+
router: messageRouter,
|
|
2027
|
+
engine,
|
|
2028
|
+
config,
|
|
2029
|
+
observer,
|
|
2030
|
+
conversationContexts,
|
|
2031
|
+
agentRouter,
|
|
2032
|
+
defaultSystemPrompt,
|
|
2033
|
+
memoryBackend,
|
|
2034
|
+
skillRegistry,
|
|
2035
|
+
voiceProcessor,
|
|
2036
|
+
onInflightChange: trackInflight,
|
|
2037
|
+
workerPool,
|
|
2038
|
+
inFlightLoops,
|
|
2039
|
+
pendingMessages,
|
|
2040
|
+
sharedVerifier,
|
|
2041
|
+
sessionNotes
|
|
2042
|
+
});
|
|
2043
|
+
resumed++;
|
|
2044
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2045
|
+
}
|
|
2046
|
+
if (resumed > 0) {
|
|
2047
|
+
console.log(` ${GREEN}[recovery]${RESET} Resumed ${resumed} in-flight session(s) from notes.`);
|
|
2048
|
+
}
|
|
2049
|
+
})();
|
|
2050
|
+
const CONTEXT_IDLE_MS = 60 * 6e4;
|
|
2051
|
+
const evictionTimer = setInterval(() => {
|
|
2052
|
+
gatewayRateLimiter.evictStale();
|
|
2053
|
+
const heap = process.memoryUsage();
|
|
2054
|
+
const heapMB = Math.round(heap.heapUsed / 1024 / 1024);
|
|
2055
|
+
const rssMB = Math.round(heap.rss / 1024 / 1024);
|
|
2056
|
+
const contextIdleMs = heapMB > 500 ? 5 * 6e4 : CONTEXT_IDLE_MS;
|
|
2057
|
+
const now = Date.now();
|
|
2058
|
+
for (const [key, entry] of conversationContexts) {
|
|
2059
|
+
if (now - entry.lastActiveAt > contextIdleMs) {
|
|
2060
|
+
conversationContexts.delete(key);
|
|
2061
|
+
sessionNotes.delete(key);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
sessionManager.evictIdle(contextIdleMs);
|
|
2065
|
+
messageRouter.evictStale();
|
|
2066
|
+
server.evictIdleCanvas(contextIdleMs);
|
|
2067
|
+
if (heapMB > 500 && typeof globalThis.gc === "function") {
|
|
2068
|
+
globalThis.gc();
|
|
2069
|
+
}
|
|
2070
|
+
console.log(
|
|
2071
|
+
` ${DIM}[eviction] heap=${heapMB}MB rss=${rssMB}MB contexts=${conversationContexts.size} sessions=${sessionManager.size}${RESET}`
|
|
2072
|
+
);
|
|
2073
|
+
if (heapMB > 1500) {
|
|
2074
|
+
console.log(
|
|
2075
|
+
` ${YELLOW}[OOM warning]${RESET} Heap at ${heapMB}MB \u2014 restart the gateway or set NODE_OPTIONS=--max-old-space-size=512`
|
|
2076
|
+
);
|
|
2077
|
+
} else if (heapMB > 500) {
|
|
2078
|
+
console.log(` ${DIM}[mem pressure]${RESET} Heap at ${heapMB}MB \u2014 evicting with 5 min idle window${RESET}`);
|
|
2079
|
+
}
|
|
2080
|
+
}, 5 * 6e4);
|
|
2081
|
+
await new Promise((resolve) => {
|
|
2082
|
+
const shutdown = async () => {
|
|
2083
|
+
clearInterval(evictionTimer);
|
|
2084
|
+
console.log(`
|
|
2085
|
+
${DIM}Shutting down gateway...${RESET}`);
|
|
2086
|
+
if (scheduler) {
|
|
2087
|
+
scheduler.stop();
|
|
2088
|
+
}
|
|
2089
|
+
if (channelSupervisor.isRunning) {
|
|
2090
|
+
try {
|
|
2091
|
+
await channelSupervisor.stop();
|
|
2092
|
+
} catch {
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
if (inFlightCount > 0) {
|
|
2096
|
+
console.log(` ${DIM}Draining ${inFlightCount} in-flight message(s)...${RESET}`);
|
|
2097
|
+
await waitForDrain(3e4);
|
|
2098
|
+
}
|
|
2099
|
+
if (tunnel) {
|
|
2100
|
+
try {
|
|
2101
|
+
await tunnel.stop();
|
|
2102
|
+
} catch {
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
if (workerPool) {
|
|
2106
|
+
try {
|
|
2107
|
+
await workerPool.shutdown();
|
|
2108
|
+
} catch {
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (memoryBackend) {
|
|
2112
|
+
try {
|
|
2113
|
+
await memoryBackend.close();
|
|
2114
|
+
} catch {
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
await server.stop();
|
|
2118
|
+
await observer.flush?.();
|
|
2119
|
+
console.log(` ${DIM}Goodbye!${RESET}
|
|
2120
|
+
`);
|
|
2121
|
+
resolve();
|
|
2122
|
+
};
|
|
2123
|
+
const onSignal = () => void shutdown();
|
|
2124
|
+
process.once("SIGINT", onSignal);
|
|
2125
|
+
process.once("SIGTERM", onSignal);
|
|
2126
|
+
process.on("unhandledRejection", (reason) => {
|
|
2127
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
2128
|
+
console.error(` ${RED}\u2717 Unhandled rejection:${RESET} ${msg}`);
|
|
2129
|
+
observer.onError(new Error(`Unhandled rejection: ${msg}`), { source: "gateway" });
|
|
2130
|
+
});
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
var RateLimiter = class {
|
|
2134
|
+
constructor(maxRequests, windowMs) {
|
|
2135
|
+
this.maxRequests = maxRequests;
|
|
2136
|
+
this.windowMs = windowMs;
|
|
2137
|
+
}
|
|
2138
|
+
windows = /* @__PURE__ */ new Map();
|
|
2139
|
+
/** Returns true if the request is allowed; false if rate-limited. */
|
|
2140
|
+
allow(key) {
|
|
2141
|
+
const now = Date.now();
|
|
2142
|
+
const cutoff = now - this.windowMs;
|
|
2143
|
+
const timestamps = (this.windows.get(key) ?? []).filter((t) => t > cutoff);
|
|
2144
|
+
if (timestamps.length >= this.maxRequests) {
|
|
2145
|
+
return false;
|
|
2146
|
+
}
|
|
2147
|
+
timestamps.push(now);
|
|
2148
|
+
this.windows.set(key, timestamps);
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
/** Remove keys whose timestamps have all expired. */
|
|
2152
|
+
evictStale() {
|
|
2153
|
+
const now = Date.now();
|
|
2154
|
+
const cutoff = now - this.windowMs;
|
|
2155
|
+
for (const [key, timestamps] of this.windows) {
|
|
2156
|
+
if (timestamps.every((t) => t <= cutoff)) {
|
|
2157
|
+
this.windows.delete(key);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
var gatewayRateLimiter = new RateLimiter(20, 6e4);
|
|
2163
|
+
function handleInboundMessage(opts) {
|
|
2164
|
+
const {
|
|
2165
|
+
msg,
|
|
2166
|
+
channel,
|
|
2167
|
+
router,
|
|
2168
|
+
engine,
|
|
2169
|
+
config,
|
|
2170
|
+
observer,
|
|
2171
|
+
conversationContexts,
|
|
2172
|
+
agentRouter,
|
|
2173
|
+
defaultSystemPrompt,
|
|
2174
|
+
memoryBackend,
|
|
2175
|
+
skillRegistry,
|
|
2176
|
+
voiceProcessor,
|
|
2177
|
+
onInflightChange,
|
|
2178
|
+
workerPool,
|
|
2179
|
+
inFlightLoops,
|
|
2180
|
+
pendingMessages,
|
|
2181
|
+
sharedVerifier,
|
|
2182
|
+
sessionNotes
|
|
2183
|
+
} = opts;
|
|
2184
|
+
if (!engine) {
|
|
2185
|
+
channel.send(msg.from, {
|
|
2186
|
+
text: "ch4p is not configured with an LLM engine. Please set up an API key via `ch4p onboard`.",
|
|
2187
|
+
replyTo: msg.id
|
|
2188
|
+
}).catch(() => {
|
|
2189
|
+
});
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const hasAudio = msg.attachments?.some((a) => a.type === "audio") ?? false;
|
|
2193
|
+
if (!msg.text && !hasAudio) return;
|
|
2194
|
+
const { userId } = msg.from;
|
|
2195
|
+
const rateLimitKey = `${msg.channelId ?? "unknown"}:${userId ?? "anonymous"}`;
|
|
2196
|
+
if (!gatewayRateLimiter.allow(rateLimitKey)) {
|
|
2197
|
+
channel.send(msg.from, {
|
|
2198
|
+
text: "You are sending messages too quickly. Please wait a moment.",
|
|
2199
|
+
replyTo: msg.id
|
|
2200
|
+
}).catch(() => {
|
|
2201
|
+
});
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
const userKey = `${msg.channelId ?? "unknown"}:${msg.from.userId ?? "anonymous"}`;
|
|
2205
|
+
if (inFlightLoops) {
|
|
2206
|
+
const inflight = inFlightLoops.get(userKey);
|
|
2207
|
+
if (inflight) {
|
|
2208
|
+
if (inflight.permissionPending) {
|
|
2209
|
+
inflight.loop.steerEngine(msg.text ?? "");
|
|
2210
|
+
inflight.permissionPending = false;
|
|
2211
|
+
} else if (pendingMessages) {
|
|
2212
|
+
const queue = pendingMessages.get(userKey) ?? [];
|
|
2213
|
+
if (queue.length < MAX_PENDING_PER_USER) {
|
|
2214
|
+
queue.push({ msg, channel });
|
|
2215
|
+
} else {
|
|
2216
|
+
queue[queue.length - 1] = { msg, channel };
|
|
2217
|
+
}
|
|
2218
|
+
pendingMessages.set(userKey, queue);
|
|
2219
|
+
if (queue.length === 1) {
|
|
2220
|
+
channel.send(msg.from, {
|
|
2221
|
+
text: "Got it \u2014 I'll get to your message once I finish what I'm working on.",
|
|
2222
|
+
replyTo: msg.id
|
|
2223
|
+
}).catch(() => {
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
} else {
|
|
2227
|
+
channel.send(msg.from, {
|
|
2228
|
+
text: "I'm still working on your previous message. Please wait for me to finish.",
|
|
2229
|
+
replyTo: msg.id
|
|
2230
|
+
}).catch(() => {
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
const routeResult = router.route(msg);
|
|
2237
|
+
if (!routeResult) return;
|
|
2238
|
+
onInflightChange?.(1);
|
|
2239
|
+
void (async () => {
|
|
2240
|
+
let runTimer;
|
|
2241
|
+
let contextKey = "";
|
|
2242
|
+
try {
|
|
2243
|
+
const processedMsg = voiceProcessor ? await voiceProcessor.processInbound(msg) : msg;
|
|
2244
|
+
if (!processedMsg.text) return;
|
|
2245
|
+
const routing = agentRouter.route(processedMsg, defaultSystemPrompt);
|
|
2246
|
+
const { userId: userId2, groupId, threadId } = msg.from;
|
|
2247
|
+
contextKey = groupId && threadId ? `${msg.channelId ?? ""}:group:${groupId}:thread:${threadId}` : groupId ? `${msg.channelId ?? ""}:group:${groupId}:user:${userId2 ?? "anonymous"}` : `${msg.channelId ?? ""}:${userId2 ?? "anonymous"}`;
|
|
2248
|
+
let contextEntry = conversationContexts.get(contextKey);
|
|
2249
|
+
if (!contextEntry) {
|
|
2250
|
+
if (conversationContexts.size >= MAX_CONTEXTS) {
|
|
2251
|
+
let oldestKey;
|
|
2252
|
+
let oldestTime = Infinity;
|
|
2253
|
+
for (const [k, v] of conversationContexts) {
|
|
2254
|
+
if (v.lastActiveAt < oldestTime) {
|
|
2255
|
+
oldestTime = v.lastActiveAt;
|
|
2256
|
+
oldestKey = k;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
if (oldestKey) conversationContexts.delete(oldestKey);
|
|
2260
|
+
}
|
|
2261
|
+
const ctx = new ContextManager({ maxTokens: GATEWAY_CONTEXT_MAX_TOKENS });
|
|
2262
|
+
const initPrompt = routing.systemPrompt ?? routeResult.config.systemPrompt ?? defaultSystemPrompt;
|
|
2263
|
+
ctx.setSystemPrompt(initPrompt);
|
|
2264
|
+
contextEntry = { ctx, lastActiveAt: Date.now() };
|
|
2265
|
+
conversationContexts.set(contextKey, contextEntry);
|
|
2266
|
+
} else {
|
|
2267
|
+
contextEntry.lastActiveAt = Date.now();
|
|
2268
|
+
}
|
|
2269
|
+
const sharedContext = contextEntry.ctx;
|
|
2270
|
+
sessionNotes?.upsert({
|
|
2271
|
+
contextKey,
|
|
2272
|
+
channelId: msg.channelId,
|
|
2273
|
+
userId: userId2 ?? "anonymous",
|
|
2274
|
+
groupId: msg.from.groupId,
|
|
2275
|
+
threadId: msg.from.threadId,
|
|
2276
|
+
request: processedMsg.text,
|
|
2277
|
+
requestAt: Date.now(),
|
|
2278
|
+
recentActivity: []
|
|
2279
|
+
});
|
|
2280
|
+
const routedSessionConfig = {
|
|
2281
|
+
...routeResult.config,
|
|
2282
|
+
model: routing.model ?? routeResult.config.model,
|
|
2283
|
+
systemPrompt: routing.systemPrompt ?? routeResult.config.systemPrompt
|
|
2284
|
+
};
|
|
2285
|
+
const session = new Session(routedSessionConfig, {
|
|
2286
|
+
sharedContext,
|
|
2287
|
+
maxErrors: config.agent.maxSessionErrors
|
|
2288
|
+
});
|
|
2289
|
+
const toolExclude = config.autonomy.level === "readonly" ? ["bash", "file_write", "file_edit", "delegate", "browser"] : ["delegate", "browser"];
|
|
2290
|
+
if (!config.mesh?.enabled) {
|
|
2291
|
+
toolExclude.push("mesh");
|
|
2292
|
+
}
|
|
2293
|
+
for (const t of routing.toolExclude) {
|
|
2294
|
+
if (!toolExclude.includes(t)) toolExclude.push(t);
|
|
2295
|
+
}
|
|
2296
|
+
const tools = ToolRegistry.createDefault({ exclude: toolExclude });
|
|
2297
|
+
if (skillRegistry && skillRegistry.size > 0) {
|
|
2298
|
+
tools.register(new LoadSkillTool(skillRegistry));
|
|
2299
|
+
}
|
|
2300
|
+
const x402PluginCfg = config.x402;
|
|
2301
|
+
if (x402PluginCfg?.enabled) {
|
|
2302
|
+
tools.register(new X402PayTool());
|
|
2303
|
+
}
|
|
2304
|
+
const securityPolicy = new DefaultSecurityPolicy({
|
|
2305
|
+
workspace: routeResult.config.cwd ?? process.cwd(),
|
|
2306
|
+
autonomyLevel: config.autonomy.level,
|
|
2307
|
+
allowedCommands: config.autonomy.allowedCommands,
|
|
2308
|
+
blockedPaths: config.security.blockedPaths
|
|
2309
|
+
});
|
|
2310
|
+
const autoSave = config.memory.autoSave !== false;
|
|
2311
|
+
const memNamespace = `u:${msg.channelId ?? "unknown"}:${userId2 ?? "anonymous"}`;
|
|
2312
|
+
const onBeforeFirstRun = memoryBackend && autoSave ? createAutoRecallHook(memoryBackend, { namespace: memNamespace }) : void 0;
|
|
2313
|
+
const onAfterComplete = memoryBackend && autoSave ? createAutoSummarizeHook(memoryBackend, { namespace: memNamespace }) : void 0;
|
|
2314
|
+
const toolContextExtensions = {};
|
|
2315
|
+
if (config.search?.enabled && config.search.apiKey) {
|
|
2316
|
+
toolContextExtensions.searchApiKey = config.search.apiKey;
|
|
2317
|
+
toolContextExtensions.searchConfig = {
|
|
2318
|
+
maxResults: config.search.maxResults,
|
|
2319
|
+
country: config.search.country,
|
|
2320
|
+
searchLang: config.search.searchLang
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
if (x402PluginCfg?.enabled && x402PluginCfg.client?.privateKey) {
|
|
2324
|
+
const cc = x402PluginCfg.client;
|
|
2325
|
+
toolContextExtensions.x402Signer = createEIP712Signer(cc.privateKey, {
|
|
2326
|
+
chainId: cc.chainId,
|
|
2327
|
+
tokenAddress: cc.tokenAddress,
|
|
2328
|
+
tokenName: cc.tokenName,
|
|
2329
|
+
tokenVersion: cc.tokenVersion
|
|
2330
|
+
});
|
|
2331
|
+
toolContextExtensions.agentWalletAddress = walletAddress(cc.privateKey);
|
|
2332
|
+
}
|
|
2333
|
+
toolContextExtensions.resolveEngine = (_engineId) => engine;
|
|
2334
|
+
const loop = new AgentLoop(session, engine, tools.list(), observer, {
|
|
2335
|
+
maxIterations: routing.maxIterations,
|
|
2336
|
+
// Per-agent routing override.
|
|
2337
|
+
maxRetries: 2,
|
|
2338
|
+
enableStateSnapshots: true,
|
|
2339
|
+
verifier: sharedVerifier,
|
|
2340
|
+
memoryBackend,
|
|
2341
|
+
securityPolicy,
|
|
2342
|
+
onBeforeFirstRun,
|
|
2343
|
+
onAfterComplete,
|
|
2344
|
+
toolContextExtensions: Object.keys(toolContextExtensions).length > 0 ? toolContextExtensions : void 0,
|
|
2345
|
+
workerPool,
|
|
2346
|
+
maxToolResults: config.agent.maxToolResults,
|
|
2347
|
+
maxToolOutputLen: config.agent.maxToolOutputLen,
|
|
2348
|
+
maxStateRecords: config.agent.maxStateRecords
|
|
2349
|
+
});
|
|
2350
|
+
if (inFlightLoops) {
|
|
2351
|
+
inFlightLoops.set(userKey, { loop, permissionPending: false });
|
|
2352
|
+
}
|
|
2353
|
+
const DEFAULT_RUN_TIMEOUT_MS = 3e5;
|
|
2354
|
+
const runTimeoutMs = config.agent.runTimeout ?? DEFAULT_RUN_TIMEOUT_MS;
|
|
2355
|
+
runTimer = setTimeout(() => {
|
|
2356
|
+
loop.abort("Gateway run timeout exceeded");
|
|
2357
|
+
}, runTimeoutMs);
|
|
2358
|
+
let responseText = "";
|
|
2359
|
+
const PERM_RE = /\[y\/n\]|\[Y\/N\]|do you want to|allow this|permission required/i;
|
|
2360
|
+
for await (const event of loop.run(processedMsg.text)) {
|
|
2361
|
+
if (event.type === "text") {
|
|
2362
|
+
responseText = event.partial;
|
|
2363
|
+
if (inFlightLoops && PERM_RE.test(event.partial)) {
|
|
2364
|
+
const entry = inFlightLoops.get(userKey);
|
|
2365
|
+
if (entry) entry.permissionPending = true;
|
|
2366
|
+
}
|
|
2367
|
+
} else if (event.type === "complete") {
|
|
2368
|
+
responseText = event.answer;
|
|
2369
|
+
if (event.answer) sessionNotes?.appendActivity(contextKey, event.answer);
|
|
2370
|
+
} else if (event.type === "error") {
|
|
2371
|
+
responseText = `Error: ${event.error.message}`;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
if (responseText) {
|
|
2375
|
+
const outbound = {
|
|
2376
|
+
text: responseText,
|
|
2377
|
+
replyTo: msg.id,
|
|
2378
|
+
format: "text"
|
|
2379
|
+
};
|
|
2380
|
+
const finalOutbound = voiceProcessor ? await voiceProcessor.processOutbound(outbound) : outbound;
|
|
2381
|
+
await channel.send(msg.from, finalOutbound);
|
|
2382
|
+
}
|
|
2383
|
+
} catch (err) {
|
|
2384
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2385
|
+
await channel.send(msg.from, {
|
|
2386
|
+
text: `Sorry, I encountered an error: ${errMsg}`,
|
|
2387
|
+
replyTo: msg.id
|
|
2388
|
+
}).catch(() => {
|
|
2389
|
+
});
|
|
2390
|
+
} finally {
|
|
2391
|
+
clearTimeout(runTimer);
|
|
2392
|
+
sessionNotes?.delete(contextKey);
|
|
2393
|
+
const queue = pendingMessages?.get(userKey);
|
|
2394
|
+
const next = queue?.shift();
|
|
2395
|
+
if (queue && queue.length === 0) pendingMessages.delete(userKey);
|
|
2396
|
+
if (next) {
|
|
2397
|
+
inFlightLoops?.delete(userKey);
|
|
2398
|
+
handleInboundMessage({ ...opts, msg: next.msg, channel: next.channel });
|
|
2399
|
+
} else {
|
|
2400
|
+
inFlightLoops?.delete(userKey);
|
|
2401
|
+
onInflightChange?.(-1);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
})();
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
export {
|
|
2408
|
+
buildSafeConfig,
|
|
2409
|
+
applySafeUpdates,
|
|
2410
|
+
gateway
|
|
2411
|
+
};
|