arisa 5.1.49 → 5.1.64

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 (56) hide show
  1. package/AGENTS.md +0 -2
  2. package/README.md +9 -0
  3. package/package.json +1 -1
  4. package/src/core/agent/agent-manager.js +49 -489
  5. package/src/core/agent/agent-session-lifecycle.js +181 -0
  6. package/src/core/agent/pi-capability-tools.js +183 -0
  7. package/src/core/artifacts/artifact-store.js +73 -17
  8. package/src/core/capabilities/capability-service.js +340 -0
  9. package/src/core/config/config-defaults.js +28 -1
  10. package/src/core/tasks/task-routing.js +7 -0
  11. package/src/core/tasks/task-runner.js +68 -0
  12. package/src/core/tasks/task-store.js +382 -92
  13. package/src/core/tools/tool-output-materializer.js +5 -5
  14. package/src/core/tools/tool-registry.js +20 -5
  15. package/src/core/tools/weighted-resource-governor.js +153 -0
  16. package/src/index.js +20 -0
  17. package/src/official-tools.lock.json +62 -45
  18. package/src/runtime/arisa-capabilities.js +51 -242
  19. package/src/runtime/create-app.js +11 -2
  20. package/src/runtime/create-headless-app.js +7 -4
  21. package/src/runtime/paths.js +4 -0
  22. package/src/runtime/service-manager.js +3 -1
  23. package/src/runtime/service-supervisor.js +98 -0
  24. package/src/transport/telegram/bot.js +186 -374
  25. package/src/transport/telegram/chat-queue.js +83 -6
  26. package/src/transport/telegram/prompt-builders.js +9 -0
  27. package/src/transport/telegram/reply-topic-routing.js +111 -0
  28. package/src/transport/telegram/task-dispatcher.js +96 -36
  29. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  30. package/src/transport/telegram/telegram-session-bridge.js +177 -0
  31. package/src/transport/telegram/telegram-tools-command.js +28 -0
  32. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  33. package/src/transport/telegram/workspace-topic-store.js +228 -0
  34. package/test/agent-session-lifecycle.test.js +58 -0
  35. package/test/artifact-store.test.js +38 -2
  36. package/test/capabilities-security.test.js +58 -0
  37. package/test/chat-queue.test.js +32 -0
  38. package/test/context-and-task-bounds.test.js +76 -1
  39. package/test/device-code-message.test.js +9 -0
  40. package/test/media-caption.test.js +1 -1
  41. package/test/model-selection.test.js +9 -1
  42. package/test/official-tool-dependencies.test.js +1 -1
  43. package/test/paths.test.js +8 -0
  44. package/test/pi-capability-tools.test.js +65 -0
  45. package/test/service-manager.test.js +48 -0
  46. package/test/session-start-operational-notes.test.js +1 -1
  47. package/test/task-idempotency.test.js +40 -0
  48. package/test/task-routing.test.js +62 -0
  49. package/test/task-store.test.js +231 -7
  50. package/test/telegram-reply-topic-routing.test.js +94 -0
  51. package/test/telegram-task-dispatcher.test.js +150 -23
  52. package/test/telegram-text-artifact.test.js +13 -2
  53. package/test/telegram-tools-command.test.js +47 -0
  54. package/test/telegram-workspace-topic-store.test.js +124 -0
  55. package/test/tool-registry-run.test.js +41 -0
  56. package/test/weighted-resource-governor.test.js +95 -0
package/AGENTS.md CHANGED
@@ -169,8 +169,6 @@ Beyond time-based scheduling, tools can drive an event queue that wakes the agen
169
169
  - `poll_tool`: a recurring checker the poller **runs directly as a tool** (no agent turn spent). The poller materializes its output with the same logic as `run_tool`, so any `agent_event` the checker emits is enqueued for the next tick. Its `recurrence` reschedules the next poll.
170
170
  - `agent_event`: an incoming event. The poller delivers it as a prompt so the active runtime evaluates it and decides the next action (it may stay silent).
171
171
 
172
- Tasks without a `runAt` fire immediately, so `agent_event` and the first `poll_tool` run on the next tick.
173
-
174
172
  The poller dispatches all three kinds, but only `agent_task` is exercised by a catalog tool today (`schedule-agent-task`). The following is the pattern to follow when a checker tool is built:
175
173
 
176
174
  How a tool wires its own polling:
package/README.md CHANGED
@@ -61,6 +61,11 @@ The result is a toolset shaped by how you actually use the assistant, not by def
61
61
  - while a chat is busy, concurrent text steers Pi's active run by default
62
62
  - set `telegram.busyMessageMode` to `"queue"` to keep concurrent text messages in order; override one chat with `telegram.chatMeta[chatId].busyMessageMode`
63
63
  - media and normalized audio stay queued, and failed steering falls back to the ordered queue
64
+ - an owner-workspace General topic may route only the assistant's visible reply to a matching topic; the incoming message and General session remain unchanged, and ambiguous replies stay in General
65
+ - reply classification never runs in private chats or outside General
66
+ - each owner workspace keeps a dynamic, chat-scoped topic registry; Arisa learns topic creation, rename, close, and reopen events, and records the context of topics it creates or initializes
67
+ - when a substantial theme recurs in General without a matching topic, Arisa may occasionally propose a new one; creation still requires explicit user confirmation
68
+ - legacy `replyTopics` configuration is imported once into the dynamic registry and then removed
64
69
 
65
70
  ### Tool model
66
71
  No tools ship with the core. All installed tools live under `~/.arisa/tools/<tool-name>`, whether they come from the [official catalog](https://github.com/clasen/Arisa/tree/main/tools), from another source the user chooses, or are created by the agent itself.
@@ -176,6 +181,10 @@ arisa --silent # run without verbose logs
176
181
 
177
182
  Authorized Telegram chats can run the same safe service lifecycle with `/restart`.
178
183
 
184
+ Background mode runs the Telegram/Pi worker under a lightweight supervisor. Unexpected worker exits use bounded exponential restart backoff. Scheduled agent tasks are serialized FIFO per conversation while different conversations remain independent; execution deadlines default to 15 minutes for scheduled prompts and 5 minutes for agent events. A timed-out turn is marked outcome-uncertain and is never replayed automatically. These policies can be overridden with `service.workerRestart*` and `tasks.*TimeoutMs` in the Arisa config.
185
+
186
+ Tools may declare weighted execution resources in their manifest, for example `"execution": { "resourceClass": "browser", "weight": 1 }`. Runs sharing a declared class queue fairly once they reach its capacity; undeclared lightweight tools remain unconstrained. The default capacity is two per declared class. Override it with `toolExecution.defaultCapacity`, `toolExecution.capacities`, and `toolExecution.maxQueuedPerClass`. Arisa logs queue waits and new worker RSS peaks for operational measurement.
187
+
179
188
  Runtime model override (current process only):
180
189
 
181
190
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.1.49",
3
+ "version": "5.1.64",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,18 +1,16 @@
1
- import path from "node:path";
2
- import { readFileSync } from "node:fs";
3
1
  import { readFile, stat } from "node:fs/promises";
4
- import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, defineTool } from "@earendil-works/pi-coding-agent";
5
- import { Type } from "@sinclair/typebox";
2
+ import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
6
3
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
7
4
  import { resolveChatModelSelection } from "./model-selection.js";
8
5
  import { appendArisaAgentsFile, arisaAgentsFile, arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
9
6
  import { withTimeout } from "./prompt-timeout.js";
10
- import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
7
+ import { buildPiToolPolicy } from "./core-tools.js";
11
8
  import { createSystemShellTool } from "./system-shell-tool.js";
12
9
  import { clampModelThinkingLevel } from "./pi-runtime.js";
13
10
  import { clampModelSpeed, createModelSpeedController } from "./model-speed.js";
14
- import { arisaHomeDir, getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../runtime/paths.js";
15
- import { searchOfficialToolCatalog } from "../tools/official-tool-catalog.js";
11
+ import { arisaHomeDir } from "../../runtime/paths.js";
12
+ import { AgentSessionLifecycle } from "./agent-session-lifecycle.js";
13
+ import { createPiCapabilityTools } from "./pi-capability-tools.js";
16
14
  import { ToolResourceNoteStore } from "../tools/tool-resource-note-store.js";
17
15
  import { materializeToolOutput } from "../tools/tool-output-materializer.js";
18
16
 
@@ -32,35 +30,6 @@ const arisaToolNames = [
32
30
  "send_artifact"
33
31
  ];
34
32
 
35
- const operationalNoteMaxChars = 220;
36
-
37
- function normalizeOperationalNote(note) {
38
- const text = typeof note === "string" ? note : note?.text;
39
- const trimmed = String(text || "").replace(/\s+/g, " ").trim();
40
- if (!trimmed) return "";
41
- return trimmed.length <= operationalNoteMaxChars ? trimmed : `${trimmed.slice(0, operationalNoteMaxChars - 1).trim()}…`;
42
- }
43
-
44
- export function loadSessionStartOperationalNotes() {
45
- try {
46
- const raw = readFileSync(sessionStartOperationalNotesFile, "utf8");
47
- const parsed = JSON.parse(raw);
48
- const notes = Array.isArray(parsed) ? parsed : parsed?.notes;
49
- if (!Array.isArray(notes)) return [];
50
- return notes.map(normalizeOperationalNote).filter(Boolean).slice(0, 20);
51
- } catch {
52
- return [];
53
- }
54
- }
55
-
56
- function formatSessionStartOperationalNotes(notes) {
57
- if (!notes.length) return "";
58
- return [
59
- "Durable operating notes for this Arisa session:",
60
- ...notes.map((note) => `- ${note}`)
61
- ].join("\n");
62
- }
63
-
64
33
  const estimatedImageTokens = 1_200;
65
34
 
66
35
  function estimateContentTokens(content) {
@@ -119,39 +88,6 @@ function guardTools(tools, accessGuard) {
119
88
  }));
120
89
  }
121
90
 
122
- function closeAgentSession(session) {
123
- if (session?.close) return session.close();
124
- if (session?.dispose) return session.dispose();
125
- return undefined;
126
- }
127
-
128
- export const defaultScheduledTaskListLimit = 50;
129
- export const maxScheduledTaskListLimit = 100;
130
-
131
- export function selectScheduledTasks(tasks = [], { status, limit = defaultScheduledTaskListLimit } = {}) {
132
- const parsedLimit = Number(limit);
133
- const resolvedLimit = Math.min(
134
- Math.max(Number.isFinite(parsedLimit) ? Math.trunc(parsedLimit) : defaultScheduledTaskListLimit, 1),
135
- maxScheduledTaskListLimit
136
- );
137
- const allTasks = Array.isArray(tasks) ? tasks : [];
138
- const orderedTasks = status
139
- ? [...allTasks].reverse()
140
- : [
141
- ...allTasks.filter((task) => task.status === "pending" || task.status === "running").reverse(),
142
- ...allTasks.filter((task) => task.status !== "pending" && task.status !== "running").reverse()
143
- ];
144
- const visibleTasks = orderedTasks.slice(0, resolvedLimit);
145
-
146
- return {
147
- tasks: visibleTasks,
148
- total: allTasks.length,
149
- returned: visibleTasks.length,
150
- limit: resolvedLimit,
151
- truncated: visibleTasks.length < allTasks.length
152
- };
153
- }
154
-
155
91
  function isLocalBaseUrl(value) {
156
92
  if (typeof value !== "string" || !value.trim()) return false;
157
93
  try {
@@ -185,32 +121,6 @@ async function promptAndThrowOnAssistantError(session, prompt) {
185
121
  }
186
122
  }
187
123
 
188
- function inferDeliveryMethod(artifact) {
189
- if (artifact.kind === "audio" || (artifact.mimeType || "").startsWith("audio/")) return "audio";
190
- if (artifact.kind === "image" || (artifact.mimeType || "").startsWith("image/")) return "photo";
191
- if (artifact.kind === "video" || (artifact.mimeType || "").startsWith("video/")) return "video";
192
- return "document";
193
- }
194
-
195
- function containsAbsolutePath(value) {
196
- if (typeof value !== "string") return false;
197
- return /(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(value);
198
- }
199
-
200
- export function resolveMediaCaption(caption) {
201
- if (caption && !containsAbsolutePath(caption)) return caption;
202
- return undefined;
203
- }
204
-
205
- async function deliverArtifactToChat({ artifact, telegram, caption, method, logger }) {
206
- const resolvedMethod = method || artifact.metadata?.delivery?.method || inferDeliveryMethod(artifact);
207
- const fileName = path.basename(artifact.path);
208
- const resolvedCaption = resolveMediaCaption(caption);
209
- logger?.log("agent", `deliver artifact ${artifact.id} as ${resolvedMethod}`);
210
- await telegram.sendMedia(artifact.path, { method: resolvedMethod, caption: resolvedCaption, filename: fileName });
211
- return { method: resolvedMethod, fileName, artifactId: artifact.id };
212
- }
213
-
214
124
  async function assertDirectory(dir, label) {
215
125
  const stats = await stat(dir);
216
126
  if (!stats.isDirectory()) {
@@ -242,11 +152,21 @@ export class AgentManager {
242
152
  this.taskStore = taskStore;
243
153
  this.logger = logger;
244
154
  this.resourceNotes = new ToolResourceNoteStore();
245
- this.sessions = new Map();
246
- this.pendingNewSessions = new Set();
247
- this.pendingSessionHandoffs = new Map();
155
+ this.sessionLifecycle = new AgentSessionLifecycle({
156
+ logger,
157
+ summarizeContext: summarizeRetainedContext
158
+ });
159
+ this.sessions = this.sessionLifecycle.sessions;
160
+ this.pendingNewSessions = this.sessionLifecycle.pendingNewSessions;
161
+ this.pendingSessionHandoffs = this.sessionLifecycle.pendingSessionHandoffs;
162
+ this.sessionClosePromises = this.sessionLifecycle.sessionClosePromises;
248
163
  this.artifactDeliveryHandler = null;
249
- this.sessionClosePromises = new Map();
164
+ this.capabilityService = null;
165
+ }
166
+
167
+ setCapabilityService(capabilityService) {
168
+ if (!capabilityService?.execute) throw new Error("AgentManager requires CapabilityService");
169
+ this.capabilityService = capabilityService;
250
170
  }
251
171
 
252
172
  setArtifactDeliveryHandler(handler) {
@@ -259,122 +179,42 @@ export class AgentManager {
259
179
  }
260
180
 
261
181
  closeCachedSession(sessionKey) {
262
- const key = String(sessionKey);
263
- const existing = this.sessions.get(key);
264
- this.sessions.delete(key);
265
- const closeSession = (existing?.session?.close || existing?.session?.dispose)
266
- ? () => closeAgentSession(existing.session)
267
- : null;
268
- if (!closeSession) {
269
- return this.sessionClosePromises.get(key) || Promise.resolve();
270
- }
271
-
272
- const previousClose = this.sessionClosePromises.get(key);
273
- const closePromise = Promise.resolve(previousClose)
274
- .catch(() => {})
275
- .then(closeSession)
276
- .catch((error) => {
277
- this.logger?.error?.("agent", `session close failed for chat ${key}: ${error instanceof Error ? error.message : String(error)}`);
278
- })
279
- .finally(() => {
280
- if (this.sessionClosePromises.get(key) === closePromise) {
281
- this.sessionClosePromises.delete(key);
282
- }
283
- });
284
- this.sessionClosePromises.set(key, closePromise);
285
- return closePromise;
182
+ return this.sessionLifecycle.closeCached(sessionKey);
286
183
  }
287
184
 
288
- async waitForSessionClose(sessionKey) {
289
- const key = String(sessionKey);
290
- let closing = this.sessionClosePromises.get(key);
291
- while (closing) {
292
- await closing;
293
- closing = this.sessionClosePromises.get(key);
294
- }
185
+ waitForSessionClose(sessionKey) {
186
+ return this.sessionLifecycle.waitForClose(sessionKey);
295
187
  }
296
188
 
297
189
  setConfig(config) {
298
- for (const key of this.sessions.keys()) this.closeCachedSession(key);
190
+ this.sessionLifecycle.resetConfigState();
299
191
  this.config = config;
300
- this.pendingNewSessions.clear();
301
- this.pendingSessionHandoffs.clear();
302
192
  }
303
193
 
304
- resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
305
- const sessionKey = String(chatId);
306
- this.closeCachedSession(sessionKey);
307
- this.pendingNewSessions.add(sessionKey);
308
- const text = String(handoff || "").trim();
309
- const parent = String(parentSession || "").trim();
310
- if (text || parent) {
311
- this.pendingSessionHandoffs.set(sessionKey, { text, parentSession: parent });
312
- } else {
313
- this.pendingSessionHandoffs.delete(sessionKey);
314
- }
194
+ resetSession(chatId, options = {}) {
195
+ this.sessionLifecycle.resetSession(chatId, options);
315
196
  }
316
197
 
317
198
  clearSessionCache(chatId) {
318
- this.closeCachedSession(String(chatId));
199
+ this.sessionLifecycle.closeCached(String(chatId));
319
200
  }
320
201
 
321
- async getRuntimeDiagnostic() {
322
- const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
323
- const base = { chatId };
324
- try {
325
- const stats = context.session.getSessionStats();
326
- const retained = summarizeRetainedContext(context.session.messages);
327
- return {
328
- ...base,
329
- ...retained,
330
- tokens: stats.contextUsage?.tokens ?? null,
331
- contextWindow: stats.contextUsage?.contextWindow ?? null,
332
- percent: stats.contextUsage?.percent ?? null
333
- };
334
- } catch (error) {
335
- return { ...base, error: error instanceof Error ? error.message : String(error) };
336
- }
337
- }));
338
- return {
339
- harness: "pi",
340
- sessions: this.sessions.size,
341
- closingSessions: this.sessionClosePromises.size,
342
- contexts
343
- };
202
+ async abortSession(chatId) {
203
+ const sessionKey = String(chatId);
204
+ const context = this.sessions.get(sessionKey);
205
+ try {
206
+ await context?.session?.abort?.();
207
+ } finally {
208
+ await this.sessionLifecycle.closeCached(sessionKey);
209
+ }
210
+ }
211
+
212
+ getRuntimeDiagnostic() {
213
+ return this.sessionLifecycle.getDiagnostic();
344
214
  }
345
215
 
346
216
  createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
347
- const sessionKey = String(chatId);
348
- const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
349
- if (this.pendingNewSessions.has(sessionKey)) {
350
- this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
351
- const handoff = this.pendingSessionHandoffs.get(sessionKey);
352
- const sessionManager = SessionManager.create(
353
- workspaceDir,
354
- sessionDir,
355
- handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
356
- );
357
- const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
358
- if (operationalNotes) {
359
- sessionManager.appendCustomMessageEntry(
360
- "arisa-operational-notes",
361
- operationalNotes,
362
- false,
363
- { source: "session-start" }
364
- );
365
- }
366
- if (handoff?.text) {
367
- sessionManager.appendCustomMessageEntry(
368
- "arisa-session-handoff",
369
- handoff.text,
370
- false,
371
- { source: "telegram-new" }
372
- );
373
- }
374
- return { sessionManager, isNewSession: true };
375
- }
376
- this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
377
- return { sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir), isNewSession: false };
217
+ return this.sessionLifecycle.createSessionManager(chatId, workspaceDir, sessionRevision);
378
218
  }
379
219
 
380
220
  async validatePiAgent(config = this.config) {
@@ -525,10 +365,7 @@ export class AgentManager {
525
365
  accessGuardTarget
526
366
  };
527
367
  this.sessions.set(sessionKey, ctx);
528
- if (isNewSession) {
529
- this.pendingNewSessions.delete(sessionKey);
530
- this.pendingSessionHandoffs.delete(sessionKey);
531
- }
368
+ if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
532
369
  return ctx;
533
370
  }
534
371
 
@@ -547,12 +384,7 @@ export class AgentManager {
547
384
  }
548
385
 
549
386
  async close() {
550
- const contexts = [...this.sessions.values()];
551
- this.sessions.clear();
552
- await Promise.allSettled([
553
- ...this.sessionClosePromises.values(),
554
- ...contexts.map((context) => closeAgentSession(context.session))
555
- ]);
387
+ await this.sessionLifecycle.closeAll();
556
388
  }
557
389
 
558
390
  async runTool({ name, request, chatId, taskContext = null }) {
@@ -576,285 +408,13 @@ export class AgentManager {
576
408
  }
577
409
 
578
410
  createTools(telegram, chatId, policy = buildPiToolPolicy({ config: this.config, customToolNames: arisaToolNames })) {
579
- const chatArtifactStore = this.artifactStore.forChat(chatId);
580
-
581
- return [
582
- defineTool({
583
- name: "list_tools",
584
- label: "List tools",
585
- description: "List Arisa tools, or search installed tool metadata by capability with automatic official-catalog fallback.",
586
- parameters: Type.Object({ query: Type.Optional(Type.String()) }),
587
- execute: async (_id, params) => {
588
- await this.toolRegistry.load();
589
- const coreTools = getCoreCodingTools({
590
- tools: policy.tools,
591
- excludeTools: policy.excludeTools
592
- });
593
- const nativeTools = [{
594
- name: "system_shell",
595
- source: "arisa-native",
596
- description: "Run native system shell commands in the active Arisa workspace.",
597
- workspaceDir: policy.workspaceDir,
598
- shell: policy.shell.shellPath || (process.platform === "win32" ? "powershell" : "sh"),
599
- enabled: !(policy.excludeTools || []).includes("system_shell")
600
- }];
601
- const query = params.query?.trim() || "";
602
- let catalogFallback = null;
603
- const cliTools = query
604
- ? this.toolRegistry.search(query).map((tool) => ({
605
- ...tool,
606
- source: "arisa-modular",
607
- invocation: "run_tool"
608
- }))
609
- : (await this.toolRegistry.listWithRuntime(chatId)).map((tool) => ({
610
- ...tool,
611
- source: "arisa-modular",
612
- invocation: "run_tool"
613
- }));
614
- if (query && cliTools.length === 0) {
615
- try {
616
- catalogFallback = await searchOfficialToolCatalog(query);
617
- } catch (error) {
618
- catalogFallback = { unavailable: true, error: error?.message || String(error), matches: [] };
619
- }
620
- }
621
- const result = {
622
- query: query || null,
623
- workspaceDir: policy.workspaceDir,
624
- coreTools: query ? [] : coreTools,
625
- nativeTools: query ? [] : nativeTools,
626
- cliTools,
627
- officialCatalogMatches: Array.isArray(catalogFallback) ? catalogFallback : catalogFallback?.matches || [],
628
- catalogFallback: catalogFallback && !Array.isArray(catalogFallback) ? catalogFallback : null,
629
- tools: query ? cliTools : [...coreTools.filter((tool) => tool.enabled), ...nativeTools.filter((tool) => tool.enabled), ...cliTools]
630
- };
631
- return {
632
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
633
- details: result
634
- };
635
- }
636
- }),
637
- defineTool({
638
- name: "tool_help",
639
- label: "Tool help",
640
- description: "Show --help text for a CLI tool.",
641
- parameters: Type.Object({ name: Type.String() }),
642
- execute: async (_id, params) => {
643
- await this.toolRegistry.load();
644
- const help = await this.toolRegistry.help(params.name);
645
- return { content: [{ type: "text", text: help }], details: { help } };
646
- }
647
- }),
648
- defineTool({
649
- name: "tool_skills",
650
- label: "Tool skills",
651
- description: "Show skills assigned to a CLI tool via its manifest skillHints.",
652
- parameters: Type.Object({ name: Type.String() }),
653
- execute: async (_id, params) => {
654
- await this.toolRegistry.load();
655
- const skills = await this.toolRegistry.resolveSkills(params.name);
656
- const visible = skills.map(({ content, ...item }) => item);
657
- return { content: [{ type: "text", text: JSON.stringify(visible, null, 2) }], details: visible };
658
- }
659
- }),
660
- defineTool({
661
- name: "set_tool_config",
662
- label: "Set tool config",
663
- description: "Write a tool config value scoped to the current chat.",
664
- parameters: Type.Object({ name: Type.String(), field: Type.String(), value: Type.String() }),
665
- execute: async (_id, params) => {
666
- await this.toolRegistry.load();
667
- const result = await this.toolRegistry.setConfig(params.name, params.field, params.value, chatId);
668
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
669
- }
670
- }),
671
- defineTool({
672
- name: "set_tool_resource_note",
673
- label: "Set tool resource note",
674
- description: "Set or clear a deterministic chat-scoped note of up to 200 characters for one tool resource.",
675
- parameters: Type.Object({
676
- name: Type.String(),
677
- resourceId: Type.String(),
678
- note: Type.String()
679
- }),
680
- execute: async (_id, params) => {
681
- const result = await this.resourceNotes.set(chatId, params.name, params.resourceId, params.note);
682
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
683
- }
684
- }),
685
- defineTool({
686
- name: "run_tool",
687
- label: "Run tool",
688
- description: "Run a CLI tool using text input or an artifactId. Inspect the returned status/resolution fields. If a tool reports missing config, ask the user naturally, use set_tool_config, and retry. Set `deliver: true` to also send the generated file to the chat in one step (only when you want the user to receive it now, not for intermediate pipe steps).",
689
- parameters: Type.Object({
690
- name: Type.String(),
691
- artifactId: Type.Optional(Type.String()),
692
- text: Type.Optional(Type.String()),
693
- resourceId: Type.Optional(Type.String()),
694
- args: Type.Optional(Type.Record(Type.String(), Type.String())),
695
- deliver: Type.Optional(Type.Boolean())
696
- }),
697
- execute: async (_id, params) => {
698
- let artifact = null;
699
- if (params.artifactId) {
700
- artifact = await chatArtifactStore.get(params.artifactId);
701
- if (!artifact) {
702
- return { content: [{ type: "text", text: `Artifact not found: ${params.artifactId}` }], details: { ok: false } };
703
- }
704
- }
705
- const result = await this.runTool({
706
- name: params.name,
707
- request: {
708
- artifact,
709
- text: params.text,
710
- resourceId: params.resourceId,
711
- args: params.args || {}
712
- },
713
- chatId,
714
- taskContext: telegram.getTaskContext()
715
- });
716
-
717
- if (params.deliver && result.output?.artifactId) {
718
- const generated = await chatArtifactStore.get(result.output.artifactId);
719
- if (generated?.path) {
720
- result.sent = await deliverArtifactToChat({ artifact: generated, telegram, logger: this.logger });
721
- }
722
- }
723
-
724
- return {
725
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
726
- details: result
727
- };
728
- }
729
- }),
730
- defineTool({
731
- name: "list_scheduled_tasks",
732
- label: "List scheduled tasks",
733
- 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.",
734
- parameters: Type.Object({
735
- status: Type.Optional(Type.String()),
736
- limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
737
- }),
738
- execute: async (_id, params) => {
739
- const tasks = await this.taskStore.list({ chatId, status: params.status });
740
- const result = selectScheduledTasks(tasks, {
741
- status: params.status,
742
- limit: params.limit
743
- });
744
- return {
745
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
746
- details: result
747
- };
748
- }
749
- }),
750
- defineTool({
751
- name: "cancel_scheduled_task",
752
- label: "Cancel scheduled task",
753
- description: "Cancel one scheduled async task by id for the current Telegram chat.",
754
- parameters: Type.Object({ id: Type.String() }),
755
- execute: async (_id, params) => {
756
- const existing = await this.taskStore.get(params.id);
757
- if (!existing || existing.payload?.chatId !== chatId) {
758
- return {
759
- content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Task not found" }) }],
760
- details: { ok: false, error: "Task not found" }
761
- };
762
- }
763
- const task = await this.taskStore.cancel(params.id);
764
- return {
765
- content: [{ type: "text", text: JSON.stringify({ ok: true, task }, null, 2) }],
766
- details: { ok: true, task }
767
- };
768
- }
769
- }),
770
- defineTool({
771
- name: "cancel_all_scheduled_tasks",
772
- label: "Cancel all scheduled tasks",
773
- description: "Cancel all pending or running async tasks for the current Telegram chat.",
774
- parameters: Type.Object({}),
775
- execute: async () => {
776
- const tasks = await this.taskStore.cancelAll({ chatId });
777
- return {
778
- content: [{ type: "text", text: JSON.stringify({ ok: true, cancelled: tasks.length }, null, 2) }],
779
- details: { ok: true, tasks }
780
- };
781
- }
782
- }),
783
- defineTool({
784
- name: "create_telegram_topic",
785
- label: "Create Telegram topic",
786
- description: "Create and initialize a new topic in the current owner-only Telegram forum. Topic names are dynamic, and context seeds the isolated session without copying unrelated history.",
787
- parameters: Type.Object({
788
- name: Type.String({ minLength: 1, maxLength: 128 }),
789
- context: Type.String({ minLength: 1, maxLength: 4000 })
790
- }),
791
- execute: async (_id, params) => {
792
- if (typeof telegram.createForumTopic !== "function") {
793
- const result = { ok: false, error: "Telegram topic creation is unavailable in this chat." };
794
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
795
- }
796
- const result = await telegram.createForumTopic(params.name.trim(), params.context.trim());
797
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
798
- }
799
- }),
800
- defineTool({
801
- name: "initialize_telegram_topic",
802
- label: "Initialize Telegram topic",
803
- description: "Seed or replace the isolated context of an existing topic in the current owner-only Telegram forum.",
804
- parameters: Type.Object({
805
- messageThreadId: Type.Integer({ minimum: 2 }),
806
- name: Type.String({ minLength: 1, maxLength: 128 }),
807
- context: Type.String({ minLength: 1, maxLength: 4000 })
808
- }),
809
- execute: async (_id, params) => {
810
- if (typeof telegram.initializeForumTopic !== "function") {
811
- const result = { ok: false, error: "Telegram topic initialization is unavailable in this chat." };
812
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
813
- }
814
- const result = await telegram.initializeForumTopic({
815
- messageThreadId: params.messageThreadId,
816
- name: params.name.trim(),
817
- context: params.context.trim()
818
- });
819
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
820
- }
821
- }),
822
- defineTool({
823
- name: "send_artifact",
824
- label: "Send artifact",
825
- description: "Deliver an existing chat artifact to the current Telegram chat. Pass the `artifactId` returned by run_tool or from an inbound file. The delivery method and filename are derived from the artifact (its delivery hint, kind, and stored name); internal local paths are never exposed. No caption is shown by default, since the filename already appears on the attachment; set `caption` only to add a separate visible label, or `method` to override the delivery method. The artifact is not deleted.",
826
- parameters: Type.Object({
827
- artifactId: Type.String(),
828
- caption: Type.Optional(Type.String()),
829
- method: Type.Optional(Type.Union([
830
- Type.Literal("voice"),
831
- Type.Literal("audio"),
832
- Type.Literal("document")
833
- ]))
834
- }),
835
- execute: async (_id, params) => {
836
- const artifact = await chatArtifactStore.get(params.artifactId);
837
- if (!artifact) {
838
- const result = { ok: false, status: "failed", error: `Artifact not found: ${params.artifactId}` };
839
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
840
- }
841
- if (!artifact.path) {
842
- const result = { ok: false, status: "failed", error: `Artifact ${params.artifactId} has no file to deliver.` };
843
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
844
- }
845
- const sent = await deliverArtifactToChat({
846
- artifact,
847
- telegram,
848
- caption: params.caption,
849
- method: params.method,
850
- logger: this.logger
851
- });
852
- return {
853
- content: [{ type: "text", text: `Media sent to Telegram as ${sent.method}.` }],
854
- details: { ok: true, sent }
855
- };
856
- }
857
- })
858
- ];
411
+ return createPiCapabilityTools({
412
+ capabilityService: this.capabilityService,
413
+ telegram,
414
+ chatId,
415
+ policy,
416
+ logger: this.logger
417
+ });
859
418
  }
419
+
860
420
  }