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.
- package/package.json +1 -2
- package/src/backend/claude-sdk/handler.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -1
- package/src/backend/kilo/handler/index.ts +1 -2
- package/src/backend/kilo/handler/state.ts +3 -8
- package/src/backend/kilo/index.ts +2 -28
- package/src/backend/openai-agents/handler/message.ts +1 -1
- package/src/backend/openai-agents/session.ts +1 -1
- package/src/backend/opencode/handler/index.ts +1 -2
- package/src/backend/opencode/handler/state.ts +3 -8
- package/src/backend/opencode/index.ts +0 -7
- package/src/backend/shared/delivery.ts +1 -1
- package/src/backend/shared/handle-retry.ts +1 -1
- package/src/backend/shared/index.ts +3 -2
- package/src/backend/shared/turn-interrupt.ts +1 -1
- package/src/cli/index.ts +0 -8
- package/src/core/bus/index.ts +1 -9
- package/src/core/mesh/registry.ts +106 -4
- package/src/core/mesh/service.ts +22 -7
- package/src/core/mesh/transfers.ts +33 -4
- package/src/core/plugin/index.ts +0 -9
- package/src/core/prompt/index.ts +1 -16
- package/src/core/vfs/index.ts +2 -21
- package/src/core/weaver/index.ts +5 -9
- package/src/frontend/discord/actions/messaging.ts +73 -13
- package/src/frontend/discord/actions/shared.ts +21 -3
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/errors.ts +16 -5
- package/src/frontend/discord/formatting.ts +19 -4
- package/src/frontend/native/index.ts +22 -8
- package/src/frontend/native/protocol.ts +6 -0
- package/src/frontend/native/server.ts +171 -12
- package/src/frontend/shared/format.ts +6 -0
- package/src/frontend/telegram/actions/messaging.ts +120 -24
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/formatting.ts +64 -19
- package/src/frontend/terminal/commands.ts +46 -8
- package/src/frontend/terminal/input.ts +110 -12
- package/src/frontend/terminal/renderer.ts +2 -0
- package/src/{util → storage}/metrics.ts +1 -1
- package/src/storage/scheduled-store.ts +6 -3
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/backend/codex/index.ts +0 -33
- package/src/backend/openai-agents/index.ts +0 -48
- package/src/cli/plugin-entries.ts +0 -104
- package/src/core/agent-runtime/index.ts +0 -62
- package/src/core/background/index.ts +0 -21
- package/src/core/engine/index.ts +0 -20
- package/src/core/models/index.ts +0 -17
- package/src/core/soul/index.ts +0 -140
- package/src/core/tools/mcp-server.ts +0 -111
- /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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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,
|
|
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
|
|
215
|
-
|
|
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);
|
|
@@ -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 "../../../
|
|
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;.
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
processed = processed
|
|
67
|
-
|
|
68
|
-
|
|
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;.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
23
|
-
type
|
|
24
|
-
type
|
|
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:
|
|
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():
|
|
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
|
-
//
|
|
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(" · "))}`);
|
|
@@ -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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
package/src/util/config.ts
CHANGED
|
@@ -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
|