@friendlyrobot/discord-pi-agent 0.21.0 → 0.21.3

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/dist/logger.js ADDED
@@ -0,0 +1,26 @@
1
+ import pino from "pino";
2
+ const loggerLevel = process.env.DISCORD_PI_AGENT_LOG_LEVEL || process.env.LOG_LEVEL || "info";
3
+ const usePrettyTransport = process.stdout.isTTY;
4
+ const baseOptions = {
5
+ level: loggerLevel,
6
+ };
7
+ export const logger = usePrettyTransport
8
+ ? pino({
9
+ ...baseOptions,
10
+ transport: {
11
+ target: "pino-pretty",
12
+ options: {
13
+ colorize: true,
14
+ colorizeObjects: true,
15
+ levelFirst: true,
16
+ translateTime: "SYS:standard",
17
+ ignore: "pid,hostname",
18
+ singleLine: false,
19
+ messageFormat: "[{module}] {if direction}{direction} {end}{msg}",
20
+ },
21
+ },
22
+ })
23
+ : pino(baseOptions);
24
+ export function createModuleLogger(moduleName) {
25
+ return logger.child({ module: moduleName });
26
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Transforms markdown tables into Discord-friendly code blocks.
3
+ * Discord doesn't support markdown tables natively, so we:
4
+ * 1. Format the entire document with Prettier (tables get formatted)
5
+ * 2. Use marked Lexer to identify tables
6
+ * 3. Wrap tables in triple backticks
7
+ * 4. Run Prettier once more on the entire result (tables stay formatted in code blocks)
8
+ */
9
+ import { Lexer } from "marked";
10
+ import { createModuleLogger } from "./logger";
11
+ const logger = createModuleLogger("markdown-table-transformer");
12
+ const CODE_BLOCK_WRAPPER = "```\n{TABLE}\n```";
13
+ /**
14
+ * Transforms markdown tables in the text to Discord-friendly code blocks.
15
+ * Uses marked's Lexer to properly identify tables.
16
+ */
17
+ export async function transformMarkdownTablesToCodeBlocks(text) {
18
+ // Ensure code fences are on their own lines.
19
+ // AI responses sometimes place ``` at the end of a text line
20
+ // (e.g. "some text.```"). This breaks CommonMark parsing — marked
21
+ // doesn't see it as a fence, and Prettier later "fixes" the resulting
22
+ // unbalanced blocks by adding spurious closing ```.
23
+ const normalized = normalizeCodeFences(text);
24
+ // Format the whole document first (tables are still markdown, so they get formatted)
25
+ const formatted = await formatWithPrettier(normalized);
26
+ // Use marked Lexer to identify tables
27
+ const tokens = Lexer.lex(formatted);
28
+ const result = [];
29
+ for (const token of tokens) {
30
+ if (token.type === "table") {
31
+ result.push(CODE_BLOCK_WRAPPER.replace("{TABLE}", token.raw.trimEnd()));
32
+ }
33
+ else {
34
+ result.push(token.raw);
35
+ }
36
+ }
37
+ // Run Prettier once more (tables are now in code blocks, won't be reformatted)
38
+ return formatWithPrettier(result.join(""));
39
+ }
40
+ /**
41
+ * Normalizes misplaced code fences so CommonMark parsers handle them correctly.
42
+ * AI output often has ``` in positions that Discord renders fine but break
43
+ * standard markdown parsing:
44
+ *
45
+ * 1. "some text.```" → fence at end of text line → split to own line
46
+ * 2. "```some text" → fence at start with text after → split to own line
47
+ *
48
+ * Without this, Prettier may "fix" unbalanced fences by adding spurious ```.
49
+ */
50
+ function normalizeCodeFences(text) {
51
+ const lines = text.split("\n");
52
+ const result = [];
53
+ for (const line of lines) {
54
+ const trimmed = line.trimEnd();
55
+ // Case 1: fence at end of non-fence line (e.g., "hello world.```")
56
+ if (trimmed.endsWith("```") && !trimmed.startsWith("```")) {
57
+ const beforeFence = trimmed.slice(0, -3).trimEnd();
58
+ if (beforeFence) {
59
+ result.push(beforeFence);
60
+ }
61
+ result.push("```");
62
+ // Case 2: fence at start with trailing text that is NOT a valid info string.
63
+ // "```java" → keep as-is (info string). "```修正了" → split (prose text).
64
+ }
65
+ else if (trimmed.startsWith("```") && !isValidFenceLine(trimmed)) {
66
+ result.push("```");
67
+ const afterFence = trimmed.slice(3).trimStart();
68
+ if (afterFence) {
69
+ result.push(afterFence);
70
+ }
71
+ }
72
+ else {
73
+ result.push(line);
74
+ }
75
+ }
76
+ return result.join("\n");
77
+ }
78
+ /**
79
+ * Returns true if the line looks like a valid CommonMark code fence.
80
+ *
81
+ * Valid fences:
82
+ * ``` (bare fence)
83
+ * ```java (info string)
84
+ * ```c++ (unusual but valid info string)
85
+ * ```diff:ts (colon-separated directive)
86
+ *
87
+ * We only split when the content after ``` looks like prose —
88
+ * starts with a non-ASCII character (e.g., Chinese text),
89
+ * or the first "word" has multiple space-separated pieces.
90
+ */
91
+ function isValidFenceLine(line) {
92
+ const trimmed = line.trimEnd();
93
+ // Bare fence: ```
94
+ if (trimmed === "```") {
95
+ return true;
96
+ }
97
+ const afterFence = trimmed.slice(3);
98
+ if (!afterFence) {
99
+ return true;
100
+ }
101
+ // If it starts with a non-ASCII character, it's prose (e.g. ```修正了)
102
+ // ASCII range: 0x00-0x7F
103
+ if (afterFence.codePointAt(0) > 0x7f) {
104
+ return false;
105
+ }
106
+ // Info strings are a single non-whitespace token per CommonMark spec.
107
+ // If there's more than one token, it's prose (e.g. ```this is prose).
108
+ // Trailing whitespace like "```java " is fine — trimEnd already handled.
109
+ const tokens = afterFence.trimStart().split(/\s+/);
110
+ if (tokens.length > 1) {
111
+ return false;
112
+ }
113
+ // The token must not contain a backtick (would break the fence)
114
+ if (tokens[0].includes("`")) {
115
+ return false;
116
+ }
117
+ return true;
118
+ }
119
+ /**
120
+ * Formats markdown text using Prettier.
121
+ * Logs on failure.
122
+ */
123
+ async function formatWithPrettier(text) {
124
+ const prettier = await import("prettier");
125
+ try {
126
+ const formatted = await prettier.format(text, {
127
+ parser: "markdown",
128
+ printWidth: 80,
129
+ });
130
+ return formatted.trim();
131
+ }
132
+ catch (error) {
133
+ logger.error({
134
+ error,
135
+ }, "Prettier formatting failed");
136
+ return text;
137
+ }
138
+ }
@@ -0,0 +1,87 @@
1
+ import { createModuleLogger } from "./logger";
2
+ const logger = createModuleLogger("media-description");
3
+ /**
4
+ * Use a vision-capable model to describe a media attachment (image or PDF),
5
+ * returning a text description that can be inlined into a prompt for a
6
+ * non-vision model.
7
+ *
8
+ * Creates a temporary in-memory session, sends the media, extracts the
9
+ * assistant's text reply, then disposes the session.
10
+ */
11
+ export async function describeMediaAttachment(agentService, imageData, mimeType, userText, visionModel) {
12
+ const session = await agentService.createTemporarySession();
13
+ await session.setModel(visionModel);
14
+ const mediaType = getMediaType(mimeType);
15
+ const imageContent = {
16
+ type: "image",
17
+ data: imageData,
18
+ mimeType,
19
+ };
20
+ let promptText;
21
+ if (mediaType === "document") {
22
+ promptText =
23
+ userText.trim().length > 0
24
+ ? `The user sent a document with the following message: "${userText}". Please extract and summarize the text content of this document. Be thorough — include all important details, sections, and data from the document.`
25
+ : "Please extract and summarize the text content of this document. Be thorough — include all important details, sections, data, and key points.";
26
+ }
27
+ else {
28
+ promptText =
29
+ userText.trim().length > 0
30
+ ? `The user sent this image with the following message: "${userText}". Please describe the image in detail and address any questions from the user's message.`
31
+ : "Please describe this image in detail. What do you see?";
32
+ }
33
+ let text = "";
34
+ try {
35
+ await session.prompt(promptText, { images: [imageContent] });
36
+ text = extractLastAssistantText(session);
37
+ }
38
+ catch (error) {
39
+ logger.error({ error, mimeType }, "vision model prompt failed");
40
+ text = "(Vision model failed to process the file.)";
41
+ }
42
+ finally {
43
+ session.dispose();
44
+ }
45
+ if (!text) {
46
+ return "(Vision model returned no description.)";
47
+ }
48
+ logger.debug({ textLength: text.length, mimeType }, "media described");
49
+ return text;
50
+ }
51
+ function extractLastAssistantText(session) {
52
+ const messages = session.messages;
53
+ for (let i = messages.length - 1; i >= 0; i--) {
54
+ const msg = messages[i];
55
+ if (!msg || !isAssistantMessage(msg)) {
56
+ continue;
57
+ }
58
+ const content = msg.content;
59
+ if (!Array.isArray(content)) {
60
+ continue;
61
+ }
62
+ const textBlocks = [];
63
+ for (const item of content) {
64
+ if (typeof item === "object" &&
65
+ item !== null &&
66
+ "type" in item &&
67
+ item.type === "text") {
68
+ textBlocks.push(item.text);
69
+ }
70
+ }
71
+ return textBlocks.join("\n").trim();
72
+ }
73
+ return "";
74
+ }
75
+ function getMediaType(mimeType) {
76
+ if (mimeType.startsWith("image/")) {
77
+ return "image";
78
+ }
79
+ // PDF, Word, PowerPoint — all treated as documents for prompting.
80
+ return "document";
81
+ }
82
+ function isAssistantMessage(msg) {
83
+ return (typeof msg === "object" &&
84
+ msg !== null &&
85
+ "role" in msg &&
86
+ msg.role === "assistant");
87
+ }
@@ -0,0 +1,38 @@
1
+ import { marked } from "marked";
2
+ const DISCORD_MESSAGE_LIMIT = 2000;
3
+ const SAFE_MESSAGE_LIMIT = 1900;
4
+ /**
5
+ * Chunk markdown text safely, preserving structural integrity of
6
+ * code blocks, tables, lists, and other block-level elements.
7
+ * Uses marked's lexer to split on token boundaries so no element
8
+ * gets bisected mid-structure.
9
+ */
10
+ export function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
11
+ if (text.length <= maxChunkSize) {
12
+ return [text];
13
+ }
14
+ const tokens = marked.lexer(text);
15
+ const chunks = [];
16
+ let currentTokens = [];
17
+ let currentSize = 0;
18
+ const flushChunk = () => {
19
+ if (currentTokens.length > 0) {
20
+ chunks.push(currentTokens
21
+ .map((t) => t.raw)
22
+ .join("")
23
+ .trim());
24
+ currentTokens = [];
25
+ currentSize = 0;
26
+ }
27
+ };
28
+ for (const token of tokens) {
29
+ const size = token.raw.length;
30
+ if (currentSize + size > maxChunkSize && currentTokens.length > 0) {
31
+ flushChunk();
32
+ }
33
+ currentTokens.push(token);
34
+ currentSize += size;
35
+ }
36
+ flushChunk();
37
+ return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
38
+ }
@@ -2,17 +2,6 @@ export type DiscordPromptTimeFormatOptions = {
2
2
  timeZone?: string;
3
3
  locale?: string;
4
4
  };
5
- export type DiscordPromptScope = "dm" | "thread";
6
- export type DiscordMessageContextPromptOptions = {
7
- scope: DiscordPromptScope;
8
- messageId: string;
9
- authorId: string;
10
- sentAt?: string;
11
- sentAtLocal?: string;
12
- authorName?: string;
13
- threadId?: string;
14
- threadTitle?: string;
15
- forumChannelId?: string | null;
16
- };
17
- export declare function buildDiscordMessageContextPrompt(userMessage: string, options: DiscordMessageContextPromptOptions): string;
18
5
  export declare function formatDiscordPromptTime(date: Date, options?: DiscordPromptTimeFormatOptions): string;
6
+ /** Wrap content in an XML-style tag: `<tag>content</tag>`. */
7
+ export declare function wrapXmlTag(tag: string, content: string): string;
@@ -0,0 +1,19 @@
1
+ export function formatDiscordPromptTime(date, options = {}) {
2
+ const timeZone = options.timeZone || "UTC";
3
+ const locale = options.locale || "en-AU";
4
+ return new Intl.DateTimeFormat(locale, {
5
+ timeZone,
6
+ weekday: "short",
7
+ day: "numeric",
8
+ month: "short",
9
+ year: "2-digit",
10
+ hour: "2-digit",
11
+ minute: "2-digit",
12
+ hour12: false,
13
+ timeZoneName: "short",
14
+ }).format(date);
15
+ }
16
+ /** Wrap content in an XML-style tag: `<tag>content</tag>`. */
17
+ export function wrapXmlTag(tag, content) {
18
+ return `<${tag}>${content}</${tag}>`;
19
+ }
@@ -0,0 +1,37 @@
1
+ export class PromptQueue {
2
+ queue = [];
3
+ running = false;
4
+ enqueue(task) {
5
+ return new Promise((resolve, reject) => {
6
+ this.queue.push(async () => {
7
+ try {
8
+ resolve(await task());
9
+ }
10
+ catch (error) {
11
+ reject(error);
12
+ }
13
+ });
14
+ this.runNextTask();
15
+ });
16
+ }
17
+ getSnapshot() {
18
+ return {
19
+ pending: this.queue.length,
20
+ busy: this.running,
21
+ };
22
+ }
23
+ runNextTask() {
24
+ if (this.running) {
25
+ return;
26
+ }
27
+ const next = this.queue.shift();
28
+ if (!next) {
29
+ return;
30
+ }
31
+ this.running = true;
32
+ void next().finally(() => {
33
+ this.running = false;
34
+ this.runNextTask();
35
+ });
36
+ }
37
+ }
@@ -0,0 +1,281 @@
1
+ import { sessionDirForScope } from "./session-registry";
2
+ function getSessionStatusText(session, promptQueue, extras) {
3
+ const model = session.model
4
+ ? `${session.model.provider}/${session.model.id}`
5
+ : "(no model selected)";
6
+ const contextUsage = session.getContextUsage();
7
+ const contextLine = contextUsage
8
+ ? contextUsage.tokens === null || contextUsage.percent === null
9
+ ? `context: ?/${contextUsage.contextWindow}`
10
+ : `context: ${contextUsage.tokens}/${contextUsage.contextWindow} (${Math.round(contextUsage.percent)}%)`
11
+ : "context: (unavailable)";
12
+ const thinkingInfo = session.supportsThinking()
13
+ ? `thinking: ${session.thinkingLevel} (available: ${session.getAvailableThinkingLevels().join(", ")})`
14
+ : "thinking: not supported";
15
+ const queueStatus = promptQueue.getSnapshot();
16
+ const lines = [
17
+ `model: ${model}`,
18
+ `session-id: ${session.sessionId}`,
19
+ `session-file: ${session.sessionFile ?? "(none)"}`,
20
+ `streaming: ${session.isStreaming}`,
21
+ thinkingInfo,
22
+ contextLine,
23
+ `queue-pending: ${queueStatus.pending}`,
24
+ `queue-busy: ${queueStatus.busy}`,
25
+ ];
26
+ if (extras?.tools && extras.tools.length > 0) {
27
+ const toolNames = extras.tools.map((tool) => tool.name);
28
+ lines.push("", `Tools (${extras.tools.length}): ${toolNames.join(", ")}`);
29
+ }
30
+ if (extras?.skillsSummary) {
31
+ lines.push("", extras.skillsSummary);
32
+ }
33
+ if (extras?.extensionsSummary) {
34
+ lines.push("", extras.extensionsSummary);
35
+ }
36
+ return lines.join("\n");
37
+ }
38
+ function getEffectiveSession(context) {
39
+ return context.session ?? context.agentService.getSession();
40
+ }
41
+ function requireEffectiveSession(context) {
42
+ const session = getEffectiveSession(context);
43
+ if (!session) {
44
+ return {
45
+ handled: true,
46
+ response: "No active session.",
47
+ };
48
+ }
49
+ return { session };
50
+ }
51
+ async function handleHelpCommand(trimmedInput, context) {
52
+ if (trimmedInput !== "!help") {
53
+ return null;
54
+ }
55
+ const extraCommands = context.session
56
+ ? "\n!archive - archive this thread and end the session"
57
+ : "";
58
+ return {
59
+ handled: true,
60
+ response: [
61
+ "Commands:",
62
+ "!help - show this message",
63
+ "!status - show current session status",
64
+ "!thinking - show or set thinking/reasoning level",
65
+ "!model - list available models or switch to one",
66
+ "!compact - compact the persistent session",
67
+ "!reset-session - start a fresh persistent session",
68
+ "!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
69
+ "!reaction - show or set the working reaction emoji",
70
+ extraCommands,
71
+ "Any other text goes to the agent session.",
72
+ ]
73
+ .filter(Boolean)
74
+ .join("\n"),
75
+ };
76
+ }
77
+ async function handleArchiveCommand(trimmedInput, context) {
78
+ if (trimmedInput !== "!archive") {
79
+ return null;
80
+ }
81
+ if (!context.session) {
82
+ return {
83
+ handled: true,
84
+ response: "!archive is only available in forum threads.",
85
+ };
86
+ }
87
+ return {
88
+ handled: true,
89
+ archive: true,
90
+ response: "Archiving thread and shutting down session.",
91
+ };
92
+ }
93
+ async function handleStatusCommand(trimmedInput, context) {
94
+ if (trimmedInput !== "!status") {
95
+ return null;
96
+ }
97
+ const effectiveSession = requireEffectiveSession(context);
98
+ if ("handled" in effectiveSession) {
99
+ return effectiveSession;
100
+ }
101
+ const tools = effectiveSession.session.getAllTools();
102
+ const extensionsSummary = context.agentService.resources.getExtensionsSummary();
103
+ const skillsSummary = context.agentService.resources.getSkillsSummary();
104
+ return {
105
+ handled: true,
106
+ response: getSessionStatusText(effectiveSession.session, context.promptQueue, {
107
+ tools,
108
+ extensionsSummary,
109
+ skillsSummary,
110
+ }),
111
+ };
112
+ }
113
+ async function handleThinkingCommand(trimmedInput, context) {
114
+ if (trimmedInput !== "!thinking" && !trimmedInput.startsWith("!thinking ")) {
115
+ return null;
116
+ }
117
+ const effectiveSession = requireEffectiveSession(context);
118
+ if ("handled" in effectiveSession) {
119
+ return effectiveSession;
120
+ }
121
+ const parts = trimmedInput.split(" ");
122
+ if (parts.length === 1) {
123
+ const info = context.agentService.models.getThinkingLevel(effectiveSession.session);
124
+ if (!info.supported) {
125
+ return {
126
+ handled: true,
127
+ response: "Current model does not support reasoning/thinking.",
128
+ };
129
+ }
130
+ return {
131
+ handled: true,
132
+ response: [
133
+ `Current: ${info.current}`,
134
+ `Available: ${info.available.join(", ")}`,
135
+ `Usage: !thinking <level>`,
136
+ ].join("\n"),
137
+ };
138
+ }
139
+ const requestedLevel = parts[1];
140
+ return {
141
+ handled: true,
142
+ response: context.agentService.models.setThinkingLevel(effectiveSession.session, requestedLevel),
143
+ };
144
+ }
145
+ async function handleModelCommand(trimmedInput, context) {
146
+ if (trimmedInput !== "!model" && !trimmedInput.startsWith("!model ")) {
147
+ return null;
148
+ }
149
+ const effectiveSession = requireEffectiveSession(context);
150
+ if ("handled" in effectiveSession) {
151
+ return effectiveSession;
152
+ }
153
+ const parts = trimmedInput.split(" ");
154
+ if (parts.length === 1) {
155
+ const current = context.agentService.models.getCurrentModelDisplay(effectiveSession.session);
156
+ const modelList = await context.agentService.models.listModels(effectiveSession.session);
157
+ return {
158
+ handled: true,
159
+ response: `Current model: ${current}\n\n${modelList}`,
160
+ };
161
+ }
162
+ const argument = parts.slice(1).join(" ");
163
+ const slashIndex = argument.indexOf("/");
164
+ if (slashIndex === -1) {
165
+ return {
166
+ handled: true,
167
+ response: "Usage: !model <provider/modelId>\n" +
168
+ "Example: !model openrouter/anthropic/claude-sonnet-4\n" +
169
+ "Use !model without args to see available models.",
170
+ };
171
+ }
172
+ const provider = argument.substring(0, slashIndex).trim();
173
+ const modelId = argument.substring(slashIndex + 1).trim();
174
+ return {
175
+ handled: true,
176
+ response: await context.agentService.models.switchModel(provider, modelId, effectiveSession.session),
177
+ };
178
+ }
179
+ async function handleCompactCommand(trimmedInput, context) {
180
+ if (trimmedInput !== "!compact") {
181
+ return null;
182
+ }
183
+ const effectiveSession = requireEffectiveSession(context);
184
+ if ("handled" in effectiveSession) {
185
+ return effectiveSession;
186
+ }
187
+ return {
188
+ handled: true,
189
+ response: await context.promptQueue.enqueue(async () => {
190
+ await effectiveSession.session.compact();
191
+ return `Compaction finished for session ${effectiveSession.session.sessionId}.`;
192
+ }),
193
+ };
194
+ }
195
+ async function handleReloadCommand(trimmedInput, context) {
196
+ if (trimmedInput !== "!reload") {
197
+ return null;
198
+ }
199
+ return {
200
+ handled: true,
201
+ response: await context.promptQueue.enqueue(async () => {
202
+ return context.agentService.resources.reloadResources();
203
+ }),
204
+ };
205
+ }
206
+ async function handleResetSessionCommand(trimmedInput, context) {
207
+ if (trimmedInput !== "!reset-session") {
208
+ return null;
209
+ }
210
+ const effectiveSession = requireEffectiveSession(context);
211
+ if ("handled" in effectiveSession) {
212
+ return effectiveSession;
213
+ }
214
+ let newSession;
215
+ const response = await context.promptQueue.enqueue(async () => {
216
+ const previousSessionFile = effectiveSession.session.sessionFile;
217
+ await effectiveSession.session.abort();
218
+ effectiveSession.session.dispose();
219
+ const sessionDir = sessionDirForScope(context.agentService.getAgentDir(), context.scope);
220
+ newSession = await context.agentService.createSession(sessionDir);
221
+ return `Started a fresh session. Old session kept at ${previousSessionFile ?? "(unknown path)"}.`;
222
+ });
223
+ return {
224
+ handled: true,
225
+ response,
226
+ newSession,
227
+ };
228
+ }
229
+ async function handleReactionCommand(trimmedInput, _context) {
230
+ if (trimmedInput !== "!reaction" && !trimmedInput.startsWith("!reaction ")) {
231
+ return null;
232
+ }
233
+ const parts = trimmedInput.split(" ");
234
+ if (parts.length === 1) {
235
+ return {
236
+ handled: true,
237
+ response: `Current working reaction: ${_context.workingEmoji}\n` +
238
+ `Usage: !reaction <emoji> to change it.\n` +
239
+ `Examples: !reaction 🔄 or !reaction ⏳`,
240
+ };
241
+ }
242
+ const emoji = parts.slice(1).join(" ").trim();
243
+ if (!emoji) {
244
+ return {
245
+ handled: true,
246
+ response: "Please provide an emoji. Example: !reaction 🔄",
247
+ };
248
+ }
249
+ return {
250
+ handled: true,
251
+ workingEmoji: emoji,
252
+ response: `Working reaction emoji set to ${emoji}`,
253
+ };
254
+ }
255
+ const commandHandlers = [
256
+ handleHelpCommand,
257
+ handleArchiveCommand,
258
+ handleStatusCommand,
259
+ handleThinkingCommand,
260
+ handleModelCommand,
261
+ handleCompactCommand,
262
+ handleReloadCommand,
263
+ handleResetSessionCommand,
264
+ handleReactionCommand,
265
+ ];
266
+ export async function executeSessionCommand(input, context) {
267
+ const trimmedInput = input.trim();
268
+ if (!trimmedInput.startsWith("!")) {
269
+ return { handled: false };
270
+ }
271
+ for (const handler of commandHandlers) {
272
+ const result = await handler(trimmedInput, context);
273
+ if (result) {
274
+ return result;
275
+ }
276
+ }
277
+ return {
278
+ handled: true,
279
+ response: `Unknown command: ${trimmedInput}. Try !help.`,
280
+ };
281
+ }