@inceptionstack/roundhouse 0.5.15 → 0.5.16
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
package/src/gateway/gateway.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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")) {
|
package/src/gateway/helpers.ts
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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,106 @@
|
|
|
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 } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { ROUNDHOUSE_DIR } from "../config";
|
|
16
|
+
|
|
17
|
+
const TOPICS_FILE = join(ROUNDHOUSE_DIR, "active-topics.json");
|
|
18
|
+
|
|
19
|
+
/** Active topic per chat (chatId → topicName). "main" means default. */
|
|
20
|
+
let activeTopics = new Map<string, string>();
|
|
21
|
+
|
|
22
|
+
// Load persisted topics on module init
|
|
23
|
+
try {
|
|
24
|
+
const data = JSON.parse(readFileSync(TOPICS_FILE, "utf8"));
|
|
25
|
+
activeTopics = new Map(Object.entries(data));
|
|
26
|
+
} catch { /* first run or corrupt — start fresh */ }
|
|
27
|
+
|
|
28
|
+
function persistTopics(): void {
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(ROUNDHOUSE_DIR, { recursive: true });
|
|
31
|
+
writeFileSync(TOPICS_FILE, JSON.stringify(Object.fromEntries(activeTopics)));
|
|
32
|
+
} catch (e) { console.error("[roundhouse] failed to persist topics:", e); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Get the active topic for a chat. Returns undefined if on default "main". */
|
|
36
|
+
export function getActiveTopic(chatId: string): string | undefined {
|
|
37
|
+
const topic = activeTopics.get(chatId);
|
|
38
|
+
return (topic && topic !== "main") ? topic : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Apply topic override to a resolved agent thread ID. */
|
|
42
|
+
export function applyTopicOverride(agentThreadId: string, thread: { id?: string }): string {
|
|
43
|
+
if (agentThreadId !== "main") return agentThreadId;
|
|
44
|
+
const chatId = thread.id?.split(":")[1] ?? thread.id ?? "";
|
|
45
|
+
const topic = getActiveTopic(String(chatId));
|
|
46
|
+
return topic ? `topic:${topic}` : agentThreadId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Set the active topic for a chat. */
|
|
50
|
+
export function setActiveTopic(chatId: string, topic: string): void {
|
|
51
|
+
if (topic === "main" || topic === "off" || topic === "") {
|
|
52
|
+
activeTopics.delete(chatId);
|
|
53
|
+
} else {
|
|
54
|
+
activeTopics.set(chatId, topic);
|
|
55
|
+
}
|
|
56
|
+
persistTopics();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get all known topics from memory-state directory. */
|
|
60
|
+
export function listTopics(): string[] {
|
|
61
|
+
const stateDir = join(ROUNDHOUSE_DIR, "memory-state");
|
|
62
|
+
try {
|
|
63
|
+
return readdirSync(stateDir)
|
|
64
|
+
.filter(f => f.startsWith("topic_c") && f.endsWith(".json"))
|
|
65
|
+
.map(f => f.replace(/^topic_c/, "").replace(/\.json$/, ""))
|
|
66
|
+
.map(f => f.replace(/__/g, "_")); // reverse threadIdToDir encoding
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TopicCommandContext {
|
|
73
|
+
thread: { id: string };
|
|
74
|
+
text: string;
|
|
75
|
+
postWithFallback: (thread: any, text: string) => Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function handleTopic(ctx: TopicCommandContext): Promise<void> {
|
|
79
|
+
const { thread, text, postWithFallback } = ctx;
|
|
80
|
+
|
|
81
|
+
// Extract chat ID from thread (for private: "telegram:<chatId>")
|
|
82
|
+
const chatId = thread.id?.split(":")[1] ?? thread.id;
|
|
83
|
+
|
|
84
|
+
// Parse the topic name from the command
|
|
85
|
+
const match = text.match(/^\/topic(?:@\S+)?\s+(.+)/i);
|
|
86
|
+
const topicName = match?.[1]?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
87
|
+
|
|
88
|
+
if (!topicName) {
|
|
89
|
+
// Show current topic + known topics
|
|
90
|
+
const current = getActiveTopic(chatId) ?? "main (default)";
|
|
91
|
+
const known = listTopics();
|
|
92
|
+
let msg = `📂 Current topic: \`${current}\`\n\n`;
|
|
93
|
+
if (known.length > 0) {
|
|
94
|
+
msg += `Known topics: ${known.map(t => `\`${t}\``).join(", ")}\n\n`;
|
|
95
|
+
}
|
|
96
|
+
msg += `Switch with: \`/topic <name>\`\nReturn to default: \`/topic main\``;
|
|
97
|
+
await postWithFallback(thread, msg);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setActiveTopic(chatId, topicName);
|
|
102
|
+
const display = topicName === "main" || topicName === "off" ? "main (default)" : topicName;
|
|
103
|
+
const isNew = topicName !== "main" && topicName !== "off";
|
|
104
|
+
const emoji = isNew ? "📂" : "🏠";
|
|
105
|
+
await postWithFallback(thread, `${emoji} Switched to topic: \`${display}\`\n\nAgent context is now independent for this topic.`);
|
|
106
|
+
}
|
|
@@ -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" },
|