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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-complete notify — key-ring-backed crafting model call.
|
|
3
|
+
*
|
|
4
|
+
* The notify model call reuses "our systems" — NOT the telegram bot token.
|
|
5
|
+
* It resolves a model + API key through the pi extension context's
|
|
6
|
+
* modelRegistry, which pi-braid-providers wires to the key-ring
|
|
7
|
+
* (ring-lifecycle.ts auth.apiKey.resolve → getActiveKey()). This mirrors the
|
|
8
|
+
* extension→LLM path used by core's llm-call.ts: completeSimple from
|
|
9
|
+
* @earendil-works/pi-ai/compat, with the key coming from the provider, never
|
|
10
|
+
* from telegram credentials.
|
|
11
|
+
*
|
|
12
|
+
* Model selection policy:
|
|
13
|
+
* 1. BRAID_TG_NOTIFY_MODEL override (env, "provider/model-id" or "model-id")
|
|
14
|
+
* 2. ctx.model (the session model)
|
|
15
|
+
* 3. first available model in the registry
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Context, Message, Model, Api } from "@earendil-works/pi-ai";
|
|
19
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
20
|
+
import {
|
|
21
|
+
buildNotifyPrompts,
|
|
22
|
+
buildRichSummaryPrompts,
|
|
23
|
+
type NotifyFormat,
|
|
24
|
+
} from "./craft.js";
|
|
25
|
+
|
|
26
|
+
/** Minimal registry surface used here (subset of pi-core ModelRegistry). */
|
|
27
|
+
export interface NotifyModelRegistry {
|
|
28
|
+
find?(provider: string, modelId: string): Model<Api> | undefined;
|
|
29
|
+
getAll?(): Model<Api>[];
|
|
30
|
+
getAvailable?(): Model<Api>[];
|
|
31
|
+
getApiKeyForProvider?(provider: string): Promise<string | undefined>;
|
|
32
|
+
hasConfiguredAuth?(model: Model<Api>): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the notify model. `configuredPattern` is an optional env override
|
|
37
|
+
* shaped like "provider/model-id" (or bare "model-id"); falls back to the
|
|
38
|
+
* session model, then the first available registry model.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveNotifyModel(
|
|
41
|
+
registry: NotifyModelRegistry,
|
|
42
|
+
sessionModel: Model<Api> | undefined,
|
|
43
|
+
configuredPattern?: string,
|
|
44
|
+
): Model<Api> | undefined {
|
|
45
|
+
if (configuredPattern?.trim()) {
|
|
46
|
+
const pattern = configuredPattern.trim();
|
|
47
|
+
const slash = pattern.indexOf("/");
|
|
48
|
+
if (slash >= 0) {
|
|
49
|
+
const provider = pattern.slice(0, slash);
|
|
50
|
+
const modelId = pattern.slice(slash + 1);
|
|
51
|
+
const found = registry.find?.(provider, modelId);
|
|
52
|
+
if (found) return found;
|
|
53
|
+
} else {
|
|
54
|
+
const all = registry.getAll?.() ?? [];
|
|
55
|
+
const found = all.find(
|
|
56
|
+
(m) => m.id === pattern || m.id.endsWith(`/${pattern}`),
|
|
57
|
+
);
|
|
58
|
+
if (found) return found;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (sessionModel) return sessionModel;
|
|
62
|
+
const available = registry.getAvailable?.() ?? registry.getAll?.() ?? [];
|
|
63
|
+
return available[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Output budget of a bare craft call (legacy default — targets fit in it). */
|
|
67
|
+
export const DEFAULT_CRAFT_MAX_TOKENS = 200;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Output-token budget for a craft target of `chars` characters. A cap the
|
|
71
|
+
* model cannot reach ends in mid-sentence (a visibly compressed message),
|
|
72
|
+
* so the budget scales with the target: ~2 chars/token, floored at 512 and
|
|
73
|
+
* capped at 4096 tokens. The turn-notify path passes this for notifyMaxChars.
|
|
74
|
+
*/
|
|
75
|
+
export function craftMaxTokensFor(chars: number): number {
|
|
76
|
+
return Math.min(4096, Math.max(512, Math.ceil(chars / 2)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Payload returned by the crafting call. */
|
|
80
|
+
export interface CraftModelResult {
|
|
81
|
+
ok: boolean;
|
|
82
|
+
text?: string;
|
|
83
|
+
error?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Make the crafting call. Returns the distilled text on success, or a
|
|
88
|
+
* non-throwing { ok:false } on failure so the caller can fall back to the
|
|
89
|
+
* truncation guard. Injectable completeSimple for deterministic tests.
|
|
90
|
+
*/
|
|
91
|
+
export async function callCraftModel(
|
|
92
|
+
registry: NotifyModelRegistry,
|
|
93
|
+
model: Model<Api>,
|
|
94
|
+
prompts: { system: string; user: string },
|
|
95
|
+
opts: {
|
|
96
|
+
completeSimpleFn?: typeof completeSimple;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
/** Output token budget (default DEFAULT_CRAFT_MAX_TOKENS = 200). */
|
|
99
|
+
maxTokens?: number;
|
|
100
|
+
} = {},
|
|
101
|
+
): Promise<CraftModelResult> {
|
|
102
|
+
try {
|
|
103
|
+
let apiKey: string | undefined;
|
|
104
|
+
if (registry.getApiKeyForProvider) {
|
|
105
|
+
apiKey = await registry.getApiKeyForProvider(model.provider);
|
|
106
|
+
}
|
|
107
|
+
const fn = opts.completeSimpleFn ?? completeSimple;
|
|
108
|
+
const context: Context = {
|
|
109
|
+
systemPrompt: prompts.system,
|
|
110
|
+
messages: [
|
|
111
|
+
{
|
|
112
|
+
role: "user",
|
|
113
|
+
content: prompts.user,
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
} satisfies Message,
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
const assistant = await fn(model as Model<Api>, context, {
|
|
119
|
+
apiKey,
|
|
120
|
+
maxTokens: opts.maxTokens ?? DEFAULT_CRAFT_MAX_TOKENS,
|
|
121
|
+
temperature: 0.2,
|
|
122
|
+
signal: opts.signal,
|
|
123
|
+
});
|
|
124
|
+
if (assistant.errorMessage) {
|
|
125
|
+
return { ok: false, error: assistant.errorMessage };
|
|
126
|
+
}
|
|
127
|
+
const text = (assistant.content ?? [])
|
|
128
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
129
|
+
.map((c) => c.text)
|
|
130
|
+
.join("\n")
|
|
131
|
+
.trim();
|
|
132
|
+
if (!text)
|
|
133
|
+
return { ok: false, error: "empty response from crafting model" };
|
|
134
|
+
return { ok: true, text };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: err instanceof Error ? err.message : String(err),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the text-crafting hook used by telegram_send and /telegram send: given
|
|
145
|
+
* a too-long message, distill it to a concise owner update (≤ the applicable
|
|
146
|
+
* send cap). Returns `null` when no model is resolvable or the call fails —
|
|
147
|
+
* the caller ERRORS rather than truncating silently. Key-ring-backed
|
|
148
|
+
* (modelRegistry), never the bot token. `opt.prompts` swaps the prompt builder
|
|
149
|
+
* (rich summaries vs concise distills).
|
|
150
|
+
*/
|
|
151
|
+
export function createTextCraftHook(
|
|
152
|
+
registry: NotifyModelRegistry,
|
|
153
|
+
sessionModel: Model<Api> | undefined,
|
|
154
|
+
configuredPattern?: string,
|
|
155
|
+
opts: {
|
|
156
|
+
completeSimpleFn?: typeof completeSimple;
|
|
157
|
+
signal?: AbortSignal;
|
|
158
|
+
prompts?: (text: string) => { system: string; user: string };
|
|
159
|
+
} = {},
|
|
160
|
+
): (text: string) => Promise<{ text: string; crafted: boolean } | null> {
|
|
161
|
+
const promptsBuilder = opts.prompts ?? buildNotifyPrompts;
|
|
162
|
+
return async (
|
|
163
|
+
text: string,
|
|
164
|
+
): Promise<{ text: string; crafted: boolean } | null> => {
|
|
165
|
+
const model = resolveNotifyModel(registry, sessionModel, configuredPattern);
|
|
166
|
+
if (!model) return null;
|
|
167
|
+
const prompts = promptsBuilder(text);
|
|
168
|
+
const result = await callCraftModel(registry, model, prompts, opts);
|
|
169
|
+
if (!result.ok || !result.text) return null;
|
|
170
|
+
return { text: result.text, crafted: true };
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Rich-summary crafting hook for the live-update final report: same
|
|
176
|
+
* key-ring-backed path as `createTextCraftHook`, but the prompt builder is the
|
|
177
|
+
* rich variant (buildRichSummaryPrompts) — the report may be long and
|
|
178
|
+
* formatted; split-to-fit handles the Telegram 4096 cap downstream. `opts.mode`
|
|
179
|
+
* selects which markup the prompt asks for (default Markdown); the delivery
|
|
180
|
+
* layer still re-guards the crafted body.
|
|
181
|
+
*/
|
|
182
|
+
export function createRichCraftHook(
|
|
183
|
+
registry: NotifyModelRegistry,
|
|
184
|
+
sessionModel: Model<Api> | undefined,
|
|
185
|
+
configuredPattern?: string,
|
|
186
|
+
opts: {
|
|
187
|
+
completeSimpleFn?: typeof completeSimple;
|
|
188
|
+
signal?: AbortSignal;
|
|
189
|
+
mode?: NotifyFormat;
|
|
190
|
+
} = {},
|
|
191
|
+
): (text: string) => Promise<{ text: string; crafted: boolean } | null> {
|
|
192
|
+
return createTextCraftHook(registry, sessionModel, configuredPattern, {
|
|
193
|
+
...opts,
|
|
194
|
+
prompts: (text: string) =>
|
|
195
|
+
buildRichSummaryPrompts(text, opts.mode ?? "Markdown"),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding-window rate limiter for turn-complete auto-notify.
|
|
3
|
+
*
|
|
4
|
+
* Pure + deterministic: `try(now)` takes an explicit timestamp so tests can
|
|
5
|
+
* drive window boundaries without sleeping. The default policy (max N per
|
|
6
|
+
* minute) is a deployment default; the limiter itself is policy-agnostic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SlidingWindowLimiterOptions {
|
|
10
|
+
/** Max notifications allowed per window. */
|
|
11
|
+
maxPerWindow: number;
|
|
12
|
+
/** Window length in ms. */
|
|
13
|
+
windowMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SlidingWindowLimiter {
|
|
17
|
+
private readonly maxPerWindow: number;
|
|
18
|
+
private readonly windowMs: number;
|
|
19
|
+
private hits: number[] = [];
|
|
20
|
+
|
|
21
|
+
constructor(opts: SlidingWindowLimiterOptions) {
|
|
22
|
+
if (!(opts.maxPerWindow > 0)) throw new Error("maxPerWindow must be > 0");
|
|
23
|
+
if (!(opts.windowMs > 0)) throw new Error("windowMs must be > 0");
|
|
24
|
+
this.maxPerWindow = opts.maxPerWindow;
|
|
25
|
+
this.windowMs = opts.windowMs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Attempt to take a slot at `now`. Returns true when the call is allowed
|
|
30
|
+
* (and records it); false when the window is saturated.
|
|
31
|
+
*/
|
|
32
|
+
try(now: number = Date.now()): boolean {
|
|
33
|
+
const cutoff = now - this.windowMs;
|
|
34
|
+
this.hits = this.hits.filter((t) => t > cutoff);
|
|
35
|
+
if (this.hits.length >= this.maxPerWindow) return false;
|
|
36
|
+
this.hits.push(now);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Clear all recorded hits (test helper / session reset). */
|
|
41
|
+
reset(): void {
|
|
42
|
+
this.hits = [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-complete auto-notify — turn_end handler orchestration.
|
|
3
|
+
*
|
|
4
|
+
* On turn_end with a completed plain-text answer (no trailing tool call):
|
|
5
|
+
* 1. rate-limit gate (max N per minute, sliding window)
|
|
6
|
+
* 2. distilling LLM call (via the injected model loader — key-ring-backed)
|
|
7
|
+
* 3. guard-fallback to the notifyMaxChars cap (crafting model produces the
|
|
8
|
+
* message; the truncation rule stays only as a safety net)
|
|
9
|
+
* 4. deliver through the outbound seam (telegram client — no [tg-end])
|
|
10
|
+
*
|
|
11
|
+
* The handler is pure-of-network: the model call and delivery are injected so
|
|
12
|
+
* the fixture tests run deterministically with zero real LLM calls and zero
|
|
13
|
+
* network. Production wiring (src/index.ts) supplies the real implementations.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
shouldNotifyTurnEnd,
|
|
18
|
+
extractFinalText,
|
|
19
|
+
buildNotifyPrompts,
|
|
20
|
+
truncateGuard,
|
|
21
|
+
} from "./craft.js";
|
|
22
|
+
import { SlidingWindowLimiter } from "./rate-limit.js";
|
|
23
|
+
import {
|
|
24
|
+
craftMaxTokensFor,
|
|
25
|
+
type CraftModelResult,
|
|
26
|
+
type NotifyModelRegistry,
|
|
27
|
+
} from "./model.js";
|
|
28
|
+
import { resolveNotifyMaxChars } from "../telegram-settings.js";
|
|
29
|
+
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
30
|
+
|
|
31
|
+
/** Model-call context bound from the turn_end ExtensionContext. */
|
|
32
|
+
export interface NotifyModelCtx {
|
|
33
|
+
registry: NotifyModelRegistry;
|
|
34
|
+
sessionModel?: Model<Api> | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TurnNotifyDeps {
|
|
38
|
+
/** Distilling model call. The model + key are resolved from `modelCtx`
|
|
39
|
+
* inside the handler (key-ring-backed via ctx.modelRegistry). `opts.maxTokens`
|
|
40
|
+
* is the output budget sized for the resolved notify cap — forward it to the
|
|
41
|
+
* provider so a long target is not cut mid-sentence.
|
|
42
|
+
*/
|
|
43
|
+
callModel(
|
|
44
|
+
prompts: { system: string; user: string },
|
|
45
|
+
modelCtx: NotifyModelCtx,
|
|
46
|
+
opts?: { maxTokens?: number },
|
|
47
|
+
): Promise<CraftModelResult>;
|
|
48
|
+
/** Outbound delivery of the crafted text (≤ notifyMaxChars). Never [tg-end]-decorated. */
|
|
49
|
+
deliver(text: string): Promise<void> | void;
|
|
50
|
+
/** Clock (injectable for deterministic rate-limit tests). */
|
|
51
|
+
now?(): number;
|
|
52
|
+
/** Max auto-notifies per window. */
|
|
53
|
+
maxPerWindow?: number;
|
|
54
|
+
/** Sliding window length in ms. */
|
|
55
|
+
windowMs?: number;
|
|
56
|
+
/** Guard-fallback length cap override (default: braid.telegram.notifyMaxChars). */
|
|
57
|
+
maxLength?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Shared defaults — policy surface for the design decision list. */
|
|
61
|
+
export const DEFAULT_NOTIFY_MAX_PER_MIN = 4;
|
|
62
|
+
export const DEFAULT_NOTIFY_WINDOW_MS = 60_000;
|
|
63
|
+
|
|
64
|
+
export interface TurnNotifyOutcome {
|
|
65
|
+
notified: boolean;
|
|
66
|
+
/** Why nothing was sent (rate-limited / no text / model+guard both empty). */
|
|
67
|
+
skipped?: "rate-limited" | "no-answer-text" | "empty-result";
|
|
68
|
+
text?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the turn_end handler. Non-throwing: any failure inside is swallowed and
|
|
73
|
+
* logged to the caller's sink — an auto-notify must never break the turn loop.
|
|
74
|
+
*/
|
|
75
|
+
export function createTurnNotifyHandler(
|
|
76
|
+
deps: TurnNotifyDeps,
|
|
77
|
+
): (
|
|
78
|
+
event: unknown,
|
|
79
|
+
modelCtx: NotifyModelCtx,
|
|
80
|
+
log?: (m: string, t?: "info" | "warning" | "error") => void,
|
|
81
|
+
) => Promise<TurnNotifyOutcome> {
|
|
82
|
+
const limiter = new SlidingWindowLimiter({
|
|
83
|
+
maxPerWindow: deps.maxPerWindow ?? DEFAULT_NOTIFY_MAX_PER_MIN,
|
|
84
|
+
windowMs: deps.windowMs ?? DEFAULT_NOTIFY_WINDOW_MS,
|
|
85
|
+
});
|
|
86
|
+
const now = deps.now ?? (() => Date.now());
|
|
87
|
+
|
|
88
|
+
/** Resolve the cap per call: explicit override, else settings (no caching). */
|
|
89
|
+
const resolveCap = (): number =>
|
|
90
|
+
typeof deps.maxLength === "number" &&
|
|
91
|
+
Number.isFinite(deps.maxLength) &&
|
|
92
|
+
deps.maxLength >= 1
|
|
93
|
+
? Math.floor(deps.maxLength)
|
|
94
|
+
: resolveNotifyMaxChars();
|
|
95
|
+
|
|
96
|
+
return async (
|
|
97
|
+
event,
|
|
98
|
+
modelCtx,
|
|
99
|
+
log = () => {},
|
|
100
|
+
): Promise<TurnNotifyOutcome> => {
|
|
101
|
+
try {
|
|
102
|
+
if (!shouldNotifyTurnEnd(event)) return { notified: false };
|
|
103
|
+
if (!limiter.try(now()))
|
|
104
|
+
return { notified: false, skipped: "rate-limited" };
|
|
105
|
+
|
|
106
|
+
const finalText = extractFinalText(event);
|
|
107
|
+
if (!finalText) return { notified: false, skipped: "no-answer-text" };
|
|
108
|
+
|
|
109
|
+
const cap = resolveCap();
|
|
110
|
+
const prompts = buildNotifyPrompts(finalText, cap);
|
|
111
|
+
const crafted = await deps.callModel(prompts, modelCtx, {
|
|
112
|
+
maxTokens: craftMaxTokensFor(cap),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Crafting model produces the message; guard-truncate is the fallback.
|
|
116
|
+
const text = truncateGuard(
|
|
117
|
+
crafted.ok && crafted.text ? crafted.text : finalText,
|
|
118
|
+
cap,
|
|
119
|
+
);
|
|
120
|
+
if (!text) return { notified: false, skipped: "empty-result" };
|
|
121
|
+
|
|
122
|
+
await deps.deliver(text);
|
|
123
|
+
if (!crafted.ok) {
|
|
124
|
+
log(
|
|
125
|
+
`[telegram notify] crafting model failed (${crafted.error ?? "unknown"}) — delivered guard-truncated text`,
|
|
126
|
+
"warning",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return { notified: true, text };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
log(
|
|
132
|
+
`[telegram notify] error: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
+
"error",
|
|
134
|
+
);
|
|
135
|
+
return { notified: false };
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|