@inceptionstack/roundhouse 0.5.22 → 0.5.25

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.
@@ -6,14 +6,41 @@
6
6
  *
7
7
  * Usage:
8
8
  * /topic deploy — switch to "deploy" topic (creates if new)
9
- * /topic — show current topic + list all
9
+ * /topic — show current topic + list of known topics as
10
+ * clickable inline-keyboard buttons (Telegram)
10
11
  * /topic main — return to default session
12
+ *
13
+ * Inline keyboard: when called with no args in a private chat and at
14
+ * least one known topic exists, we send a Telegram inline keyboard so
15
+ * the user can switch with a tap. Clicking a button fires a callback
16
+ * routed through chat.onAction(TOPIC_ACTION_ID) → handleTopicAction().
11
17
  */
12
18
 
13
19
  import { readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
14
20
  import { join } from "node:path";
15
21
  import { randomBytes } from "node:crypto";
16
22
  import { ROUNDHOUSE_DIR } from "../config";
23
+ import {
24
+ encodeCallbackData,
25
+ toKeyboardRows,
26
+ extractTelegramChatId,
27
+ type ChatThreadLike,
28
+ type InlineButton,
29
+ type InlineKeyboard,
30
+ } from "./inline-keyboard";
31
+
32
+ /** Action ID for topic-select inline-keyboard callbacks */
33
+ export const TOPIC_ACTION_ID = "topic_select";
34
+
35
+ /**
36
+ * Special sentinel value used by the "🏠 main (default)" button.
37
+ *
38
+ * Must be a string that `normalizeTopicName()` can never emit, so that a user
39
+ * who creates a topic via `/topic <name>` can't accidentally collide with it.
40
+ * The normalizer strips leading/trailing `-`, so any sentinel starting or
41
+ * ending with `-` is unrepresentable as a user-created topic name.
42
+ */
43
+ const MAIN_SENTINEL = "-main";
17
44
 
18
45
  const TOPICS_FILE = join(ROUNDHOUSE_DIR, "active-topics.json");
19
46
 
@@ -75,16 +102,44 @@ export function listTopics(chatId: string): string[] {
75
102
  }
76
103
 
77
104
  export interface TopicCommandContext {
78
- thread: { id: string };
105
+ thread: ChatThreadLike;
79
106
  text: string;
80
- postWithFallback: (thread: any, text: string) => Promise<void>;
107
+ postWithFallback: (thread: ChatThreadLike, text: string) => Promise<void>;
108
+ }
109
+
110
+ /** Build an inline keyboard listing all known topics + a "main" escape hatch. */
111
+ function buildTopicKeyboard(topics: string[], current: string | undefined): InlineKeyboard {
112
+ const onMain = !current;
113
+ const buttons: InlineButton[] = [];
114
+
115
+ // Always include "main (default)" first so users can escape back.
116
+ // ✓ appears when we're currently on main (i.e. no active topic).
117
+ buttons.push({
118
+ text: onMain ? "🏠 main (default) ✓" : "🏠 main (default)",
119
+ callback_data: encodeCallbackData(TOPIC_ACTION_ID, MAIN_SENTINEL),
120
+ });
121
+
122
+ for (const t of topics) {
123
+ const isActive = t === current;
124
+ buttons.push({
125
+ text: isActive ? `📂 ${t} ✓` : `📂 ${t}`,
126
+ callback_data: encodeCallbackData(TOPIC_ACTION_ID, t),
127
+ });
128
+ }
129
+
130
+ return toKeyboardRows(buttons);
131
+ }
132
+
133
+ /** Normalize a topic name the same way as the command parser. */
134
+ function normalizeTopicName(raw: string): string {
135
+ return raw.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
81
136
  }
82
137
 
83
138
  export async function handleTopic(ctx: TopicCommandContext): Promise<void> {
84
139
  const { thread, text, postWithFallback } = ctx;
85
140
 
86
141
  // Extract chat ID from thread (for private: "telegram:<chatId>")
87
- const chatId = thread.id?.split(":")[1] ?? thread.id;
142
+ const chatId = (thread?.id?.split(":")[1] ?? thread?.id ?? "") as string;
88
143
 
89
144
  // /topic only works in private chats (groups use forum topics instead)
90
145
  if (chatId && chatId.startsWith("-")) {
@@ -94,24 +149,98 @@ export async function handleTopic(ctx: TopicCommandContext): Promise<void> {
94
149
 
95
150
  // Parse the topic name from the command
96
151
  const match = text.match(/^\/topic(?:@\S+)?\s+(.+)/i);
97
- const topicName = match?.[1]?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
152
+ const topicName = match?.[1] ? normalizeTopicName(match[1]) : "";
98
153
 
99
154
  if (!topicName) {
100
- // Show current topic + known topics
101
- const current = getActiveTopic(chatId) ?? "main (default)";
102
- const known = listTopics(chatId);
103
- let msg = `📂 Current topic: \`${current}\`\n\n`;
104
- if (known.length > 0) {
105
- msg += `Known topics: ${known.map(t => `\`${t}\``).join(", ")}\n\n`;
106
- }
107
- msg += `Switch with: \`/topic <name>\`\nReturn to default: \`/topic main\``;
108
- await postWithFallback(thread, msg);
155
+ await showTopicMenu(thread, chatId, postWithFallback);
109
156
  return;
110
157
  }
111
158
 
159
+ await applyTopicSelection(chatId, topicName, thread, postWithFallback);
160
+ }
161
+
162
+ /** Show the current topic + inline keyboard (or text fallback). */
163
+ async function showTopicMenu(
164
+ thread: ChatThreadLike,
165
+ chatId: string,
166
+ postWithFallback: (thread: ChatThreadLike, text: string) => Promise<void>,
167
+ ): Promise<void> {
168
+ const current = getActiveTopic(chatId);
169
+ const currentDisplay = current ?? "main (default)";
170
+ const known = listTopics(chatId);
171
+
172
+ // Try inline keyboard if we have any known topics and the adapter supports
173
+ // raw Telegram calls. Otherwise fall back to text.
174
+ const telegramFetch = thread?.adapter?.telegramFetch;
175
+ if (known.length > 0 && telegramFetch) {
176
+ const tgChatId = extractTelegramChatId(thread);
177
+ if (tgChatId) {
178
+ try {
179
+ await telegramFetch("sendMessage", {
180
+ chat_id: tgChatId,
181
+ text: `📂 Current topic: <b>${currentDisplay}</b>\n\nTap a topic to switch:`,
182
+ parse_mode: "HTML",
183
+ reply_markup: buildTopicKeyboard(known, current),
184
+ });
185
+ return;
186
+ } catch (err) {
187
+ console.warn("[roundhouse] /topic inline keyboard failed, falling back:", (err as Error).message);
188
+ }
189
+ }
190
+ }
191
+
192
+ // Text fallback
193
+ let msg = `📂 Current topic: \`${currentDisplay}\`\n\n`;
194
+ if (known.length > 0) {
195
+ msg += `Known topics: ${known.map(t => `\`${t}\``).join(", ")}\n\n`;
196
+ }
197
+ msg += `Switch with: \`/topic <name>\`\nReturn to default: \`/topic main\``;
198
+ await postWithFallback(thread, msg);
199
+ }
200
+
201
+ /**
202
+ * Apply a topic selection. Shared by `/topic <name>` and inline-keyboard clicks.
203
+ * `topicName` must already be normalized (or be a known sentinel like "main").
204
+ */
205
+ export async function applyTopicSelection(
206
+ chatId: string,
207
+ topicName: string,
208
+ thread: ChatThreadLike,
209
+ postWithFallback: (thread: ChatThreadLike, text: string) => Promise<void>,
210
+ ): Promise<void> {
112
211
  setActiveTopic(chatId, topicName);
113
- const display = topicName === "main" || topicName === "off" ? "main (default)" : topicName;
114
- const isNew = topicName !== "main" && topicName !== "off";
115
- const emoji = isNew ? "📂" : "🏠";
116
- await postWithFallback(thread, `${emoji} Switched to topic: \`${display}\`\n\nAgent context is now independent for this topic.`);
212
+ const isDefault = topicName === "main" || topicName === "off" || topicName === "";
213
+ const display = isDefault ? "main (default)" : topicName;
214
+ const emoji = isDefault ? "🏠" : "📂";
215
+ const suffix = isDefault
216
+ ? "Back to the default session."
217
+ : "Agent context is now independent for this topic.";
218
+ await postWithFallback(thread, `${emoji} Switched to topic: \`${display}\`\n\n${suffix}`);
219
+ }
220
+
221
+ /**
222
+ * Handle inline-keyboard callback for topic selection.
223
+ * Call this from chat.onAction(TOPIC_ACTION_ID, ...).
224
+ */
225
+ export async function handleTopicAction(event: {
226
+ value?: string;
227
+ thread: ChatThreadLike;
228
+ }): Promise<void> {
229
+ const raw = event.value;
230
+ if (!raw) return;
231
+
232
+ const thread = event.thread;
233
+ const chatId = (thread?.id?.split(":")[1] ?? thread?.id ?? "") as string;
234
+ if (!chatId) return;
235
+
236
+ const topicName = raw === MAIN_SENTINEL ? "main" : normalizeTopicName(raw);
237
+ if (!topicName && raw !== MAIN_SENTINEL) return;
238
+
239
+ const postFn = async (_t: ChatThreadLike, text: string) => {
240
+ if (!thread?.post) return;
241
+ try { await thread.post({ markdown: text }); }
242
+ catch { try { await thread.post(text); } catch { /* ignore */ } }
243
+ };
244
+
245
+ await applyTopicSelection(chatId, topicName, thread, postFn);
117
246
  }
@@ -227,20 +227,44 @@ export async function flushMemoryThenCompact(
227
227
  const effectiveLevel = level === "manual" ? "hard" : level;
228
228
  const t0 = Date.now();
229
229
 
230
+ // On "emergency" we skip the flush step entirely. Flush is a normal agent
231
+ // prompt turn routed through the live session (see PiAdapter.promptWithModel
232
+ // → entry.session.prompt). At emergency pressure the session is already at
233
+ // or above the model's context limit, so appending any turn — including the
234
+ // flush prompt — will be rejected by the provider (e.g. Bedrock returns
235
+ // "prompt is too long: N tokens > 200000 maximum"). Because the catch block
236
+ // below re-arms `pendingCompact`, this would loop forever on every user
237
+ // turn. pi-ai's `session.compact()` builds its own summarization payload
238
+ // from older history (keeping `keepRecentTokens` recent messages) and does
239
+ // NOT require the live session to fit under the limit — so skipping flush
240
+ // lets us recover. Facts-to-MEMORY.md (the whole point of flush) is a
241
+ // best-effort nicety that the next soft/hard flush can catch up on.
242
+ const skipFlush = effectiveLevel === "emergency";
243
+
230
244
  try {
231
- // Step 1: flush
232
- const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, effectiveLevel);
233
- console.log(`[memory] flushing memory for ${threadId} (level: ${level}${flushModel ? `, model: ${flushModel}` : ""})`);
234
- await onProgress?.("💭 Flushing memory...");
235
- await sendFlush(flushText);
236
- const flushMs = Date.now() - t0;
245
+ let flushMs = 0;
246
+ if (!skipFlush) {
247
+ // Step 1: flush
248
+ const flushText = buildFlushPrompt(mode === "unknown" ? "full" : mode, effectiveLevel);
249
+ console.log(`[memory] flushing memory for ${threadId} (level: ${level}${flushModel ? `, model: ${flushModel}` : ""})`);
250
+ await onProgress?.("💭 Flushing memory...");
251
+ await sendFlush(flushText);
252
+ flushMs = Date.now() - t0;
253
+ } else {
254
+ console.log(`[memory] skipping flush for ${threadId} — emergency pressure, going straight to compact`);
255
+ }
237
256
 
238
257
  // Step 2: compact (use flush model if compactWithModel is available)
239
- console.log(`[memory] compacting ${threadId} (flush took ${flushMs}ms)`);
240
- await onProgress?.(`✂️ Compacting context... (flush took ${(flushMs / 1000).toFixed(1)}s)`);
258
+ const flushNote = skipFlush ? "flush skipped (emergency)" : `flush took ${flushMs}ms`;
259
+ console.log(`[memory] compacting ${threadId} (${flushNote})`);
260
+ const progressNote = skipFlush
261
+ ? "✂️ Compacting context... (emergency — skipping flush)"
262
+ : `✂️ Compacting context... (flush took ${(flushMs / 1000).toFixed(1)}s)`;
263
+ await onProgress?.(progressNote);
241
264
  const t1 = Date.now();
242
- const result = flushModel && agent.compactWithModel
243
- ? await agent.compactWithModel(threadId, flushModel)
265
+ const usedCompactModel = Boolean(flushModel && agent.compactWithModel);
266
+ const result = usedCompactModel
267
+ ? await agent.compactWithModel!(threadId, flushModel!)
244
268
  : await agent.compact!(threadId);
245
269
  const compactMs = Date.now() - t1;
246
270
  if (!result) return null;
@@ -255,7 +279,17 @@ export async function flushMemoryThenCompact(
255
279
  }
256
280
 
257
281
  const totalMs = Date.now() - t0;
258
- const timing = { flushMs, compactMs, totalMs, model: flushModel ?? "default" };
282
+ // Telemetry nuance: if we called agent.compactWithModel(flushModel), that's
283
+ // what we *requested*. But per the AgentAdapter contract, a BaseAdapter-
284
+ // derived adapter may provide only a default `compactWithModel` shim that
285
+ // ignores modelId and delegates to compact() (see src/agents/base-adapter.ts).
286
+ // We cannot distinguish a real override from the shim at this layer
287
+ // without widening the adapter return type to include `modelUsed`.
288
+ // So `timing.model` is the requested model, not a guaranteed-used one.
289
+ // Follow-up: return {modelUsed} from compact/compactWithModel for precise
290
+ // telemetry. At minimum we correctly report "default" when no flushModel
291
+ // was even requested, or when compactWithModel is entirely absent.
292
+ const timing = { flushMs, compactMs, totalMs, model: usedCompactModel ? flushModel! : "default" };
259
293
  console.log(`[memory] flush+compact done for ${threadId}: ${result.tokensBefore} → ${result.tokensAfter ?? "?"} tokens | flush=${flushMs}ms compact=${compactMs}ms total=${totalMs}ms model=${timing.model}`);
260
294
 
261
295
  // Persist timing log for debugging (async, fire-and-forget)
@@ -43,6 +43,11 @@ export class SubAgentOrchestratorImpl implements SubAgentOrchestrator, SubAgentL
43
43
  });
44
44
  }
45
45
  onCompletion(listener: (status: RunStatus) => Promise<void> | void): () => void { return this.finalizer.onCompletion(listener); }
46
+ private spawnListeners: Array<(status: RunStatus) => Promise<void> | void> = [];
47
+ onSpawn(listener: (status: RunStatus) => Promise<void> | void): () => void {
48
+ this.spawnListeners.push(listener);
49
+ return () => { this.spawnListeners = this.spawnListeners.filter(l => l !== listener); };
50
+ }
46
51
  isRunManagedInProcess(runId: string): boolean { return this.children.has(runId); }
47
52
 
48
53
  async spawn(spec: SpawnSpec): Promise<string> {
@@ -94,6 +99,12 @@ export class SubAgentOrchestratorImpl implements SubAgentOrchestrator, SubAgentL
94
99
  this.children.set(runId, { pid });
95
100
  await this.store.write(initialStatus);
96
101
  resolveReady!();
102
+
103
+ // Notify spawn listeners
104
+ for (const listener of this.spawnListeners) {
105
+ try { await listener(initialStatus); } catch {}
106
+ }
107
+
97
108
  return runId;
98
109
  } catch (err) {
99
110
  this.children.delete(runId);
@@ -150,7 +161,11 @@ export class SubAgentOrchestratorImpl implements SubAgentOrchestrator, SubAgentL
150
161
  const alive = await this.isProcessAlive(current.pid, current.spawnClockTicks);
151
162
  if (alive) return current;
152
163
 
153
- const outcome = this.terminationHandler.terminalStatusFor(current);
164
+ // Process is dead. If it had a requested outcome (abort/timeout), use that.
165
+ // Otherwise check stdout — orphan recovery doesn't know exit code.
166
+ const outcome = current.requestedOutcome
167
+ ? this.terminationHandler.terminalStatusFor(current)
168
+ : await this.inferFromStdout(current.runId);
154
169
  return this.finalizer.finalizeRun(current.runId, outcome, { notify });
155
170
  }
156
171
 
@@ -180,14 +195,26 @@ export class SubAgentOrchestratorImpl implements SubAgentOrchestrator, SubAgentL
180
195
  */
181
196
  private async inferOutcome(runId: string, exitCode: number | null): Promise<"complete" | "failed"> {
182
197
  if (exitCode === 0) return "complete";
183
- if (exitCode === null) return "failed"; // signal-killed, not a teardown error
198
+ if (exitCode === null) {
199
+ // For handleChildExit: null means signal-killed → failed
200
+ // But callers recovering orphans should use inferFromStdout directly
201
+ return "failed";
202
+ }
203
+ return this.inferFromStdout(runId);
204
+ }
205
+
206
+ /**
207
+ * Check stdout for substantial output. Used for:
208
+ * - Non-zero exit codes (teardown errors)
209
+ * - Recovered orphan processes (unknown exit code)
210
+ */
211
+ private async inferFromStdout(runId: string): Promise<"complete" | "failed"> {
184
212
  try {
185
213
  const stdoutPath = join(this.store.getRunDir(runId), "stdout.log");
186
214
  const content = await readFile(stdoutPath, "utf-8");
187
- // If stdout has substantial content (>50 chars), the agent did its job
188
215
  if (content.trim().length > 50) return "complete";
189
216
  } catch {
190
- // No stdout file or can't read — fall through to failed
217
+ // No stdout file or can't read
191
218
  }
192
219
  return "failed";
193
220
  }
@@ -39,7 +39,7 @@ export class ProcessLauncher {
39
39
  let child: ChildProcess | undefined;
40
40
 
41
41
  try {
42
- child = this.spawnProcess("pi", ["--session-dir", runDir, "-p", `@${join(runDir, "brief.md")}`], {
42
+ child = this.spawnProcess("pi", ["--no-extensions", "--no-skills", "--session-dir", runDir, "-p", `@${join(runDir, "brief.md")}`], {
43
43
  cwd,
44
44
  detached: true,
45
45
  stdio: ["ignore", stdoutHandle.fd, stderrHandle.fd],
@@ -45,6 +45,7 @@ export interface SubAgentOrchestrator {
45
45
  status(runId: string): Promise<RunStatus | null>;
46
46
  list(): Promise<RunStatus[]>;
47
47
  abort(runId: string): Promise<void>;
48
+ onSpawn(listener: (status: RunStatus) => Promise<void> | void): () => void;
48
49
  }
49
50
 
50
51
  /** Internal API used by SubAgentWatcher for lifecycle management */
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { TransportAdapter, ChatThread, IncomingMessage, PairingResult } from "../types";
9
9
  import { isTelegramThread, postTelegramHtml } from "./html";
10
+ import { markdownToTelegramHtml } from "./format";
10
11
  import { sendTelegramToMany } from "./notify";
11
12
  import { BOT_COMMANDS } from "./bot-commands";
12
13
  import { readPendingPairing, completePendingPairing, clearPendingPairing, isStartForNonce } from "./pairing";
@@ -77,12 +78,14 @@ export class TelegramAdapter implements TransportAdapter {
77
78
  return thread;
78
79
  }
79
80
 
80
- async notify(chatIds: number[], text: string, options?: { parseMode?: string }): Promise<void> {
81
+ async notify(chatIds: number[], text: string): Promise<void> {
81
82
  if (!process.env.TELEGRAM_BOT_TOKEN) {
82
83
  console.warn("[roundhouse] TELEGRAM_BOT_TOKEN not set — skipping notification");
83
84
  return;
84
85
  }
85
- await sendTelegramToMany(chatIds, text, options);
86
+ // Convert lightweight markdown to Telegram HTML
87
+ const html = markdownToTelegramHtml(text);
88
+ await sendTelegramToMany(chatIds, html, { parseMode: "HTML" });
86
89
  }
87
90
 
88
91
  async isPairingPending(): Promise<boolean> {
@@ -54,7 +54,7 @@ export interface TransportAdapter {
54
54
  ownsThread(thread: ChatThread): boolean;
55
55
 
56
56
  /** Send notifications to configured recipients */
57
- notify(chatIds: number[], text: string, options?: { parseMode?: string }): Promise<void>;
57
+ notify(chatIds: number[], text: string): Promise<void>;
58
58
 
59
59
  /**
60
60
  * Create a thread object for a given chat ID.