@inceptionstack/roundhouse 0.5.14 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to `@inceptionstack/roundhouse` are documented here.
4
4
 
5
+ ## [0.5.14] — 2026-05-10
6
+
7
+ ### Added
8
+ - **"What's New" notification** — after `/update` + restart, startup message shows changelog highlights from the new version
9
+ - Command dispatch registry — cleaner gateway routing (−13 lines)
10
+ - Status helpers extracted (`formatUptime`, `checkAvailableUpdate`)
11
+
12
+ ### Fixed
13
+ - COMMAND_REGISTRY type safety (`CommandContext` not `any`)
14
+ - CHANGELOG.md now included in published npm package
15
+
16
+ ## [0.5.13] — 2026-05-10
17
+
18
+ ### Added
19
+ - **soul.md + user.md persona injection** — agent identity + user context, auto-reloads on file change
20
+ - tools.md now hints agent to check `~/.roundhouse/workspace/later.md`
21
+
22
+ ### Fixed
23
+ - XML injection: escape `</persona>` in user-supplied persona files
24
+ - `mkdirSync` before `writeSettings` (fixes fresh-install crash)
25
+ - mtime check uses `!==` instead of `>` (catches deletions)
26
+ - `/later@BotName` suffix now stripped in group chats
27
+
28
+ ## [0.5.12] — 2026-05-10
29
+
30
+ ### Added
31
+ - **Inline keyboard for /model** — 8 frontier Bedrock models (2-column, 4-row layout)
32
+ - Models: Claude Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5, DeepSeek R1, Llama 4, Nova Pro, Mistral Large
33
+
34
+ ## [0.5.11] — 2026-05-09
35
+
36
+ ### Added
37
+ - **/later command** — quick-capture ideas to `~/.roundhouse/workspace/later.md`
38
+
5
39
  ## [0.5.10] — 2026-05-09
6
40
 
7
41
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -68,6 +68,14 @@ export async function handleUpdate(ctx: CommandContext): Promise<void> {
68
68
  await thread.post("⚠️ /update requires an allowlist to be configured.");
69
69
  return;
70
70
  }
71
+ // Seed .last-version with current version BEFORE update, so new code detects the change on restart
72
+ try {
73
+ const { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } = await import("../config");
74
+ const { mkdirSync, writeFileSync } = await import("node:fs");
75
+ const { join } = await import("node:path");
76
+ mkdirSync(ROUNDHOUSE_DIR, { recursive: true });
77
+ writeFileSync(join(ROUNDHOUSE_DIR, ".last-version"), ROUNDHOUSE_VERSION + "\n");
78
+ } catch {}
71
79
  console.log(`[roundhouse] /update requested by @${authorName} in thread=${thread.id}`);
72
80
  const progress = await createProgressMessage(thread, "📦 Checking for updates...");
73
81
  try {
@@ -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,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
+ }
@@ -48,12 +48,11 @@ export function checkVersionChange(): string | null {
48
48
  // No change
49
49
  if (lastVersion === ROUNDHOUSE_VERSION) return null;
50
50
 
51
- // First run (no previous version)
52
- if (!lastVersion) return null;
53
-
54
- // Version changed — this is an update
51
+ // Version changed (or first run after feature was added) — show what's new
55
52
  const changelog = getLatestChangelog();
56
- const header = `🆕 Updated: v${lastVersion} → v${ROUNDHOUSE_VERSION}`;
53
+ const header = lastVersion
54
+ ? `🆕 Updated: v${lastVersion} → v${ROUNDHOUSE_VERSION}`
55
+ : `🆕 What's new in v${ROUNDHOUSE_VERSION}`;
57
56
  if (!changelog) return header;
58
57
  return `${header}\n\n${changelog}`;
59
58
  }
@@ -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" },