@fickydev/pigent 0.1.7 → 0.1.8

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
@@ -43,6 +43,8 @@
43
43
  - Planned model selection support across profile, agent, and Telegram chat override levels.
44
44
  - Added profile and agent config-based Pi model and thinking level selection.
45
45
  - Added session-scoped model and thinking level overrides with `/model` and `/thinking` Telegram commands.
46
+ - Planned Telegram inline-button model and thinking level pickers backed by configured model choices.
47
+ - Added Telegram inline-button pickers for `/model` and `/thinking`, callback handling, and bot command menu registration.
46
48
  - Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
47
49
 
48
50
  ### Changed
package/PLAN.md CHANGED
@@ -206,7 +206,27 @@ Telegram command support should persist session-scoped overrides after config su
206
206
  - `/thinking <level>` sets thinking level for the default agent session in the current chat/thread
207
207
  - `/thinking default` clears thinking override for the default agent session in the current chat/thread
208
208
 
209
- The command handler should validate agent access and model availability through a Pi model resolver. It must not reveal API keys, auth values, raw environment variables, or provider secrets.
209
+ Telegram should also provide inline-button pickers so non-technical users can choose from configured choices:
210
+
211
+ - `/model` can reply with inline keyboard buttons for configured model choices plus `Use default`
212
+ - `/thinking` can reply with inline keyboard buttons for `Off`, `Low`, `Medium`, `High`, and `Use default`
213
+ - callback payloads should be short and action-scoped, such as `model:set:<choiceId>`, `model:default`, `thinking:set:medium`, and `thinking:default`
214
+ - after selection, bot should answer the callback and update or send a confirmation message
215
+ - daemon startup should register Telegram bot commands through `setMyCommands` so users see Pigent commands in the Telegram command menu
216
+
217
+ Model choices should be explicitly configured instead of exposing every Pi model blindly:
218
+
219
+ ```yaml
220
+ modelChoices:
221
+ - id: anthropic/claude-sonnet-4-5
222
+ label: Claude Sonnet
223
+ - id: anthropic/claude-opus-4-5
224
+ label: Claude Opus
225
+ - id: openai/gpt-4.1
226
+ label: GPT-4.1
227
+ ```
228
+
229
+ The command and callback handlers should validate agent access and model availability through a Pi model resolver. They must not reveal API keys, auth values, raw environment variables, or provider secrets.
210
230
 
211
231
  ## Prompt Composition
212
232
 
package/TODO.md CHANGED
@@ -39,6 +39,7 @@
39
39
 
40
40
  ## Config
41
41
 
42
+ - [x] Add root `modelChoices` config for Telegram model picker buttons
42
43
  - [x] Add profile-level `thinkingLevel` config
43
44
  - [x] Add agent-level `model` override config
44
45
  - [x] Add agent-level `thinkingLevel` override config
@@ -84,11 +85,15 @@
84
85
  - [x] Define `OutboundMessage`
85
86
  - [x] Define `ChannelAdapter`
86
87
  - [x] Define Telegram normalized types
88
+ - [x] Normalize Telegram callback query updates
87
89
  - [ ] Implement `TelegramApi`
88
90
  - [x] `getUpdates`
89
91
  - [x] `sendMessage`
90
92
  - [x] error handling
91
93
  - [x] retry/backoff
94
+ - [x] `answerCallbackQuery`
95
+ - [x] inline keyboard `reply_markup` support
96
+ - [x] `setMyCommands` Telegram command menu registration
92
97
  - [x] Implement `TelegramPollingAdapter`
93
98
  - [x] polling loop
94
99
  - [x] offset tracking
@@ -179,6 +184,11 @@
179
184
  - [x] `/thinking` show thinking level for default chat agent session
180
185
  - [x] `/thinking <level>` set thinking level for default chat agent session
181
186
  - [x] `/thinking default` clear thinking level for default chat agent session
187
+ - [x] `/model` inline button picker for configured model choices
188
+ - [x] `/thinking` inline button picker
189
+ - [x] Handle model picker callback and persist selected session model
190
+ - [x] Handle thinking picker callback and persist selected session thinking level
191
+ - [x] Add `Use default` model/thinking buttons
182
192
  - [ ] `/model <agentId> <provider/modelId>` set model for an explicit agent session
183
193
  - [ ] `/thinking <agentId> <level>` set thinking level for an explicit agent session
184
194
  - [x] `/help` show bot commands
@@ -192,6 +202,8 @@
192
202
  - [ ] Unit test heartbeat `NOOP` behavior
193
203
  - [ ] Unit test model selection priority
194
204
  - [ ] Unit test Telegram `/model` command parsing
205
+ - [ ] Unit test Telegram model picker callback parsing
206
+ - [ ] Unit test Telegram thinking picker callback parsing
195
207
  - [ ] Repository tests against temp SQLite DB
196
208
  - [ ] Integration test fake Telegram update to fake Pi runner
197
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
package/pigent.yaml CHANGED
@@ -1,5 +1,14 @@
1
1
  telegramChats: []
2
+ modelChoices: []
2
3
 
4
+ # Add choices to show buttons for /model.
5
+ #
6
+ # modelChoices:
7
+ # - id: anthropic/claude-sonnet-4-5
8
+ # label: Claude Sonnet
9
+ # - id: anthropic/claude-opus-4-5
10
+ # label: Claude Opus
11
+ #
3
12
  # Replace chatId with a real Telegram chat id to enable routing.
4
13
  #
5
14
  # telegramChats:
@@ -1,4 +1,5 @@
1
- import type { InboundMessage } from "../channels/types";
1
+ import type { InboundMessage, InlineKeyboardButton } from "../channels/types";
2
+ import type { ModelChoiceConfig } from "../config/schemas";
2
3
  import type { Repositories } from "../db/repositories";
3
4
  import { isValidModelRef } from "../pi/PiModelResolver";
4
5
  import type { AgentRegistry } from "./AgentRegistry";
@@ -7,6 +8,7 @@ export type BotCommandResult =
7
8
  | {
8
9
  handled: true;
9
10
  text: string;
11
+ inlineKeyboard?: InlineKeyboardButton[][];
10
12
  }
11
13
  | {
12
14
  handled: false;
@@ -19,6 +21,7 @@ export class BotCommandHandler {
19
21
  constructor(
20
22
  private readonly registry: AgentRegistry,
21
23
  private readonly repositories: Repositories,
24
+ private readonly modelChoices: ModelChoiceConfig[] = [],
22
25
  ) {}
23
26
 
24
27
  async handle(message: InboundMessage): Promise<BotCommandResult> {
@@ -31,9 +34,17 @@ export class BotCommandHandler {
31
34
  case "/agents":
32
35
  return { handled: true, text: await this.agentsText(message) };
33
36
  case "/model":
34
- return { handled: true, text: await this.modelText(message) };
37
+ return await this.modelResult(message);
35
38
  case "/thinking":
36
- return { handled: true, text: await this.thinkingText(message) };
39
+ return await this.thinkingResult(message);
40
+ case "/model:set":
41
+ return { handled: true, text: await this.setModelFromChoice(message) };
42
+ case "/model:default":
43
+ return { handled: true, text: await this.clearModel(message) };
44
+ case "/thinking:set":
45
+ return { handled: true, text: await this.setThinkingFromCallback(message) };
46
+ case "/thinking:default":
47
+ return { handled: true, text: await this.clearThinking(message) };
37
48
  default:
38
49
  return { handled: false };
39
50
  }
@@ -44,10 +55,10 @@ export class BotCommandHandler {
44
55
  "Pigent commands:",
45
56
  "/help - show this help",
46
57
  "/agents - list agents for this chat",
47
- "/model - show model for this chat session",
58
+ "/model - show model picker for this chat session",
48
59
  "/model <provider/modelId> - set model for this chat session",
49
60
  "/model default - clear model override for this chat session",
50
- "/thinking - show thinking level for this chat session",
61
+ "/thinking - show thinking picker for this chat session",
51
62
  "/thinking <off|low|medium|high> - set thinking level for this chat session",
52
63
  "/thinking default - clear thinking override for this chat session",
53
64
  "/agent <agentId> <message> - send message to specific agent",
@@ -77,56 +88,97 @@ export class BotCommandHandler {
77
88
  ].join("\n");
78
89
  }
79
90
 
80
- private async modelText(message: InboundMessage): Promise<string> {
91
+ private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
81
92
  const sessionResult = await this.getDefaultSession(message);
82
- if (!sessionResult.ok) return sessionResult.message;
93
+ if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
83
94
 
84
95
  const [, ...args] = message.text.trim().split(/\s+/);
85
96
  const value = args.join(" ").trim();
86
97
 
87
98
  if (!value) {
88
- return [
89
- `Agent: ${sessionResult.agentId}`,
90
- `Session model: ${sessionResult.session.model ?? "default"}`,
91
- "Use /model <provider/modelId> to set a session model.",
92
- "Use /model default to clear the session model override.",
93
- ].join("\n");
99
+ return {
100
+ handled: true,
101
+ text: [
102
+ `Agent: ${sessionResult.agentId}`,
103
+ `Session model: ${sessionResult.session.model ?? "default"}`,
104
+ this.modelChoices.length > 0
105
+ ? "Choose a model:"
106
+ : "No model choices configured. Use /model <provider/modelId> to set manually.",
107
+ ].join("\n"),
108
+ inlineKeyboard: this.modelChoices.length > 0 ? this.modelKeyboard() : undefined,
109
+ };
94
110
  }
95
111
 
96
112
  if (value === "default") {
97
- const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
98
- return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
113
+ return { handled: true, text: await this.clearModel(message) };
99
114
  }
100
115
 
101
116
  if (!isValidModelRef(value)) {
102
- return "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5";
117
+ return { handled: true, text: "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5" };
103
118
  }
104
119
 
105
120
  const session = await this.repositories.sessions.updateModel(sessionResult.session.id, value);
106
- return `Session model for ${session.agentId} set to ${session.model}.`;
121
+ return { handled: true, text: `Session model for ${session.agentId} set to ${session.model}.` };
107
122
  }
108
123
 
109
- private async thinkingText(message: InboundMessage): Promise<string> {
124
+ private async thinkingResult(message: InboundMessage): Promise<BotCommandResult> {
110
125
  const sessionResult = await this.getDefaultSession(message);
111
- if (!sessionResult.ok) return sessionResult.message;
126
+ if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
112
127
 
113
128
  const [, rawValue] = message.text.trim().split(/\s+/, 2);
114
129
  const value = rawValue?.trim();
115
130
 
116
131
  if (!value) {
117
- return [
118
- `Agent: ${sessionResult.agentId}`,
119
- `Session thinking level: ${sessionResult.session.thinkingLevel ?? "default"}`,
120
- "Use /thinking <off|low|medium|high> to set a session thinking level.",
121
- "Use /thinking default to clear the session thinking override.",
122
- ].join("\n");
132
+ return {
133
+ handled: true,
134
+ text: [
135
+ `Agent: ${sessionResult.agentId}`,
136
+ `Session thinking level: ${sessionResult.session.thinkingLevel ?? "default"}`,
137
+ "Choose thinking level:",
138
+ ].join("\n"),
139
+ inlineKeyboard: this.thinkingKeyboard(),
140
+ };
123
141
  }
124
142
 
125
143
  if (value === "default") {
126
- const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
127
- return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
144
+ return { handled: true, text: await this.clearThinking(message) };
145
+ }
146
+
147
+ if (!isThinkingLevel(value)) {
148
+ return { handled: true, text: "Invalid thinking level. Use one of: off, low, medium, high, default" };
128
149
  }
129
150
 
151
+ const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
152
+ return { handled: true, text: `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.` };
153
+ }
154
+
155
+ private async setModelFromChoice(message: InboundMessage): Promise<string> {
156
+ const sessionResult = await this.getDefaultSession(message);
157
+ if (!sessionResult.ok) return sessionResult.message;
158
+
159
+ const choiceIndex = Number(message.text.slice("/model:set:".length));
160
+ if (!Number.isSafeInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= this.modelChoices.length) {
161
+ return "Unknown model choice.";
162
+ }
163
+
164
+ const choice = this.modelChoices[choiceIndex];
165
+ const session = await this.repositories.sessions.updateModel(sessionResult.session.id, choice.id);
166
+ return `Session model for ${session.agentId} set to ${choice.label} (${session.model}).`;
167
+ }
168
+
169
+ private async clearModel(message: InboundMessage): Promise<string> {
170
+ const sessionResult = await this.getDefaultSession(message);
171
+ if (!sessionResult.ok) return sessionResult.message;
172
+
173
+ const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
174
+ return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
175
+ }
176
+
177
+ private async setThinkingFromCallback(message: InboundMessage): Promise<string> {
178
+ const sessionResult = await this.getDefaultSession(message);
179
+ if (!sessionResult.ok) return sessionResult.message;
180
+
181
+ const value = message.text.slice("/thinking:set:".length);
130
182
  if (!isThinkingLevel(value)) {
131
183
  return "Invalid thinking level. Use one of: off, low, medium, high, default";
132
184
  }
@@ -135,6 +187,41 @@ export class BotCommandHandler {
135
187
  return `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.`;
136
188
  }
137
189
 
190
+ private async clearThinking(message: InboundMessage): Promise<string> {
191
+ const sessionResult = await this.getDefaultSession(message);
192
+ if (!sessionResult.ok) return sessionResult.message;
193
+
194
+ const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
195
+ return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
196
+ }
197
+
198
+ private modelKeyboard(): InlineKeyboardButton[][] {
199
+ const rows = chunk(
200
+ this.modelChoices.map((choice, index) => ({
201
+ text: choice.label,
202
+ callbackData: `model:set:${index}`,
203
+ })),
204
+ 2,
205
+ );
206
+
207
+ rows.push([{ text: "Use default", callbackData: "model:default" }]);
208
+ return rows;
209
+ }
210
+
211
+ private thinkingKeyboard(): InlineKeyboardButton[][] {
212
+ return [
213
+ [
214
+ { text: "Off", callbackData: "thinking:set:off" },
215
+ { text: "Low", callbackData: "thinking:set:low" },
216
+ ],
217
+ [
218
+ { text: "Medium", callbackData: "thinking:set:medium" },
219
+ { text: "High", callbackData: "thinking:set:high" },
220
+ ],
221
+ [{ text: "Use default", callbackData: "thinking:default" }],
222
+ ];
223
+ }
224
+
138
225
  private async getDefaultSession(message: InboundMessage) {
139
226
  if (message.channel !== "telegram") {
140
227
  return { ok: false as const, message: "Unsupported channel." };
@@ -175,3 +262,13 @@ export class BotCommandHandler {
175
262
  function isThinkingLevel(value: string): value is ThinkingLevel {
176
263
  return THINKING_LEVELS.includes(value as ThinkingLevel);
177
264
  }
265
+
266
+ function chunk<T>(items: T[], size: number): T[][] {
267
+ const rows: T[][] = [];
268
+
269
+ for (let index = 0; index < items.length; index += size) {
270
+ rows.push(items.slice(index, index + size));
271
+ }
272
+
273
+ return rows;
274
+ }
@@ -1,4 +1,13 @@
1
- import type { TelegramGetMeResponse, TelegramGetUpdatesResponse, TelegramSendMessageResponse, TelegramUpdate, TelegramUser } from "./types";
1
+ import type {
2
+ TelegramAnswerCallbackQueryResponse,
3
+ TelegramGetMeResponse,
4
+ TelegramGetUpdatesResponse,
5
+ TelegramSendMessageResponse,
6
+ TelegramSetMyCommandsResponse,
7
+ TelegramUpdate,
8
+ TelegramUser,
9
+ } from "./types";
10
+ import type { InlineKeyboardButton } from "../types";
2
11
 
3
12
  export type TelegramApiOptions = {
4
13
  token: string;
@@ -13,6 +22,11 @@ export type GetUpdatesOptions = {
13
22
  limit?: number;
14
23
  };
15
24
 
25
+ export type TelegramBotCommand = {
26
+ command: string;
27
+ description: string;
28
+ };
29
+
16
30
  type TelegramApiResponse<T> = T & {
17
31
  ok: boolean;
18
32
  description?: string;
@@ -43,18 +57,37 @@ export class TelegramApi {
43
57
  offset: options.offset,
44
58
  timeout: options.timeoutSeconds ?? 30,
45
59
  limit: options.limit ?? 50,
46
- allowed_updates: ["message"],
60
+ allowed_updates: ["message", "callback_query"],
47
61
  });
48
62
 
49
63
  return response.result;
50
64
  }
51
65
 
52
- async sendMessage(input: { chatId: string; text: string; threadId?: string | null }): Promise<void> {
66
+ async sendMessage(input: {
67
+ chatId: string;
68
+ text: string;
69
+ threadId?: string | null;
70
+ inlineKeyboard?: InlineKeyboardButton[][];
71
+ }): Promise<void> {
53
72
  await this.request<TelegramSendMessageResponse>("sendMessage", {
54
73
  chat_id: input.chatId,
55
74
  text: input.text,
56
75
  message_thread_id: input.threadId ? Number(input.threadId) : undefined,
57
76
  disable_web_page_preview: true,
77
+ reply_markup: input.inlineKeyboard ? toTelegramInlineKeyboard(input.inlineKeyboard) : undefined,
78
+ });
79
+ }
80
+
81
+ async answerCallbackQuery(input: { callbackQueryId: string; text?: string }): Promise<void> {
82
+ await this.request<TelegramAnswerCallbackQueryResponse>("answerCallbackQuery", {
83
+ callback_query_id: input.callbackQueryId,
84
+ text: input.text,
85
+ });
86
+ }
87
+
88
+ async setMyCommands(commands: TelegramBotCommand[]): Promise<void> {
89
+ await this.request<TelegramSetMyCommandsResponse>("setMyCommands", {
90
+ commands,
58
91
  });
59
92
  }
60
93
 
@@ -150,6 +183,17 @@ class TelegramApiError extends Error {
150
183
  }
151
184
  }
152
185
 
186
+ function toTelegramInlineKeyboard(inlineKeyboard: InlineKeyboardButton[][]) {
187
+ return {
188
+ inline_keyboard: inlineKeyboard.map((row) =>
189
+ row.map((button) => ({
190
+ text: button.text,
191
+ callback_data: button.callbackData,
192
+ })),
193
+ ),
194
+ };
195
+ }
196
+
153
197
  async function parseTelegramPayload<T extends { ok: boolean; description?: string }>(
154
198
  response: Response,
155
199
  ): Promise<TelegramApiResponse<T> | null> {
@@ -1,14 +1,15 @@
1
1
  import type { RuntimeKvRepository } from "../../db/repositories/RuntimeKvRepository";
2
2
  import { logger } from "../../logging/logger";
3
3
  import type { ChannelAdapter, InboundMessage, MessageHandler, OutboundMessage } from "../types";
4
- import { TelegramApi } from "./TelegramApi";
5
- import type { TelegramMessage, TelegramUpdate } from "./types";
4
+ import { TelegramApi, type TelegramBotCommand } from "./TelegramApi";
5
+ import type { TelegramCallbackQuery, TelegramMessage, TelegramUpdate } from "./types";
6
6
 
7
7
  export type TelegramPollingAdapterOptions = {
8
8
  api: TelegramApi;
9
9
  runtimeKv: RuntimeKvRepository;
10
10
  pollTimeoutSeconds?: number;
11
11
  pollIntervalMs?: number;
12
+ commands?: TelegramBotCommand[];
12
13
  };
13
14
 
14
15
  const offsetKey = "telegram:update_offset";
@@ -28,6 +29,10 @@ export class TelegramPollingAdapter implements ChannelAdapter {
28
29
  const botUser = await this.options.api.getMe();
29
30
  this.botUserId = String(botUser.id);
30
31
 
32
+ if (this.options.commands?.length) {
33
+ await this.options.api.setMyCommands(this.options.commands);
34
+ }
35
+
31
36
  this.running = true;
32
37
  this.loopPromise = this.poll(handler);
33
38
  logger.info("telegram polling started", {
@@ -54,6 +59,7 @@ export class TelegramPollingAdapter implements ChannelAdapter {
54
59
  chatId: message.chatId,
55
60
  threadId: message.threadId,
56
61
  text: message.text,
62
+ inlineKeyboard: message.inlineKeyboard,
57
63
  });
58
64
  }
59
65
 
@@ -80,6 +86,11 @@ export class TelegramPollingAdapter implements ChannelAdapter {
80
86
  }
81
87
 
82
88
  private async handleUpdate(update: TelegramUpdate, handler: MessageHandler): Promise<void> {
89
+ if (update.callback_query) {
90
+ await this.handleCallbackQuery(update.update_id, update.callback_query, handler);
91
+ return;
92
+ }
93
+
83
94
  const message = update.message;
84
95
  if (!message?.text) return;
85
96
  if (message.from?.is_bot) return;
@@ -89,6 +100,21 @@ export class TelegramPollingAdapter implements ChannelAdapter {
89
100
  await handler(normalized);
90
101
  }
91
102
 
103
+ private async handleCallbackQuery(
104
+ updateId: number,
105
+ callbackQuery: TelegramCallbackQuery,
106
+ handler: MessageHandler,
107
+ ): Promise<void> {
108
+ await this.options.api.answerCallbackQuery({ callbackQueryId: callbackQuery.id });
109
+
110
+ if (!callbackQuery.data || !callbackQuery.message) return;
111
+ if (callbackQuery.from.is_bot) return;
112
+ if (this.botUserId && String(callbackQuery.from.id) === this.botUserId) return;
113
+
114
+ const normalized = normalizeTelegramCallbackQuery(updateId, callbackQuery);
115
+ await handler(normalized);
116
+ }
117
+
92
118
  private async loadOffset(): Promise<number | undefined> {
93
119
  const value = await this.options.runtimeKv.get(offsetKey);
94
120
  if (!value) return undefined;
@@ -118,6 +144,23 @@ function normalizeTelegramMessage(updateId: number, message: TelegramMessage): I
118
144
  };
119
145
  }
120
146
 
147
+ function normalizeTelegramCallbackQuery(updateId: number, callbackQuery: TelegramCallbackQuery): InboundMessage {
148
+ const message = callbackQuery.message;
149
+ const senderName = [callbackQuery.from.first_name, callbackQuery.from.last_name].filter(Boolean).join(" ");
150
+
151
+ return {
152
+ id: String(updateId),
153
+ channel: "telegram",
154
+ chatId: String(message?.chat.id),
155
+ threadId: message?.message_thread_id ? String(message.message_thread_id) : null,
156
+ senderId: String(callbackQuery.from.id),
157
+ senderName: senderName || callbackQuery.from.username || null,
158
+ text: `/${callbackQuery.data}`,
159
+ raw: callbackQuery,
160
+ receivedAt: new Date(),
161
+ };
162
+ }
163
+
121
164
  function sleep(ms: number): Promise<void> {
122
165
  return new Promise((resolve) => setTimeout(resolve, ms));
123
166
  }
@@ -25,10 +25,18 @@ export type TelegramMessage = {
25
25
  text?: string;
26
26
  };
27
27
 
28
+ export type TelegramCallbackQuery = {
29
+ id: string;
30
+ from: TelegramUser;
31
+ message?: TelegramMessage;
32
+ data?: string;
33
+ };
34
+
28
35
  export type TelegramUpdate = {
29
36
  update_id: number;
30
37
  message?: TelegramMessage;
31
38
  edited_message?: TelegramMessage;
39
+ callback_query?: TelegramCallbackQuery;
32
40
  };
33
41
 
34
42
  export type TelegramGetUpdatesResponse = {
@@ -48,3 +56,15 @@ export type TelegramSendMessageResponse = {
48
56
  result?: TelegramMessage;
49
57
  description?: string;
50
58
  };
59
+
60
+ export type TelegramAnswerCallbackQueryResponse = {
61
+ ok: boolean;
62
+ result: boolean;
63
+ description?: string;
64
+ };
65
+
66
+ export type TelegramSetMyCommandsResponse = {
67
+ ok: boolean;
68
+ result: boolean;
69
+ description?: string;
70
+ };
@@ -12,11 +12,17 @@ export type InboundMessage = {
12
12
  receivedAt: Date;
13
13
  };
14
14
 
15
+ export type InlineKeyboardButton = {
16
+ text: string;
17
+ callbackData: string;
18
+ };
19
+
15
20
  export type OutboundMessage = {
16
21
  channel: ChannelId;
17
22
  chatId: string;
18
23
  threadId?: string | null;
19
24
  text: string;
25
+ inlineKeyboard?: InlineKeyboardButton[][];
20
26
  };
21
27
 
22
28
  export type MessageHandler = (message: InboundMessage) => Promise<void>;
@@ -111,5 +111,6 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<Loade
111
111
  agents,
112
112
  profiles,
113
113
  telegramChats: rootConfig.telegramChats,
114
+ modelChoices: rootConfig.modelChoices,
114
115
  };
115
116
  }
@@ -65,6 +65,11 @@ export const ProfileConfigSchema = z.object({
65
65
  }),
66
66
  });
67
67
 
68
+ export const ModelChoiceConfigSchema = z.object({
69
+ id: modelReferenceSchema,
70
+ label: z.string().min(1),
71
+ });
72
+
68
73
  export const TelegramChatConfigSchema = z.object({
69
74
  chatId: z.string().min(1),
70
75
  title: z.string().optional(),
@@ -76,6 +81,7 @@ export const TelegramChatConfigSchema = z.object({
76
81
 
77
82
  export const RootConfigSchema = z.object({
78
83
  telegramChats: z.array(TelegramChatConfigSchema).default([]),
84
+ modelChoices: z.array(ModelChoiceConfigSchema).default([]),
79
85
  });
80
86
 
81
87
  export type PermissionConfig = z.infer<typeof PermissionConfigSchema>;
@@ -83,6 +89,7 @@ export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
83
89
  export type AgentConfig = z.infer<typeof AgentConfigSchema>;
84
90
  export type ProfileConfig = z.infer<typeof ProfileConfigSchema>;
85
91
  export type TelegramChatConfig = z.infer<typeof TelegramChatConfigSchema>;
92
+ export type ModelChoiceConfig = z.infer<typeof ModelChoiceConfigSchema>;
86
93
  export type RootConfig = z.infer<typeof RootConfigSchema>;
87
94
 
88
95
  export type LoadedAgentConfig = AgentConfig & {
@@ -94,4 +101,5 @@ export type LoadedConfig = {
94
101
  agents: LoadedAgentConfig[];
95
102
  profiles: ProfileConfig[];
96
103
  telegramChats: TelegramChatConfig[];
104
+ modelChoices: ModelChoiceConfig[];
97
105
  };
@@ -24,7 +24,7 @@ export class AgentDaemon {
24
24
  ) {
25
25
  this.adapters = createAdapters(repositories);
26
26
  this.registry = new AgentRegistry(config, repositories);
27
- this.commands = new BotCommandHandler(this.registry, repositories);
27
+ this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices);
28
28
  this.router = new MessageRouter(repositories, this.registry);
29
29
  this.runner = new AgentRunner(this.registry, repositories);
30
30
  }
@@ -92,6 +92,7 @@ export class AgentDaemon {
92
92
  chatId: message.chatId,
93
93
  threadId: message.threadId,
94
94
  text: command.text,
95
+ inlineKeyboard: command.inlineKeyboard,
95
96
  });
96
97
  return;
97
98
  }
@@ -158,9 +159,20 @@ function createAdapters(repositories: Repositories): ChannelAdapter[] {
158
159
  runtimeKv: repositories.runtimeKv,
159
160
  pollTimeoutSeconds: Number(process.env.TELEGRAM_POLL_TIMEOUT_SECONDS ?? 30),
160
161
  pollIntervalMs: Number(process.env.TELEGRAM_POLL_INTERVAL_MS ?? 1000),
162
+ commands: telegramCommands(),
161
163
  }),
162
164
  );
163
165
  }
164
166
 
165
167
  return adapters;
166
168
  }
169
+
170
+ function telegramCommands() {
171
+ return [
172
+ { command: "help", description: "Show Pigent commands" },
173
+ { command: "agents", description: "List agents available in this chat" },
174
+ { command: "model", description: "Choose model for this chat session" },
175
+ { command: "thinking", description: "Choose thinking level for this chat session" },
176
+ { command: "agent", description: "Send message to a specific agent" },
177
+ ];
178
+ }