@inceptionstack/roundhouse 0.5.15 → 0.5.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@ import { handleStreaming as _handleStream } from "./streaming";
25
25
  import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
26
26
  import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command";
27
27
  import { handleLater } from "./later-command";
28
+ import { handleTopic, applyTopicOverride } from "./topic-command";
28
29
  import { TelegramAdapter } from "../transports";
29
30
  import type { TransportAdapter } from "../transports";
30
31
  import { hostname } from "node:os";
@@ -215,7 +216,7 @@ export class Gateway {
215
216
 
216
217
  // ── Unified handler ────────────────────────────
217
218
  const handle = async (thread: any, message: any) => {
218
- const agentThreadId = resolveAgentThreadId(thread, message);
219
+ let agentThreadId = applyTopicOverride(resolveAgentThreadId(thread, message), thread);
219
220
  const userText = message.text ?? "";
220
221
  const authorName = message.author?.userName ?? message.author?.userId ?? "?";
221
222
  const rawAttachments = message.attachments ?? [];
@@ -267,13 +268,18 @@ export class Gateway {
267
268
  return;
268
269
  }
269
270
 
271
+ if (isCommandWithArgs(trimmed, "/topic") || isCommand(trimmed, "/topic")) {
272
+ await handleTopic({ thread, text: trimmed, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
273
+ return;
274
+ }
275
+
270
276
  // Dispatch to agent turn handler
271
277
  await this.handleAgentTurn(thread, agentThreadId, userText, rawAttachments, verboseThreads, threadLocks, abortControllers);
272
278
  };
273
279
 
274
280
  // ── Wire Chat SDK events ───────────────────────
275
281
  const handleOrAbort = async (thread: any, message: any) => {
276
- const agentThreadId = resolveAgentThreadId(thread, message);
282
+ let agentThreadId = applyTopicOverride(resolveAgentThreadId(thread, message), thread);
277
283
  const text = (message.text ?? "").trim();
278
284
  // /stop — abort the in-flight agent run immediately
279
285
  if (isCommand(text, "/stop")) {
@@ -51,21 +51,40 @@ function getChatId(thread: any, message: any): string {
51
51
 
52
52
  /**
53
53
  * Resolve the agent-facing thread ID from a chat message.
54
- * Private/DM → "main", group → "group:<chatId>"
54
+ * Private/DM → "main", group → "group:<chatId>", forum topic → "group:<chatId>:topic:<topicId>"
55
55
  */
56
56
  export function resolveAgentThreadId(thread: any, message: any): string {
57
57
  const chatType = String(message?.chat?.type ?? thread?.chat?.type ?? thread?.type ?? "").toLowerCase();
58
+
59
+ // Extract topic ID if present (forum threads)
60
+ const topicId = message?.message_thread_id ?? thread?.messageThreadId ?? extractTopicFromThreadId(thread?.id);
61
+
58
62
  if (["private", "dm", "direct", "im"].includes(chatType)) return "main";
59
- if (["group", "supergroup", "channel"].includes(chatType)) return `group:${getChatId(thread, message)}`;
63
+ if (["group", "supergroup", "channel"].includes(chatType)) {
64
+ const base = `group:${getChatId(thread, message)}`;
65
+ return topicId ? `${base}:topic:${topicId}` : base;
66
+ }
60
67
 
61
68
  const telegramChatId = telegramChatIdFromThreadId(thread?.id);
62
69
  if (telegramChatId !== null) {
63
- return telegramChatId < 0 ? `group:${telegramChatId}` : "main";
70
+ const base = telegramChatId < 0 ? `group:${telegramChatId}` : "main";
71
+ return (topicId && telegramChatId < 0) ? `${base}:topic:${topicId}` : base;
64
72
  }
65
73
 
66
74
  return String(thread?.id ?? "main");
67
75
  }
68
76
 
77
+ /** Extract message_thread_id from thread ID string like "telegram:chatId:topicId" */
78
+ function extractTopicFromThreadId(threadId: string | undefined): number | undefined {
79
+ if (!threadId) return undefined;
80
+ const parts = threadId.split(":");
81
+ if (parts.length === 3 && parts[0] === "telegram") {
82
+ const parsed = parseInt(parts[2], 10);
83
+ return Number.isFinite(parsed) ? parsed : undefined;
84
+ }
85
+ return undefined;
86
+ }
87
+
69
88
  // ── System Resources ─────────────────────────────────
70
89
 
71
90
  export interface SystemResources {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * gateway/topic-command.ts — Named topic sessions in private chats
3
+ *
4
+ * Allows switching between independent conversations via /topic <name>.
5
+ * Each topic has its own agent session (memory, context, thread).
6
+ *
7
+ * Usage:
8
+ * /topic deploy — switch to "deploy" topic (creates if new)
9
+ * /topic — show current topic + list all
10
+ * /topic main — return to default session
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { randomBytes } from "node:crypto";
16
+ import { ROUNDHOUSE_DIR } from "../config";
17
+
18
+ const TOPICS_FILE = join(ROUNDHOUSE_DIR, "active-topics.json");
19
+
20
+ /** Active topic per chat (chatId → topicName). "main" means default. */
21
+ let activeTopics = new Map<string, string>();
22
+
23
+ // Load persisted topics on module init
24
+ try {
25
+ const data = JSON.parse(readFileSync(TOPICS_FILE, "utf8"));
26
+ activeTopics = new Map(Object.entries(data));
27
+ } catch { /* first run or corrupt — start fresh */ }
28
+
29
+ function persistTopics(): void {
30
+ try {
31
+ mkdirSync(ROUNDHOUSE_DIR, { recursive: true });
32
+ const tmp = TOPICS_FILE + "." + randomBytes(4).toString("hex");
33
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(activeTopics)));
34
+ renameSync(tmp, TOPICS_FILE);
35
+ } catch (e) { console.error("[roundhouse] failed to persist topics:", e); }
36
+ }
37
+
38
+ /** Get the active topic for a chat. Returns undefined if on default "main". */
39
+ export function getActiveTopic(chatId: string): string | undefined {
40
+ const topic = activeTopics.get(chatId);
41
+ return (topic && topic !== "main") ? topic : undefined;
42
+ }
43
+
44
+ /** Apply topic override to a resolved agent thread ID. Scoped per chat. */
45
+ export function applyTopicOverride(agentThreadId: string, thread: { id?: string }): string {
46
+ if (agentThreadId !== "main") return agentThreadId;
47
+ const chatId = thread.id?.split(":")[1] ?? thread.id ?? "";
48
+ const topic = getActiveTopic(String(chatId));
49
+ return topic ? `topic:${chatId}:${topic}` : agentThreadId;
50
+ }
51
+
52
+ /** Set the active topic for a chat. */
53
+ export function setActiveTopic(chatId: string, topic: string): void {
54
+ if (topic === "main" || topic === "off" || topic === "") {
55
+ activeTopics.delete(chatId);
56
+ } else {
57
+ activeTopics.set(chatId, topic);
58
+ }
59
+ persistTopics();
60
+ }
61
+
62
+ /** Get all known topics for a specific chat from memory-state directory. */
63
+ export function listTopics(chatId: string): string[] {
64
+ const stateDir = join(ROUNDHOUSE_DIR, "memory-state");
65
+ // Files are named topic_c<chatId>_c<topicName>.json (threadIdToDir encoding)
66
+ const prefix = `topic_c${chatId}_c`;
67
+ try {
68
+ return readdirSync(stateDir)
69
+ .filter(f => f.startsWith(prefix) && f.endsWith(".json"))
70
+ .map(f => f.slice(prefix.length).replace(/\.json$/, ""))
71
+ .map(f => f.replace(/__/g, "_")); // reverse threadIdToDir underscore encoding
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+
77
+ export interface TopicCommandContext {
78
+ thread: { id: string };
79
+ text: string;
80
+ postWithFallback: (thread: any, text: string) => Promise<void>;
81
+ }
82
+
83
+ export async function handleTopic(ctx: TopicCommandContext): Promise<void> {
84
+ const { thread, text, postWithFallback } = ctx;
85
+
86
+ // Extract chat ID from thread (for private: "telegram:<chatId>")
87
+ const chatId = thread.id?.split(":")[1] ?? thread.id;
88
+
89
+ // /topic only works in private chats (groups use forum topics instead)
90
+ if (chatId && chatId.startsWith("-")) {
91
+ await postWithFallback(thread, "⚠️ /topic only works in private chats. Use Telegram forum topics for groups.");
92
+ return;
93
+ }
94
+
95
+ // Parse the topic name from the command
96
+ const match = text.match(/^\/topic(?:@\S+)?\s+(.+)/i);
97
+ const topicName = match?.[1]?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
98
+
99
+ 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);
109
+ return;
110
+ }
111
+
112
+ 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.`);
117
+ }
@@ -15,6 +15,7 @@ export const BOT_COMMANDS: BotCommand[] = [
15
15
  { command: "compact", description: "Compact context window" },
16
16
  { command: "model", description: "Show or switch AI model" },
17
17
  { command: "later", description: "Save an idea for later" },
18
+ { command: "topic", description: "Switch conversation topic" },
18
19
  { command: "verbose", description: "Toggle verbose tool output" },
19
20
  { command: "stop", description: "Stop the current agent run" },
20
21
  { command: "restart", description: "Restart agent process" },