agentus 0.4.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.4.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,6 +416,12 @@ 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
@@ -11,7 +11,8 @@
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "1.5.1",
13
13
  "@agentus/shared": "*",
14
- "ws": "^8.22.0"
14
+ "ws": "^8.22.0",
15
+ "yaml": "^2.9.1"
15
16
  },
16
17
  "devDependencies": {
17
18
  "@types/node": "^22.0.0",
@@ -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
+ }
@@ -9,20 +9,22 @@ 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";
@@ -261,6 +263,21 @@ hub = new ObserverHub({
261
263
  };
262
264
  pick();
263
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),
264
281
  emit,
265
282
  });
266
283
 
@@ -282,7 +299,12 @@ const notify = new NotifyCenter({
282
299
  // The page reporting which session it is showing is also the idle reaper's clock: opening a slot
283
300
  // (and staying in it) counts as USED, so switching away gives it a full threshold instead of
284
301
  // reaping it on the next tick because its last MESSAGE was days ago.
285
- onFocus: (sessionId: string) => mgr.touch(sessionId),
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
+ },
286
308
  // Username+password pairing: the phone types the same login the browser uses. The check and
287
309
  // its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
288
310
  credentials: (username, password, ip) => {
@@ -299,6 +321,11 @@ const notify = new NotifyCenter({
299
321
  },
300
322
  });
301
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
+
302
329
  // One row per interaction. A notification button carries an opaque actionId plus the ref the
303
330
  // publisher attached; the cockpit is the only place that knows what it means. Adding a new
304
331
  // interaction is therefore a case here (or a new ref.type), never an app rebuild.
@@ -808,6 +835,22 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
808
835
  });
809
836
  }
810
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
+
811
854
  // ---- the phone's onboarding page + the APK, both behind the operator login ----------
812
855
  // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
813
856
  // sent to the login screen (the SPA at /) and comes back with a session cookie.
@@ -1238,7 +1281,12 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1238
1281
  if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
1239
1282
  if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
1240
1283
  if (body.observer && typeof body.observer === "object") saveObserver(body.observer as Record<string, unknown>);
1241
- if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer) saveSettings(body);
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);
1242
1290
  return send(res, 200, publicSettings());
1243
1291
  } catch (e) {
1244
1292
  return send(res, 400, { error: String((e as Error)?.message ?? e) });
@@ -1350,11 +1398,15 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1350
1398
  // here used to kill the whole server (QA#19, unhandled rejection)
1351
1399
  if (mgr.hasSession(id)) {
1352
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);
1353
1404
  return send(res, 200, { closed: id });
1354
1405
  }
1355
1406
  // not live: either a cold slot the operator wants gone, or an unknown id.
1356
1407
  // Purging is what makes the rail's "on disk · N" list manageable (M4).
1357
1408
  if (store.deleteSession(id)) {
1409
+ hub?.forget(id);
1358
1410
  emit(sessionsEvent());
1359
1411
  return send(res, 200, { purged: id });
1360
1412
  }
@@ -1367,6 +1419,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1367
1419
  // had no way to make it.
1368
1420
  try {
1369
1421
  const info = await mgr.setArchived(id, true);
1422
+ hub?.forget(id);
1370
1423
  emit(sessionsEvent());
1371
1424
  return send(res, 200, info);
1372
1425
  } catch (e) {
@@ -1525,6 +1578,86 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1525
1578
  return send(res, 409, { error: msg });
1526
1579
  }
1527
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
+ }
1528
1661
  if (req.method === "POST" && sub === "/fork") {
1529
1662
  try {
1530
1663
  // forking a cold session resumes it first (the call has to reach a live agent)