agentus 0.4.3 → 0.4.5
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/package.json +3 -2
- package/packages/server/mock/agent.mjs +29 -8
- package/packages/server/package.json +2 -1
- package/packages/server/src/acp/session-manager.ts +33 -1
- package/packages/server/src/dashscope.ts +82 -0
- package/packages/server/src/index.ts +188 -7
- package/packages/server/src/notify/center.ts +127 -7
- package/packages/server/src/notify/webpush.ts +295 -0
- package/packages/server/src/observer/hub.ts +241 -19
- package/packages/server/src/settings.ts +159 -2
- package/packages/server/src/speech/route.ts +181 -0
- package/packages/server/src/store/store.ts +57 -0
- package/packages/shared/src/index.ts +5 -1
- package/packages/web/dist/assets/index-1rw3zpcN.css +32 -0
- package/packages/web/dist/assets/index-BxGj4INU.js +125 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +44 -0
- package/packages/web/dist/assets/index-CdQ43EDJ.css +0 -32
- package/packages/web/dist/assets/index-DzCpHPZV.js +0 -125
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@agentclientprotocol/sdk": "1.5.1",
|
|
32
32
|
"tsx": "^4.19.0",
|
|
33
|
-
"ws": "^8.22.0"
|
|
33
|
+
"ws": "^8.22.0",
|
|
34
|
+
"yaml": "^2.9.1"
|
|
34
35
|
},
|
|
35
36
|
"workspaces": [
|
|
36
37
|
"packages/*"
|
|
@@ -416,18 +416,39 @@ const agent = () => ({
|
|
|
416
416
|
// per-turn slow mode: long stream so QA can drop the socket mid-turn (AC6)
|
|
417
417
|
const slow = /\[slow\]/.test(text) ? Math.max(SLOW, 900) : SLOW;
|
|
418
418
|
|
|
419
|
+
// `[hold:<ms>]` — a turn that is IN FLIGHT but SILENT for that long (issue #24's heartbeat
|
|
420
|
+
// fixture). No frames at all, so 「有任务在跑 + 没有动静」 is exactly the state the ticker must
|
|
421
|
+
// speak on — and the state a `[slow]` stream can never reproduce (it emits every 900 ms).
|
|
422
|
+
const hold = Number((/\[hold:(\d+)\]/.exec(text) || [])[1] || 0);
|
|
423
|
+
if (hold > 0) await sleep(Math.min(180_000, hold));
|
|
424
|
+
|
|
419
425
|
// `[status]` — the agent-status carrier from issue #18, exactly as hermes' `make_status_cb` sends
|
|
420
426
|
// it: an EMPTY thought chunk whose `_meta.hermes` holds the sentence. Sent mid-turn on purpose, so a
|
|
421
427
|
// sweep can prove the line appears while the turn runs and is gone when it ends, and that NOTHING
|
|
422
428
|
// of it reaches the stored transcript.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
429
|
+
// `[status]` / `[status:<kind>]` / `[status:<kind>#<n>]` — the carrier above, with the kind and a
|
|
430
|
+
// repeat count the sweeps need: a compaction is NOT one frame (start, then heartbeats, then done),
|
|
431
|
+
// and the cockpit must fold the heartbeats into the row they belong to instead of stacking rows.
|
|
432
|
+
const statusM = /\[status(?::([a-z_]+))?(?:#(\d+))?\]/.exec(text);
|
|
433
|
+
if (statusM) {
|
|
434
|
+
const kind = statusM[1] ?? "lifecycle";
|
|
435
|
+
const times = Math.min(Number(statusM[2] ?? 1), 8);
|
|
436
|
+
const line = kind === "lifecycle"
|
|
437
|
+
? "👁️ Hindsight — recalled 8 memories"
|
|
438
|
+
: kind === "compacting"
|
|
439
|
+
? (times > 1 ? "🗜️ Compacting context — still summarizing earlier conversation..." : "🗜️ Compacting context — summarizing earlier conversation...")
|
|
440
|
+
: kind === "compacted"
|
|
441
|
+
? "✓ Context compaction complete — continuing turn..."
|
|
442
|
+
: `⚠️ mock ${kind} status`;
|
|
443
|
+
for (let i = 0; i < times; i++) {
|
|
444
|
+
setTimeout(() => {
|
|
445
|
+
void send(agent._conn, sessionId, {
|
|
446
|
+
sessionUpdate: "agent_thought_chunk",
|
|
447
|
+
_meta: { hermes: { kind: "status", statusKind: kind, text: line } },
|
|
448
|
+
content: { type: "text", text: "" },
|
|
449
|
+
}).catch(() => undefined);
|
|
450
|
+
}, Math.min(slow * 2, 1200) + i * 350);
|
|
451
|
+
}
|
|
431
452
|
}
|
|
432
453
|
|
|
433
454
|
// `[blank]` — the SAME carrier with its `_meta` stripped, i.e. the shape a status line takes when
|
|
@@ -1888,7 +1888,18 @@ export class SessionManager {
|
|
|
1888
1888
|
}
|
|
1889
1889
|
const statusText = statusLineOf(u);
|
|
1890
1890
|
if (statusText) {
|
|
1891
|
-
|
|
1891
|
+
const statusKind = statusKindOf(u);
|
|
1892
|
+
const category = noteCategoryOf(statusText, statusKind);
|
|
1893
|
+
if (category) {
|
|
1894
|
+
// 落库(操作者 2026-10-18 选的 A 档):刷新页面、重连、换设备之后,这行还在。
|
|
1895
|
+
// 折叠在 store 里做(见 Store.appendNote),因为「压缩开始 → 心跳 → 完成」和每回合的
|
|
1896
|
+
// 一次回忆都必须收敛成一行 —— 客户端折只治屏幕噪音,库里照样长心跳。
|
|
1897
|
+
const row = this.#store.appendNote(live.info.id, { category, text: statusText });
|
|
1898
|
+
this.#emit({ t: "message", message: row });
|
|
1899
|
+
} else {
|
|
1900
|
+
// 剩下的(provider 等待的倒计时、租约闲聊)本来就是一秒就作废的呼吸声:只走 live 通道。
|
|
1901
|
+
this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind });
|
|
1902
|
+
}
|
|
1892
1903
|
return;
|
|
1893
1904
|
}
|
|
1894
1905
|
let msg: Parameters<Store["appendMessage"]>[0] | null = null;
|
|
@@ -2590,6 +2601,27 @@ function statusKindOf(u: Record<string, unknown>): string | undefined {
|
|
|
2590
2601
|
return typeof kind === "string" && kind ? kind : undefined;
|
|
2591
2602
|
}
|
|
2592
2603
|
|
|
2604
|
+
/** 哪些状态行值得留档(操作者 2026-10-18 选的是 A 档)。
|
|
2605
|
+
*
|
|
2606
|
+
* Hermes 发来的「状态行」是一根混在一起的管子:`status_output.py:102` 把所有东西都标成
|
|
2607
|
+
* `lifecycle`,只有警告是 `warn`。所以分类只能看**文本**,而这些字符串是从 fork 里抄来的
|
|
2608
|
+
* 真实文案(`conversation_compression.py` 的 `🗜️ Compacting context` / `✓ Context compaction
|
|
2609
|
+
* complete` / `📦 Pre-API compression:`,`memory_manager.describe_recall` 的 `recalled N memories`):
|
|
2610
|
+
*
|
|
2611
|
+
* · memory —— 「这一轮记忆召回了什么」,每回合都会发生,事后有意义;
|
|
2612
|
+
* · compaction —— 「上下文压过」,一行就够(记住它压缩过,不需要记住它压了 12 次心跳);
|
|
2613
|
+
* · warn —— 稀有,且几乎总是"要人看一眼"的东西。
|
|
2614
|
+
*
|
|
2615
|
+
* provider 等待(`⏳ waiting on … 15s waiting for the first stream chunk`,十来秒一条倒计时)和
|
|
2616
|
+
* 「Session is free; loading the latest transcript...」这类租约闲聊返回 null:它们描述的是"此刻
|
|
2617
|
+
* 网络的呼吸",一秒后就作废,落库只会把会话记录变成心跳日志。 */
|
|
2618
|
+
export function noteCategoryOf(text: string, statusKind?: string): "memory" | "compaction" | "warn" | null {
|
|
2619
|
+
if (statusKind === "warn") return "warn";
|
|
2620
|
+
if (/Compacting context|Context compaction complete|compression:|compacting ~/i.test(text)) return "compaction";
|
|
2621
|
+
if (/recalled (?:relevant memory|1 memory|\d+ memories)/i.test(text)) return "memory";
|
|
2622
|
+
return null;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2593
2625
|
function buildPromptBlocks(text: string, attachments: PromptAttachment[]): unknown[] {
|
|
2594
2626
|
const blocks: unknown[] = [];
|
|
2595
2627
|
const body = text.trim();
|
|
@@ -233,3 +233,85 @@ export function openDashscopeStream(opts: {
|
|
|
233
233
|
started,
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 语音总结: one message in, a few spoken sentences out.
|
|
239
|
+
*
|
|
240
|
+
* Same door as batch ASR (`{compat}/chat/completions`) with the audio block swapped for text, so
|
|
241
|
+
* the operator's existing voice endpoint and key are enough — there is no second model to
|
|
242
|
+
* configure and no agent, no session and no history involved.
|
|
243
|
+
*
|
|
244
|
+
* The input is capped at `maxChars × 20` characters (30k ≈ 15k tokens is already the ceiling for a
|
|
245
|
+
* 30-second listen): a 200 KB pasted log must not turn a long press into a bill.
|
|
246
|
+
*/
|
|
247
|
+
export async function summarizeForSpeech(opts: {
|
|
248
|
+
text: string;
|
|
249
|
+
maxChars: number;
|
|
250
|
+
prompt: string;
|
|
251
|
+
model: string;
|
|
252
|
+
timeoutSec: number;
|
|
253
|
+
/** Where to send it. Absent = the voice endpoint (settings → 语音), which is the old, always-on
|
|
254
|
+
* route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for the
|
|
255
|
+
* summary to be billed to whatever model he is actually talking to, not to a second vendor. */
|
|
256
|
+
endpoint?: { baseUrl: string; apiKey: string };
|
|
257
|
+
}): Promise<{ text: string; truncated: boolean; ms: number }> {
|
|
258
|
+
const { key, compat } = opts.endpoint
|
|
259
|
+
? { key: opts.endpoint.apiKey, compat: opts.endpoint.baseUrl.replace(/\/+$/, "") }
|
|
260
|
+
: creds();
|
|
261
|
+
const v = getSettings().voice;
|
|
262
|
+
const limit = Math.max(400, opts.maxChars * 20);
|
|
263
|
+
const body = opts.text.trim();
|
|
264
|
+
const truncated = body.length > limit;
|
|
265
|
+
const input = truncated ? `${body.slice(0, limit)}\n…(后面还有 ${body.length - limit} 字没发)` : body;
|
|
266
|
+
const ac = new AbortController();
|
|
267
|
+
const timer = setTimeout(() => ac.abort(), Math.max(5, opts.timeoutSec) * 1000);
|
|
268
|
+
const started = Date.now();
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetch(`${compat}/chat/completions`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
model: opts.model || (opts.endpoint ? "" : "qwen-flash"),
|
|
275
|
+
messages: [
|
|
276
|
+
{ role: "system", content: opts.prompt.replace(/\{maxChars\}/g, String(opts.maxChars)) },
|
|
277
|
+
{ role: "user", content: input },
|
|
278
|
+
],
|
|
279
|
+
// No temperature knob: this surface is not a playground, and the default has produced
|
|
280
|
+
// the steadiest length compliance. `max_tokens` guards against a chatty model — and it
|
|
281
|
+
// must leave room for REASONING tokens, which count against the same budget: measured on
|
|
282
|
+
// deepseek-v4-flash, a 480-token cap was spent entirely on reasoning_content and the
|
|
283
|
+
// visible answer came back empty with finish_reason=length. 1200 is the floor that makes a
|
|
284
|
+
// reasoning model answer; for a plain one it is only a ceiling nobody reaches.
|
|
285
|
+
max_tokens: Math.max(1200, opts.maxChars * 6),
|
|
286
|
+
}),
|
|
287
|
+
signal: ac.signal,
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
|
291
|
+
throw new DashscopeError(`chat/completions ${res.status}: ${detail}`, "upstream");
|
|
292
|
+
}
|
|
293
|
+
const data = (await res.json()) as {
|
|
294
|
+
choices?: { message?: { content?: string; reasoning_content?: string }; finish_reason?: string }[];
|
|
295
|
+
};
|
|
296
|
+
const choice = data?.choices?.[0];
|
|
297
|
+
const out = String(choice?.message?.content ?? "").trim();
|
|
298
|
+
if (!out) {
|
|
299
|
+
// Say WHICH silence this was. An empty `content` with a fat `reasoning_content` and
|
|
300
|
+
// finish_reason=length means the budget went to thinking, not that the model refused.
|
|
301
|
+
const thought = String(choice?.message?.reasoning_content ?? "").length;
|
|
302
|
+
const why = choice?.finish_reason === "length"
|
|
303
|
+
? `预算被推理吃光了(reasoning ${thought} 字,finish_reason=length)—— 调大字数上限,或换一个非推理模型`
|
|
304
|
+
: `模型返回空内容(finish_reason=${choice?.finish_reason ?? "?"})`;
|
|
305
|
+
throw new DashscopeError(why, "upstream");
|
|
306
|
+
}
|
|
307
|
+
void v;
|
|
308
|
+
return { text: out, truncated, ms: Date.now() - started };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
if ((e as Error).name === "AbortError") {
|
|
311
|
+
throw new DashscopeError(`summary timed out after ${opts.timeoutSec}s`, "timeout");
|
|
312
|
+
}
|
|
313
|
+
throw e;
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -2,27 +2,29 @@
|
|
|
2
2
|
// The browser never talks to CLI subprocesses directly (design.md §1).
|
|
3
3
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
4
|
import type { Duplex } from "node:stream";
|
|
5
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID, X509Certificate } from "node:crypto";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import { WebSocketServer, WebSocket } from "ws";
|
|
12
|
-
import { Store } from "./store/store.js";
|
|
12
|
+
import { Store, textOf } from "./store/store.js";
|
|
13
|
+
import { routeForModel, type SpeechRoute } from "./speech/route.js";
|
|
13
14
|
import { exportFilename, renderJson, renderMarkdown, type ExportSessionHeader } from "./store/export.js";
|
|
14
15
|
import { IMAGE_UNSUPPORTED, SessionManager, SessionUnavailable } from "./acp/session-manager.js";
|
|
15
16
|
import { describeAcpError } from "./acp/errors.js";
|
|
16
17
|
import { BACKENDS } from "./acp/backends.js";
|
|
17
|
-
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
|
+
import { HERMES_DEFAULT_HOME, classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
19
|
import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
|
|
19
20
|
import { terms } from "./term.js";
|
|
20
21
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
21
22
|
import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
22
|
-
import { openDashscopeStream } from "./dashscope.js";
|
|
23
|
+
import { openDashscopeStream, summarizeForSpeech } from "./dashscope.js";
|
|
23
24
|
import {
|
|
25
|
+
SUMMARY_CHARS_RANGE, SUMMARY_MODEL_DEFAULT, SUMMARY_PROMPT_DEFAULT,
|
|
24
26
|
agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
|
|
25
|
-
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveTheme,
|
|
27
|
+
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
|
|
26
28
|
} from "./settings.js";
|
|
27
29
|
import { ObserverHub } from "./observer/hub.js";
|
|
28
30
|
import * as auth from "./auth.js";
|
|
@@ -101,6 +103,19 @@ const APK_FILE = process.env.AGENTUS_APK
|
|
|
101
103
|
path.join(DATA_DIR, "agentus-companion.apk"),
|
|
102
104
|
].find((p) => fs.existsSync(p))
|
|
103
105
|
?? path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk");
|
|
106
|
+
// The root CA this instance's HTTPS is signed by, served to logged-in operators so a phone can
|
|
107
|
+
// trust the tunnel's self-signed cert once instead of tapping through a warning forever. Same
|
|
108
|
+
// reasoning as the APK: an artifact you install on a device belongs behind the operator login, and
|
|
109
|
+
// a page that invites you to install a TRUST ANCHOR especially so. Public half only — `ca.key` is
|
|
110
|
+
// never read here, and the file this points at holds the certificate alone.
|
|
111
|
+
// Candidates: an explicit pin (a Caddy/safe-nat root lives outside any checkout), then the data
|
|
112
|
+
// dir for a self-contained install, then the Caddy default. Absent is not fatal: the card says so.
|
|
113
|
+
const CA_FILE = process.env.AGENTUS_CA_FILE
|
|
114
|
+
?? [
|
|
115
|
+
path.join(DATA_DIR, "tls", "ca.pem"),
|
|
116
|
+
path.join(homedir(), ".config", "caddy", "tls", "ca.pem"),
|
|
117
|
+
].find((p) => fs.existsSync(p))
|
|
118
|
+
?? path.join(DATA_DIR, "tls", "ca.pem");
|
|
104
119
|
// `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
|
|
105
120
|
// what it decided, then exits — before any listener, store or subprocess exists. Print and
|
|
106
121
|
// exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
|
|
@@ -248,6 +263,21 @@ hub = new ObserverHub({
|
|
|
248
263
|
};
|
|
249
264
|
pick();
|
|
250
265
|
},
|
|
266
|
+
// 工作播报 的作用域与生命周期 (issues #23/#24, 2026-10-18).
|
|
267
|
+
// `watching` is the SAME presence the notification rules use: 「他在看哪个会话」 has one answer in
|
|
268
|
+
// this process, and a second, quieter definition of it is how a feature starts talking about the
|
|
269
|
+
// wrong session. `notify` is a `const` declared below — this closure only ever runs from an event
|
|
270
|
+
// handler, never during construction.
|
|
271
|
+
watching: (id) => notify.operatorWatching(id),
|
|
272
|
+
// The reaped-observer fix: a `system` session the idle reaper took no longer accepts prompts, and
|
|
273
|
+
// trusting its id forever is what made the brief say 「播报失败:no such session」 until a restart.
|
|
274
|
+
// `hasSession` (not `list()`) on purpose: `list()` is the RAIL's view and excludes system sessions,
|
|
275
|
+
// so asking it about an observer answers 「no」 every single time — which would re-spawn a fresh
|
|
276
|
+
// observer on every turn and throw away the context continuity the whole design is for.
|
|
277
|
+
alive: (id) => mgr.hasSession(id),
|
|
278
|
+
// 切走即关 / 关掉收听即关: closing the PROCESS is the point — an idle observer is a whole ACP
|
|
279
|
+
// runtime, and the reaper's five minutes are five minutes of paying for nothing.
|
|
280
|
+
disposeSession: (id) => mgr.closeSession(id),
|
|
251
281
|
emit,
|
|
252
282
|
});
|
|
253
283
|
|
|
@@ -269,7 +299,12 @@ const notify = new NotifyCenter({
|
|
|
269
299
|
// The page reporting which session it is showing is also the idle reaper's clock: opening a slot
|
|
270
300
|
// (and staying in it) counts as USED, so switching away gives it a full threshold instead of
|
|
271
301
|
// reaping it on the next tick because its last MESSAGE was days ago.
|
|
272
|
-
|
|
302
|
+
// ...and it is also the 工作播报 scope switch (issue #23): the observer follows the session on
|
|
303
|
+
// screen, and the previous one is CLOSED rather than left warming. Two consumers, one fact.
|
|
304
|
+
onFocus: (sessionId: string) => {
|
|
305
|
+
mgr.touch(sessionId);
|
|
306
|
+
hub?.focusChanged(sessionId);
|
|
307
|
+
},
|
|
273
308
|
// Username+password pairing: the phone types the same login the browser uses. The check and
|
|
274
309
|
// its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
|
|
275
310
|
credentials: (username, password, ip) => {
|
|
@@ -286,6 +321,11 @@ const notify = new NotifyCenter({
|
|
|
286
321
|
},
|
|
287
322
|
});
|
|
288
323
|
|
|
324
|
+
// The beat starts here, and not at construction: `tick()` reads `notify`'s presence, and a timer
|
|
325
|
+
// started before that `const` exists would be the one thing in this file able to throw a TDZ error
|
|
326
|
+
// at runtime. 15s is finer than the smallest beat the menu offers (1 minute).
|
|
327
|
+
hub?.start();
|
|
328
|
+
|
|
289
329
|
// One row per interaction. A notification button carries an opaque actionId plus the ref the
|
|
290
330
|
// publisher attached; the cockpit is the only place that knows what it means. Adding a new
|
|
291
331
|
// interaction is therefore a case here (or a new ref.type), never an app rebuild.
|
|
@@ -760,6 +800,57 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
760
800
|
return res.end("Agentus: authentication required\n");
|
|
761
801
|
}
|
|
762
802
|
|
|
803
|
+
// ---- the root CA, behind the operator login too -------------------------------------
|
|
804
|
+
// `GET /api/tls` what the cert is (subject, validity, SHA-256 fingerprint) so the
|
|
805
|
+
// settings card can show it before you download anything;
|
|
806
|
+
// `GET /api/tls/ca.crt` the DER the phone/Mac installs. DER + `application/x-x509-ca-cert`
|
|
807
|
+
// is what makes iOS/macOS open the *profile install* flow on a plain
|
|
808
|
+
// tap — serve it as text/plain or octet-stream and iOS just shows text.
|
|
809
|
+
// Nothing here reads a key: this is the certificate, which is public material by design.
|
|
810
|
+
if (url.pathname === "/api/tls" || url.pathname === "/api/tls/ca.crt") {
|
|
811
|
+
if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
|
|
812
|
+
if (!fs.existsSync(CA_FILE)) return send(res, 404, { error: "no ca configured", path: CA_FILE });
|
|
813
|
+
let cert: X509Certificate;
|
|
814
|
+
try { cert = new X509Certificate(fs.readFileSync(CA_FILE)); }
|
|
815
|
+
catch (e) { return send(res, 500, { error: `ca is not a certificate: ${(e as Error).message}` }); }
|
|
816
|
+
if (url.pathname === "/api/tls/ca.crt") {
|
|
817
|
+
res.writeHead(200, {
|
|
818
|
+
"content-type": "application/x-x509-ca-cert",
|
|
819
|
+
"content-length": String(cert.raw.length),
|
|
820
|
+
"content-disposition": 'attachment; filename="agentus-ca.crt"',
|
|
821
|
+
"cache-control": "no-store",
|
|
822
|
+
});
|
|
823
|
+
return res.end(cert.raw);
|
|
824
|
+
}
|
|
825
|
+
return send(res, 200, {
|
|
826
|
+
ok: true,
|
|
827
|
+
hasCA: true,
|
|
828
|
+
subject: cert.subject.replace(/\n/g, ", "),
|
|
829
|
+
issuer: cert.issuer.replace(/\n/g, ", "),
|
|
830
|
+
notBefore: cert.validFrom,
|
|
831
|
+
notAfter: cert.validTo,
|
|
832
|
+
fingerprint256: cert.fingerprint256,
|
|
833
|
+
bytes: cert.raw.length,
|
|
834
|
+
selfSigned: cert.subject === cert.issuer,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---- 工作播报 diagnostics (issues #23/#24) ------------------------------------------
|
|
839
|
+
// What the hub is watching and what it spawned. Operator-only, read-only, no secrets: this is
|
|
840
|
+
// the answer to 「它有没有在看这个会话 / 观察员还活着吗」 that used to require reading logs.
|
|
841
|
+
if (url.pathname === "/api/observer" && req.method === "GET") {
|
|
842
|
+
if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
|
|
843
|
+
const o = getSettings().observer;
|
|
844
|
+
return send(res, 200, {
|
|
845
|
+
enabled: o.enabled,
|
|
846
|
+
intervalSec: o.intervalSec,
|
|
847
|
+
throttleSec: o.throttleSec,
|
|
848
|
+
observers: hub?.observerIds() ?? [],
|
|
849
|
+
watches: hub?.debug() ?? [],
|
|
850
|
+
ticks: hub?.ticks() ?? 0,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
763
854
|
// ---- the phone's onboarding page + the APK, both behind the operator login ----------
|
|
764
855
|
// The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
|
|
765
856
|
// sent to the login screen (the SPA at /) and comes back with a session cookie.
|
|
@@ -1190,7 +1281,12 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1190
1281
|
if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
|
|
1191
1282
|
if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
|
|
1192
1283
|
if (body.observer && typeof body.observer === "object") saveObserver(body.observer as Record<string, unknown>);
|
|
1193
|
-
|
|
1284
|
+
// Turning the speaker off (or the observer off) must stop the COST, not just the voice:
|
|
1285
|
+
// the observer is a whole ACP process, and waiting for the idle reaper to take it is five
|
|
1286
|
+
// minutes of paying for a feature the operator just switched off (issue #23 §3.2).
|
|
1287
|
+
if (body.prefs || body.observer) hub?.onPolicyChanged();
|
|
1288
|
+
if (body.summary && typeof body.summary === "object") saveSummary(body.summary as Record<string, unknown>);
|
|
1289
|
+
if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer && !body.summary) saveSettings(body);
|
|
1194
1290
|
return send(res, 200, publicSettings());
|
|
1195
1291
|
} catch (e) {
|
|
1196
1292
|
return send(res, 400, { error: String((e as Error)?.message ?? e) });
|
|
@@ -1302,11 +1398,15 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1302
1398
|
// here used to kill the whole server (QA#19, unhandled rejection)
|
|
1303
1399
|
if (mgr.hasSession(id)) {
|
|
1304
1400
|
await mgr.closeSession(id);
|
|
1401
|
+
// 工作播报 (#23): a closed session's observer and its watch entry have no future. Before
|
|
1402
|
+
// this, `forget()` had zero call sites and the map only grew.
|
|
1403
|
+
hub?.forget(id);
|
|
1305
1404
|
return send(res, 200, { closed: id });
|
|
1306
1405
|
}
|
|
1307
1406
|
// not live: either a cold slot the operator wants gone, or an unknown id.
|
|
1308
1407
|
// Purging is what makes the rail's "on disk · N" list manageable (M4).
|
|
1309
1408
|
if (store.deleteSession(id)) {
|
|
1409
|
+
hub?.forget(id);
|
|
1310
1410
|
emit(sessionsEvent());
|
|
1311
1411
|
return send(res, 200, { purged: id });
|
|
1312
1412
|
}
|
|
@@ -1319,6 +1419,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1319
1419
|
// had no way to make it.
|
|
1320
1420
|
try {
|
|
1321
1421
|
const info = await mgr.setArchived(id, true);
|
|
1422
|
+
hub?.forget(id);
|
|
1322
1423
|
emit(sessionsEvent());
|
|
1323
1424
|
return send(res, 200, info);
|
|
1324
1425
|
} catch (e) {
|
|
@@ -1477,6 +1578,86 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1477
1578
|
return send(res, 409, { error: msg });
|
|
1478
1579
|
}
|
|
1479
1580
|
}
|
|
1581
|
+
if (req.method === "POST" && sub === "/speech-summary") {
|
|
1582
|
+
/** The endpoint this session's OWN model lives on. Read per request: the operator can
|
|
1583
|
+
* switch the model from the toolbar and the next long press must follow it. */
|
|
1584
|
+
const sessionRoute = (): { route: SpeechRoute | null; why: string } => {
|
|
1585
|
+
const row = store.getSession(id);
|
|
1586
|
+
if (!row) return { route: null, why: "会话不在库里" };
|
|
1587
|
+
const be = store.listBackends().find((b) => b.id === row.backend);
|
|
1588
|
+
const models = (row.models ?? {}) as { currentModelId?: string | null };
|
|
1589
|
+
// The provider keys live in this box's agent home, which is a property of the runtime —
|
|
1590
|
+
// not of the vendor that happens to be driving this session. A backend that owns no home
|
|
1591
|
+
// (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
|
|
1592
|
+
const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
|
|
1593
|
+
return routeForModel(models.currentModelId ?? null, home);
|
|
1594
|
+
};
|
|
1595
|
+
// 语音总结 (the message speaker's long press): ONE stored message, made listenable.
|
|
1596
|
+
//
|
|
1597
|
+
// No agent, no fork, no history: the text goes to the voice endpoint's chat surface, the
|
|
1598
|
+
// same one ASR already posts to. The BODY of the message is read from the store by key —
|
|
1599
|
+
// the browser does not get to say what reaches the model, and the server's copy is what the
|
|
1600
|
+
// operator actually saw on screen.
|
|
1601
|
+
const cfg = summarySettings();
|
|
1602
|
+
if (!cfg.enabled) return send(res, 409, { error: "语音总结已关闭(设置 → 语音总结)", code: "disabled" });
|
|
1603
|
+
const body = await readJson(req);
|
|
1604
|
+
const key = String(body.key ?? "").trim();
|
|
1605
|
+
if (!key) return send(res, 400, { error: "key is required" });
|
|
1606
|
+
const row = store.messageByKey(id, key);
|
|
1607
|
+
if (!row) return send(res, 404, { error: `no such message: ${key}` });
|
|
1608
|
+
// 字数档 (issue from the operator, 2026-10-18): the message speaker's long press offers
|
|
1609
|
+
// 100/150/200 字 next to 「按设置」. The count is the ONE thing that varies per call, and it
|
|
1610
|
+
// rides the prompt's existing `{maxChars}` placeholder — so this is the only place it has to
|
|
1611
|
+
// be honoured. Clamped to the same range the settings page validates, so a crafted body
|
|
1612
|
+
// cannot ask for a 10k-character invoice.
|
|
1613
|
+
const asked = Math.round(Number(body.maxChars));
|
|
1614
|
+
const [clo, chi] = SUMMARY_CHARS_RANGE;
|
|
1615
|
+
const maxChars = Number.isFinite(asked) && asked >= clo && asked <= chi ? asked : cfg.maxChars;
|
|
1616
|
+
// Judge "short enough to just read" on what would actually be SPOKEN: voice.ts drops code
|
|
1617
|
+
// blocks, and a 20-line diff must not count as a long message worth a model call.
|
|
1618
|
+
const spoken = textOf(row.payload).replace(/```[\s\S]*?```/g, " ").replace(/\s+/g, " ").trim();
|
|
1619
|
+
if (!spoken) return send(res, 409, { error: "这条消息没有可以念的文字", code: "empty" });
|
|
1620
|
+
if (spoken.length <= maxChars) {
|
|
1621
|
+
return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
// The operator's rule: bill this to the model he is already talking to. The voice
|
|
1625
|
+
// endpoint stays as the fallback (and is the whole route when followSession is off).
|
|
1626
|
+
const own = cfg.followSession ? sessionRoute() : { route: null, why: "" };
|
|
1627
|
+
const model = own.route?.model || cfg.model || SUMMARY_MODEL_DEFAULT;
|
|
1628
|
+
const r = await summarizeForSpeech({
|
|
1629
|
+
text: spoken,
|
|
1630
|
+
maxChars,
|
|
1631
|
+
prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
|
|
1632
|
+
model,
|
|
1633
|
+
timeoutSec: cfg.timeoutSec,
|
|
1634
|
+
endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
|
|
1635
|
+
});
|
|
1636
|
+
// Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
|
|
1637
|
+
// request, so the answer is normalised here — the note clamps to two lines and the TTS
|
|
1638
|
+
// chunker reads sentence by sentence, and a stray blank line costs both of them.
|
|
1639
|
+
const text = r.text.replace(/\s*\n+\s*/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
1640
|
+
return send(res, 200, {
|
|
1641
|
+
ok: true, via: "model", text, chars: text.length,
|
|
1642
|
+
// echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
|
|
1643
|
+
// configured number would make the menu look broken
|
|
1644
|
+
maxChars,
|
|
1645
|
+
ms: r.ms, truncated: r.truncated, model,
|
|
1646
|
+
// which account this call was billed to — the note prints it, and a fallback that
|
|
1647
|
+
// silently used the OTHER provider would be a lie about the cost
|
|
1648
|
+
route: own.route ? "session" : "voice",
|
|
1649
|
+
provider: own.route?.provider ?? "dashscope",
|
|
1650
|
+
routeNote: own.route ? own.route.source : own.why,
|
|
1651
|
+
});
|
|
1652
|
+
} catch (e) {
|
|
1653
|
+
// 409 = nothing is configured, 504 = we gave up waiting, 502 = the endpoint said no.
|
|
1654
|
+
// None of them may be papered over with the original text: a fallback that looks like a
|
|
1655
|
+
// summary is worse than an honest failure the UI can offer to retry.
|
|
1656
|
+
const err = e as { code?: string; message?: string };
|
|
1657
|
+
const status = err.code === "not_configured" ? 409 : err.code === "timeout" ? 504 : 502;
|
|
1658
|
+
return send(res, status, { error: String(err.message ?? e), code: err.code ?? "upstream" });
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1480
1661
|
if (req.method === "POST" && sub === "/fork") {
|
|
1481
1662
|
try {
|
|
1482
1663
|
// forking a cold session resumes it first (the call has to reach a live agent)
|