arisa 4.3.4 → 5.0.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.
Files changed (41) hide show
  1. package/AGENTS.md +18 -17
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +27 -0
  18. package/src/index.js +61 -6
  19. package/src/runtime/arisa-capabilities.js +45 -1
  20. package/src/runtime/bootstrap.js +3 -2
  21. package/src/runtime/create-app.js +47 -11
  22. package/src/runtime/doctor.js +307 -0
  23. package/src/runtime/log-viewer.js +165 -0
  24. package/src/runtime/paths.js +4 -1
  25. package/src/runtime/service-manager.js +106 -8
  26. package/src/runtime/tool-process-supervisor.js +107 -10
  27. package/src/transport/telegram/bot.js +533 -99
  28. package/src/transport/telegram/model-picker.js +28 -2
  29. package/test/agent-tool-policy.test.js +26 -1
  30. package/test/auth-flow.test.js +28 -2
  31. package/test/capabilities-security.test.js +37 -0
  32. package/test/context-and-task-bounds.test.js +279 -0
  33. package/test/daemon-runtime.test.js +130 -2
  34. package/test/dependency-warnings.test.js +17 -0
  35. package/test/doctor.test.js +90 -0
  36. package/test/log-viewer.test.js +90 -0
  37. package/test/model-selection.test.js +125 -2
  38. package/test/paths.test.js +8 -0
  39. package/test/pi-compaction.test.js +43 -0
  40. package/test/service-manager.test.js +234 -0
  41. package/test/task-store.test.js +31 -0
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { readFile, stat, unlink } from "node:fs/promises";
3
- import { createAgentSession, DefaultResourceLoader, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
6
  import { resolveChatModelSelection } from "./model-selection.js";
@@ -9,6 +9,7 @@ import { withTimeout } from "./prompt-timeout.js";
9
9
  import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
10
10
  import { createSystemShellTool } from "./system-shell-tool.js";
11
11
  import { clampModelThinkingLevel } from "./pi-runtime.js";
12
+ import { clampModelSpeed, createModelSpeedController } from "./model-speed.js";
12
13
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
13
14
 
14
15
  const piValidationTimeoutMs = 60_000;
@@ -24,6 +25,113 @@ const arisaToolNames = [
24
25
  "send_artifact"
25
26
  ];
26
27
 
28
+ function messageText(content) {
29
+ if (typeof content === "string") return content.trim();
30
+ if (!Array.isArray(content)) return "";
31
+ return content
32
+ .filter((item) => item?.type === "text" && typeof item.text === "string")
33
+ .map((item) => item.text.trim())
34
+ .filter(Boolean)
35
+ .join("\n");
36
+ }
37
+
38
+ export function formatPortableSessionHistory(messages = []) {
39
+ return messages
40
+ .map((message) => {
41
+ const text = messageText(message?.content);
42
+ if (!text) return "";
43
+ const role = message.role === "assistant"
44
+ ? "Assistant"
45
+ : message.role === "user"
46
+ ? "User"
47
+ : (message.customType ? `Session memory (${message.customType})` : "Session context");
48
+ return `${role}:\n${text}`;
49
+ })
50
+ .filter(Boolean)
51
+ .join("\n\n");
52
+ }
53
+
54
+ const estimatedImageTokens = 1_200;
55
+
56
+ function estimateContentTokens(content) {
57
+ if (typeof content === "string") return Math.ceil(content.length / 4);
58
+ if (!Array.isArray(content)) return 0;
59
+ const chars = content.reduce((total, item) => {
60
+ if (item?.type === "image") return total + estimatedImageTokens * 4;
61
+ if (typeof item?.text === "string") return total + item.text.length;
62
+ if (typeof item?.thinking === "string") return total + item.thinking.length;
63
+ if (item?.type === "toolCall") {
64
+ return total + String(item.name || "").length + JSON.stringify(item.arguments || {}).length;
65
+ }
66
+ return total;
67
+ }, 0);
68
+ return Math.ceil(chars / 4);
69
+ }
70
+
71
+ function estimateMessageTokens(message) {
72
+ if (["user", "assistant", "custom", "toolResult"].includes(message?.role)) {
73
+ return estimateContentTokens(message.content);
74
+ }
75
+ if (message?.role === "bashExecution") {
76
+ return Math.ceil((String(message.command || "").length + String(message.output || "").length) / 4);
77
+ }
78
+ if (["branchSummary", "compactionSummary"].includes(message?.role)) {
79
+ return Math.ceil(String(message.summary || "").length / 4);
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ function summarizeRetainedContext(messages = []) {
85
+ const sizes = messages.map((message) => ({
86
+ role: message?.role,
87
+ tokens: estimateMessageTokens(message)
88
+ }));
89
+ const estimatedTokens = sizes.reduce((total, item) => total + item.tokens, 0);
90
+ const toolResultTokens = sizes
91
+ .filter((item) => item.role === "toolResult")
92
+ .reduce((total, item) => total + item.tokens, 0);
93
+ const largestMessageTokens = sizes.reduce((largest, item) => Math.max(largest, item.tokens), 0);
94
+ return {
95
+ messages: messages.length,
96
+ estimatedTokens,
97
+ toolResultPercent: estimatedTokens ? toolResultTokens / estimatedTokens * 100 : 0,
98
+ largestMessagePercent: estimatedTokens ? largestMessageTokens / estimatedTokens * 100 : 0
99
+ };
100
+ }
101
+
102
+ function closeAgentSession(session) {
103
+ if (session?.close) return session.close();
104
+ if (session?.dispose) return session.dispose();
105
+ return undefined;
106
+ }
107
+
108
+ export const defaultScheduledTaskListLimit = 50;
109
+ export const maxScheduledTaskListLimit = 100;
110
+
111
+ export function selectScheduledTasks(tasks = [], { status, limit = defaultScheduledTaskListLimit } = {}) {
112
+ const parsedLimit = Number(limit);
113
+ const resolvedLimit = Math.min(
114
+ Math.max(Number.isFinite(parsedLimit) ? Math.trunc(parsedLimit) : defaultScheduledTaskListLimit, 1),
115
+ maxScheduledTaskListLimit
116
+ );
117
+ const allTasks = Array.isArray(tasks) ? tasks : [];
118
+ const orderedTasks = status
119
+ ? [...allTasks].reverse()
120
+ : [
121
+ ...allTasks.filter((task) => task.status === "pending" || task.status === "running").reverse(),
122
+ ...allTasks.filter((task) => task.status !== "pending" && task.status !== "running").reverse()
123
+ ];
124
+ const visibleTasks = orderedTasks.slice(0, resolvedLimit);
125
+
126
+ return {
127
+ tasks: visibleTasks,
128
+ total: allTasks.length,
129
+ returned: visibleTasks.length,
130
+ limit: resolvedLimit,
131
+ truncated: visibleTasks.length < allTasks.length
132
+ };
133
+ }
134
+
27
135
  function isLocalBaseUrl(value) {
28
136
  if (typeof value !== "string" || !value.trim()) return false;
29
137
  try {
@@ -90,11 +198,16 @@ async function assertDirectory(dir, label) {
90
198
  }
91
199
  }
92
200
 
93
- async function createArisaResourceLoader({ cwd, agentDir }) {
201
+ export function createPiSettingsManager(config) {
202
+ return SettingsManager.inMemory({ compaction: { ...config.pi.compaction } });
203
+ }
204
+
205
+ async function createArisaResourceLoader({ cwd, agentDir, settingsManager }) {
94
206
  const arisaAgentsContent = await readFile(arisaAgentsFile, "utf8");
95
207
  const resourceLoader = new DefaultResourceLoader({
96
208
  cwd,
97
209
  agentDir,
210
+ settingsManager,
98
211
  agentsFilesOverride: (current) => appendArisaAgentsFile(current, arisaAgentsContent)
99
212
  });
100
213
  await resourceLoader.reload();
@@ -110,22 +223,104 @@ export class AgentManager {
110
223
  this.logger = logger;
111
224
  this.sessions = new Map();
112
225
  this.pendingNewSessions = new Set();
226
+ this.pendingSessionHandoffs = new Map();
227
+ this.artifactDeliveryHandler = null;
228
+ this.sessionClosePromises = new Map();
229
+ }
230
+
231
+ setArtifactDeliveryHandler(handler) {
232
+ this.artifactDeliveryHandler = handler;
233
+ }
234
+
235
+ async deliverArtifact(payload) {
236
+ if (!this.artifactDeliveryHandler) throw new Error("Telegram artifact delivery is unavailable");
237
+ return this.artifactDeliveryHandler(payload);
238
+ }
239
+
240
+ closeCachedSession(sessionKey) {
241
+ const key = String(sessionKey);
242
+ const existing = this.sessions.get(key);
243
+ this.sessions.delete(key);
244
+ const closeSession = (existing?.session?.close || existing?.session?.dispose)
245
+ ? () => closeAgentSession(existing.session)
246
+ : null;
247
+ if (!closeSession) {
248
+ return this.sessionClosePromises.get(key) || Promise.resolve();
249
+ }
250
+
251
+ const previousClose = this.sessionClosePromises.get(key);
252
+ const closePromise = Promise.resolve(previousClose)
253
+ .catch(() => {})
254
+ .then(closeSession)
255
+ .catch((error) => {
256
+ this.logger?.error?.("agent", `session close failed for chat ${key}: ${error instanceof Error ? error.message : String(error)}`);
257
+ })
258
+ .finally(() => {
259
+ if (this.sessionClosePromises.get(key) === closePromise) {
260
+ this.sessionClosePromises.delete(key);
261
+ }
262
+ });
263
+ this.sessionClosePromises.set(key, closePromise);
264
+ return closePromise;
265
+ }
266
+
267
+ async waitForSessionClose(sessionKey) {
268
+ const key = String(sessionKey);
269
+ let closing = this.sessionClosePromises.get(key);
270
+ while (closing) {
271
+ await closing;
272
+ closing = this.sessionClosePromises.get(key);
273
+ }
113
274
  }
114
275
 
115
276
  setConfig(config) {
277
+ for (const key of this.sessions.keys()) this.closeCachedSession(key);
116
278
  this.config = config;
117
- this.sessions.clear();
118
279
  this.pendingNewSessions.clear();
280
+ this.pendingSessionHandoffs.clear();
119
281
  }
120
282
 
121
- resetSession(chatId) {
283
+ resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
122
284
  const sessionKey = String(chatId);
123
- this.sessions.delete(sessionKey);
285
+ this.closeCachedSession(sessionKey);
124
286
  this.pendingNewSessions.add(sessionKey);
287
+ const text = String(handoff || "").trim();
288
+ const parent = String(parentSession || "").trim();
289
+ if (text || parent) {
290
+ this.pendingSessionHandoffs.set(sessionKey, { text, parentSession: parent });
291
+ } else {
292
+ this.pendingSessionHandoffs.delete(sessionKey);
293
+ }
125
294
  }
126
295
 
127
296
  clearSessionCache(chatId) {
128
- this.sessions.delete(String(chatId));
297
+ this.closeCachedSession(String(chatId));
298
+ }
299
+
300
+ async getRuntimeDiagnostic({ contextInspectionTimeoutMs } = {}) {
301
+ const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
302
+ const base = { chatId };
303
+ try {
304
+ const stats = context.session.getSessionStats();
305
+ const retained = summarizeRetainedContext(context.session.messages);
306
+ return {
307
+ ...base,
308
+ ...retained,
309
+ tokens: stats.contextUsage?.tokens ?? null,
310
+ contextWindow: stats.contextUsage?.contextWindow ?? null,
311
+ percent: stats.contextUsage?.percent ?? null
312
+ };
313
+ } catch (error) {
314
+ return { ...base, error: error instanceof Error ? error.message : String(error) };
315
+ }
316
+ }));
317
+ return {
318
+ harness: "pi",
319
+ sessions: this.sessions.size,
320
+ closingSessions: this.sessionClosePromises.size,
321
+ managedProcessIds: [],
322
+ contexts
323
+ };
129
324
  }
130
325
 
131
326
  createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
@@ -133,36 +328,60 @@ export class AgentManager {
133
328
  const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
134
329
  if (this.pendingNewSessions.has(sessionKey)) {
135
330
  this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
136
- return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
331
+ const handoff = this.pendingSessionHandoffs.get(sessionKey);
332
+ const sessionManager = SessionManager.create(
333
+ workspaceDir,
334
+ sessionDir,
335
+ handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
336
+ );
337
+ if (handoff?.text) {
338
+ sessionManager.appendCustomMessageEntry(
339
+ "arisa-session-handoff",
340
+ handoff.text,
341
+ false,
342
+ { source: "telegram-new" }
343
+ );
344
+ }
345
+ return { sessionManager, isNewSession: true };
137
346
  }
138
347
  this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
139
348
  return { sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir), isNewSession: false };
140
349
  }
141
350
 
142
- async validatePiAgent() {
351
+ async validatePiAgent(config = this.config) {
143
352
  this.logger?.log("agent", "validating Pi session");
144
353
  const { authStorage, modelRegistry } = createPiRuntime({
145
- provider: this.config.pi.provider,
146
- apiKey: this.config.pi.apiKey
354
+ provider: config.pi.provider,
355
+ apiKey: config.pi.apiKey
147
356
  });
148
- const model = modelRegistry.find(this.config.pi.provider, this.config.pi.model);
357
+ const model = modelRegistry.find(config.pi.provider, config.pi.model);
149
358
  if (!model) {
150
- throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model}`);
359
+ throw new Error(`Model not found: ${config.pi.provider}/${config.pi.model}`);
151
360
  }
152
- if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
153
- throw new Error(`No auth found for ${this.config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
361
+ if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, { authStorage, modelRegistry })) {
362
+ throw new Error(`No auth found for ${config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
154
363
  }
155
364
 
365
+ const settingsManager = createPiSettingsManager(config);
156
366
  const { session } = await createAgentSession({
157
367
  authStorage,
158
368
  modelRegistry,
159
369
  model,
160
- sessionManager: SessionManager.inMemory(),
161
- });
162
- await withTimeout(promptAndThrowOnAssistantError(session, "Reply with exactly: OK"), {
163
- timeoutMs: piValidationTimeoutMs,
164
- label: "Pi validation prompt"
370
+ settingsManager,
371
+ sessionManager: SessionManager.inMemory()
165
372
  });
373
+ try {
374
+ await withTimeout(promptAndThrowOnAssistantError(session, "Reply with exactly: OK"), {
375
+ timeoutMs: piValidationTimeoutMs,
376
+ label: "Pi validation prompt"
377
+ });
378
+ } finally {
379
+ session.dispose();
380
+ }
381
+ }
382
+
383
+ async validateAgent(config = this.config) {
384
+ return this.validatePiAgent(config);
166
385
  }
167
386
 
168
387
  async getSessionContext(chatId, telegram) {
@@ -178,11 +397,16 @@ export class AgentManager {
178
397
  this.logger?.log("agent", `updating effort for chat ${sessionKey}: ${existing.session.thinkingLevel} -> ${desiredThinkingLevel}`);
179
398
  existing.session.setThinkingLevel(desiredThinkingLevel);
180
399
  }
400
+ const desiredSpeed = clampModelSpeed(existing.session.model, modelSelection.speed);
401
+ if (existing.speedController.speed !== desiredSpeed) {
402
+ this.logger?.log("agent", `updating speed for chat ${sessionKey}: ${existing.speedController.speed}x -> ${desiredSpeed}x`);
403
+ existing.speedController.setSpeed(desiredSpeed);
404
+ }
181
405
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
182
406
  return existing;
183
407
  }
184
408
  this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
185
- this.sessions.delete(sessionKey);
409
+ this.closeCachedSession(sessionKey);
186
410
  this.pendingNewSessions.add(sessionKey);
187
411
  }
188
412
 
@@ -196,6 +420,7 @@ export class AgentManager {
196
420
  throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
197
421
  }
198
422
  const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
423
+ const speed = clampModelSpeed(model, modelSelection.speed);
199
424
 
200
425
  const policy = buildPiToolPolicy({
201
426
  config: this.config,
@@ -208,14 +433,16 @@ export class AgentManager {
208
433
  modelSelection.sessionRevision
209
434
  );
210
435
  const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
211
- this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId} effort ${thinkingLevel}`);
436
+ this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId} effort ${thinkingLevel} speed ${speed}x`);
212
437
  const customTools = [
213
438
  ...this.createTools(telegram, chatId, policy),
214
439
  createSystemShellTool({ workspaceDir: policy.workspaceDir, shell: policy.shell })
215
440
  ];
441
+ const settingsManager = createPiSettingsManager(this.config);
216
442
  const resourceLoader = await createArisaResourceLoader({
217
443
  cwd: policy.workspaceDir,
218
- agentDir: arisaHomeDir
444
+ agentDir: arisaHomeDir,
445
+ settingsManager
219
446
  });
220
447
  const { session } = await createAgentSession({
221
448
  cwd: policy.workspaceDir,
@@ -228,8 +455,11 @@ export class AgentManager {
228
455
  tools: policy.tools,
229
456
  excludeTools: policy.excludeTools,
230
457
  customTools,
458
+ settingsManager,
231
459
  sessionManager
232
460
  });
461
+ const speedController = createModelSpeedController(session.agent.streamFn, speed);
462
+ session.agent.streamFn = speedController.streamFn;
233
463
 
234
464
  if (!hasExistingSession) {
235
465
  this.logger?.log("agent", `created new session for chat ${sessionKey}`);
@@ -239,14 +469,38 @@ export class AgentManager {
239
469
  })}`);
240
470
  }
241
471
 
242
- const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey };
472
+ const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey, speedController };
243
473
  this.sessions.set(sessionKey, ctx);
244
474
  if (isNewSession) {
245
475
  this.pendingNewSessions.delete(sessionKey);
476
+ this.pendingSessionHandoffs.delete(sessionKey);
246
477
  }
247
478
  return ctx;
248
479
  }
249
480
 
481
+ async getAvailableModels(chatId) {
482
+ const { listProviderModels } = await import("./pi-runtime.js");
483
+ const runtime = createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
484
+ return listProviderModels(this.config.pi.provider, runtime);
485
+ }
486
+
487
+ async setModelSpeed(chatId, speed) {
488
+ const context = this.sessions.get(String(chatId));
489
+ if (!context) return speed;
490
+ const effectiveSpeed = clampModelSpeed(context.session.model, speed);
491
+ context.speedController.setSpeed(effectiveSpeed);
492
+ return effectiveSpeed;
493
+ }
494
+
495
+ async close() {
496
+ const contexts = [...this.sessions.values()];
497
+ this.sessions.clear();
498
+ await Promise.allSettled([
499
+ ...this.sessionClosePromises.values(),
500
+ ...contexts.map((context) => closeAgentSession(context.session))
501
+ ]);
502
+ }
503
+
250
504
  async runTool({ name, request, chatId }) {
251
505
  await this.toolRegistry.load();
252
506
  this.logger?.log("agent", `run_tool ${name}`);
@@ -297,7 +551,7 @@ export class AgentManager {
297
551
  defineTool({
298
552
  name: "list_tools",
299
553
  label: "List tools",
300
- description: "List Arisa core, native shell, and modular CLI tools with their capabilities.",
554
+ description: "List Arisa core, native shell, and modular CLI tools with their capabilities and daemon diagnostics.",
301
555
  parameters: Type.Object({}),
302
556
  execute: async () => {
303
557
  await this.toolRegistry.load();
@@ -313,7 +567,7 @@ export class AgentManager {
313
567
  shell: policy.shell.shellPath || (process.platform === "win32" ? "powershell" : "sh"),
314
568
  enabled: !(policy.excludeTools || []).includes("system_shell")
315
569
  }];
316
- const cliTools = this.toolRegistry.list().map((tool) => ({
570
+ const cliTools = (await this.toolRegistry.listWithRuntime(chatId)).map((tool) => ({
317
571
  ...tool,
318
572
  source: "arisa-modular",
319
573
  invocation: "run_tool"
@@ -410,15 +664,20 @@ export class AgentManager {
410
664
  defineTool({
411
665
  name: "list_scheduled_tasks",
412
666
  label: "List scheduled tasks",
413
- description: "List scheduled async tasks for the current Telegram chat.",
667
+ description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending/running tasks, and accept an optional limit up to 100.",
414
668
  parameters: Type.Object({
415
- status: Type.Optional(Type.String())
669
+ status: Type.Optional(Type.String()),
670
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
416
671
  }),
417
672
  execute: async (_id, params) => {
418
673
  const tasks = await this.taskStore.list({ chatId, status: params.status });
674
+ const result = selectScheduledTasks(tasks, {
675
+ status: params.status,
676
+ limit: params.limit
677
+ });
419
678
  return {
420
- content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
421
- details: { tasks }
679
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
680
+ details: result
422
681
  };
423
682
  }
424
683
  }),
@@ -1,10 +1,11 @@
1
1
  import { createPiRuntime, hasProviderAuth, supportsProviderOAuth } from "./pi-runtime.js";
2
+ import { resolveChatModelSelection } from "./model-selection.js";
2
3
 
3
4
  const authInvalidatedPatterns = [
4
5
  /authentication token has been invalidated/i,
5
6
  /token (?:has been )?invalidated/i,
6
7
  /try signing in again/i,
7
- /auth(?:entication)? token (?:expired|revoked|invalid)/i
8
+ /auth(?:entication)? token (?:is |has been )?(?:expired|revoked|invalid)/i
8
9
  ];
9
10
 
10
11
  const missingAuthPatterns = [
@@ -32,23 +33,26 @@ export function getPiAuthIssue(error) {
32
33
  return null;
33
34
  }
34
35
 
35
- export function getPiAuthStatus(config) {
36
+ export function getPiAuthStatus(config, chatId = null) {
36
37
  const runtime = createPiRuntime({
37
38
  provider: config.pi.provider,
38
39
  apiKey: config.pi.apiKey
39
40
  });
41
+ const modelSelection = chatId == null
42
+ ? { provider: config.pi.provider, model: config.pi.model }
43
+ : resolveChatModelSelection(config, chatId);
40
44
 
41
45
  return {
42
- provider: config.pi.provider,
43
- model: config.pi.model,
46
+ provider: modelSelection.provider,
47
+ model: modelSelection.model,
44
48
  hasApiKey: Boolean(config.pi.apiKey),
45
49
  hasStoredAuth: hasProviderAuth(config.pi.provider, runtime),
46
50
  supportsOAuth: supportsProviderOAuth(config.pi.provider, runtime)
47
51
  };
48
52
  }
49
53
 
50
- export function buildPiAuthTelegramMessage({ config, issue = null, verified = false }) {
51
- const status = getPiAuthStatus(config);
54
+ export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
55
+ const status = getPiAuthStatus(config, chatId);
52
56
  let title = `Pi authentication status for ${status.provider}/${status.model}.`;
53
57
  if (issue) {
54
58
  title = `Pi authentication needs attention for ${status.provider}/${status.model}.`;
@@ -91,8 +95,8 @@ export function buildPiAuthTelegramMessage({ config, issue = null, verified = fa
91
95
  return lines.join("\n");
92
96
  }
93
97
 
94
- export function buildPiAuthRecoveryBlockedMessage({ config, issue = null, renewalActive = false }) {
95
- const status = getPiAuthStatus(config);
98
+ export function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
99
+ const status = getPiAuthStatus(config, chatId);
96
100
  const lines = [
97
101
  `Pi authentication is not ready for ${status.provider}/${status.model}.`,
98
102
  "I did not send your message to the agent."
@@ -1,7 +1,13 @@
1
+ import { normalizeModelSpeed } from "./model-speed.js";
2
+
1
3
  function chatKey(chatId) {
2
4
  return String(chatId);
3
5
  }
4
6
 
7
+ export function getAgentConfig(config) {
8
+ return config.pi;
9
+ }
10
+
5
11
  function normalizeSessionRevision(sessionRevision) {
6
12
  if (sessionRevision == null) return 0;
7
13
  if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
@@ -11,12 +17,14 @@ function normalizeSessionRevision(sessionRevision) {
11
17
  }
12
18
 
13
19
  export function resolveChatModelSelection(config, chatId) {
14
- const selection = config.pi.chatModels?.[chatKey(chatId)];
15
- if (!selection || selection.provider !== config.pi.provider) {
20
+ const agentConfig = getAgentConfig(config);
21
+ const selection = agentConfig.chatModels?.[chatKey(chatId)];
22
+ if (!selection || selection.provider !== agentConfig.provider) {
16
23
  return {
17
- provider: config.pi.provider,
18
- model: config.pi.model,
19
- thinkingLevel: config.pi.thinkingLevel,
24
+ provider: agentConfig.provider,
25
+ model: agentConfig.model,
26
+ thinkingLevel: agentConfig.thinkingLevel,
27
+ ...(agentConfig.speed !== undefined ? { speed: normalizeModelSpeed(agentConfig.speed) } : {}),
20
28
  sessionRevision: 0
21
29
  };
22
30
  }
@@ -24,7 +32,10 @@ export function resolveChatModelSelection(config, chatId) {
24
32
  return {
25
33
  provider: selection.provider,
26
34
  model: selection.model,
27
- thinkingLevel: selection.thinkingLevel ?? config.pi.thinkingLevel,
35
+ thinkingLevel: selection.thinkingLevel ?? agentConfig.thinkingLevel,
36
+ ...(agentConfig.speed !== undefined
37
+ ? { speed: normalizeModelSpeed(selection.speed ?? agentConfig.speed) }
38
+ : {}),
28
39
  sessionRevision
29
40
  };
30
41
  }
@@ -37,29 +48,58 @@ export function resolveChatThinkingLevel(config, chatId) {
37
48
  return resolveChatModelSelection(config, chatId).thinkingLevel;
38
49
  }
39
50
 
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}`);
51
+ export function resolveChatSpeed(config, chatId) {
52
+ const speed = resolveChatModelSelection(config, chatId).speed;
53
+ if (speed === undefined) throw new Error("Model speed is not configured for the active runtime");
54
+ return speed;
55
+ }
56
+
57
+ export function selectChatModel(config, chatId, model, { thinkingLevel, speed } = {}) {
58
+ const agentConfig = getAgentConfig(config);
59
+ if (model.provider !== agentConfig.provider) {
60
+ throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${agentConfig.provider}`);
43
61
  }
44
- config.pi.chatModels ||= {};
62
+ agentConfig.chatModels ||= {};
45
63
  const key = chatKey(chatId);
46
- const sessionRevision = (config.pi.chatModels[key]?.sessionRevision || 0) + 1;
47
- config.pi.chatModels[key] = {
64
+ const sessionRevision = (agentConfig.chatModels[key]?.sessionRevision || 0) + 1;
65
+ agentConfig.chatModels[key] = {
48
66
  provider: model.provider,
49
67
  model: model.id,
50
68
  thinkingLevel,
69
+ ...(agentConfig.speed !== undefined
70
+ ? { speed: normalizeModelSpeed(speed ?? resolveChatModelSelection(config, chatId).speed) }
71
+ : {}),
51
72
  sessionRevision
52
73
  };
53
74
  }
54
75
 
55
76
  export function selectChatThinkingLevel(config, chatId, thinkingLevel) {
56
- config.pi.chatModels ||= {};
77
+ const agentConfig = getAgentConfig(config);
78
+ agentConfig.chatModels ||= {};
57
79
  const key = chatKey(chatId);
58
80
  const current = resolveChatModelSelection(config, chatId);
59
- config.pi.chatModels[key] = {
81
+ agentConfig.chatModels[key] = {
60
82
  provider: current.provider,
61
83
  model: current.model,
62
84
  thinkingLevel,
85
+ ...(current.speed !== undefined ? { speed: current.speed } : {}),
86
+ sessionRevision: current.sessionRevision
87
+ };
88
+ }
89
+
90
+ export function selectChatSpeed(config, chatId, speed) {
91
+ const agentConfig = getAgentConfig(config);
92
+ if (agentConfig.speed === undefined) {
93
+ throw new Error("Model speed is not configured for the active runtime");
94
+ }
95
+ agentConfig.chatModels ||= {};
96
+ const key = chatKey(chatId);
97
+ const current = resolveChatModelSelection(config, chatId);
98
+ agentConfig.chatModels[key] = {
99
+ provider: current.provider,
100
+ model: current.model,
101
+ thinkingLevel: current.thinkingLevel,
102
+ speed: normalizeModelSpeed(speed),
63
103
  sessionRevision: current.sessionRevision
64
104
  };
65
105
  }