talon-agent 5.0.0 → 5.0.1

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 (127) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +3 -3
  3. package/src/backend/claude-sdk/factory.ts +21 -41
  4. package/src/backend/claude-sdk/host/in-process.ts +147 -0
  5. package/src/backend/codex/mcp-config.ts +1 -1
  6. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  7. package/src/backend/runtime/index.ts +2 -2
  8. package/src/backend/runtime/turn/delivery.ts +1 -1
  9. package/src/backend/runtime/turn/turn-phases.ts +1 -1
  10. package/src/bootstrap.ts +2 -2
  11. package/src/cli.ts +5 -2
  12. package/src/core/agent-runtime/README.md +15 -0
  13. package/src/core/agent-runtime/agent-host.ts +387 -0
  14. package/src/core/background/cron/scheduler.ts +1 -1
  15. package/src/core/background/triggers/command.ts +1 -1
  16. package/src/{util → core/config}/harden.ts +1 -1
  17. package/src/core/config/index.ts +1 -1
  18. package/src/core/daemon/pidfile.ts +1 -1
  19. package/src/core/daemon/resource-sampler.ts +2 -2
  20. package/src/{util → core/daemon}/respawn.ts +1 -1
  21. package/src/core/doctor/index.ts +1 -1
  22. package/src/core/engine/gateway-actions/fetch-url.ts +1 -1
  23. package/src/core/engine/gateway-actions/index.ts +1 -1
  24. package/src/core/engine/gateway-actions/memory.ts +2 -2
  25. package/src/core/engine/gateway-actions/native/exec-background.ts +129 -0
  26. package/src/core/engine/gateway-actions/native/exec-remote.ts +85 -0
  27. package/src/core/engine/gateway-actions/native/exec.ts +182 -0
  28. package/src/core/engine/gateway-actions/native/index.ts +46 -0
  29. package/src/core/engine/gateway-actions/native/params.ts +46 -0
  30. package/src/core/engine/gateway-actions/native/read.ts +182 -0
  31. package/src/core/engine/gateway-actions/native/results.ts +34 -0
  32. package/src/core/engine/gateway-actions/native/search.ts +212 -0
  33. package/src/core/engine/gateway-actions/native/shell.ts +39 -0
  34. package/src/core/engine/gateway-actions/native/teleport.ts +55 -0
  35. package/src/core/engine/gateway-actions/native/write.ts +170 -0
  36. package/src/core/frontend-runtime/builtins.ts +2 -2
  37. package/src/core/mcp-hub/index.ts +1 -1
  38. package/src/core/mcp-hub/talon-server.ts +1 -1
  39. package/src/core/memory/taps.ts +1 -1
  40. package/src/core/plugin/mcp.ts +1 -1
  41. package/src/core/scripts/lua.ts +1 -1
  42. package/src/core/tools/{chat.ts → chat/chat.ts} +1 -1
  43. package/src/core/tools/{cross-send.ts → chat/cross-send.ts} +1 -1
  44. package/src/core/tools/{history.ts → chat/history.ts} +2 -2
  45. package/src/core/tools/{media.ts → chat/media.ts} +1 -1
  46. package/src/core/tools/{members.ts → chat/members.ts} +2 -2
  47. package/src/core/tools/{messaging.ts → chat/messaging.ts} +2 -2
  48. package/src/core/tools/{moderation.ts → chat/moderation.ts} +2 -2
  49. package/src/core/tools/{stickers.ts → chat/stickers.ts} +2 -2
  50. package/src/core/tools/{whatsapp.ts → chat/whatsapp.ts} +1 -1
  51. package/src/core/tools/{memory.ts → content/memory.ts} +2 -2
  52. package/src/core/tools/{web.ts → content/web.ts} +1 -1
  53. package/src/core/tools/index.ts +20 -20
  54. package/src/core/tools/{admin.ts → ops/admin.ts} +1 -1
  55. package/src/core/tools/{bridge.ts → ops/bridge.ts} +2 -2
  56. package/src/core/tools/{goals.ts → ops/goals.ts} +2 -2
  57. package/src/core/tools/{mesh.ts → ops/mesh.ts} +1 -1
  58. package/src/core/tools/{models.ts → ops/models.ts} +1 -1
  59. package/src/core/tools/{native.ts → ops/native.ts} +1 -1
  60. package/src/core/tools/{scheduling.ts → ops/scheduling.ts} +1 -1
  61. package/src/core/tools/{scripts.ts → ops/scripts.ts} +1 -1
  62. package/src/core/tools/{skills.ts → ops/skills.ts} +1 -1
  63. package/src/core/tools/{triggers.ts → ops/triggers.ts} +1 -1
  64. package/src/{util → core/vfs}/workspace.ts +2 -2
  65. package/src/frontend/discord/callbacks/components/effort.ts +1 -1
  66. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  67. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  68. package/src/frontend/discord/commands/admin.ts +1 -1
  69. package/src/frontend/discord/commands/info.ts +1 -1
  70. package/src/frontend/discord/commands/interaction.ts +1 -1
  71. package/src/frontend/discord/commands/session.ts +3 -3
  72. package/src/frontend/discord/commands/settings.ts +1 -1
  73. package/src/frontend/discord/handlers/messages.ts +1 -1
  74. package/src/frontend/discord/handlers/state.ts +1 -1
  75. package/src/frontend/discord/middleware.ts +1 -1
  76. package/src/frontend/discord/ready.ts +1 -1
  77. package/src/frontend/discord/render.ts +41 -259
  78. package/src/frontend/native/chats/chats.ts +1 -1
  79. package/src/frontend/native/surface/models.ts +1 -1
  80. package/src/frontend/native/turn/context.ts +1 -1
  81. package/src/frontend/native/turn/emit.ts +1 -1
  82. package/src/frontend/{shared → presentation}/format.ts +4 -3
  83. package/src/frontend/{shared → presentation}/reasoning-levels.ts +12 -0
  84. package/src/frontend/presentation/reports.ts +493 -0
  85. package/src/frontend/{shared → presentation}/session-status.ts +1 -1
  86. package/src/frontend/teams/commands.ts +2 -2
  87. package/src/frontend/teams/turn.ts +1 -1
  88. package/src/frontend/telegram/admin/health.ts +1 -1
  89. package/src/frontend/telegram/admin/sessions.ts +1 -1
  90. package/src/frontend/telegram/callbacks/effort.ts +2 -2
  91. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  92. package/src/frontend/telegram/callbacks/model/views.ts +1 -1
  93. package/src/frontend/telegram/callbacks/query.ts +1 -1
  94. package/src/frontend/telegram/callbacks/settings.ts +2 -2
  95. package/src/frontend/telegram/commands/admin.ts +4 -4
  96. package/src/frontend/telegram/commands/info.ts +2 -2
  97. package/src/frontend/telegram/commands/session.ts +4 -4
  98. package/src/frontend/telegram/commands/settings.ts +4 -2
  99. package/src/frontend/telegram/handlers/state.ts +1 -1
  100. package/src/frontend/telegram/model-menu.ts +1 -1
  101. package/src/frontend/telegram/render/html.ts +30 -0
  102. package/src/frontend/telegram/{helpers → render}/menu.ts +46 -19
  103. package/src/frontend/telegram/render/reports.ts +182 -0
  104. package/src/frontend/terminal/builtins/context.ts +2 -2
  105. package/src/frontend/terminal/builtins/session.ts +1 -1
  106. package/src/frontend/terminal/builtins/status.ts +2 -2
  107. package/src/frontend/terminal/index.ts +2 -2
  108. package/src/frontend/terminal/renderer.ts +1 -1
  109. package/src/frontend/whatsapp/commands.ts +5 -5
  110. package/src/frontend/whatsapp/registry.ts +1 -1
  111. package/src/index.ts +5 -2
  112. package/src/util/runtime.ts +1 -1
  113. package/src/core/engine/gateway-actions/native.ts +0 -1035
  114. package/src/frontend/telegram/helpers/diagnostics.ts +0 -428
  115. package/src/frontend/telegram/helpers/format.ts +0 -42
  116. package/src/frontend/telegram/helpers/index.ts +0 -13
  117. package/src/util/cleanup-registry.ts +0 -36
  118. /package/src/{util → core/daemon}/boot-timer.ts +0 -0
  119. /package/src/{util → core/frontend-runtime}/chat-id.ts +0 -0
  120. /package/src/{util/mcp-launcher.ts → core/mcp-hub/launcher.ts} +0 -0
  121. /package/src/{util → core/tools/content}/web-content.ts +0 -0
  122. /package/src/core/tools/{mcp-env.ts → ops/mcp-env.ts} +0 -0
  123. /package/src/{util → core/weaver}/session-name.ts +0 -0
  124. /package/src/frontend/{shared → presentation}/access.ts +0 -0
  125. /package/src/frontend/{shared → presentation}/model-commands.ts +0 -0
  126. /package/src/frontend/{shared → presentation}/plan-usage-report.ts +0 -0
  127. /package/src/frontend/{shared → presentation}/status-context.ts +0 -0
@@ -7,26 +7,26 @@
7
7
 
8
8
  import type { ToolDefinition, ToolFrontend, ToolTag } from "./types.js";
9
9
 
10
- import { messagingTools } from "./messaging.js";
11
- import { chatTools } from "./chat.js";
12
- import { historyTools } from "./history.js";
13
- import { memberTools } from "./members.js";
14
- import { mediaTools } from "./media.js";
15
- import { stickerTools } from "./stickers.js";
16
- import { schedulingTools } from "./scheduling.js";
17
- import { triggerTools } from "./triggers.js";
18
- import { goalTools } from "./goals.js";
19
- import { memoryTools } from "./memory.js";
20
- import { scriptTools } from "./scripts.js";
21
- import { skillTools } from "./skills.js";
22
- import { webTools } from "./web.js";
23
- import { adminTools } from "./admin.js";
24
- import { modelTools } from "./models.js";
25
- import { meshTools } from "./mesh.js";
26
- import { crossSendTools } from "./cross-send.js";
27
- import { whatsappTools } from "./whatsapp.js";
28
- import { moderationTools } from "./moderation.js";
29
- import { nativeTools } from "./native.js";
10
+ import { messagingTools } from "./chat/messaging.js";
11
+ import { chatTools } from "./chat/chat.js";
12
+ import { historyTools } from "./chat/history.js";
13
+ import { memberTools } from "./chat/members.js";
14
+ import { mediaTools } from "./chat/media.js";
15
+ import { stickerTools } from "./chat/stickers.js";
16
+ import { schedulingTools } from "./ops/scheduling.js";
17
+ import { triggerTools } from "./ops/triggers.js";
18
+ import { goalTools } from "./ops/goals.js";
19
+ import { memoryTools } from "./content/memory.js";
20
+ import { scriptTools } from "./ops/scripts.js";
21
+ import { skillTools } from "./ops/skills.js";
22
+ import { webTools } from "./content/web.js";
23
+ import { adminTools } from "./ops/admin.js";
24
+ import { modelTools } from "./ops/models.js";
25
+ import { meshTools } from "./ops/mesh.js";
26
+ import { crossSendTools } from "./chat/cross-send.js";
27
+ import { whatsappTools } from "./chat/whatsapp.js";
28
+ import { moderationTools } from "./chat/moderation.js";
29
+ import { nativeTools } from "./ops/native.js";
30
30
 
31
31
  /** All built-in tool definitions. */
32
32
  export const ALL_TOOLS: readonly ToolDefinition[] = [
@@ -5,7 +5,7 @@
5
5
  * messaging or content. Available on all frontends.
6
6
  */
7
7
 
8
- import type { ToolDefinition } from "./types.js";
8
+ import type { ToolDefinition } from "../types.js";
9
9
 
10
10
  export const adminTools: ToolDefinition[] = [
11
11
  {
@@ -6,8 +6,8 @@
6
6
  */
7
7
 
8
8
  import { Agent, fetch as undiciFetch } from "undici";
9
- import { isBunRuntime } from "../../util/runtime.js";
10
- import type { BridgeFunction } from "./types.js";
9
+ import { isBunRuntime } from "../../../util/runtime.js";
10
+ import type { BridgeFunction } from "../types.js";
11
11
 
12
12
  /** Default wall-clock budget for a bridge action. */
13
13
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -10,8 +10,8 @@
10
10
  */
11
11
 
12
12
  import { z } from "zod";
13
- import type { ToolDefinition } from "./types.js";
14
- import { chatIdSchema } from "./schemas.js";
13
+ import type { ToolDefinition } from "../types.js";
14
+ import { chatIdSchema } from "../schemas.js";
15
15
 
16
16
  /**
17
17
  * Heartbeat-mode routing: like `send`, every goal tool threads an
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { ToolDefinition } from "./types.js";
2
+ import type { ToolDefinition } from "../types.js";
3
3
 
4
4
  /**
5
5
  * Device-mesh tools. Deliberately NOT frontend-restricted: the mesh is
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { z } from "zod";
12
- import type { ToolDefinition } from "./types.js";
12
+ import type { ToolDefinition } from "../types.js";
13
13
 
14
14
  export const modelTools: ToolDefinition[] = [
15
15
  {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { ToolDefinition } from "./types.js";
2
+ import type { ToolDefinition } from "../types.js";
3
3
 
4
4
  /**
5
5
  * Native tools — Talon's own shell/filesystem primitives, the replacement
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { z } from "zod";
6
- import type { ToolDefinition } from "./types.js";
6
+ import type { ToolDefinition } from "../types.js";
7
7
 
8
8
  export const schedulingTools: ToolDefinition[] = [
9
9
  {
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { z } from "zod";
13
- import type { ToolDefinition } from "./types.js";
13
+ import type { ToolDefinition } from "../types.js";
14
14
 
15
15
  export const scriptTools: ToolDefinition[] = [
16
16
  {
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { z } from "zod";
14
- import type { ToolDefinition } from "./types.js";
14
+ import type { ToolDefinition } from "../types.js";
15
15
 
16
16
  export const skillTools: ToolDefinition[] = [
17
17
  {
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { z } from "zod";
12
- import type { ToolDefinition } from "./types.js";
12
+ import type { ToolDefinition } from "../types.js";
13
13
 
14
14
  const TRIGGER_DESCRIPTION = `Create a long-running watcher script. The script runs as a supervised
15
15
  subprocess and signals back via stdout or its exit code.
@@ -20,8 +20,8 @@ import {
20
20
  } from "node:fs";
21
21
  import { createHash } from "node:crypto";
22
22
  import { join, resolve } from "node:path";
23
- import { log } from "./log.js";
24
- import { dirs, files as pathFiles } from "./paths.js";
23
+ import { log } from "../../util/log.js";
24
+ import { dirs, files as pathFiles } from "../../util/paths.js";
25
25
  import { listSeedPrompts, readPromptAsset } from "#prompt-assets";
26
26
 
27
27
  const IDENTITY_SEED = `# Identity
@@ -14,7 +14,7 @@ import {
14
14
  import {
15
15
  getActiveReasoningLevels,
16
16
  supportsReasoningLevel,
17
- } from "../../../shared/reasoning-levels.js";
17
+ } from "../../../presentation/reasoning-levels.js";
18
18
  import type { ComponentContext, ComponentInteraction } from "./types.js";
19
19
 
20
20
  async function selectEffort(
@@ -24,7 +24,7 @@
24
24
  import { MessageFlags } from "discord.js";
25
25
  import type { TalonConfig } from "../../../../core/config/index.js";
26
26
  import type { Gateway } from "../../../../core/engine/gateway.js";
27
- import { deriveNumericChatId } from "../../../../util/chat-id.js";
27
+ import { deriveNumericChatId } from "../../../../core/frontend-runtime/chat-id.js";
28
28
  import { logError } from "../../../../util/log.js";
29
29
  import {
30
30
  isInteractionAllowed,
@@ -41,7 +41,7 @@ import {
41
41
  displayReasoningEffort,
42
42
  getActiveReasoningLevels,
43
43
  supportsReasoningLevel,
44
- } from "../../../shared/reasoning-levels.js";
44
+ } from "../../../presentation/reasoning-levels.js";
45
45
  import { logError } from "../../../../util/log.js";
46
46
  import { safeSlice } from "../../formatting.js";
47
47
  import type { ComponentContext, ComponentInteraction } from "./types.js";
@@ -12,7 +12,7 @@ import {
12
12
  } from "discord.js";
13
13
  import type { TalonConfig } from "../../../core/config/index.js";
14
14
  import type { Gateway } from "../../../core/engine/gateway.js";
15
- import { respawnSelf } from "../../../util/respawn.js";
15
+ import { respawnSelf } from "../../../core/daemon/respawn.js";
16
16
  import { forceDream } from "../../../core/background/dream/index.js";
17
17
  import {
18
18
  formatDuration,
@@ -13,7 +13,7 @@ import {
13
13
  renderUsageMessage,
14
14
  } from "../render.js";
15
15
  import type { TalonConfig } from "../../../core/config/index.js";
16
- import { collectPlanUsage } from "../../shared/plan-usage-report.js";
16
+ import { collectPlanUsage } from "../../presentation/plan-usage-report.js";
17
17
  import { getLoadedPlugins } from "../../../core/plugin/index.js";
18
18
  import { getMeshService } from "../../../core/mesh/index.js";
19
19
  import type { MeshPingResult } from "../../../core/mesh/service.js";
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { type ChatInputCommandInteraction, MessageFlags } from "discord.js";
7
- import { deriveNumericChatId } from "../../../util/chat-id.js";
7
+ import { deriveNumericChatId } from "../../../core/frontend-runtime/chat-id.js";
8
8
  import {
9
9
  suppressMentions,
10
10
  splitMessage,
@@ -2,7 +2,7 @@
2
2
  * Session commands — /reset and /status.
3
3
  *
4
4
  * Data gathering and the reset sequence live in
5
- * frontend/shared/session-status.ts; this file only renders Discord markdown.
5
+ * frontend/presentation/session-status.ts; this file only renders Discord markdown.
6
6
  */
7
7
 
8
8
  import { type ChatInputCommandInteraction, MessageFlags } from "discord.js";
@@ -22,11 +22,11 @@ import {
22
22
  import {
23
23
  performSessionReset,
24
24
  collectSessionStatus,
25
- } from "../../shared/session-status.js";
25
+ } from "../../presentation/session-status.js";
26
26
  import {
27
27
  formatCacheTempLine,
28
28
  formatDaemonLine,
29
- } from "../../shared/status-context.js";
29
+ } from "../../presentation/status-context.js";
30
30
  import { stopCurrentTurn } from "../../../core/engine/dispatcher.js";
31
31
  import { reply } from "./interaction.js";
32
32
 
@@ -38,7 +38,7 @@ import {
38
38
  displayReasoningEffort,
39
39
  getActiveReasoningLevels,
40
40
  supportsReasoningLevel,
41
- } from "../../shared/reasoning-levels.js";
41
+ } from "../../presentation/reasoning-levels.js";
42
42
  import {
43
43
  getBackendIdForChat,
44
44
  resolveChatBackend,
@@ -9,7 +9,7 @@ import { ChannelType } from "discord.js";
9
9
  import type { TalonConfig } from "../../../core/config/index.js";
10
10
  import { setMessageFilePath } from "../../../storage/history.js";
11
11
  import { addMedia } from "../../../storage/media-index.js";
12
- import { deriveNumericChatId } from "../../../util/chat-id.js";
12
+ import { deriveNumericChatId } from "../../../core/frontend-runtime/chat-id.js";
13
13
  import { logError } from "../../../util/log.js";
14
14
  import {
15
15
  shouldHandleInGuild,
@@ -11,7 +11,7 @@ import {
11
11
  createDmUserTracker,
12
12
  createNoticeCooldown,
13
13
  createRateLimiter,
14
- } from "../../shared/access.js";
14
+ } from "../../presentation/access.js";
15
15
 
16
16
  // ── Chat registry: numericChatId → Discord channel info ─────────────────────
17
17
  // The gateway/dispatcher uses numeric chatIds. Action handlers need to map
@@ -19,7 +19,7 @@ import { ChannelType } from "discord.js";
19
19
  import type { TalonConfig } from "../../core/config/index.js";
20
20
  import { pushMessage } from "../../storage/history.js";
21
21
  import { registerChat } from "../../core/background/pulse/pulse.js";
22
- import { deriveNumericChatId } from "../../util/chat-id.js";
22
+ import { deriveNumericChatId } from "../../core/frontend-runtime/chat-id.js";
23
23
  import { handleMessage, getSenderName } from "./handlers/index.js";
24
24
 
25
25
  export function registerMiddleware(client: Client, config: TalonConfig): void {
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { ActivityType, type Client } from "discord.js";
8
8
  import { log, logError } from "../../util/log.js";
9
- import { deriveNumericChatId } from "../../util/chat-id.js";
9
+ import { deriveNumericChatId } from "../../core/frontend-runtime/chat-id.js";
10
10
  import { registerCommandsForGuilds } from "./commands/index.js";
11
11
  import { registerDiscordChat } from "./handlers/index.js";
12
12
  import { leaveNonWhitelistedGuilds } from "./guild-policy.js";
@@ -2,13 +2,17 @@
2
2
  * Message rendering for Discord commands, callbacks, and the settings panel:
3
3
  * metrics, doctor, mesh, usage and settings text, sized to Discord's limits.
4
4
  *
5
- * Discord-specific quirks vs the Telegram helpers:
5
+ * The reports themselves live in `frontend/presentation/reports.ts` — this
6
+ * file is Discord's dialect of them (`DISCORD_REPORTS`) plus the wrappers
7
+ * that keep each command to a single import.
8
+ *
9
+ * Discord-specific quirks vs the Telegram renderers:
6
10
  * - settings panel uses Components (Buttons + Select Menus), not inline keyboard.
7
11
  * - custom_id strings are limited to 100 chars total — keep payload compact.
8
12
  * - chat IDs are Discord snowflakes (strings), not numbers.
13
+ * - markdown needs no escaping, so `escape` is the identity function.
9
14
  */
10
15
 
11
- import { REASONING_LEVEL_DESCRIPTIONS } from "../../core/models/reasoning-levels.js";
12
16
  import {
13
17
  DISCORD_MAX_TEXT,
14
18
  DISCORD_SAFE_RESERVE,
@@ -16,13 +20,18 @@ import {
16
20
  } from "./formatting.js";
17
21
  import type { DoctorReport } from "../../core/doctor/index.js";
18
22
  import type { MeshPingResult } from "../../core/mesh/service.js";
19
- import type { BackendUsageEntry } from "../shared/plan-usage-report.js";
23
+ import type { BackendUsageEntry } from "../presentation/plan-usage-report.js";
20
24
  import {
21
- DEFAULT_PULSE_INTERVAL_MS,
22
- formatDuration,
23
- formatBytes,
24
- formatModelLabel,
25
- } from "../shared/format.js";
25
+ renderDoctorReport,
26
+ renderMeshReport as renderMeshReportWith,
27
+ renderMetricsMessages as renderMetricsMessagesWith,
28
+ renderSettingsText as renderSettingsTextWith,
29
+ renderUsageMessage as renderUsageMessageWith,
30
+ type MetricsSnapshot,
31
+ type ReportFormatter,
32
+ } from "../presentation/reports.js";
33
+
34
+ export { EFFORT_DESCRIPTIONS } from "../presentation/reasoning-levels.js";
26
35
 
27
36
  export {
28
37
  parseInterval,
@@ -31,28 +40,23 @@ export {
31
40
  formatBytes,
32
41
  formatUsd,
33
42
  formatModelLabel,
34
- } from "../shared/format.js";
43
+ } from "../presentation/format.js";
35
44
 
36
- /** Per-message length budget for metrics output. */
45
+ /** Per-message length budget for report output. */
37
46
  const DEFAULT_METRICS_MESSAGE_MAX = DISCORD_MAX_TEXT - DISCORD_SAFE_RESERVE;
38
47
 
39
- /** Effort level descriptions shown next to each option in select menus. */
40
- export const EFFORT_DESCRIPTIONS: Record<string, string> = {
41
- ...REASONING_LEVEL_DESCRIPTIONS,
42
- };
43
-
44
- type MetricsSnapshot = {
45
- counters: Record<string, number>;
46
- histograms: Record<
47
- string,
48
- { count: number; avg: number; min: number; max: number }
49
- >;
48
+ /** Discord markdown: bold/italic/code markers, no escaping, 2000-char messages. */
49
+ const DISCORD_REPORTS: ReportFormatter = {
50
+ bold: (s) => `**${s}**`,
51
+ italic: (s) => `_${s}_`,
52
+ emphasis: (s) => `*${s}*`,
53
+ code: (s) => `\`${s}\``,
54
+ escape: (s) => s,
55
+ lineLimit: DEFAULT_METRICS_MESSAGE_MAX,
56
+ metricLabelMax: 60,
57
+ pulseLabel: "🔔 Pulse:",
50
58
  };
51
59
 
52
- function truncateMetricLabel(label: string, max = 60): string {
53
- return label.length <= max ? label : `${label.slice(0, max - 3)}...`;
54
- }
55
-
56
60
  /**
57
61
  * Render the metrics report into one or more Discord messages, each ≤ maxLen
58
62
  * (default ~1900 chars to leave headroom under the 2000-char limit).
@@ -62,117 +66,9 @@ export function renderMetricsMessages(
62
66
  maxLen = DEFAULT_METRICS_MESSAGE_MAX,
63
67
  title = "📊 Metrics",
64
68
  ): string[] {
65
- const firstHeader = `**${title}**`;
66
- const continuationHeader = `**${title} (cont.)**`;
67
- const sections: string[][] = [];
68
-
69
- // Histograms come in two flavours: durations (keys ending in `_ms`,
70
- // rendered as human times) and plain counts like `tool_calls_per_turn`
71
- // (rendered as bare numbers — "min=1ms" for a count is nonsense).
72
- const histKeys = Object.keys(metrics.histograms).sort();
73
- const durationKeys = histKeys.filter((key) => key.endsWith("_ms"));
74
- const countKeys = histKeys.filter((key) => !key.endsWith("_ms"));
75
- const histLine = (key: string, fmt: (v: number) => string): string => {
76
- const h = metrics.histograms[key];
77
- return (
78
- ` \`${truncateMetricLabel(key)}\` n=${h.count} ` +
79
- `avg=${fmt(h.avg)} min=${fmt(h.min)} ` +
80
- `max=${fmt(h.max)}`
81
- );
82
- };
83
- if (durationKeys.length > 0) {
84
- sections.push([
85
- "**Latency**",
86
- ...durationKeys.map((key) => histLine(key, formatDuration)),
87
- ]);
88
- }
89
- if (countKeys.length > 0) {
90
- sections.push([
91
- "**Distributions**",
92
- ...countKeys.map((key) => histLine(key, String)),
93
- ]);
94
- }
95
-
96
- const counterKeys = Object.keys(metrics.counters).sort();
97
- if (counterKeys.length > 0) {
98
- const groups = new Map<string, string[]>();
99
- for (const key of counterKeys) {
100
- const prefix = key.includes(".") ? key.split(".")[0]! : "general";
101
- if (!groups.has(prefix)) groups.set(prefix, []);
102
- groups.get(prefix)!.push(key);
103
- }
104
- for (const prefix of [...groups.keys()].sort()) {
105
- // tool_calls reads best as a leaderboard — busiest tools first.
106
- // Other groups keep alphabetical order (stable lookup by name).
107
- const keys =
108
- prefix === "tool_calls"
109
- ? [...groups.get(prefix)!].sort(
110
- (a, b) =>
111
- metrics.counters[b]! - metrics.counters[a]! ||
112
- a.localeCompare(b),
113
- )
114
- : groups.get(prefix)!;
115
- sections.push([
116
- `**${prefix}**`,
117
- ...keys.map((key) => {
118
- const label = key.includes(".")
119
- ? key.split(".").slice(1).join(".")
120
- : key;
121
- return ` \`${truncateMetricLabel(label)}\` ${metrics.counters[key]!.toLocaleString()}`;
122
- }),
123
- ]);
124
- }
125
- }
126
-
127
- if (sections.length === 0) {
128
- return [`${firstHeader}\n\n_No metrics recorded yet._`];
129
- }
130
-
131
- const chunks: string[] = [];
132
- let header = firstHeader;
133
- let current = header;
134
- const flush = () => {
135
- chunks.push(current);
136
- header = continuationHeader;
137
- current = header;
138
- };
139
- const appendLine = (line: string) => {
140
- if (!line && current === header) return;
141
- const candidate = `${current}\n${line}`;
142
- if (candidate.length <= maxLen) {
143
- current = candidate;
144
- return;
145
- }
146
- if (current !== header) {
147
- flush();
148
- if (!line) return;
149
- }
150
- const available = maxLen - header.length - 1;
151
- if (available < 0) return;
152
- const safeLine =
153
- line.length <= available
154
- ? line
155
- : available >= 4
156
- ? `${line.slice(0, available - 3)}...`
157
- : line.slice(0, available);
158
- current = `${current}\n${safeLine}`;
159
- };
160
-
161
- for (const section of sections) {
162
- appendLine("");
163
- for (const line of section) appendLine(line);
164
- }
165
- if (current !== header || chunks.length === 0) chunks.push(current);
166
- return chunks;
69
+ return renderMetricsMessagesWith(metrics, DISCORD_REPORTS, maxLen, title);
167
70
  }
168
71
 
169
- const DOCTOR_ICONS: Record<string, string> = {
170
- ok: "✅",
171
- warn: "⚠️",
172
- fail: "❌",
173
- info: "▫️",
174
- };
175
-
176
72
  /**
177
73
  * Render a DoctorReport as Discord markdown, split to fit the message cap.
178
74
  * Same data as `talon doctor` and Telegram's /doctor.
@@ -181,59 +77,7 @@ export function renderDoctorMessages(
181
77
  report: DoctorReport,
182
78
  maxLen = DEFAULT_METRICS_MESSAGE_MAX,
183
79
  ): string[] {
184
- const lines = ["**🩺 Talon Doctor**", "", "**Environment**"];
185
-
186
- const render = (check: DoctorReport["checks"][number]): string =>
187
- `${DOCTOR_ICONS[check.status]} ${check.label}${check.detail ? ` (${check.detail})` : ""}`;
188
-
189
- for (const check of report.checks.filter((c) => !c.inactive)) {
190
- lines.push(render(check));
191
- }
192
-
193
- // Configured-but-idle backends get their own block: they describe what a
194
- // switch would run into, not the state of the running deployment.
195
- const idle = report.checks.filter((c) => c.inactive);
196
- if (idle.length > 0) {
197
- lines.push("", "**Other backends**", ...idle.map(render));
198
- }
199
-
200
- lines.push("", "**Native modules**");
201
- for (const mod of report.native) {
202
- const size =
203
- mod.sizeBytes !== undefined ? ` · ${formatBytes(mod.sizeBytes)}` : "";
204
- const note = mod.note ? ` (${mod.note})` : "";
205
- lines.push(
206
- `${mod.ok ? DOCTOR_ICONS.ok : DOCTOR_ICONS.fail} \`${mod.name}\` — ${mod.language} → ${mod.target}${size}${note}`,
207
- );
208
- }
209
-
210
- lines.push(
211
- "",
212
- "**Process**",
213
- `Uptime ${formatDuration(process.uptime() * 1000)} · PID ${process.pid} · Node ${process.versions.node}`,
214
- "",
215
- report.issues === 0
216
- ? `${DOCTOR_ICONS.ok} All checks passed.`
217
- : `${DOCTOR_ICONS.warn} ${report.issues} issue(s) found.`,
218
- );
219
-
220
- return splitMessage(lines.join("\n"), maxLen);
221
- }
222
-
223
- function meshDeviceLine(r: MeshPingResult, now: number): string {
224
- const d = r.device;
225
- const bits: string[] = [d.platform];
226
- if (r.reachable && typeof r.latencyMs === "number") {
227
- bits.push(`${r.latencyMs} ms`);
228
- } else if (d.online && r.error) {
229
- bits.push(r.error);
230
- } else if (!d.online) {
231
- bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
232
- }
233
- if (typeof d.battery === "number") {
234
- bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
235
- }
236
- return ` **${d.name}** — ${bits.join(" · ")}`;
80
+ return splitMessage(renderDoctorReport(report, DISCORD_REPORTS), maxLen);
237
81
  }
238
82
 
239
83
  /**
@@ -244,71 +88,12 @@ export function renderMeshReport(
244
88
  results: MeshPingResult[],
245
89
  now = Date.now(),
246
90
  ): string {
247
- if (results.length === 0) {
248
- return "**Mesh**\n\n_No devices have registered yet._";
249
- }
250
-
251
- const responding = results
252
- .filter((r) => r.reachable)
253
- .sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
254
- const unreachable = results
255
- .filter((r) => !r.reachable && r.device.online)
256
- .sort((a, b) => a.device.name.localeCompare(b.device.name));
257
- const offline = results
258
- .filter((r) => !r.reachable && !r.device.online)
259
- .sort((a, b) => b.device.lastSeen - a.device.lastSeen);
260
-
261
- const summary = [
262
- `${results.length} device${results.length === 1 ? "" : "s"}`,
263
- `${responding.length} responding`,
264
- ...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
265
- ...(offline.length > 0 ? [`${offline.length} offline`] : []),
266
- ].join(" · ");
267
-
268
- const lines = ["**Mesh**", summary];
269
- const section = (title: string, entries: MeshPingResult[]): void => {
270
- if (entries.length === 0) return;
271
- lines.push(
272
- "",
273
- `**${title}**`,
274
- ...entries.map((r) => meshDeviceLine(r, now)),
275
- );
276
- };
277
- section("Responding", responding);
278
- section("Unreachable", unreachable);
279
- section("Offline", offline);
280
-
281
- return lines.join("\n");
91
+ return renderMeshReportWith(results, DISCORD_REPORTS, now);
282
92
  }
283
93
 
284
94
  /** Render the `/usage` report — one block per exposed backend. */
285
95
  export function renderUsageMessage(entries: BackendUsageEntry[]): string {
286
- const lines = ["**📊 Plan usage**"];
287
-
288
- for (const entry of entries) {
289
- const name = entry.label || entry.id;
290
- if (!entry.plan) {
291
- lines.push("", `**${name}** — _${entry.note ?? ""}_`);
292
- continue;
293
- }
294
- const age = entry.plan.ageLabel ? ` *(${entry.plan.ageLabel})*` : "";
295
- const plan = entry.plan.plan ? ` · ${entry.plan.plan}` : "";
296
- lines.push("", `**${name}**${plan}${age}`);
297
- if (entry.plan.resetsAvailable) {
298
- const n = entry.plan.resetsAvailable;
299
- lines.push(
300
- ` • You have **${n}** usage limit reset${n === 1 ? "" : "s"} available`,
301
- );
302
- }
303
- for (const w of entry.plan.windows) {
304
- const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
305
- lines.push(
306
- ` \`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${reset}`,
307
- );
308
- }
309
- }
310
-
311
- return lines.join("\n");
96
+ return renderUsageMessageWith(entries, DISCORD_REPORTS);
312
97
  }
313
98
 
314
99
  /** Settings panel: build the markdown body. */
@@ -319,15 +104,12 @@ export function renderSettingsText(
319
104
  pulseIntervalMs?: number,
320
105
  modelDetails?: Array<string>,
321
106
  ): string {
322
- const intervalStr = pulseIntervalMs
323
- ? formatDuration(pulseIntervalMs)
324
- : formatDuration(DEFAULT_PULSE_INTERVAL_MS);
325
- return [
326
- "**🦅 Settings**",
327
- "",
328
- `**Model:** \`${formatModelLabel(model)}\``,
329
- ...(modelDetails?.length ? modelDetails : []),
330
- `**Effort:** ${effort}`,
331
- `**🔔 Pulse:** ${proactive ? "on" : "off"} (every ${intervalStr})`,
332
- ].join("\n");
107
+ return renderSettingsTextWith(
108
+ DISCORD_REPORTS,
109
+ model,
110
+ effort,
111
+ proactive,
112
+ pulseIntervalMs,
113
+ modelDetails,
114
+ );
333
115
  }