agentus 0.14.1 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
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,6 +30,9 @@ import {
30
30
  modelUsage,
31
31
  } from "./settings.js";
32
32
  import { ObserverHub } from "./observer/hub.js";
33
+ import {
34
+ SPEAK_INTERVAL_MAX, allSessionSpeak, clearSessionSpeak, initSessionSpeak, resolveSpeak, setSessionSpeak,
35
+ } from "./session-speak.js";
33
36
  import * as auth from "./auth.js";
34
37
  import { NotifyCenter } from "./notify/center.js";
35
38
  import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent, SessionInfo } from "../../shared/src/index.js";
@@ -273,7 +276,15 @@ hub = new ObserverHub({
273
276
  settings: observerSettings,
274
277
  // 工作播报 is the operator's third speaker position; in the other two the cockpit says nothing, so
275
278
  // no observer is spawned and no tokens are spent.
276
- speakEnabled: () => getSettings().prefs.speakMode === "brief",
279
+ //
280
+ // Per SESSION since #68: the horn belongs to the conversation it was switched on in, and the
281
+ // global `prefs.speakMode` is only the DEFAULT for sessions that were never given their own value.
282
+ // Resolved in one place (`resolveSpeak`) so the horn, the hub and the diagnostic endpoint can
283
+ // never disagree about what a session is set to.
284
+ speakOf: (id) => resolveSpeak(id, {
285
+ mode: getSettings().prefs.speakMode,
286
+ intervalSec: getSettings().observer.intervalSec,
287
+ }),
277
288
  createSession: async (backend, cwd, title) => (await mgr.create(backend, cwd, title, { system: true })).id,
278
289
  prompt: (id, text) => mgr.prompt(id, text, []),
279
290
  waitReady: (id, ms) => mgr.awaitReady(id, ms).then(() => undefined),
@@ -304,6 +315,9 @@ hub = new ObserverHub({
304
315
  // wrong session. `notify` is a `const` declared below — this closure only ever runs from an event
305
316
  // handler, never during construction.
306
317
  watching: (id) => notify.operatorWatching(id),
318
+ // 播报的「他在看这个会话」是另一件事 (issue #69 §5): broadcasting exists so he can look AWAY, so it
319
+ // asks only whether this is the session he is on, not whether he touched the keyboard recently.
320
+ onSession: (id) => notify.operatorOnSession(id),
307
321
  // The reaped-observer fix: a `system` session the idle reaper took no longer accepts prompts, and
308
322
  // trusting its id forever is what made the brief say 「播报失败:no such session」 until a restart.
309
323
  // `hasSession` (not `list()`) on purpose: `list()` is the RAIL's view and excludes system sessions,
@@ -411,6 +425,8 @@ notify.onNotice((notice) => {
411
425
  // `settings` table: the UI owns them and env only bootstraps them (settings.ts). Read
412
426
  // before anything asks.
413
427
  initSettings(DATA_DIR, store);
428
+ // 会话级喇叭档位 (issue #68) rides the same settings table under its own key.
429
+ initSessionSpeak(store);
414
430
  // Dynamic hotwords are mined from the transcript — the store's business, not voice.ts's,
415
431
  // so the lookup is injected rather than imported.
416
432
  setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word));
@@ -1061,6 +1077,61 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
1061
1077
  });
1062
1078
  }
1063
1079
 
1080
+ // ---- 会话级喇叭档位 (issue #68) -------------------------------------------------------
1081
+ // The horn in the corner belongs to the conversation it was switched on in: turning it on in one
1082
+ // session used to make EVERY session start reading aloud. The global `prefs.speakMode` is now the
1083
+ // DEFAULT layer — a session without its own row inherits it, which is the whole compatibility
1084
+ // story (nothing changes for anyone who never touched a horn).
1085
+ if (url.pathname === "/api/speak-modes" && req.method === "GET") {
1086
+ if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
1087
+ const p = getSettings().prefs;
1088
+ const o = getSettings().observer;
1089
+ return send(res, 200, {
1090
+ sessions: allSessionSpeak(),
1091
+ defaults: { mode: p.speakMode, intervalSec: o.intervalSec },
1092
+ // The observer is an ACP process that spends tokens, so a beat on it cannot fire while that
1093
+ // section is off — the menu has to say so instead of showing a tickmark (#69 §1).
1094
+ observerEnabled: o.enabled,
1095
+ });
1096
+ }
1097
+ // 一个会话的档位:GET 解析,PUT 写会话行,DELETE 回到「跟全局默认走」。
1098
+ const speakRoute = /^\/api\/sessions\/([\w.-]+)\/speak$/.exec(url.pathname);
1099
+ if (speakRoute) {
1100
+ if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
1101
+ const sid = speakRoute[1];
1102
+ if (req.method === "GET") {
1103
+ return send(res, 200, { speak: resolveSpeak(sid, { mode: getSettings().prefs.speakMode, intervalSec: getSettings().observer.intervalSec }) });
1104
+ }
1105
+ if (req.method === "PUT" || req.method === "POST") {
1106
+ const body = await readJson(req);
1107
+ const mode = String(body.mode ?? "");
1108
+ if (mode !== "off" && mode !== "reply" && mode !== "brief") {
1109
+ return send(res, 400, { error: `invalid mode: ${JSON.stringify(body.mode)}` });
1110
+ }
1111
+ const intervalSec = Number(body.intervalSec ?? 0);
1112
+ if (!Number.isFinite(intervalSec) || intervalSec < 0 || intervalSec > SPEAK_INTERVAL_MAX) {
1113
+ return send(res, 400, { error: `invalid intervalSec: ${JSON.stringify(body.intervalSec)}` });
1114
+ }
1115
+ const speak = setSessionSpeak(sid, mode, intervalSec);
1116
+ // 档位离开 brief 就是收尾 (#69 §2): the observer is a whole ACP process, and waiting for the
1117
+ // idle reaper would be five more minutes of paying for a feature that was just switched off.
1118
+ hub?.onPolicyChanged();
1119
+ return send(res, 200, {
1120
+ speak,
1121
+ resolved: resolveSpeak(sid, { mode: getSettings().prefs.speakMode, intervalSec: getSettings().observer.intervalSec }),
1122
+ });
1123
+ }
1124
+ if (req.method === "DELETE") {
1125
+ // 回到默认层。删掉这一行而不是写 off:写 off 会让它永远不再继承默认。
1126
+ clearSessionSpeak(sid);
1127
+ hub?.onPolicyChanged();
1128
+ return send(res, 200, {
1129
+ resolved: resolveSpeak(sid, { mode: getSettings().prefs.speakMode, intervalSec: getSettings().observer.intervalSec }),
1130
+ });
1131
+ }
1132
+ return send(res, 405, { error: "method not allowed" });
1133
+ }
1134
+
1064
1135
  // ---- the phone's onboarding page + the APK, both behind the operator login ----------
1065
1136
  // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
1066
1137
  // sent to the login screen (the SPA at /) and comes back with a session cookie.
@@ -1503,7 +1574,12 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1503
1574
  if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer && !body.summary) saveSettings(body);
1504
1575
  return send(res, 200, publicSettings());
1505
1576
  } catch (e) {
1506
- return send(res, 400, { error: String((e as Error)?.message ?? e) });
1577
+ // `field` rides along when the validator named one (#65): the page shows it under that input.
1578
+ const field = (e as { field?: unknown })?.field;
1579
+ return send(res, 400, {
1580
+ error: String((e as Error)?.message ?? e),
1581
+ ...(typeof field === "string" ? { field } : {}),
1582
+ });
1507
1583
  }
1508
1584
  }
1509
1585
  // The model ids the configured endpoint really serves, for the settings dropdowns
@@ -315,6 +315,21 @@ export class NotifyCenter {
315
315
  return this.presence.visible || Boolean(this.presence.call);
316
316
  }
317
317
 
318
+ /** Is this the session the operator is ON right now — sessionId + TTL only, with no 「page visible
319
+ * / keyboard touched」 requirement (issue #69 §5).
320
+ *
321
+ * `operatorWatching` above answers 「is he in front of the screen」, which is the right question for
322
+ * a notification: do not make a sound at someone who is already looking. 工作播报 asks the opposite
323
+ * question — it exists so that he can look AWAY and still hear progress — so reusing the
324
+ * screen-presence test there meant the beat required him to sit and watch, and it stayed silent
325
+ * exactly when it was needed. Same presence record, one TTL, two questions.
326
+ */
327
+ operatorOnSession(sessionId: string): boolean {
328
+ if (!sessionId) return false;
329
+ if (this.presence.sessionId !== sessionId) return false;
330
+ return Date.now() - this.presence.at < NotifyCenter.WATCH_TTL_MS;
331
+ }
332
+
318
333
  constructor(opts: NotifyCenterOptions) {
319
334
  this.dir = path.join(opts.dataDir, "notify");
320
335
  fs.mkdirSync(this.dir, { recursive: true });
@@ -40,6 +40,7 @@ import fs from "node:fs";
40
40
  import path from "node:path";
41
41
  import type { BackendId, ServerEvent } from "../../../shared/src/index.js";
42
42
  import type { ObserverSettings } from "../settings.js";
43
+ import type { SpeakMode } from "../session-speak.js";
43
44
  import { OBSERVER_PROMPT_DEFAULT } from "../settings.js";
44
45
 
45
46
  /** Spoken lines are short on purpose: they are heard once, in the middle of other work. */
@@ -59,8 +60,12 @@ const TICK_MS = 15_000;
59
60
  export interface ObserverDeps {
60
61
  /** Live settings, read per event — the switch must work without a restart. */
61
62
  settings(): ObserverSettings;
62
- /** Whether the operator's speaker is in 工作播报 mode. False = do not even try. */
63
- speakEnabled(): boolean;
63
+ /** The speaker position that applies to ONE session (issue #68): a session the operator set up
64
+ * itself wins, otherwise the global default. This replaced a single global `speakEnabled()`,
65
+ * which was why turning the horn on in one session made every other session start talking. */
66
+ speakOf?(sessionId: string): { mode: SpeakMode; intervalSec: number };
67
+ /** Legacy fallback for hosts and unit tests that only know the global switch. */
68
+ speakEnabled?(): boolean;
64
69
  /** Spawn a SYSTEM session (out of the rail); returns our id for it. */
65
70
  createSession(backend: BackendId, cwd: string, title: string): Promise<string>;
66
71
  /** Send one line to a session. Never `interrupt`: an observer must not take the floor. */
@@ -77,9 +82,14 @@ export interface ObserverDeps {
77
82
  disposeSession?(observerId: string): Promise<void>;
78
83
  /** Best-effort: move a session into a read-only mode if the backend offers one. */
79
84
  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. */
85
+ /** Is the operator looking at THIS session right now — the NOTIFY centre's presence, which asks
86
+ * 「is he in front of the screen」. Absent = every session counts. */
82
87
  watching?(sessionId: string): boolean;
88
+ /** 播报的「这是他当前那个会话吗」(issue #69 §5): sessionId + TTL only, NO 「2 分钟内动过键鼠」.
89
+ * 工作播报 exists precisely so the operator can look away — reusing the notification's
90
+ * screen-presence test for it made the beat require him to be watching, which defeats the feature.
91
+ * Absent = fall back to `watching`, so an old host keeps working. */
92
+ onSession?(sessionId: string): boolean;
83
93
  /** Does this session still have a live ACP process? Absent = assume yes. This is the half of #23
84
94
  * that turns a reaped observer into a recoverable one instead of a permanent 「播报失败」. */
85
95
  alive?(sessionId: string): boolean;
@@ -117,8 +127,23 @@ interface Watch {
117
127
  startedAt: number;
118
128
  /** Model-backed briefs spent on the CURRENT turn (the MAX_BRIEFS_PER_TURN counter). */
119
129
  briefsThisTurn: number;
130
+ /** Why the last beat skipped this watch (issue #69 §6): the beat used to be silent in every sense —
131
+ * no sound, no log line, no field on the diagnostic endpoint, so 「它到底有没有在跑」 could only be
132
+ * guessed at. Kept per watch and cheap (one small object). */
133
+ lastSkip: { reason: SkipReason; at: number } | null;
120
134
  }
121
135
 
136
+ /** Every reason a beat can decline to speak. The names are the contract with `/api/observer`. */
137
+ export type SkipReason =
138
+ | "disabled" // the observer section itself is off
139
+ | "speaker-off" // this session's speaker is not in 工作播报
140
+ | "no-beat" // speaker is on 工作播报 but no interval was chosen
141
+ | "busy" // a brief is already being produced
142
+ | "not-inflight" // no turn running → nothing to report
143
+ | "not-current" // not the session the operator is on
144
+ | "no-beat-yet" // the interval has not elapsed
145
+ | "per-turn-cap"; // MAX_BRIEFS_PER_TURN reached
146
+
122
147
  const NOTHING = "(没有可播报的进展)";
123
148
 
124
149
  /** Cut a model's answer down to one spoken line: first non-empty line, no Markdown furniture, no
@@ -173,7 +198,11 @@ export class ObserverHub {
173
198
  return this.#ticks;
174
199
  }
175
200
 
176
- debug(): { watched: string; observerId: string | null; inflight: boolean; busy: boolean; briefs: number; watching: boolean; quietMs: number }[] {
201
+ debug(): {
202
+ watched: string; observerId: string | null; inflight: boolean; busy: boolean; briefs: number;
203
+ watching: boolean; quietMs: number; mode: SpeakMode; intervalSec: number;
204
+ skip: SkipReason | null; skipAgoMs: number | null;
205
+ }[] {
177
206
  const now = this.#now();
178
207
  return [...this.#watch.values()].map((w) => ({
179
208
  watched: w.observedId,
@@ -183,6 +212,10 @@ export class ObserverHub {
183
212
  briefs: w.briefsThisTurn,
184
213
  watching: this.#watching(w.observedId),
185
214
  quietMs: now - w.lastActivityAt,
215
+ mode: this.#mode(w.observedId),
216
+ intervalSec: this.#beat(w.observedId),
217
+ skip: w.lastSkip?.reason ?? null,
218
+ skipAgoMs: w.lastSkip ? now - w.lastSkip.at : null,
186
219
  }));
187
220
  }
188
221
 
@@ -225,8 +258,12 @@ export class ObserverHub {
225
258
  * Until #23 this was invisible — `speakEnabled()` only made the hub idle, while the observer
226
259
  * process (a whole ACP runtime, ~137 MB) sat there for the reaper's five minutes. */
227
260
  onPolicyChanged(): void {
228
- if (this.#deps.speakEnabled() && this.#deps.settings().enabled) return;
229
- for (const w of [...this.#watch.values()]) void this.#dispose(w);
261
+ const enabled = this.#deps.settings().enabled;
262
+ for (const w of [...this.#watch.values()]) {
263
+ // The section itself off, or THIS session no longer on 工作播报 (issue #69 §2: leaving the
264
+ // 档位 must reap its observer instead of leaving an orphan behind for the idle reaper).
265
+ if (!enabled || this.#mode(w.observedId) !== "brief") void this.#dispose(w);
266
+ }
230
267
  }
231
268
 
232
269
  /** Every ServerEvent in the process passes through here (index.ts wires it in front of the
@@ -278,7 +315,7 @@ export class ObserverHub {
278
315
  // still delivers it (approvals are never suppressed by 「我在看」), so the voice follows the
279
316
  // same scope as everything else here. Flip this to `#speaking()` alone if 「后台审批也念出来」
280
317
  // turns out to be wanted.
281
- if (!this.#speaking()) return;
318
+ if (!this.#speaking(evt.request.sessionId)) return;
282
319
  if (!this.#watching(evt.request.sessionId)) return;
283
320
  const sid = evt.request.sessionId;
284
321
  const title = evt.request.toolCallTitle?.trim();
@@ -287,7 +324,7 @@ export class ObserverHub {
287
324
  }
288
325
 
289
326
  if (evt.t === "plan") {
290
- if (!this.#speaking()) return;
327
+ if (!this.#speaking(evt.sessionId)) return;
291
328
  const plan = evt.plan;
292
329
  if (!plan) return;
293
330
  // Zero-cost, but still an announcement: the rail is where you read progress on a session you
@@ -315,23 +352,30 @@ export class ObserverHub {
315
352
  tick(): void {
316
353
  this.#ticks++;
317
354
  const s = this.#deps.settings();
318
- if (!s.enabled || s.intervalSec <= 0 || !this.#deps.speakEnabled()) return;
355
+ if (!s.enabled) return; // the observer section itself is off
319
356
  const now = this.#now();
320
357
  for (const w of [...this.#watch.values()]) {
358
+ const mode = this.#mode(w.observedId);
359
+ if (mode !== "brief") { this.#skip(w, "speaker-off"); continue; }
360
+ const beat = this.#beat(w.observedId);
361
+ if (beat <= 0) { this.#skip(w, "no-beat"); continue; }
321
362
  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
363
+ if (w.busy || w.spawning) { this.#skip(w, "busy"); continue; }
364
+ if (!w.inflight) { this.#skip(w, "not-inflight"); continue; }
365
+ // #69 §5: the beat asks 「is this the session he is on」, NOT 「has he touched the keyboard in
366
+ // the last two minutes」 — asking the operator to sit and watch is the opposite of the feature.
367
+ if (!this.#onSession(w.observedId)) { this.#skip(w, "not-current"); continue; }
368
+ if (now - w.lastBriefAt < beat * 1000) { this.#skip(w, "no-beat-yet"); continue; }
369
+ // #69 §3: `throttleSec` is the de-duplicating gap for EVENT-driven briefs (see #maybeBrief).
370
+ // Applying it here as well meant 「每 1 分钟」 could never fire faster than 2 minutes.
371
+ if (w.briefsThisTurn >= MAX_BRIEFS_PER_TURN) { this.#skip(w, "per-turn-cap"); continue; }
328
372
  void this.#maybeBrief(w.observedId, undefined, "heartbeat");
329
373
  }
330
374
  }
331
375
 
332
376
  /** A turn ended (or a beat came due) on a session the operator owns: is it worth a brief? */
333
377
  async #maybeBrief(sessionId: string, error?: string, kind: "turn-end" | "heartbeat" = "turn-end"): Promise<void> {
334
- if (!this.#speaking()) return;
378
+ if (!this.#speaking(sessionId)) return;
335
379
  if (this.#observers.has(sessionId)) return;
336
380
  if (!this.#watching(sessionId)) return;
337
381
  const w = this.#watchFor(sessionId);
@@ -362,8 +406,12 @@ export class ObserverHub {
362
406
  w.queuedKind = kind;
363
407
  return;
364
408
  }
409
+ // #69 §3: `throttleSec` de-duplicates EVENT-driven briefs (two turn-ends a second apart must not
410
+ // buy two model calls). A beat the operator picked himself was already spaced by `intervalSec`
411
+ // in tick(), and applying the gap a second time here is exactly what made 「每 1 分钟」 faster
412
+ // than every 2 minutes on the wire — the gate passed, then this line shut it again.
365
413
  const gap = settings.throttleSec * 1000;
366
- if (gap > 0 && this.#now() - w.lastBriefAt < gap) return;
414
+ if (kind !== "heartbeat" && gap > 0 && this.#now() - w.lastBriefAt < gap) return;
367
415
 
368
416
  let observerId = w.observerId;
369
417
  if (!observerId) {
@@ -377,7 +425,6 @@ export class ObserverHub {
377
425
  // event arriving during the spawn must queue instead of starting a rival turn.
378
426
  w.busy = true;
379
427
  w.lastBriefAt = this.#now();
380
- w.briefsThisTurn++;
381
428
  try {
382
429
  observerId = await this.#spawnObserver(w);
383
430
  } catch (e) {
@@ -403,7 +450,9 @@ export class ObserverHub {
403
450
 
404
451
  w.busy = true;
405
452
  w.lastBriefAt = this.#now();
406
- w.briefsThisTurn++;
453
+ // `briefsThisTurn` is charged in #finishBrief, and only for a line that really went out (#69 §6):
454
+ // charging it here meant a sentinel answer (没有可播报的进展)or a degraded fallback still spent
455
+ // one of the MAX_BRIEFS_PER_TURN, so eight quiet answers could mute the rest of a long turn.
407
456
  // The ONE thing an event passes the observer: that something happened. Everything else is read.
408
457
  const why =
409
458
  kind === "heartbeat"
@@ -429,7 +478,10 @@ export class ObserverHub {
429
478
  const text = oneLine(w.answer);
430
479
  w.answer = "";
431
480
  w.busy = false;
432
- if (text && text !== NOTHING) this.#speak(w.observedId, text, "brief", 1);
481
+ if (text && text !== NOTHING) {
482
+ this.#speak(w.observedId, text, "brief", 1);
483
+ w.briefsThisTurn++; // spent by a line the operator actually heard
484
+ }
433
485
  if (w.queued) {
434
486
  const kind = w.queuedKind;
435
487
  w.queued = false;
@@ -489,7 +541,7 @@ export class ObserverHub {
489
541
  w = {
490
542
  observedId: sessionId, observerId: null, spawning: false, lastBriefAt: 0, queued: false,
491
543
  queuedKind: "turn-end", answer: "", busy: false, announcedSteps: new Set(),
492
- inflight: false, lastActivityAt: now, startedAt: now, briefsThisTurn: 0,
544
+ inflight: false, lastActivityAt: now, startedAt: now, briefsThisTurn: 0, lastSkip: null,
493
545
  };
494
546
  this.#watch.set(sessionId, w);
495
547
  }
@@ -508,9 +560,34 @@ export class ObserverHub {
508
560
  w.answer = "";
509
561
  }
510
562
 
511
- #speaking(): boolean {
512
- if (!this.#deps.speakEnabled()) return false;
513
- return true;
563
+ /** 这个会话现在开着工作播报吗 (issue #68: per session, not one global switch). */
564
+ #speaking(sessionId: string): boolean {
565
+ return this.#mode(sessionId) === "brief";
566
+ }
567
+
568
+ /** The speaker position that applies to one session: the host resolves session row → global
569
+ * default. `speakEnabled` is the single-switch fallback for hosts and tests that predate #68. */
570
+ #mode(sessionId: string): SpeakMode {
571
+ const fn = this.#deps.speakOf;
572
+ if (fn) return fn(sessionId).mode;
573
+ return this.#deps.speakEnabled?.() ? "brief" : "off";
574
+ }
575
+
576
+ /** The beat for one session, in seconds (0 = the operator did not ask for a beat). */
577
+ #beat(sessionId: string): number {
578
+ const fn = this.#deps.speakOf;
579
+ if (fn) return fn(sessionId).intervalSec;
580
+ return this.#deps.settings().intervalSec;
581
+ }
582
+
583
+ /** Broadcasting asks a weaker question than notifying (issue #69 §5). */
584
+ #onSession(sessionId: string): boolean {
585
+ const fn = this.#deps.onSession;
586
+ return fn ? fn(sessionId) : this.#watching(sessionId);
587
+ }
588
+
589
+ #skip(w: Watch, reason: SkipReason): void {
590
+ w.lastSkip = { reason, at: this.#now() };
514
591
  }
515
592
 
516
593
  /** Is the operator on THIS session? The scope gate of #23; no dep = no scope (tests, old hosts). */
@@ -0,0 +1,89 @@
1
+ // 会话级的「喇叭档位」(issue #68).
2
+ //
3
+ // 全局 `prefs.speakMode` 表达的是「我怎么工作」,所以它跟着人跨设备走(见 settings.ts 文件头)。
4
+ // 但「我在盯这一个长任务,把它念出来」是**某一个会话**的意图 —— 一个会话打开喇叭,其它会话跟着
5
+ // 一起念,正是 issue #68 报的行为。两层不冲突:全局是**默认层**,会话行一旦存在就覆盖它。
6
+ //
7
+ // 存储用 settings 表里的一行 JSON(key = "sessionSpeak"):
8
+ // { "<sessionId>": { "mode": "off"|"reply"|"brief", "intervalSec": number, "at": number } }
9
+ // 不新开表,因为这一层没有任何按条件查询的需求(一次全读,一次一行写),而 settings 表本来就在做
10
+ // 「操作员配置」这件事;新表还要配迁移与清理。
11
+ import type { Store } from "./store/store.js";
12
+
13
+ export type SpeakMode = "off" | "reply" | "brief";
14
+
15
+ export interface SessionSpeak {
16
+ mode: SpeakMode;
17
+ /** 只在 mode === "brief" 时有意义:每 N 秒报一次。 */
18
+ intervalSec: number;
19
+ at: number;
20
+ }
21
+
22
+ /** 存储层的粗校验上限;真正的可选档位由前端的喇叭菜单决定。 */
23
+ export const SPEAK_INTERVAL_MAX = 3600;
24
+
25
+ let db: Store | null = null;
26
+
27
+ export function initSessionSpeak(store: Store): void {
28
+ db = store;
29
+ }
30
+
31
+ function pick(raw: unknown): SessionSpeak | null {
32
+ if (!raw || typeof raw !== "object") return null;
33
+ const r = raw as Record<string, unknown>;
34
+ const mode = r.mode;
35
+ if (mode !== "off" && mode !== "reply" && mode !== "brief") return null;
36
+ const iv = Number(r.intervalSec);
37
+ const intervalSec = Number.isFinite(iv) && iv >= 0 && iv <= SPEAK_INTERVAL_MAX ? Math.round(iv) : 0;
38
+ const at = Number(r.at);
39
+ return { mode, intervalSec, at: Number.isFinite(at) ? at : 0 };
40
+ }
41
+
42
+ /** 所有会话自己的档位。**没有**这一行的会话 = 继承全局默认(这正是兼容性的全部内容)。 */
43
+ export function allSessionSpeak(): Record<string, SessionSpeak> {
44
+ const raw = db?.getSetting("sessionSpeak");
45
+ const out: Record<string, SessionSpeak> = {};
46
+ if (!raw || typeof raw !== "object") return out;
47
+ for (const [id, v] of Object.entries(raw as Record<string, unknown>)) {
48
+ const s = pick(v);
49
+ if (s) out[id] = s;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ export function sessionSpeakOf(sessionId: string): SessionSpeak | null {
55
+ if (!sessionId) return null;
56
+ return allSessionSpeak()[sessionId] ?? null;
57
+ }
58
+
59
+ export function setSessionSpeak(sessionId: string, mode: SpeakMode, intervalSec = 0): SessionSpeak {
60
+ const all = allSessionSpeak();
61
+ const next: SessionSpeak = {
62
+ mode,
63
+ intervalSec: mode === "brief" ? Math.min(SPEAK_INTERVAL_MAX, Math.max(0, Math.round(intervalSec))) : 0,
64
+ at: Date.now(),
65
+ };
66
+ all[sessionId] = next;
67
+ db?.setSetting("sessionSpeak", all);
68
+ return next;
69
+ }
70
+
71
+ /** 回到「跟全局默认走」。删掉这一行,**而不是**写一条 `off` —— 写 off 会让这个会话永远不再继承
72
+ * 默认层,下次操作员改全局默认时它还会是静音的,那就成了另一种「莫名其妙」。 */
73
+ export function clearSessionSpeak(sessionId: string): void {
74
+ const all = allSessionSpeak();
75
+ if (!(sessionId in all)) return;
76
+ delete all[sessionId];
77
+ db?.setSetting("sessionSpeak", all);
78
+ }
79
+
80
+ /** 一个会话实际生效的档位:会话行优先,否则全局默认。
81
+ * 这是唯一一处把两层合成一个答案的地方 —— hub 的闸门、喇叭的显示、诊断端点都读它。 */
82
+ export function resolveSpeak(
83
+ sessionId: string,
84
+ dflt: { mode: SpeakMode; intervalSec: number },
85
+ ): { mode: SpeakMode; intervalSec: number; source: "session" | "default" } {
86
+ const own = sessionSpeakOf(sessionId);
87
+ if (own) return { mode: own.mode, intervalSec: own.intervalSec, source: "session" };
88
+ return { mode: dflt.mode, intervalSec: dflt.intervalSec, source: "default" };
89
+ }
@@ -753,7 +753,12 @@ export function publicSettings(): Record<string, unknown> {
753
753
  };
754
754
  }
755
755
 
756
- /** Merge a partial update. `apiKey: null` keeps the stored key (the UI round-trips a
756
+ /** A validation failure that names the field it rejected. */
757
+ function fieldError(field: string, message: string): Error {
758
+ return Object.assign(new Error(message), { field });
759
+ }
760
+
761
+ /** Merge a partial update. `apiKey: *** keeps the stored key (the UI round-trips a
757
762
  * mask, never the secret). Empty strings clear. Values are validated narrowly: this
758
763
  * config reaches an external API with the operator's credentials. */
759
764
  export function saveSettings(patch: Record<string, unknown>): Settings {
@@ -781,7 +786,10 @@ export function saveSettings(patch: Record<string, unknown>): Settings {
781
786
  if (raw === null || raw === undefined) { /* keep */ }
782
787
  else if (typeof raw === "string" && (raw === "" || /^sk-[\w.-]{6,}$/.test(raw))) v.apiKey = raw;
783
788
  else if (typeof raw === "string" && raw.startsWith("••••")) { /* unchanged mask: keep */ }
784
- else throw new Error("invalid apiKey (expected sk-…, or empty to clear)");
789
+ // `field` is what lets the page put this message under the box that caused it. A banner at the
790
+ // top of a long settings page is invisible from the bottom of it, which is how 「保存没反应」
791
+ // became the reported bug (#65) when the server had answered 400 with the exact reason.
792
+ else throw fieldError("voice.apiKey", "invalid apiKey (expected sk-…, or empty to clear)");
785
793
  }
786
794
  // a baseUrl typed as the compatible-mode URL still works: store the root for the
787
795
  // native endpoints and derive the compatible path from it