@inceptionstack/roundhouse 0.3.18 → 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 +1 -1
- package/src/agents/pi.ts +59 -68
- package/src/cli/cli.ts +42 -82
- package/src/cli/doctor/checks/disk.ts +2 -2
- package/src/config.ts +3 -2
- package/src/cron/runner.ts +9 -8
- package/src/gateway.ts +94 -41
- package/src/memory/state.ts +3 -15
- package/src/util.ts +0 -8
package/package.json
CHANGED
package/src/agents/pi.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Wraps pi's SDK (createAgentSession) as an AgentAdapter.
|
|
5
5
|
* One persistent session per thread, stored at:
|
|
6
|
-
* ~/.
|
|
6
|
+
* ~/.roundhouse/sessions/<thread_id>/<session>.jsonl
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { mkdir
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { join, dirname } from "node:path";
|
|
12
12
|
import { homedir } from "node:os";
|
|
@@ -24,19 +24,16 @@ import {
|
|
|
24
24
|
} from "@mariozechner/pi-coding-agent";
|
|
25
25
|
|
|
26
26
|
import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../types";
|
|
27
|
-
import {
|
|
27
|
+
import { SESSIONS_DIR } from "../config";
|
|
28
|
+
import { DEBUG_STREAM, threadIdToDir } from "../util";
|
|
28
29
|
|
|
29
30
|
interface SessionEntry {
|
|
30
31
|
session: AgentSession;
|
|
31
32
|
lastUsed: number;
|
|
33
|
+
inFlight: number;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
const DEFAULT_SESSIONS_DIR =
|
|
35
|
-
homedir(),
|
|
36
|
-
".pi",
|
|
37
|
-
"agent",
|
|
38
|
-
"gateway-sessions"
|
|
39
|
-
);
|
|
36
|
+
const DEFAULT_SESSIONS_DIR = SESSIONS_DIR;
|
|
40
37
|
const DEFAULT_MAX_IDLE_MS = 30 * 60 * 1000;
|
|
41
38
|
|
|
42
39
|
export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
@@ -107,72 +104,65 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
107
104
|
}
|
|
108
105
|
|
|
109
106
|
async function runPromptAndFollowUps(entry: SessionEntry, text: string, onDraining?: () => void, onDrainComplete?: () => void): Promise<void> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// where agent_end extension handlers run and queue follow-up messages
|
|
115
|
-
// (e.g. pi-lgtm calls sendMessage with deliverAs: "followUp").
|
|
116
|
-
// The actual long wait is in the while loop's waitForIdle() below.
|
|
117
|
-
let notifiedDraining = false;
|
|
118
|
-
if (onDraining && (entry.session.isStreaming || entry.session.agent.hasQueuedMessages())) {
|
|
119
|
-
onDraining();
|
|
120
|
-
notifiedDraining = true;
|
|
121
|
-
}
|
|
107
|
+
entry.inFlight++;
|
|
108
|
+
try {
|
|
109
|
+
await entry.session.prompt(text);
|
|
110
|
+
await drainSessionEvents(entry.session);
|
|
122
111
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// (b) `isStreaming === true` — an extension called the same sendMessage
|
|
132
|
-
// *after isStreaming became false*. In that path pi's
|
|
133
|
-
// `sendCustomMessage` skips the queue entirely and calls
|
|
134
|
-
// `agent.prompt(appMessage)` directly as fire-and-forget, kicking
|
|
135
|
-
// off a brand-new agent run. `hasQueuedMessages()` returns false
|
|
136
|
-
// for this run, but `isStreaming` is true — we have to
|
|
137
|
-
// `waitForIdle()` so subscribers see the new run's events (e.g. the
|
|
138
|
-
// agent's reply to a pi-lgtm code review) before we unsubscribe.
|
|
139
|
-
//
|
|
140
|
-
// Without (b), pi CLI works (its subscriber stays attached across runs)
|
|
141
|
-
// but roundhouse delivers the review bubble then goes silent.
|
|
142
|
-
while (true) {
|
|
143
|
-
if (entry.session.isStreaming) {
|
|
144
|
-
await entry.session.agent.waitForIdle();
|
|
145
|
-
await drainSessionEvents(entry.session);
|
|
146
|
-
continue;
|
|
112
|
+
// Check for pending follow-up work AFTER drainSessionEvents — that's
|
|
113
|
+
// where agent_end extension handlers run and queue follow-up messages
|
|
114
|
+
// (e.g. pi-lgtm calls sendMessage with deliverAs: "followUp").
|
|
115
|
+
// The actual long wait is in the while loop's waitForIdle() below.
|
|
116
|
+
let notifiedDraining = false;
|
|
117
|
+
if (onDraining && (entry.session.isStreaming || entry.session.agent.hasQueuedMessages())) {
|
|
118
|
+
onDraining();
|
|
119
|
+
notifiedDraining = true;
|
|
147
120
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
121
|
+
|
|
122
|
+
// Loop until the session is fully idle. Two separate conditions can keep
|
|
123
|
+
// work in flight after the initial prompt resolves:
|
|
124
|
+
//
|
|
125
|
+
// (a) `hasQueuedMessages()` — an extension called `pi.sendMessage(...,
|
|
126
|
+
// { triggerTurn: true, deliverAs: "followUp" })` *while isStreaming
|
|
127
|
+
// was true* — pi queued onto `agent.followUp()`, so we manually
|
|
128
|
+
// drain it with `continue()`.
|
|
129
|
+
//
|
|
130
|
+
// (b) `isStreaming === true` — an extension called the same sendMessage
|
|
131
|
+
// *after isStreaming became false*. In that path pi's
|
|
132
|
+
// `sendCustomMessage` skips the queue entirely and calls
|
|
133
|
+
// `agent.prompt(appMessage)` directly as fire-and-forget, kicking
|
|
134
|
+
// off a brand-new agent run. `hasQueuedMessages()` returns false
|
|
135
|
+
// for this run, but `isStreaming` is true — we have to
|
|
136
|
+
// `waitForIdle()` so subscribers see the new run's events (e.g. the
|
|
137
|
+
// agent's reply to a pi-lgtm code review) before we unsubscribe.
|
|
138
|
+
//
|
|
139
|
+
// Without (b), pi CLI works (its subscriber stays attached across runs)
|
|
140
|
+
// but roundhouse delivers the review bubble then goes silent.
|
|
141
|
+
while (true) {
|
|
142
|
+
if (entry.session.isStreaming) {
|
|
143
|
+
await entry.session.agent.waitForIdle();
|
|
144
|
+
await drainSessionEvents(entry.session);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (entry.session.agent.hasQueuedMessages()) {
|
|
148
|
+
await entry.session.agent.continue();
|
|
149
|
+
await drainSessionEvents(entry.session);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
152
153
|
}
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
154
|
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
if (notifiedDraining && onDrainComplete) {
|
|
156
|
+
onDrainComplete();
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
entry.inFlight--;
|
|
160
|
+
entry.lastUsed = Date.now();
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
async function createSession(threadId: string): Promise<SessionEntry> {
|
|
162
|
-
|
|
163
|
-
const newPath = join(sessionsDir, dirName);
|
|
164
|
-
const legacyPath = join(sessionsDir, threadIdToDirLegacy(threadId));
|
|
165
|
-
// Migrate: if legacy dir exists but new doesn't, use legacy
|
|
166
|
-
if (dirName !== threadIdToDirLegacy(threadId)) {
|
|
167
|
-
try {
|
|
168
|
-
await stat(legacyPath);
|
|
169
|
-
// Legacy exists — check if new exists
|
|
170
|
-
try { await stat(newPath); } catch {
|
|
171
|
-
// New doesn't exist, use legacy to preserve session history
|
|
172
|
-
dirName = threadIdToDirLegacy(threadId);
|
|
173
|
-
}
|
|
174
|
-
} catch { /* legacy doesn't exist either, use new */ }
|
|
175
|
-
}
|
|
165
|
+
const dirName = threadIdToDir(threadId);
|
|
176
166
|
const threadDir = join(sessionsDir, dirName);
|
|
177
167
|
await mkdir(threadDir, { recursive: true });
|
|
178
168
|
|
|
@@ -214,7 +204,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
214
204
|
console.log(`[pi-agent] model fallback: ${result.modelFallbackMessage}`);
|
|
215
205
|
}
|
|
216
206
|
|
|
217
|
-
const entry: SessionEntry = { session: result.session, lastUsed: Date.now() };
|
|
207
|
+
const entry: SessionEntry = { session: result.session, lastUsed: Date.now(), inFlight: 0 };
|
|
218
208
|
sessions.set(threadId, entry);
|
|
219
209
|
|
|
220
210
|
// Detect memory capabilities from loaded extensions (first session only)
|
|
@@ -260,6 +250,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
260
250
|
function reap() {
|
|
261
251
|
const now = Date.now();
|
|
262
252
|
for (const [id, entry] of sessions) {
|
|
253
|
+
if (entry.inFlight > 0) continue; // skip busy sessions
|
|
263
254
|
if (now - entry.lastUsed > maxIdleMs) {
|
|
264
255
|
entry.session.dispose();
|
|
265
256
|
sessions.delete(id);
|
package/src/cli/cli.ts
CHANGED
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { resolve, dirname } from "node:path";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
8
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
10
9
|
import { readdirSync, statSync } from "node:fs";
|
|
11
|
-
import { execSync, spawn } from "node:child_process";
|
|
10
|
+
import { execSync, execFileSync, spawn } from "node:child_process";
|
|
12
11
|
import { fileURLToPath } from "node:url";
|
|
13
12
|
|
|
14
13
|
import {
|
|
@@ -16,12 +15,14 @@ import {
|
|
|
16
15
|
CONFIG_PATH,
|
|
17
16
|
ENV_FILE_PATH,
|
|
18
17
|
DEFAULT_CONFIG,
|
|
18
|
+
SESSIONS_DIR,
|
|
19
19
|
SERVICE_NAME,
|
|
20
20
|
fileExists,
|
|
21
21
|
loadConfig,
|
|
22
22
|
resolveEnvFilePath,
|
|
23
23
|
} from "../config";
|
|
24
24
|
import { getAgentSdkPackage } from "../agents/registry";
|
|
25
|
+
import { threadIdToDir } from "../util";
|
|
25
26
|
import { parseEnvFile, serializeEnvFile, envQuote } from "./env-file";
|
|
26
27
|
import {
|
|
27
28
|
SERVICE_PATH,
|
|
@@ -40,6 +41,11 @@ const __dirname = dirname(__filename);
|
|
|
40
41
|
|
|
41
42
|
// ── Shell helpers ───────────────────────────────────
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Shell helper — WARNING: passes `cmd` through the system shell.
|
|
46
|
+
* Only call with trusted/hardcoded strings. Any dynamic segments must be
|
|
47
|
+
* validated (e.g. `/^\d+$/.test(pid)`) before interpolation.
|
|
48
|
+
*/
|
|
43
49
|
function run(cmd: string, opts?: { silent?: boolean }): string {
|
|
44
50
|
try {
|
|
45
51
|
return execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" }).trim();
|
|
@@ -79,10 +85,10 @@ async function cmdRun() {
|
|
|
79
85
|
await import(jsPath);
|
|
80
86
|
} else {
|
|
81
87
|
const tsxPath = resolve(__dirname, "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
88
|
+
execFileSync(process.execPath, [tsxPath, indexPath], {
|
|
89
|
+
stdio: "inherit",
|
|
90
|
+
env: { ...process.env, ROUNDHOUSE_CONFIG: CONFIG_PATH },
|
|
91
|
+
});
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -279,87 +285,30 @@ async function cmdTui() {
|
|
|
279
285
|
process.exit(1);
|
|
280
286
|
}
|
|
281
287
|
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
288
|
+
const threadArg = process.argv[3];
|
|
289
|
+
const threadId = threadArg || "main";
|
|
290
|
+
const threadDir = threadIdToDir(threadId);
|
|
291
|
+
const threadPath = resolve(SESSIONS_DIR, threadDir);
|
|
292
|
+
let candidates: Array<{ sessionFile: string; mtime: number }> = [];
|
|
286
293
|
try {
|
|
287
|
-
|
|
288
|
-
.filter((
|
|
289
|
-
.
|
|
294
|
+
candidates = readdirSync(threadPath)
|
|
295
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
296
|
+
.map((f) => {
|
|
297
|
+
const sessionFile = resolve(threadPath, f);
|
|
298
|
+
return { sessionFile, mtime: statSync(sessionFile).mtimeMs };
|
|
299
|
+
})
|
|
300
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
290
301
|
} catch {
|
|
291
|
-
console.error(`No
|
|
302
|
+
console.error(`No session directory found at ${threadPath}.`);
|
|
292
303
|
process.exit(1);
|
|
293
304
|
}
|
|
294
305
|
|
|
295
|
-
if (threadDirs.length === 0) {
|
|
296
|
-
console.error("No gateway sessions found. Send a message via Telegram/Slack first.");
|
|
297
|
-
process.exit(1);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const threadArg = process.argv[3];
|
|
301
|
-
|
|
302
|
-
interface SessionCandidate { threadDir: string; sessionFile: string; mtime: number; }
|
|
303
|
-
|
|
304
|
-
const candidates: SessionCandidate[] = [];
|
|
305
|
-
for (const dir of threadDirs) {
|
|
306
|
-
if (threadArg && !dir.includes(threadArg)) continue;
|
|
307
|
-
const threadPath = resolve(sessionsBase, dir);
|
|
308
|
-
try {
|
|
309
|
-
for (const f of readdirSync(threadPath).filter((f) => f.endsWith(".jsonl"))) {
|
|
310
|
-
const fullPath = resolve(threadPath, f);
|
|
311
|
-
candidates.push({ threadDir: dir, sessionFile: fullPath, mtime: statSync(fullPath).mtimeMs });
|
|
312
|
-
}
|
|
313
|
-
} catch {}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
306
|
if (candidates.length === 0) {
|
|
317
|
-
|
|
318
|
-
console.error(`No sessions found matching "${threadArg}".`);
|
|
319
|
-
console.log("Available threads:");
|
|
320
|
-
for (const d of threadDirs) console.log(` ${d}`);
|
|
321
|
-
} else {
|
|
322
|
-
console.error("No session files found.");
|
|
323
|
-
}
|
|
307
|
+
console.error(`No session files found at ${threadPath}.`);
|
|
324
308
|
process.exit(1);
|
|
325
309
|
}
|
|
326
310
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
let selected: SessionCandidate;
|
|
330
|
-
const uniqueThreads = [...new Set(candidates.map((c) => c.threadDir))];
|
|
331
|
-
|
|
332
|
-
if (uniqueThreads.length === 1 || threadArg) {
|
|
333
|
-
selected = candidates[0];
|
|
334
|
-
} else {
|
|
335
|
-
console.log("Available sessions (most recent first):\n");
|
|
336
|
-
const shown: SessionCandidate[] = [];
|
|
337
|
-
const seen = new Set<string>();
|
|
338
|
-
for (const c of candidates) {
|
|
339
|
-
if (seen.has(c.threadDir)) continue;
|
|
340
|
-
seen.add(c.threadDir);
|
|
341
|
-
shown.push(c);
|
|
342
|
-
}
|
|
343
|
-
for (let i = 0; i < shown.length; i++) {
|
|
344
|
-
const age = Math.round((Date.now() - shown[i].mtime) / 60000);
|
|
345
|
-
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
|
|
346
|
-
console.log(` [${i + 1}] ${shown[i].threadDir} (${ageStr})`);
|
|
347
|
-
}
|
|
348
|
-
console.log();
|
|
349
|
-
|
|
350
|
-
const readline = await import("node:readline");
|
|
351
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
352
|
-
const answer = await new Promise<string>((r) => {
|
|
353
|
-
rl.question("Pick a session [1]: ", (ans) => { rl.close(); r(ans.trim() || "1"); });
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
const idx = parseInt(answer, 10) - 1;
|
|
357
|
-
if (isNaN(idx) || idx < 0 || idx >= shown.length) {
|
|
358
|
-
console.error("Invalid selection.");
|
|
359
|
-
process.exit(1);
|
|
360
|
-
}
|
|
361
|
-
selected = candidates.find((c) => c.threadDir === shown[idx].threadDir)!;
|
|
362
|
-
}
|
|
311
|
+
const selected = candidates[0];
|
|
363
312
|
|
|
364
313
|
console.log(`\nOpening: ${selected.sessionFile}\n`);
|
|
365
314
|
|
|
@@ -394,7 +343,7 @@ Commands:
|
|
|
394
343
|
config Show config path and contents
|
|
395
344
|
agent <message> Send a message to the agent and print response
|
|
396
345
|
Options: --thread <id>, --stdin, --timeout <sec>,
|
|
397
|
-
--no-timeout, --verbose
|
|
346
|
+
--no-timeout, --verbose, --ephemeral
|
|
398
347
|
doctor [--fix] Check system health and configuration
|
|
399
348
|
Options: --fix, --json, --verbose
|
|
400
349
|
cron <command> Manage scheduled jobs (add, list, trigger, etc.)
|
|
@@ -414,6 +363,7 @@ Environment:
|
|
|
414
363
|
async function cmdAgent() {
|
|
415
364
|
// Usage: roundhouse agent <message>
|
|
416
365
|
// roundhouse agent --thread <id> <message>
|
|
366
|
+
// roundhouse agent --ephemeral <message>
|
|
417
367
|
// echo "message" | roundhouse agent --stdin
|
|
418
368
|
const args = process.argv.slice(3);
|
|
419
369
|
let threadId = "";
|
|
@@ -421,6 +371,7 @@ async function cmdAgent() {
|
|
|
421
371
|
let useStdin = false;
|
|
422
372
|
let timeoutMs = 120_000;
|
|
423
373
|
let verbose = false;
|
|
374
|
+
let ephemeral = false;
|
|
424
375
|
|
|
425
376
|
for (let i = 0; i < args.length; i++) {
|
|
426
377
|
if (args[i] === "--thread" && args[i + 1]) {
|
|
@@ -435,6 +386,8 @@ async function cmdAgent() {
|
|
|
435
386
|
timeoutMs = 0;
|
|
436
387
|
} else if (args[i] === "--verbose") {
|
|
437
388
|
verbose = true;
|
|
389
|
+
} else if (args[i] === "--ephemeral") {
|
|
390
|
+
ephemeral = true;
|
|
438
391
|
} else if (args[i].startsWith("-")) {
|
|
439
392
|
console.error(`Unknown flag: ${args[i]}`);
|
|
440
393
|
process.exit(1);
|
|
@@ -468,13 +421,20 @@ async function cmdAgent() {
|
|
|
468
421
|
console.error(" echo \"message\" | roundhouse agent --stdin");
|
|
469
422
|
console.error(" roundhouse agent --timeout 60 <message>");
|
|
470
423
|
console.error(" roundhouse agent --verbose <message>");
|
|
424
|
+
console.error(" roundhouse agent --ephemeral <message>");
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (threadId && ephemeral) {
|
|
429
|
+
console.error("--thread and --ephemeral cannot be used together");
|
|
471
430
|
process.exit(1);
|
|
472
431
|
}
|
|
473
432
|
|
|
474
|
-
// Default:
|
|
475
|
-
// --thread <id> opts into persistent/shared sessions
|
|
433
|
+
// Default: shared main session. --ephemeral restores one-off CLI behavior.
|
|
476
434
|
if (!threadId) {
|
|
477
|
-
threadId =
|
|
435
|
+
threadId = ephemeral
|
|
436
|
+
? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
437
|
+
: "main";
|
|
478
438
|
}
|
|
479
439
|
|
|
480
440
|
// Suppress debug/info logs unless --verbose
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* Disk and directory checks
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { access,
|
|
5
|
+
import { access, readdir, constants } from "node:fs/promises";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { mkdirSync } from "node:fs";
|
|
9
9
|
import type { DoctorCheck } from "../types";
|
|
10
10
|
import { run } from "../shell";
|
|
11
|
+
import { SESSIONS_DIR } from "../../../config";
|
|
11
12
|
|
|
12
13
|
const INCOMING_DIR = process.env.ROUNDHOUSE_INCOMING_DIR ?? join(homedir(), ".roundhouse", "incoming");
|
|
13
|
-
const SESSIONS_DIR = join(homedir(), ".pi", "agent", "gateway-sessions");
|
|
14
14
|
|
|
15
15
|
export const diskChecks: DoctorCheck[] = [
|
|
16
16
|
{
|
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
|
@@ -68,6 +68,33 @@ const ROUNDHOUSE_VERSION: string = (() => {
|
|
|
68
68
|
catch { return "unknown"; }
|
|
69
69
|
})();
|
|
70
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
|
+
|
|
71
98
|
// ── Chat SDK adapter factories ───────────────────────
|
|
72
99
|
// Lazy-imported so we don't crash if an adapter package isn't installed.
|
|
73
100
|
|
|
@@ -156,7 +183,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
|
|
|
156
183
|
// Per-message directory: <thread>/<timestamp_nonce>/
|
|
157
184
|
const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
|
|
158
185
|
try {
|
|
159
|
-
mkdirSync(msgDir, { recursive: true });
|
|
186
|
+
mkdirSync(msgDir, { recursive: true, mode: 0o700 });
|
|
160
187
|
} catch (err) {
|
|
161
188
|
console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
|
|
162
189
|
return { saved: [], skipped: ["Failed to create storage directory"] };
|
|
@@ -215,7 +242,7 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
|
|
|
215
242
|
const fileName = `${i}-${rawName}`;
|
|
216
243
|
const filePath = join(msgDir, fileName);
|
|
217
244
|
|
|
218
|
-
await writeFile(filePath, buf);
|
|
245
|
+
await writeFile(filePath, buf, { mode: 0o600 });
|
|
219
246
|
|
|
220
247
|
const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
|
|
221
248
|
const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
|
|
@@ -279,9 +306,13 @@ export class Gateway {
|
|
|
279
306
|
: typeof thread.id === "string" && thread.id.startsWith("telegram:")
|
|
280
307
|
? parseInt(thread.id.split(":")[1], 10)
|
|
281
308
|
: undefined;
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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;
|
|
285
316
|
|
|
286
317
|
if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
|
|
287
318
|
console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
|
|
@@ -372,6 +403,13 @@ export class Gateway {
|
|
|
372
403
|
if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
|
|
373
404
|
const allowedUserIds = this.config.chat.allowedUserIds;
|
|
374
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
|
+
}
|
|
412
|
+
|
|
375
413
|
// Per-thread verbose toggle (shows tool_start messages)
|
|
376
414
|
const verboseThreads = new Set<string>();
|
|
377
415
|
|
|
@@ -383,12 +421,13 @@ export class Gateway {
|
|
|
383
421
|
|
|
384
422
|
// ── Unified handler ────────────────────────────
|
|
385
423
|
const handle = async (thread: any, message: any) => {
|
|
424
|
+
const agentThreadId = resolveAgentThreadId(thread, message);
|
|
386
425
|
const userText = message.text ?? "";
|
|
387
426
|
const authorName = message.author?.userName ?? message.author?.userId ?? "?";
|
|
388
427
|
const rawAttachments = message.attachments ?? [];
|
|
389
428
|
|
|
390
429
|
console.log(
|
|
391
|
-
`[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)` : ""}`
|
|
392
431
|
);
|
|
393
432
|
|
|
394
433
|
// Check for pending Telegram pairing (headless setup)
|
|
@@ -407,14 +446,14 @@ export class Gateway {
|
|
|
407
446
|
|
|
408
447
|
// Handle /new command — dispose current session, start fresh
|
|
409
448
|
if (isCommand(userText.trim(), "/new")) {
|
|
410
|
-
const agent = this.router.resolve(
|
|
449
|
+
const agent = this.router.resolve(agentThreadId);
|
|
411
450
|
if (agent.restart) {
|
|
412
|
-
await agent.restart(
|
|
451
|
+
await agent.restart(agentThreadId);
|
|
413
452
|
await thread.post("🔄 Session restarted. Send a message to begin a new conversation.");
|
|
414
453
|
} else {
|
|
415
454
|
await thread.post("⚠️ New session not supported for this agent.");
|
|
416
455
|
}
|
|
417
|
-
console.log(`[roundhouse] /new for thread=${thread.id}`);
|
|
456
|
+
console.log(`[roundhouse] /new for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
418
457
|
return;
|
|
419
458
|
}
|
|
420
459
|
|
|
@@ -437,13 +476,22 @@ export class Gateway {
|
|
|
437
476
|
}
|
|
438
477
|
|
|
439
478
|
// Handle /compact command — flush memory then compact session context
|
|
479
|
+
// Routed through the per-thread lock to prevent concurrent agent access
|
|
440
480
|
if (isCommand(userText.trim(), "/compact")) {
|
|
441
|
-
const agent = this.router.resolve(
|
|
481
|
+
const agent = this.router.resolve(agentThreadId);
|
|
442
482
|
if (!agent.compact) {
|
|
443
483
|
await thread.post("⚠️ Compaction not supported for this agent.");
|
|
444
484
|
return;
|
|
445
485
|
}
|
|
446
|
-
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
|
+
|
|
447
495
|
await thread.post("📝 Saving memory and compacting...");
|
|
448
496
|
const stopTyping = startTypingLoop(thread);
|
|
449
497
|
try {
|
|
@@ -451,7 +499,7 @@ export class Gateway {
|
|
|
451
499
|
const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
|
|
452
500
|
// If memory is disabled, compact directly without flush
|
|
453
501
|
if (this.config.memory?.enabled === false) {
|
|
454
|
-
const result = await agent.compact(
|
|
502
|
+
const result = await agent.compact(agentThreadId);
|
|
455
503
|
if (!result) {
|
|
456
504
|
await thread.post("⚠️ No active session to compact. Send a message first.");
|
|
457
505
|
} else {
|
|
@@ -459,7 +507,7 @@ export class Gateway {
|
|
|
459
507
|
await thread.post(`✅ Compaction complete\n\nCompacted ${beforeK}K tokens down to a summary.\nContext usage will update after your next message.`);
|
|
460
508
|
}
|
|
461
509
|
} else {
|
|
462
|
-
const result = await flushMemoryThenCompact(
|
|
510
|
+
const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "manual", this.config.memory);
|
|
463
511
|
if (!result) {
|
|
464
512
|
await thread.post("⚠️ No active session to compact. Send a message first.");
|
|
465
513
|
} else {
|
|
@@ -472,13 +520,17 @@ export class Gateway {
|
|
|
472
520
|
await thread.post(`⚠️ Compaction failed: ${msg.slice(0, 200)}`);
|
|
473
521
|
} finally {
|
|
474
522
|
stopTyping();
|
|
523
|
+
releaseLock!();
|
|
524
|
+
if (threadLocks.get(agentThreadId) === lockPromise) {
|
|
525
|
+
threadLocks.delete(agentThreadId);
|
|
526
|
+
}
|
|
475
527
|
}
|
|
476
528
|
return;
|
|
477
529
|
}
|
|
478
530
|
|
|
479
531
|
// Handle /status command — show gateway details
|
|
480
532
|
if (isCommand(userText.trim(), "/status")) {
|
|
481
|
-
const agent = this.router.resolve(
|
|
533
|
+
const agent = this.router.resolve(agentThreadId);
|
|
482
534
|
const uptimeSec = process.uptime();
|
|
483
535
|
const uptimeStr = uptimeSec < 3600
|
|
484
536
|
? `${Math.floor(uptimeSec / 60)}m ${Math.floor(uptimeSec % 60)}s`
|
|
@@ -488,7 +540,7 @@ export class Gateway {
|
|
|
488
540
|
const nodeVer = process.version;
|
|
489
541
|
const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
|
|
490
542
|
|
|
491
|
-
const info = agent.getInfo ? agent.getInfo(
|
|
543
|
+
const info = agent.getInfo ? agent.getInfo(agentThreadId) : {};
|
|
492
544
|
const agentVersion = info.version ? `v${info.version}` : "";
|
|
493
545
|
const agentLabel = agentVersion ? `\`${agent.name}\` (${agentVersion})` : `\`${agent.name}\``;
|
|
494
546
|
|
|
@@ -509,7 +561,7 @@ export class Gateway {
|
|
|
509
561
|
`💾 Memory: ${memMB} MB`,
|
|
510
562
|
`🟢 Node: ${nodeVer}`,
|
|
511
563
|
`🔧 Debug stream: ${debugStream ? "on" : "off"}`,
|
|
512
|
-
`📢 Verbose: ${verboseThreads.has(
|
|
564
|
+
`📢 Verbose: ${verboseThreads.has(agentThreadId) ? "on" : "off"}`,
|
|
513
565
|
);
|
|
514
566
|
|
|
515
567
|
const allowedCount = allowedUsers.length;
|
|
@@ -551,14 +603,14 @@ export class Gateway {
|
|
|
551
603
|
}
|
|
552
604
|
|
|
553
605
|
await thread.post({ markdown: lines.join("\n") });
|
|
554
|
-
console.log(`[roundhouse] /status for thread=${thread.id}`);
|
|
606
|
+
console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
555
607
|
return;
|
|
556
608
|
}
|
|
557
609
|
|
|
558
610
|
// Save any attachments (voice messages, images, files, etc.)
|
|
559
611
|
let attachmentResult: AttachmentResult = { saved: [], skipped: [] };
|
|
560
612
|
try {
|
|
561
|
-
attachmentResult = await saveAttachments(
|
|
613
|
+
attachmentResult = await saveAttachments(agentThreadId, rawAttachments);
|
|
562
614
|
} catch (err) {
|
|
563
615
|
console.error(`[roundhouse] saveAttachments error:`, (err as Error).message);
|
|
564
616
|
if (!userText.trim()) {
|
|
@@ -588,16 +640,16 @@ export class Gateway {
|
|
|
588
640
|
return;
|
|
589
641
|
}
|
|
590
642
|
|
|
591
|
-
const agent = this.router.resolve(
|
|
643
|
+
const agent = this.router.resolve(agentThreadId);
|
|
592
644
|
|
|
593
645
|
// Serialize prompts per-thread (concurrent mode allows /stop to bypass)
|
|
594
|
-
const prevLock = threadLocks.get(
|
|
646
|
+
const prevLock = threadLocks.get(agentThreadId);
|
|
595
647
|
let releaseLock: () => void;
|
|
596
648
|
const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
|
|
597
|
-
threadLocks.set(
|
|
649
|
+
threadLocks.set(agentThreadId, lockPromise);
|
|
598
650
|
if (prevLock) await prevLock;
|
|
599
651
|
|
|
600
|
-
console.log(`[roundhouse] → ${agent.name} | thread=${
|
|
652
|
+
console.log(`[roundhouse] → ${agent.name} | thread=${agentThreadId}`);
|
|
601
653
|
|
|
602
654
|
// Enrich audio attachments with transcripts (STT) — inside thread lock to prevent stampede
|
|
603
655
|
if (this.sttService && agentMessage.attachments?.length) {
|
|
@@ -624,7 +676,7 @@ export class Gateway {
|
|
|
624
676
|
const memoryRoot = this.config.memory?.rootDir ?? agentCwd;
|
|
625
677
|
let memoryPrepared: Awaited<ReturnType<typeof prepareMemoryForTurn>> | undefined;
|
|
626
678
|
try {
|
|
627
|
-
memoryPrepared = await prepareMemoryForTurn(
|
|
679
|
+
memoryPrepared = await prepareMemoryForTurn(agentThreadId, agentMessage, agent, memoryRoot, this.config.memory);
|
|
628
680
|
agentMessage = memoryPrepared.message;
|
|
629
681
|
} catch (err) {
|
|
630
682
|
console.error(`[roundhouse] memory prepare error:`, (err as Error).message);
|
|
@@ -635,15 +687,15 @@ export class Gateway {
|
|
|
635
687
|
try {
|
|
636
688
|
if (agent.promptStream) {
|
|
637
689
|
const ac = new AbortController();
|
|
638
|
-
abortControllers.set(
|
|
690
|
+
abortControllers.set(agentThreadId, ac);
|
|
639
691
|
try {
|
|
640
|
-
await this.handleStreaming(thread, agent.promptStream(
|
|
692
|
+
await this.handleStreaming(thread, agent.promptStream(agentThreadId, agentMessage), verboseThreads.has(agentThreadId), ac.signal);
|
|
641
693
|
} finally {
|
|
642
|
-
abortControllers.delete(
|
|
694
|
+
abortControllers.delete(agentThreadId);
|
|
643
695
|
}
|
|
644
696
|
} else {
|
|
645
697
|
// Fallback: non-streaming prompt
|
|
646
|
-
const reply = await agent.prompt(
|
|
698
|
+
const reply = await agent.prompt(agentThreadId, agentMessage);
|
|
647
699
|
if (reply.text) {
|
|
648
700
|
await this.postWithFallback(thread, reply.text);
|
|
649
701
|
}
|
|
@@ -652,7 +704,7 @@ export class Gateway {
|
|
|
652
704
|
// ── Memory: post-turn finalize + pressure check ───
|
|
653
705
|
try {
|
|
654
706
|
const pressure = await finalizeMemoryForTurn(
|
|
655
|
-
|
|
707
|
+
agentThreadId,
|
|
656
708
|
memoryPrepared?.beforeDigest ?? null,
|
|
657
709
|
agent, memoryRoot, this.config.memory,
|
|
658
710
|
);
|
|
@@ -661,7 +713,7 @@ export class Gateway {
|
|
|
661
713
|
if (effectivePressure !== "none") {
|
|
662
714
|
// Run flush/compact INSIDE the thread lock to prevent race with next user message
|
|
663
715
|
try {
|
|
664
|
-
await this.handleContextPressure(thread, agent, memoryRoot, effectivePressure);
|
|
716
|
+
await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
|
|
665
717
|
} catch (err) {
|
|
666
718
|
console.error(`[roundhouse] context pressure handler error:`, (err as Error).message);
|
|
667
719
|
}
|
|
@@ -679,34 +731,35 @@ export class Gateway {
|
|
|
679
731
|
} finally {
|
|
680
732
|
stopTyping();
|
|
681
733
|
releaseLock!();
|
|
682
|
-
if (threadLocks.get(
|
|
683
|
-
threadLocks.delete(
|
|
734
|
+
if (threadLocks.get(agentThreadId) === lockPromise) {
|
|
735
|
+
threadLocks.delete(agentThreadId);
|
|
684
736
|
}
|
|
685
737
|
}
|
|
686
738
|
};
|
|
687
739
|
|
|
688
740
|
// ── Wire Chat SDK events ───────────────────────
|
|
689
741
|
const handleOrAbort = async (thread: any, message: any) => {
|
|
742
|
+
const agentThreadId = resolveAgentThreadId(thread, message);
|
|
690
743
|
const text = (message.text ?? "").trim();
|
|
691
744
|
// /stop is handled immediately — abort the in-flight agent run
|
|
692
745
|
// without waiting for the current handler to finish
|
|
693
746
|
if (isCommand(text, "/stop")) {
|
|
694
747
|
if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
|
|
695
|
-
const agent = this.router.resolve(
|
|
748
|
+
const agent = this.router.resolve(agentThreadId);
|
|
696
749
|
if (agent.abort) {
|
|
697
|
-
await agent.abort(
|
|
698
|
-
abortControllers.get(
|
|
750
|
+
await agent.abort(agentThreadId);
|
|
751
|
+
abortControllers.get(agentThreadId)?.abort();
|
|
699
752
|
try { await thread.post("⏹️ Stopped."); } catch {}
|
|
700
753
|
} else {
|
|
701
754
|
try { await thread.post("⚠️ Abort not supported for this agent."); } catch {}
|
|
702
755
|
}
|
|
703
|
-
console.log(`[roundhouse] /stop for thread=${thread.id}`);
|
|
756
|
+
console.log(`[roundhouse] /stop for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
704
757
|
return;
|
|
705
758
|
}
|
|
706
759
|
// /verbose is a gateway toggle — runs immediately, no queuing
|
|
707
760
|
if (isCommand(text, "/verbose")) {
|
|
708
761
|
if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
|
|
709
|
-
const threadId =
|
|
762
|
+
const threadId = agentThreadId;
|
|
710
763
|
if (verboseThreads.has(threadId)) {
|
|
711
764
|
verboseThreads.delete(threadId);
|
|
712
765
|
try { await thread.post("🔇 Verbose mode OFF — tool status messages hidden."); } catch {}
|
|
@@ -714,7 +767,7 @@ export class Gateway {
|
|
|
714
767
|
verboseThreads.add(threadId);
|
|
715
768
|
try { await thread.post("📢 Verbose mode ON — showing tool calls."); } catch {}
|
|
716
769
|
}
|
|
717
|
-
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"}`);
|
|
718
771
|
return;
|
|
719
772
|
}
|
|
720
773
|
// /doctor runs health checks immediately — no agent access needed
|
|
@@ -834,16 +887,16 @@ export class Gateway {
|
|
|
834
887
|
* Handle context pressure — flush memory and/or compact.
|
|
835
888
|
* Runs inside the thread lock after a turn completes.
|
|
836
889
|
*/
|
|
837
|
-
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) {
|
|
838
891
|
if (pressure === "none") return;
|
|
839
892
|
|
|
840
|
-
console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id}`);
|
|
893
|
+
console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
841
894
|
|
|
842
895
|
if (pressure === "soft") {
|
|
843
896
|
// Soft: prompt agent to save facts, no compact
|
|
844
897
|
// Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
|
|
845
898
|
try {
|
|
846
|
-
await flushMemoryThenCompact(
|
|
899
|
+
await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
|
|
847
900
|
} catch (err) {
|
|
848
901
|
console.error(`[roundhouse] soft flush error:`, (err as Error).message);
|
|
849
902
|
}
|
|
@@ -853,7 +906,7 @@ export class Gateway {
|
|
|
853
906
|
// Hard or emergency: flush + compact
|
|
854
907
|
try {
|
|
855
908
|
await thread.post(`📝 ${pressure === "emergency" ? "⚠️ Context nearly full! " : ""}Saving memory and compacting...`);
|
|
856
|
-
const result = await flushMemoryThenCompact(
|
|
909
|
+
const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, pressure, this.config.memory);
|
|
857
910
|
if (result) {
|
|
858
911
|
const beforeK = (result.tokensBefore / 1000).toFixed(1);
|
|
859
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/util.ts
CHANGED
|
@@ -105,14 +105,6 @@ export function threadIdToDir(threadId: string): string {
|
|
|
105
105
|
.replace(/[^a-zA-Z0-9_-]/g, (ch) => `_x${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** Legacy encoding (v0.3.x) for migration fallback */
|
|
109
|
-
export function threadIdToDirLegacy(threadId: string): string {
|
|
110
|
-
return threadId
|
|
111
|
-
.replace(/_/g, "_u")
|
|
112
|
-
.replace(/:/g, "_c")
|
|
113
|
-
.replace(/[^a-zA-Z0-9_-]/g, "_x");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
108
|
/**
|
|
117
109
|
* Generate a short random attachment ID (e.g. "att_a1b2c3d4").
|
|
118
110
|
*/
|