pi-braid-telegram 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Durable store for owner messages not yet handed to the session.
3
+ *
4
+ * Owner messages steer by policy: an arrival is handed over at the coalescing
5
+ * window's close, busy or idle. An entry stays here only when that handoff
6
+ * did not happen, either because pi refused the steer (a prompt during
7
+ * compaction) or because a factory re-run (reload, session replacement)
8
+ * landed mid-window. The hold outlives the delivery attempt, so it lives in a
9
+ * FILE, not in an array: the extension factory is re-run on both lifecycle
10
+ * paths, a reload (resource-loader reload() clears the module cache and calls
11
+ * the factory again) and a session replacement (new/resume/fork build a new
12
+ * resource loader whose reload() calls the factory again, so the closure does
13
+ * not survive into the replacement session), and the delivery handler returns
14
+ * before the offset confirmation: the hold is written through while the
15
+ * handler runs, and the poller persists the offset past the update only at
16
+ * the end of the poll cycle. An in-memory hold would be dropped silently once
17
+ * the offset moves; the file survives the re-run, and each entry carries the
18
+ * session it was accepted for, so a hold rehydrated into a different named
19
+ * session is reported, not delivered — while an entry accepted before any
20
+ * session could name itself is adopted by the first session that names itself.
21
+ *
22
+ * This module only stores and returns entries: which session is current,
23
+ * whether an entry may be delivered, and what a human is told about an entry
24
+ * that cannot be, are delivery policy and live in the extension (index.ts).
25
+ * Every function here is total — a missing, truncated, or hand-edited file
26
+ * reads as "no entries" rather than throwing into a lifecycle handler; read
27
+ * failures other than ENOENT and version mismatches are surfaced through the
28
+ * caller-supplied reporter.
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
32
+ import { basename, dirname, extname, join } from "node:path";
33
+
34
+ import type { IncomingOwnerMessage } from "./inbound-poller.js";
35
+
36
+ /** One held owner message plus the session it was accepted for. */
37
+ export interface QueuedInboundMessage {
38
+ message: IncomingOwnerMessage;
39
+ /**
40
+ * Session the message was accepted for. `undefined` when the live context
41
+ * could not name one (no session manager on a synthetic context); such an
42
+ * entry is adopted by the first session that names itself (the current id is
43
+ * stamped on it at delivery/rehydrate). An entry accepted for a different
44
+ * named session is never delivered across that boundary.
45
+ */
46
+ sessionId?: string;
47
+ /** Acceptance time (ms) — keeps arrival order across process boundaries. */
48
+ queuedAt: number;
49
+ }
50
+
51
+ /** Current on-disk shape. Bumped only when the entry shape changes. */
52
+ const QUEUE_VERSION = 1;
53
+
54
+ /**
55
+ * Queue file for a given poller state file: a sibling in the same directory,
56
+ * named after the state file's stem, so the documented `BRAID_TG_STATE_FILE`
57
+ * knob governs both the offset and the hold, and two state files in one
58
+ * directory keep two separate holds. The name also says whose hold it was:
59
+ * moving the state file to another directory strands the old queue beside the
60
+ * old path — there is no auto-migration, and none is possible from the file
61
+ * alone.
62
+ */
63
+ export function inboundQueueFileFor(stateFile: string): string {
64
+ const stem = basename(stateFile, extname(stateFile));
65
+ return join(dirname(stateFile), `${stem}-inbound-queue.json`);
66
+ }
67
+
68
+ /** Entry-shape guard for hand-edited files (the file version is checked separately). */
69
+ function isQueuedEntry(value: unknown): value is QueuedInboundMessage {
70
+ const entry = value as Partial<QueuedInboundMessage> | undefined;
71
+ const message = entry?.message as Partial<IncomingOwnerMessage> | undefined;
72
+ if (!message || typeof message !== "object") return false;
73
+ if (!Number.isFinite(message.updateId)) return false;
74
+ if (typeof message.text !== "string") return false;
75
+ if (entry?.sessionId !== undefined && typeof entry.sessionId !== "string") {
76
+ return false;
77
+ }
78
+ return Number.isFinite(entry?.queuedAt);
79
+ }
80
+
81
+ /**
82
+ * Read problems that are not "nothing held": a non-ENOENT read failure or a
83
+ * file written by another version. The store stays total (the caller still
84
+ * gets `[]`), but a silent discard is exactly the failure class the durable
85
+ * hold exists to remove, so the caller is handed a message for its log.
86
+ * ENOENT and unparsable content are not reported: absent and hand-edited state
87
+ * is the documented "no entries" reading.
88
+ */
89
+ export type QueueReadReporter = (message: string) => void;
90
+
91
+ /**
92
+ * Read the held entries. Never throws: absent, unreadable, unparsable, or
93
+ * foreign-version state means "nothing held" (a lost hold is reported by the
94
+ * delivery path, which knows the session it belonged to; a read failure here
95
+ * must not take down the lifecycle handler that is rehydrating it). Read
96
+ * failures other than ENOENT and version mismatches are passed to `report`.
97
+ */
98
+ export async function loadInboundQueue(
99
+ queueFile: string,
100
+ report?: QueueReadReporter,
101
+ ): Promise<QueuedInboundMessage[]> {
102
+ let raw: string;
103
+ try {
104
+ raw = await readFile(queueFile, "utf8");
105
+ } catch (error) {
106
+ const code = (error as { code?: unknown } | null)?.code;
107
+ if (code !== "ENOENT") {
108
+ report?.(
109
+ `could not read the inbound hold file ${queueFile}: ${String(error)}`,
110
+ );
111
+ }
112
+ return [];
113
+ }
114
+ let parsed: unknown;
115
+ try {
116
+ parsed = JSON.parse(raw);
117
+ } catch {
118
+ return [];
119
+ }
120
+ const file = parsed as { version?: unknown; entries?: unknown } | null;
121
+ if (file?.version !== QUEUE_VERSION) {
122
+ report?.(
123
+ `ignoring the inbound hold file ${queueFile}: version ${String(file?.version)} is not ${QUEUE_VERSION}`,
124
+ );
125
+ return [];
126
+ }
127
+ const entries = Array.isArray(file.entries) ? file.entries : [];
128
+ return entries.filter(isQueuedEntry);
129
+ }
130
+
131
+ /**
132
+ * Persist the held entries ATOMICALLY (write-temp-rename, same rationale as
133
+ * savePauseState): the JSON is written to a temp file first and renamed over
134
+ * the target, so a crash mid-write can never leave a truncated queue behind —
135
+ * the truncation would read back as "nothing held" and lose exactly the
136
+ * messages this file exists to protect. The temp name is unique per write:
137
+ * a shared temp made concurrent savers fail each other's rename (ENOENT),
138
+ * while unique temps all land and the last rename wins with a whole snapshot.
139
+ */
140
+ export async function saveInboundQueue(
141
+ queueFile: string,
142
+ entries: QueuedInboundMessage[],
143
+ ): Promise<void> {
144
+ const dir = dirname(queueFile);
145
+ try {
146
+ await mkdir(dir, { recursive: true });
147
+ } catch {
148
+ // parent may already exist; the write will surface real errors
149
+ }
150
+ const tmp = `${queueFile}.${randomUUID()}.tmp`;
151
+ await writeFile(
152
+ tmp,
153
+ JSON.stringify({ version: QUEUE_VERSION, entries }, null, 2) + "\n",
154
+ "utf8",
155
+ );
156
+ await rename(tmp, queueFile);
157
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Local-time rendering for the telegram package — MIRROR of the core
3
+ * formatter.
4
+ *
5
+ * Telegram cannot import pi-braid core (standalone package, no core dep) —
6
+ * the established cross-package pattern is a documented literal mirror with
7
+ * a keep-in-sync test (see watchdog-tunnel.ts: the WATCHDOG_TG_SIGNAL_KEY
8
+ * mirror). This module mirrors packages/core/src/lib/utils/local-time.ts:
9
+ *
10
+ * formatWhisperClock(ms) -> "17:05 MSK" (whisper header `@ HH:MM TZ`)
11
+ * formatLocalTime(ms) -> "17:05 MSK (UTC+3)"
12
+ *
13
+ * Owner TZ: `braid.timezone` in ~/.pi/agent/settings.json (same key the
14
+ * core formatter reads — one setting controls both), default Europe/Moscow.
15
+ * Keep the two files in sync; test/local-time.test.mjs pins the parity
16
+ * contract (same inputs → same outputs as the core shapes).
17
+ *
18
+ * All Intl-based (Node >= 22 full ICU). No external deps.
19
+ */
20
+
21
+ import { readFileSync, existsSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { homedir } from "node:os";
24
+
25
+ export const DEFAULT_TIME_ZONE = "Europe/Moscow";
26
+
27
+ /** settings.json (pi-native) — same path + braid block as pause-control.ts. */
28
+ const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
29
+
30
+ /**
31
+ * Owner time zone: braid.timezone in ~/.pi/agent/settings.json when set,
32
+ * else Europe/Moscow. Mirrors core's getOwnerTimeZone() so both packages
33
+ * render the SAME zone from the SAME setting. Never throws.
34
+ */
35
+ export function ownerTimeZone(): string {
36
+ try {
37
+ if (existsSync(SETTINGS_PATH)) {
38
+ const parsed = JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as {
39
+ braid?: { timezone?: unknown };
40
+ };
41
+ const tz = parsed?.braid?.timezone;
42
+ if (typeof tz === "string" && tz.trim()) return tz.trim();
43
+ }
44
+ } catch {
45
+ // non-fatal — fall through to the default
46
+ }
47
+ return DEFAULT_TIME_ZONE;
48
+ }
49
+
50
+ /** Validate an IANA time zone name cheaply (invalid names fall back). */
51
+ function isValidTimeZone(tz: string): boolean {
52
+ try {
53
+ new Intl.DateTimeFormat("en-GB", { timeZone: tz });
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Abbreviation overrides for well-known FIXED-offset zones — CLDR renders
62
+ * Europe/Moscow as "GMT+3" but the owner format examples specify "MSK".
63
+ * MIRROR of packages/core/src/lib/utils/local-time.ts ZONE_ABBREVIATIONS —
64
+ * keep in sync (parity pinned by test/local-time.test.mjs). Zones not in the
65
+ * map fall back to the ICU short name ("EDT", "GMT+1", ...). Never invent an
66
+ * abbreviation for a DST zone.
67
+ */
68
+ const ZONE_ABBREVIATIONS: Record<string, string> = {
69
+ "Europe/Moscow": "MSK",
70
+ "Europe/Kaliningrad": "EET",
71
+ "Europe/Samara": "SAMT",
72
+ "Asia/Yekaterinburg": "YEKT",
73
+ "Asia/Omsk": "OMST",
74
+ "Asia/Novosibirsk": "NOVT",
75
+ "Asia/Krasnoyarsk": "KRAT",
76
+ "Asia/Irkutsk": "IRKT",
77
+ "Asia/Yakutsk": "YAKT",
78
+ "Asia/Vladivostok": "VLAT",
79
+ "Asia/Magadan": "MAGT",
80
+ "Asia/Kamchatka": "PETT",
81
+ };
82
+
83
+ /** The zone's short abbreviation (e.g. "MSK"); ICU short fallback otherwise. */
84
+ function zoneAbbreviation(date: Date, timeZone: string): string {
85
+ const known = ZONE_ABBREVIATIONS[timeZone];
86
+ if (known) return known;
87
+ const parts = new Intl.DateTimeFormat("en-GB", {
88
+ timeZone,
89
+ timeZoneName: "short",
90
+ }).formatToParts(date);
91
+ const name = parts.find((p) => p.type === "timeZoneName")?.value;
92
+ return name && name.length > 0 ? name : "UTC";
93
+ }
94
+
95
+ /** Offset from UTC in minutes at the given instant (DST-safe). */
96
+ function zoneOffsetMinutes(date: Date, timeZone: string): number {
97
+ const parts = new Intl.DateTimeFormat("en-US", {
98
+ timeZone,
99
+ hour12: false,
100
+ year: "numeric",
101
+ month: "2-digit",
102
+ day: "2-digit",
103
+ hour: "2-digit",
104
+ minute: "2-digit",
105
+ second: "2-digit",
106
+ }).formatToParts(date);
107
+ const map: Record<string, string> = {};
108
+ for (const p of parts) map[p.type] = p.value;
109
+ const asUTC = Date.UTC(
110
+ Number(map.year),
111
+ Number(map.month) - 1,
112
+ Number(map.day),
113
+ Number(map.hour) % 24,
114
+ Number(map.minute),
115
+ Number(map.second),
116
+ );
117
+ return Math.round((asUTC - date.getTime()) / 60_000);
118
+ }
119
+
120
+ /** Render a minutes offset as "UTC+3" / "UTC-5" / "UTC+5:30" / "UTC+0". */
121
+ function formatOffset(minutes: number): string {
122
+ if (minutes === 0) return "UTC+0";
123
+ const sign = minutes > 0 ? "+" : "-";
124
+ const abs = Math.abs(minutes);
125
+ const h = Math.floor(abs / 60);
126
+ const m = abs % 60;
127
+ return `UTC${sign}${h}${m > 0 ? `:${String(m).padStart(2, "0")}` : ""}`;
128
+ }
129
+
130
+ /**
131
+ * Full local-time form (mirror of core formatLocalTime): "HH:MM TZ (UTC±X)".
132
+ *
133
+ * @param ms epoch milliseconds to render (default: now)
134
+ * @param withDate prepend "YYYY-MM-DD "
135
+ */
136
+ export function formatLocalTime(
137
+ ms: number = Date.now(),
138
+ withDate = false,
139
+ ): string {
140
+ const tz = ownerTimeZone();
141
+ const safeTz = isValidTimeZone(tz) ? tz : DEFAULT_TIME_ZONE;
142
+ const date = new Date(ms);
143
+ const time = new Intl.DateTimeFormat("en-GB", {
144
+ timeZone: safeTz,
145
+ hour: "2-digit",
146
+ minute: "2-digit",
147
+ hour12: false,
148
+ }).format(date);
149
+ const abbr = zoneAbbreviation(date, safeTz);
150
+ const datePart = withDate
151
+ ? `${new Intl.DateTimeFormat("en-CA", {
152
+ timeZone: safeTz,
153
+ year: "numeric",
154
+ month: "2-digit",
155
+ day: "2-digit",
156
+ }).format(date)} `
157
+ : "";
158
+ return `${datePart}${time} ${abbr} (${formatOffset(zoneOffsetMinutes(date, safeTz))})`;
159
+ }
160
+
161
+ /**
162
+ * Compact header clock (mirror of core formatWhisperClock): "HH:MM TZ" —
163
+ * the whisper-header form (`@ HH:MM TZ`), a few tokens short.
164
+ *
165
+ * @param ms epoch milliseconds to render (default: now). For inbound TG
166
+ * messages pass `update.date * 1000` (unix seconds → ms) so the
167
+ * header shows WHEN the owner actually sent the message.
168
+ */
169
+ export function formatWhisperClock(ms: number = Date.now()): string {
170
+ const tz = ownerTimeZone();
171
+ const safeTz = isValidTimeZone(tz) ? tz : DEFAULT_TIME_ZONE;
172
+ const date = new Date(ms);
173
+ const time = new Intl.DateTimeFormat("en-GB", {
174
+ timeZone: safeTz,
175
+ hour: "2-digit",
176
+ minute: "2-digit",
177
+ hour12: false,
178
+ }).format(date);
179
+ const abbr = zoneAbbreviation(date, safeTz);
180
+ return `${time} ${abbr}`;
181
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Agent-delivered report flag — the telegram_send ↔ live-update seam.
3
+ *
4
+ * Live-update's finish() sends its OWN separate turn-end summary. When the
5
+ * AGENT already delivered an explicit report via telegram_send during the
6
+ * turn, that auto summary duplicates it (two similar messages,
7
+ * one raw/unformatted). This module is the tiny process-local flag the send
8
+ * path sets and the live-update resolver reads + clears at the turn boundary.
9
+ *
10
+ * Process-local only (no persistence, no IPC): both sides run in the same
11
+ * extension process. The flag is intentionally dumb — no timestamps, no
12
+ * counting — a successful explicit delivery is all finish() needs to know.
13
+ */
14
+ let agentDeliveredFlag = false;
15
+
16
+ /** Record that the agent explicitly delivered a Telegram message this turn. */
17
+ export function markAgentDeliveredMessage(): void {
18
+ agentDeliveredFlag = true;
19
+ }
20
+
21
+ /** Clear the flag (fresh turn boundary). Idempotent. */
22
+ export function clearAgentDeliveredMessage(): void {
23
+ agentDeliveredFlag = false;
24
+ }
25
+
26
+ /** True when the agent sent an explicit Telegram message this turn. */
27
+ export function wasAgentDeliveredMessage(): boolean {
28
+ return agentDeliveredFlag;
29
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * turn-complete notify — pure crafting logic.
3
+ *
4
+ * Pure, unit-testable without pi-core runtime or any LLM:
5
+ * - shouldNotifyTurnEnd — decide whether a turn_end event warrants a notify
6
+ * - extractFinalText — pull the assistant's final text out of the event
7
+ * - buildNotifyPrompts — the distilling prompt (system + user)
8
+ * - truncateGuard — the ≤ notifyMaxChars guard-fallback: the crafting
9
+ * model PRODUCES the message; this stays only as a
10
+ * safety net (default: braid.telegram.notifyMaxChars,
11
+ * 1200 when unset).
12
+ *
13
+ * Turn-end payload (VERIFIED against pi-core dist/core/extensions/types.d.ts):
14
+ * TurnEndEvent { type: "turn_end", turnIndex, message, toolResults }
15
+ * — NO separate toolCalls flag. A "trailing tool call" turn is detected by
16
+ * message.stopReason === "toolUse" (the model's last action was emitting a
17
+ * tool call, so work continues next turn). A completed answer turn has
18
+ * stopReason "stop" (or length/error) with text content.
19
+ *
20
+ * The notify model call reuses "our systems" (not the bot token): it goes
21
+ * through ctx.modelRegistry (which pi-braid-providers wires to the key-ring
22
+ * via auth.apiKey.resolve), mirroring the extension→LLM path that core's
23
+ * llm-call.ts wraps (completeSimple from @earendil-works/pi-ai/compat).
24
+ */
25
+
26
+ import { DEFAULT_NOTIFY_MAX_CHARS } from "../telegram-settings.js";
27
+
28
+ /** Which turns notify: only completed-answer turns (plain text, no trailing
29
+ * tool call). Never notify while the agent is mid-work. */
30
+ export function shouldNotifyTurnEnd(event: unknown): boolean {
31
+ const message = (
32
+ event as {
33
+ message?: { role?: unknown; stopReason?: unknown; content?: unknown };
34
+ }
35
+ )?.message;
36
+ if (!message || typeof message !== "object") return false;
37
+ if (message.role !== "assistant") return false;
38
+ // "toolUse" stopReason = the model's last action was a tool call → the turn
39
+ // did NOT complete with an answer (work continues next turn). Skip notify.
40
+ if (message.stopReason === "toolUse") return false;
41
+ return extractFinalTextFromContent(message.content).trim().length > 0;
42
+ }
43
+
44
+ /** Inspect an assistant message's content for its final text. */
45
+ function extractFinalTextFromContent(content: unknown): string {
46
+ if (typeof content === "string") return content.trim();
47
+ if (!Array.isArray(content)) return "";
48
+ const parts: string[] = [];
49
+ for (const block of content) {
50
+ const b = block as { type?: unknown; text?: unknown } | undefined;
51
+ if (b?.type === "text" && typeof b.text === "string") parts.push(b.text);
52
+ }
53
+ return parts.join("\n").trim();
54
+ }
55
+
56
+ /**
57
+ * Is this a terminal turn WITH NO REPORT — i.e. the task is over without an
58
+ * answer? Canonical terminal-non-completed detection (context-manager
59
+ * handlers/events.ts: lastAssistant.stopReason is neither "error" nor
60
+ * "aborted") — do NOT re-derive it here. Concretely:
61
+ * - last assistant stopReason "error" or "aborted" → terminal, no report;
62
+ * - no assistant message at all → terminal, no report;
63
+ * - stopReason "toolUse" (or anything else non-terminal) → work continues
64
+ * next turn — the live-update indicator must STAY active.
65
+ */
66
+ export function isTerminalNoReportTurn(event: unknown): boolean {
67
+ const message = (
68
+ event as { message?: { role?: unknown; stopReason?: unknown } }
69
+ )?.message;
70
+ if (!message || typeof message !== "object") return true; // no assistant message → no report
71
+ if (message.role !== "assistant") return true; // turn ended without an assistant report
72
+ if (message.stopReason === "toolUse") return false; // work continues next turn
73
+ return message.stopReason === "error" || message.stopReason === "aborted";
74
+ }
75
+
76
+ /** The final assistant text of a turn ("" when absent). */
77
+ export function extractFinalText(event: unknown): string {
78
+ const message = (event as { message?: { content?: unknown } })?.message;
79
+ if (!message || typeof message !== "object") return "";
80
+ return extractFinalTextFromContent(message.content);
81
+ }
82
+
83
+ /**
84
+ * Build the distilling prompt for the crafting model.
85
+ *
86
+ * The model receives the agent's final answer and must produce an owner-ready
87
+ * update, at most `maxLength` chars (the configured notifyMaxChars — default
88
+ * 1200). No greetings, no filler, no markdown noise.
89
+ */
90
+ export function buildNotifyPrompts(
91
+ finalText: string,
92
+ maxLength: number = DEFAULT_NOTIFY_MAX_CHARS,
93
+ ): {
94
+ system: string;
95
+ user: string;
96
+ } {
97
+ const system =
98
+ "You distill an AI assistant's finished response into ONE concise, " +
99
+ `plain-text update for its human operator. Rules:\n` +
100
+ `- At most ${maxLength} characters — complete and readable, never a clipped fragment.\n` +
101
+ `- State what was done / the key result. No greetings, no sign-off, no filler.\n` +
102
+ `- Plain text only: no markdown formatting, no bullet lists.\n` +
103
+ `- If the response has no actionable content, reply with the single word: DONE.`;
104
+ const user = `Agent's finished response:\n\n${finalText.slice(0, 4000)}`;
105
+ return { system, user };
106
+ }
107
+
108
+ /** Delivered-format choice threaded into the rich-summary craft prompt. */
109
+ export type NotifyFormat = "HTML" | "Markdown";
110
+
111
+ /**
112
+ * Build the rich summary prompt for the live-update final report.
113
+ * Unlike the concise notify prompt, this one FLAGS the model to produce
114
+ * structured formatting (`mode`) and to make the report as long as needed —
115
+ * split-to-fit handles the 4096-char cap downstream. `mode` is threaded into
116
+ * the prompt (not just the transport) so the model emits the right markup
117
+ * from the start instead of the delivery layer guessing. Defaults to
118
+ * "Markdown".
119
+ */
120
+ export function buildRichSummaryPrompts(
121
+ finalText: string,
122
+ mode: NotifyFormat = "Markdown",
123
+ ): { system: string; user: string } {
124
+ const formatting =
125
+ mode === "HTML"
126
+ ? "- Use Telegram HTML: <b>bold</b> headings, <i>italic</i>, <code>code</code>, short sections.\n" +
127
+ "- Telegram HTML only: NO raw Markdown (no **, _, `, [#](url)) — HTML tags only.\n"
128
+ : "- Use Telegram Markdown: **bold** headings, bullet lists, short sections.\n" +
129
+ "- Telegram Markdown only: **bold**, _italic_, `code` — no raw HTML, no fences.\n";
130
+ const system =
131
+ "You turn an AI assistant's finished response into a beautiful, structured " +
132
+ "Telegram report for its human operator. Rules:\n" +
133
+ formatting +
134
+ "- Keep it readable on mobile: short paragraphs; lists over prose walls.\n" +
135
+ "- Lead with what was done, then key results, then any follow-ups.\n" +
136
+ "- No title banner, no sign-off, no filler — length as needed (split-to-fit handles the 4096 cap).";
137
+ const user = `Agent's finished response:\n\n${finalText.slice(0, RICH_SUMMARY_INPUT_MAX)}`;
138
+ return { system, user };
139
+ }
140
+
141
+ /** Max response chars fed to the rich-summary crafting model. */
142
+ export const RICH_SUMMARY_INPUT_MAX = 12_000;
143
+
144
+ /**
145
+ * Guard-fallback: hard-truncate to `maxLen` with an ellipsis. Used when the
146
+ * crafting model is unavailable or returns something malformed — the cap is a
147
+ * safety net, never the primary shortening path.
148
+ */
149
+ export function truncateGuard(
150
+ text: string,
151
+ maxLen: number = DEFAULT_NOTIFY_MAX_CHARS,
152
+ ): string {
153
+ const trimmed = text.trim();
154
+ if (trimmed.length <= maxLen) return trimmed;
155
+ const head = trimmed.slice(0, Math.max(0, maxLen - 1)).trimEnd();
156
+ return head + "…";
157
+ }