@inceptionstack/roundhouse 0.3.17 → 0.3.19
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/package.json +2 -1
- package/src/agents/pi.ts +59 -68
- package/src/agents/registry.ts +109 -16
- package/src/cli/cli.ts +42 -82
- package/src/cli/doctor/checks/disk.ts +2 -2
- package/src/cli/qr.ts +24 -0
- package/src/cli/setup-logger.ts +142 -0
- package/src/cli/setup-prompts.ts +78 -0
- package/src/cli/setup-telegram.ts +12 -5
- package/src/cli/setup.ts +446 -71
- package/src/config.ts +3 -2
- package/src/cron/runner.ts +9 -8
- package/src/gateway.ts +179 -39
- package/src/memory/state.ts +3 -15
- package/src/pairing.ts +112 -0
- package/src/util.ts +0 -8
package/src/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const LEGACY_CONFIG_DIR = resolve(homedir(), ".config", "roundhouse");
|
|
|
22
22
|
export const CONFIG_DIR = ROUNDHOUSE_DIR;
|
|
23
23
|
export const CONFIG_PATH = resolve(ROUNDHOUSE_DIR, "gateway.config.json");
|
|
24
24
|
export const ENV_FILE_PATH = resolve(ROUNDHOUSE_DIR, ".env");
|
|
25
|
+
export const SESSIONS_DIR = resolve(ROUNDHOUSE_DIR, "sessions");
|
|
25
26
|
|
|
26
27
|
/** Legacy env file name (deprecated) */
|
|
27
28
|
export const LEGACY_ENV_FILE_PATH = resolve(ROUNDHOUSE_DIR, "env");
|
|
@@ -181,11 +182,11 @@ export async function loadConfig(): Promise<GatewayConfig> {
|
|
|
181
182
|
console.error(`[roundhouse] failed to parse config at ${resolved.path}: ${err.message}`);
|
|
182
183
|
process.exit(1);
|
|
183
184
|
}
|
|
184
|
-
// Try cwd
|
|
185
|
+
// Try cwd (with security warning)
|
|
185
186
|
try {
|
|
186
187
|
const cwdPath = resolve(process.cwd(), "gateway.config.json");
|
|
187
188
|
const raw = await readFile(cwdPath, "utf8");
|
|
188
|
-
console.
|
|
189
|
+
console.warn(`[roundhouse] ⚠️ loaded gateway.config.json from cwd (${cwdPath}) — consider using ~/.roundhouse/gateway.config.json instead`);
|
|
189
190
|
config = JSON.parse(raw) as GatewayConfig;
|
|
190
191
|
} catch (cwdErr: any) {
|
|
191
192
|
if (cwdErr.code !== "ENOENT") {
|
package/src/cron/runner.ts
CHANGED
|
@@ -31,19 +31,20 @@ export class CronRunner {
|
|
|
31
31
|
const threadId = buildCronThreadId(job.id, runId);
|
|
32
32
|
const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
const tz = job.schedule.type === "cron" ? job.schedule.tz : job.schedule.type === "once" ? job.schedule.tz : undefined;
|
|
36
|
-
const ctx = buildTemplateContext(job.id, job.description, runId, scheduledAt, startedAt, tz, process.cwd(), job.vars ?? {});
|
|
37
|
-
const prompt = renderTemplate(job.prompt, ctx) + CRON_PROMPT_SUFFIX;
|
|
38
|
-
|
|
39
|
-
console.log(`[cron] starting ${job.id} [${runId}] kind=${kind}`);
|
|
40
|
-
|
|
41
|
-
// Create fresh agent — use provided config or load dynamically for CLI trigger
|
|
34
|
+
// Load agent config before rendering prompt (template needs agentCfg.cwd)
|
|
42
35
|
let agentCfg = this.agentConfig;
|
|
43
36
|
if (!agentCfg) {
|
|
44
37
|
const { loadConfig } = await import("../config");
|
|
45
38
|
agentCfg = (await loadConfig()).agent;
|
|
46
39
|
}
|
|
40
|
+
|
|
41
|
+
// Render prompt
|
|
42
|
+
const tz = job.schedule.type === "cron" ? job.schedule.tz : job.schedule.type === "once" ? job.schedule.tz : undefined;
|
|
43
|
+
const agentCwd = (agentCfg.cwd as string) ?? process.cwd();
|
|
44
|
+
const ctx = buildTemplateContext(job.id, job.description, runId, scheduledAt, startedAt, tz, agentCwd, job.vars ?? {});
|
|
45
|
+
const prompt = renderTemplate(job.prompt, ctx) + CRON_PROMPT_SUFFIX;
|
|
46
|
+
|
|
47
|
+
console.log(`[cron] starting ${job.id} [${runId}] kind=${kind}`);
|
|
47
48
|
const { type, ...rest } = agentCfg;
|
|
48
49
|
const factory = getAgentFactory(type);
|
|
49
50
|
const agent = factory({ ...rest, sessionDir: undefined });
|
package/src/gateway.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { BOT_COMMANDS } from "./commands";
|
|
|
20
20
|
import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
|
|
21
21
|
import { maxPressure } from "./memory/policy";
|
|
22
22
|
import type { PressureLevel } from "./memory/types";
|
|
23
|
+
import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
|
|
23
24
|
|
|
24
25
|
/** Match a Telegram command, handling optional @botname suffix */
|
|
25
26
|
/** Bot username for command suffix validation (set during gateway init) */
|
|
@@ -67,6 +68,33 @@ const ROUNDHOUSE_VERSION: string = (() => {
|
|
|
67
68
|
catch { return "unknown"; }
|
|
68
69
|
})();
|
|
69
70
|
|
|
71
|
+
function telegramChatIdFromThreadId(threadId: unknown): number | null {
|
|
72
|
+
if (typeof threadId !== "string") return null;
|
|
73
|
+
const match = threadId.match(/^telegram:(-?\d+)/);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const parsed = parseInt(match[1], 10);
|
|
76
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getChatId(thread: any, message: any): string {
|
|
80
|
+
const id = message?.chat?.id ?? message?.chatId ?? thread?.chatId;
|
|
81
|
+
if (id !== undefined && id !== null) return String(id);
|
|
82
|
+
return String(thread?.id ?? "unknown");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveAgentThreadId(thread: any, message: any): string {
|
|
86
|
+
const chatType = String(message?.chat?.type ?? thread?.chat?.type ?? thread?.type ?? "").toLowerCase();
|
|
87
|
+
if (["private", "dm", "direct", "im"].includes(chatType)) return "main";
|
|
88
|
+
if (["group", "supergroup", "channel"].includes(chatType)) return `group:${getChatId(thread, message)}`;
|
|
89
|
+
|
|
90
|
+
const telegramChatId = telegramChatIdFromThreadId(thread?.id);
|
|
91
|
+
if (telegramChatId !== null) {
|
|
92
|
+
return telegramChatId < 0 ? `group:${telegramChatId}` : "main";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return String(thread?.id ?? "main");
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
// ── Chat SDK adapter factories ───────────────────────
|
|
71
99
|
// Lazy-imported so we don't crash if an adapter package isn't installed.
|
|
72
100
|
|
|
@@ -155,7 +183,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
|
|
|
155
183
|
// Per-message directory: <thread>/<timestamp_nonce>/
|
|
156
184
|
const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
|
|
157
185
|
try {
|
|
158
|
-
mkdirSync(msgDir, { recursive: true });
|
|
186
|
+
mkdirSync(msgDir, { recursive: true, mode: 0o700 });
|
|
159
187
|
} catch (err) {
|
|
160
188
|
console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
|
|
161
189
|
return { saved: [], skipped: ["Failed to create storage directory"] };
|
|
@@ -214,7 +242,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
|
|
|
214
242
|
const fileName = `${i}-${rawName}`;
|
|
215
243
|
const filePath = join(msgDir, fileName);
|
|
216
244
|
|
|
217
|
-
await writeFile(filePath, buf);
|
|
245
|
+
await writeFile(filePath, buf, { mode: 0o600 });
|
|
218
246
|
|
|
219
247
|
const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
|
|
220
248
|
const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
|
|
@@ -242,6 +270,7 @@ export class Gateway {
|
|
|
242
270
|
private chat!: Chat;
|
|
243
271
|
private router: AgentRouter;
|
|
244
272
|
private config: GatewayConfig;
|
|
273
|
+
private pairingComplete = false;
|
|
245
274
|
private sttService: SttService | null = null;
|
|
246
275
|
private cronScheduler: CronSchedulerService | null = null;
|
|
247
276
|
|
|
@@ -251,6 +280,86 @@ export class Gateway {
|
|
|
251
280
|
_botUsername = config.chat.botUsername || "";
|
|
252
281
|
}
|
|
253
282
|
|
|
283
|
+
/** Handle pending Telegram pairing from headless setup. Returns true if handled. */
|
|
284
|
+
private async handlePendingPairing(
|
|
285
|
+
text: string,
|
|
286
|
+
message: any,
|
|
287
|
+
thread: any,
|
|
288
|
+
authorName: string,
|
|
289
|
+
): Promise<boolean> {
|
|
290
|
+
try {
|
|
291
|
+
const pending = await readPendingPairing();
|
|
292
|
+
if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const fromUser = authorName.toLowerCase();
|
|
297
|
+
const allowed = pending.allowedUsers.map(u => u.toLowerCase());
|
|
298
|
+
if (!fromUser || !allowed.includes(fromUser)) {
|
|
299
|
+
console.log(`[roundhouse] Pairing nonce from unauthorized user @${authorName}`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Extract IDs from the chat adapter message
|
|
304
|
+
const chatId = typeof message.chatId === "number"
|
|
305
|
+
? message.chatId
|
|
306
|
+
: typeof thread.id === "string" && thread.id.startsWith("telegram:")
|
|
307
|
+
? parseInt(thread.id.split(":")[1], 10)
|
|
308
|
+
: undefined;
|
|
309
|
+
// Chat SDK Telegram adapter provides userId (not id)
|
|
310
|
+
const rawUserId = message.author?.userId ?? message.author?.id ?? message.raw?.from?.id;
|
|
311
|
+
const userId = typeof rawUserId === "number"
|
|
312
|
+
? rawUserId
|
|
313
|
+
: typeof rawUserId === "string"
|
|
314
|
+
? parseInt(rawUserId, 10)
|
|
315
|
+
: undefined;
|
|
316
|
+
|
|
317
|
+
if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
|
|
318
|
+
console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
|
|
319
|
+
await thread.post("⚠️ Pairing nonce accepted but could not capture your Telegram IDs. Try sending /start again, or run: roundhouse pair");
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await completePendingPairing({ chatId, userId, username: authorName });
|
|
324
|
+
|
|
325
|
+
// Update in-memory config
|
|
326
|
+
if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
|
|
327
|
+
if (!this.config.chat.allowedUserIds.includes(userId)) {
|
|
328
|
+
this.config.chat.allowedUserIds.push(userId);
|
|
329
|
+
}
|
|
330
|
+
if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
|
|
331
|
+
if (!this.config.chat.notifyChatIds.includes(chatId)) {
|
|
332
|
+
this.config.chat.notifyChatIds.push(chatId);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Atomic config file update
|
|
336
|
+
try {
|
|
337
|
+
const { readFile: rf, rename: mvf, writeFile: wf, unlink: ulf } = await import("node:fs/promises");
|
|
338
|
+
const { randomBytes: rb } = await import("node:crypto");
|
|
339
|
+
const cfgPath = join(ROUNDHOUSE_DIR, "gateway.config.json");
|
|
340
|
+
const configRaw = JSON.parse(await rf(cfgPath, "utf8"));
|
|
341
|
+
if (!configRaw.chat) configRaw.chat = {};
|
|
342
|
+
if (!configRaw.chat.allowedUserIds) configRaw.chat.allowedUserIds = [];
|
|
343
|
+
if (!configRaw.chat.allowedUserIds.includes(userId)) configRaw.chat.allowedUserIds.push(userId);
|
|
344
|
+
if (!configRaw.chat.notifyChatIds) configRaw.chat.notifyChatIds = [];
|
|
345
|
+
if (!configRaw.chat.notifyChatIds.includes(chatId)) configRaw.chat.notifyChatIds.push(chatId);
|
|
346
|
+
const tmp = `${cfgPath}.tmp.${rb(4).toString("hex")}`;
|
|
347
|
+
await wf(tmp, JSON.stringify(configRaw, null, 2) + "\n");
|
|
348
|
+
await mvf(tmp, cfgPath).catch(async (e) => { try { await ulf(tmp); } catch {} throw e; });
|
|
349
|
+
} catch (cfgErr) {
|
|
350
|
+
console.error("[roundhouse] failed to update config after pairing:", cfgErr);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log(`[roundhouse] Telegram pairing complete: @${authorName} chatId=${chatId} userId=${userId}`);
|
|
354
|
+
this.pairingComplete = true;
|
|
355
|
+
await thread.post("✅ Roundhouse paired successfully!\n\nSend /status to verify everything is working.");
|
|
356
|
+
return true;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.error("[roundhouse] error checking pending pairing:", err);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
254
363
|
async start() {
|
|
255
364
|
const chatAdapters = await buildChatAdapters(this.config.chat.adapters);
|
|
256
365
|
|
|
@@ -289,7 +398,17 @@ export class Gateway {
|
|
|
289
398
|
const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) =>
|
|
290
399
|
u.toLowerCase()
|
|
291
400
|
);
|
|
292
|
-
|
|
401
|
+
// Ensure arrays exist on config so pairing hook mutations are visible to isAllowed
|
|
402
|
+
if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
|
|
403
|
+
if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
|
|
404
|
+
const allowedUserIds = this.config.chat.allowedUserIds;
|
|
405
|
+
|
|
406
|
+
// SECURITY: Warn (loudly) when no auth allowlist is configured
|
|
407
|
+
if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
|
|
408
|
+
console.warn("\n⚠️ WARNING: No allowedUsers or allowedUserIds configured!");
|
|
409
|
+
console.warn(" Any Telegram user who finds this bot can interact with the agent.");
|
|
410
|
+
console.warn(" Run: roundhouse setup --telegram --user <your-username>\n");
|
|
411
|
+
}
|
|
293
412
|
|
|
294
413
|
// Per-thread verbose toggle (shows tool_start messages)
|
|
295
414
|
const verboseThreads = new Set<string>();
|
|
@@ -302,14 +421,21 @@ export class Gateway {
|
|
|
302
421
|
|
|
303
422
|
// ── Unified handler ────────────────────────────
|
|
304
423
|
const handle = async (thread: any, message: any) => {
|
|
424
|
+
const agentThreadId = resolveAgentThreadId(thread, message);
|
|
305
425
|
const userText = message.text ?? "";
|
|
306
426
|
const authorName = message.author?.userName ?? message.author?.userId ?? "?";
|
|
307
427
|
const rawAttachments = message.attachments ?? [];
|
|
308
428
|
|
|
309
429
|
console.log(
|
|
310
|
-
`[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
|
|
430
|
+
`[roundhouse] ${thread.id} -> ${agentThreadId} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
|
|
311
431
|
);
|
|
312
432
|
|
|
433
|
+
// Check for pending Telegram pairing (headless setup)
|
|
434
|
+
if (userText.trim().startsWith("/start ") && !this.pairingComplete) {
|
|
435
|
+
const handled = await this.handlePendingPairing(userText.trim(), message, thread, authorName ?? "");
|
|
436
|
+
if (handled) return;
|
|
437
|
+
}
|
|
438
|
+
|
|
313
439
|
if (!isAllowed(message, allowedUsers, allowedUserIds)) {
|
|
314
440
|
console.log(`[roundhouse] blocked @${authorName} (not in allowlist)`);
|
|
315
441
|
return;
|
|
@@ -320,14 +446,14 @@ export class Gateway {
|
|
|
320
446
|
|
|
321
447
|
// Handle /new command — dispose current session, start fresh
|
|
322
448
|
if (isCommand(userText.trim(), "/new")) {
|
|
323
|
-
const agent = this.router.resolve(
|
|
449
|
+
const agent = this.router.resolve(agentThreadId);
|
|
324
450
|
if (agent.restart) {
|
|
325
|
-
await agent.restart(
|
|
451
|
+
await agent.restart(agentThreadId);
|
|
326
452
|
await thread.post("🔄 Session restarted. Send a message to begin a new conversation.");
|
|
327
453
|
} else {
|
|
328
454
|
await thread.post("⚠️ New session not supported for this agent.");
|
|
329
455
|
}
|
|
330
|
-
console.log(`[roundhouse] /new for thread=${thread.id}`);
|
|
456
|
+
console.log(`[roundhouse] /new for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
331
457
|
return;
|
|
332
458
|
}
|
|
333
459
|
|
|
@@ -350,13 +476,22 @@ export class Gateway {
|
|
|
350
476
|
}
|
|
351
477
|
|
|
352
478
|
// Handle /compact command — flush memory then compact session context
|
|
479
|
+
// Routed through the per-thread lock to prevent concurrent agent access
|
|
353
480
|
if (isCommand(userText.trim(), "/compact")) {
|
|
354
|
-
const agent = this.router.resolve(
|
|
481
|
+
const agent = this.router.resolve(agentThreadId);
|
|
355
482
|
if (!agent.compact) {
|
|
356
483
|
await thread.post("⚠️ Compaction not supported for this agent.");
|
|
357
484
|
return;
|
|
358
485
|
}
|
|
359
|
-
console.log(`[roundhouse] /compact for thread=${thread.id}`);
|
|
486
|
+
console.log(`[roundhouse] /compact for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
487
|
+
|
|
488
|
+
// Acquire per-thread lock (same as normal prompts)
|
|
489
|
+
const prevLock = threadLocks.get(agentThreadId);
|
|
490
|
+
let releaseLock: () => void;
|
|
491
|
+
const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
|
|
492
|
+
threadLocks.set(agentThreadId, lockPromise);
|
|
493
|
+
if (prevLock) await prevLock;
|
|
494
|
+
|
|
360
495
|
await thread.post("📝 Saving memory and compacting...");
|
|
361
496
|
const stopTyping = startTypingLoop(thread);
|
|
362
497
|
try {
|
|
@@ -364,7 +499,7 @@ export class Gateway {
|
|
|
364
499
|
const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
|
|
365
500
|
// If memory is disabled, compact directly without flush
|
|
366
501
|
if (this.config.memory?.enabled === false) {
|
|
367
|
-
const result = await agent.compact(
|
|
502
|
+
const result = await agent.compact(agentThreadId);
|
|
368
503
|
if (!result) {
|
|
369
504
|
await thread.post("⚠️ No active session to compact. Send a message first.");
|
|
370
505
|
} else {
|
|
@@ -372,7 +507,7 @@ export class Gateway {
|
|
|
372
507
|
await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
|
|
373
508
|
}
|
|
374
509
|
} else {
|
|
375
|
-
const result = await flushMemoryThenCompact(
|
|
510
|
+
const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "manual", this.config.memory);
|
|
376
511
|
if (!result) {
|
|
377
512
|
await thread.post("⚠️ No active session to compact. Send a message first.");
|
|
378
513
|
} else {
|
|
@@ -385,13 +520,17 @@ export class Gateway {
|
|
|
385
520
|
await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
|
|
386
521
|
} finally {
|
|
387
522
|
stopTyping();
|
|
523
|
+
releaseLock!();
|
|
524
|
+
if (threadLocks.get(agentThreadId) === lockPromise) {
|
|
525
|
+
threadLocks.delete(agentThreadId);
|
|
526
|
+
}
|
|
388
527
|
}
|
|
389
528
|
return;
|
|
390
529
|
}
|
|
391
530
|
|
|
392
531
|
// Handle /status command — show gateway details
|
|
393
532
|
if (isCommand(userText.trim(), "/status")) {
|
|
394
|
-
const agent = this.router.resolve(
|
|
533
|
+
const agent = this.router.resolve(agentThreadId);
|
|
395
534
|
const uptimeSec = process.uptime();
|
|
396
535
|
const uptimeStr = uptimeSec < 3600
|
|
397
536
|
? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
|
|
@@ -401,7 +540,7 @@ export class Gateway {
|
|
|
401
540
|
const nodeVer = process.version;
|
|
402
541
|
const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
|
|
403
542
|
|
|
404
|
-
const info = agent.getInfo ? agent.getInfo(
|
|
543
|
+
const info = agent.getInfo ? agent.getInfo(agentThreadId) : {};
|
|
405
544
|
const agentVersion = info.version ? `v${info.version}` : "";
|
|
406
545
|
const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
|
|
407
546
|
|
|
@@ -422,7 +561,7 @@ export class Gateway {
|
|
|
422
561
|
`💾 Memory: ${memMB} MB`,
|
|
423
562
|
`🟢 Node: ${nodeVer}`,
|
|
424
563
|
`🔧 Debug stream: ${debugStream ? "on" : "off"}`,
|
|
425
|
-
`📢 Verbose: ${verboseThreads.has(
|
|
564
|
+
`📢 Verbose: ${verboseThreads.has(agentThreadId) ? "on" : "off"}`,
|
|
426
565
|
);
|
|
427
566
|
|
|
428
567
|
const allowedCount = allowedUsers.length;
|
|
@@ -464,14 +603,14 @@ export class Gateway {
|
|
|
464
603
|
}
|
|
465
604
|
|
|
466
605
|
await thread.post({ markdown: lines.join("\n") });
|
|
467
|
-
console.log(`[roundhouse] /status for thread=${thread.id}`);
|
|
606
|
+
console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
468
607
|
return;
|
|
469
608
|
}
|
|
470
609
|
|
|
471
610
|
// Save any attachments (voice messages, images, files, etc.)
|
|
472
611
|
let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
|
|
473
612
|
try {
|
|
474
|
-
attachmentResult = await saveAttachments(
|
|
613
|
+
attachmentResult = await saveAttachments(agentThreadId, rawAttachments);
|
|
475
614
|
} catch (err) {
|
|
476
615
|
console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
|
|
477
616
|
if (!userText.trim()) {
|
|
@@ -501,16 +640,16 @@ export class Gateway {
|
|
|
501
640
|
return;
|
|
502
641
|
}
|
|
503
642
|
|
|
504
|
-
const agent = this.router.resolve(
|
|
643
|
+
const agent = this.router.resolve(agentThreadId);
|
|
505
644
|
|
|
506
645
|
// Serialize prompts per-thread (concurrent mode allows /stop to bypass)
|
|
507
|
-
const prevLock = threadLocks.get(
|
|
646
|
+
const prevLock = threadLocks.get(agentThreadId);
|
|
508
647
|
let releaseLock: () => void;
|
|
509
648
|
const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
|
|
510
|
-
threadLocks.set(
|
|
649
|
+
threadLocks.set(agentThreadId, lockPromise);
|
|
511
650
|
if (prevLock) await prevLock;
|
|
512
651
|
|
|
513
|
-
console.log(`[roundhouse] → ${agent.name} | thread=${
|
|
652
|
+
console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
|
|
514
653
|
|
|
515
654
|
// Enrich audio attachments with transcripts (STT) — inside thread lock to prevent stampede
|
|
516
655
|
if (this.sttService && agentMessage.attachments?.length) {
|
|
@@ -537,7 +676,7 @@ export class Gateway {
|
|
|
537
676
|
const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
|
|
538
677
|
let memoryPrepared: Awaited<ReturnType<typeof prepareMemoryForTurn>> | undefined;
|
|
539
678
|
try {
|
|
540
|
-
memoryPrepared = await prepareMemoryForTurn(
|
|
679
|
+
memoryPrepared = await prepareMemoryForTurn(agentThreadId, agentMessage, agent, memoryRoot, this.config.memory);
|
|
541
680
|
agentMessage = memoryPrepared.message;
|
|
542
681
|
} catch (err) {
|
|
543
682
|
console.error(`[roundhouse] memory prepare error:`, (err as Error).message);
|
|
@@ -548,15 +687,15 @@ export class Gateway {
|
|
|
548
687
|
try {
|
|
549
688
|
if (agent.promptStream) {
|
|
550
689
|
const ac = new AbortController();
|
|
551
|
-
abortControllers.set(
|
|
690
|
+
abortControllers.set(agentThreadId, ac);
|
|
552
691
|
try {
|
|
553
|
-
await this.handleStreaming(thread, agent.promptStream(
|
|
692
|
+
await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
|
|
554
693
|
} finally {
|
|
555
|
-
abortControllers.delete(
|
|
694
|
+
abortControllers.delete(agentThreadId);
|
|
556
695
|
}
|
|
557
696
|
} else {
|
|
558
697
|
// Fallback: non-streaming prompt
|
|
559
|
-
const reply = await agent.prompt(
|
|
698
|
+
const reply = await agent.prompt(agentThreadId, agentMessage);
|
|
560
699
|
if (reply.text) {
|
|
561
700
|
await this.postWithFallback(thread, reply.text);
|
|
562
701
|
}
|
|
@@ -565,7 +704,7 @@ export class Gateway {
|
|
|
565
704
|
// ── Memory: post-turn finalize + pressure check ───
|
|
566
705
|
try {
|
|
567
706
|
const pressure = await finalizeMemoryForTurn(
|
|
568
|
-
|
|
707
|
+
agentThreadId,
|
|
569
708
|
memoryPrepared?.beforeDigest ?? null,
|
|
570
709
|
agent, memoryRoot, this.config.memory,
|
|
571
710
|
);
|
|
@@ -574,7 +713,7 @@ export class Gateway {
|
|
|
574
713
|
if (effectivePressure !== "none") {
|
|
575
714
|
// Run flush/compact INSIDE the thread lock to prevent race with next user message
|
|
576
715
|
try {
|
|
577
|
-
await this.handleContextPressure(thread, agent, memoryRoot, effectivePressure);
|
|
716
|
+
await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
|
|
578
717
|
} catch (err) {
|
|
579
718
|
console.error(`[roundhouse] context pressure handler error:`, (err as Error).message);
|
|
580
719
|
}
|
|
@@ -592,34 +731,35 @@ export class Gateway {
|
|
|
592
731
|
} finally {
|
|
593
732
|
stopTyping();
|
|
594
733
|
releaseLock!();
|
|
595
|
-
if (threadLocks.get(
|
|
596
|
-
threadLocks.delete(
|
|
734
|
+
if (threadLocks.get(agentThreadId) === lockPromise) {
|
|
735
|
+
threadLocks.delete(agentThreadId);
|
|
597
736
|
}
|
|
598
737
|
}
|
|
599
738
|
};
|
|
600
739
|
|
|
601
740
|
// ── Wire Chat SDK events ───────────────────────
|
|
602
741
|
const handleOrAbort = async (thread: any, message: any) => {
|
|
742
|
+
const agentThreadId = resolveAgentThreadId(thread, message);
|
|
603
743
|
const text = (message.text ?? "").trim();
|
|
604
744
|
// /stop is handled immediately — abort the in-flight agent run
|
|
605
745
|
// without waiting for the current handler to finish
|
|
606
746
|
if (isCommand(text, "/stop")) {
|
|
607
747
|
if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
|
|
608
|
-
const agent = this.router.resolve(
|
|
748
|
+
const agent = this.router.resolve(agentThreadId);
|
|
609
749
|
if (agent.abort) {
|
|
610
|
-
await agent.abort(
|
|
611
|
-
abortControllers.get(
|
|
750
|
+
await agent.abort(agentThreadId);
|
|
751
|
+
abortControllers.get(agentThreadId)?.abort();
|
|
612
752
|
try { await thread.post("⏹️ Stopped."); } catch {}
|
|
613
753
|
} else {
|
|
614
754
|
try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
|
|
615
755
|
}
|
|
616
|
-
console.log(`[roundhouse] /stop for thread=${thread.id}`);
|
|
756
|
+
console.log(`[roundhouse] /stop for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
617
757
|
return;
|
|
618
758
|
}
|
|
619
759
|
// /verbose is a gateway toggle — runs immediately, no queuing
|
|
620
760
|
if (isCommand(text, "/verbose")) {
|
|
621
761
|
if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
|
|
622
|
-
const threadId =
|
|
762
|
+
const threadId = agentThreadId;
|
|
623
763
|
if (verboseThreads.has(threadId)) {
|
|
624
764
|
verboseThreads.delete(threadId);
|
|
625
765
|
try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
|
|
@@ -627,7 +767,7 @@ export class Gateway {
|
|
|
627
767
|
verboseThreads.add(threadId);
|
|
628
768
|
try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
|
|
629
769
|
}
|
|
630
|
-
console.log(`[roundhouse] /verbose for thread=${threadId} -> ${verboseThreads.has(threadId) ? "on" : "off"}`);
|
|
770
|
+
console.log(`[roundhouse] /verbose for thread=${thread.id} agentThread=${threadId} -> ${verboseThreads.has(threadId) ? "on" : "off"}`);
|
|
631
771
|
return;
|
|
632
772
|
}
|
|
633
773
|
// /doctor runs health checks immediately — no agent access needed
|
|
@@ -747,16 +887,16 @@ export class Gateway {
|
|
|
747
887
|
* Handle context pressure — flush memory and/or compact.
|
|
748
888
|
* Runs inside the thread lock after a turn completes.
|
|
749
889
|
*/
|
|
750
|
-
private async handleContextPressure(thread: any, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
|
|
890
|
+
private async handleContextPressure(thread: any, agentThreadId: string, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
|
|
751
891
|
if (pressure === "none") return;
|
|
752
892
|
|
|
753
|
-
console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id}`);
|
|
893
|
+
console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
754
894
|
|
|
755
895
|
if (pressure === "soft") {
|
|
756
896
|
// Soft: prompt agent to save facts, no compact
|
|
757
897
|
// Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
|
|
758
898
|
try {
|
|
759
|
-
await flushMemoryThenCompact(
|
|
899
|
+
await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
|
|
760
900
|
} catch (err) {
|
|
761
901
|
console.error(`[roundhouse] soft flush error:`, (err as Error).message);
|
|
762
902
|
}
|
|
@@ -766,7 +906,7 @@ export class Gateway {
|
|
|
766
906
|
// Hard or emergency: flush + compact
|
|
767
907
|
try {
|
|
768
908
|
await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`);
|
|
769
|
-
const result = await flushMemoryThenCompact(
|
|
909
|
+
const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, pressure, this.config.memory);
|
|
770
910
|
if (result) {
|
|
771
911
|
const beforeK = (result.tokensBefore / 1000).toFixed(1);
|
|
772
912
|
await thread.post(`✅ Auto-compacted: ${beforeK}K tokens → summary.`);
|
package/src/memory/state.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
|
9
9
|
import { resolve, dirname } from "node:path";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { ROUNDHOUSE_DIR } from "../config";
|
|
12
|
-
import { threadIdToDir
|
|
12
|
+
import { threadIdToDir } from "../util";
|
|
13
13
|
import type { ThreadMemoryState } from "./types";
|
|
14
14
|
|
|
15
15
|
const STATE_DIR = resolve(ROUNDHOUSE_DIR, "memory-state");
|
|
@@ -18,24 +18,12 @@ function stateFilePath(threadId: string): string {
|
|
|
18
18
|
return resolve(STATE_DIR, `${threadIdToDir(threadId)}.json`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function legacyStateFilePath(threadId: string): string {
|
|
22
|
-
return resolve(STATE_DIR, `${threadIdToDirLegacy(threadId)}.json`);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
21
|
/** Load per-thread memory state (returns empty state if none exists) */
|
|
26
22
|
export async function loadThreadMemoryState(threadId: string): Promise<ThreadMemoryState> {
|
|
27
23
|
try {
|
|
28
24
|
const raw = await readFile(stateFilePath(threadId), "utf8");
|
|
29
25
|
return JSON.parse(raw) as ThreadMemoryState;
|
|
30
26
|
} catch {
|
|
31
|
-
// Fallback to legacy encoding for pre-v0.4 state files
|
|
32
|
-
try {
|
|
33
|
-
const legacyPath = legacyStateFilePath(threadId);
|
|
34
|
-
if (legacyPath !== stateFilePath(threadId)) {
|
|
35
|
-
const raw = await readFile(legacyPath, "utf8");
|
|
36
|
-
return JSON.parse(raw) as ThreadMemoryState;
|
|
37
|
-
}
|
|
38
|
-
} catch {}
|
|
39
27
|
return {};
|
|
40
28
|
}
|
|
41
29
|
}
|
|
@@ -43,10 +31,10 @@ export async function loadThreadMemoryState(threadId: string): Promise<ThreadMem
|
|
|
43
31
|
/** Save per-thread memory state (atomic write to prevent corruption) */
|
|
44
32
|
export async function saveThreadMemoryState(threadId: string, state: ThreadMemoryState): Promise<void> {
|
|
45
33
|
const path = stateFilePath(threadId);
|
|
46
|
-
await mkdir(dirname(path), { recursive: true });
|
|
34
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
47
35
|
const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
|
|
48
36
|
try {
|
|
49
|
-
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
37
|
+
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
50
38
|
await rename(tmp, path);
|
|
51
39
|
} catch (err) {
|
|
52
40
|
try { await unlink(tmp); } catch {}
|
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pairing.ts — Persistent pending-pairing state for Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - setup --telegram --headless: writes pending pairing before starting gateway
|
|
6
|
+
* - gateway.ts: detects pending pairing and completes on /start <nonce>
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { ROUNDHOUSE_DIR } from "./config";
|
|
12
|
+
|
|
13
|
+
export interface PendingPairing {
|
|
14
|
+
version: 1;
|
|
15
|
+
nonce: string;
|
|
16
|
+
botUsername: string;
|
|
17
|
+
allowedUsers: string[];
|
|
18
|
+
createdAt: string;
|
|
19
|
+
status: "pending" | "paired";
|
|
20
|
+
pairedAt?: string;
|
|
21
|
+
chatId?: number;
|
|
22
|
+
userId?: number;
|
|
23
|
+
username?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PAIRING_PATH = resolve(ROUNDHOUSE_DIR, "telegram-pairing.json");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate a pairing nonce: "rh-" + 8 random hex bytes.
|
|
30
|
+
*/
|
|
31
|
+
export function createPairingNonce(): string {
|
|
32
|
+
return `rh-${randomBytes(8).toString("hex")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the Telegram deep link for pairing.
|
|
37
|
+
*/
|
|
38
|
+
export function createPairingLink(botUsername: string, nonce: string): string {
|
|
39
|
+
return `https://t.me/${botUsername}?start=${nonce}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a message text matches /start <nonce>.
|
|
44
|
+
*/
|
|
45
|
+
export function isStartForNonce(text: string, nonce: string): boolean {
|
|
46
|
+
const trimmed = text.trim();
|
|
47
|
+
return trimmed === `/start ${nonce}` || trimmed === nonce;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the pending pairing file. Returns null if not found or invalid.
|
|
52
|
+
*/
|
|
53
|
+
export async function readPendingPairing(): Promise<PendingPairing | null> {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await readFile(PAIRING_PATH, "utf8");
|
|
56
|
+
const data = JSON.parse(raw);
|
|
57
|
+
if (data?.version === 1 && data?.nonce && data?.status) {
|
|
58
|
+
return data as PendingPairing;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write pending pairing state (atomic, mode 0600).
|
|
68
|
+
*/
|
|
69
|
+
export async function writePendingPairing(state: PendingPairing): Promise<void> {
|
|
70
|
+
await mkdir(dirname(PAIRING_PATH), { recursive: true });
|
|
71
|
+
const tmp = `${PAIRING_PATH}.tmp.${randomBytes(4).toString("hex")}`;
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
74
|
+
await rename(tmp, PAIRING_PATH);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
try { await unlink(tmp); } catch {}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Mark pairing as complete — merges result into existing pending state.
|
|
83
|
+
*/
|
|
84
|
+
export async function completePendingPairing(result: {
|
|
85
|
+
chatId: number;
|
|
86
|
+
userId: number;
|
|
87
|
+
username: string;
|
|
88
|
+
}): Promise<PendingPairing | null> {
|
|
89
|
+
const existing = await readPendingPairing();
|
|
90
|
+
if (!existing || existing.status !== "pending") return null;
|
|
91
|
+
|
|
92
|
+
const completed: PendingPairing = {
|
|
93
|
+
...existing,
|
|
94
|
+
status: "paired",
|
|
95
|
+
pairedAt: new Date().toISOString(),
|
|
96
|
+
chatId: result.chatId,
|
|
97
|
+
userId: result.userId,
|
|
98
|
+
username: result.username,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
await writePendingPairing(completed);
|
|
102
|
+
return completed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Clear the pairing file.
|
|
107
|
+
*/
|
|
108
|
+
export async function clearPendingPairing(): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
await unlink(PAIRING_PATH);
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|