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,469 @@
1
+ // Operator settings: one row per section in the store's `settings` table, secrets never
2
+ // echoed back.
3
+ //
4
+ // Why the DB and not just env: the voice / theme / call / prefs features are configurable
5
+ // from the UI, and a restart (or another device) must not lose the operator's choices. Env
6
+ // stays the *bootstrap*: DASHSCOPE_API_KEY / DASHSCOPE_BASE_URL are read from the process
7
+ // env and — because the server is usually started from a plain shell, not from Hermes —
8
+ // from the .env files Hermes itself reads (~/.hermes/.env, the test home).
9
+ //
10
+ // Why not localStorage (the old home of the prefs section): a setting that describes how
11
+ // THE OPERATOR works — read replies aloud, which voice, which recogniser, how the call
12
+ // behaves — has to follow them from the phone to the laptop. localStorage already proved the
13
+ // point by disagreeing with itself across two tabs of the same instance. The browser keeps a
14
+ // copy for the first frame; the store is the truth.
15
+ //
16
+ // Secret handling: values live in the 0600 store and only ever leave through mask() —
17
+ // "…abcd". The browser PUTs a full key only when the operator types one; an unchanged
18
+ // masked value round-trips as null and keeps the stored secret.
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import type { Store } from "./store/store.js";
22
+
23
+ export interface VoiceSettings {
24
+ provider: "browser" | "openai" | "dashscope";
25
+ /** base URL. dashscope: the MaaS root (…/compatible-mode stripped) or the full
26
+ * compatible-mode URL — both accepted, normalised on use. openai: the /v1 base. */
27
+ baseUrl: string;
28
+ apiKey: string;
29
+ /** speech-to-text. The two defaults are DashScope-specific names; any string is
30
+ * allowed because the dropdown is filled from the endpoint's own model list. */
31
+ asrModel: string;
32
+ /** batch/fallback ASR model (also used when the streaming model is unavailable) */
33
+ asrBatchModel: string;
34
+ /** use the WebSocket streaming pipeline when the provider supports it */
35
+ asrStream: boolean;
36
+ /** speech-synthesis model + voice (voice is provider-specific; longanhuan_v3.6 is
37
+ * 龙安欢 on qwen-audio-3.0-tts-flash) */
38
+ ttsModel: string;
39
+ ttsVoice: string;
40
+ ttsFormat: "wav" | "mp3";
41
+ /** fixed hotwords, each "词" or "词=权重" (weight 1..5, 50 = super-hotword) */
42
+ hotwords: string[];
43
+ /** draw extra hotwords from recent transcript text (context enhancement) */
44
+ dynamicHotwords: boolean;
45
+ /** max hotwords sent per request */
46
+ hotwordLimit: number;
47
+ }
48
+
49
+ /** Theme is a config item too (the operator asked for light/dark/follow-system), and it
50
+ * lives here so the choice survives a browser and a device: the page paints from
51
+ * localStorage for the first frame, then adopts this. */
52
+ export interface ThemeSettings {
53
+ mode: "system" | "light" | "dark";
54
+ /** empty = the built-in accent; otherwise a #hex colour */
55
+ accent: string;
56
+ }
57
+
58
+ export const THEME_DEFAULT: ThemeSettings = { mode: "system", accent: "" };
59
+
60
+ /** Call-mode tuning. These are operator-visible because they are environment-dependent —
61
+ * what a phone's own loudspeaker leaks back into its microphone is nothing like a headset
62
+ * in a quiet room — so the defaults are a starting point, not a truth. The call page owns
63
+ * the panel; the settings page reads the same numbers. */
64
+ export type InterruptMode = "voice" | "button";
65
+
66
+ export interface CallSettings {
67
+ /** WHO gets to interrupt the agent, and the operator picks:
68
+ * - `voice` — the operator's own voice crossing the barge threshold (hands-free);
69
+ * - `button` — only the orb, i.e. a deliberate press.
70
+ * Both exist because the room decides: a phone on a table leaks its own loudspeaker straight
71
+ * into its own microphone, so a hands-free call there interrupts itself all the time. The orb
72
+ * tap always works in both modes — it is the one control that means "the floor changes hands". */
73
+ interruptMode: InterruptMode;
74
+ /** HOW EASY it is to talk over the reply, 0..100 (high = easy). Stored as a sensitivity,
75
+ * not as a mic level: the panel says "灵敏度 60%" and the number has to mean that. The
76
+ * threshold it maps to lives in the client (callSettings.ts `bargeLevelOf`), because the
77
+ * mic scale is the client's business. */
78
+ bargeSensitivity: number;
79
+ /** how long that level has to hold before the floor changes hands (ms) */
80
+ bargeMs: number;
81
+ /** silence that ends the operator's sentence (ms) */
82
+ silenceMs: number;
83
+ /** an AUTOMATIC send shorter than this many characters is dropped instead of being sent
84
+ * as a turn (1 = no limit). A tap on the orb always sends what is there — that gesture
85
+ * is an explicit instruction, not a guess about where a sentence ended. */
86
+ minChars: number;
87
+ }
88
+
89
+ /** How the operator wants to TALK to the agent. These were browser-local (localStorage) and
90
+ * are now the operator's settings like everything else: the read-aloud switch, the voice and
91
+ * the speed have to be the same on the phone as on the laptop, and a second tab disagreeing
92
+ * with the first is a bug, not a feature. */
93
+ export interface PrefsSettings {
94
+ /** read a finished reply aloud without being asked */
95
+ autoRead: boolean;
96
+ /** browser voiceURI ("" = pick the best for the language) */
97
+ voiceURI: string;
98
+ rate: number;
99
+ /** BCP-47 for both directions; "" = whatever the browser is set to */
100
+ lang: string;
101
+ /** route synthesis through the server endpoint instead of the browser */
102
+ serverTts: boolean;
103
+ /** which recogniser to use, when the caller does not force one */
104
+ stt: "auto" | "browser" | "stream" | "server";
105
+ }
106
+
107
+ export const PREFS_DEFAULT: PrefsSettings = {
108
+ autoRead: false, voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
109
+ };
110
+
111
+ export const CALL_DEFAULT: CallSettings = {
112
+ interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1200, minChars: 3,
113
+ };
114
+
115
+ export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
116
+
117
+ /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
118
+ * outside both the range table and the loops that validate numbers. */
119
+ export const CALL_NUMERIC = ["bargeSensitivity", "bargeMs", "silenceMs", "minChars"] as const;
120
+ export type CallNumeric = (typeof CALL_NUMERIC)[number];
121
+
122
+ /** What this section shipped with before the sensitivity rename. A stored section that is still
123
+ * EXACTLY this is a section nobody ever touched — the operator should get the new defaults
124
+ * rather than a fossil of the old ones (the live instance had 最少字数 1 stored simply because
125
+ * 1 used to be the default). One deliberate change to any knob and the section is left alone. */
126
+ /** True when every knob still sits at a shipped value — either generation: the pre-rename row
127
+ * had `bargeLevel`, and a row that has already been through pickCall has no such key at all. */
128
+ function callAtShippedDefaults(r: Record<string, unknown>): boolean {
129
+ const sens = r.bargeSensitivity === undefined ? Number(r.bargeLevel) === 0.2 : Number(r.bargeSensitivity) === 60;
130
+ // The mode counts too: a row that only says `interruptMode: "button"` IS a deliberate choice and
131
+ // must not be reset along with a section that happens to still carry the old numbers.
132
+ const modeShipped = r.interruptMode === undefined || r.interruptMode === CALL_DEFAULT.interruptMode;
133
+ return sens && modeShipped && Number(r.bargeMs) === 300 && Number(r.silenceMs) === 1200 && Number(r.minChars) === 1;
134
+ }
135
+
136
+ /** What each knob may be. The ranges are the panel's slider ends; the server is the one
137
+ * that enforces them, so a hand-written request cannot store a nonsense value. */
138
+ export const CALL_RANGE: Record<CallNumeric, [number, number]> = {
139
+ bargeSensitivity: [0, 100],
140
+ bargeMs: [100, 1000],
141
+ silenceMs: [400, 4000],
142
+ minChars: [1, 20],
143
+ };
144
+
145
+ export interface Settings {
146
+ voice: VoiceSettings;
147
+ theme: ThemeSettings;
148
+ call: CallSettings;
149
+ prefs: PrefsSettings;
150
+ updatedAt: number;
151
+ }
152
+
153
+ const DEFAULTS: VoiceSettings = {
154
+ provider: "dashscope",
155
+ baseUrl: "",
156
+ apiKey: "",
157
+ asrModel: "qwen-audio-3.1-asr-flash-streaming",
158
+ asrBatchModel: "qwen3-asr-flash",
159
+ asrStream: true,
160
+ ttsModel: "qwen-audio-3.0-tts-flash",
161
+ ttsVoice: "longanhuan_v3.6",
162
+ // mp3, not wav: measured on a real endpoint, the same 84-character reply is 664 KB of wav
163
+ // versus 193 KB of mp3 (and 154 KB vs 37 KB for one sentence). The operator is often on a
164
+ // phone or through a tunnel, where those bytes are the wait. Still a setting: wav is one
165
+ // click away for anyone who wants it.
166
+ ttsFormat: "mp3",
167
+ hotwords: [],
168
+ dynamicHotwords: true,
169
+ hotwordLimit: 30,
170
+ };
171
+
172
+ let db: Store | null = null;
173
+ let legacyFile = "";
174
+ let cache: Settings = {
175
+ voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
176
+ prefs: { ...PREFS_DEFAULT }, updatedAt: 0,
177
+ };
178
+ /** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
179
+ let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
180
+
181
+ function readEnvFile(p: string): Record<string, string> {
182
+ const out: Record<string, string> = {};
183
+ try {
184
+ for (const line of fs.readFileSync(p, "utf8").split("\n")) {
185
+ const t = line.trim();
186
+ if (!t || t.startsWith("#") || !t.includes("=")) continue;
187
+ const i = t.indexOf("=");
188
+ out[t.slice(0, i)] = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
189
+ }
190
+ } catch { /* missing/unreadable: no env file */ }
191
+ return out;
192
+ }
193
+
194
+ function discoverEnv(): { apiKey: string; baseUrl: string; source: string } | null {
195
+ const sources: { env: Record<string, string | undefined>; label: string }[] = [
196
+ { env: process.env, label: "process env" },
197
+ ];
198
+ // The credentials live in the agent's own home — the cockpit reads the same .env its slots do.
199
+ // AGENTUS_HERMES_HOME can move that fallback to whatever home this box really uses.
200
+ const cands = [path.join(process.env.HOME ?? "", ".hermes", ".env")];
201
+ if (process.env.AGENTUS_HERMES_HOME) {
202
+ cands.push(path.join(process.env.AGENTUS_HERMES_HOME, ".env"));
203
+ }
204
+ for (const p of cands) {
205
+ if (fs.existsSync(p)) sources.push({ env: readEnvFile(p), label: p });
206
+ }
207
+ for (const s of sources) {
208
+ const apiKey = s.env.DASHSCOPE_API_KEY ?? "";
209
+ const baseUrl = s.env.DASHSCOPE_BASE_URL ?? "";
210
+ if (apiKey && baseUrl) return { apiKey, baseUrl, source: s.label };
211
+ }
212
+ return null;
213
+ }
214
+
215
+ export function initSettings(dataDir: string, store: Store): Settings {
216
+ db = store;
217
+ legacyFile = path.join(dataDir, "settings.json");
218
+ envCreds = discoverEnv();
219
+ const stored: Partial<Settings> = { ...(store.listSettings() as Partial<Settings>) };
220
+ if (!stored.voice && !stored.theme && !stored.call) {
221
+ // first run on this DB: adopt whatever the pre-store JSON file held, once. The file is
222
+ // left in place (renamed) rather than deleted — it is the only copy of a key that the
223
+ // operator may have typed by hand.
224
+ try {
225
+ const fromFile = JSON.parse(fs.readFileSync(legacyFile, "utf8")) as Partial<Settings>;
226
+ if (fromFile.voice || fromFile.theme || fromFile.call) {
227
+ stored.voice = fromFile.voice;
228
+ stored.theme = fromFile.theme;
229
+ stored.call = fromFile.call;
230
+ console.log(`[agentus] settings: imported ${legacyFile} into the store`);
231
+ }
232
+ fs.renameSync(legacyFile, `${legacyFile}.imported`);
233
+ } catch { /* no legacy file: nothing to migrate */ }
234
+ }
235
+ cache = {
236
+ voice: { ...DEFAULTS, ...(stored.voice ?? {}) },
237
+ theme: { ...THEME_DEFAULT, ...(stored.theme ?? {}) },
238
+ call: pickCall(stored.call),
239
+ prefs: pickPrefs(stored.prefs),
240
+ updatedAt: Number((stored as Settings).updatedAt ?? 0),
241
+ };
242
+ // an unset baseUrl/apiKey bootstrap from the environment: the operator said the
243
+ // Bailian endpoint lives in their .env, so the page must work before they type it
244
+ if (!cache.voice.baseUrl && envCreds) cache.voice.baseUrl = envCreds.baseUrl;
245
+ if (!cache.voice.apiKey && envCreds) cache.voice.apiKey = envCreds.apiKey;
246
+ persist();
247
+ return cache;
248
+ }
249
+
250
+ /** A stored section is not trusted: it may predate a rename (the call knobs replaced
251
+ * `bargeLevel` with `bargeSensitivity`) or carry keys nothing understands any more. Anything
252
+ * unrecognised or out of range is dropped, so the payload the UI reads has exactly the
253
+ * fields it knows about. */
254
+ function pickCall(raw: unknown): CallSettings {
255
+ const out = { ...CALL_DEFAULT };
256
+ if (raw && typeof raw === "object") {
257
+ if (callAtShippedDefaults(raw as Record<string, unknown>)) return out;
258
+ const mode = (raw as Record<string, unknown>).interruptMode;
259
+ if (INTERRUPT_MODES.includes(mode as InterruptMode)) out.interruptMode = mode as InterruptMode;
260
+ for (const k of CALL_NUMERIC) {
261
+ const v = Number((raw as Record<string, unknown>)[k]);
262
+ const [lo, hi] = CALL_RANGE[k];
263
+ if (Number.isFinite(v) && v >= lo && v <= hi) out[k] = v;
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ function pickPrefs(raw: unknown): PrefsSettings {
270
+ const out = { ...PREFS_DEFAULT };
271
+ if (raw && typeof raw === "object") {
272
+ const r = raw as Record<string, unknown>;
273
+ if (typeof r.autoRead === "boolean") out.autoRead = r.autoRead;
274
+ if (typeof r.serverTts === "boolean") out.serverTts = r.serverTts;
275
+ if (typeof r.voiceURI === "string" && r.voiceURI.length <= 200) out.voiceURI = r.voiceURI;
276
+ if (typeof r.lang === "string" && (!r.lang || /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(r.lang))) out.lang = r.lang;
277
+ const rate = Number(r.rate);
278
+ if (Number.isFinite(rate) && rate >= 0.5 && rate <= 2) out.rate = rate;
279
+ if (r.stt === "auto" || r.stt === "browser" || r.stt === "stream" || r.stt === "server") out.stt = r.stt;
280
+ }
281
+ return out;
282
+ }
283
+
284
+ /** One row per section, so a change to the call knobs cannot lose the speech key. */
285
+ function persist(): void {
286
+ if (!db) return;
287
+ try {
288
+ db.setSetting("voice", cache.voice);
289
+ db.setSetting("theme", cache.theme);
290
+ db.setSetting("call", cache.call);
291
+ db.setSetting("prefs", cache.prefs);
292
+ } catch (e) {
293
+ console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
294
+ }
295
+ }
296
+
297
+ export function getSettings(): Settings {
298
+ return cache;
299
+ }
300
+
301
+ export function mask(secret: string): string {
302
+ if (!secret) return "";
303
+ return `••••${secret.slice(-4)}`;
304
+ }
305
+
306
+ /** What the settings page may see: everything except raw secrets. */
307
+ export function publicSettings(): Record<string, unknown> {
308
+ const v = cache.voice;
309
+ return {
310
+ provider: v.provider,
311
+ baseUrl: v.baseUrl,
312
+ apiKeySet: Boolean(v.apiKey),
313
+ apiKeyMasked: mask(v.apiKey),
314
+ apiKeySource: envCreds?.source ?? null,
315
+ theme: cache.theme,
316
+ themeDefaults: THEME_DEFAULT,
317
+ call: cache.call,
318
+ callDefaults: CALL_DEFAULT,
319
+ callRange: CALL_RANGE,
320
+ // The 百炼 system voices shipped with qwen-audio-3.0-tts-flash. A dropdown cannot be
321
+ // complete (tenants add voices), so the page also accepts a typed id.
322
+ ttsVoices: ["longanhuan_v3.6", "longjielidou_v3.6", "loongeva_v3.6", "loongjohn"],
323
+ asrModel: v.asrModel,
324
+ asrBatchModel: v.asrBatchModel,
325
+ asrStream: v.asrStream,
326
+ ttsModel: v.ttsModel,
327
+ ttsVoice: v.ttsVoice,
328
+ ttsFormat: v.ttsFormat,
329
+ hotwords: v.hotwords,
330
+ dynamicHotwords: v.dynamicHotwords,
331
+ hotwordLimit: v.hotwordLimit,
332
+ prefs: cache.prefs,
333
+ prefsDefaults: PREFS_DEFAULT,
334
+ // the defaults, so the UI can offer "reset to Bailian defaults"
335
+ defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
336
+ updatedAt: cache.updatedAt,
337
+ };
338
+ }
339
+
340
+ /** Merge a partial update. `apiKey: null` keeps the stored key (the UI round-trips a
341
+ * mask, never the secret). Empty strings clear. Values are validated narrowly: this
342
+ * config reaches an external API with the operator's credentials. */
343
+ export function saveSettings(patch: Record<string, unknown>): Settings {
344
+ const v = { ...cache.voice };
345
+ const pick = <K extends keyof VoiceSettings>(k: K, ok: (x: unknown) => x is VoiceSettings[K]): boolean => {
346
+ if (!(k in patch)) return false;
347
+ const raw = patch[k];
348
+ if (!ok(raw)) throw new Error(`invalid ${String(k)}: ${JSON.stringify(raw)?.slice(0, 60)}`);
349
+ v[k] = raw;
350
+ return true;
351
+ };
352
+ pick("provider", (x): x is VoiceSettings["provider"] => ["browser", "openai", "dashscope"].includes(String(x)));
353
+ pick("baseUrl", (x): x is string => typeof x === "string" && (x === "" || /^https?:\/\//.test(x)));
354
+ pick("asrModel", (x): x is string => typeof x === "string" && x.length <= 100);
355
+ pick("asrBatchModel", (x): x is string => typeof x === "string" && x.length <= 100);
356
+ pick("asrStream", (x): x is boolean => typeof x === "boolean");
357
+ pick("ttsModel", (x): x is string => typeof x === "string" && x.length <= 100);
358
+ pick("ttsVoice", (x): x is string => typeof x === "string" && x.length <= 100);
359
+ pick("ttsFormat", (x): x is "wav" | "mp3" => x === "wav" || x === "mp3");
360
+ pick("dynamicHotwords", (x): x is boolean => typeof x === "boolean");
361
+ pick("hotwordLimit", (x): x is number => typeof x === "number" && x >= 0 && x <= 200);
362
+ pick("hotwords", (x): x is string[] => Array.isArray(x) && x.every((i) => typeof i === "string" && i.length <= 60));
363
+ if ("apiKey" in patch) {
364
+ const raw = patch.apiKey;
365
+ if (raw === null || raw === undefined) { /* keep */ }
366
+ else if (typeof raw === "string" && (raw === "" || /^sk-[\w.-]{6,}$/.test(raw))) v.apiKey = raw;
367
+ else if (typeof raw === "string" && raw.startsWith("••••")) { /* unchanged mask: keep */ }
368
+ else throw new Error("invalid apiKey (expected sk-…, or empty to clear)");
369
+ }
370
+ // a baseUrl typed as the compatible-mode URL still works: store the root for the
371
+ // native endpoints and derive the compatible path from it
372
+ cache = { ...cache, voice: v, updatedAt: Date.now() };
373
+ persist();
374
+ return cache;
375
+ }
376
+
377
+ /** Theme updates are separate from voice: a bad accent must not be able to lock an
378
+ * operator out of the page that would fix it — mode is an enum, accent is a hex colour. */
379
+ export function saveTheme(patch: Record<string, unknown>): Settings {
380
+ const t = { ...cache.theme };
381
+ if ("mode" in patch) {
382
+ const mode = String(patch.mode ?? "");
383
+ if (!["system", "light", "dark"].includes(mode)) throw new Error(`invalid theme mode: ${JSON.stringify(patch.mode)}`);
384
+ t.mode = mode as ThemeSettings["mode"];
385
+ }
386
+ if ("accent" in patch) {
387
+ const accent = String(patch.accent ?? "");
388
+ if (accent && !/^#[0-9a-fA-F]{3,8}$/.test(accent)) throw new Error("invalid accent (expected #hex, or empty)");
389
+ t.accent = accent;
390
+ }
391
+ cache = { ...cache, theme: t, updatedAt: Date.now() };
392
+ persist();
393
+ return cache;
394
+ }
395
+
396
+ /** Call tuning is a separate update for the same reason theme is: a bad number here must
397
+ * not be able to lock the operator out of the page that would fix it. Every key is checked
398
+ * against CALL_RANGE before it is stored. */
399
+ export function saveCall(patch: Record<string, unknown>): Settings {
400
+ const c = { ...cache.call };
401
+ if ("interruptMode" in patch) {
402
+ const mode = patch.interruptMode;
403
+ if (!INTERRUPT_MODES.includes(mode as InterruptMode)) {
404
+ throw new Error(`invalid interruptMode: ${JSON.stringify(mode)} (expected voice|button)`);
405
+ }
406
+ c.interruptMode = mode as InterruptMode;
407
+ }
408
+ for (const key of CALL_NUMERIC) {
409
+ if (!(key in patch)) continue;
410
+ const raw = typeof patch[key] === "number" ? patch[key] as number : Number(patch[key]);
411
+ const [lo, hi] = CALL_RANGE[key];
412
+ if (!Number.isFinite(raw) || raw < lo || raw > hi) {
413
+ throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi})`);
414
+ }
415
+ c[key] = raw;
416
+ }
417
+ cache = { ...cache, call: c, updatedAt: Date.now() };
418
+ persist();
419
+ return cache;
420
+ }
421
+
422
+ /** The talk-to-the-agent preferences. Every key is checked here rather than trusted: this
423
+ * set reaches a speech endpoint, and `rate` in particular feeds an audio API. */
424
+ export function savePrefs(patch: Record<string, unknown>): Settings {
425
+ const next = { ...cache.prefs };
426
+ if ("autoRead" in patch) {
427
+ if (typeof patch.autoRead !== "boolean") throw new Error("invalid autoRead: expected true/false");
428
+ next.autoRead = patch.autoRead;
429
+ }
430
+ if ("serverTts" in patch) {
431
+ if (typeof patch.serverTts !== "boolean") throw new Error("invalid serverTts: expected true/false");
432
+ next.serverTts = patch.serverTts;
433
+ }
434
+ if ("voiceURI" in patch) {
435
+ const v = String(patch.voiceURI ?? "");
436
+ if (v.length > 200) throw new Error("invalid voiceURI: too long");
437
+ next.voiceURI = v;
438
+ }
439
+ if ("lang" in patch) {
440
+ const v = String(patch.lang ?? "");
441
+ if (v && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(v)) throw new Error(`invalid lang: ${JSON.stringify(v).slice(0, 40)}`);
442
+ next.lang = v;
443
+ }
444
+ if ("rate" in patch) {
445
+ const v = Number(patch.rate);
446
+ if (!Number.isFinite(v) || v < 0.5 || v > 2) throw new Error(`invalid rate: ${JSON.stringify(patch.rate)} (expected 0.5…2)`);
447
+ next.rate = v;
448
+ }
449
+ if ("stt" in patch) {
450
+ const v = String(patch.stt ?? "");
451
+ if (!["auto", "browser", "stream", "server"].includes(v)) throw new Error(`invalid stt: ${JSON.stringify(patch.stt)}`);
452
+ next.stt = v as PrefsSettings["stt"];
453
+ }
454
+ cache = { ...cache, prefs: next, updatedAt: Date.now() };
455
+ persist();
456
+ return cache;
457
+ }
458
+
459
+ /** Normalise the two URL shapes operators paste: MaaS root or .../compatible-mode/v1. */
460
+ export function dashscopeUrls(baseUrl: string): { root: string; compat: string; ws: string } {
461
+ const trimmed = baseUrl.replace(/\/+$/, "");
462
+ const compatIdx = trimmed.indexOf("/compatible-mode");
463
+ const root = compatIdx >= 0 ? trimmed.slice(0, compatIdx) : trimmed;
464
+ return {
465
+ root,
466
+ compat: compatIdx >= 0 ? trimmed : `${root}/compatible-mode/v1`,
467
+ ws: trimmed.replace(/^http/, "ws").split("/compatible-mode")[0] + "/api-ws/v1/inference",
468
+ };
469
+ }
@@ -0,0 +1,209 @@
1
+ // Session export. ACP has no notion of "export" (verified against the SDK types and
2
+ // AionUi: their export is a conversation-layer feature over their OWN storage, exactly
3
+ // like ours — the protocol only carries the stream). So this renderer works purely from
4
+ // what we persisted: `messages` rows whose payloads are raw ACP update fragments.
5
+ //
6
+ // The fold here mirrors packages/web's ingest rules so the exported transcript reads
7
+ // like the cockpit: consecutive agent/thought chunks join into one paragraph, a user
8
+ // row breaks the paragraph, tool rows are discrete entries keyed by toolCallId.
9
+ import type { StoredMessage } from "../../../shared/src/index.js";
10
+
11
+ export interface ExportSessionHeader {
12
+ id: string;
13
+ title: string;
14
+ backend: string;
15
+ cwd: string;
16
+ workspace?: string | null;
17
+ acpSessionId: string | null;
18
+ createdAt: number;
19
+ }
20
+
21
+ function ts(at: number): string {
22
+ return new Date(at).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
23
+ }
24
+
25
+ function contentText(payload: Record<string, unknown>): string {
26
+ // user rows carry OUR shape ({ text }); agent/thought rows carry the raw ACP
27
+ // update fragment ({ content: { type: "text", text } }). Accept both.
28
+ if (typeof payload.text === "string" && payload.text) return payload.text;
29
+ const c = payload.content as { type?: string; text?: string } | undefined;
30
+ if (c?.type === "text" && typeof c.text === "string") return c.text;
31
+ return "";
32
+ }
33
+
34
+ /** Tool result as text — same shape the cockpit bubble uses, but an archive should not
35
+ * lose bytes to a UI cap, so only extreme rows get clipped (marked, never silent). */
36
+ function toolDetail(payload: Record<string, unknown>): string {
37
+ const CAP = 20000;
38
+ const clip = (s: string): string => (s.length > CAP ? `${s.slice(0, CAP)}\n… [truncated, ${s.length} chars total]` : s);
39
+ const raw = payload.rawOutput;
40
+ if (typeof raw === "string" && raw.trim()) return clip(raw);
41
+ if (raw && typeof raw === "object") {
42
+ try {
43
+ return clip(JSON.stringify(raw, null, 1));
44
+ } catch {
45
+ /* non-serialisable — fall through to content items */
46
+ }
47
+ }
48
+ const items = payload.content;
49
+ if (Array.isArray(items)) {
50
+ const parts: string[] = [];
51
+ for (const it of items as Record<string, unknown>[]) {
52
+ if (it?.type === "content") {
53
+ const inner = it.content as { type?: string; text?: string } | undefined;
54
+ if (inner?.text) parts.push(inner.text);
55
+ } else if (it?.type === "diff") {
56
+ parts.push(`diff ${String(it.path ?? "?")}`);
57
+ } else if (typeof it?.text === "string") {
58
+ parts.push(it.text as string);
59
+ }
60
+ }
61
+ if (parts.length) return clip(parts.join("\n"));
62
+ }
63
+ return "";
64
+ }
65
+
66
+ function toolInput(payload: Record<string, unknown>): string {
67
+ const raw = payload.rawInput;
68
+ if (typeof raw === "string") return raw;
69
+ if (raw && typeof raw === "object") {
70
+ try {
71
+ return JSON.stringify(raw);
72
+ } catch {
73
+ return "";
74
+ }
75
+ }
76
+ return "";
77
+ }
78
+
79
+ type Group =
80
+ | { kind: "user" | "agent" | "thought"; text: string; at: number }
81
+ | { kind: "tool"; payload: Record<string, unknown>; at: number; status: string }
82
+ | { kind: "plan"; entries: unknown[]; at: number };
83
+
84
+ /** fold stored rows into read-order groups (chunk rows merge; tool rows are discrete) */
85
+ export function foldMessages(messages: StoredMessage[]): Group[] {
86
+ const groups: Group[] = [];
87
+ const pushText = (kind: "user" | "agent" | "thought", text: string, at: number, mergeable: boolean): void => {
88
+ const last = groups[groups.length - 1];
89
+ if (mergeable && last && last.kind === kind) {
90
+ // consecutive agent/thought chunks join (a user/tool/plan row breaks them because
91
+ // it pushed a different group on top)
92
+ (last as Extract<Group, { text: string }>).text += text;
93
+ return;
94
+ }
95
+ groups.push({ kind, text, at });
96
+ };
97
+ for (const m of messages) {
98
+ const p = (m.payload ?? {}) as Record<string, unknown>;
99
+ switch (m.kind) {
100
+ case "user": {
101
+ const text = contentText(p);
102
+ if (text) pushText("user", text, m.createdAt, false);
103
+ break;
104
+ }
105
+ case "agent": {
106
+ const text = contentText(p);
107
+ // merge into a still-open agent run; a row that carries no text is skipped
108
+ if (text) pushText("agent", text, m.createdAt, true);
109
+ break;
110
+ }
111
+ case "thought": {
112
+ const text = contentText(p);
113
+ if (text) pushText("thought", text, m.createdAt, true);
114
+ break;
115
+ }
116
+ case "tool": {
117
+ const last = groups[groups.length - 1];
118
+ const sameTool =
119
+ last && last.kind === "tool" &&
120
+ String(last.payload.toolCallId ?? m.toolCallId ?? "") === String(p.toolCallId ?? m.toolCallId ?? "");
121
+ if (sameTool) {
122
+ (last as Extract<Group, { payload: unknown }>).payload = p; // upserted row = latest wins
123
+ (last as Extract<Group, { at: number }>).at = m.createdAt;
124
+ break;
125
+ }
126
+ groups.push({ kind: "tool", payload: p, at: m.createdAt, status: String(p.status ?? "") });
127
+ break;
128
+ }
129
+ case "plan": {
130
+ groups.push({ kind: "plan", entries: Array.isArray(p.entries) ? p.entries : [], at: m.createdAt });
131
+ break;
132
+ }
133
+ default:
134
+ break; // meta/unknown kinds are not part of the transcript
135
+ }
136
+ }
137
+ // merge adjacent same-kind agent/thought groups too (user rows interleaved as separate
138
+ // groups keep the Q/A order intact)
139
+ const merged: Group[] = [];
140
+ for (const g of groups) {
141
+ const prev = merged[merged.length - 1];
142
+ if (prev && (g.kind === "agent" || g.kind === "thought") && prev.kind === g.kind) {
143
+ (prev as Extract<Group, { text: string }>).text += (g as Extract<Group, { text: string }>).text;
144
+ } else merged.push(g);
145
+ }
146
+ return merged;
147
+ }
148
+
149
+ const LABEL: Record<string, string> = { user: "User", agent: "Agent", thought: "Thought" };
150
+
151
+ export function renderMarkdown(session: ExportSessionHeader, messages: StoredMessage[]): string {
152
+ const out: string[] = [];
153
+ out.push(`# ${session.title || "session"}`, "");
154
+ out.push(`- backend: ${session.backend}`);
155
+ out.push(`- session id: ${session.id}${session.acpSessionId ? ` (agent side: ${session.acpSessionId})` : ""}`);
156
+ out.push(`- working directory: ${session.workspace || session.cwd}`);
157
+ out.push(`- created: ${ts(session.createdAt)} · exported: ${ts(Date.now())}`);
158
+ out.push("");
159
+ for (const g of foldMessages(messages)) {
160
+ if (g.kind === "user" || g.kind === "agent" || g.kind === "thought") {
161
+ out.push(`### ${LABEL[g.kind]} · ${ts(g.at)}`, "", (g as { text: string }).text.trim(), "");
162
+ } else if (g.kind === "tool") {
163
+ const p = g.payload as Record<string, unknown>;
164
+ const title = String(p.title ?? "tool call");
165
+ const status = String(p.status ?? "");
166
+ out.push(`### Tool · ${title}${status ? ` (${status})` : ""} · ${ts(g.at)}`);
167
+ const input = toolInput(p);
168
+ if (input) out.push("", `Input: \`\`\`${input.length < 200 ? " " : "\n"}${input}\`\`\``, "");
169
+ const detail = toolDetail(p);
170
+ if (detail) out.push("```", detail, "```", "");
171
+ } else if (g.kind === "plan") {
172
+ out.push(`### Plan · ${ts(g.at)}`);
173
+ for (const e of g.entries as { content?: string; status?: string }[]) {
174
+ out.push(`- [${e.status ?? "?"}] ${e.content ?? ""}`);
175
+ }
176
+ out.push("");
177
+ }
178
+ }
179
+ if (out.length <= 7) out.push("_(这个会话还没有消息记录。)_", "");
180
+ return out.join("\n");
181
+ }
182
+
183
+ export function renderJson(session: ExportSessionHeader, messages: StoredMessage[]): string {
184
+ return JSON.stringify(
185
+ {
186
+ format: "agentus-session/1",
187
+ exportedAt: new Date().toISOString(),
188
+ session,
189
+ // lossless: every stored row with its raw payload, seq order preserved
190
+ messages: messages.map((m) => ({
191
+ seq: m.seq, kind: m.kind, createdAt: m.createdAt,
192
+ toolCallId: m.toolCallId ?? null, payload: m.payload,
193
+ })),
194
+ },
195
+ null,
196
+ 2,
197
+ );
198
+ }
199
+
200
+ /** filename-safe slug from the title (never trust a display string in a header) */
201
+ export function exportFilename(title: string, ext: string): string {
202
+ const slug = (title || "session")
203
+ .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ")
204
+ .trim()
205
+ .replace(/\s+/g, "-")
206
+ .slice(0, 60)
207
+ .replace(/^-+|-+$/g, "");
208
+ return `${slug || "session"}.${ext}`;
209
+ }