agentus 0.0.0-stage → 0.2.0

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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,201 @@
1
+ // A real (PTY-backed) shell for the workspace panel's Terminal tab.
2
+ //
3
+ // Design notes, since this is the one place the cockpit forks a process that is
4
+ // not an agent:
5
+ //
6
+ // * **No native dependency, and honest about what that costs.** node-pty is a
7
+ // build-time native module; this project is deliberately dependency-light, so
8
+ // the default shell is `$SHELL -i` over ordinary pipes. That runs commands,
9
+ // streams output, keeps the prompt and `cd` — everything a cockpit panel is
10
+ // used for — but it is *not* a tty: no job control, no colour, and full-screen
11
+ // programs (vim, top) will refuse to start. Two ways out, both opt-in:
12
+ // AGENTUS_TERM_PTY=1 wraps the shell in Python's stdlib `pty.spawn` for a
13
+ // real terminal, and AGENTUS_TERM_CMD overrides the whole command line.
14
+ // * **One shell per panel, killed with the socket.** The child lives in the
15
+ // server's own group and is killed on disconnect, on `term-close`, and in the
16
+ // process-exit safety net (see installSafetyNet) — an abandoned shell is
17
+ // exactly the orphan class AC5 exists to prevent.
18
+ // * **The workspace is the fence.** The shell starts in the slot's workspace,
19
+ // because that is the directory the operator pointed this slot at. It is not
20
+ // a sandbox and does not pretend to be: the person running the cockpit already
21
+ // owns the machine (same trust model as the agent processes).
22
+ import { spawn, type ChildProcess } from "node:child_process";
23
+ import { randomUUID } from "node:crypto";
24
+ import fs from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ export interface TermSession {
29
+ id: string;
30
+ cwd: string;
31
+ pid: number | null;
32
+ /** bytes of output kept for a late joiner / a reconnect */
33
+ buffer: string;
34
+ child: ChildProcess;
35
+ subscribers: Set<(chunk: string) => void>;
36
+ exited: { code: number | null; signal: string | null } | null;
37
+ /** true when the child has a real tty (see shellCommand). The panel says which it got, because
38
+ * a pipe shell and a tty look nothing alike and the operator should not have to guess. */
39
+ tty: boolean;
40
+ /** Resize the tty (no-op on a pipe shell). The kernel then SIGWINCHes the child's foreground
41
+ * process group, which is what makes a full-screen program redraw at the new width. */
42
+ resize: (cols: number, rows: number) => void;
43
+ }
44
+
45
+ const MAX_BUFFER = 256 * 1024;
46
+
47
+ /** The PTY helper that ships with the server. */
48
+ function ptyHelperPath(): string | null {
49
+ for (const rel of ["./pty-helper.py", "../src/pty-helper.py"]) {
50
+ try {
51
+ const p = fileURLToPath(new URL(rel, import.meta.url));
52
+ if (fs.existsSync(p)) return p;
53
+ } catch {
54
+ /* not this layout */
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** How to start the shell.
61
+ *
62
+ * Default is a REAL pty via `pty-helper.py` (python3 stdlib `forkpty`): the shell gets a controlling
63
+ * terminal, so prompts, echo, colours, job control and full-screen programs behave. That is the
64
+ * whole point of the panel — a terminal emulator in front of a pipe shell is a lie (no prompt lands
65
+ * on screen, keystrokes are never echoed, resize means nothing).
66
+ *
67
+ * Escapes: `AGENTUS_TERM_CMD` runs an arbitrary command line instead (pipes), `AGENTUS_TERM_PTY=0`
68
+ * forces the old pipe shell, and a machine without python3 degrades to it automatically. Whoever
69
+ * gets a pipe shell is told so in the panel rather than being shown a fake terminal. */
70
+ function shellCommand(shell: string): { cmd: string; args: string[]; tty: boolean } {
71
+ const override = (process.env.AGENTUS_TERM_CMD || "").trim();
72
+ if (override) {
73
+ const parts = override.split(/\s+/);
74
+ return { cmd: parts[0], args: parts.slice(1), tty: false };
75
+ }
76
+ if (process.env.AGENTUS_TERM_PTY !== "0") {
77
+ const helper = ptyHelperPath();
78
+ if (helper) return { cmd: "python3", args: [helper, shell, "-i"], tty: true };
79
+ }
80
+ return { cmd: shell, args: ["-i"], tty: false };
81
+ }
82
+
83
+ function pickShell(): string {
84
+ const candidate = process.env.SHELL && fs.existsSync(process.env.SHELL) ? process.env.SHELL : null;
85
+ if (candidate) return candidate;
86
+ for (const s of ["/bin/zsh", "/bin/bash", "/bin/sh"]) {
87
+ if (fs.existsSync(s)) return s;
88
+ }
89
+ return "/bin/sh";
90
+ }
91
+
92
+ export class TermService {
93
+ #sessions = new Map<string, TermSession>();
94
+
95
+ start(cwd: string, size?: { cols: number; rows: number }): TermSession {
96
+ const shell = pickShell();
97
+ const { cmd, args, tty } = shellCommand(shell);
98
+ const cols = Math.max(20, Math.min(size?.cols ?? 80, 1000));
99
+ const rows = Math.max(5, Math.min(size?.rows ?? 24, 1000));
100
+ const child = spawn(cmd, args, {
101
+ cwd: fs.existsSync(cwd) ? cwd : homedir(),
102
+ env: {
103
+ ...process.env,
104
+ TERM: "xterm-256color",
105
+ PYTHONUNBUFFERED: "1",
106
+ // Let the shell know a machine is driving, so prompts stay plain.
107
+ AGENTUS_TERMINAL: "1",
108
+ // The pty is sized at start because the shell reads its size when it draws the first
109
+ // prompt — resizing afterwards still works, but a 80x24 flash on every open is not free.
110
+ AGENTUS_PTY_COLS: String(cols),
111
+ AGENTUS_PTY_ROWS: String(rows),
112
+ },
113
+ // fd 3 is the PTY helper's control channel (resize). A pipe shell has no use for it.
114
+ stdio: tty ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
115
+ // own process group: the shell (or the pty helper wrapping it) is killed as a group, never
116
+ // one process at a time (see kill()).
117
+ detached: true,
118
+ }) as ChildProcess;
119
+
120
+ const ctl = tty ? ((child.stdio[3] ?? null) as NodeJS.WritableStream | null) : null;
121
+ const session: TermSession = {
122
+ id: randomUUID(),
123
+ cwd,
124
+ pid: child.pid ?? null,
125
+ buffer: "",
126
+ child,
127
+ subscribers: new Set(),
128
+ exited: null,
129
+ tty,
130
+ resize: (c: number, r: number): void => {
131
+ if (!ctl) return;
132
+ try {
133
+ ctl.write(`resize ${Math.max(1, Math.min(c, 1000))} ${Math.max(1, Math.min(r, 1000))}\n`);
134
+ } catch {
135
+ /* the helper is gone; the exit event says so */
136
+ }
137
+ },
138
+ };
139
+ const push = (chunk: string): void => {
140
+ session.buffer = (session.buffer + chunk).slice(-MAX_BUFFER);
141
+ for (const fn of session.subscribers) {
142
+ try {
143
+ fn(chunk);
144
+ } catch {
145
+ /* a dead socket must not break the other listeners */
146
+ }
147
+ }
148
+ };
149
+ child.stdout?.setEncoding("utf8");
150
+ child.stderr?.setEncoding("utf8");
151
+ // stderr is part of the terminal picture (a missing command prints there). With a pty both
152
+ // streams are already merged into the pty master, so only stdout carries them — harmless.
153
+ child.stdout?.on("data", (d: string) => push(d));
154
+ child.stderr?.on("data", (d: string) => push(d));
155
+ child.on("exit", (code: number | null, signal: string | null) => {
156
+ session.exited = { code, signal };
157
+ push(`\r\n[process exited${code != null ? ` with code ${code}` : ""}${signal ? ` (${signal})` : ""}]\r\n`);
158
+ this.#sessions.delete(session.id);
159
+ });
160
+ child.on("error", (err: Error) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));
161
+ this.#sessions.set(session.id, session);
162
+ return session;
163
+ }
164
+
165
+ get(id: string): TermSession | undefined {
166
+ return this.#sessions.get(id);
167
+ }
168
+
169
+ list(): { id: string; cwd: string; pid: number | null; alive: boolean }[] {
170
+ return [...this.#sessions.values()].map((s) => ({
171
+ id: s.id, cwd: s.cwd, pid: s.pid, alive: s.exited === null,
172
+ }));
173
+ }
174
+
175
+ kill(id: string): boolean {
176
+ const s = this.#sessions.get(id);
177
+ if (!s) return false;
178
+ // The wrapper is `script`; killing the group takes the shell with it.
179
+ try {
180
+ if (s.child.pid) process.kill(-s.child.pid, "SIGKILL");
181
+ } catch {
182
+ /* no own process group: fall through to the direct kill */
183
+ }
184
+ try {
185
+ s.child.kill("SIGKILL");
186
+ } catch {
187
+ /* already gone */
188
+ }
189
+ this.#sessions.delete(id);
190
+ return true;
191
+ }
192
+
193
+ /** Used by the process-exit safety net and on shutdown: no shells outlive us. */
194
+ killAll(): number {
195
+ const ids = [...this.#sessions.keys()];
196
+ for (const id of ids) this.kill(id);
197
+ return ids.length;
198
+ }
199
+ }
200
+
201
+ export const terms = new TermService();
@@ -0,0 +1,109 @@
1
+ // Session titles.
2
+ //
3
+ // Two sources, in this order of authority:
4
+ // 1. the AGENT — ACP's own `session_info_update` carries `title` ("Agents send this
5
+ // notification to update session information like title… This allows clients to
6
+ // display dynamic session names"). Zero cost, no model call in our layer, and it is
7
+ // the only source that can be right about a conversation we never parse. If an agent
8
+ // ever uses it, it wins.
9
+ // 2. US — a deterministic derivation, so a new session stops being "Hermes @ tmp" the
10
+ // moment the operator asks something. hermes-studio/AionUi lineage (AionUi's
11
+ // `buildAutoTitleFromContent`): first non-empty line of the first user prompt, markdown
12
+ // furniture stripped, ≤50 chars. No model, instant, works offline.
13
+ //
14
+ // The operator's own rename always outranks both (the store keeps `title` = what is
15
+ // displayed and `auto_title` = the generated name, so a rename is reversible and a
16
+ // generated name never stomps a hand-written one).
17
+ import type { StoredMessage } from "../../shared/src/index.js";
18
+
19
+ /** Hard cap for a single-line row, in characters (AionUi uses 50). */
20
+ export const TITLE_MAX = 50;
21
+
22
+ /**
23
+ * Deterministic title from one blob of text (AionUi's rules, reimplemented):
24
+ * drop thinking blocks, take the first line that carries words, strip the markdown
25
+ * furniture that would otherwise lead (`#`, `>`, `-`, `1.`), collapse runs of spaces,
26
+ * and cap the length. Returns null when nothing usable is left.
27
+ */
28
+ export function deriveTitle(raw: string | null | undefined): string | null {
29
+ if (!raw) return null;
30
+ const withoutThink = raw
31
+ .replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, " ")
32
+ .replace(/<think(?:ing)?>[\s\S]*$/i, " ");
33
+ const lines = withoutThink
34
+ .replace(/\r/g, "")
35
+ .split("\n")
36
+ .map((l) => l.trim())
37
+ .filter((l) => l && l !== "```" && !/^```/.test(l));
38
+ const first = lines[0] ?? "";
39
+ const normalized = first
40
+ .replace(/^[#>*\-\d.\s]+/u, "")
41
+ .replace(/\s+/g, " ")
42
+ .trim()
43
+ .slice(0, TITLE_MAX)
44
+ .trim();
45
+ return normalized || null;
46
+ }
47
+
48
+ /** Title from the FIRST user prompt we stored for a session (the AionUi trigger point). */
49
+ export function titleFromFirstPrompt(msgs: StoredMessage[]): string | null {
50
+ for (const m of msgs) {
51
+ if (m.kind !== "user") continue;
52
+ const t = deriveTitle(userText(m));
53
+ if (t) return t;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** Title from the LATEST user prompt — the cheap reading of "regenerate from the context":
59
+ * what the operator is working on NOW, rather than what they said first. */
60
+ export function titleFromLatestPrompt(msgs: StoredMessage[]): string | null {
61
+ for (let i = msgs.length - 1; i >= 0; i--) {
62
+ const m = msgs[i];
63
+ if (m.kind !== "user") continue;
64
+ const t = deriveTitle(userText(m));
65
+ if (t) return t;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** user rows store `{ text }`; accept a `{content:{text}}` shape too so this keeps working
71
+ * whichever way the ingest writes them (export.ts learned that lesson already). */
72
+ function userText(m: StoredMessage): string {
73
+ const p = (m.payload ?? {}) as {
74
+ text?: unknown;
75
+ content?: { text?: unknown } | null;
76
+ };
77
+ if (typeof p.text === "string") return p.text;
78
+ if (p.content && typeof p.content.text === "string") return p.content.text;
79
+ return "";
80
+ }
81
+
82
+ /**
83
+ * The prompt for the agent-driven path. Short on purpose: the answer is one line we paste
84
+ * into a row, and every extra word is an extra second of latency.
85
+ */
86
+ export const TITLE_INSTRUCTION =
87
+ "请为这段对话生成一个会话标题:不超过 12 个字或 6 个英文单词,直接输出标题本身," +
88
+ "不要引号、不要句号、不要任何解释或前缀。";
89
+
90
+ /** Clean whatever the agent answered into a single-line title. Models like to answer with
91
+ * quotes, a bullet, "标题:" or a whole sentence; none of that belongs in a rail row. */
92
+ export function cleanAgentTitle(raw: string | null | undefined): string | null {
93
+ if (!raw) return null;
94
+ let t = raw.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, " ");
95
+ const line = t
96
+ .replace(/\r/g, "")
97
+ .split("\n")
98
+ .map((l) => l.trim())
99
+ .filter(Boolean)
100
+ .find((l) => !/^```/.test(l)) ?? "";
101
+ t = line
102
+ .replace(/^[#>*\-\d.、\s]+/u, "")
103
+ .replace(/^(会话标题|标题|title)\s*[::]\s*/i, "")
104
+ .replace(/^["'“”‘’《【\[]+|["'“”‘’》】\]]+$/g, "")
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ if (!t) return null;
108
+ return t.slice(0, TITLE_MAX).trim() || null;
109
+ }
@@ -0,0 +1,251 @@
1
+ // Voice: speech-to-text and text-to-speech.
2
+ //
3
+ // Two layers (unchanged from the first voice pass): the browser can do both directions
4
+ // with zero configuration, and the server adds a provider when the operator configures
5
+ // one. What changed is where the configuration lives: the settings page (settings.ts)
6
+ // owns provider / base URL / key / models / voice / hotwords; env remains a bootstrap.
7
+ //
8
+ // This module is a ROUTER over providers:
9
+ // "dashscope" 阿里百炼 (dashscope.ts): streaming ASR over its inference WebSocket,
10
+ // batch ASR over the OpenAI-compatible chat route, TTS via
11
+ // SpeechSynthesizer; fixed + dynamic hotwords ride along.
12
+ // "openai" any OpenAI-compatible /audio/speech + /audio/transcriptions endpoint
13
+ // (the AGENTUS_TTS_* / AGENTUS_STT_* bootstrap path).
14
+ // "browser" no server path (the UI uses speechSynthesis / SpeechRecognition).
15
+ //
16
+ // The server still synthesises or recognises nothing itself: every call forwards to an
17
+ // endpoint the operator owns. That keeps the project's "the UI holds no intelligence"
18
+ // line intact (a voice endpoint is I/O, like the file panel, not an agent).
19
+ import { getSettings, dashscopeUrls } from "./settings.js";
20
+ import { dashscopeAsr, dashscopeTts, DashscopeError } from "./dashscope.js";
21
+
22
+ export interface VoiceConfig {
23
+ tts: { baseUrl: string; key: string; model: string; voice: string } | null;
24
+ stt: { baseUrl: string; key: string; model: string; language: string } | null;
25
+ }
26
+
27
+ const trimmed = (v: string | undefined): string => String(v ?? "").trim().replace(/\/+$/, "");
28
+
29
+ /** Env bootstrap for the OpenAI-compatible provider (unchanged semantics: what the
30
+ * fake-endpoint tests and openai users hit; the settings page overrides it). */
31
+ export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
32
+ const ttsBase = trimmed(env.AGENTUS_TTS_BASE_URL);
33
+ const sttBase = trimmed(env.AGENTUS_STT_BASE_URL);
34
+ return {
35
+ tts: ttsBase
36
+ ? {
37
+ baseUrl: ttsBase,
38
+ key: String(env.AGENTUS_TTS_API_KEY ?? env.OPENAI_API_KEY ?? ""),
39
+ model: String(env.AGENTUS_TTS_MODEL || "tts-1"),
40
+ voice: String(env.AGENTUS_TTS_VOICE || "alloy"),
41
+ }
42
+ : null,
43
+ stt: sttBase
44
+ ? {
45
+ baseUrl: sttBase,
46
+ key: String(env.AGENTUS_STT_API_KEY ?? env.OPENAI_API_KEY ?? ""),
47
+ model: String(env.AGENTUS_STT_MODEL || "whisper-1"),
48
+ language: String(env.AGENTUS_STT_LANGUAGE || ""),
49
+ }
50
+ : null,
51
+ };
52
+ }
53
+
54
+ export type VoiceProvider = "dashscope" | "openai" | "browser";
55
+
56
+ /** Which provider would answer right now. The settings page wins over env; "dashscope"
57
+ * selected but incomplete resolves to "browser" (the UI must not be promised a server
58
+ * path that will 501). */
59
+ export function resolveVoice(env: NodeJS.ProcessEnv = process.env): {
60
+ provider: VoiceProvider;
61
+ tts: boolean;
62
+ stt: boolean;
63
+ openai: VoiceConfig;
64
+ } {
65
+ const v = getSettings().voice;
66
+ const openai = voiceConfig(env);
67
+ if (v.provider === "dashscope") {
68
+ const ready = Boolean(v.baseUrl && v.apiKey);
69
+ return { provider: ready ? "dashscope" : "browser", tts: ready, stt: ready, openai };
70
+ }
71
+ if (v.provider === "openai") {
72
+ const has = Boolean(v.baseUrl && v.apiKey);
73
+ return {
74
+ provider: has || openai.tts || openai.stt ? "openai" : "browser",
75
+ tts: has || Boolean(openai.tts),
76
+ stt: has || Boolean(openai.stt),
77
+ openai,
78
+ };
79
+ }
80
+ // provider "browser": env bootstrap still counts (a test server or an operator who
81
+ // prefers env over the page)
82
+ return { provider: "browser", tts: Boolean(openai.tts), stt: Boolean(openai.stt), openai };
83
+ }
84
+
85
+ /** What the browser asks before it decides who speaks. */
86
+ export function voiceCapabilities(): Record<string, unknown> {
87
+ const v = getSettings().voice;
88
+ const r = resolveVoice();
89
+ return {
90
+ provider: r.provider,
91
+ tts: {
92
+ server: r.tts,
93
+ model: r.provider === "dashscope" ? v.ttsModel : (r.openai.tts?.model ?? null),
94
+ voice: r.provider === "dashscope" ? v.ttsVoice : (r.openai.tts?.voice ?? null),
95
+ },
96
+ stt: {
97
+ server: r.stt,
98
+ streaming: r.provider === "dashscope" && v.asrStream,
99
+ model: r.provider === "dashscope" ? v.asrModel : null,
100
+ batchModel: r.provider === "dashscope" ? v.asrBatchModel : (r.openai.stt?.model ?? null),
101
+ language: r.openai.stt?.language || null,
102
+ },
103
+ hotwords: { fixed: v.hotwords.length, dynamic: v.dynamicHotwords, limit: v.hotwordLimit },
104
+ };
105
+ }
106
+
107
+ export class VoiceError extends Error {
108
+ constructor(message: string, readonly code: "not_configured" | "upstream" | "timeout") {
109
+ super(message);
110
+ }
111
+ }
112
+
113
+ const TIMEOUT_MS = 60_000;
114
+
115
+ async function withTimeout<T>(label: string, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
116
+ const ac = new AbortController();
117
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
118
+ try {
119
+ return await run(ac.signal);
120
+ } catch (e) {
121
+ if ((e as Error).name === "AbortError") throw new VoiceError(`${label} timed out after ${TIMEOUT_MS / 1000}s`, "timeout");
122
+ if (e instanceof VoiceError || e instanceof DashscopeError) throw e;
123
+ // DNS/connection failure: the operator's endpoint is the problem, not this process —
124
+ // report it as an upstream error so the UI says what actually happened.
125
+ throw new VoiceError(`${label}: cannot reach the voice endpoint (${String((e as Error)?.message ?? e)})`, "upstream");
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+
131
+ function wrapDashscope(e: unknown): VoiceError {
132
+ if (e instanceof DashscopeError) {
133
+ return new VoiceError(e.message, e.code === "timeout" ? "timeout" : e.code === "not_configured" ? "not_configured" : "upstream");
134
+ }
135
+ return new VoiceError(String((e as Error)?.message ?? e), "upstream");
136
+ }
137
+
138
+ /** Text -> audio bytes, routed. The browser only ever sees bytes: the key and the
139
+ * provider's signed result URL stay server-side. */
140
+ export async function synthesize(text: string, opts: { voice?: string; speed?: number } = {}): Promise<{
141
+ contentType: string;
142
+ audio: Buffer;
143
+ }> {
144
+ const v = getSettings().voice;
145
+ const r = resolveVoice();
146
+ if (r.provider === "dashscope") return dashscopeTts(text, opts).catch((e) => { throw wrapDashscope(e); });
147
+ // openai-compatible: settings endpoint+key when complete, else the env bootstrap
148
+ const fromSettings = v.baseUrl && v.apiKey ? { baseUrl: trimmed(v.baseUrl), key: v.apiKey, model: v.ttsModel || "tts-1", voice: v.ttsVoice } : null;
149
+ const cfg = fromSettings ?? r.openai.tts;
150
+ if (!cfg) throw new VoiceError("server TTS is not configured (settings page, or AGENTUS_TTS_BASE_URL)", "not_configured");
151
+ const res = await withTimeout("tts", (signal) => fetch(`${cfg.baseUrl}/audio/speech`, {
152
+ method: "POST",
153
+ signal,
154
+ headers: { "content-type": "application/json", ...(cfg.key ? { authorization: `Bearer ${cfg.key}` } : {}) },
155
+ body: JSON.stringify({
156
+ model: cfg.model,
157
+ voice: opts.voice || cfg.voice || "alloy",
158
+ input: text,
159
+ ...(opts.speed && opts.speed !== 1 ? { speed: opts.speed } : {}),
160
+ }),
161
+ }));
162
+ if (!res.ok) throw new VoiceError(`tts upstream ${res.status}: ${(await res.text().catch(() => "")).slice(0, 400)}`, "upstream");
163
+ return { contentType: res.headers.get("content-type") ?? "audio/mpeg", audio: Buffer.from(await res.arrayBuffer()) };
164
+ }
165
+
166
+ /** Audio bytes -> transcript, routed. `sessionId` lets the provider pull context hotwords
167
+ * from the transcript it already has (dynamic hotwords). */
168
+ export async function transcribe(
169
+ audio: Buffer,
170
+ mime: string,
171
+ opts: { filename?: string; language?: string; sessionId?: string; contextWords?: string[] } = {},
172
+ ): Promise<string> {
173
+ const v = getSettings().voice;
174
+ const r = resolveVoice();
175
+ if (r.provider === "dashscope") {
176
+ return dashscopeAsr(audio, mime, { contextWords: opts.contextWords ?? hotwordsForRequest(opts.sessionId) })
177
+ .catch((e) => { throw wrapDashscope(e); });
178
+ }
179
+ const fromSettings = v.baseUrl && v.apiKey ? { baseUrl: trimmed(v.baseUrl), key: v.apiKey, model: v.asrBatchModel, language: "" } : null;
180
+ const cfg = fromSettings ?? r.openai.stt;
181
+ if (!cfg) throw new VoiceError("server STT is not configured (settings page, or AGENTUS_STT_BASE_URL)", "not_configured");
182
+ const form = new FormData();
183
+ const name = opts.filename || `dictation.${mimeToExt(mime)}`;
184
+ form.append("file", new Blob([new Uint8Array(audio)], { type: mime || "audio/webm" }), name);
185
+ form.append("model", cfg.model);
186
+ if (opts.language || cfg.language) form.append("language", opts.language || cfg.language);
187
+ const res = await withTimeout("stt", (signal) => fetch(`${cfg.baseUrl}/audio/transcriptions`, {
188
+ method: "POST",
189
+ signal,
190
+ headers: cfg.key ? { authorization: `Bearer ${cfg.key}` } : undefined,
191
+ body: form,
192
+ }));
193
+ if (!res.ok) throw new VoiceError(`stt upstream ${res.status}: ${(await res.text().catch(() => "")).slice(0, 400)}`, "upstream");
194
+ const data = (await res.json()) as { text?: string };
195
+ return String(data?.text ?? "").trim();
196
+ }
197
+
198
+ /** Hotword extraction needs the store, which lives outside this module: injected at
199
+ * boot (index.ts) so voice.ts stays free of database imports. */
200
+ type HotwordFn = (sessionId?: string) => string[];
201
+ let hotwordsForRequest: HotwordFn = () => [];
202
+ export function setHotwordSource(fn: HotwordFn): void {
203
+ hotwordsForRequest = fn;
204
+ }
205
+
206
+ /** The model ids the configured endpoint serves, split into the two lists the settings
207
+ * page offers. The operator named two defaults but asked for "其它的也可选", and every
208
+ * Bailian tenant advertises a different set — so we ask the endpoint instead of
209
+ * hard-coding a catalogue. */
210
+ export async function listVoiceModels(): Promise<{ total: number; asr: string[]; tts: string[] }> {
211
+ const v = getSettings().voice;
212
+ const r = resolveVoice();
213
+ let url = "";
214
+ let key = "";
215
+ if (r.provider === "dashscope") {
216
+ const { compat } = dashscopeUrls(v.baseUrl);
217
+ url = `${compat}/models`;
218
+ key = v.apiKey;
219
+ } else {
220
+ // the settings page's endpoint first: resolveVoice() falls back to the env bootstrap,
221
+ // so an operator who typed an endpoint must not be told "not configured" (the page's
222
+ // "重新读取" button would be a lie).
223
+ const base = trimmed(v.baseUrl) || r.openai.tts?.baseUrl || r.openai.stt?.baseUrl || "";
224
+ if (!base) throw new VoiceError("no voice endpoint configured", "not_configured");
225
+ key = (v.baseUrl && v.apiKey ? v.apiKey : "") || r.openai.tts?.key || r.openai.stt?.key || "";
226
+ url = `${base}/models`;
227
+ }
228
+ const res = await withTimeout("models", (signal) => fetch(url, {
229
+ signal,
230
+ headers: key ? { authorization: `Bearer ${key}` } : undefined,
231
+ }));
232
+ if (!res.ok) throw new VoiceError(`models upstream ${res.status}: ${(await res.text().catch(() => "")).slice(0, 300)}`, "upstream");
233
+ const data = (await res.json()) as { data?: { id?: string }[]; models?: { name?: string }[] };
234
+ const ids = (data.data ?? []).map((m) => String(m.id ?? "")).filter(Boolean);
235
+ for (const m of data.models ?? []) if (m.name) ids.push(String(m.name));
236
+ const asr = ids.filter((id) => /asr|speech-recog|recognition|paraformer|sensevoice/i.test(id));
237
+ const tts = ids.filter((id) => /tts|speech-synth|cosyvoice|sambert/i.test(id));
238
+ return { total: ids.length, asr, tts };
239
+ }
240
+
241
+ function mimeToExt(mime: string): string {
242
+ const m = mime.toLowerCase();
243
+ if (m.includes("webm")) return "webm";
244
+ if (m.includes("ogg")) return "ogg";
245
+ if (m.includes("wav")) return "wav";
246
+ if (m.includes("mp4") || m.includes("m4a") || m.includes("aac")) return "m4a";
247
+ if (m.includes("mpeg") || m.includes("mp3")) return "mp3";
248
+ if (m.includes("flac")) return "flac";
249
+ if (m.includes("pcm")) return "pcm";
250
+ return "webm";
251
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@agentus/shared",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "scripts": {
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "devDependencies": {
12
+ "typescript": "^5.6.0"
13
+ }
14
+ }