agentus 0.3.9 → 0.4.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/README.md +35 -0
- package/README.zh-CN.md +31 -0
- package/bin/agentus.mjs +27 -0
- package/bin/cli.mjs +339 -0
- package/docs/installation.md +1 -0
- package/package.json +3 -1
- package/packages/server/mcp/plan-server.mjs +18 -3
- package/packages/server/mock/agent.mjs +19 -0
- package/packages/server/src/acp/backends.ts +25 -0
- package/packages/server/src/acp/registry.ts +6 -0
- package/packages/server/src/acp/session-manager.ts +269 -25
- package/packages/server/src/cockpit/api.ts +402 -0
- package/packages/server/src/index.ts +293 -7
- package/packages/server/src/notify/center.ts +55 -4
- package/packages/server/src/notify/types.ts +6 -1
- package/packages/server/src/observer/hub.ts +327 -0
- package/packages/server/src/plan/plan.ts +16 -0
- package/packages/server/src/settings.ts +166 -8
- package/packages/server/src/skills/install.ts +102 -0
- package/packages/server/src/store/store.ts +51 -13
- package/packages/shared/src/index.ts +34 -1
- package/packages/web/dist/assets/index-CRo7LVdP.css +32 -0
- package/packages/web/dist/assets/index-q4g0lPdX.js +123 -0
- package/packages/web/dist/index.html +2 -2
- package/skills/agentus-cockpit/SKILL.md +183 -0
- package/packages/web/dist/assets/index-CMRMZCT8.js +0 -122
- package/packages/web/dist/assets/index-LqLBh4zI.css +0 -32
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// 工作播报 (issue #16): ONE long-lived observer agent per watched session, driven by the cockpit.
|
|
2
|
+
//
|
|
3
|
+
// WHY AN AGENT AND NOT A TEMPLATE
|
|
4
|
+
// The cockpit could say 「回合结束了」 by itself — that is exactly what #11's plan title does, and it
|
|
5
|
+
// stays as the fallback. What it cannot do is NOTICE: 「第三步失败了,它在重试同一条命令」 is a
|
|
6
|
+
// judgement about a transcript, and a string template that tries to make that judgement is a
|
|
7
|
+
// summariser nobody can debug. So the cockpit hires a reader: a normal ACP session with its own
|
|
8
|
+
// transcript, spawned once, kept alive, and asked one line per event (「看看进展」). Because it is
|
|
9
|
+
// long-lived its context carries over — the second brief costs a line, not the whole story — and
|
|
10
|
+
// because its facts come from the cockpit CLI (`$AGENTUS_COCKPIT_BIN`, issue #15) the trigger line
|
|
11
|
+
// never has to carry data that could be wrong.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THIS MODULE IS ALLOWED TO KNOW
|
|
14
|
+
// Nothing about agents' internals. It reads ServerEvents off the same emitter the browser reads,
|
|
15
|
+
// decides "is this worth a voice", and (for two event kinds) formats the line ITSELF with no model
|
|
16
|
+
// call at all: an approval waiting and a plan step closing are facts, and paying a model to restate a
|
|
17
|
+
// fact is how a cockpit becomes expensive and vague.
|
|
18
|
+
//
|
|
19
|
+
// THE OBSERVER IS A SYSTEM SESSION
|
|
20
|
+
// Its working directory is a scratch directory of its own (`<home>/observers/<sid>`), never the
|
|
21
|
+
// observed session's cwd: an observer runs a full agent runtime with file and shell tools, and it
|
|
22
|
+
// should no more be able to write into the project than a log shipper should. `system: true` keeps it
|
|
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.
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import type { BackendId, ServerEvent } from "../../../shared/src/index.js";
|
|
28
|
+
import type { ObserverSettings } from "../settings.js";
|
|
29
|
+
import { OBSERVER_PROMPT_DEFAULT } from "../settings.js";
|
|
30
|
+
|
|
31
|
+
/** Spoken lines are short on purpose: they are heard once, in the middle of other work. */
|
|
32
|
+
export const BRIEF_MAX = 120;
|
|
33
|
+
|
|
34
|
+
export interface ObserverDeps {
|
|
35
|
+
/** Live settings, read per event — the switch must work without a restart. */
|
|
36
|
+
settings(): ObserverSettings;
|
|
37
|
+
/** Whether the operator's speaker is in 工作播报 mode. False = do not even try. */
|
|
38
|
+
speakEnabled(): boolean;
|
|
39
|
+
/** Spawn a SYSTEM session (out of the rail); returns our id for it. */
|
|
40
|
+
createSession(backend: BackendId, cwd: string, title: string): Promise<string>;
|
|
41
|
+
/** Send one line to a session. Never `interrupt`: an observer must not take the floor. */
|
|
42
|
+
prompt(sessionId: string, text: string): Promise<void>;
|
|
43
|
+
/** Resolve once a freshly spawned session can accept a prompt (its handshake finished). */
|
|
44
|
+
waitReady(sessionId: string, timeoutMs?: number): Promise<void>;
|
|
45
|
+
/** The title a session is known by (for the trigger line and the prompt header). */
|
|
46
|
+
titleOf(sessionId: string): string | null;
|
|
47
|
+
/** Which backend the observed session runs on (used when the observer follows it). */
|
|
48
|
+
backendOf(sessionId: string): BackendId | null;
|
|
49
|
+
/** #11's one-line task title for a session — the zero-cost fallback when no observer can speak. */
|
|
50
|
+
planTitleOf?(sessionId: string): string | null;
|
|
51
|
+
/** Close (or just forget) an observer whose observed session is gone. */
|
|
52
|
+
disposeSession?(observerId: string): Promise<void>;
|
|
53
|
+
/** Best-effort: move a session into a read-only mode if the backend offers one. */
|
|
54
|
+
preferReadonly?(sessionId: string): void;
|
|
55
|
+
/** Base directory for per-observer scratch dirs. */
|
|
56
|
+
home: string;
|
|
57
|
+
emit(evt: ServerEvent): void;
|
|
58
|
+
now?(): number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Watch {
|
|
62
|
+
/** The session being watched (the watch map is keyed by it too; kept for the observer's side). */
|
|
63
|
+
observedId: string;
|
|
64
|
+
/** Our observer session id, once one exists. */
|
|
65
|
+
observerId: string | null;
|
|
66
|
+
/** A spawn is in flight — do not start a second one. */
|
|
67
|
+
spawning: boolean;
|
|
68
|
+
/** When the last MODEL-backed brief went out (for the throttle). */
|
|
69
|
+
lastBriefAt: number;
|
|
70
|
+
/** The trigger is owed: an event arrived while a brief was still being produced. */
|
|
71
|
+
queued: boolean;
|
|
72
|
+
/** Text of the observer's current turn, accumulated from `message` frames. */
|
|
73
|
+
answer: string;
|
|
74
|
+
/** True while the observer is producing a brief. */
|
|
75
|
+
busy: boolean;
|
|
76
|
+
/** Plan step keys already announced, so an unchanged plan is not read out twice. */
|
|
77
|
+
announcedSteps: Set<string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NOTHING = "(没有可播报的进展)";
|
|
81
|
+
|
|
82
|
+
/** Cut a model's answer down to one spoken line: first non-empty line, no Markdown furniture, no
|
|
83
|
+
* quotes, hard cap. A brief is read aloud, so anything resembling formatting is noise. */
|
|
84
|
+
export function oneLine(text: string, max = BRIEF_MAX): string {
|
|
85
|
+
const first = text
|
|
86
|
+
.split(/\r?\n/)
|
|
87
|
+
.map((l) => l.replace(/^\s*(?:[-*#>\d.)\s]+)/, "").trim())
|
|
88
|
+
.find((l) => l.length > 0) ?? "";
|
|
89
|
+
const flat = first
|
|
90
|
+
.replace(/[*_`]/g, "")
|
|
91
|
+
.replace(/^["'「『]+|["'」』]+$/g, "")
|
|
92
|
+
.replace(/\s+/g, " ")
|
|
93
|
+
.trim();
|
|
94
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class ObserverHub {
|
|
98
|
+
#deps: ObserverDeps;
|
|
99
|
+
#watch = new Map<string, Watch>();
|
|
100
|
+
/** Observer ids, so their own frames are never mistaken for the operator's sessions. */
|
|
101
|
+
#observers = new Set<string>();
|
|
102
|
+
#now: () => number;
|
|
103
|
+
|
|
104
|
+
constructor(deps: ObserverDeps) {
|
|
105
|
+
this.#deps = deps;
|
|
106
|
+
this.#now = deps.now ?? Date.now;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
observerIds(): string[] {
|
|
110
|
+
return [...this.#observers];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
isObserver(sessionId: string): boolean {
|
|
114
|
+
return this.#observers.has(sessionId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Forget everything about a session (it was closed, archived or purged). The observer process is
|
|
118
|
+
* the idle reaper's business; we only stop treating this pair as current. */
|
|
119
|
+
forget(sessionId: string): void {
|
|
120
|
+
const w = this.#watch.get(sessionId);
|
|
121
|
+
this.#watch.delete(sessionId);
|
|
122
|
+
if (w?.observerId) this.#observers.delete(w.observerId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every ServerEvent in the process passes through here (index.ts wires it in front of the
|
|
126
|
+
* broadcast). Cheap by construction: a few kind checks per frame. */
|
|
127
|
+
onEvent(evt: ServerEvent): void {
|
|
128
|
+
if (evt.t === "message") {
|
|
129
|
+
// The observer's own answer, being assembled.
|
|
130
|
+
const sid = evt.message.sessionId;
|
|
131
|
+
// `message` frames for an observer carry the OBSERVER's id, while the watch entry is keyed by
|
|
132
|
+
// the OBSERVED session — so this is a lookup by observer id, not by key.
|
|
133
|
+
for (const watch of this.#watch.values()) {
|
|
134
|
+
if (watch.observerId !== sid) continue;
|
|
135
|
+
if (evt.message.kind === "agent") watch.answer += textOfPayload(evt.message.payload);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (evt.t === "turn-end") {
|
|
141
|
+
// An observer's own turn-end IS the brief's arrival — resolved by observer id, because the
|
|
142
|
+
// watch map is keyed by the OBSERVED session (looking the observer up in it finds nothing,
|
|
143
|
+
// which is how the first version turned every observer turn into another trigger).
|
|
144
|
+
if (this.#observers.has(evt.sessionId)) {
|
|
145
|
+
for (const w of this.#watch.values()) if (w.observerId === evt.sessionId) this.#finishBrief(w);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
void this.#maybeBrief(evt.sessionId, evt.error);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
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.
|
|
155
|
+
if (!this.#speaking()) return;
|
|
156
|
+
const sid = evt.request.sessionId;
|
|
157
|
+
const title = evt.request.toolCallTitle?.trim();
|
|
158
|
+
this.#speak(sid, title ? `有请求等你确认:${title}` : "有个请求等你确认", "approval", 2);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (evt.t === "plan") {
|
|
163
|
+
if (!this.#speaking()) return;
|
|
164
|
+
const w = this.#watchFor(evt.sessionId);
|
|
165
|
+
const plan = evt.plan;
|
|
166
|
+
if (!plan) return;
|
|
167
|
+
for (const item of plan.items) {
|
|
168
|
+
if (item.status !== "completed") continue;
|
|
169
|
+
// PlanItem has no id: its content IS the identity (ACP replaces the whole list each frame).
|
|
170
|
+
const key = item.content;
|
|
171
|
+
if (w.announcedSteps.has(key)) continue;
|
|
172
|
+
w.announcedSteps.add(key);
|
|
173
|
+
// The FIRST completed step of a plan is worth a word; after that the rail is the place to
|
|
174
|
+
// read progress, and a voice per step would be a metronome.
|
|
175
|
+
if (w.announcedSteps.size === 1) {
|
|
176
|
+
this.#speak(evt.sessionId, `完成一步:${oneLine(item.content, 60)}`, "plan-step", 1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A turn ended on a session the operator owns: is it worth a brief? */
|
|
184
|
+
async #maybeBrief(sessionId: string, error?: string): Promise<void> {
|
|
185
|
+
if (!this.#speaking()) return;
|
|
186
|
+
if (this.#observers.has(sessionId)) return;
|
|
187
|
+
const w = this.#watchFor(sessionId);
|
|
188
|
+
const settings = this.#deps.settings();
|
|
189
|
+
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);
|
|
193
|
+
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);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (w.busy) {
|
|
199
|
+
// A brief is in flight: remember that another one is owed and let the current one finish,
|
|
200
|
+
// rather than interleaving two turns into one voice.
|
|
201
|
+
w.queued = true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const gap = settings.throttleSec * 1000;
|
|
205
|
+
if (gap > 0 && this.#now() - w.lastBriefAt < gap) return;
|
|
206
|
+
|
|
207
|
+
let observerId = w.observerId;
|
|
208
|
+
if (!observerId) {
|
|
209
|
+
if (w.spawning) {
|
|
210
|
+
w.queued = true;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
w.spawning = true;
|
|
214
|
+
// Busy from here: the observer's FIRST turn (persona + look) is itself a brief, and a second
|
|
215
|
+
// event arriving during the spawn must queue instead of starting a rival turn.
|
|
216
|
+
w.busy = true;
|
|
217
|
+
try {
|
|
218
|
+
observerId = await this.#spawnObserver(w);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
w.spawning = false;
|
|
221
|
+
w.busy = false;
|
|
222
|
+
// Degrade rather than go silent: the operator asked for 工作播报 and a broken observer must
|
|
223
|
+
// not turn the feature off without telling them.
|
|
224
|
+
const planTitle = this.#deps.planTitleOf?.(sessionId) ?? null;
|
|
225
|
+
const title = this.#deps.titleOf(sessionId) ?? "会话";
|
|
226
|
+
this.#speak(
|
|
227
|
+
sessionId,
|
|
228
|
+
planTitle ? `${title} · ${planTitle}` : `${title} 的播报员没起来:${oneLine(String((e as Error)?.message ?? e), 50)}`,
|
|
229
|
+
"fallback",
|
|
230
|
+
1,
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
w.spawning = false;
|
|
235
|
+
// `#spawnObserver` already registered the id and marked the watch busy: the first brief is
|
|
236
|
+
// produced by the prompt it sent, so there is nothing to send here.
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
w.busy = true;
|
|
241
|
+
w.lastBriefAt = this.#now();
|
|
242
|
+
// The ONE thing an event passes the observer: that something happened. Everything else is read.
|
|
243
|
+
const why = error ? "这一轮报了错。" : "这一轮结束了。";
|
|
244
|
+
try {
|
|
245
|
+
await this.#deps.prompt(observerId, `${why}看一眼进展,按你的契约给一句播报。`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
w.busy = false;
|
|
248
|
+
this.#speak(sessionId, `播报失败:${oneLine(String((e as Error)?.message ?? e), 50)}`, "fallback", 1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The observer answered: turn its text into the spoken line. */
|
|
253
|
+
#finishBrief(w: Watch): void {
|
|
254
|
+
const text = oneLine(w.answer);
|
|
255
|
+
w.answer = "";
|
|
256
|
+
w.busy = false;
|
|
257
|
+
if (text && text !== NOTHING) this.#speak(w.observedId, text, "brief", 1);
|
|
258
|
+
if (w.queued) {
|
|
259
|
+
w.queued = false;
|
|
260
|
+
void this.#maybeBrief(w.observedId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async #spawnObserver(w: Watch): Promise<string> {
|
|
265
|
+
const observedId = w.observedId;
|
|
266
|
+
const settings = this.#deps.settings();
|
|
267
|
+
const backend = settings.backend ?? this.#deps.backendOf(observedId);
|
|
268
|
+
if (!backend) throw new Error("不知道该用哪个后端");
|
|
269
|
+
const dir = path.join(this.#deps.home, "observers", observedId);
|
|
270
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
271
|
+
const title = this.#deps.titleOf(observedId) ?? observedId;
|
|
272
|
+
const id = await this.#deps.createSession(backend, dir, `观察员 · ${title}`);
|
|
273
|
+
// Registered BEFORE its first prompt: the turn it is about to run is a brief, and its frames must
|
|
274
|
+
// already be attributable to this pair (see the turn-end branch in onEvent).
|
|
275
|
+
w.observerId = id;
|
|
276
|
+
this.#observers.add(id);
|
|
277
|
+
// `create` returns before the handshake by design; an observer is spawned and prompted in the
|
|
278
|
+
// same breath, so wait for the one fact only the agent can give us.
|
|
279
|
+
await this.#deps.waitReady(id);
|
|
280
|
+
this.#deps.preferReadonly?.(id);
|
|
281
|
+
// The prompt is the operator's, plus the one fact the observer cannot read for itself: which
|
|
282
|
+
// session it is watching. Everything else it looks up.
|
|
283
|
+
const wording = settings.systemPrompt.trim() || OBSERVER_PROMPT_DEFAULT;
|
|
284
|
+
const system = `${wording}\n\n你负责的会话 id 是 ${observedId}(名字:${title})。`;
|
|
285
|
+
// ACP has no "system prompt" call: the persona rides as the observer's first turn, and its own
|
|
286
|
+
// transcript keeps it. Everything after that is a one-line trigger.
|
|
287
|
+
await this.#deps.prompt(id, `${system}\n\n现在先看一眼这个会话的现状,给一句播报。`);
|
|
288
|
+
return id;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#watchFor(sessionId: string): Watch {
|
|
292
|
+
let w = this.#watch.get(sessionId);
|
|
293
|
+
if (!w) {
|
|
294
|
+
w = { observedId: sessionId, observerId: null, spawning: false, lastBriefAt: 0, queued: false, answer: "", busy: false, announcedSteps: new Set() };
|
|
295
|
+
this.#watch.set(sessionId, w);
|
|
296
|
+
}
|
|
297
|
+
return w;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#speaking(): boolean {
|
|
301
|
+
if (!this.#deps.speakEnabled()) return false;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#speak(sessionId: string, text: string, kind: "approval" | "plan-step" | "brief" | "fallback", priority: 1 | 2): void {
|
|
306
|
+
const clean = oneLine(text);
|
|
307
|
+
if (!clean) return;
|
|
308
|
+
this.#deps.emit({
|
|
309
|
+
t: "speak",
|
|
310
|
+
id: `${sessionId}:${kind}:${this.#now()}`,
|
|
311
|
+
sessionId,
|
|
312
|
+
text: clean,
|
|
313
|
+
kind,
|
|
314
|
+
priority,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The streamed text inside an `agent`/`thought` payload (ACP: `content.text`). */
|
|
320
|
+
function textOfPayload(payload: unknown): string {
|
|
321
|
+
const c = (payload as { content?: unknown } | null)?.content;
|
|
322
|
+
if (c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string") {
|
|
323
|
+
return (c as { text: string }).text;
|
|
324
|
+
}
|
|
325
|
+
const t = (payload as { text?: unknown } | null)?.text;
|
|
326
|
+
return typeof t === "string" ? t : "";
|
|
327
|
+
}
|
|
@@ -19,6 +19,22 @@ import type { PlanItem } from "../../../shared/src/index.js";
|
|
|
19
19
|
/** Studio caps its plan remark at 1000 chars; take the same bound rather than invent one. */
|
|
20
20
|
export const MAX_EXPLANATION = 1000;
|
|
21
21
|
|
|
22
|
+
/** How long the plan's `title` may be.
|
|
23
|
+
*
|
|
24
|
+
* Forty is a NOTIFICATION's budget, not a document's: the body line it lands on already carries the
|
|
25
|
+
* session name and the duration (`nameAndDetail`), and the collapsed row of a phone card is one
|
|
26
|
+
* line. A writer that wants to say more writes a remark — `explanation` is the place for prose. */
|
|
27
|
+
export const MAX_TITLE = 40;
|
|
28
|
+
|
|
29
|
+
/** Coerce a writer's `title`. Collapsed whitespace, and `null` for anything that is not a real
|
|
30
|
+
* string: an empty title must land as "no title", never as an empty line on the card. */
|
|
31
|
+
export function clampTitle(raw: unknown): string | null {
|
|
32
|
+
if (typeof raw !== "string") return null;
|
|
33
|
+
const s = raw.replace(/\s+/g, " ").trim();
|
|
34
|
+
if (!s) return null;
|
|
35
|
+
return s.length > MAX_TITLE ? s.slice(0, MAX_TITLE) : s;
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
export type PlanTerminal = "ended" | "interrupted" | "failed";
|
|
23
39
|
export type PlanSource = "acp" | "mcp" | "server";
|
|
24
40
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// masked value round-trips as null and keeps the stored secret.
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
|
+
import type { BackendId } from "../../shared/src/index.js";
|
|
21
22
|
import type { Store } from "./store/store.js";
|
|
22
23
|
|
|
23
24
|
export interface VoiceSettings {
|
|
@@ -91,8 +92,13 @@ export interface CallSettings {
|
|
|
91
92
|
* the speed have to be the same on the phone as on the laptop, and a second tab disagreeing
|
|
92
93
|
* with the first is a bug, not a feature. */
|
|
93
94
|
export interface PrefsSettings {
|
|
94
|
-
/** read a finished reply aloud without being asked
|
|
95
|
+
/** read a finished reply aloud without being asked. Kept as a DERIVED mirror of `speakMode`:
|
|
96
|
+
* the two used to be one switch, and a client that only knows this name must keep working. */
|
|
95
97
|
autoRead: boolean;
|
|
98
|
+
/** WHAT gets read aloud (issue #16). "off" = nothing; "reply" = the finished reply, full text
|
|
99
|
+
* (the old autoRead); "brief" = the cockpit's own one-line briefs (`t:"speak"`), which is what
|
|
100
|
+
* a long answer needs — reading a whole reply aloud was the thing operators turned off. */
|
|
101
|
+
speakMode: SpeakMode;
|
|
96
102
|
/** browser voiceURI ("" = pick the best for the language) */
|
|
97
103
|
voiceURI: string;
|
|
98
104
|
rate: number;
|
|
@@ -133,8 +139,11 @@ export interface PlanHintSettings {
|
|
|
133
139
|
instruction: string;
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
export const SPEAK_MODES = ["off", "reply", "brief"] as const;
|
|
143
|
+
export type SpeakMode = (typeof SPEAK_MODES)[number];
|
|
144
|
+
|
|
136
145
|
export const PREFS_DEFAULT: PrefsSettings = {
|
|
137
|
-
autoRead: false, voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
|
|
146
|
+
autoRead: false, speakMode: "off", voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
|
|
138
147
|
// On + every turn by default: the feature exists because the tool was there and nobody used it,
|
|
139
148
|
// and 对齐 Studio means the block rides on every turn (see everyTurn's note). Both are visible
|
|
140
149
|
// and switchable in 设置 — this is a default, not a lock.
|
|
@@ -142,7 +151,10 @@ export const PREFS_DEFAULT: PrefsSettings = {
|
|
|
142
151
|
};
|
|
143
152
|
|
|
144
153
|
export const CALL_DEFAULT: CallSettings = {
|
|
145
|
-
|
|
154
|
+
/* 说完停顿 1.5s (was 1.2): the operator reads a pause as "it stopped listening" before 1.2 s,
|
|
155
|
+
and a long sentence with a breath in the middle got cut. Both ends of the range still let
|
|
156
|
+
the room decide — this is only where a fresh install starts. */
|
|
157
|
+
interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1500, minChars: 3,
|
|
146
158
|
};
|
|
147
159
|
|
|
148
160
|
export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
|
|
@@ -167,9 +179,18 @@ export interface AgentSettings {
|
|
|
167
179
|
* is a permanent memory floor (5 × ~137 MB RSS measured); with it, a cockpit left alone for a day
|
|
168
180
|
* comes back to nothing running. 0 = 不过期. */
|
|
169
181
|
warmTtlHours: number;
|
|
182
|
+
/** 驾驶舱互发消息 (issue #15): may an agent hand a message to ANOTHER session through the cockpit
|
|
183
|
+
* CLI (`agentus cockpit send`)? Off by default — two agents that can talk to each other without
|
|
184
|
+
* being asked are a loop generator, and the operator pays for those tokens. Reading the cockpit
|
|
185
|
+
* (`context`/`messages`/`plan`) is NOT gated: it cannot change anything, and it is what the
|
|
186
|
+
* 工作播报 observer runs on. */
|
|
187
|
+
cockpitSend: boolean;
|
|
170
188
|
}
|
|
171
189
|
|
|
172
|
-
export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24 };
|
|
190
|
+
export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false };
|
|
191
|
+
|
|
192
|
+
/** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
|
|
193
|
+
type AgentNumberKey = "idleKillMin" | "warmSlots" | "warmTtlHours";
|
|
173
194
|
|
|
174
195
|
/** What the settings page may offer: the ends of the number field. Server-side enforcement uses
|
|
175
196
|
* the same pair, so a hand-written request cannot store a nonsense value. */
|
|
@@ -180,6 +201,48 @@ export const AGENT_WARM_RANGE: [number, number] = [0, 20];
|
|
|
180
201
|
/** 0 = 不过期; a week is the longest span worth offering on the page. */
|
|
181
202
|
export const AGENT_WARM_TTL_RANGE: [number, number] = [0, 168];
|
|
182
203
|
|
|
204
|
+
/** 观察智能体 (`工作播报`, issue #16): ONE long-lived ACP session per observed session, spawned by the
|
|
205
|
+
* cockpit, in its own scratch directory, read-only, out of the rail. It is the thing that turns
|
|
206
|
+
* 「回合结束了」 into one sentence a person can listen to — it reads the cockpit through the CLI
|
|
207
|
+
* (issue #15) instead of being handed a summary, because a summariser that is told what happened
|
|
208
|
+
* cannot notice what the operator actually cares about.
|
|
209
|
+
*
|
|
210
|
+
* Everything here is the operator's: the switch, which backend/model runs it (a cheap fast model is
|
|
211
|
+
* the right answer), the wording it answers in, and the throttle. */
|
|
212
|
+
export interface ObserverSettings {
|
|
213
|
+
/** 工作播报 on/off. Off = no observer is spawned at all and the speaker falls back to #11's
|
|
214
|
+
* plan title (zero cost), then to nothing. */
|
|
215
|
+
enabled: boolean;
|
|
216
|
+
/** Which backend the observer runs on. null = same backend as the observed session (the default:
|
|
217
|
+
* an install that only configured one agent should not have to configure a second one). */
|
|
218
|
+
backend: BackendId | null;
|
|
219
|
+
/** The observer's own wording. "" = the built-in template (which is also what the settings page
|
|
220
|
+
* shows as the thing it will actually send). Sized like the plan hint's instruction: a few
|
|
221
|
+
* sentences of house style, not a document — it rides on ONE turn per brief. */
|
|
222
|
+
systemPrompt: string;
|
|
223
|
+
/** Shortest gap between two MODEL-backed briefs for the same session, in seconds. Zero-cost
|
|
224
|
+
* events (an approval is waiting, a step completed) never wait on it. Default 120: a brief per
|
|
225
|
+
* turn is fine, a brief per token is a phone that never stops talking. */
|
|
226
|
+
throttleSec: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const OBSERVER_PROMPT_MAX = 2000;
|
|
230
|
+
export const OBSERVER_THROTTLE_RANGE: [number, number] = [0, 3600];
|
|
231
|
+
|
|
232
|
+
/** What the observer is told, when the operator writes nothing. Two jobs in one paragraph: say who
|
|
233
|
+
* it is watching and how to find out (the cockpit CLI — never trust the trigger line for facts), and
|
|
234
|
+
* fix the output shape (one sentence, ≤60 characters, spoken aloud by a TTS voice). */
|
|
235
|
+
export const OBSERVER_PROMPT_DEFAULT = [
|
|
236
|
+
"你是这个驾驶舱里某个会话的观察员。用 `$AGENTUS_COCKPIT_BIN session list` 看全景,",
|
|
237
|
+
"用 `session <id>` / `messages <id> --tail 20` / `plan <id>` 核实它正在做什么、卡在哪。",
|
|
238
|
+
"只陈述这些工具读到的事实,不猜、不补、不评价;工具没说的就当不知道。",
|
|
239
|
+
"输出一句中文播报,不超过 60 字,会被语音念出来:不要 Markdown、不要列表、不要引号、不要解释你在做什么。",
|
|
240
|
+
].join("\n");
|
|
241
|
+
|
|
242
|
+
export const OBSERVER_DEFAULT: ObserverSettings = {
|
|
243
|
+
enabled: false, backend: null, systemPrompt: "", throttleSec: 120,
|
|
244
|
+
};
|
|
245
|
+
|
|
183
246
|
/** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
|
|
184
247
|
* outside both the range table and the loops that validate numbers. */
|
|
185
248
|
export const CALL_NUMERIC = ["bargeSensitivity", "bargeMs", "silenceMs", "minChars"] as const;
|
|
@@ -214,6 +277,7 @@ export interface Settings {
|
|
|
214
277
|
call: CallSettings;
|
|
215
278
|
prefs: PrefsSettings;
|
|
216
279
|
agent: AgentSettings;
|
|
280
|
+
observer: ObserverSettings;
|
|
217
281
|
updatedAt: number;
|
|
218
282
|
}
|
|
219
283
|
|
|
@@ -240,7 +304,8 @@ let db: Store | null = null;
|
|
|
240
304
|
let legacyFile = "";
|
|
241
305
|
let cache: Settings = {
|
|
242
306
|
voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
|
|
243
|
-
prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT },
|
|
307
|
+
prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT },
|
|
308
|
+
observer: { ...OBSERVER_DEFAULT }, updatedAt: 0,
|
|
244
309
|
};
|
|
245
310
|
/** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
|
|
246
311
|
let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
|
|
@@ -305,6 +370,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
|
|
|
305
370
|
call: pickCall(stored.call),
|
|
306
371
|
prefs: pickPrefs(stored.prefs),
|
|
307
372
|
agent: pickAgent(stored.agent),
|
|
373
|
+
observer: pickObserver(stored.observer),
|
|
308
374
|
updatedAt: Number((stored as Settings).updatedAt ?? 0),
|
|
309
375
|
};
|
|
310
376
|
// an unset baseUrl/apiKey bootstrap from the environment: the operator said the
|
|
@@ -342,7 +408,9 @@ function pickAgent(raw: unknown): AgentSettings {
|
|
|
342
408
|
const out = { ...AGENT_DEFAULT };
|
|
343
409
|
if (raw && typeof raw === "object") {
|
|
344
410
|
const r = raw as Record<string, unknown>;
|
|
345
|
-
|
|
411
|
+
// Explicitly the NUMERIC keys: `AgentSettings` also carries `cockpitSend: boolean`, and a
|
|
412
|
+
// `keyof` assignment would have to satisfy both sides of the union.
|
|
413
|
+
const take = (key: AgentNumberKey, range: [number, number]): void => {
|
|
346
414
|
const v = Number(r[key]);
|
|
347
415
|
const [lo, hi] = range;
|
|
348
416
|
if (Number.isFinite(v) && v >= lo && v <= hi) out[key] = v;
|
|
@@ -350,6 +418,27 @@ function pickAgent(raw: unknown): AgentSettings {
|
|
|
350
418
|
take("idleKillMin", AGENT_IDLE_RANGE);
|
|
351
419
|
take("warmSlots", AGENT_WARM_RANGE);
|
|
352
420
|
take("warmTtlHours", AGENT_WARM_TTL_RANGE);
|
|
421
|
+
if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The observer's own section. Untrusted like the rest: an unknown backend id, a prompt over the
|
|
427
|
+
* cap or a throttle outside the range falls back to the shipped default rather than throwing —
|
|
428
|
+
* this section decides whether the cockpit spawns an agent to talk about another agent, and a
|
|
429
|
+
* corrupt row must leave it in the SHIPPED state (off), not in a half-configured one. */
|
|
430
|
+
function pickObserver(raw: unknown): ObserverSettings {
|
|
431
|
+
const out = { ...OBSERVER_DEFAULT };
|
|
432
|
+
if (raw && typeof raw === "object") {
|
|
433
|
+
const r = raw as Record<string, unknown>;
|
|
434
|
+
if (typeof r.enabled === "boolean") out.enabled = r.enabled;
|
|
435
|
+
if (typeof r.backend === "string" && r.backend) out.backend = r.backend as BackendId;
|
|
436
|
+
if (typeof r.systemPrompt === "string" && r.systemPrompt.length <= OBSERVER_PROMPT_MAX) {
|
|
437
|
+
out.systemPrompt = r.systemPrompt;
|
|
438
|
+
}
|
|
439
|
+
const n = Number(r.throttleSec);
|
|
440
|
+
const [lo, hi] = OBSERVER_THROTTLE_RANGE;
|
|
441
|
+
if (Number.isFinite(n) && n >= lo && n <= hi) out.throttleSec = n;
|
|
353
442
|
}
|
|
354
443
|
return out;
|
|
355
444
|
}
|
|
@@ -358,7 +447,12 @@ function pickPrefs(raw: unknown): PrefsSettings {
|
|
|
358
447
|
const out = { ...PREFS_DEFAULT };
|
|
359
448
|
if (raw && typeof raw === "object") {
|
|
360
449
|
const r = raw as Record<string, unknown>;
|
|
361
|
-
|
|
450
|
+
// `speakMode` is the source of truth; `autoRead` is the pre-#16 name for its "reply" value. A
|
|
451
|
+
// stored section carries one or the other depending on which build wrote it last, so accept
|
|
452
|
+
// both and let the newer name win when both are present.
|
|
453
|
+
if (r.speakMode === "off" || r.speakMode === "reply" || r.speakMode === "brief") out.speakMode = r.speakMode;
|
|
454
|
+
else if (typeof r.autoRead === "boolean") out.speakMode = r.autoRead ? "reply" : "off";
|
|
455
|
+
out.autoRead = out.speakMode === "reply";
|
|
362
456
|
if (typeof r.serverTts === "boolean") out.serverTts = r.serverTts;
|
|
363
457
|
if (typeof r.voiceURI === "string" && r.voiceURI.length <= 200) out.voiceURI = r.voiceURI;
|
|
364
458
|
if (typeof r.lang === "string" && (!r.lang || /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(r.lang))) out.lang = r.lang;
|
|
@@ -387,6 +481,7 @@ function persist(): void {
|
|
|
387
481
|
db.setSetting("call", cache.call);
|
|
388
482
|
db.setSetting("prefs", cache.prefs);
|
|
389
483
|
db.setSetting("agent", cache.agent);
|
|
484
|
+
db.setSetting("observer", cache.observer);
|
|
390
485
|
} catch (e) {
|
|
391
486
|
console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
|
|
392
487
|
}
|
|
@@ -434,6 +529,11 @@ export function publicSettings(): Record<string, unknown> {
|
|
|
434
529
|
agentIdleRange: AGENT_IDLE_RANGE,
|
|
435
530
|
agentWarmRange: AGENT_WARM_RANGE,
|
|
436
531
|
agentWarmTtlRange: AGENT_WARM_TTL_RANGE,
|
|
532
|
+
observer: cache.observer,
|
|
533
|
+
observerDefaults: OBSERVER_DEFAULT,
|
|
534
|
+
observerPromptDefault: OBSERVER_PROMPT_DEFAULT,
|
|
535
|
+
observerPromptMax: OBSERVER_PROMPT_MAX,
|
|
536
|
+
observerThrottleRange: OBSERVER_THROTTLE_RANGE,
|
|
437
537
|
// the defaults, so the UI can offer "reset to Bailian defaults"
|
|
438
538
|
defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
|
|
439
539
|
updatedAt: cache.updatedAt,
|
|
@@ -554,6 +654,18 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
|
|
|
554
654
|
if (!["auto", "browser", "stream", "server"].includes(v)) throw new Error(`invalid stt: ${JSON.stringify(patch.stt)}`);
|
|
555
655
|
next.stt = v as PrefsSettings["stt"];
|
|
556
656
|
}
|
|
657
|
+
// 三档(关 / 自动朗读 / 工作播报). The old boolean still writes, so a cached page from before this
|
|
658
|
+
// change does something sensible instead of failing: autoRead:true = "reply", false = "off".
|
|
659
|
+
if ("speakMode" in patch) {
|
|
660
|
+
const v = String(patch.speakMode ?? "");
|
|
661
|
+
if (!SPEAK_MODES.includes(v as SpeakMode)) throw new Error(`invalid speakMode: ${JSON.stringify(patch.speakMode)}`);
|
|
662
|
+
next.speakMode = v as SpeakMode;
|
|
663
|
+
next.autoRead = v === "reply";
|
|
664
|
+
} else if ("autoRead" in patch) {
|
|
665
|
+
if (typeof patch.autoRead !== "boolean") throw new Error("invalid autoRead: expected true/false");
|
|
666
|
+
next.speakMode = patch.autoRead ? "reply" : "off";
|
|
667
|
+
next.autoRead = patch.autoRead;
|
|
668
|
+
}
|
|
557
669
|
if ("planHint" in patch) {
|
|
558
670
|
const h = patch.planHint;
|
|
559
671
|
if (!h || typeof h !== "object") throw new Error("invalid planHint: expected an object");
|
|
@@ -589,7 +701,7 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
|
|
|
589
701
|
* text fields. */
|
|
590
702
|
export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
591
703
|
const a = { ...cache.agent };
|
|
592
|
-
const set = (key:
|
|
704
|
+
const set = (key: AgentNumberKey, range: [number, number], unit: string, off: string): void => {
|
|
593
705
|
if (!(key in patch)) return;
|
|
594
706
|
const raw = typeof patch[key] === "number" ? (patch[key] as number) : Number(patch[key]);
|
|
595
707
|
const [lo, hi] = range;
|
|
@@ -601,6 +713,11 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
|
601
713
|
set("idleKillMin", AGENT_IDLE_RANGE, "minutes", "0 = off");
|
|
602
714
|
set("warmSlots", AGENT_WARM_RANGE, "slots", "0 = off");
|
|
603
715
|
set("warmTtlHours", AGENT_WARM_TTL_RANGE, "hours", "0 = never expires");
|
|
716
|
+
// A boolean next to three numbers: spelled out rather than forced through the numeric `set`.
|
|
717
|
+
if ("cockpitSend" in patch) {
|
|
718
|
+
if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
|
|
719
|
+
a.cockpitSend = patch.cockpitSend;
|
|
720
|
+
}
|
|
604
721
|
cache = { ...cache, agent: a, updatedAt: Date.now() };
|
|
605
722
|
persist();
|
|
606
723
|
return cache;
|
|
@@ -613,6 +730,47 @@ export function agentSettings(): AgentSettings {
|
|
|
613
730
|
return cache.agent;
|
|
614
731
|
}
|
|
615
732
|
|
|
733
|
+
/** 观察智能体 knobs (issue #16). Read per event, like the plan hint: 工作播报 must be switchable
|
|
734
|
+
* without restarting anything. */
|
|
735
|
+
export function observerSettings(): ObserverSettings {
|
|
736
|
+
return cache.observer;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Save 观察智能体. Its own update, like the agent knobs, so a bad value in this section cannot
|
|
740
|
+
* lock the operator out of the page that fixes it. */
|
|
741
|
+
export function saveObserver(patch: Record<string, unknown>): Settings {
|
|
742
|
+
const o = { ...cache.observer };
|
|
743
|
+
if ("enabled" in patch) {
|
|
744
|
+
if (typeof patch.enabled !== "boolean") throw new Error("invalid enabled: expected true/false");
|
|
745
|
+
o.enabled = patch.enabled;
|
|
746
|
+
}
|
|
747
|
+
if ("backend" in patch) {
|
|
748
|
+
const v = patch.backend;
|
|
749
|
+
if (v === null || v === "") o.backend = null;
|
|
750
|
+
else if (typeof v === "string" && v.length <= 40) o.backend = v as BackendId;
|
|
751
|
+
else throw new Error(`invalid backend: ${JSON.stringify(v).slice(0, 40)}`);
|
|
752
|
+
}
|
|
753
|
+
if ("systemPrompt" in patch) {
|
|
754
|
+
const v = String(patch.systemPrompt ?? "");
|
|
755
|
+
if (v.length > OBSERVER_PROMPT_MAX) {
|
|
756
|
+
throw new Error(`invalid systemPrompt: longer than ${OBSERVER_PROMPT_MAX} characters`);
|
|
757
|
+
}
|
|
758
|
+
// Whitespace-only means "use the built-in template", the same rule the plan hint uses.
|
|
759
|
+
o.systemPrompt = v.trim() ? v : "";
|
|
760
|
+
}
|
|
761
|
+
if ("throttleSec" in patch) {
|
|
762
|
+
const n = Number(patch.throttleSec);
|
|
763
|
+
const [lo, hi] = OBSERVER_THROTTLE_RANGE;
|
|
764
|
+
if (!Number.isFinite(n) || n < lo || n > hi) {
|
|
765
|
+
throw new Error(`invalid throttleSec: ${JSON.stringify(patch.throttleSec)} (expected ${lo}…${hi} seconds)`);
|
|
766
|
+
}
|
|
767
|
+
o.throttleSec = Math.round(n);
|
|
768
|
+
}
|
|
769
|
+
cache = { ...cache, observer: o, updatedAt: Date.now() };
|
|
770
|
+
persist();
|
|
771
|
+
return cache;
|
|
772
|
+
}
|
|
773
|
+
|
|
616
774
|
/** The plan-hint knobs. Read on EVERY prompt (not cached here), so the switch in 设置 takes effect
|
|
617
775
|
* on the operator's very next turn — no restart, no session to hibernate. */
|
|
618
776
|
export function planHintSettings(): PlanHintSettings {
|