agentus 0.0.0-stage → 0.1.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 (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +459 -2
  4. package/bin/agentus.mjs +147 -0
  5. package/docs/android-notify-contract.md +658 -0
  6. package/docs/refs/aionui-acp-rules.md +46 -0
  7. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  8. package/docs/transcript-folding.md +139 -0
  9. package/package.json +75 -5
  10. package/packages/server/mcp/plan-server.mjs +217 -0
  11. package/packages/server/mock/agent.mjs +658 -0
  12. package/packages/server/package.json +22 -0
  13. package/packages/server/src/acp/backends.ts +196 -0
  14. package/packages/server/src/acp/errors.ts +46 -0
  15. package/packages/server/src/acp/registry.ts +579 -0
  16. package/packages/server/src/acp/session-manager.ts +1868 -0
  17. package/packages/server/src/auth.ts +688 -0
  18. package/packages/server/src/dashscope.ts +235 -0
  19. package/packages/server/src/fs.ts +165 -0
  20. package/packages/server/src/hotwords.ts +132 -0
  21. package/packages/server/src/index.ts +1404 -0
  22. package/packages/server/src/notify/center.ts +994 -0
  23. package/packages/server/src/notify/types.ts +222 -0
  24. package/packages/server/src/plan/plan.ts +126 -0
  25. package/packages/server/src/settings.ts +469 -0
  26. package/packages/server/src/store/export.ts +209 -0
  27. package/packages/server/src/store/store.ts +1238 -0
  28. package/packages/server/src/term.ts +160 -0
  29. package/packages/server/src/title.ts +109 -0
  30. package/packages/server/src/voice.ts +251 -0
  31. package/packages/shared/package.json +14 -0
  32. package/packages/shared/src/index.ts +271 -0
  33. package/packages/web/dist/assets/index-B52l55_4.css +1 -0
  34. package/packages/web/dist/assets/index-BRgC7oYt.js +246 -0
  35. package/packages/web/dist/coding-agents/hermes.png +0 -0
  36. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  37. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  38. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  39. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  40. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  41. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  42. package/packages/web/dist/icons/favicon.ico +0 -0
  43. package/packages/web/dist/icons/icon-192.png +0 -0
  44. package/packages/web/dist/icons/icon-512.png +0 -0
  45. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  46. package/packages/web/dist/index.html +30 -0
  47. package/packages/web/dist/manifest.webmanifest +17 -0
  48. package/packages/web/dist/sw.js +61 -0
  49. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,235 @@
1
+ // DashScope (阿里百炼) voice adapter.
2
+ //
3
+ // Shapes verified against the operator's own MaaS endpoint on 2026-10-02 (probe scripts
4
+ // in ~/.hermes/cache/agentus/night):
5
+ // TTS POST {root}/api/v1/services/audio/tts/SpeechSynthesizer
6
+ // {model:"qwen-audio-3.0-tts-flash", input:{text, voice:"longanhuan_v3.6",
7
+ // format:"wav", sample_rate}} → output.audio.url (fetch server-side, return bytes)
8
+ // ASR POST {compat}/chat/completions with an input_audio base64 data URL
9
+ // (qwen3-asr-flash) → choices[0].message.content; hotwords ride in a system
10
+ // message (the compat surface has no vocabulary parameter)
11
+ // ASR流 WSS {root}/api-ws/v1/inference, run-task/finish-task duplex events, binary
12
+ // PCM frames after task-started, result-generated carries partial + final
13
+ // sentences. Supports instant hotwords (parameters.vocabulary) and context.
14
+ //
15
+ // The base URL is the operator's — every Bailian workspace id is different, and the
16
+ // whole point is that this is NOT the coding-plan endpoint. No URLs or keys are baked in.
17
+ import WebSocket from "ws";
18
+ import { getSettings, dashscopeUrls } from "./settings.js";
19
+
20
+ export class DashscopeError extends Error {
21
+ constructor(message: string, readonly code: "not_configured" | "upstream" | "timeout" | "bad_audio") {
22
+ super(message);
23
+ }
24
+ }
25
+
26
+ const TIMEOUT_MS = 45_000;
27
+
28
+ function creds(): { key: string; root: string; compat: string; ws: string } {
29
+ const v = getSettings().voice;
30
+ if (v.provider !== "dashscope" || !v.baseUrl || !v.apiKey) {
31
+ throw new DashscopeError("dashscope voice is not configured (settings page: endpoint + API key)", "not_configured");
32
+ }
33
+ const urls = dashscopeUrls(v.baseUrl);
34
+ return { key: v.apiKey, ...urls };
35
+ }
36
+
37
+ async function fetchJson(url: string, init: RequestInit, label: string): Promise<Response> {
38
+ const ac = new AbortController();
39
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
40
+ try {
41
+ const res = await fetch(url, { ...init, signal: ac.signal });
42
+ if (!res.ok) {
43
+ const detail = await res.text().catch(() => "");
44
+ throw new DashscopeError(`${label} ${res.status}: ${detail.slice(0, 300) || res.statusText}`, "upstream");
45
+ }
46
+ return res;
47
+ } catch (e) {
48
+ if ((e as Error).name === "AbortError") throw new DashscopeError(`${label} timed out after ${TIMEOUT_MS / 1000}s`, "timeout");
49
+ throw e;
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ /** Text → audio bytes. Non-streaming: the response carries a 24h OSS URL; we fetch it
56
+ * server-side so the browser only ever talks to Agentus (and the key never pairs with
57
+ * a cross-origin request from the client). */
58
+ export async function dashscopeTts(text: string, opts: { voice?: string; speed?: number } = {}): Promise<{
59
+ contentType: string;
60
+ audio: Buffer;
61
+ }> {
62
+ const { key, root } = creds();
63
+ const v = getSettings().voice;
64
+ const rate = Math.min(2, Math.max(0.5, opts.speed ?? 1));
65
+ const res = await fetchJson(`${root}/api/v1/services/audio/tts/SpeechSynthesizer`, {
66
+ method: "POST",
67
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
68
+ body: JSON.stringify({
69
+ model: v.ttsModel,
70
+ input: {
71
+ text,
72
+ voice: opts.voice || v.ttsVoice,
73
+ format: v.ttsFormat,
74
+ sample_rate: v.ttsFormat === "wav" ? 24000 : undefined,
75
+ ...(rate !== 1 ? { rate } : {}),
76
+ },
77
+ }),
78
+ }, "dashscope tts");
79
+ const data = (await res.json()) as { output?: { audio?: { url?: string; data?: string } } };
80
+ const audio = data?.output?.audio;
81
+ if (audio?.data) {
82
+ return { contentType: mimeOf(v.ttsFormat), audio: Buffer.from(audio.data, "base64") };
83
+ }
84
+ if (!audio?.url) throw new DashscopeError(`dashscope tts returned no audio: ${JSON.stringify(data).slice(0, 200)}`, "upstream");
85
+ // the url is http:// on some tenants; fetch as-is (same cloud, short-lived, signed)
86
+ const dl = await fetchJson(audio.url, { method: "GET" }, "dashscope tts download");
87
+ return { contentType: dl.headers.get("content-type") ?? mimeOf(v.ttsFormat), audio: Buffer.from(await dl.arrayBuffer()) };
88
+ }
89
+
90
+ function mimeOf(format: string): string {
91
+ return format === "wav" ? "audio/wav" : format === "opus" ? "audio/ogg" : "audio/mpeg";
92
+ }
93
+
94
+ /** Hotwords for the compat route: a compact system message. The docs describe it as
95
+ * "背景文本和实体词表" — exactly the context-enhancement channel, and it is what makes
96
+ * "Agentus"/"网关" survive instead of "安逸 slot"/"万关". */
97
+ export function hotwordContext(words: string[]): string {
98
+ if (!words.length) return "";
99
+ return `本次语音可能涉及的词汇表(识别时可优先匹配):${words.slice(0, 60).join("、")}。`;
100
+ }
101
+
102
+ /** One-shot transcription (the stop-and-finalise path, and the batch model).
103
+ * `audio` is raw bytes; `mime` decides the data URL prefix the model is told. */
104
+ export async function dashscopeAsr(audio: Buffer, mime: string, opts: { contextWords?: string[] } = {}): Promise<string> {
105
+ const { key, compat } = creds();
106
+ const v = getSettings().voice;
107
+ const dataUrl = `data:${mime || "audio/wav"};base64,${audio.toString("base64")}`;
108
+ const messages: unknown[] = [];
109
+ const ctx = hotwordContext(opts.contextWords ?? []);
110
+ if (ctx) messages.push({ role: "system", content: [{ type: "text", text: ctx }] });
111
+ messages.push({ role: "user", content: [{ type: "input_audio", input_audio: { data: dataUrl } }] });
112
+ const res = await fetchJson(`${compat}/chat/completions`, {
113
+ method: "POST",
114
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
115
+ body: JSON.stringify({ model: v.asrBatchModel, messages, asr_options: { enable_itn: true } }),
116
+ }, "dashscope asr");
117
+ const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
118
+ return String(data?.choices?.[0]?.message?.content ?? "").trim();
119
+ }
120
+
121
+ /**
122
+ * A live streaming recognition task over the DashScope WebSocket.
123
+ *
124
+ * The browser cannot talk to this endpoint itself: the auth header on a WebSocket
125
+ * handshake is forbidden in browsers, and the raw key must not leave the server anyway.
126
+ * So the cockpit server owns the upstream socket and the browser relays PCM through our
127
+ * own /ws/asr (see index.ts). Events:
128
+ * onPartial(text) — running sentence, replaces the previous partial
129
+ * onFinal(text) — a completed sentence (sentence_end)
130
+ */
131
+ export function openDashscopeStream(opts: {
132
+ /** instant hotwords WITH weights: {"词": 1..5}, 50 = super-hotword */
133
+ vocabulary?: Record<string, number>;
134
+ /** plain word list, used when no weighted vocabulary is available */
135
+ contextWords?: string[];
136
+ /** context enhancement: recent lines of the conversation being dictated into */
137
+ context?: { role: "user" | "assistant"; text: string }[];
138
+ onPartial: (text: string) => void;
139
+ onFinal: (text: string) => void;
140
+ onError: (message: string) => void;
141
+ onClose: () => void;
142
+ }): { sendAudio: (chunk: Buffer) => void; stop: () => void; started: Promise<void> } {
143
+ const { key, ws } = creds();
144
+ const v = getSettings().voice;
145
+ const socket = new WebSocket(ws, { headers: { authorization: `Bearer ${key}` } });
146
+ const taskId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
147
+ let startedResolve: (() => void) | null = null;
148
+ let startedReject: ((e: Error) => void) | null = null;
149
+ const started = new Promise<void>((resolve, reject) => { startedResolve = resolve; startedReject = reject; });
150
+ let closing = false;
151
+ // "done" must be exactly once and must not depend on WHO closed the socket: the first
152
+ // relay had the finalise path suppressed (closing=true swallowed the close), so the
153
+ // browser sat waiting for an end that never came.
154
+ let done = false;
155
+ const notifyDone = (): void => {
156
+ if (done) return;
157
+ done = true;
158
+ opts.onClose();
159
+ };
160
+ const vocab: Record<string, number> = { ...(opts.vocabulary ?? {}) };
161
+ for (const w of opts.contextWords ?? []) if (!(w in vocab)) vocab[w] = 4;
162
+
163
+ socket.on("open", () => {
164
+ socket.send(JSON.stringify({
165
+ header: { action: "run-task", task_id: taskId, streaming: "duplex" },
166
+ payload: {
167
+ task_group: "audio", task: "asr", function: "recognition", model: v.asrModel,
168
+ parameters: { format: "pcm", sample_rate: 16000, ...(Object.keys(vocab).length ? { vocabulary: vocab } : {}) },
169
+ // context enhancement: what the operator has been talking about. Only sent when
170
+ // we have it: an empty array is not the same request as "no context".
171
+ input: opts.context?.length ? { context: opts.context.slice(0, 5) } : {},
172
+ },
173
+ }));
174
+ });
175
+ socket.on("message", (raw: Buffer | string) => {
176
+ let msg: { header?: { event?: string; error_message?: string }; payload?: { output?: { sentence?: { text?: string; sentence_end?: boolean }; text?: string } } };
177
+ try {
178
+ msg = JSON.parse(String(raw)) as typeof msg;
179
+ } catch {
180
+ return;
181
+ }
182
+ const event = msg?.header?.event ?? "";
183
+ if (event === "task-started") { startedResolve?.(); startedResolve = null; return; }
184
+ if (event === "task-failed") {
185
+ const m = `dashscope asr: ${msg?.header?.error_message ?? "task failed"}`;
186
+ startedReject?.(new Error(m)); startedReject = null;
187
+ opts.onError(m);
188
+ return;
189
+ }
190
+ if (event === "result-generated") {
191
+ const sent = msg?.payload?.output?.sentence;
192
+ const text = String(sent?.text ?? msg?.payload?.output?.text ?? "");
193
+ if (!text) return;
194
+ if (sent?.sentence_end) opts.onFinal(text);
195
+ else opts.onPartial(text);
196
+ return;
197
+ }
198
+ if (event === "task-finished") {
199
+ // the API's own end-of-task event: report it now rather than waiting for the socket
200
+ startedReject?.(new Error("task finished before start"));
201
+ startedReject = null;
202
+ notifyDone();
203
+ return;
204
+ }
205
+ });
206
+ socket.on("error", (e: Error) => {
207
+ startedReject?.(e); startedReject = null;
208
+ opts.onError(`dashscope asr socket: ${e.message}`);
209
+ });
210
+ socket.on("close", notifyDone);
211
+
212
+ const timeout = setTimeout(() => {
213
+ startedReject?.(new Error("dashscope asr: task-started timed out"));
214
+ startedReject = null;
215
+ }, 10_000);
216
+ started.catch(() => { /* surfaced via onError/stop below */ }).finally(() => void 0);
217
+ // clear the timer once started succeeds; reject path already rejected the promise
218
+ void started.then(() => clearTimeout(timeout), () => { clearTimeout(timeout); try { socket.close(); } catch { /* gone */ } });
219
+
220
+ return {
221
+ sendAudio(chunk: Buffer): void {
222
+ if (socket.readyState === socket.OPEN) socket.send(chunk, { binary: true });
223
+ },
224
+ stop(): void {
225
+ closing = true;
226
+ try {
227
+ if (socket.readyState === socket.OPEN) {
228
+ socket.send(JSON.stringify({ header: { action: "finish-task", task_id: taskId, streaming: "duplex" }, payload: { input: {} } }));
229
+ setTimeout(() => { try { socket.close(); } catch { /* already */ } }, 2000);
230
+ }
231
+ } catch { /* already closed */ }
232
+ },
233
+ started,
234
+ };
235
+ }
@@ -0,0 +1,165 @@
1
+ // Directory browsing for the new-slot workspace picker.
2
+ //
3
+ // The operator already has the right to point an agent at any directory (that is the
4
+ // product), so this adds no new power — it just makes the choice discoverable instead
5
+ // of demanding a path typed from memory. Listing is deliberately narrow: directories
6
+ // only, one level at a time, capped, and never followed through symlinks we cannot stat.
7
+ import fs from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import path from "node:path";
10
+
11
+ export interface DirEntry {
12
+ name: string;
13
+ path: string;
14
+ /** "dir" for directories, "file" for everything else the panel can show */
15
+ kind: "dir" | "file";
16
+ /** bytes, files only (the panel prints a human size) */
17
+ size?: number;
18
+ }
19
+
20
+ export interface DirListing {
21
+ /** the directory that was listed (absolute, symlink-resolved) */
22
+ path: string;
23
+ /** parent directory, or null at the root */
24
+ parent: string | null;
25
+ home: string;
26
+ entries: DirEntry[];
27
+ /** hit the entry cap — the UI says so instead of pretending the list is complete */
28
+ truncated: boolean;
29
+ }
30
+
31
+ const MAX_ENTRIES = 300;
32
+
33
+ /** `~`, `~/x`, relative and absolute all land on an absolute path. */
34
+ export function expandPath(input: string | null | undefined): string {
35
+ const raw = String(input ?? "").trim();
36
+ if (!raw) return homedir();
37
+ if (raw === "~") return homedir();
38
+ if (raw.startsWith("~/")) return path.resolve(path.join(homedir(), raw.slice(2)));
39
+ return path.resolve(raw);
40
+ }
41
+
42
+ export class FsError extends Error {
43
+ constructor(message: string, readonly code: "not_found" | "not_a_directory" | "not_a_file" | "permission_denied" | "bad_path") {
44
+ super(message);
45
+ }
46
+ }
47
+
48
+ export function listDirs(input: string | null | undefined, opts: { includeFiles?: boolean } = {}): DirListing {
49
+ const target = expandPath(input);
50
+ let st: fs.Stats;
51
+ try {
52
+ st = fs.statSync(target); // stat (not lstat): a symlinked workspace should work
53
+ } catch (e) {
54
+ const code = (e as NodeJS.ErrnoException).code;
55
+ if (code === "ENOENT") throw new FsError(`no such directory: ${target}`, "not_found");
56
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
57
+ throw new FsError(`cannot read ${target}: ${code ?? String(e)}`, "bad_path");
58
+ }
59
+ if (!st.isDirectory()) throw new FsError(`not a directory: ${target}`, "not_a_directory");
60
+
61
+ let names: fs.Dirent[] = [];
62
+ try {
63
+ names = fs.readdirSync(target, { withFileTypes: true });
64
+ } catch (e) {
65
+ const code = (e as NodeJS.ErrnoException).code;
66
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
67
+ throw new FsError(`cannot list ${target}: ${code ?? String(e)}`, "bad_path");
68
+ }
69
+
70
+ const entries: DirEntry[] = [];
71
+ for (const d of names) {
72
+ // Hidden directories are the bulk of a home directory and almost never the answer;
73
+ // the path input still accepts them when someone really wants one.
74
+ if (d.name.startsWith(".")) continue;
75
+ const full = path.join(target, d.name);
76
+ let isDir = d.isDirectory();
77
+ // a symlink to a directory is a normal way to organise work: stat it
78
+ if (!isDir && d.isSymbolicLink()) {
79
+ try {
80
+ isDir = fs.statSync(full).isDirectory();
81
+ } catch {
82
+ continue; // dangling symlink or unreadable target: skip
83
+ }
84
+ }
85
+ if (isDir) {
86
+ entries.push({ name: d.name, path: full, kind: "dir" });
87
+ continue;
88
+ }
89
+ // The file panel (head ▤) wants files too; the picker (new slot) must not
90
+ // offer one as a working directory, so it stays opt-in.
91
+ if (opts.includeFiles && !d.isSymbolicLink()) {
92
+ let size: number | undefined;
93
+ try {
94
+ size = fs.statSync(full).size;
95
+ } catch {
96
+ /* unreadable: still list it, size unknown */
97
+ }
98
+ entries.push({ name: d.name, path: full, kind: "file", size });
99
+ }
100
+ }
101
+ entries.sort((a, b) => (
102
+ a.kind === b.kind
103
+ ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
104
+ : a.kind === "dir" ? -1 : 1 // directories first, like every file manager
105
+ ));
106
+
107
+ const truncated = entries.length > MAX_ENTRIES;
108
+ const trimmed = truncated ? entries.slice(0, MAX_ENTRIES) : entries;
109
+ const parent = path.dirname(target);
110
+ return {
111
+ path: target,
112
+ parent: parent === target ? null : parent,
113
+ home: homedir(),
114
+ entries: trimmed,
115
+ truncated,
116
+ };
117
+ }
118
+
119
+ export interface FilePreview {
120
+ path: string;
121
+ name: string;
122
+ size: number;
123
+ /** decoded utf-8 text, cut at maxBytes (the tail is reported, never faked) */
124
+ content: string;
125
+ truncated: boolean;
126
+ /** looks like binary: the panel shows a "cannot preview" note instead of mojibake */
127
+ binary: boolean;
128
+ }
129
+
130
+ const DEFAULT_PREVIEW_BYTES = 256 * 1024;
131
+
132
+ /** Read a file for the workspace panel. Read-only on purpose: this panel is a
133
+ * browser, not an editor — writing files is the agent's job, not the cockpit's. */
134
+ export function readTextFile(input: string, opts: { maxBytes?: number } = {}): FilePreview {
135
+ const target = expandPath(input);
136
+ const maxBytes = Math.max(1024, opts.maxBytes ?? DEFAULT_PREVIEW_BYTES);
137
+ let st: fs.Stats;
138
+ try {
139
+ st = fs.statSync(target);
140
+ } catch (e) {
141
+ const code = (e as NodeJS.ErrnoException).code;
142
+ if (code === "ENOENT") throw new FsError(`no such file: ${target}`, "not_found");
143
+ if (code === "EACCES") throw new FsError(`permission denied: ${target}`, "permission_denied");
144
+ throw new FsError(`cannot read ${target}: ${code ?? String(e)}`, "bad_path");
145
+ }
146
+ if (st.isDirectory()) throw new FsError(`not a file: ${target}`, "not_a_file");
147
+ const fd = fs.openSync(target, "r");
148
+ try {
149
+ const len = Math.min(st.size, maxBytes);
150
+ const buf = Buffer.alloc(len);
151
+ fs.readSync(fd, buf, 0, len, 0);
152
+ // A NUL byte in the first 8KB is the classic binary tell.
153
+ const binary = buf.subarray(0, Math.min(buf.length, 8192)).includes(0);
154
+ return {
155
+ path: target,
156
+ name: path.basename(target),
157
+ size: st.size,
158
+ content: binary ? "" : buf.toString("utf8"),
159
+ truncated: st.size > len,
160
+ binary,
161
+ };
162
+ } finally {
163
+ fs.closeSync(fd);
164
+ }
165
+ }
@@ -0,0 +1,132 @@
1
+ // Hotwords: fixed + dynamic, both feeding the same place (the ASR request).
2
+ //
3
+ // DashScope gives two channels and we use them where each is strongest:
4
+ // * instant `vocabulary` ({"词": 权重}) on the streaming WebSocket — decoded-side bias,
5
+ // exactly the mechanism the operator named ("固定热词");
6
+ // * a system-message word list on the OpenAI-compatible batch route — the docs'
7
+ // "实体词表" context channel, because that surface has no vocabulary parameter.
8
+ //
9
+ // "根据上下文动态生成高频热词": the cockpit knows the real context — the session's own
10
+ // transcript (and the workspace names). We rank recent message text by term frequency
11
+ // with a cheap CJK+latin tokenizer, keep plausible terms (length, noise words, code
12
+ // identifiers), and merge them under the fixed list (fixed wins, and fixed entries may
13
+ // carry =weight, including DashScope's weight-50 super-hotwords).
14
+ import { getSettings } from "./settings.js";
15
+ import type { Store } from "./store/store.js";
16
+
17
+ export interface Hotword {
18
+ word: string;
19
+ weight: number;
20
+ origin: "fixed" | "dynamic";
21
+ }
22
+
23
+ /** Words that are frequent but useless as hotwords: they bias toward text that is
24
+ * already the default output. */
25
+ const STOP = new Set([
26
+ "的", "了", "是", "在", "我", "你", "他", "她", "它", "我们", "你们", "他们", "这", "那", "有", "没有",
27
+ "一个", "可以", "什么", "怎么", "这个", "那个", "就是", "然后", "因为", "所以", "如果", "但是",
28
+ "the", "and", "for", "with", "that", "this", "from", "have", "will", "your", "you", "are", "was",
29
+ "not", "but", "all", "can", "get", "out", "its", "into", "then", "than", "also", "use", "used",
30
+ "using", "useful", "here", "there", "when", "what", "which", "while", "please", "thanks",
31
+ ]);
32
+
33
+ /** Parse "词" or "词=4" (weight 1..5, or the super-hotword 50). */
34
+ export function parseHotword(raw: string): Hotword | null {
35
+ const t = raw.trim();
36
+ if (!t) return null;
37
+ const m = /^(.*?)(?:\s*=\s*(\d+))?$/s.exec(t);
38
+ const word = (m?.[1] ?? t).trim();
39
+ if (!word) return null;
40
+ let weight = Number(m?.[2] ?? 4);
41
+ if (!Number.isFinite(weight)) weight = 4;
42
+ weight = weight >= 50 ? 50 : Math.min(5, Math.max(1, Math.round(weight)));
43
+ return { word, weight, origin: "fixed" };
44
+ }
45
+
46
+ const WORD_RE = /[A-Za-z][A-Za-z0-9_+.-]{2,}|[一-龥]{2,8}/g;
47
+
48
+ /** Term-frequency over the operator's recent text, filtered and ranked. Returns at most
49
+ * `limit` terms; fixed hotwords always survive the cap separately. */
50
+ export function extractDynamicHotwords(texts: string[], limit = 20): Hotword[] {
51
+ const counts = new Map<string, { n: number; sample: string }>();
52
+ for (const text of texts) {
53
+ for (const m of text.matchAll(WORD_RE)) {
54
+ const raw = m[0];
55
+ const lower = raw.toLowerCase();
56
+ if (STOP.has(lower) || STOP.has(raw)) continue;
57
+ // ascii runs: require a bit of substance (a path segment, an identifier), not noise
58
+ if (/^[A-Za-z]/.test(raw)) {
59
+ if (raw.length > 24) continue;
60
+ // skip obvious code-only tokens like "return", file suffixes
61
+ if (/^[\w.-]*\.(ts|js|py|md|json|yaml|yml|sh|tsx|jsx)$/.test(lower)) continue;
62
+ } else if (raw.length > 8) {
63
+ continue;
64
+ }
65
+ const prev = counts.get(raw);
66
+ if (prev) prev.n += 1;
67
+ else counts.set(raw, { n: 1, sample: raw });
68
+ }
69
+ }
70
+ const ranked = [...counts.entries()]
71
+ .filter(([, v]) => v.n >= 2)
72
+ .sort((a, b) => b[1].n - a[1].n || a[0].localeCompare(b[0]))
73
+ .slice(0, limit);
74
+ return ranked.map(([word, v]) => ({
75
+ word,
76
+ weight: v.n >= 5 ? 5 : 4,
77
+ origin: "dynamic" as const,
78
+ }));
79
+ }
80
+
81
+ /** Recent transcript text to mine hotwords from: the given session first (its messages
82
+ * are the conversation the operator is dictating INTO), then the other sessions so a
83
+ * term used across the cockpit today also surfaces. */
84
+ export function contextTexts(store: Store, sessionId?: string, budgetChars = 24_000): string[] {
85
+ const out: string[] = [];
86
+ let used = 0;
87
+ const take = (rows: { kind: string; payload: unknown }[], cap: number): void => {
88
+ let n = 0;
89
+ for (const r of rows) {
90
+ if (n >= cap || used > budgetChars) break;
91
+ const p = r.payload as { text?: string; title?: string; content?: string };
92
+ const t = String(p?.text ?? p?.title ?? p?.content ?? "");
93
+ if (t.length > 1 && t.length < 4000) {
94
+ out.push(t);
95
+ used += t.length;
96
+ n += 1;
97
+ }
98
+ }
99
+ };
100
+ if (sessionId) take(store.messagesTail(sessionId, 60).messages, 40);
101
+ for (const s of store.listSessions(false)) {
102
+ if (s.id === sessionId) continue;
103
+ take(store.messagesTail(s.id, 12).messages, 8);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** The full list an ASR request should carry: fixed first (they may use =50), dynamic
109
+ * filling the rest up to the configured limit. */
110
+ export function hotwordsFor(store: Store, sessionId?: string): Hotword[] {
111
+ const v = getSettings().voice;
112
+ const fixed = v.hotwords.map(parseHotword).filter((h): h is Hotword => Boolean(h));
113
+ const seen = new Set(fixed.map((h) => h.word));
114
+ const limit = Math.max(0, Math.min(200, v.hotwordLimit));
115
+ const out: Hotword[] = fixed.slice(0, limit);
116
+ if (v.dynamicHotwords) {
117
+ const dyn = extractDynamicHotwords(contextTexts(store, sessionId), Math.max(0, limit - out.length));
118
+ for (const h of dyn) {
119
+ if (seen.has(h.word)) continue;
120
+ seen.add(h.word);
121
+ out.push(h);
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** DashScope's instant-hotword payload: {词: 权重}. */
128
+ export function vocabularyOf(words: Hotword[]): Record<string, number> {
129
+ const out: Record<string, number> = {};
130
+ for (const h of words) out[h.word] = h.weight;
131
+ return out;
132
+ }