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.
- package/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -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
|
+
}
|