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.
@@ -21,7 +21,21 @@
21
21
  // observed session's cwd: an observer runs a full agent runtime with file and shell tools, and it
22
22
  // should no more be able to write into the project than a log shipper should. `system: true` keeps it
23
23
  // out of the rail and out of the cockpit CLI's scope (see SessionManager.list). It is spawned ONCE
24
- // and reused; the idle reaper may put it to sleep like any slot, and `ensure()` wakes it.
24
+ // and reused; the idle reaper may put it to sleep like any slot, and `#ensure()` below wakes it.
25
+ //
26
+ // THREE THINGS THIS FILE LEARNED THE HARD WAY (issues #23 / #24, 2026-10-18)
27
+ // 1. **The scope is the session on screen.** 「替你看所有会话」 is not the feature — the rail already
28
+ // does that, and four busy sessions used to grow four observer processes all talking over each
29
+ // other. Every trigger now asks `watching()` (the notify centre's presence, the same fact the
30
+ // quiet-when-watching rule uses), and a focus change CLOSES the previous observer, so the number
31
+ // of observer processes is bounded by one.
32
+ // 2. **A reaped observer must not be a dead end.** `observerId` used to be trusted forever: once the
33
+ // idle reaper took the process, every later brief said 「播报失败:no such session」 until the
34
+ // server restarted. `#ensure()` is the fix the old header comment had already promised.
35
+ // 3. **A turn that never ends produces no `turn-end`.** Event-driven only means a 20-minute turn is
36
+ // 20 minutes of silence — the exact complaint that opened #24. The ticker now speaks on a beat,
37
+ // but ONLY while a turn is in flight (see `intervalSec`): the timer covers the blind spot, it
38
+ // does not become a metronome for an idle cockpit.
25
39
  import fs from "node:fs";
26
40
  import path from "node:path";
27
41
  import type { BackendId, ServerEvent } from "../../../shared/src/index.js";
@@ -31,6 +45,17 @@ import { OBSERVER_PROMPT_DEFAULT } from "../settings.js";
31
45
  /** Spoken lines are short on purpose: they are heard once, in the middle of other work. */
32
46
  export const BRIEF_MAX = 120;
33
47
 
48
+ /** How many model-backed briefs one turn may cost, heartbeat included (issue #24).
49
+ *
50
+ * The ticker is already bounded by `intervalSec`; this is the second wall. A turn that runs two
51
+ * hours at a 1-minute beat would otherwise buy 120 paragraphs, and the operator would have learned
52
+ * to ignore all of them long before the last one arrived. */
53
+ export const MAX_BRIEFS_PER_TURN = 8;
54
+
55
+ /** How often the ticker looks. Cheap (a few comparisons), and finer than the smallest beat offered
56
+ * (1 minute), so a 1-minute interval is not silently rounded up to two. */
57
+ const TICK_MS = 15_000;
58
+
34
59
  export interface ObserverDeps {
35
60
  /** Live settings, read per event — the switch must work without a restart. */
36
61
  settings(): ObserverSettings;
@@ -52,6 +77,12 @@ export interface ObserverDeps {
52
77
  disposeSession?(observerId: string): Promise<void>;
53
78
  /** Best-effort: move a session into a read-only mode if the backend offers one. */
54
79
  preferReadonly?(sessionId: string): void;
80
+ /** Is the operator looking at THIS session right now (the notify centre's presence)? Absent = every
81
+ * session counts, which is the #16 behaviour and what a unit test without a presence wants. */
82
+ watching?(sessionId: string): boolean;
83
+ /** Does this session still have a live ACP process? Absent = assume yes. This is the half of #23
84
+ * that turns a reaped observer into a recoverable one instead of a permanent 「播报失败」. */
85
+ alive?(sessionId: string): boolean;
55
86
  /** Base directory for per-observer scratch dirs. */
56
87
  home: string;
57
88
  emit(evt: ServerEvent): void;
@@ -65,16 +96,27 @@ interface Watch {
65
96
  observerId: string | null;
66
97
  /** A spawn is in flight — do not start a second one. */
67
98
  spawning: boolean;
68
- /** When the last MODEL-backed brief went out (for the throttle). */
99
+ /** When the last MODEL-backed brief was ASKED for (feeds both the min-gap and the beat). */
69
100
  lastBriefAt: number;
70
101
  /** The trigger is owed: an event arrived while a brief was still being produced. */
71
102
  queued: boolean;
103
+ /** WHICH trigger is owed — a heartbeat must not be replayed as a turn-end brief. */
104
+ queuedKind: "turn-end" | "heartbeat";
72
105
  /** Text of the observer's current turn, accumulated from `message` frames. */
73
106
  answer: string;
74
107
  /** True while the observer is producing a brief. */
75
108
  busy: boolean;
76
109
  /** Plan step keys already announced, so an unchanged plan is not read out twice. */
77
110
  announcedSteps: Set<string>;
111
+ /** A turn is IN FLIGHT (a `turn-start` arrived and its `turn-end` has not). The heartbeat's gate:
112
+ * no turn in flight, no beat — an idle session has nothing to report and must stay silent. */
113
+ inflight: boolean;
114
+ /** When a frame for this session was last seen. 「还有没有动静」 is measured from here. */
115
+ lastActivityAt: number;
116
+ /** When the current turn started (for 「已经跑了 N 分钟」). */
117
+ startedAt: number;
118
+ /** Model-backed briefs spent on the CURRENT turn (the MAX_BRIEFS_PER_TURN counter). */
119
+ briefsThisTurn: number;
78
120
  }
79
121
 
80
122
  const NOTHING = "(没有可播报的进展)";
@@ -94,12 +136,22 @@ export function oneLine(text: string, max = BRIEF_MAX): string {
94
136
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
95
137
  }
96
138
 
139
+ /** 「7 分钟」 / 「1 小时 12 分钟」 — the one fact a heartbeat has without a model call. */
140
+ export function minutesLabel(ms: number): string {
141
+ const min = Math.max(1, Math.round(ms / 60_000));
142
+ return min < 60 ? `${min} 分钟` : `${Math.floor(min / 60)} 小时 ${min % 60} 分钟`;
143
+ }
144
+
97
145
  export class ObserverHub {
98
146
  #deps: ObserverDeps;
99
147
  #watch = new Map<string, Watch>();
100
148
  /** Observer ids, so their own frames are never mistaken for the operator's sessions. */
101
149
  #observers = new Set<string>();
102
150
  #now: () => number;
151
+ #timer: ReturnType<typeof setInterval> | null = null;
152
+ /** how many times the ticker has run — `GET /api/observer` prints it, and 「节拍没动静」 has to be
153
+ * distinguishable from 「timer 没起来」. */
154
+ #ticks = 0;
103
155
 
104
156
  constructor(deps: ObserverDeps) {
105
157
  this.#deps = deps;
@@ -114,19 +166,73 @@ export class ObserverHub {
114
166
  return this.#observers.has(sessionId);
115
167
  }
116
168
 
169
+ /** Everything the hub currently holds, for `GET /api/observer` and the QA sweeps. Read-only and
170
+ * cheap: 工作播报's bookkeeping used to be invisible from outside, which is why 「它到底有没有在
171
+ * 看这个会话」 could only be guessed at from the speak frames. */
172
+ ticks(): number {
173
+ return this.#ticks;
174
+ }
175
+
176
+ debug(): { watched: string; observerId: string | null; inflight: boolean; busy: boolean; briefs: number; watching: boolean; quietMs: number }[] {
177
+ const now = this.#now();
178
+ return [...this.#watch.values()].map((w) => ({
179
+ watched: w.observedId,
180
+ observerId: w.observerId,
181
+ inflight: w.inflight,
182
+ busy: w.busy,
183
+ briefs: w.briefsThisTurn,
184
+ watching: this.#watching(w.observedId),
185
+ quietMs: now - w.lastActivityAt,
186
+ }));
187
+ }
188
+
189
+ /** Start the beat (issue #24). Called AFTER the notify centre exists, because the gate reads its
190
+ * presence — a ticker started earlier would be a timer reading an uninitialised `const`. */
191
+ start(intervalMs = TICK_MS): void {
192
+ if (this.#timer) return;
193
+ this.#timer = setInterval(() => this.tick(), intervalMs);
194
+ // Never let a server keep a process alive just for this timer.
195
+ (this.#timer as unknown as { unref?: () => void }).unref?.();
196
+ }
197
+
198
+ stop(): void {
199
+ if (this.#timer) clearInterval(this.#timer);
200
+ this.#timer = null;
201
+ }
202
+
117
203
  /** Forget everything about a session (it was closed, archived or purged). The observer process is
118
204
  * the idle reaper's business; we only stop treating this pair as current. */
119
205
  forget(sessionId: string): void {
120
206
  const w = this.#watch.get(sessionId);
121
- this.#watch.delete(sessionId);
122
207
  if (w?.observerId) this.#observers.delete(w.observerId);
208
+ this.#watch.delete(sessionId);
209
+ }
210
+
211
+ /** The operator moved to another session: CLOSE the one we were reading (issue #23).
212
+ *
213
+ * `next` is the session now on screen (null = nobody). 「切走即关」 is about switching SESSIONS,
214
+ * not about a tab going to the background: a hidden page keeps its observer, because a rebuild
215
+ * means re-reading the whole session and the first brief after a rebuild is the expensive one.
216
+ * A closed tab is the idle reaper's job — `#ensure()` is what makes that safe. */
217
+ focusChanged(next: string | null): void {
218
+ for (const [sid, w] of [...this.#watch]) {
219
+ if (sid === next || !w.observerId) continue;
220
+ void this.#dispose(w);
221
+ }
222
+ }
223
+
224
+ /** The operator turned the speaker (or the observer) off: stop paying NOW, not at the next reap.
225
+ * Until #23 this was invisible — `speakEnabled()` only made the hub idle, while the observer
226
+ * process (a whole ACP runtime, ~137 MB) sat there for the reaper's five minutes. */
227
+ onPolicyChanged(): void {
228
+ if (this.#deps.speakEnabled() && this.#deps.settings().enabled) return;
229
+ for (const w of [...this.#watch.values()]) void this.#dispose(w);
123
230
  }
124
231
 
125
232
  /** Every ServerEvent in the process passes through here (index.ts wires it in front of the
126
233
  * broadcast). Cheap by construction: a few kind checks per frame. */
127
234
  onEvent(evt: ServerEvent): void {
128
235
  if (evt.t === "message") {
129
- // The observer's own answer, being assembled.
130
236
  const sid = evt.message.sessionId;
131
237
  // `message` frames for an observer carry the OBSERVER's id, while the watch entry is keyed by
132
238
  // the OBSERVED session — so this is a lookup by observer id, not by key.
@@ -134,6 +240,19 @@ export class ObserverHub {
134
240
  if (watch.observerId !== sid) continue;
135
241
  if (evt.message.kind === "agent") watch.answer += textOfPayload(evt.message.payload);
136
242
  }
243
+ // ANY frame counts as 「有动静」: the beat only fires when nothing at all has happened since.
244
+ const w = this.#watch.get(sid);
245
+ if (w) w.lastActivityAt = this.#now();
246
+ return;
247
+ }
248
+
249
+ if (evt.t === "turn-start") {
250
+ if (this.#observers.has(evt.sessionId)) return;
251
+ const w = this.#watchFor(evt.sessionId);
252
+ w.inflight = true;
253
+ w.startedAt = this.#now();
254
+ w.lastActivityAt = w.startedAt;
255
+ w.briefsThisTurn = 0;
137
256
  return;
138
257
  }
139
258
 
@@ -145,14 +264,22 @@ export class ObserverHub {
145
264
  for (const w of this.#watch.values()) if (w.observerId === evt.sessionId) this.#finishBrief(w);
146
265
  return;
147
266
  }
148
- void this.#maybeBrief(evt.sessionId, evt.error);
267
+ const w = this.#watchFor(evt.sessionId);
268
+ w.inflight = false;
269
+ w.lastActivityAt = this.#now();
270
+ void this.#maybeBrief(evt.sessionId, evt.error, "turn-end");
149
271
  return;
150
272
  }
151
273
 
152
274
  if (evt.t === "permission") {
153
- // A request waiting for an answer is the one thing never worth a model call: the fact is
154
- // right here, and the operator may be away from the screen for a while.
275
+ // A request waiting for an answer is never worth a model call: the fact is right here, and the
276
+ // operator may be away from the screen. It is also the one blocking event in the product, so
277
+ // it is the one place where silencing a background session is a real trade: the phone push
278
+ // still delivers it (approvals are never suppressed by 「我在看」), so the voice follows the
279
+ // same scope as everything else here. Flip this to `#speaking()` alone if 「后台审批也念出来」
280
+ // turns out to be wanted.
155
281
  if (!this.#speaking()) return;
282
+ if (!this.#watching(evt.request.sessionId)) return;
156
283
  const sid = evt.request.sessionId;
157
284
  const title = evt.request.toolCallTitle?.trim();
158
285
  this.#speak(sid, title ? `有请求等你确认:${title}` : "有个请求等你确认", "approval", 2);
@@ -161,9 +288,12 @@ export class ObserverHub {
161
288
 
162
289
  if (evt.t === "plan") {
163
290
  if (!this.#speaking()) return;
164
- const w = this.#watchFor(evt.sessionId);
165
291
  const plan = evt.plan;
166
292
  if (!plan) return;
293
+ // Zero-cost, but still an announcement: the rail is where you read progress on a session you
294
+ // are not looking at, so the voice follows the same scope as the model-backed brief.
295
+ if (!this.#watching(evt.sessionId)) return;
296
+ const w = this.#watchFor(evt.sessionId);
167
297
  for (const item of plan.items) {
168
298
  if (item.status !== "completed") continue;
169
299
  // PlanItem has no id: its content IS the identity (ACP replaces the whole list each frame).
@@ -180,25 +310,56 @@ export class ObserverHub {
180
310
  }
181
311
  }
182
312
 
183
- /** A turn ended on a session the operator owns: is it worth a brief? */
184
- async #maybeBrief(sessionId: string, error?: string): Promise<void> {
313
+ /** One pass of the beat (issue #24). Written for many watches even though #23 keeps it at one:
314
+ * the ticker must never become the reason the scope rule matters. */
315
+ tick(): void {
316
+ this.#ticks++;
317
+ const s = this.#deps.settings();
318
+ if (!s.enabled || s.intervalSec <= 0 || !this.#deps.speakEnabled()) return;
319
+ const now = this.#now();
320
+ for (const w of [...this.#watch.values()]) {
321
+ this.#ensure(w); // a dead observer is not 「busy」
322
+ if (w.busy || w.spawning) continue; // a brief is already on its way
323
+ if (!w.inflight) continue; // no turn → nothing to report
324
+ if (!this.#watching(w.observedId)) continue; // not the session on screen
325
+ if (now - w.lastBriefAt < s.intervalSec * 1000) continue; // the beat
326
+ if (now - w.lastBriefAt < s.throttleSec * 1000) continue; // the min-gap still guards
327
+ if (w.briefsThisTurn >= MAX_BRIEFS_PER_TURN) continue; // the per-turn wall
328
+ void this.#maybeBrief(w.observedId, undefined, "heartbeat");
329
+ }
330
+ }
331
+
332
+ /** A turn ended (or a beat came due) on a session the operator owns: is it worth a brief? */
333
+ async #maybeBrief(sessionId: string, error?: string, kind: "turn-end" | "heartbeat" = "turn-end"): Promise<void> {
185
334
  if (!this.#speaking()) return;
186
335
  if (this.#observers.has(sessionId)) return;
336
+ if (!this.#watching(sessionId)) return;
187
337
  const w = this.#watchFor(sessionId);
188
338
  const settings = this.#deps.settings();
189
339
  if (!settings.enabled) {
190
- // Degrade to the zero-cost line (#11's plan title) — the observer is off, but the operator
191
- // still asked to be told what happened.
192
- const title = this.#deps.titleOf(sessionId);
340
+ // Degrade to a zero-cost line: the observer is off, but the operator still asked to be told
341
+ // what happened. For a beat that is 「还在跑」 — which is the whole point of the beat.
342
+ const title = this.#deps.titleOf(sessionId) ?? "会话";
343
+ if (kind === "heartbeat") {
344
+ const ran = this.#now() - (w.startedAt || w.lastActivityAt);
345
+ this.#speak(sessionId, `${title} 已跑 ${minutesLabel(ran)},还没结束`, "fallback", 1);
346
+ return;
347
+ }
193
348
  const planTitle = this.#deps.planTitleOf?.(sessionId) ?? null;
194
- if (error) this.#speak(sessionId, `${title ?? "会话"} 出错了:${oneLine(error, 60)}`, "fallback", 1);
195
- else if (planTitle) this.#speak(sessionId, `${title ?? "会话"} · ${planTitle}`, "fallback", 1);
349
+ if (error) this.#speak(sessionId, `${title} 出错了:${oneLine(error, 60)}`, "fallback", 1);
350
+ else if (planTitle) this.#speak(sessionId, `${title} · ${planTitle}`, "fallback", 1);
196
351
  return;
197
352
  }
353
+ // #23's other half, and it has to come BEFORE the busy check: a reaped observer never sends the
354
+ // turn-end that clears `busy`, so 「观察员被回收」 used to leave the watch stuck at busy=true and
355
+ // every later brief was queued into a session that no longer exists. `#ensure()` is the missing
356
+ // piece the header comment had been promising.
357
+ this.#ensure(w);
198
358
  if (w.busy) {
199
359
  // A brief is in flight: remember that another one is owed and let the current one finish,
200
360
  // rather than interleaving two turns into one voice.
201
361
  w.queued = true;
362
+ w.queuedKind = kind;
202
363
  return;
203
364
  }
204
365
  const gap = settings.throttleSec * 1000;
@@ -208,12 +369,15 @@ export class ObserverHub {
208
369
  if (!observerId) {
209
370
  if (w.spawning) {
210
371
  w.queued = true;
372
+ w.queuedKind = kind;
211
373
  return;
212
374
  }
213
375
  w.spawning = true;
214
376
  // Busy from here: the observer's FIRST turn (persona + look) is itself a brief, and a second
215
377
  // event arriving during the spawn must queue instead of starting a rival turn.
216
378
  w.busy = true;
379
+ w.lastBriefAt = this.#now();
380
+ w.briefsThisTurn++;
217
381
  try {
218
382
  observerId = await this.#spawnObserver(w);
219
383
  } catch (e) {
@@ -239,12 +403,23 @@ export class ObserverHub {
239
403
 
240
404
  w.busy = true;
241
405
  w.lastBriefAt = this.#now();
406
+ w.briefsThisTurn++;
242
407
  // The ONE thing an event passes the observer: that something happened. Everything else is read.
243
- const why = error ? "这一轮报了错。" : "这一轮结束了。";
408
+ const why =
409
+ kind === "heartbeat"
410
+ ? `这个会话已经跑了 ${minutesLabel(this.#now() - (w.startedAt || w.lastActivityAt))}还没结束。` +
411
+ "用一句话说清它现在在干什么、卡在哪;不要回「(没有可播报的进展)」。"
412
+ : error
413
+ ? "这一轮报了错。看一眼进展,按你的契约给一句播报。"
414
+ : "这一轮结束了。看一眼进展,按你的契约给一句播报。";
244
415
  try {
245
- await this.#deps.prompt(observerId, `${why}看一眼进展,按你的契约给一句播报。`);
416
+ await this.#deps.prompt(observerId, why);
246
417
  } catch (e) {
247
418
  w.busy = false;
419
+ // The prompt itself failed — most likely the process is gone. Do not keep a dead id (that was
420
+ // the permanent-failure bug): drop it and let the next event re-spawn.
421
+ this.#observers.delete(observerId);
422
+ w.observerId = null;
248
423
  this.#speak(sessionId, `播报失败:${oneLine(String((e as Error)?.message ?? e), 50)}`, "fallback", 1);
249
424
  }
250
425
  }
@@ -256,8 +431,9 @@ export class ObserverHub {
256
431
  w.busy = false;
257
432
  if (text && text !== NOTHING) this.#speak(w.observedId, text, "brief", 1);
258
433
  if (w.queued) {
434
+ const kind = w.queuedKind;
259
435
  w.queued = false;
260
- void this.#maybeBrief(w.observedId);
436
+ void this.#maybeBrief(w.observedId, undefined, kind);
261
437
  }
262
438
  }
263
439
 
@@ -288,20 +464,66 @@ export class ObserverHub {
288
464
  return id;
289
465
  }
290
466
 
467
+ /** Close an observer and drop its watch. Best effort: a failed close must not keep the bookkeeping
468
+ * alive, or the map becomes exactly the leak `forget()` was written to prevent. */
469
+ async #dispose(w: Watch): Promise<void> {
470
+ const id = w.observerId;
471
+ w.observerId = null;
472
+ w.busy = false;
473
+ w.queued = false;
474
+ if (id) {
475
+ this.#observers.delete(id);
476
+ try {
477
+ await this.#deps.disposeSession?.(id);
478
+ } catch {
479
+ /* closing is best effort — the reaper is the backstop, our map is already clean */
480
+ }
481
+ }
482
+ if (this.#watch.get(w.observedId) === w) this.#watch.delete(w.observedId);
483
+ }
484
+
291
485
  #watchFor(sessionId: string): Watch {
292
486
  let w = this.#watch.get(sessionId);
293
487
  if (!w) {
294
- w = { observedId: sessionId, observerId: null, spawning: false, lastBriefAt: 0, queued: false, answer: "", busy: false, announcedSteps: new Set() };
488
+ const now = this.#now();
489
+ w = {
490
+ observedId: sessionId, observerId: null, spawning: false, lastBriefAt: 0, queued: false,
491
+ queuedKind: "turn-end", answer: "", busy: false, announcedSteps: new Set(),
492
+ inflight: false, lastActivityAt: now, startedAt: now, briefsThisTurn: 0,
493
+ };
295
494
  this.#watch.set(sessionId, w);
296
495
  }
297
496
  return w;
298
497
  }
299
498
 
499
+ /** Make the watch honest about its observer. A process that is gone is not 「busy」: without this,
500
+ * a reap (or a close) mid-brief left `busy` true forever and the session went permanently silent. */
501
+ #ensure(w: Watch): void {
502
+ if (!w.observerId) return;
503
+ if (this.#alive(w.observerId)) return;
504
+ this.#observers.delete(w.observerId);
505
+ w.observerId = null;
506
+ w.busy = false;
507
+ w.spawning = false;
508
+ w.answer = "";
509
+ }
510
+
300
511
  #speaking(): boolean {
301
512
  if (!this.#deps.speakEnabled()) return false;
302
513
  return true;
303
514
  }
304
515
 
516
+ /** Is the operator on THIS session? The scope gate of #23; no dep = no scope (tests, old hosts). */
517
+ #watching(sessionId: string): boolean {
518
+ const fn = this.#deps.watching;
519
+ return fn ? fn(sessionId) : true;
520
+ }
521
+
522
+ #alive(sessionId: string): boolean {
523
+ const fn = this.#deps.alive;
524
+ return fn ? fn(sessionId) : true;
525
+ }
526
+
305
527
  #speak(sessionId: string, text: string, kind: "approval" | "plan-step" | "brief" | "fallback", priority: 1 | 2): void {
306
528
  const clean = oneLine(text);
307
529
  if (!clean) return;
@@ -230,10 +230,21 @@ export interface ObserverSettings {
230
230
  * events (an approval is waiting, a step completed) never wait on it. Default 120: a brief per
231
231
  * turn is fine, a brief per token is a phone that never stops talking. */
232
232
  throttleSec: number;
233
+ /** 节拍上限 (issue #24), in seconds. 0 = off = pure event-driven (what shipped in #16).
234
+ *
235
+ * What it is NOT: a period the cockpit obeys unconditionally. A long turn that never ends
236
+ * produces no `turn-end`, so the operator hears nothing for minutes — that is the hole this
237
+ * fills. The ticker only fires while a turn is IN FLIGHT and the last line was at least this
238
+ * long ago, so an idle cockpit stays silent and a busy one gets a word every N minutes.
239
+ * Deliberately separate from `throttleSec`: min-gap answers 「别话痨」, interval answers
240
+ * 「长回合别死寂」, and one number cannot serve both. */
241
+ intervalSec: number;
233
242
  }
234
243
 
235
244
  export const OBSERVER_PROMPT_MAX = 2000;
236
245
  export const OBSERVER_THROTTLE_RANGE: [number, number] = [0, 3600];
246
+ /** 0 means 「不按节拍,只在事件上说」 — the whole feature's off switch for the timer. */
247
+ export const OBSERVER_INTERVAL_RANGE: [number, number] = [0, 3600];
237
248
 
238
249
  /** What the observer is told, when the operator writes nothing. Two jobs in one paragraph: say who
239
250
  * it is watching and how to find out (the cockpit CLI — never trust the trigger line for facts), and
@@ -246,7 +257,60 @@ export const OBSERVER_PROMPT_DEFAULT = [
246
257
  ].join("\n");
247
258
 
248
259
  export const OBSERVER_DEFAULT: ObserverSettings = {
249
- enabled: false, backend: null, systemPrompt: "", throttleSec: 120,
260
+ // intervalSec 0 on purpose: a timer that spends tokens must be asked for. The speaker menu
261
+ // offers 1/2/5 minutes in one tap, so turning it on is still one gesture.
262
+ enabled: false, backend: null, systemPrompt: "", throttleSec: 120, intervalSec: 0,
263
+ };
264
+
265
+ /** 语音总结 (the message speaker's long press, 2026-10-18): ONE message → a few sentences worth
266
+ * listening to.
267
+ *
268
+ * Deliberately NOT an agent, and deliberately not a fork: no session, no history, no tools. The
269
+ * server posts the message's own text plus this instruction to `{compat}/chat/completions` — the
270
+ * same door ASR already uses — and speaks what comes back. Two reasons the history must stay out
271
+ * of it: a summary of ONE message does not need the conversation, and handing it the conversation
272
+ * makes the model summarise the wrong thing (the session instead of the message).
273
+ */
274
+ export interface SummarySettings {
275
+ /** the long-press menu offers 语音播报总结 at all; off = the menu item disappears */
276
+ enabled: boolean;
277
+ /** The instruction. "" = the built-in template (which is what the settings page shows as the
278
+ * thing it will actually send). `{maxChars}` is substituted at call time. */
279
+ prompt: string;
280
+ /** Target length of the spoken text, in characters. Also the threshold below which the message is
281
+ * spoken verbatim without asking a model at all (cheap and instant beats a paraphrase). */
282
+ maxChars: number;
283
+ /** Use THIS session's own provider + model (`deepseek:deepseek-v4-flash` → DeepSeek) instead of
284
+ * the voice endpoint. On by default: the summary is a side call on the operator's existing
285
+ * account, not a second vendor to configure. When it cannot be resolved (a provider with no
286
+ * OpenAI-compatible endpoint, no key on this box) the call falls back to the voice endpoint and
287
+ * SAYS so in the response. */
288
+ followSession: boolean;
289
+ /** chat model on the voice endpoint — now the FALLBACK (and the only route when
290
+ * `followSession` is off). "" = SUMMARY_MODEL_DEFAULT. */
291
+ model: string;
292
+ /** give up after this many seconds — and SAY so; never pass the original off as a summary. */
293
+ timeoutSec: number;
294
+ }
295
+
296
+ export const SUMMARY_PROMPT_MAX = 2000;
297
+ export const SUMMARY_CHARS_RANGE: [number, number] = [40, 400];
298
+ export const SUMMARY_TIMEOUT_RANGE: [number, number] = [5, 60];
299
+ /** Measured against this operator's workspace, 2026-10-18: `qwen-flash` answered a 40-character
300
+ * ask with 67 prompt / 20 completion tokens. `qwen3-flash` and `qwen-turbo` are 404 there ("Model
301
+ * not exist"), and `qwen3.8-flash` spent 235 output tokens thinking about the same sentence. */
302
+ export const SUMMARY_MODEL_DEFAULT = "qwen-flash";
303
+
304
+ export const SUMMARY_PROMPT_DEFAULT = [
305
+ "下面是我在一个工作会话里拿到的一段输出。请把它改写成一段「用嘴念出来」的摘要:",
306
+ "· 只讲结论和关键动作;不复述步骤,不念代码,不念表格里的数字(关键结论除外)",
307
+ "· 口语化,一句话一个意思;不要 Markdown、列表、引号、括号、表情",
308
+ "· 不超过 {maxChars} 字;先说发生了什么变化 / 发现了什么,再说影响",
309
+ "· 第一句直接说事,不要「这段内容讲了…」这种开场",
310
+ ].join("\n");
311
+
312
+ export const SUMMARY_DEFAULT: SummarySettings = {
313
+ enabled: true, prompt: "", maxChars: 120, followSession: true, model: "", timeoutSec: 20,
250
314
  };
251
315
 
252
316
  /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
@@ -284,6 +348,7 @@ export interface Settings {
284
348
  prefs: PrefsSettings;
285
349
  agent: AgentSettings;
286
350
  observer: ObserverSettings;
351
+ summary: SummarySettings;
287
352
  updatedAt: number;
288
353
  }
289
354
 
@@ -311,7 +376,7 @@ let legacyFile = "";
311
376
  let cache: Settings = {
312
377
  voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
313
378
  prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT },
314
- observer: { ...OBSERVER_DEFAULT }, updatedAt: 0,
379
+ observer: { ...OBSERVER_DEFAULT }, summary: { ...SUMMARY_DEFAULT }, updatedAt: 0,
315
380
  };
316
381
  /** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
317
382
  let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
@@ -377,6 +442,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
377
442
  prefs: pickPrefs(stored.prefs),
378
443
  agent: pickAgent(stored.agent),
379
444
  observer: pickObserver(stored.observer),
445
+ summary: pickSummary(stored.summary),
380
446
  updatedAt: Number((stored as Settings).updatedAt ?? 0),
381
447
  };
382
448
  // an unset baseUrl/apiKey bootstrap from the environment: the operator said the
@@ -446,6 +512,9 @@ function pickObserver(raw: unknown): ObserverSettings {
446
512
  const n = Number(r.throttleSec);
447
513
  const [lo, hi] = OBSERVER_THROTTLE_RANGE;
448
514
  if (Number.isFinite(n) && n >= lo && n <= hi) out.throttleSec = n;
515
+ const iv = Number(r.intervalSec);
516
+ const [ilo, ihi] = OBSERVER_INTERVAL_RANGE;
517
+ if (Number.isFinite(iv) && iv >= ilo && iv <= ihi) out.intervalSec = Math.round(iv);
449
518
  }
450
519
  return out;
451
520
  }
@@ -489,6 +558,7 @@ function persist(): void {
489
558
  db.setSetting("prefs", cache.prefs);
490
559
  db.setSetting("agent", cache.agent);
491
560
  db.setSetting("observer", cache.observer);
561
+ db.setSetting("summary", cache.summary);
492
562
  } catch (e) {
493
563
  console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
494
564
  }
@@ -541,6 +611,14 @@ export function publicSettings(): Record<string, unknown> {
541
611
  observerPromptDefault: OBSERVER_PROMPT_DEFAULT,
542
612
  observerPromptMax: OBSERVER_PROMPT_MAX,
543
613
  observerThrottleRange: OBSERVER_THROTTLE_RANGE,
614
+ observerIntervalRange: OBSERVER_INTERVAL_RANGE,
615
+ summary: cache.summary,
616
+ summaryDefaults: SUMMARY_DEFAULT,
617
+ summaryPromptDefault: SUMMARY_PROMPT_DEFAULT,
618
+ summaryPromptMax: SUMMARY_PROMPT_MAX,
619
+ summaryCharsRange: SUMMARY_CHARS_RANGE,
620
+ summaryTimeoutRange: SUMMARY_TIMEOUT_RANGE,
621
+ summaryModelDefault: SUMMARY_MODEL_DEFAULT,
544
622
  // the defaults, so the UI can offer "reset to Bailian defaults"
545
623
  defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
546
624
  updatedAt: cache.updatedAt,
@@ -777,11 +855,90 @@ export function saveObserver(patch: Record<string, unknown>): Settings {
777
855
  }
778
856
  o.throttleSec = Math.round(n);
779
857
  }
858
+ if ("intervalSec" in patch) {
859
+ const n = Number(patch.intervalSec);
860
+ const [lo, hi] = OBSERVER_INTERVAL_RANGE;
861
+ if (!Number.isFinite(n) || n < lo || n > hi) {
862
+ throw new Error(`invalid intervalSec: ${JSON.stringify(patch.intervalSec)} (expected ${lo}…${hi} seconds, 0 = off)`);
863
+ }
864
+ o.intervalSec = Math.round(n);
865
+ }
780
866
  cache = { ...cache, observer: o, updatedAt: Date.now() };
781
867
  persist();
782
868
  return cache;
783
869
  }
784
870
 
871
+ /** 语音总结 knobs. Untrusted like every other section: a corrupt row must leave it in the SHIPPED
872
+ * state (on, 120 characters, built-in prompt), never in a half-configured one. */
873
+ function pickSummary(raw: unknown): SummarySettings {
874
+ const out = { ...SUMMARY_DEFAULT };
875
+ if (raw && typeof raw === "object") {
876
+ const r = raw as Record<string, unknown>;
877
+ if (typeof r.enabled === "boolean") out.enabled = r.enabled;
878
+ if (typeof r.followSession === "boolean") out.followSession = r.followSession;
879
+ if (typeof r.prompt === "string" && r.prompt.length <= SUMMARY_PROMPT_MAX) out.prompt = r.prompt;
880
+ if (typeof r.model === "string" && r.model.length <= 60) out.model = r.model;
881
+ const chars = Number(r.maxChars);
882
+ const [clo, chi] = SUMMARY_CHARS_RANGE;
883
+ if (Number.isFinite(chars) && chars >= clo && chars <= chi) out.maxChars = Math.round(chars);
884
+ const t = Number(r.timeoutSec);
885
+ const [tlo, thi] = SUMMARY_TIMEOUT_RANGE;
886
+ if (Number.isFinite(t) && t >= tlo && t <= thi) out.timeoutSec = Math.round(t);
887
+ }
888
+ return out;
889
+ }
890
+
891
+ /** Read per request: the long press must pick up a new prompt without a restart. */
892
+ export function summarySettings(): SummarySettings {
893
+ return cache.summary;
894
+ }
895
+
896
+ /** Save 语音总结. Its own update, so a bad value here cannot lock the operator out of the page
897
+ * that fixes it. */
898
+ export function saveSummary(patch: Record<string, unknown>): Settings {
899
+ const s = { ...cache.summary };
900
+ if ("enabled" in patch) {
901
+ if (typeof patch.enabled !== "boolean") throw new Error("invalid enabled: expected true/false");
902
+ s.enabled = patch.enabled;
903
+ }
904
+ if ("prompt" in patch) {
905
+ const v = String(patch.prompt ?? "");
906
+ if (v.length > SUMMARY_PROMPT_MAX) {
907
+ throw new Error(`invalid prompt: longer than ${SUMMARY_PROMPT_MAX} characters`);
908
+ }
909
+ // whitespace-only means "use the built-in template", same rule as the observer's wording
910
+ s.prompt = v.trim() ? v : "";
911
+ }
912
+ if ("followSession" in patch) {
913
+ if (typeof patch.followSession !== "boolean") throw new Error("invalid followSession: expected true/false");
914
+ s.followSession = patch.followSession;
915
+ }
916
+ if ("model" in patch) {
917
+ const v = String(patch.model ?? "").trim();
918
+ if (v.length > 60) throw new Error("invalid model: longer than 60 characters");
919
+ s.model = v;
920
+ }
921
+ if ("maxChars" in patch) {
922
+ const n = Number(patch.maxChars);
923
+ const [lo, hi] = SUMMARY_CHARS_RANGE;
924
+ if (!Number.isFinite(n) || n < lo || n > hi) {
925
+ throw new Error(`invalid maxChars: ${JSON.stringify(patch.maxChars)} (expected ${lo}…${hi})`);
926
+ }
927
+ s.maxChars = Math.round(n);
928
+ }
929
+ if ("timeoutSec" in patch) {
930
+ const n = Number(patch.timeoutSec);
931
+ const [lo, hi] = SUMMARY_TIMEOUT_RANGE;
932
+ if (!Number.isFinite(n) || n < lo || n > hi) {
933
+ throw new Error(`invalid timeoutSec: ${JSON.stringify(patch.timeoutSec)} (expected ${lo}…${hi})`);
934
+ }
935
+ s.timeoutSec = Math.round(n);
936
+ }
937
+ cache = { ...cache, summary: s, updatedAt: Date.now() };
938
+ persist();
939
+ return cache;
940
+ }
941
+
785
942
  /** The plan-hint knobs. Read on EVERY prompt (not cached here), so the switch in 设置 takes effect
786
943
  * on the operator's very next turn — no restart, no session to hibernate. */
787
944
  export function planHintSettings(): PlanHintSettings {