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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-wide poller ownership lock.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's getUpdates is a per-bot singleton: only one consumer may long-
|
|
5
|
+
* poll at a time. A module-local flag cannot provide this guarantee — it is
|
|
6
|
+
* per-process state, so two pi processes could each "own" the poller and
|
|
7
|
+
* 409-fight over the stream. This lock file is the machine-wide half: every
|
|
8
|
+
* start path acquires it first and refuses while a LIVE foreign pid holds
|
|
9
|
+
* it; a stale lock (dead pid, corrupt, or unreadable file) is taken over
|
|
10
|
+
* silently. A crashed holder therefore never locks the channel out, and a
|
|
11
|
+
* same-pid re-acquire (session reload cycles) always succeeds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { braidDir } from "./state-paths.js";
|
|
17
|
+
|
|
18
|
+
/** Default lock location: beside the other braid-owned telegram state
|
|
19
|
+
* (~/.pi/agent/braid, BRAID_HOME-overridable). Null when no absolute braid
|
|
20
|
+
* root resolves: the poller refuses to start rather than lock beside the cwd. */
|
|
21
|
+
export function defaultPollerLockPath(): string | null {
|
|
22
|
+
const root = braidDir();
|
|
23
|
+
return root === null ? null : join(root, "telegram-poller.lock");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Who holds the lock: the pid is the liveness key; startedAt/role/startedBy
|
|
27
|
+
* make a refusal message name the holder. */
|
|
28
|
+
export interface PollerLockInfo {
|
|
29
|
+
pid: number;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
role?: string;
|
|
32
|
+
startedBy?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AcquirePollerLockResult =
|
|
36
|
+
| { ok: true; tookOver: boolean }
|
|
37
|
+
| { ok: false; holder: PollerLockInfo };
|
|
38
|
+
|
|
39
|
+
/** Read the lock file; absent/corrupt/unreadable reads as "no holder". */
|
|
40
|
+
export function readPollerLock(path: string): PollerLockInfo | undefined {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as PollerLockInfo;
|
|
43
|
+
if (Number.isFinite(parsed?.pid)) return parsed;
|
|
44
|
+
} catch {
|
|
45
|
+
// fall through to undefined
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Live-pid probe. Signal-0 errors with EPERM when the pid exists but is not
|
|
52
|
+
* ours to signal (still alive); ESRCH means the pid is gone.
|
|
53
|
+
*/
|
|
54
|
+
export function isPidAlive(pid: number): boolean {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return (error as NodeJS.ErrnoException)?.code === "EPERM";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Best-effort release at process exit: the exit hook removes the lock this
|
|
64
|
+
// process last acquired. A hook that never runs (kill -9, crash) leaves the
|
|
65
|
+
// file behind — harmless, because the next acquirer sees a dead pid and
|
|
66
|
+
// takes the lock over.
|
|
67
|
+
let exitHookInstalled = false;
|
|
68
|
+
let heldLockPath: string | undefined;
|
|
69
|
+
|
|
70
|
+
function installExitRelease(): void {
|
|
71
|
+
if (exitHookInstalled) return;
|
|
72
|
+
exitHookInstalled = true;
|
|
73
|
+
process.on("exit", () => {
|
|
74
|
+
if (!heldLockPath) return;
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(heldLockPath);
|
|
77
|
+
} catch {
|
|
78
|
+
// best effort — the live-pid check covers a surviving file
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Take the lock. A live FOREIGN holder refuses (the caller reports it); our
|
|
85
|
+
* own pid, a dead pid, or an unreadable file is taken over silently.
|
|
86
|
+
*/
|
|
87
|
+
export function acquirePollerLock(
|
|
88
|
+
path: string,
|
|
89
|
+
info: PollerLockInfo,
|
|
90
|
+
pidAlive: (pid: number) => boolean = isPidAlive,
|
|
91
|
+
): AcquirePollerLockResult {
|
|
92
|
+
const existing = readPollerLock(path);
|
|
93
|
+
if (existing && existing.pid !== info.pid && pidAlive(existing.pid)) {
|
|
94
|
+
return { ok: false, holder: existing };
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
writeFileSync(path, JSON.stringify(info));
|
|
98
|
+
heldLockPath = path;
|
|
99
|
+
installExitRelease();
|
|
100
|
+
return { ok: true, tookOver: existing !== undefined };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Release the lock — only when this process holds it; a foreign lock is
|
|
105
|
+
* never touched. Best-effort: reports whether the file was removed.
|
|
106
|
+
*/
|
|
107
|
+
export function releasePollerLock(path: string): boolean {
|
|
108
|
+
const existing = readPollerLock(path);
|
|
109
|
+
if (!existing || existing.pid !== process.pid) return false;
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(path);
|
|
112
|
+
} catch {
|
|
113
|
+
// already gone — treat as released
|
|
114
|
+
}
|
|
115
|
+
if (heldLockPath === path) heldLockPath = undefined;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Human-readable holder description for refusal messages. */
|
|
120
|
+
export function describeLockHolder(holder: PollerLockInfo): string {
|
|
121
|
+
const since = Number.isFinite(holder.startedAt)
|
|
122
|
+
? new Date(holder.startedAt).toISOString()
|
|
123
|
+
: "unknown time";
|
|
124
|
+
const role = holder.role ?? "unknown role";
|
|
125
|
+
const by = holder.startedBy ? `, started by ${holder.startedBy}` : "";
|
|
126
|
+
return `pid ${holder.pid} since ${since} (${role}${by})`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session role marker: which seat this pi session occupies in the braid
|
|
3
|
+
* topology. The orchestrator seat exports BRAID_SESSION_ROLE=worker into a
|
|
4
|
+
* worker's pane before pi starts; the telegram extension reads it at
|
|
5
|
+
* registration time and keeps the poller-ownership surfaces seat-only —
|
|
6
|
+
* Telegram's getUpdates is a per-bot singleton the orchestrator seat owns,
|
|
7
|
+
* so a worker session never registers the poller control or the pull-inbox
|
|
8
|
+
* tool and never starts the poller (telegram_send stays available).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ENV_SESSION_ROLE = "BRAID_SESSION_ROLE";
|
|
12
|
+
|
|
13
|
+
export type SessionRole = "seat" | "worker";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the role from the given env (defaults to the process env). Only
|
|
17
|
+
* the exact value "worker" selects the worker role; unset, "seat", or any
|
|
18
|
+
* other value keeps the seat default, so deployments without the marker are
|
|
19
|
+
* unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveSessionRole(
|
|
22
|
+
env: Record<string, string | undefined> = process.env,
|
|
23
|
+
): SessionRole {
|
|
24
|
+
return env[ENV_SESSION_ROLE]?.trim().toLowerCase() === "worker"
|
|
25
|
+
? "worker"
|
|
26
|
+
: "seat";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Worker-role predicate the extension factory gates on. */
|
|
30
|
+
export function isWorkerRole(): boolean {
|
|
31
|
+
return resolveSessionRole() === "worker";
|
|
32
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical state locations for the Telegram extension.
|
|
3
|
+
*
|
|
4
|
+
* Contract: braid-owned state lives under the braid home
|
|
5
|
+
* (~/.pi/agent/braid, overridable per call via BRAID_HOME), never under the
|
|
6
|
+
* session's working directory — a cwd-relative state file pollutes every
|
|
7
|
+
* project a session runs in (and forks one offset state per cwd).
|
|
8
|
+
* Explicit BRAID_TG_STATE_FILE / BRAID_TG_LIVE_UPDATE_STATE_FILE overrides
|
|
9
|
+
* still win at the call sites.
|
|
10
|
+
*
|
|
11
|
+
* Downloaded media and the inline-keyboard pause state moved from pi's agent
|
|
12
|
+
* directory into `<braidDir>/telegram/`. Both are read-compatible with their
|
|
13
|
+
* pre-move location: a read falls back to the old copy when the new one is
|
|
14
|
+
* absent (`resolveMediaReadPath`, `legacyPauseStateFile`), a write never
|
|
15
|
+
* touches it.
|
|
16
|
+
*
|
|
17
|
+
* Refusal contract: `braidDir()` returns null when no absolute root can be
|
|
18
|
+
* derived - BRAID_HOME is unset and `os.homedir()` does not resolve an
|
|
19
|
+
* absolute path (a relative or blank HOME would otherwise put the whole tree
|
|
20
|
+
* beside the cwd), or BRAID_HOME is set to a path that is not absolute (a
|
|
21
|
+
* relative override resolves against whatever cwd the process has, which is
|
|
22
|
+
* the same hazard). Every derived accessor returns null with it; callers
|
|
23
|
+
* refuse instead of writing somewhere unowned, and `warnUnderivableRoot`
|
|
24
|
+
* reports the refusal once per process through the extension's TUI-safe log
|
|
25
|
+
* sink (never console.*).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Braid home root; BRAID_HOME wins. Read on every call so tests can pin the
|
|
34
|
+
* whole tree into a tmpdir (mirrors pi-braid core's braidDir()). Null when no
|
|
35
|
+
* absolute root can be derived: see the refusal contract above.
|
|
36
|
+
*/
|
|
37
|
+
export function braidDir(): string | null {
|
|
38
|
+
const override = process.env.BRAID_HOME;
|
|
39
|
+
if (override && override.trim()) {
|
|
40
|
+
if (!path.isAbsolute(override)) return null;
|
|
41
|
+
return path.resolve(override);
|
|
42
|
+
}
|
|
43
|
+
let home: string;
|
|
44
|
+
try {
|
|
45
|
+
home = os.homedir();
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!home || !path.isAbsolute(home)) return null;
|
|
50
|
+
return path.join(home, ".pi", "agent", "braid");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Warn sink for a refused default path. The extension factory binds it to
|
|
54
|
+
* the TUI-safe log; unset it is silent, exactly like log() itself. */
|
|
55
|
+
let refusalSink: ((message: string) => void) | undefined;
|
|
56
|
+
|
|
57
|
+
/** Bind (or clear) the sink a path refusal is reported through. */
|
|
58
|
+
export function setPathRefusalSink(
|
|
59
|
+
sink: ((message: string) => void) | undefined,
|
|
60
|
+
): void {
|
|
61
|
+
refusalSink = sink;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let warnedRefusal = false;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Report one refused default path, once per process: a request loop must not
|
|
68
|
+
* repeat the line, and the caller still refuses. Silent with no sink bound
|
|
69
|
+
* (headless/print runs), so the TUI input line never sees it.
|
|
70
|
+
*/
|
|
71
|
+
export function warnUnderivableRoot(what: string): void {
|
|
72
|
+
if (warnedRefusal) return;
|
|
73
|
+
warnedRefusal = true;
|
|
74
|
+
refusalSink?.(
|
|
75
|
+
`${what}: BRAID_HOME is unset and no absolute home resolves, so braid's ` +
|
|
76
|
+
`state directory cannot be derived without writing beside the cwd. ` +
|
|
77
|
+
`Set BRAID_HOME (or HOME) to an absolute directory.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Telegram state dir under the braid root: media, pause state, future
|
|
82
|
+
* sinks. The offset/live-update files predate it and stay at the root. */
|
|
83
|
+
export function telegramStateDir(): string | null {
|
|
84
|
+
const root = braidDir();
|
|
85
|
+
return root === null ? null : path.join(root, "telegram");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Default inbound offset/lock state file. */
|
|
89
|
+
export function defaultStateFilePath(): string | null {
|
|
90
|
+
const root = braidDir();
|
|
91
|
+
return root === null ? null : path.join(root, "telegram-state.json");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Default live-update tracker state file (beside the offset state). */
|
|
95
|
+
export function defaultLiveUpdateFilePath(): string | null {
|
|
96
|
+
const root = braidDir();
|
|
97
|
+
return root === null ? null : path.join(root, "telegram-live-update.json");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Media dir for downloaded photo/voice/document bytes. */
|
|
101
|
+
export function defaultMediaDir(): string | null {
|
|
102
|
+
const dir = telegramStateDir();
|
|
103
|
+
return dir === null ? null : path.join(dir, "media");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Pre-move media dir directly under pi's agent directory. Read-only fallback
|
|
108
|
+
* for files recorded before the relocation; never a write target. Null when
|
|
109
|
+
* no absolute home resolves (a relative fallback would read the cwd).
|
|
110
|
+
*
|
|
111
|
+
* This accessor and `resolveMediaReadPath` ship with no production caller:
|
|
112
|
+
* they are the documented read seam for pre-move media, kept for the
|
|
113
|
+
* compatibility contract and exercised by the package's tests.
|
|
114
|
+
*/
|
|
115
|
+
export function legacyMediaDir(): string | null {
|
|
116
|
+
let home: string;
|
|
117
|
+
try {
|
|
118
|
+
home = os.homedir();
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
if (!home || !path.isAbsolute(home)) return null;
|
|
123
|
+
return path.join(home, ".pi", "agent", "telegram-media");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read-side resolution for one stored media file: the braid-root copy when it
|
|
128
|
+
* exists, else the pre-move copy of the same name when that exists, else the
|
|
129
|
+
* current-root path (so a caller reports the miss against the write target).
|
|
130
|
+
* Writes always go to `defaultMediaDir()`. Null when neither root resolves.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveMediaReadPath(fileName: string): string | null {
|
|
133
|
+
const current = path.isAbsolute(fileName)
|
|
134
|
+
? fileName
|
|
135
|
+
: (() => {
|
|
136
|
+
const dir = defaultMediaDir();
|
|
137
|
+
return dir === null ? null : path.join(dir, fileName);
|
|
138
|
+
})();
|
|
139
|
+
if (current !== null && existsSync(current)) return current;
|
|
140
|
+
const legacyDir = legacyMediaDir();
|
|
141
|
+
const legacy =
|
|
142
|
+
legacyDir === null ? null : path.join(legacyDir, path.basename(fileName));
|
|
143
|
+
if (legacy !== null && existsSync(legacy)) return legacy;
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Pause-state file under the braid root (the write target). */
|
|
148
|
+
export function defaultPauseStateFile(): string | null {
|
|
149
|
+
const dir = telegramStateDir();
|
|
150
|
+
return dir === null ? null : path.join(dir, "pause-state.json");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Pre-move pause-state file directly under pi's agent directory. Read-only
|
|
154
|
+
* fallback; never a write target. Null when no absolute home resolves. */
|
|
155
|
+
export function legacyPauseStateFile(): string | null {
|
|
156
|
+
let home: string;
|
|
157
|
+
try {
|
|
158
|
+
home = os.homedir();
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (!home || !path.isAbsolute(home)) return null;
|
|
163
|
+
return path.join(home, ".pi", "agent", "telegram-pause-state.json");
|
|
164
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stt-keys.ts — the STT key pool: candidate resolution, failover memory,
|
|
3
|
+
* and runtime counters for voice-transcription reliability.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order (first hit wins, deduped by key value):
|
|
6
|
+
* 1. explicit — the caller-resolved BRAID_TG_STT_API_KEY (alias included)
|
|
7
|
+
* 2. list — BRAID_TG_STT_KEYS, comma/whitespace separated
|
|
8
|
+
* 3. keyring — macOS `security find-generic-password -s <service> -w`
|
|
9
|
+
* (darwin-only; absent/failed item tolerated silently)
|
|
10
|
+
* 4. env — vars matching /^AGENT_ZERO_API_KEY(_[A-Z0-9]+)?$/ in
|
|
11
|
+
* lexicographic order (deterministic across runs)
|
|
12
|
+
*
|
|
13
|
+
* Only candidate INDEX and SOURCE KIND ever leave this module — never key
|
|
14
|
+
* material. The failure line and counters name `key N/source`, not the key.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where a candidate came from (logged/counted; never the key itself). */
|
|
18
|
+
export type SttKeySource = "explicit" | "list" | "keyring" | "env";
|
|
19
|
+
|
|
20
|
+
/** One key candidate: the pool entry transcribe failover advances through. */
|
|
21
|
+
export interface SttKeyCandidate {
|
|
22
|
+
/** The key material (consumer-internal only). */
|
|
23
|
+
key: string;
|
|
24
|
+
/** Source kind (observability-safe: this is what gets logged). */
|
|
25
|
+
source: SttKeySource;
|
|
26
|
+
/** Position in the resolved pool (stable for the process lifetime). */
|
|
27
|
+
index: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Injectable keyring exec: returns null/throws when the item is absent. */
|
|
31
|
+
export type SttKeyringExec = (
|
|
32
|
+
cmd: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
) => Promise<{ stdout: string } | null>;
|
|
35
|
+
|
|
36
|
+
/** Input for resolveSttKeyCandidates (pure + injectable, no real exec in tests). */
|
|
37
|
+
export interface ResolveSttKeyCandidatesInput {
|
|
38
|
+
/** The caller-resolved explicit key (BRAID_TG_STT_API_KEY incl. alias). */
|
|
39
|
+
explicit?: string;
|
|
40
|
+
/** Raw env record (defaults to process.env). */
|
|
41
|
+
env?: Record<string, string | undefined>;
|
|
42
|
+
/** Keyring exec seam (defaults to the real `security` invocation). */
|
|
43
|
+
exec?: SttKeyringExec;
|
|
44
|
+
/** Platform override (default process.platform; keyring is darwin-only). */
|
|
45
|
+
platform?: string;
|
|
46
|
+
/** Keyring service name (default BRAID_TG_STT_KEYRING_SERVICE or "braid-stt"). */
|
|
47
|
+
keyringService?: string;
|
|
48
|
+
/** Include the AGENT_ZERO_API_KEY* env candidates (default true). The agent-zero
|
|
49
|
+
* key family belongs to the Venice chat endpoint; enabling them against
|
|
50
|
+
* any other provider base URL would leak the key across providers, so
|
|
51
|
+
* transcriptions-mode callers turn this off. */
|
|
52
|
+
includeAgentZeroEnv?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Default macOS keyring service for the STT key. */
|
|
56
|
+
export const DEFAULT_STT_KEYRING_SERVICE = "braid-stt";
|
|
57
|
+
|
|
58
|
+
/** agent-zero key env-var pattern: base name plus optional _SUFFIX (A-Z0-9). */
|
|
59
|
+
const AGENT_ZERO_KEY_PATTERN = /^AGENT_ZERO_API_KEY(_[A-Z0-9]+)?$/;
|
|
60
|
+
|
|
61
|
+
function defaultKeyringExec(): SttKeyringExec {
|
|
62
|
+
return async (cmd, args) => {
|
|
63
|
+
const { execFile } = await import("node:child_process");
|
|
64
|
+
return await new Promise((resolveExec) => {
|
|
65
|
+
execFile(cmd, args, { timeout: 3000 }, (error, stdout) => {
|
|
66
|
+
if (error)
|
|
67
|
+
resolveExec(null); // absent item / lock — tolerated
|
|
68
|
+
else resolveExec({ stdout: String(stdout) });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the STT key candidate pool in the documented order. Absent sources
|
|
76
|
+
* contribute nothing; duplicate key values keep only their FIRST position.
|
|
77
|
+
* Never throws — a failed keyring probe just yields no candidate.
|
|
78
|
+
*/
|
|
79
|
+
export async function resolveSttKeyCandidates(
|
|
80
|
+
input: ResolveSttKeyCandidatesInput = {},
|
|
81
|
+
): Promise<SttKeyCandidate[]> {
|
|
82
|
+
const envRecord =
|
|
83
|
+
input.env ?? (process.env as Record<string, string | undefined>);
|
|
84
|
+
const platform = input.platform ?? process.platform;
|
|
85
|
+
const out: SttKeyCandidate[] = [];
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
const push = (key: string | undefined, source: SttKeySource): void => {
|
|
88
|
+
const trimmed = key?.trim();
|
|
89
|
+
if (!trimmed || seen.has(trimmed)) return;
|
|
90
|
+
seen.add(trimmed);
|
|
91
|
+
out.push({ key: trimmed, source, index: out.length });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
push(input.explicit, "explicit");
|
|
95
|
+
|
|
96
|
+
const list = envRecord["BRAID_TG_STT_KEYS"];
|
|
97
|
+
if (list) {
|
|
98
|
+
for (const part of list.split(/[,\s]+/)) push(part, "list");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (platform === "darwin") {
|
|
102
|
+
const service =
|
|
103
|
+
input.keyringService ??
|
|
104
|
+
envRecord["BRAID_TG_STT_KEYRING_SERVICE"] ??
|
|
105
|
+
DEFAULT_STT_KEYRING_SERVICE;
|
|
106
|
+
try {
|
|
107
|
+
const exec = input.exec ?? defaultKeyringExec();
|
|
108
|
+
const result = await exec("security", [
|
|
109
|
+
"find-generic-password",
|
|
110
|
+
"-s",
|
|
111
|
+
service,
|
|
112
|
+
"-w",
|
|
113
|
+
]);
|
|
114
|
+
push(result?.stdout, "keyring");
|
|
115
|
+
} catch {
|
|
116
|
+
// keyring unavailable — tolerated, pool continues with env candidates
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (input.includeAgentZeroEnv ?? true) {
|
|
121
|
+
for (const name of Object.keys(envRecord)
|
|
122
|
+
.filter((n) => AGENT_ZERO_KEY_PATTERN.test(n))
|
|
123
|
+
.sort()) {
|
|
124
|
+
push(envRecord[name], "env");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Process-lifetime state: winner memory + counters
|
|
132
|
+
|
|
133
|
+
let preferred: SttKeyCandidate | null = null;
|
|
134
|
+
let counterState = freshCounters();
|
|
135
|
+
|
|
136
|
+
function freshCounters(): SttCounters {
|
|
137
|
+
return {
|
|
138
|
+
attempts: 0,
|
|
139
|
+
successesByCandidateIndex: {},
|
|
140
|
+
normalizationsApplied: 0,
|
|
141
|
+
failuresByClass: {},
|
|
142
|
+
transportRetries: 0,
|
|
143
|
+
transportRetrySuccesses: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** STT runtime counters (observability snapshot for callers/tests). */
|
|
148
|
+
export interface SttCounters {
|
|
149
|
+
/** Transcription requests issued (each candidate hit counts). */
|
|
150
|
+
attempts: number;
|
|
151
|
+
/** Successful transcriptions keyed by winning candidate index. */
|
|
152
|
+
successesByCandidateIndex: Record<number, number>;
|
|
153
|
+
/** Normalization rounds whose output was actually retried with. */
|
|
154
|
+
normalizationsApplied: number;
|
|
155
|
+
/** Final failures keyed by error class (auth/quota/rate/other/empty/no-key). */
|
|
156
|
+
failuresByClass: Record<string, number>;
|
|
157
|
+
/** Transport-class retries issued (a thrown request retried once, per round). */
|
|
158
|
+
transportRetries: number;
|
|
159
|
+
/** Transport-class retries whose retry then returned a transcript. */
|
|
160
|
+
transportRetrySuccesses: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Remember the winning candidate so later calls try it FIRST. */
|
|
164
|
+
export function rememberSttKeyWinner(candidate: SttKeyCandidate): void {
|
|
165
|
+
preferred = candidate;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The last remembered winner (null until the first success). */
|
|
169
|
+
export function getPreferredSttKey(): SttKeyCandidate | null {
|
|
170
|
+
return preferred;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Current counters snapshot (fresh objects — safe to hand to tests/logs). */
|
|
174
|
+
export function getSttCounters(): SttCounters {
|
|
175
|
+
return {
|
|
176
|
+
...counterState,
|
|
177
|
+
successesByCandidateIndex: { ...counterState.successesByCandidateIndex },
|
|
178
|
+
failuresByClass: { ...counterState.failuresByClass },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Test seam: clear winner memory and counters. */
|
|
183
|
+
export function resetSttState(): void {
|
|
184
|
+
preferred = null;
|
|
185
|
+
counterState = freshCounters();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @internal counter mutation helpers (used by transcribe.ts failover). */
|
|
189
|
+
export function countSttAttempt(): void {
|
|
190
|
+
counterState.attempts += 1;
|
|
191
|
+
}
|
|
192
|
+
export function countSttSuccess(candidate: SttKeyCandidate): void {
|
|
193
|
+
const map = counterState.successesByCandidateIndex;
|
|
194
|
+
map[candidate.index] = (map[candidate.index] ?? 0) + 1;
|
|
195
|
+
}
|
|
196
|
+
export function countSttNormalizationApplied(): void {
|
|
197
|
+
counterState.normalizationsApplied += 1;
|
|
198
|
+
}
|
|
199
|
+
export function countSttFailure(errorClass: string): void {
|
|
200
|
+
const map = counterState.failuresByClass;
|
|
201
|
+
map[errorClass] = (map[errorClass] ?? 0) + 1;
|
|
202
|
+
}
|
|
203
|
+
/** @internal A transport-class retry was issued (counted apart from attempts,
|
|
204
|
+
* which counts key-pool hits). */
|
|
205
|
+
export function countSttTransportRetry(): void {
|
|
206
|
+
counterState.transportRetries += 1;
|
|
207
|
+
}
|
|
208
|
+
/** @internal A transport-class retry then transcribed successfully. */
|
|
209
|
+
export function countSttTransportRetrySuccess(): void {
|
|
210
|
+
counterState.transportRetrySuccesses += 1;
|
|
211
|
+
}
|