arisa 4.3.0 → 4.3.2
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/package.json +1 -1
- package/src/core/agent/agent-manager.js +14 -7
- package/src/core/agent/model-selection.js +36 -0
- package/src/core/agent/pi-runtime.js +8 -0
- package/src/core/config/config-defaults.js +8 -0
- package/src/core/tools/daemon-processes.js +6 -1
- package/src/runtime/bootstrap.js +12 -34
- package/src/runtime/paths.js +8 -2
- package/src/transport/telegram/bot.js +114 -0
- package/src/transport/telegram/model-picker.js +25 -0
- package/src/transport/telegram/paged-inline-keyboard.js +30 -0
- package/test/daemon-runtime.test.js +32 -0
- package/test/model-selection.test.js +99 -0
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile, stat, unlink } from "node:fs/promises";
|
|
|
3
3
|
import { createAgentSession, DefaultResourceLoader, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
|
|
6
|
+
import { resolveChatModelSelection } from "./model-selection.js";
|
|
6
7
|
import { appendArisaAgentsFile, arisaAgentsFile, arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
|
|
7
8
|
import { withTimeout } from "./prompt-timeout.js";
|
|
8
9
|
import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
|
|
@@ -126,9 +127,9 @@ export class AgentManager {
|
|
|
126
127
|
this.sessions.delete(String(chatId));
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
createSessionManager(chatId, workspaceDir = arisaInstallDir) {
|
|
130
|
+
createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
|
|
130
131
|
const sessionKey = String(chatId);
|
|
131
|
-
const sessionDir = getChatPiSessionsDir(sessionKey);
|
|
132
|
+
const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
|
|
132
133
|
if (this.pendingNewSessions.has(sessionKey)) {
|
|
133
134
|
this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
|
|
134
135
|
return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
|
|
@@ -165,14 +166,16 @@ export class AgentManager {
|
|
|
165
166
|
|
|
166
167
|
async getSessionContext(chatId, telegram) {
|
|
167
168
|
const sessionKey = String(chatId);
|
|
168
|
-
const
|
|
169
|
+
const modelSelection = resolveChatModelSelection(this.config, sessionKey);
|
|
170
|
+
const effectiveModelId = modelSelection.model;
|
|
171
|
+
const effectiveModelKey = `${modelSelection.provider}/${effectiveModelId}@${modelSelection.sessionRevision}`;
|
|
169
172
|
if (this.sessions.has(sessionKey)) {
|
|
170
173
|
const existing = this.sessions.get(sessionKey);
|
|
171
|
-
if (existing?.
|
|
174
|
+
if (existing?.modelKey === effectiveModelKey) {
|
|
172
175
|
this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
|
|
173
176
|
return existing;
|
|
174
177
|
}
|
|
175
|
-
this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.
|
|
178
|
+
this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
|
|
176
179
|
this.sessions.delete(sessionKey);
|
|
177
180
|
this.pendingNewSessions.add(sessionKey);
|
|
178
181
|
}
|
|
@@ -192,7 +195,11 @@ export class AgentManager {
|
|
|
192
195
|
customToolNames: [...arisaToolNames, "system_shell"]
|
|
193
196
|
});
|
|
194
197
|
await assertDirectory(policy.workspaceDir, "pi.workspaceDir");
|
|
195
|
-
const { sessionManager, isNewSession } = this.createSessionManager(
|
|
198
|
+
const { sessionManager, isNewSession } = this.createSessionManager(
|
|
199
|
+
sessionKey,
|
|
200
|
+
policy.workspaceDir,
|
|
201
|
+
modelSelection.sessionRevision
|
|
202
|
+
);
|
|
196
203
|
const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
|
|
197
204
|
this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
|
|
198
205
|
const customTools = [
|
|
@@ -224,7 +231,7 @@ export class AgentManager {
|
|
|
224
231
|
})}`);
|
|
225
232
|
}
|
|
226
233
|
|
|
227
|
-
const ctx = { session, modelId: effectiveModelId };
|
|
234
|
+
const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey };
|
|
228
235
|
this.sessions.set(sessionKey, ctx);
|
|
229
236
|
if (isNewSession) {
|
|
230
237
|
this.pendingNewSessions.delete(sessionKey);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function chatKey(chatId) {
|
|
2
|
+
return String(chatId);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function resolveChatModelSelection(config, chatId) {
|
|
6
|
+
const selection = config.pi.chatModels?.[chatKey(chatId)];
|
|
7
|
+
if (!selection || selection.provider !== config.pi.provider) {
|
|
8
|
+
return {
|
|
9
|
+
provider: config.pi.provider,
|
|
10
|
+
model: config.pi.model,
|
|
11
|
+
sessionRevision: 0
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isSafeInteger(selection.sessionRevision) || selection.sessionRevision <= 0) {
|
|
15
|
+
throw new Error(`Invalid model session revision for chat ${chatId}`);
|
|
16
|
+
}
|
|
17
|
+
return selection;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveChatModel(config, chatId) {
|
|
21
|
+
return resolveChatModelSelection(config, chatId).model;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function selectChatModel(config, chatId, model) {
|
|
25
|
+
if (model.provider !== config.pi.provider) {
|
|
26
|
+
throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${config.pi.provider}`);
|
|
27
|
+
}
|
|
28
|
+
config.pi.chatModels ||= {};
|
|
29
|
+
const key = chatKey(chatId);
|
|
30
|
+
const sessionRevision = (config.pi.chatModels[key]?.sessionRevision || 0) + 1;
|
|
31
|
+
config.pi.chatModels[key] = {
|
|
32
|
+
provider: model.provider,
|
|
33
|
+
model: model.id,
|
|
34
|
+
sessionRevision
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -53,6 +53,14 @@ export function listProviderModels(provider, runtime = createPiRuntime()) {
|
|
|
53
53
|
.sort((a, b) => compareText(a.name || a.id, b.name || b.id));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function formatPiModelOption(model) {
|
|
57
|
+
const capabilities = [
|
|
58
|
+
model.reasoning ? "reasoning" : null,
|
|
59
|
+
model.input?.includes("image") ? "image" : null
|
|
60
|
+
].filter(Boolean).join(", ");
|
|
61
|
+
return capabilities ? `${model.id} [${capabilities}]` : model.id;
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
export function findPiModel({ provider, model, apiKey } = {}) {
|
|
57
65
|
const runtime = createPiRuntime({ provider, apiKey });
|
|
58
66
|
return {
|
|
@@ -14,9 +14,17 @@ export const daemonConfigDefaults = Object.freeze({
|
|
|
14
14
|
queuePollIntervalMs: 250
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
export const telegramConfigDefaults = Object.freeze({
|
|
18
|
+
modelPickerPageSize: 8
|
|
19
|
+
});
|
|
20
|
+
|
|
17
21
|
export function applyConfigDefaults(config) {
|
|
18
22
|
return {
|
|
19
23
|
...config,
|
|
24
|
+
telegram: {
|
|
25
|
+
...telegramConfigDefaults,
|
|
26
|
+
...(config.telegram || {})
|
|
27
|
+
},
|
|
20
28
|
daemons: {
|
|
21
29
|
...daemonConfigDefaults,
|
|
22
30
|
...(config.daemons || {})
|
|
@@ -337,11 +337,16 @@ async function listChatDaemonRecords() {
|
|
|
337
337
|
const records = [];
|
|
338
338
|
for (const chatEntry of chatEntries) {
|
|
339
339
|
if (!chatEntry.isDirectory()) continue;
|
|
340
|
+
let scope;
|
|
341
|
+
try {
|
|
342
|
+
scope = normalizeDaemonScope({ type: "chat", chatId: chatEntry.name });
|
|
343
|
+
} catch {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
340
346
|
const toolsRoot = path.join(chatsDir, chatEntry.name, "state", "tools");
|
|
341
347
|
const toolEntries = await readdir(toolsRoot, { withFileTypes: true }).catch(() => []);
|
|
342
348
|
for (const toolEntry of toolEntries) {
|
|
343
349
|
if (!toolEntry.isDirectory()) continue;
|
|
344
|
-
const scope = { type: "chat", chatId: chatEntry.name };
|
|
345
350
|
const meta = await readJson(daemonPaths({ toolName: toolEntry.name, scope }).metaFile, null);
|
|
346
351
|
if (meta?.toolName && meta?.entryPath) records.push(meta);
|
|
347
352
|
}
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -5,9 +5,10 @@ import { stdin as input, stdout as output } from "node:process";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { Bot } from "grammy";
|
|
7
7
|
import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
|
|
8
|
-
import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
|
|
9
|
-
import { applyConfigDefaults } from "../core/config/config-defaults.js";
|
|
8
|
+
import { createPiRuntime, formatPiModelOption, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
|
|
9
|
+
import { applyConfigDefaults, telegramConfigDefaults } from "../core/config/config-defaults.js";
|
|
10
10
|
import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
|
|
11
|
+
import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
|
|
11
12
|
import { configFile, ensureArisaHome } from "./paths.js";
|
|
12
13
|
|
|
13
14
|
const ARISA_BANNER = [
|
|
@@ -100,30 +101,6 @@ function parseYesNo(value, fallback = true) {
|
|
|
100
101
|
return null;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
function buildPagedInlineKeyboard(action, items, { page = 0, pageSize = 8 } = {}) {
|
|
104
|
-
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
|
|
105
|
-
const currentPage = Math.max(0, Math.min(pageCount - 1, page));
|
|
106
|
-
const startIndex = currentPage * pageSize;
|
|
107
|
-
const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
|
|
108
|
-
text: item.text,
|
|
109
|
-
callback_data: `${action}:${startIndex + index}`
|
|
110
|
-
}]));
|
|
111
|
-
|
|
112
|
-
if (pageCount > 1) {
|
|
113
|
-
const navigation = [];
|
|
114
|
-
if (currentPage > 0) {
|
|
115
|
-
navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
|
|
116
|
-
}
|
|
117
|
-
navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
|
|
118
|
-
if (currentPage < pageCount - 1) {
|
|
119
|
-
navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
|
|
120
|
-
}
|
|
121
|
-
rows.push(navigation);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return { inline_keyboard: rows };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
104
|
function getIncomingChatMeta(ctx) {
|
|
128
105
|
return {
|
|
129
106
|
languageCode: ctx.from?.language_code || "",
|
|
@@ -138,11 +115,6 @@ function formatProviderOption(item) {
|
|
|
138
115
|
return `${item.provider} (${item.modelCount} models, ${authLabel})`;
|
|
139
116
|
}
|
|
140
117
|
|
|
141
|
-
function formatModelOption(model) {
|
|
142
|
-
const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
|
|
143
|
-
return capabilities ? `${model.id} [${capabilities}]` : model.id;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
118
|
function selectPiLoginOption(options = []) {
|
|
147
119
|
return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
|
|
148
120
|
|| options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
|
|
@@ -215,7 +187,7 @@ async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
|
|
|
215
187
|
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
|
|
216
188
|
console.log(`\nAvailable models for ${selectedProvider.provider}:`);
|
|
217
189
|
models.forEach((model, index) => {
|
|
218
|
-
console.log(`${index + 1}. ${
|
|
190
|
+
console.log(`${index + 1}. ${formatPiModelOption(model)}`);
|
|
219
191
|
});
|
|
220
192
|
|
|
221
193
|
const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
|
|
@@ -326,14 +298,20 @@ async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
|
|
|
326
298
|
const askProvider = async (ctx = null, page = 0) => {
|
|
327
299
|
state = "provider";
|
|
328
300
|
await showSetupPrompt(ctx, "Select the Pi provider Arisa should use:", {
|
|
329
|
-
reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), {
|
|
301
|
+
reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), {
|
|
302
|
+
page,
|
|
303
|
+
pageSize: telegramConfigDefaults.modelPickerPageSize
|
|
304
|
+
})
|
|
330
305
|
});
|
|
331
306
|
};
|
|
332
307
|
|
|
333
308
|
const askModel = async (ctx = null, page = 0) => {
|
|
334
309
|
state = "model";
|
|
335
310
|
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
|
|
336
|
-
const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text:
|
|
311
|
+
const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatPiModelOption(model) })), {
|
|
312
|
+
page,
|
|
313
|
+
pageSize: telegramConfigDefaults.modelPickerPageSize
|
|
314
|
+
});
|
|
337
315
|
keyboard.inline_keyboard.push([{ text: "Back to providers", callback_data: "back:provider" }]);
|
|
338
316
|
await showSetupPrompt(ctx, `Select the model for ${selectedProvider.provider}:`, {
|
|
339
317
|
reply_markup: keyboard
|
package/src/runtime/paths.js
CHANGED
|
@@ -72,8 +72,14 @@ export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
|
|
|
72
72
|
: path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function getChatPiSessionsDir(chatId) {
|
|
76
|
-
|
|
75
|
+
export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
|
|
76
|
+
if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
|
|
77
|
+
throw new Error(`Invalid Pi session revision: ${sessionRevision}`);
|
|
78
|
+
}
|
|
79
|
+
const sessionsDir = path.join(getChatDir(chatId), "state", "pi-sessions");
|
|
80
|
+
return sessionRevision === 0
|
|
81
|
+
? sessionsDir
|
|
82
|
+
: path.join(sessionsDir, String(sessionRevision));
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
export function getToolDir(toolName) {
|
|
@@ -3,9 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import { authorizeChat } from "./auth.js";
|
|
4
4
|
import { captureIncomingArtifact, formatLocationText } from "./media.js";
|
|
5
5
|
import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
|
|
6
|
+
import { buildModelPicker, parseModelPickerAction } from "./model-picker.js";
|
|
6
7
|
import { renderTelegramHtml } from "./text-format.js";
|
|
7
8
|
import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
|
|
8
9
|
import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
|
|
10
|
+
import { resolveChatModel, selectChatModel } from "../../core/agent/model-selection.js";
|
|
11
|
+
import { createPiRuntime, listProviderModels } from "../../core/agent/pi-runtime.js";
|
|
9
12
|
import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
|
|
10
13
|
|
|
11
14
|
const slowPromptNoticeMs = 300_000;
|
|
@@ -410,6 +413,49 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
410
413
|
return perChatState.get(chatId);
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
function getProviderModels() {
|
|
417
|
+
const runtime = createPiRuntime({
|
|
418
|
+
provider: config.pi.provider,
|
|
419
|
+
apiKey: config.pi.apiKey
|
|
420
|
+
});
|
|
421
|
+
return listProviderModels(config.pi.provider, runtime);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function showModelPicker(ctx, page = 0) {
|
|
425
|
+
const picker = buildModelPicker({
|
|
426
|
+
provider: config.pi.provider,
|
|
427
|
+
models: getProviderModels(),
|
|
428
|
+
selectedModelId: resolveChatModel(config, ctx.chat.id),
|
|
429
|
+
page,
|
|
430
|
+
pageSize: config.telegram.modelPickerPageSize
|
|
431
|
+
});
|
|
432
|
+
const extra = { reply_markup: picker.replyMarkup };
|
|
433
|
+
const messageId = ctx.callbackQuery?.message?.message_id;
|
|
434
|
+
if (messageId) {
|
|
435
|
+
return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
|
|
436
|
+
}
|
|
437
|
+
return ctx.reply(picker.text, extra);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function persistChatModel(chatId, model) {
|
|
441
|
+
const key = chatKey(chatId);
|
|
442
|
+
const hadSelections = Boolean(config.pi.chatModels);
|
|
443
|
+
const previousSelection = config.pi.chatModels?.[key];
|
|
444
|
+
selectChatModel(config, chatId, model);
|
|
445
|
+
try {
|
|
446
|
+
await saveConfig(config);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (previousSelection) {
|
|
449
|
+
config.pi.chatModels[key] = previousSelection;
|
|
450
|
+
} else {
|
|
451
|
+
delete config.pi.chatModels[key];
|
|
452
|
+
if (!hadSelections) delete config.pi.chatModels;
|
|
453
|
+
}
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
agentManager.resetSession(chatId);
|
|
457
|
+
}
|
|
458
|
+
|
|
413
459
|
async function buildIncomingPrompt(ctx) {
|
|
414
460
|
const chatId = ctx.chat.id;
|
|
415
461
|
logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
|
|
@@ -684,6 +730,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
684
730
|
await handleNewCommand(ctx);
|
|
685
731
|
});
|
|
686
732
|
|
|
733
|
+
bot.command("model", async (ctx) => {
|
|
734
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
735
|
+
if (!auth.ok) return;
|
|
736
|
+
await showModelPicker(ctx);
|
|
737
|
+
});
|
|
738
|
+
|
|
687
739
|
bot.command("auth", async (ctx) => {
|
|
688
740
|
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
689
741
|
if (!auth.ok) return;
|
|
@@ -717,6 +769,67 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
717
769
|
}
|
|
718
770
|
});
|
|
719
771
|
|
|
772
|
+
bot.on("callback_query:data", async (ctx, next) => {
|
|
773
|
+
const action = parseModelPickerAction(ctx.callbackQuery.data);
|
|
774
|
+
if (!action) return next();
|
|
775
|
+
if (action.type === "noop") {
|
|
776
|
+
await ctx.answerCallbackQuery();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
|
|
781
|
+
if (!auth.ok) {
|
|
782
|
+
await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
if (action.type === "page") {
|
|
788
|
+
await showModelPicker(ctx, action.value);
|
|
789
|
+
await ctx.answerCallbackQuery();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (getChatState(ctx.chat.id).processing) {
|
|
794
|
+
await ctx.answerCallbackQuery({
|
|
795
|
+
text: "Wait for the current response before changing models.",
|
|
796
|
+
show_alert: true
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const models = getProviderModels();
|
|
802
|
+
const model = models[action.value];
|
|
803
|
+
if (!model) {
|
|
804
|
+
await ctx.answerCallbackQuery({
|
|
805
|
+
text: "This model list is no longer current. Run /model again.",
|
|
806
|
+
show_alert: true
|
|
807
|
+
});
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const currentModelId = resolveChatModel(config, ctx.chat.id);
|
|
812
|
+
if (model.id === currentModelId) {
|
|
813
|
+
await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
await persistChatModel(ctx.chat.id, model);
|
|
818
|
+
await ctx.api.editMessageText(
|
|
819
|
+
ctx.chat.id,
|
|
820
|
+
ctx.callbackQuery.message.message_id,
|
|
821
|
+
`Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
|
|
822
|
+
);
|
|
823
|
+
await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
|
|
824
|
+
} catch (error) {
|
|
825
|
+
logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
|
|
826
|
+
await ctx.answerCallbackQuery({
|
|
827
|
+
text: "Could not change the model.",
|
|
828
|
+
show_alert: true
|
|
829
|
+
}).catch(() => {});
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
720
833
|
bot.on("message", async (ctx) => {
|
|
721
834
|
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
722
835
|
if (!auth.ok) return;
|
|
@@ -753,6 +866,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
753
866
|
config.telegram.chatMeta ||= {};
|
|
754
867
|
await bot.api.setMyCommands([
|
|
755
868
|
{ command: "new", description: "Start a new chat context" },
|
|
869
|
+
{ command: "model", description: "Choose the model for this chat" },
|
|
756
870
|
{ command: "auth", description: "Show Pi authentication status" }
|
|
757
871
|
]);
|
|
758
872
|
if (!taskTimer) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatPiModelOption } from "../../core/agent/pi-runtime.js";
|
|
2
|
+
import { buildPagedInlineKeyboard } from "./paged-inline-keyboard.js";
|
|
3
|
+
|
|
4
|
+
export function parseModelPickerAction(data) {
|
|
5
|
+
if (data === "noop:page") return { type: "noop", value: null };
|
|
6
|
+
const match = /^(model|model-page):(\d+)$/.exec(String(data || ""));
|
|
7
|
+
if (!match) return null;
|
|
8
|
+
return {
|
|
9
|
+
type: match[1] === "model" ? "select" : "page",
|
|
10
|
+
value: Number(match[2])
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildModelPicker({ provider, models, selectedModelId, page, pageSize }) {
|
|
15
|
+
if (!models.length) {
|
|
16
|
+
throw new Error(`No models available for provider ${provider}`);
|
|
17
|
+
}
|
|
18
|
+
const items = models.map((model) => ({
|
|
19
|
+
text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
text: `Current model: ${provider}/${selectedModelId}\nSelect a model for this chat:`,
|
|
23
|
+
replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function requirePositiveInteger(value, name) {
|
|
2
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
3
|
+
throw new Error(`${name} must be a positive integer`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildPagedInlineKeyboard(action, items, { page = 0, pageSize }) {
|
|
8
|
+
requirePositiveInteger(pageSize, "pageSize");
|
|
9
|
+
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
|
|
10
|
+
const currentPage = Math.max(0, Math.min(pageCount - 1, page));
|
|
11
|
+
const startIndex = currentPage * pageSize;
|
|
12
|
+
const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
|
|
13
|
+
text: item.text,
|
|
14
|
+
callback_data: `${action}:${startIndex + index}`
|
|
15
|
+
}]));
|
|
16
|
+
|
|
17
|
+
if (pageCount > 1) {
|
|
18
|
+
const navigation = [];
|
|
19
|
+
if (currentPage > 0) {
|
|
20
|
+
navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
|
|
21
|
+
}
|
|
22
|
+
navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
|
|
23
|
+
if (currentPage < pageCount - 1) {
|
|
24
|
+
navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
|
|
25
|
+
}
|
|
26
|
+
rows.push(navigation);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { inline_keyboard: rows };
|
|
30
|
+
}
|
|
@@ -116,6 +116,38 @@ test("isolates daemon process files and context by chat scope", async () => {
|
|
|
116
116
|
await Promise.all([first.stop(), second.stop()]);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
test("supervisor ignores invalid chat directories and recovers valid daemons", async () => {
|
|
120
|
+
const invalidChatDir = path.join(homeDir, "chats", "24137857-c513-4f53-b39d-1b28f51ebbb6");
|
|
121
|
+
await mkdir(path.join(invalidChatDir, "state", "tools", "orphaned-tool"), { recursive: true });
|
|
122
|
+
|
|
123
|
+
const runtime = runtimeFor({ type: "chat", chatId: "202" }, {
|
|
124
|
+
autoStart: true,
|
|
125
|
+
startupContext: { health: "ok" }
|
|
126
|
+
});
|
|
127
|
+
let supervisor;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await runtime.submit({ value: "before" }, { timeoutMs: 1_000 });
|
|
131
|
+
const oldPid = await runtime.getPid();
|
|
132
|
+
process.kill(oldPid, "SIGKILL");
|
|
133
|
+
await waitFor(() => !isProcessAlive(oldPid));
|
|
134
|
+
|
|
135
|
+
supervisor = createToolProcessSupervisor({ policy });
|
|
136
|
+
await supervisor.start();
|
|
137
|
+
|
|
138
|
+
const newPid = await waitFor(async () => {
|
|
139
|
+
const pid = await runtime.getPid();
|
|
140
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
141
|
+
return pid && pid !== oldPid && status.state === "ready" ? pid : null;
|
|
142
|
+
});
|
|
143
|
+
assert.notEqual(newPid, oldPid);
|
|
144
|
+
} finally {
|
|
145
|
+
await supervisor?.stop();
|
|
146
|
+
await runtime.stop().catch(() => {});
|
|
147
|
+
await rm(invalidChatDir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
119
151
|
test("rejects stale readiness even when the pid is alive", async () => {
|
|
120
152
|
const status = {
|
|
121
153
|
state: "ready",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { resolveChatModel, selectChatModel } from "../src/core/agent/model-selection.js";
|
|
5
|
+
import { applyConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
6
|
+
import { getChatPiSessionsDir } from "../src/runtime/paths.js";
|
|
7
|
+
import { buildModelPicker, parseModelPickerAction } from "../src/transport/telegram/model-picker.js";
|
|
8
|
+
|
|
9
|
+
function createConfig() {
|
|
10
|
+
return {
|
|
11
|
+
telegram: {},
|
|
12
|
+
pi: {
|
|
13
|
+
provider: "openai-codex",
|
|
14
|
+
model: "gpt-default"
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("resolves the default model until a chat selects one", () => {
|
|
20
|
+
const config = createConfig();
|
|
21
|
+
|
|
22
|
+
assert.equal(resolveChatModel(config, 123), "gpt-default");
|
|
23
|
+
|
|
24
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" });
|
|
25
|
+
|
|
26
|
+
assert.equal(resolveChatModel(config, 123), "gpt-selected");
|
|
27
|
+
assert.equal(resolveChatModel(config, 456), "gpt-default");
|
|
28
|
+
assert.deepEqual(config.pi.chatModels["123"], {
|
|
29
|
+
provider: "openai-codex",
|
|
30
|
+
model: "gpt-selected",
|
|
31
|
+
sessionRevision: 1
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("starts a distinct persisted Pi session revision on every model change", () => {
|
|
36
|
+
const config = createConfig();
|
|
37
|
+
|
|
38
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" });
|
|
39
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" });
|
|
40
|
+
|
|
41
|
+
assert.equal(config.pi.chatModels["123"].sessionRevision, 2);
|
|
42
|
+
assert.equal(
|
|
43
|
+
getChatPiSessionsDir(123, config.pi.chatModels["123"].sessionRevision),
|
|
44
|
+
path.join(getChatPiSessionsDir(123), "2")
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ignores a chat selection from a different active provider", () => {
|
|
49
|
+
const config = createConfig();
|
|
50
|
+
config.pi.chatModels = {
|
|
51
|
+
123: { provider: "anthropic", model: "claude-selected" }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
assert.equal(resolveChatModel(config, 123), "gpt-default");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects selecting a model outside the active provider", () => {
|
|
58
|
+
const config = createConfig();
|
|
59
|
+
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => selectChatModel(config, 123, { provider: "anthropic", id: "claude-selected" }),
|
|
62
|
+
/active provider is openai-codex/
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("builds a paged model picker and marks the current model", () => {
|
|
67
|
+
const models = [
|
|
68
|
+
{ provider: "openai-codex", id: "gpt-a", reasoning: false, input: ["text"] },
|
|
69
|
+
{ provider: "openai-codex", id: "gpt-b", reasoning: true, input: ["text", "image"] },
|
|
70
|
+
{ provider: "openai-codex", id: "gpt-c", reasoning: false, input: ["text"] }
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const picker = buildModelPicker({
|
|
74
|
+
provider: "openai-codex",
|
|
75
|
+
models,
|
|
76
|
+
selectedModelId: "gpt-b",
|
|
77
|
+
page: 0,
|
|
78
|
+
pageSize: 2
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.match(picker.text, /openai-codex\/gpt-b/);
|
|
82
|
+
assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "model:0");
|
|
83
|
+
assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ gpt-b \[reasoning, image\]$/);
|
|
84
|
+
assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("parses only model picker callback data", () => {
|
|
88
|
+
assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
|
|
89
|
+
assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
|
|
90
|
+
assert.deepEqual(parseModelPickerAction("noop:page"), { type: "noop", value: null });
|
|
91
|
+
assert.equal(parseModelPickerAction("provider:1"), null);
|
|
92
|
+
assert.equal(parseModelPickerAction("model:-1"), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("centralizes the model picker page size in Telegram config defaults", () => {
|
|
96
|
+
const config = applyConfigDefaults(createConfig());
|
|
97
|
+
|
|
98
|
+
assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
|
|
99
|
+
});
|