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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
154
|
-
//
|
|
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
|
-
/**
|
|
184
|
-
|
|
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
|
|
191
|
-
//
|
|
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
|
|
195
|
-
else if (planTitle) this.#speak(sessionId, `${title
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|