talon-agent 3.8.2 → 3.9.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 (54) hide show
  1. package/package.json +1 -2
  2. package/src/backend/claude-sdk/handler.ts +1 -1
  3. package/src/backend/codex/handler/message.ts +1 -1
  4. package/src/backend/kilo/handler/index.ts +1 -2
  5. package/src/backend/kilo/handler/state.ts +3 -8
  6. package/src/backend/kilo/index.ts +2 -28
  7. package/src/backend/openai-agents/handler/message.ts +1 -1
  8. package/src/backend/openai-agents/session.ts +1 -1
  9. package/src/backend/opencode/handler/index.ts +1 -2
  10. package/src/backend/opencode/handler/state.ts +3 -8
  11. package/src/backend/opencode/index.ts +0 -7
  12. package/src/backend/shared/delivery.ts +1 -1
  13. package/src/backend/shared/handle-retry.ts +1 -1
  14. package/src/backend/shared/index.ts +3 -2
  15. package/src/backend/shared/turn-interrupt.ts +1 -1
  16. package/src/cli/index.ts +0 -8
  17. package/src/core/bus/index.ts +1 -9
  18. package/src/core/mesh/registry.ts +106 -4
  19. package/src/core/mesh/service.ts +22 -7
  20. package/src/core/mesh/transfers.ts +33 -4
  21. package/src/core/plugin/index.ts +0 -9
  22. package/src/core/prompt/index.ts +1 -16
  23. package/src/core/vfs/index.ts +2 -21
  24. package/src/core/weaver/index.ts +5 -9
  25. package/src/frontend/discord/actions/messaging.ts +73 -13
  26. package/src/frontend/discord/actions/shared.ts +21 -3
  27. package/src/frontend/discord/commands/admin.ts +1 -1
  28. package/src/frontend/discord/errors.ts +16 -5
  29. package/src/frontend/discord/formatting.ts +19 -4
  30. package/src/frontend/native/index.ts +22 -8
  31. package/src/frontend/native/protocol.ts +6 -0
  32. package/src/frontend/native/server.ts +171 -12
  33. package/src/frontend/shared/format.ts +6 -0
  34. package/src/frontend/telegram/actions/messaging.ts +120 -24
  35. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  36. package/src/frontend/telegram/commands/admin.ts +1 -1
  37. package/src/frontend/telegram/formatting.ts +64 -19
  38. package/src/frontend/terminal/commands.ts +46 -8
  39. package/src/frontend/terminal/input.ts +110 -12
  40. package/src/frontend/terminal/renderer.ts +2 -0
  41. package/src/{util → storage}/metrics.ts +1 -1
  42. package/src/storage/scheduled-store.ts +6 -3
  43. package/src/util/config.ts +8 -0
  44. package/src/util/log.ts +1 -0
  45. package/src/backend/codex/index.ts +0 -33
  46. package/src/backend/openai-agents/index.ts +0 -48
  47. package/src/cli/plugin-entries.ts +0 -104
  48. package/src/core/agent-runtime/index.ts +0 -62
  49. package/src/core/background/index.ts +0 -21
  50. package/src/core/engine/index.ts +0 -20
  51. package/src/core/models/index.ts +0 -17
  52. package/src/core/soul/index.ts +0 -140
  53. package/src/core/tools/mcp-server.ts +0 -111
  54. /package/src/{backend/shared → util}/session-name.ts +0 -0
@@ -18,6 +18,99 @@ import {
18
18
  import { sendText, toPositiveId } from "./shared.js";
19
19
  import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
20
20
 
21
+ // ── Inline keyboards ─────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Telegram caps `callback_data` at 64 BYTES, not characters
25
+ * (https://core.telegram.org/bots/api#inlinekeyboardbutton). Exceeding it
26
+ * fails the whole sendMessage with BUTTON_DATA_INVALID — the message never
27
+ * arrives, buttons and all.
28
+ *
29
+ * Bytes rather than chars matters: a 21-character Japanese label is already
30
+ * over budget, so non-Latin keyboards break far sooner than English ones.
31
+ */
32
+ const CALLBACK_DATA_MAX_BYTES = 64;
33
+
34
+ type ButtonSpec = { text: string; url?: string; callback_data?: string };
35
+
36
+ /** The two inline-button shapes this module emits. */
37
+ type BuiltButton =
38
+ { text: string; url: string } | { text: string; callback_data: string };
39
+
40
+ /** Byte length of `text` when UTF-8 encoded. */
41
+ function utf8Bytes(text: string): number {
42
+ return Buffer.byteLength(text, "utf8");
43
+ }
44
+
45
+ /** Longest prefix of `text` that fits `maxBytes`, never splitting a code point. */
46
+ function truncateUtf8(text: string, maxBytes: number): string {
47
+ if (utf8Bytes(text) <= maxBytes) return text;
48
+ let out = "";
49
+ let used = 0;
50
+ for (const char of text) {
51
+ const size = utf8Bytes(char);
52
+ if (used + size > maxBytes) break;
53
+ out += char;
54
+ used += size;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Resolve one button's callback data.
61
+ *
62
+ * An EXPLICIT `callback_data` is semantic — the model dispatches on the exact
63
+ * value it chose — so an over-long one is reported rather than truncated;
64
+ * handing the callback handler a different string than the model expects
65
+ * would be a silent behaviour change. The `text` fallback carries no such
66
+ * contract, so it is truncated to fit.
67
+ */
68
+ function callbackDataFor(
69
+ btn: ButtonSpec,
70
+ ): { data: string } | { error: string } {
71
+ if (btn.callback_data !== undefined) {
72
+ const bytes = utf8Bytes(btn.callback_data);
73
+ if (bytes > CALLBACK_DATA_MAX_BYTES) {
74
+ return {
75
+ error:
76
+ `callback_data for button "${btn.text}" is ${bytes} bytes; ` +
77
+ `Telegram allows at most ${CALLBACK_DATA_MAX_BYTES}. Use a short ` +
78
+ `token (e.g. "opt_a") and keep the wording in the button text.`,
79
+ };
80
+ }
81
+ return { data: btn.callback_data };
82
+ }
83
+ return { data: truncateUtf8(btn.text, CALLBACK_DATA_MAX_BYTES) };
84
+ }
85
+
86
+ /**
87
+ * Build an inline keyboard from button rows, enforcing the callback_data
88
+ * cap. Returns an error message instead of a keyboard when the model
89
+ * supplied explicit data that cannot be sent.
90
+ *
91
+ * Exported as a unit seam — every button path in this module routes
92
+ * through it, so the cap is testable without a live Bot.
93
+ */
94
+ export function buildInlineKeyboard(
95
+ rows: ButtonSpec[][],
96
+ ): { keyboard: BuiltButton[][] } | { error: string } {
97
+ const keyboard: BuiltButton[][] = [];
98
+ for (const row of rows) {
99
+ const built: BuiltButton[] = [];
100
+ for (const btn of row) {
101
+ if (btn.url) {
102
+ built.push({ text: btn.text, url: btn.url });
103
+ continue;
104
+ }
105
+ const resolved = callbackDataFor(btn);
106
+ if ("error" in resolved) return { error: resolved.error };
107
+ built.push({ text: btn.text, callback_data: resolved.data });
108
+ }
109
+ keyboard.push(built);
110
+ }
111
+ return { keyboard };
112
+ }
113
+
21
114
  // ── Scheduled sends (persistent) ─────────────────────────────────────────────
22
115
 
23
116
  /** Longest schedulable delay: 24h. Timers re-arm from the store on boot. */
@@ -26,23 +119,29 @@ const MAX_DELAY_SEC = 24 * 60 * 60;
26
119
  /** Deliver one scheduled entry (shared by the live timer and restore). */
27
120
  async function fireScheduled(bot: Bot, entry: ScheduledMessage): Promise<void> {
28
121
  const chatId = Number(entry.chatId);
122
+ // `replyTo` is stored in the owning frontend's native shape (Discord keeps
123
+ // snowflake strings there); Telegram's API wants a number.
124
+ const replyTo = toPositiveId(entry.replyTo);
29
125
  if (entry.rows) {
30
- const keyboard = entry.rows.map((row) =>
31
- row.map((btn) =>
32
- btn.url
33
- ? { text: btn.text, url: btn.url }
34
- : { text: btn.text, callback_data: btn.callback_data ?? btn.text },
35
- ),
36
- );
126
+ // Validated at schedule time; rebuilt here so entries persisted by an
127
+ // older build (or edited on disk) still can't fail the send.
128
+ const built = buildInlineKeyboard(entry.rows);
129
+ if ("error" in built) {
130
+ logWarn(
131
+ "bot",
132
+ `Scheduled message ${entry.id} has bad buttons: ${built.error}`,
133
+ );
134
+ await sendText(bot, chatId, entry.text, replyTo);
135
+ return;
136
+ }
137
+ const keyboard = built.keyboard;
37
138
  await bot.api.sendMessage(chatId, markdownToTelegramHtml(entry.text), {
38
139
  parse_mode: "HTML",
39
140
  reply_markup: { inline_keyboard: keyboard },
40
- reply_parameters: entry.replyTo
41
- ? { message_id: entry.replyTo }
42
- : undefined,
141
+ reply_parameters: replyTo ? { message_id: replyTo } : undefined,
43
142
  });
44
143
  } else {
45
- await sendText(bot, chatId, entry.text, entry.replyTo);
144
+ await sendText(bot, chatId, entry.text, replyTo);
46
145
  }
47
146
  }
48
147
 
@@ -211,20 +310,11 @@ export const messagingHandlers: TelegramActionHandlers = {
211
310
  if (text.length > TELEGRAM_MAX_TEXT)
212
311
  return { ok: false, error: `Text too long` };
213
312
  const html = markdownToTelegramHtml(text);
214
- const rows = body.rows as Array<
215
- Array<{ text: string; url?: string; callback_data?: string }>
216
- >;
313
+ const rows = body.rows as ButtonSpec[][];
314
+ const built = buildInlineKeyboard(rows);
315
+ if ("error" in built) return { ok: false, error: built.error };
316
+ const keyboard = built.keyboard;
217
317
  gateway.incrementMessages(chatId);
218
- const keyboard = rows.map((row) =>
219
- row.map((btn) =>
220
- btn.url
221
- ? { text: btn.text, url: btn.url }
222
- : {
223
- text: btn.text,
224
- callback_data: btn.callback_data ?? btn.text,
225
- },
226
- ),
227
- );
228
318
  try {
229
319
  const sent = await bot.api.sendMessage(chatId, html, {
230
320
  parse_mode: "HTML",
@@ -243,6 +333,12 @@ export const messagingHandlers: TelegramActionHandlers = {
243
333
  const text = String(body.text ?? "");
244
334
  const replyTo = toPositiveId(body.reply_to_message_id);
245
335
  const rows = body.rows as ScheduledMessage["rows"];
336
+ // Validate buttons now rather than at fire time — a rejection minutes
337
+ // later, with the turn long over, is invisible to the model.
338
+ if (rows) {
339
+ const built = buildInlineKeyboard(rows);
340
+ if ("error" in built) return { ok: false, error: built.error };
341
+ }
246
342
  // NaN (e.g. delay_seconds: "5m") must fall back to the default, not
247
343
  // propagate: setTimeout(fn, NaN) fires immediately.
248
344
  const requested = Number(body.delay_seconds ?? 60);
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Context } from "grammy";
10
- import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
10
+ import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
11
11
  import {
12
12
  renderMetricsKeyboard,
13
13
  renderMetricsPanel,
@@ -24,7 +24,7 @@ import {
24
24
  } from "../helpers/index.js";
25
25
  import { collectDoctorReport } from "../../../core/doctor.js";
26
26
  import { handleAdminCommand } from "../admin.js";
27
- import { getTodayMetrics } from "../../../util/metrics.js";
27
+ import { getTodayMetrics } from "../../../storage/metrics.js";
28
28
  import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
29
29
  import { telegramCommandMenu } from "./definitions.js";
30
30
 
@@ -25,11 +25,67 @@ export function escapeHtml(text: string): string {
25
25
  return escapeNative(text);
26
26
  }
27
27
 
28
+ /**
29
+ * True when every tag in `html` is closed in the order it was opened.
30
+ *
31
+ * The inline formatters below are independent regex passes with no
32
+ * knowledge of each other's spans, so interleaved delimiters
33
+ * (`**a _b** c_`) emit crossed tags like `<b><i>x</b></i>`. Telegram
34
+ * refuses to parse those and 400s the whole message, so the caller
35
+ * checks before committing to the formatted rendering.
36
+ *
37
+ * Only tags this module generates are possible here — everything else
38
+ * was entity-escaped in step 3 — so a plain stack is sufficient.
39
+ */
40
+ function isWellFormedHtml(html: string): boolean {
41
+ const stack: string[] = [];
42
+ const tag = /<(\/?)([a-zA-Z]+)(?:\s[^>]*)?>/g;
43
+ let match: RegExpExecArray | null;
44
+ while ((match = tag.exec(html)) !== null) {
45
+ const [, closing, name] = match;
46
+ if (closing) {
47
+ if (stack.pop() !== name) return false;
48
+ } else {
49
+ stack.push(name!);
50
+ }
51
+ }
52
+ return stack.length === 0;
53
+ }
54
+
55
+ /** Apply the inline delimiter passes to already-escaped text. */
56
+ function applyInlineFormatting(input: string): string {
57
+ let out = input;
58
+ // Bold+italic: ***text*** — must run before the ** and * passes, which
59
+ // would otherwise split it into the crossed pair `<b><i>x</b></i>`.
60
+ out = out.replace(/\*\*\*(.+?)\*\*\*/g, "<b><i>$1</i></b>");
61
+ // Bold: **text**
62
+ out = out.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
63
+ // Italic: *text* (not preceded by another *)
64
+ out = out.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<i>$1</i>");
65
+ // Italic: _text_ (surrounded by non-word or start/end)
66
+ out = out.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
67
+ // Links: [text](url) — only safe URL schemes become anchors. Both text
68
+ // and url were already HTML-escaped by step 3 (quotes included, so the
69
+ // href attribute can't be broken out of); escaping again here corrupted
70
+ // every & in a query string into &amp;amp;.
71
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
72
+ /^https?:\/\//i.test(url) ? `<a href="${url}">${text}</a>` : text,
73
+ );
74
+ // Strikethrough: ~~text~~
75
+ out = out.replace(/~~(.+?)~~/g, "<s>$1</s>");
76
+ return out;
77
+ }
78
+
28
79
  /**
29
80
  * Convert Markdown output to Telegram-safe HTML.
30
81
  *
31
82
  * Handles: bold, italic, inline code, fenced code blocks, links.
32
83
  * Escapes HTML entities in non-formatted text.
84
+ *
85
+ * Guarantees well-formed output: when the inline passes would produce
86
+ * crossed tags, the formatting is dropped rather than emitted, because
87
+ * Telegram rejects the entire message on a parse error and the caller's
88
+ * only recourse is a second round-trip with no formatting at all.
33
89
  */
34
90
  export function markdownToTelegramHtml(text: string): string {
35
91
  // Step 1: Extract fenced code blocks to avoid processing their contents.
@@ -59,25 +115,14 @@ export function markdownToTelegramHtml(text: string): string {
59
115
  // oxlint-disable-next-line no-control-regex
60
116
  processed = processed.replace(/[^`\x00]+/g, (segment) => escapeHtml(segment));
61
117
 
62
- // Step 4: Apply inline formatting.
63
- // Bold: **text**
64
- processed = processed.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
65
- // Italic: *text* (not preceded by another *)
66
- processed = processed.replace(
67
- /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g,
68
- "<i>$1</i>",
69
- );
70
- // Italic: _text_ (surrounded by non-word or start/end)
71
- processed = processed.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
72
- // Links: [text](url) — only safe URL schemes become anchors. Both text
73
- // and url were already HTML-escaped by step 3 (quotes included, so the
74
- // href attribute can't be broken out of); escaping again here corrupted
75
- // every & in a query string into &amp;amp;.
76
- processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
77
- /^https?:\/\//i.test(url) ? `<a href="${url}">${text}</a>` : text,
78
- );
79
- // Strikethrough: ~~text~~
80
- processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");
118
+ // Step 4: Apply inline formatting, but only keep it if the result is
119
+ // actually parseable. `processed` at this point holds escaped text plus
120
+ // tag-free placeholders, so it is a safe unformatted fallback.
121
+ const unformatted = processed;
122
+ processed = applyInlineFormatting(processed);
123
+ if (!isWellFormedHtml(processed)) {
124
+ processed = unformatted;
125
+ }
81
126
 
82
127
  // Steps 5+6: Restore code spans and fenced blocks. The replacement MUST
83
128
  // go through a function: with a string, String.replace interprets $-
@@ -15,7 +15,15 @@ import {
15
15
  resolveModel as coreResolveModel,
16
16
  getModels,
17
17
  } from "../../core/models/catalog.js";
18
- import { buildCacheDisplay } from "../shared/status-context.js";
18
+ import {
19
+ buildCacheDisplay,
20
+ buildContextDisplay,
21
+ } from "../shared/status-context.js";
22
+ import {
23
+ formatDuration,
24
+ formatTokenCount,
25
+ formatUsd,
26
+ } from "../shared/format.js";
19
27
  import {
20
28
  getChatSettings,
21
29
  setChatModel,
@@ -282,7 +290,7 @@ export function registerBuiltinCommands(): void {
282
290
  let displayCacheRead = u.totalCacheRead;
283
291
  let displayCacheWrite = u.totalCacheWrite;
284
292
  let backendModelLine = "";
285
- let backendContextLine = "";
293
+ let contextWindow = u.contextWindow;
286
294
  ctx.renderer.writeln();
287
295
  const nameStr = info.sessionName ? `"${info.sessionName}" · ` : "";
288
296
 
@@ -313,25 +321,55 @@ export function registerBuiltinCommands(): void {
313
321
  if (modelInfo) {
314
322
  backendModelLine = ` ${pc.bold(label)} ${modelInfo.displayName} · ${modelInfo.providerName}${modelInfo.free ? " · free" : ""}`;
315
323
  if (modelInfo.contextWindow) {
316
- backendContextLine = cache
317
- ? ` ${pc.dim(`context window ${modelInfo.contextWindow.toLocaleString()} · cache ${cache.read.toLocaleString()} / ${cache.write.toLocaleString()}`)}`
318
- : ` ${pc.dim(`context window ${modelInfo.contextWindow.toLocaleString()}`)}`;
324
+ contextWindow ||= modelInfo.contextWindow;
319
325
  }
320
326
  }
321
327
  }
322
328
 
329
+ const context = buildContextDisplay({
330
+ contextTokens: u.contextTokens,
331
+ lastPromptTokens: u.lastPromptTokens,
332
+ contextWindow,
333
+ });
334
+ const contextUsed = context.known
335
+ ? formatTokenCount(context.used)
336
+ : "unknown";
337
+ const contextMax = context.max
338
+ ? formatTokenCount(context.max)
339
+ : "unknown";
340
+ const avgResponseMs =
341
+ info.turns > 0 && u.totalResponseMs
342
+ ? Math.round(u.totalResponseMs / info.turns)
343
+ : 0;
344
+ const fastestResponseMs =
345
+ Number.isFinite(u.fastestResponseMs) && u.fastestResponseMs > 0
346
+ ? u.fastestResponseMs
347
+ : 0;
348
+
323
349
  ctx.renderer.writeln(
324
350
  ` ${pc.bold("Session")} ${nameStr}turns ${info.turns}${cache ? ` · ${cache.hitPct}% cache` : ""}`,
325
351
  );
326
352
  ctx.renderer.writeln(
327
353
  ` ${pc.dim(`in ${displayInputTokens.toLocaleString()} · out ${displayOutputTokens.toLocaleString()} tokens`)}`,
328
354
  );
355
+ ctx.renderer.writeln();
356
+ ctx.renderer.writeln(
357
+ ` ${pc.bold("Context")} ${contextUsed} / ${contextMax} (${context.known ? `${context.pct}%` : "unknown"})${context.warn ? pc.yellow(" nearing limit") : ""}`,
358
+ );
359
+ ctx.renderer.writeln(
360
+ ` ${context.warn ? pc.yellow(context.bar) : pc.dim(context.bar)}`,
361
+ );
362
+ ctx.renderer.writeln(
363
+ ` ${pc.dim(`response last ${u.lastResponseMs ? formatDuration(u.lastResponseMs) : "—"} · avg ${avgResponseMs ? formatDuration(avgResponseMs) : "—"} · best ${fastestResponseMs ? formatDuration(fastestResponseMs) : "—"}`)}`,
364
+ );
365
+ if (u.estimatedCostUsd > 0) {
366
+ ctx.renderer.writeln(
367
+ ` ${pc.dim(`estimated session cost ${formatUsd(u.estimatedCostUsd)}`)}`,
368
+ );
369
+ }
329
370
  if (backendModelLine) {
330
371
  ctx.renderer.writeln();
331
372
  ctx.renderer.writeln(backendModelLine);
332
- if (backendContextLine) {
333
- ctx.renderer.writeln(backendContextLine);
334
- }
335
373
  }
336
374
 
337
375
  const plugins = getLoadedPlugins();
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Input is a list of parts: text segments and collapsed paste blocks.
5
5
  * You can type, paste, type more, paste again. Backspace removes from the end.
6
- * Enter submits everything. Ctrl+U clears all.
6
+ * Enter submits everything. Ctrl+U clears all. Up/Down walk prompt history.
7
7
  */
8
8
 
9
9
  import pc from "picocolors";
@@ -19,9 +19,10 @@ export type InputHandler = {
19
19
  resume(): void;
20
20
  };
21
21
 
22
- type TextPart = { type: "text"; content: string };
23
- type PastePart = { type: "paste"; content: string };
24
- type Part = TextPart | PastePart;
22
+ export type InputPart =
23
+ { type: "text"; content: string } | { type: "paste"; content: string };
24
+ type TextPart = Extract<InputPart, { type: "text" }>;
25
+ type PastePart = Extract<InputPart, { type: "paste" }>;
25
26
 
26
27
  // ── Constants ────────────────────────────────────────────────────────────────
27
28
 
@@ -29,6 +30,79 @@ const PASTE_COLLAPSE_LINES = 3;
29
30
  const PASTE_COLLAPSE_CHARS = 150;
30
31
  const PASTE_START = "\x1b[200~";
31
32
  const PASTE_END = "\x1b[201~";
33
+ const MAX_HISTORY_ITEMS = 100;
34
+
35
+ function cloneParts(parts: readonly InputPart[]): InputPart[] {
36
+ return parts.map((part) => ({ ...part }));
37
+ }
38
+
39
+ function partsText(parts: readonly InputPart[]): string {
40
+ return parts
41
+ .map((part) => part.content)
42
+ .join("\n")
43
+ .trim();
44
+ }
45
+
46
+ /**
47
+ * In-process prompt history. Keeping this out of the persistent stores avoids
48
+ * creating a second plaintext transcript while still giving terminal users
49
+ * the expected Up/Down workflow.
50
+ */
51
+ export class PromptHistory {
52
+ private readonly entries: InputPart[][] = [];
53
+ private cursor = 0;
54
+ private draft: InputPart[] | null = null;
55
+
56
+ constructor(private readonly maxItems = MAX_HISTORY_ITEMS) {}
57
+
58
+ add(parts: readonly InputPart[]): void {
59
+ const text = partsText(parts);
60
+ if (!text) return;
61
+
62
+ const latest = this.entries.at(-1);
63
+ if (!latest || partsText(latest) !== text) {
64
+ this.entries.push(cloneParts(parts));
65
+ if (this.entries.length > this.maxItems) this.entries.shift();
66
+ }
67
+ this.resetNavigation();
68
+ }
69
+
70
+ move(
71
+ direction: "previous" | "next",
72
+ current: readonly InputPart[],
73
+ ): InputPart[] | undefined {
74
+ if (this.entries.length === 0) return undefined;
75
+
76
+ if (direction === "previous") {
77
+ if (this.cursor === this.entries.length) {
78
+ this.draft = cloneParts(current);
79
+ }
80
+ if (this.cursor > 0) this.cursor--;
81
+ return cloneParts(this.entries[this.cursor]!);
82
+ }
83
+
84
+ if (this.cursor === this.entries.length) return undefined;
85
+ if (this.cursor < this.entries.length - 1) {
86
+ this.cursor++;
87
+ return cloneParts(this.entries[this.cursor]!);
88
+ }
89
+
90
+ this.cursor = this.entries.length;
91
+ return cloneParts(this.draft ?? [{ type: "text", content: "" }]);
92
+ }
93
+
94
+ /** Preserve an edited recalled prompt as the current draft. */
95
+ detach(parts: readonly InputPart[]): void {
96
+ if (this.cursor === this.entries.length) return;
97
+ this.cursor = this.entries.length;
98
+ this.draft = cloneParts(parts);
99
+ }
100
+
101
+ resetNavigation(): void {
102
+ this.cursor = this.entries.length;
103
+ this.draft = null;
104
+ }
105
+ }
32
106
 
33
107
  // ── Factory ──────────────────────────────────────────────────────────────────
34
108
 
@@ -38,7 +112,8 @@ export function createInput(promptStr: string): InputHandler {
38
112
  let paused = false;
39
113
 
40
114
  // Input is an ordered list of parts
41
- let parts: Part[] = [{ type: "text", content: "" }];
115
+ let parts: InputPart[] = [{ type: "text", content: "" }];
116
+ const history = new PromptHistory();
42
117
 
43
118
  // Bracketed paste accumulation
44
119
  let inPaste = false;
@@ -46,7 +121,7 @@ export function createInput(promptStr: string): InputHandler {
46
121
 
47
122
  // ── Helpers ──
48
123
 
49
- function lastPart(): Part {
124
+ function lastPart(): InputPart {
50
125
  return parts[parts.length - 1]!;
51
126
  }
52
127
 
@@ -99,18 +174,17 @@ export function createInput(promptStr: string): InputHandler {
99
174
  }
100
175
 
101
176
  function getFullText(): string {
102
- return parts
103
- .map((p) => p.content)
104
- .join("\n")
105
- .trim();
177
+ return partsText(parts);
106
178
  }
107
179
 
108
180
  function clear(): void {
109
181
  parts = [{ type: "text", content: "" }];
182
+ history.resetNavigation();
110
183
  }
111
184
 
112
185
  function submit(): void {
113
186
  const text = getFullText();
187
+ if (!pendingResolve) history.add(parts);
114
188
  clear();
115
189
  process.stdout.write("\n");
116
190
 
@@ -135,6 +209,7 @@ export function createInput(promptStr: string): InputHandler {
135
209
  // Short paste — inline into current text part
136
210
  ensureTrailingText().content += text.replace(/\n/g, " ");
137
211
  }
212
+ history.detach(parts);
138
213
  redraw();
139
214
  }
140
215
 
@@ -160,6 +235,14 @@ export function createInput(promptStr: string): InputHandler {
160
235
  if (parts.length === 0) parts.push({ type: "text", content: "" });
161
236
  ensureTrailingText();
162
237
  }
238
+ history.detach(parts);
239
+ redraw();
240
+ }
241
+
242
+ function navigateHistory(direction: "previous" | "next"): void {
243
+ const recalled = history.move(direction, parts);
244
+ if (!recalled) return;
245
+ parts = recalled;
163
246
  redraw();
164
247
  }
165
248
 
@@ -236,9 +319,22 @@ export function createInput(promptStr: string): InputHandler {
236
319
 
237
320
  if (code === 0x1b) {
238
321
  if (i + 1 < chunk.length && chunk[i + 1] === "[") {
239
- // ANSI escape sequence (arrows, etc.) — skip
322
+ // CSI escape sequence. Up/Down navigate history; the rest are
323
+ // ignored until cursor editing is implemented.
324
+ let end = i + 2;
325
+ while (end < chunk.length && chunk.charCodeAt(end) < 0x40) end++;
326
+ const final = chunk[end];
327
+ if (final === "A") navigateHistory("previous");
328
+ if (final === "B") navigateHistory("next");
329
+ i = end;
330
+ } else if (
331
+ i + 2 < chunk.length &&
332
+ chunk[i + 1] === "O" &&
333
+ (chunk[i + 2] === "A" || chunk[i + 2] === "B")
334
+ ) {
335
+ // Some terminals use application-cursor sequences (ESC O A/B).
336
+ navigateHistory(chunk[i + 2] === "A" ? "previous" : "next");
240
337
  i += 2;
241
- while (i < chunk.length && chunk.charCodeAt(i) < 0x40) i++;
242
338
  } else if (pendingResolve) {
243
339
  // Bare Escape during waitForInput — cancel
244
340
  const resolve = pendingResolve;
@@ -253,6 +349,7 @@ export function createInput(promptStr: string): InputHandler {
253
349
  if (code === 0x09) {
254
350
  // Tab
255
351
  ensureTrailingText().content += " ";
352
+ history.detach(parts);
256
353
  redraw();
257
354
  continue;
258
355
  }
@@ -261,6 +358,7 @@ export function createInput(promptStr: string): InputHandler {
261
358
 
262
359
  // Printable char
263
360
  ensureTrailingText().content += ch;
361
+ history.detach(parts);
264
362
  redraw();
265
363
  }
266
364
  });
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import pc from "picocolors";
10
+ import { formatUsd } from "../shared/format.js";
10
11
 
11
12
  // ── Types ────────────────────────────────────────────────────────────────────
12
13
 
@@ -193,6 +194,7 @@ export function createRenderer(cols?: number, displayName = "Talon"): Renderer {
193
194
  if (typeof info.cacheHitPct === "number") {
194
195
  p.push(`${info.cacheHitPct}% cache`);
195
196
  }
197
+ if (info.costUsd > 0) p.push(formatUsd(info.costUsd));
196
198
  if (tools > 0) p.push(`${tools} tool${tools > 1 ? "s" : ""}`);
197
199
  writeln();
198
200
  writeln(` ${pc.dim(p.join(" · "))}`);
@@ -8,7 +8,7 @@ import {
8
8
  todayUtc,
9
9
  type MetricsGrain,
10
10
  type MetricsLatencyAgg,
11
- } from "../storage/sessions.js";
11
+ } from "./sessions.js";
12
12
 
13
13
  const legacyCounters = new Map<string, number>();
14
14
 
@@ -40,9 +40,12 @@ export type ScheduledMessage = {
40
40
  /** Epoch ms when the message should be sent. */
41
41
  fireAt: number;
42
42
  createdAt: number;
43
- /** Telegram: numeric message id to reply to. */
44
- replyTo?: number;
45
- /** Telegram: inline keyboard rows. */
43
+ /**
44
+ * Message id to reply to, in the owning frontend's native shape:
45
+ * numeric for Telegram, a snowflake string for Discord.
46
+ */
47
+ replyTo?: number | string;
48
+ /** Inline keyboard / button rows to replay at fire time. */
46
49
  rows?: ScheduledButtonRow[];
47
50
  };
48
51
 
@@ -173,6 +173,14 @@ const nativeConfigSchema = z
173
173
  * (~/.talon/keys/bridge-token) — the network never gets an open bridge.
174
174
  */
175
175
  token: z.string().optional(),
176
+ /**
177
+ * Origins allowed to call the bridge from a BROWSER. Native clients
178
+ * (Electron main, Flutter, curl, talon-node) send no Origin header and
179
+ * never need an entry here. Anything listed gets a matching
180
+ * Access-Control-Allow-Origin; everything else is refused 403, so a
181
+ * random web page cannot drive the agent API on the user's machine.
182
+ */
183
+ allowedOrigins: z.array(z.string().min(1)).optional(),
176
184
  /**
177
185
  * Serve the bridge over TLS with a persistent self-signed certificate
178
186
  * (~/.talon/keys/); clients pin its SHA-256 fingerprint on first
package/src/util/log.ts CHANGED
@@ -53,6 +53,7 @@ export type LogComponent =
53
53
  | "teams"
54
54
  | "discord"
55
55
  | "native"
56
+ | "mesh"
56
57
  | "config"
57
58
  | "access"
58
59
  | "github"