@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.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/agents/pi/pi-adapter.ts +156 -17
- package/src/agents/shared/message-validator.test.ts +351 -0
- package/src/agents/shared/message-validator.ts +200 -0
- package/src/agents/shared/session-repair.test.ts +378 -0
- package/src/agents/shared/session-repair.ts +328 -0
- package/src/cli/cron-commands.ts +54 -39
- package/src/gateway/command-registry.ts +158 -0
- package/src/gateway/gateway.ts +239 -102
- package/src/gateway/inline-keyboard.ts +64 -0
- package/src/gateway/model-command.ts +11 -15
- package/src/gateway/topic-command.ts +147 -18
- package/src/memory/lifecycle.ts +45 -11
- package/src/subagents/orchestrator.ts +31 -4
- package/src/subagents/process-launcher.ts +1 -1
- package/src/subagents/types.ts +1 -0
- package/src/transports/telegram/telegram-adapter.ts +5 -2
- package/src/transports/types.ts +1 -1
|
@@ -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
|
|
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:
|
|
105
|
+
thread: ChatThreadLike;
|
|
79
106
|
text: string;
|
|
80
|
-
postWithFallback: (thread:
|
|
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
|
|
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]
|
|
152
|
+
const topicName = match?.[1] ? normalizeTopicName(match[1]) : "";
|
|
98
153
|
|
|
99
154
|
if (!topicName) {
|
|
100
|
-
|
|
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
|
|
114
|
-
const
|
|
115
|
-
const emoji =
|
|
116
|
-
|
|
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
|
}
|
package/src/memory/lifecycle.ts
CHANGED
|
@@ -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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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],
|
package/src/subagents/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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> {
|
package/src/transports/types.ts
CHANGED
|
@@ -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
|
|
57
|
+
notify(chatIds: number[], text: string): Promise<void>;
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Create a thread object for a given chat ID.
|