talon-agent 5.4.1 → 5.5.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 (32) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -2
  3. package/prompts/identity.md +2 -0
  4. package/prompts/telegram.md +2 -1
  5. package/src/app.ts +8 -0
  6. package/src/backend/runtime/turn/turn-phases.ts +5 -0
  7. package/src/bootstrap.ts +55 -24
  8. package/src/core/agents/runner.ts +50 -2
  9. package/src/core/agents/types.ts +5 -0
  10. package/src/core/background/cron/job-oneshot.ts +2 -0
  11. package/src/core/background/cron/scheduler.ts +45 -2
  12. package/src/core/background/heartbeat/agent.ts +117 -20
  13. package/src/core/background/heartbeat/state.ts +11 -0
  14. package/src/core/config/index.ts +38 -0
  15. package/src/core/engine/backend-controller/index.ts +1 -0
  16. package/src/core/engine/backend-controller/pool.ts +11 -0
  17. package/src/core/engine/backend-router/headroom.ts +267 -0
  18. package/src/core/engine/backend-router/index.ts +52 -0
  19. package/src/core/engine/backend-router/ledger.ts +248 -0
  20. package/src/core/engine/backend-router/router.ts +322 -0
  21. package/src/core/engine/backend-router/usage.ts +80 -0
  22. package/src/core/engine/gateway-actions/agents/control.ts +2 -1
  23. package/src/core/engine/gateway-actions/models.ts +74 -37
  24. package/src/core/tools/ops/models.ts +2 -2
  25. package/src/frontend/presentation/plan-usage-report.ts +29 -38
  26. package/src/frontend/presentation/reports.ts +5 -1
  27. package/src/frontend/telegram/formatting.ts +39 -0
  28. package/src/frontend/telegram/handlers/access.ts +41 -0
  29. package/src/frontend/telegram/handlers/index.ts +1 -0
  30. package/src/frontend/telegram/index.ts +7 -1
  31. package/src/frontend/terminal/builtins/status.ts +29 -1
  32. package/src/util/log.ts +1 -0
@@ -89,6 +89,36 @@ function applyInlineFormatting(input: string, urls: string[]): string {
89
89
  return out;
90
90
  }
91
91
 
92
+ /**
93
+ * Block-level passes over already-escaped text: ATX headings → bold lines,
94
+ * runs of `> ` lines → one `<blockquote>`. Placeholders for code are opaque
95
+ * `\x00…\x00` tokens, so a `#` inside a fenced block is never seen here.
96
+ */
97
+ function applyBlockFormatting(input: string): string {
98
+ const lines = input.split("\n");
99
+ const out: string[] = [];
100
+ let quote: string[] | null = null;
101
+ const flushQuote = () => {
102
+ if (quote) out.push(`<blockquote>${quote.join("\n")}</blockquote>`);
103
+ quote = null;
104
+ };
105
+ for (const line of lines) {
106
+ const quoted = /^&gt;[ \t]?(.*)$/.exec(line);
107
+ if (quoted) {
108
+ (quote ??= []).push(quoted[1] ?? "");
109
+ continue;
110
+ }
111
+ flushQuote();
112
+ // `#` must be followed by whitespace to be a heading: `#993` is a
113
+ // reference, not a title. Trailing closing hashes (`## Title ##`) are
114
+ // markdown too and are dropped.
115
+ const heading = /^#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(line);
116
+ out.push(heading ? `<b>${heading[1]}</b>` : line);
117
+ }
118
+ flushQuote();
119
+ return out.join("\n");
120
+ }
121
+
92
122
  /**
93
123
  * Convert Markdown output to Telegram-safe HTML.
94
124
  *
@@ -145,6 +175,15 @@ export function markdownToTelegramHtml(text: string): string {
145
175
  // oxlint-disable-next-line no-control-regex
146
176
  processed = processed.replace(/[^`\x00]+/g, (segment) => escapeHtml(segment));
147
177
 
178
+ // Step 3b: Block-level markdown. Telegram HTML has no headings, so an
179
+ // ATX heading becomes a bold line — the alternative is the `###` going
180
+ // out raw, and `# Title` alone turns `#Title` into a hashtag entity.
181
+ // Only `#` followed by whitespace is a heading: `#993` is a PR/hashtag
182
+ // reference and stays literal. Runs of `> ` lines become a blockquote,
183
+ // which Telegram does render. The text is already escaped here, so the
184
+ // quote marker is `&gt;`.
185
+ processed = applyBlockFormatting(processed);
186
+
148
187
  // Step 4: Apply inline formatting, but only keep it if the result is
149
188
  // actually parseable. `processed` at this point holds escaped text plus
150
189
  // tag-free placeholders, so it is a safe unformatted fallback.
@@ -151,6 +151,47 @@ export async function isAccessAllowed(
151
151
  return false;
152
152
  }
153
153
 
154
+ /**
155
+ * True when the update is a /command or an inline-button press.
156
+ *
157
+ * Both are handled by `bot.command` / `bot.callbackQuery` handlers that
158
+ * run before — and independently of — the message handlers, so the
159
+ * message-path access check never sees them.
160
+ */
161
+ export function isCommandOrCallback(ctx: Context): boolean {
162
+ if (ctx.callbackQuery) return true;
163
+ const msg = ctx.message ?? ctx.editedMessage;
164
+ if (!msg) return false;
165
+ const entities = msg.entities ?? msg.caption_entities ?? [];
166
+ return entities.some((e) => e.type === "bot_command" && e.offset === 0);
167
+ }
168
+
169
+ /**
170
+ * Gate every /command and button press behind the same access check as
171
+ * ordinary messages (denylist, DM whitelist, group admin membership).
172
+ *
173
+ * Without this, a sender outside `allowedUsers` who DMs the bot is refused
174
+ * for plain text but can still run /model, /settings, /status, /mesh,
175
+ * /plugins, /memory and press any inline button, because those handlers
176
+ * are registered with grammy directly and never pass through
177
+ * `isAccessAllowed`. Must be installed BEFORE registerCommands and
178
+ * registerCallbacks so it sees the update first.
179
+ */
180
+ export function registerCommandAccessGate(bot: Bot): void {
181
+ bot.use(async (ctx, next) => {
182
+ if (!isCommandOrCallback(ctx)) return next();
183
+ if (await isAccessAllowed(ctx, bot)) return next();
184
+ if (ctx.callbackQuery) {
185
+ try {
186
+ await ctx.answerCallbackQuery();
187
+ } catch {
188
+ /* stale query — ignore */
189
+ }
190
+ }
191
+ // Dropped: not passed to any command or callback handler.
192
+ });
193
+ }
194
+
154
195
  /**
155
196
  * Maximum length of an unauthorized message body to retain in logs.
156
197
  * Keeps abusive payloads (large pastes, attachment captions etc.) bounded
@@ -17,6 +17,7 @@ export {
17
17
  setAccessControl,
18
18
  shouldHandleInGroup,
19
19
  extractUnauthorizedPreview,
20
+ registerCommandAccessGate,
20
21
  } from "./access.js";
21
22
  export {
22
23
  getSenderName,
@@ -21,7 +21,10 @@ import {
21
21
  setAdminUserId,
22
22
  telegramCommandMenu,
23
23
  } from "./commands/index.js";
24
- import { setAccessControl } from "./handlers/index.js";
24
+ import {
25
+ setAccessControl,
26
+ registerCommandAccessGate,
27
+ } from "./handlers/index.js";
25
28
  import { registerMiddleware } from "./middleware.js";
26
29
  import { confirmUpdates } from "./update-offset.js";
27
30
  import { registerCallbacks } from "./callbacks/index.js";
@@ -94,6 +97,9 @@ export function createTelegramFrontend(
94
97
  adminUserId: config.adminUserId,
95
98
  });
96
99
 
100
+ // Gate /commands and button presses behind the DM whitelist and
101
+ // group check BEFORE any command/callback handler is registered.
102
+ registerCommandAccessGate(bot);
97
103
  registerCommands(bot, config, gateway);
98
104
  registerMiddleware(bot, config);
99
105
  registerCallbacks(bot, config, gateway);
@@ -14,7 +14,14 @@ import {
14
14
  import { getChatSettings } from "../../../storage/chat-settings.js";
15
15
  import { getSessionInfo } from "../../../storage/sessions.js";
16
16
  import { getLoadedPlugins } from "../../../core/plugin/index.js";
17
- import { getPooledBackend } from "../../../core/engine/backend-controller/index.js";
17
+ import {
18
+ getPoolConfig,
19
+ getPooledBackend,
20
+ } from "../../../core/engine/backend-controller/index.js";
21
+ import {
22
+ collectBackendUsage,
23
+ formatHeadroom,
24
+ } from "../../../core/engine/backend-router/index.js";
18
25
  import type { Command, CommandContext } from "../command-registry.js";
19
26
 
20
27
  type BackendRef = CommandContext["backend"];
@@ -146,6 +153,27 @@ async function writePlan(ctx: CommandContext, be: BackendRef): Promise<void> {
146
153
  );
147
154
  }
148
155
  }
156
+ await writeHeadroom(ctx);
157
+ }
158
+
159
+ /**
160
+ * What the plan-aware router sees: one comparable figure per backend, so
161
+ * "why did that sub-agent run on agy?" is answerable from `/status`.
162
+ * Ledger-derived rows say so — they are Talon's own count, not the
163
+ * provider's.
164
+ */
165
+ async function writeHeadroom(ctx: CommandContext): Promise<void> {
166
+ const entries = await collectBackendUsage(getPoolConfig() ?? undefined).catch(
167
+ () => [],
168
+ );
169
+ if (entries.length === 0) return;
170
+ ctx.renderer.writeln();
171
+ ctx.renderer.writeln(` ${pc.bold("Headroom")}`);
172
+ for (const entry of entries) {
173
+ ctx.renderer.writeln(
174
+ ` ${(entry.label || entry.id).padEnd(14)}${pc.dim(formatHeadroom(entry.headroom))}`,
175
+ );
176
+ }
149
177
  }
150
178
 
151
179
  function writePlugins(ctx: CommandContext): void {
package/src/util/log.ts CHANGED
@@ -68,6 +68,7 @@ export type LogComponent =
68
68
  | "memory"
69
69
  | "stickers"
70
70
  | "backup"
71
+ | "router"
71
72
  | "backend-controller";
72
73
 
73
74
  const LOG_FILE = files.log;