arisa 4.3.0 → 4.3.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/package.json +1 -1
- package/src/core/agent/agent-manager.js +23 -8
- package/src/core/agent/auth-flow.js +1 -0
- package/src/core/agent/model-selection.js +65 -0
- package/src/core/agent/pi-runtime.js +49 -0
- package/src/core/config/config-defaults.js +16 -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 +277 -0
- package/src/transport/telegram/model-picker.js +69 -0
- package/src/transport/telegram/paged-inline-keyboard.js +30 -0
- package/test/auth-flow.test.js +2 -0
- package/test/daemon-runtime.test.js +32 -0
- package/test/model-selection.test.js +195 -0
package/package.json
CHANGED
|
@@ -3,10 +3,12 @@ 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";
|
|
9
10
|
import { createSystemShellTool } from "./system-shell-tool.js";
|
|
11
|
+
import { clampModelThinkingLevel } from "./pi-runtime.js";
|
|
10
12
|
import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
|
|
11
13
|
|
|
12
14
|
const piValidationTimeoutMs = 60_000;
|
|
@@ -126,9 +128,9 @@ export class AgentManager {
|
|
|
126
128
|
this.sessions.delete(String(chatId));
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
createSessionManager(chatId, workspaceDir = arisaInstallDir) {
|
|
131
|
+
createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
|
|
130
132
|
const sessionKey = String(chatId);
|
|
131
|
-
const sessionDir = getChatPiSessionsDir(sessionKey);
|
|
133
|
+
const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
|
|
132
134
|
if (this.pendingNewSessions.has(sessionKey)) {
|
|
133
135
|
this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
|
|
134
136
|
return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
|
|
@@ -165,14 +167,21 @@ export class AgentManager {
|
|
|
165
167
|
|
|
166
168
|
async getSessionContext(chatId, telegram) {
|
|
167
169
|
const sessionKey = String(chatId);
|
|
168
|
-
const
|
|
170
|
+
const modelSelection = resolveChatModelSelection(this.config, sessionKey);
|
|
171
|
+
const effectiveModelId = modelSelection.model;
|
|
172
|
+
const effectiveModelKey = `${modelSelection.provider}/${effectiveModelId}@${modelSelection.sessionRevision}`;
|
|
169
173
|
if (this.sessions.has(sessionKey)) {
|
|
170
174
|
const existing = this.sessions.get(sessionKey);
|
|
171
|
-
if (existing?.
|
|
175
|
+
if (existing?.modelKey === effectiveModelKey) {
|
|
176
|
+
const desiredThinkingLevel = clampModelThinkingLevel(existing.session.model, modelSelection.thinkingLevel);
|
|
177
|
+
if (existing.session.thinkingLevel !== desiredThinkingLevel) {
|
|
178
|
+
this.logger?.log("agent", `updating effort for chat ${sessionKey}: ${existing.session.thinkingLevel} -> ${desiredThinkingLevel}`);
|
|
179
|
+
existing.session.setThinkingLevel(desiredThinkingLevel);
|
|
180
|
+
}
|
|
172
181
|
this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
|
|
173
182
|
return existing;
|
|
174
183
|
}
|
|
175
|
-
this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.
|
|
184
|
+
this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
|
|
176
185
|
this.sessions.delete(sessionKey);
|
|
177
186
|
this.pendingNewSessions.add(sessionKey);
|
|
178
187
|
}
|
|
@@ -186,15 +195,20 @@ export class AgentManager {
|
|
|
186
195
|
if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
|
|
187
196
|
throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
|
|
188
197
|
}
|
|
198
|
+
const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
|
|
189
199
|
|
|
190
200
|
const policy = buildPiToolPolicy({
|
|
191
201
|
config: this.config,
|
|
192
202
|
customToolNames: [...arisaToolNames, "system_shell"]
|
|
193
203
|
});
|
|
194
204
|
await assertDirectory(policy.workspaceDir, "pi.workspaceDir");
|
|
195
|
-
const { sessionManager, isNewSession } = this.createSessionManager(
|
|
205
|
+
const { sessionManager, isNewSession } = this.createSessionManager(
|
|
206
|
+
sessionKey,
|
|
207
|
+
policy.workspaceDir,
|
|
208
|
+
modelSelection.sessionRevision
|
|
209
|
+
);
|
|
196
210
|
const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
|
|
197
|
-
this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
|
|
211
|
+
this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId} effort ${thinkingLevel}`);
|
|
198
212
|
const customTools = [
|
|
199
213
|
...this.createTools(telegram, chatId, policy),
|
|
200
214
|
createSystemShellTool({ workspaceDir: policy.workspaceDir, shell: policy.shell })
|
|
@@ -210,6 +224,7 @@ export class AgentManager {
|
|
|
210
224
|
authStorage,
|
|
211
225
|
modelRegistry,
|
|
212
226
|
model,
|
|
227
|
+
thinkingLevel,
|
|
213
228
|
tools: policy.tools,
|
|
214
229
|
excludeTools: policy.excludeTools,
|
|
215
230
|
customTools,
|
|
@@ -224,7 +239,7 @@ export class AgentManager {
|
|
|
224
239
|
})}`);
|
|
225
240
|
}
|
|
226
241
|
|
|
227
|
-
const ctx = { session, modelId: effectiveModelId };
|
|
242
|
+
const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey };
|
|
228
243
|
this.sessions.set(sessionKey, ctx);
|
|
229
244
|
if (isNewSession) {
|
|
230
245
|
this.pendingNewSessions.delete(sessionKey);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function chatKey(chatId) {
|
|
2
|
+
return String(chatId);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function normalizeSessionRevision(sessionRevision) {
|
|
6
|
+
if (sessionRevision == null) return 0;
|
|
7
|
+
if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
|
|
8
|
+
throw new Error("Invalid model session revision");
|
|
9
|
+
}
|
|
10
|
+
return sessionRevision;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveChatModelSelection(config, chatId) {
|
|
14
|
+
const selection = config.pi.chatModels?.[chatKey(chatId)];
|
|
15
|
+
if (!selection || selection.provider !== config.pi.provider) {
|
|
16
|
+
return {
|
|
17
|
+
provider: config.pi.provider,
|
|
18
|
+
model: config.pi.model,
|
|
19
|
+
thinkingLevel: config.pi.thinkingLevel,
|
|
20
|
+
sessionRevision: 0
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const sessionRevision = normalizeSessionRevision(selection.sessionRevision);
|
|
24
|
+
return {
|
|
25
|
+
provider: selection.provider,
|
|
26
|
+
model: selection.model,
|
|
27
|
+
thinkingLevel: selection.thinkingLevel ?? config.pi.thinkingLevel,
|
|
28
|
+
sessionRevision
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveChatModel(config, chatId) {
|
|
33
|
+
return resolveChatModelSelection(config, chatId).model;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveChatThinkingLevel(config, chatId) {
|
|
37
|
+
return resolveChatModelSelection(config, chatId).thinkingLevel;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function selectChatModel(config, chatId, model, { thinkingLevel } = {}) {
|
|
41
|
+
if (model.provider !== config.pi.provider) {
|
|
42
|
+
throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${config.pi.provider}`);
|
|
43
|
+
}
|
|
44
|
+
config.pi.chatModels ||= {};
|
|
45
|
+
const key = chatKey(chatId);
|
|
46
|
+
const sessionRevision = (config.pi.chatModels[key]?.sessionRevision || 0) + 1;
|
|
47
|
+
config.pi.chatModels[key] = {
|
|
48
|
+
provider: model.provider,
|
|
49
|
+
model: model.id,
|
|
50
|
+
thinkingLevel,
|
|
51
|
+
sessionRevision
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function selectChatThinkingLevel(config, chatId, thinkingLevel) {
|
|
56
|
+
config.pi.chatModels ||= {};
|
|
57
|
+
const key = chatKey(chatId);
|
|
58
|
+
const current = resolveChatModelSelection(config, chatId);
|
|
59
|
+
config.pi.chatModels[key] = {
|
|
60
|
+
provider: current.provider,
|
|
61
|
+
model: current.model,
|
|
62
|
+
thinkingLevel,
|
|
63
|
+
sessionRevision: current.sessionRevision
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -53,6 +53,55 @@ 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
|
+
|
|
64
|
+
/** Mirrors @earendil-works/pi-ai getSupportedThinkingLevels for the active model. */
|
|
65
|
+
const EXTENDED_THINKING_LEVELS = Object.freeze([
|
|
66
|
+
"off",
|
|
67
|
+
"minimal",
|
|
68
|
+
"low",
|
|
69
|
+
"medium",
|
|
70
|
+
"high",
|
|
71
|
+
"xhigh",
|
|
72
|
+
"max"
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export function listModelThinkingLevels(model) {
|
|
76
|
+
if (!model?.reasoning) return ["off"];
|
|
77
|
+
return EXTENDED_THINKING_LEVELS.filter((level) => {
|
|
78
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
79
|
+
if (mapped === null) return false;
|
|
80
|
+
if (level === "xhigh" || level === "max") return mapped !== undefined;
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function clampModelThinkingLevel(model, level) {
|
|
86
|
+
const availableLevels = listModelThinkingLevels(model);
|
|
87
|
+
if (availableLevels.includes(level)) return level;
|
|
88
|
+
const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
|
|
89
|
+
if (requestedIndex === -1) return availableLevels[0] ?? "off";
|
|
90
|
+
for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
|
|
91
|
+
const candidate = EXTENDED_THINKING_LEVELS[i];
|
|
92
|
+
if (availableLevels.includes(candidate)) return candidate;
|
|
93
|
+
}
|
|
94
|
+
for (let i = requestedIndex - 1; i >= 0; i--) {
|
|
95
|
+
const candidate = EXTENDED_THINKING_LEVELS[i];
|
|
96
|
+
if (availableLevels.includes(candidate)) return candidate;
|
|
97
|
+
}
|
|
98
|
+
return availableLevels[0] ?? "off";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function modelSupportsThinking(model) {
|
|
102
|
+
return listModelThinkingLevels(model).some((level) => level !== "off");
|
|
103
|
+
}
|
|
104
|
+
|
|
56
105
|
export function findPiModel({ provider, model, apiKey } = {}) {
|
|
57
106
|
const runtime = createPiRuntime({ provider, apiKey });
|
|
58
107
|
return {
|
|
@@ -14,9 +14,25 @@ export const daemonConfigDefaults = Object.freeze({
|
|
|
14
14
|
queuePollIntervalMs: 250
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
export const telegramConfigDefaults = Object.freeze({
|
|
18
|
+
modelPickerPageSize: 8
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const piConfigDefaults = Object.freeze({
|
|
22
|
+
thinkingLevel: "medium"
|
|
23
|
+
});
|
|
24
|
+
|
|
17
25
|
export function applyConfigDefaults(config) {
|
|
18
26
|
return {
|
|
19
27
|
...config,
|
|
28
|
+
telegram: {
|
|
29
|
+
...telegramConfigDefaults,
|
|
30
|
+
...(config.telegram || {})
|
|
31
|
+
},
|
|
32
|
+
pi: {
|
|
33
|
+
...piConfigDefaults,
|
|
34
|
+
...(config.pi || {})
|
|
35
|
+
},
|
|
20
36
|
daemons: {
|
|
21
37
|
...daemonConfigDefaults,
|
|
22
38
|
...(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 { buildEffortPicker, buildModelPicker, parseEffortPickerAction, 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, resolveChatThinkingLevel, selectChatModel, selectChatThinkingLevel } from "../../core/agent/model-selection.js";
|
|
11
|
+
import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } 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,102 @@ 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
|
+
selectedThinkingLevel: resolveChatThinkingLevel(config, ctx.chat.id),
|
|
430
|
+
page,
|
|
431
|
+
pageSize: config.telegram.modelPickerPageSize
|
|
432
|
+
});
|
|
433
|
+
const extra = { reply_markup: picker.replyMarkup };
|
|
434
|
+
const messageId = ctx.callbackQuery?.message?.message_id;
|
|
435
|
+
if (messageId) {
|
|
436
|
+
return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
|
|
437
|
+
}
|
|
438
|
+
return ctx.reply(picker.text, extra);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function showEffortPicker(ctx, { model, modelIndex, selectedThinkingLevel } = {}) {
|
|
442
|
+
const models = getProviderModels();
|
|
443
|
+
const resolvedModel = model || models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
|
|
444
|
+
if (!resolvedModel) {
|
|
445
|
+
throw new Error(`Model not found for provider ${config.pi.provider}`);
|
|
446
|
+
}
|
|
447
|
+
if (!modelSupportsThinking(resolvedModel)) {
|
|
448
|
+
const text = `${resolvedModel.provider}/${resolvedModel.id} does not support effort levels.`;
|
|
449
|
+
if (ctx.callbackQuery?.message?.message_id) {
|
|
450
|
+
return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
|
|
451
|
+
}
|
|
452
|
+
return ctx.reply(text);
|
|
453
|
+
}
|
|
454
|
+
const levels = listModelThinkingLevels(resolvedModel);
|
|
455
|
+
const picker = buildEffortPicker({
|
|
456
|
+
provider: resolvedModel.provider,
|
|
457
|
+
modelId: resolvedModel.id,
|
|
458
|
+
levels,
|
|
459
|
+
selectedThinkingLevel: selectedThinkingLevel
|
|
460
|
+
?? clampModelThinkingLevel(resolvedModel, resolveChatThinkingLevel(config, ctx.chat.id)),
|
|
461
|
+
modelIndex
|
|
462
|
+
});
|
|
463
|
+
const extra = { reply_markup: picker.replyMarkup };
|
|
464
|
+
const messageId = ctx.callbackQuery?.message?.message_id;
|
|
465
|
+
if (messageId) {
|
|
466
|
+
return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
|
|
467
|
+
}
|
|
468
|
+
return ctx.reply(picker.text, extra);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function persistChatModel(chatId, model, thinkingLevel) {
|
|
472
|
+
const key = chatKey(chatId);
|
|
473
|
+
const hadSelections = Boolean(config.pi.chatModels);
|
|
474
|
+
const previousSelection = config.pi.chatModels?.[key];
|
|
475
|
+
const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
|
|
476
|
+
selectChatModel(config, chatId, model, { thinkingLevel: level });
|
|
477
|
+
try {
|
|
478
|
+
await saveConfig(config);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (previousSelection) {
|
|
481
|
+
config.pi.chatModels[key] = previousSelection;
|
|
482
|
+
} else {
|
|
483
|
+
delete config.pi.chatModels[key];
|
|
484
|
+
if (!hadSelections) delete config.pi.chatModels;
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
agentManager.resetSession(chatId);
|
|
489
|
+
return level;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function persistChatEffort(chatId, model, thinkingLevel) {
|
|
493
|
+
const key = chatKey(chatId);
|
|
494
|
+
const hadSelections = Boolean(config.pi.chatModels);
|
|
495
|
+
const previousSelection = config.pi.chatModels?.[key];
|
|
496
|
+
const level = clampModelThinkingLevel(model, thinkingLevel);
|
|
497
|
+
selectChatThinkingLevel(config, chatId, level);
|
|
498
|
+
try {
|
|
499
|
+
await saveConfig(config);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (previousSelection) {
|
|
502
|
+
config.pi.chatModels[key] = previousSelection;
|
|
503
|
+
} else {
|
|
504
|
+
delete config.pi.chatModels[key];
|
|
505
|
+
if (!hadSelections) delete config.pi.chatModels;
|
|
506
|
+
}
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
return level;
|
|
510
|
+
}
|
|
511
|
+
|
|
413
512
|
async function buildIncomingPrompt(ctx) {
|
|
414
513
|
const chatId = ctx.chat.id;
|
|
415
514
|
logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
|
|
@@ -684,6 +783,18 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
684
783
|
await handleNewCommand(ctx);
|
|
685
784
|
});
|
|
686
785
|
|
|
786
|
+
bot.command("model", async (ctx) => {
|
|
787
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
788
|
+
if (!auth.ok) return;
|
|
789
|
+
await showModelPicker(ctx);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
bot.command("effort", async (ctx) => {
|
|
793
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
794
|
+
if (!auth.ok) return;
|
|
795
|
+
await showEffortPicker(ctx);
|
|
796
|
+
});
|
|
797
|
+
|
|
687
798
|
bot.command("auth", async (ctx) => {
|
|
688
799
|
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
689
800
|
if (!auth.ok) return;
|
|
@@ -717,6 +828,170 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
717
828
|
}
|
|
718
829
|
});
|
|
719
830
|
|
|
831
|
+
bot.on("callback_query:data", async (ctx, next) => {
|
|
832
|
+
const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
|
|
833
|
+
const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
|
|
834
|
+
const action = modelAction || effortAction;
|
|
835
|
+
if (!action) return next();
|
|
836
|
+
if (action.type === "noop") {
|
|
837
|
+
await ctx.answerCallbackQuery();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
|
|
842
|
+
if (!auth.ok) {
|
|
843
|
+
await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
if (action.type === "page") {
|
|
849
|
+
await showModelPicker(ctx, action.value);
|
|
850
|
+
await ctx.answerCallbackQuery();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (getChatState(ctx.chat.id).processing) {
|
|
855
|
+
await ctx.answerCallbackQuery({
|
|
856
|
+
text: action.type === "effort" || action.type === "model-effort"
|
|
857
|
+
? "Wait for the current response before changing effort."
|
|
858
|
+
: "Wait for the current response before changing models.",
|
|
859
|
+
show_alert: true
|
|
860
|
+
});
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const models = getProviderModels();
|
|
865
|
+
|
|
866
|
+
if (action.type === "select") {
|
|
867
|
+
const model = models[action.value];
|
|
868
|
+
if (!model) {
|
|
869
|
+
await ctx.answerCallbackQuery({
|
|
870
|
+
text: "This model list is no longer current. Run /model again.",
|
|
871
|
+
show_alert: true
|
|
872
|
+
});
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (modelSupportsThinking(model)) {
|
|
877
|
+
await showEffortPicker(ctx, {
|
|
878
|
+
model,
|
|
879
|
+
modelIndex: action.value,
|
|
880
|
+
selectedThinkingLevel: clampModelThinkingLevel(model, resolveChatThinkingLevel(config, ctx.chat.id))
|
|
881
|
+
});
|
|
882
|
+
await ctx.answerCallbackQuery({ text: `Choose effort for ${model.id}.` });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const currentModelId = resolveChatModel(config, ctx.chat.id);
|
|
887
|
+
const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
|
|
888
|
+
if (model.id === currentModelId && currentEffort === "off") {
|
|
889
|
+
await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
await persistChatModel(ctx.chat.id, model, "off");
|
|
894
|
+
await ctx.api.editMessageText(
|
|
895
|
+
ctx.chat.id,
|
|
896
|
+
ctx.callbackQuery.message.message_id,
|
|
897
|
+
`Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
|
|
898
|
+
);
|
|
899
|
+
await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (action.type === "model-effort") {
|
|
904
|
+
const model = models[action.modelIndex];
|
|
905
|
+
if (!model) {
|
|
906
|
+
await ctx.answerCallbackQuery({
|
|
907
|
+
text: "This model list is no longer current. Run /model again.",
|
|
908
|
+
show_alert: true
|
|
909
|
+
});
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const levels = listModelThinkingLevels(model);
|
|
913
|
+
if (!levels.includes(action.level)) {
|
|
914
|
+
await ctx.answerCallbackQuery({
|
|
915
|
+
text: "That effort level is not available for this model.",
|
|
916
|
+
show_alert: true
|
|
917
|
+
});
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const currentModelId = resolveChatModel(config, ctx.chat.id);
|
|
922
|
+
const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
|
|
923
|
+
if (model.id === currentModelId && action.level === currentEffort) {
|
|
924
|
+
await ctx.answerCallbackQuery({ text: `Already using ${model.id} at ${action.level}.` });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (model.id === currentModelId) {
|
|
929
|
+
await persistChatEffort(ctx.chat.id, model, action.level);
|
|
930
|
+
await ctx.api.editMessageText(
|
|
931
|
+
ctx.chat.id,
|
|
932
|
+
ctx.callbackQuery.message.message_id,
|
|
933
|
+
`Effort set to ${action.level} for ${model.provider}/${model.id}.`
|
|
934
|
+
);
|
|
935
|
+
await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
await persistChatModel(ctx.chat.id, model, action.level);
|
|
940
|
+
await ctx.api.editMessageText(
|
|
941
|
+
ctx.chat.id,
|
|
942
|
+
ctx.callbackQuery.message.message_id,
|
|
943
|
+
`Model changed to ${model.provider}/${model.id} (effort: ${action.level}).\nA new chat context will start with your next message.`
|
|
944
|
+
);
|
|
945
|
+
await ctx.answerCallbackQuery({ text: `Using ${model.id} / ${action.level}.` });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (action.type === "effort") {
|
|
950
|
+
const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
|
|
951
|
+
if (!model) {
|
|
952
|
+
await ctx.answerCallbackQuery({
|
|
953
|
+
text: "Current model is unavailable. Run /model again.",
|
|
954
|
+
show_alert: true
|
|
955
|
+
});
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (!modelSupportsThinking(model)) {
|
|
959
|
+
await ctx.answerCallbackQuery({
|
|
960
|
+
text: "This model does not support effort levels.",
|
|
961
|
+
show_alert: true
|
|
962
|
+
});
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const levels = listModelThinkingLevels(model);
|
|
966
|
+
if (!levels.includes(action.level)) {
|
|
967
|
+
await ctx.answerCallbackQuery({
|
|
968
|
+
text: "That effort level is not available for this model.",
|
|
969
|
+
show_alert: true
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
|
|
974
|
+
if (action.level === currentEffort) {
|
|
975
|
+
await ctx.answerCallbackQuery({ text: `Already using effort ${action.level}.` });
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
await persistChatEffort(ctx.chat.id, model, action.level);
|
|
979
|
+
await ctx.api.editMessageText(
|
|
980
|
+
ctx.chat.id,
|
|
981
|
+
ctx.callbackQuery.message.message_id,
|
|
982
|
+
`Effort set to ${action.level} for ${model.provider}/${model.id}.`
|
|
983
|
+
);
|
|
984
|
+
await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
|
|
985
|
+
}
|
|
986
|
+
} catch (error) {
|
|
987
|
+
logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
|
|
988
|
+
await ctx.answerCallbackQuery({
|
|
989
|
+
text: "Could not change the model or effort.",
|
|
990
|
+
show_alert: true
|
|
991
|
+
}).catch(() => {});
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
720
995
|
bot.on("message", async (ctx) => {
|
|
721
996
|
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
722
997
|
if (!auth.ok) return;
|
|
@@ -753,6 +1028,8 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
753
1028
|
config.telegram.chatMeta ||= {};
|
|
754
1029
|
await bot.api.setMyCommands([
|
|
755
1030
|
{ command: "new", description: "Start a new chat context" },
|
|
1031
|
+
{ command: "model", description: "Choose the model for this chat" },
|
|
1032
|
+
{ command: "effort", description: "Choose reasoning effort for this chat" },
|
|
756
1033
|
{ command: "auth", description: "Show Pi authentication status" }
|
|
757
1034
|
]);
|
|
758
1035
|
if (!taskTimer) {
|
|
@@ -0,0 +1,69 @@
|
|
|
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 parseEffortPickerAction(data) {
|
|
15
|
+
if (data === "noop:page") return { type: "noop", value: null };
|
|
16
|
+
const modelEffort = /^model-effort:(\d+):([a-z]+)$/.exec(String(data || ""));
|
|
17
|
+
if (modelEffort) {
|
|
18
|
+
return {
|
|
19
|
+
type: "model-effort",
|
|
20
|
+
modelIndex: Number(modelEffort[1]),
|
|
21
|
+
level: modelEffort[2]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const effort = /^effort:([a-z]+)$/.exec(String(data || ""));
|
|
25
|
+
if (effort) {
|
|
26
|
+
return {
|
|
27
|
+
type: "effort",
|
|
28
|
+
level: effort[1]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildModelPicker({ provider, models, selectedModelId, selectedThinkingLevel, page, pageSize }) {
|
|
35
|
+
if (!models.length) {
|
|
36
|
+
throw new Error(`No models available for provider ${provider}`);
|
|
37
|
+
}
|
|
38
|
+
const items = models.map((model) => ({
|
|
39
|
+
text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
|
|
40
|
+
}));
|
|
41
|
+
const effortLine = selectedThinkingLevel ? `\nEffort: ${selectedThinkingLevel}` : "";
|
|
42
|
+
return {
|
|
43
|
+
text: `Current model: ${provider}/${selectedModelId}${effortLine}\nSelect a model for this chat:`,
|
|
44
|
+
replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildEffortPicker({
|
|
49
|
+
provider,
|
|
50
|
+
modelId,
|
|
51
|
+
levels,
|
|
52
|
+
selectedThinkingLevel,
|
|
53
|
+
modelIndex
|
|
54
|
+
}) {
|
|
55
|
+
if (!levels.length) {
|
|
56
|
+
throw new Error(`No effort levels available for ${provider}/${modelId}`);
|
|
57
|
+
}
|
|
58
|
+
const rows = levels.map((level) => ([{
|
|
59
|
+
text: `${level === selectedThinkingLevel ? "✓ " : ""}${level}`,
|
|
60
|
+
callback_data: modelIndex == null ? `effort:${level}` : `model-effort:${modelIndex}:${level}`
|
|
61
|
+
}]));
|
|
62
|
+
const scope = modelIndex == null
|
|
63
|
+
? `Current model: ${provider}/${modelId}\nSelect effort for this chat:`
|
|
64
|
+
: `Model: ${provider}/${modelId}\nSelect effort:`;
|
|
65
|
+
return {
|
|
66
|
+
text: scope,
|
|
67
|
+
replyMarkup: { inline_keyboard: rows }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|
package/test/auth-flow.test.js
CHANGED
|
@@ -25,6 +25,8 @@ test("classifies invalidated Pi authentication tokens", () => {
|
|
|
25
25
|
test("classifies missing Pi authentication", () => {
|
|
26
26
|
for (const error of [
|
|
27
27
|
new Error("No auth found for provider"),
|
|
28
|
+
new Error("No API key for provider: openai-codex"),
|
|
29
|
+
new Error('No API key found for "openai-codex"'),
|
|
28
30
|
new Error("authentication credentials are missing")
|
|
29
31
|
]) {
|
|
30
32
|
assert.deepEqual(getPiAuthIssue(error), {
|
|
@@ -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,195 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
resolveChatModel,
|
|
6
|
+
resolveChatModelSelection,
|
|
7
|
+
resolveChatThinkingLevel,
|
|
8
|
+
selectChatModel,
|
|
9
|
+
selectChatThinkingLevel
|
|
10
|
+
} from "../src/core/agent/model-selection.js";
|
|
11
|
+
import { applyConfigDefaults, piConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
12
|
+
import {
|
|
13
|
+
clampModelThinkingLevel,
|
|
14
|
+
listModelThinkingLevels,
|
|
15
|
+
modelSupportsThinking
|
|
16
|
+
} from "../src/core/agent/pi-runtime.js";
|
|
17
|
+
import { getChatPiSessionsDir } from "../src/runtime/paths.js";
|
|
18
|
+
import {
|
|
19
|
+
buildEffortPicker,
|
|
20
|
+
buildModelPicker,
|
|
21
|
+
parseEffortPickerAction,
|
|
22
|
+
parseModelPickerAction
|
|
23
|
+
} from "../src/transport/telegram/model-picker.js";
|
|
24
|
+
|
|
25
|
+
function createConfig() {
|
|
26
|
+
return applyConfigDefaults({
|
|
27
|
+
telegram: {},
|
|
28
|
+
pi: {
|
|
29
|
+
provider: "openai-codex",
|
|
30
|
+
model: "gpt-default"
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("resolves the default model until a chat selects one", () => {
|
|
36
|
+
const config = createConfig();
|
|
37
|
+
|
|
38
|
+
assert.equal(resolveChatModel(config, 123), "gpt-default");
|
|
39
|
+
assert.equal(resolveChatThinkingLevel(config, 123), piConfigDefaults.thinkingLevel);
|
|
40
|
+
|
|
41
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" }, { thinkingLevel: "high" });
|
|
42
|
+
|
|
43
|
+
assert.equal(resolveChatModel(config, 123), "gpt-selected");
|
|
44
|
+
assert.equal(resolveChatThinkingLevel(config, 123), "high");
|
|
45
|
+
assert.equal(resolveChatModel(config, 456), "gpt-default");
|
|
46
|
+
assert.deepEqual(config.pi.chatModels["123"], {
|
|
47
|
+
provider: "openai-codex",
|
|
48
|
+
model: "gpt-selected",
|
|
49
|
+
thinkingLevel: "high",
|
|
50
|
+
sessionRevision: 1
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("starts a distinct persisted Pi session revision on every model change", () => {
|
|
55
|
+
const config = createConfig();
|
|
56
|
+
|
|
57
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" }, { thinkingLevel: "medium" });
|
|
58
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" }, { thinkingLevel: "high" });
|
|
59
|
+
|
|
60
|
+
assert.equal(config.pi.chatModels["123"].sessionRevision, 2);
|
|
61
|
+
assert.equal(
|
|
62
|
+
getChatPiSessionsDir(123, config.pi.chatModels["123"].sessionRevision),
|
|
63
|
+
path.join(getChatPiSessionsDir(123), "2")
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("updates effort without bumping the session revision", () => {
|
|
68
|
+
const config = createConfig();
|
|
69
|
+
|
|
70
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" }, { thinkingLevel: "medium" });
|
|
71
|
+
selectChatThinkingLevel(config, 123, "high");
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(resolveChatModelSelection(config, 123), {
|
|
74
|
+
provider: "openai-codex",
|
|
75
|
+
model: "gpt-a",
|
|
76
|
+
thinkingLevel: "high",
|
|
77
|
+
sessionRevision: 1
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("ignores a chat selection from a different active provider", () => {
|
|
82
|
+
const config = createConfig();
|
|
83
|
+
config.pi.chatModels = {
|
|
84
|
+
123: { provider: "anthropic", model: "claude-selected", thinkingLevel: "high", sessionRevision: 1 }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
assert.equal(resolveChatModel(config, 123), "gpt-default");
|
|
88
|
+
assert.equal(resolveChatThinkingLevel(config, 123), piConfigDefaults.thinkingLevel);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects selecting a model outside the active provider", () => {
|
|
92
|
+
const config = createConfig();
|
|
93
|
+
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => selectChatModel(config, 123, { provider: "anthropic", id: "claude-selected" }),
|
|
96
|
+
/active provider is openai-codex/
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("builds a paged model picker and marks the current model", () => {
|
|
101
|
+
const models = [
|
|
102
|
+
{ provider: "openai-codex", id: "gpt-a", reasoning: false, input: ["text"] },
|
|
103
|
+
{ provider: "openai-codex", id: "gpt-b", reasoning: true, input: ["text", "image"] },
|
|
104
|
+
{ provider: "openai-codex", id: "gpt-c", reasoning: false, input: ["text"] }
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const picker = buildModelPicker({
|
|
108
|
+
provider: "openai-codex",
|
|
109
|
+
models,
|
|
110
|
+
selectedModelId: "gpt-b",
|
|
111
|
+
selectedThinkingLevel: "high",
|
|
112
|
+
page: 0,
|
|
113
|
+
pageSize: 2
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.match(picker.text, /openai-codex\/gpt-b/);
|
|
117
|
+
assert.match(picker.text, /Effort: high/);
|
|
118
|
+
assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "model:0");
|
|
119
|
+
assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ gpt-b \[reasoning, image\]$/);
|
|
120
|
+
assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("builds an effort picker for the current model or pending model choice", () => {
|
|
124
|
+
const current = buildEffortPicker({
|
|
125
|
+
provider: "openai-codex",
|
|
126
|
+
modelId: "gpt-b",
|
|
127
|
+
levels: ["off", "low", "medium", "high"],
|
|
128
|
+
selectedThinkingLevel: "medium"
|
|
129
|
+
});
|
|
130
|
+
assert.match(current.text, /Current model: openai-codex\/gpt-b/);
|
|
131
|
+
assert.equal(current.replyMarkup.inline_keyboard[1][0].callback_data, "effort:low");
|
|
132
|
+
assert.match(current.replyMarkup.inline_keyboard[2][0].text, /^✓ medium$/);
|
|
133
|
+
|
|
134
|
+
const pending = buildEffortPicker({
|
|
135
|
+
provider: "openai-codex",
|
|
136
|
+
modelId: "gpt-b",
|
|
137
|
+
levels: ["off", "high"],
|
|
138
|
+
selectedThinkingLevel: "high",
|
|
139
|
+
modelIndex: 4
|
|
140
|
+
});
|
|
141
|
+
assert.match(pending.text, /^Model: openai-codex\/gpt-b/);
|
|
142
|
+
assert.equal(pending.replyMarkup.inline_keyboard[1][0].callback_data, "model-effort:4:high");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("parses only model picker callback data", () => {
|
|
146
|
+
assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
|
|
147
|
+
assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
|
|
148
|
+
assert.deepEqual(parseModelPickerAction("noop:page"), { type: "noop", value: null });
|
|
149
|
+
assert.equal(parseModelPickerAction("provider:1"), null);
|
|
150
|
+
assert.equal(parseModelPickerAction("model:-1"), null);
|
|
151
|
+
assert.equal(parseModelPickerAction("effort:high"), null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("parses effort picker callback data", () => {
|
|
155
|
+
assert.deepEqual(parseEffortPickerAction("effort:high"), { type: "effort", level: "high" });
|
|
156
|
+
assert.deepEqual(parseEffortPickerAction("model-effort:3:medium"), {
|
|
157
|
+
type: "model-effort",
|
|
158
|
+
modelIndex: 3,
|
|
159
|
+
level: "medium"
|
|
160
|
+
});
|
|
161
|
+
assert.deepEqual(parseEffortPickerAction("noop:page"), { type: "noop", value: null });
|
|
162
|
+
assert.equal(parseEffortPickerAction("model:1"), null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("centralizes picker defaults in config", () => {
|
|
166
|
+
const config = applyConfigDefaults({
|
|
167
|
+
telegram: {},
|
|
168
|
+
pi: { provider: "openai-codex", model: "gpt-default" }
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
|
|
172
|
+
assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("lists and clamps thinking levels from model capabilities", () => {
|
|
176
|
+
const reasoning = {
|
|
177
|
+
reasoning: true,
|
|
178
|
+
thinkingLevelMap: { xhigh: "xhigh", minimal: "low", max: null }
|
|
179
|
+
};
|
|
180
|
+
assert.deepEqual(listModelThinkingLevels(reasoning), [
|
|
181
|
+
"off",
|
|
182
|
+
"minimal",
|
|
183
|
+
"low",
|
|
184
|
+
"medium",
|
|
185
|
+
"high",
|
|
186
|
+
"xhigh"
|
|
187
|
+
]);
|
|
188
|
+
assert.equal(clampModelThinkingLevel(reasoning, "max"), "xhigh");
|
|
189
|
+
assert.equal(modelSupportsThinking(reasoning), true);
|
|
190
|
+
|
|
191
|
+
const plain = { reasoning: false };
|
|
192
|
+
assert.deepEqual(listModelThinkingLevels(plain), ["off"]);
|
|
193
|
+
assert.equal(clampModelThinkingLevel(plain, "high"), "off");
|
|
194
|
+
assert.equal(modelSupportsThinking(plain), false);
|
|
195
|
+
});
|