@fickydev/pigent 0.1.7 → 0.1.9

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,9 @@
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.
48
+ - Changed `/model` picker to use Pi's currently available models automatically when explicit `modelChoices` are not configured.
46
49
  - Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
47
50
 
48
51
  ### 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 default to Pi's currently available models from `ModelRegistry.getAvailable()`. Operators may optionally configure explicit choices to curate or rename the button list:
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. The available-model fallback should use Pi auth presence checks only and never print secret values.
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,12 @@
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 currently available Pi models
188
+ - [x] `/thinking` inline button picker
189
+ - [x] Handle model picker callback and persist selected session model
190
+ - [x] Fallback to Pi `ModelRegistry.getAvailable()` when `modelChoices` is empty
191
+ - [x] Handle thinking picker callback and persist selected session thinking level
192
+ - [x] Add `Use default` model/thinking buttons
182
193
  - [ ] `/model <agentId> <provider/modelId>` set model for an explicit agent session
183
194
  - [ ] `/thinking <agentId> <level>` set thinking level for an explicit agent session
184
195
  - [x] `/help` show bot commands
@@ -192,6 +203,8 @@
192
203
  - [ ] Unit test heartbeat `NOOP` behavior
193
204
  - [ ] Unit test model selection priority
194
205
  - [ ] Unit test Telegram `/model` command parsing
206
+ - [ ] Unit test Telegram model picker callback parsing
207
+ - [ ] Unit test Telegram thinking picker callback parsing
195
208
  - [ ] Repository tests against temp SQLite DB
196
209
  - [ ] Integration test fake Telegram update to fake Pi runner
197
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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,15 @@
1
1
  telegramChats: []
2
+ modelChoices: []
2
3
 
4
+ # Optional. If empty, /model uses Pi's currently available models automatically.
5
+ # Add choices to curate or rename buttons for /model.
6
+ #
7
+ # modelChoices:
8
+ # - id: anthropic/claude-sonnet-4-5
9
+ # label: Claude Sonnet
10
+ # - id: anthropic/claude-opus-4-5
11
+ # label: Claude Opus
12
+ #
3
13
  # Replace chatId with a real Telegram chat id to enable routing.
4
14
  #
5
15
  # telegramChats:
@@ -1,5 +1,7 @@
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";
4
+ import { getAvailableModelChoices } from "../pi/PiAvailableModels";
3
5
  import { isValidModelRef } from "../pi/PiModelResolver";
4
6
  import type { AgentRegistry } from "./AgentRegistry";
5
7
 
@@ -7,6 +9,7 @@ export type BotCommandResult =
7
9
  | {
8
10
  handled: true;
9
11
  text: string;
12
+ inlineKeyboard?: InlineKeyboardButton[][];
10
13
  }
11
14
  | {
12
15
  handled: false;
@@ -19,6 +22,7 @@ export class BotCommandHandler {
19
22
  constructor(
20
23
  private readonly registry: AgentRegistry,
21
24
  private readonly repositories: Repositories,
25
+ private readonly modelChoices: ModelChoiceConfig[] = [],
22
26
  ) {}
23
27
 
24
28
  async handle(message: InboundMessage): Promise<BotCommandResult> {
@@ -31,9 +35,17 @@ export class BotCommandHandler {
31
35
  case "/agents":
32
36
  return { handled: true, text: await this.agentsText(message) };
33
37
  case "/model":
34
- return { handled: true, text: await this.modelText(message) };
38
+ return await this.modelResult(message);
35
39
  case "/thinking":
36
- return { handled: true, text: await this.thinkingText(message) };
40
+ return await this.thinkingResult(message);
41
+ case "/model:set":
42
+ return { handled: true, text: await this.setModelFromChoice(message) };
43
+ case "/model:default":
44
+ return { handled: true, text: await this.clearModel(message) };
45
+ case "/thinking:set":
46
+ return { handled: true, text: await this.setThinkingFromCallback(message) };
47
+ case "/thinking:default":
48
+ return { handled: true, text: await this.clearThinking(message) };
37
49
  default:
38
50
  return { handled: false };
39
51
  }
@@ -44,10 +56,10 @@ export class BotCommandHandler {
44
56
  "Pigent commands:",
45
57
  "/help - show this help",
46
58
  "/agents - list agents for this chat",
47
- "/model - show model for this chat session",
59
+ "/model - show model picker for this chat session",
48
60
  "/model <provider/modelId> - set model for this chat session",
49
61
  "/model default - clear model override for this chat session",
50
- "/thinking - show thinking level for this chat session",
62
+ "/thinking - show thinking picker for this chat session",
51
63
  "/thinking <off|low|medium|high> - set thinking level for this chat session",
52
64
  "/thinking default - clear thinking override for this chat session",
53
65
  "/agent <agentId> <message> - send message to specific agent",
@@ -77,56 +89,100 @@ export class BotCommandHandler {
77
89
  ].join("\n");
78
90
  }
79
91
 
80
- private async modelText(message: InboundMessage): Promise<string> {
92
+ private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
81
93
  const sessionResult = await this.getDefaultSession(message);
82
- if (!sessionResult.ok) return sessionResult.message;
94
+ if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
83
95
 
84
96
  const [, ...args] = message.text.trim().split(/\s+/);
85
97
  const value = args.join(" ").trim();
86
98
 
87
99
  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");
100
+ const choices = await this.loadModelChoices();
101
+
102
+ return {
103
+ handled: true,
104
+ text: [
105
+ `Agent: ${sessionResult.agentId}`,
106
+ `Session model: ${sessionResult.session.model ?? "default"}`,
107
+ choices.length > 0
108
+ ? "Choose a model:"
109
+ : "No available Pi models found. Configure provider auth or use /model <provider/modelId> manually.",
110
+ ].join("\n"),
111
+ inlineKeyboard: choices.length > 0 ? this.modelKeyboard(choices) : undefined,
112
+ };
94
113
  }
95
114
 
96
115
  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"}`;
116
+ return { handled: true, text: await this.clearModel(message) };
99
117
  }
100
118
 
101
119
  if (!isValidModelRef(value)) {
102
- return "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5";
120
+ return { handled: true, text: "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5" };
103
121
  }
104
122
 
105
123
  const session = await this.repositories.sessions.updateModel(sessionResult.session.id, value);
106
- return `Session model for ${session.agentId} set to ${session.model}.`;
124
+ return { handled: true, text: `Session model for ${session.agentId} set to ${session.model}.` };
107
125
  }
108
126
 
109
- private async thinkingText(message: InboundMessage): Promise<string> {
127
+ private async thinkingResult(message: InboundMessage): Promise<BotCommandResult> {
110
128
  const sessionResult = await this.getDefaultSession(message);
111
- if (!sessionResult.ok) return sessionResult.message;
129
+ if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
112
130
 
113
131
  const [, rawValue] = message.text.trim().split(/\s+/, 2);
114
132
  const value = rawValue?.trim();
115
133
 
116
134
  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");
135
+ return {
136
+ handled: true,
137
+ text: [
138
+ `Agent: ${sessionResult.agentId}`,
139
+ `Session thinking level: ${sessionResult.session.thinkingLevel ?? "default"}`,
140
+ "Choose thinking level:",
141
+ ].join("\n"),
142
+ inlineKeyboard: this.thinkingKeyboard(),
143
+ };
123
144
  }
124
145
 
125
146
  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"}`;
147
+ return { handled: true, text: await this.clearThinking(message) };
128
148
  }
129
149
 
150
+ if (!isThinkingLevel(value)) {
151
+ return { handled: true, text: "Invalid thinking level. Use one of: off, low, medium, high, default" };
152
+ }
153
+
154
+ const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
155
+ return { handled: true, text: `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.` };
156
+ }
157
+
158
+ private async setModelFromChoice(message: InboundMessage): Promise<string> {
159
+ const sessionResult = await this.getDefaultSession(message);
160
+ if (!sessionResult.ok) return sessionResult.message;
161
+
162
+ const choices = await this.loadModelChoices();
163
+ const choiceIndex = Number(message.text.slice("/model:set:".length));
164
+ if (!Number.isSafeInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= choices.length) {
165
+ return "Unknown model choice.";
166
+ }
167
+
168
+ const choice = choices[choiceIndex];
169
+ const session = await this.repositories.sessions.updateModel(sessionResult.session.id, choice.id);
170
+ return `Session model for ${session.agentId} set to ${choice.label} (${session.model}).`;
171
+ }
172
+
173
+ private async clearModel(message: InboundMessage): Promise<string> {
174
+ const sessionResult = await this.getDefaultSession(message);
175
+ if (!sessionResult.ok) return sessionResult.message;
176
+
177
+ const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
178
+ return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
179
+ }
180
+
181
+ private async setThinkingFromCallback(message: InboundMessage): Promise<string> {
182
+ const sessionResult = await this.getDefaultSession(message);
183
+ if (!sessionResult.ok) return sessionResult.message;
184
+
185
+ const value = message.text.slice("/thinking:set:".length);
130
186
  if (!isThinkingLevel(value)) {
131
187
  return "Invalid thinking level. Use one of: off, low, medium, high, default";
132
188
  }
@@ -135,6 +191,47 @@ export class BotCommandHandler {
135
191
  return `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.`;
136
192
  }
137
193
 
194
+ private async clearThinking(message: InboundMessage): Promise<string> {
195
+ const sessionResult = await this.getDefaultSession(message);
196
+ if (!sessionResult.ok) return sessionResult.message;
197
+
198
+ const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
199
+ return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
200
+ }
201
+
202
+ private async loadModelChoices(): Promise<ModelChoiceConfig[]> {
203
+ if (this.modelChoices.length > 0) return this.modelChoices;
204
+
205
+ return getAvailableModelChoices();
206
+ }
207
+
208
+ private modelKeyboard(choices: ModelChoiceConfig[]): InlineKeyboardButton[][] {
209
+ const rows = chunk(
210
+ choices.map((choice, index) => ({
211
+ text: choice.label,
212
+ callbackData: `model:set:${index}`,
213
+ })),
214
+ 1,
215
+ );
216
+
217
+ rows.push([{ text: "Use default", callbackData: "model:default" }]);
218
+ return rows;
219
+ }
220
+
221
+ private thinkingKeyboard(): InlineKeyboardButton[][] {
222
+ return [
223
+ [
224
+ { text: "Off", callbackData: "thinking:set:off" },
225
+ { text: "Low", callbackData: "thinking:set:low" },
226
+ ],
227
+ [
228
+ { text: "Medium", callbackData: "thinking:set:medium" },
229
+ { text: "High", callbackData: "thinking:set:high" },
230
+ ],
231
+ [{ text: "Use default", callbackData: "thinking:default" }],
232
+ ];
233
+ }
234
+
138
235
  private async getDefaultSession(message: InboundMessage) {
139
236
  if (message.channel !== "telegram") {
140
237
  return { ok: false as const, message: "Unsupported channel." };
@@ -175,3 +272,13 @@ export class BotCommandHandler {
175
272
  function isThinkingLevel(value: string): value is ThinkingLevel {
176
273
  return THINKING_LEVELS.includes(value as ThinkingLevel);
177
274
  }
275
+
276
+ function chunk<T>(items: T[], size: number): T[][] {
277
+ const rows: T[][] = [];
278
+
279
+ for (let index = 0; index < items.length; index += size) {
280
+ rows.push(items.slice(index, index + size));
281
+ }
282
+
283
+ return rows;
284
+ }
@@ -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
+ }
@@ -0,0 +1,23 @@
1
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+
4
+ export type AvailableModelChoice = {
5
+ id: string;
6
+ label: string;
7
+ };
8
+
9
+ export async function getAvailableModelChoices(): Promise<AvailableModelChoice[]> {
10
+ const authStorage = AuthStorage.create();
11
+ const modelRegistry = ModelRegistry.create(authStorage);
12
+ const models = await modelRegistry.getAvailable();
13
+
14
+ return models.map(toChoice).sort((a, b) => a.label.localeCompare(b.label));
15
+ }
16
+
17
+ function toChoice(model: Model<any>): AvailableModelChoice {
18
+ const modelWithName = model as Model<any> & { name?: string };
19
+ const id = `${model.provider}/${model.id}`;
20
+ const label = modelWithName.name ? `${modelWithName.name} (${id})` : id;
21
+
22
+ return { id, label };
23
+ }