agentus 0.13.1 → 0.14.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 +1 -1
- package/packages/server/mock/agent.mjs +33 -0
- package/packages/server/src/acp/session-manager.ts +91 -5
- package/packages/server/src/index.ts +5 -0
- package/packages/server/src/model-default.ts +179 -0
- package/packages/server/src/settings.ts +45 -2
- package/packages/server/src/store/store.ts +14 -0
- package/packages/shared/src/index.ts +3 -0
- package/packages/web/dist/assets/{index-07wTpZW8.css → index-BERyIWEi.css} +1 -1
- package/packages/web/dist/assets/index-Ct70Y7d4.js +131 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sfx/interrupt.wav +0 -0
- package/packages/web/dist/sfx/thinking.m4a +0 -0
- package/packages/web/dist/sfx/thinking.mp3 +0 -0
- package/packages/web/dist/assets/index-CBdoe4-J.js +0 -131
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": {
|
|
@@ -617,6 +617,39 @@ const agent = () => ({
|
|
|
617
617
|
return { stopReason: "end_turn" };
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
// `[tail-probe]` — 一次「说一半就去做工具」的回合,用来钉住朗读队列的那条规矩(#60):
|
|
621
|
+
// 第一段文字以**没有句末标点的半句**结尾(朗读器会先扣着等合并),紧接着调一个工具(回合
|
|
622
|
+
// 因此不结束),然后**另一条消息**接着说。真实后端就是这个形状,而扣着的半句曾经在这里蒸发。
|
|
623
|
+
// QA only:别的提示词完全不走这条路。
|
|
624
|
+
if (/\[tail-probe\]/.test(text)) {
|
|
625
|
+
const first = `第一段:我先把结论说一遍。然后我去看文件,最后半句没有句号`;
|
|
626
|
+
await send(agent._conn, sessionId, {
|
|
627
|
+
sessionUpdate: "agent_message_chunk",
|
|
628
|
+
messageId: `tail-a-${sessionId}`,
|
|
629
|
+
content: { type: "text", text: first },
|
|
630
|
+
});
|
|
631
|
+
await sleep(60);
|
|
632
|
+
const id = `tc-${sessionId}-tail`;
|
|
633
|
+
await send(agent._conn, sessionId, {
|
|
634
|
+
sessionUpdate: "tool_call", toolCallId: id,
|
|
635
|
+
title: "read_file docs/notes.md", kind: "read", status: "in_progress",
|
|
636
|
+
rawInput: { path: "docs/notes.md" },
|
|
637
|
+
});
|
|
638
|
+
await sleep(400);
|
|
639
|
+
await send(agent._conn, sessionId, {
|
|
640
|
+
sessionUpdate: "tool_call_update", toolCallId: id,
|
|
641
|
+
status: "completed", rawOutput: "42 lines",
|
|
642
|
+
});
|
|
643
|
+
await sleep(120);
|
|
644
|
+
await send(agent._conn, sessionId, {
|
|
645
|
+
sessionUpdate: "agent_message_chunk",
|
|
646
|
+
messageId: `tail-b-${sessionId}`,
|
|
647
|
+
content: { type: "text", text: "文件看完了,这里是第二段。" },
|
|
648
|
+
});
|
|
649
|
+
await sleep(400); // let the frame land before the turn closes (same as the other fixtures)
|
|
650
|
+
return { stopReason: "end_turn" };
|
|
651
|
+
}
|
|
652
|
+
|
|
620
653
|
if (wantThink) {
|
|
621
654
|
// ONE `messageId` per burst, like the real adapter sends: ACP gives every chunk of one
|
|
622
655
|
// thought block the same id, and the cockpit/server coalesce on it (`block_key`). A mock
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
RequestPermissionResponse,
|
|
16
16
|
} from "@agentclientprotocol/sdk";
|
|
17
17
|
import { applyContextWindow, BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
|
|
18
|
+
import { providerFromDescription, writeModelDefault } from "../model-default.js";
|
|
18
19
|
import { forgetAgentOwner, recordAgentOwner, verifyAgentOwner } from "./agent-owner.js";
|
|
19
20
|
import { thinkingOptionOf, voiceAppliesToPrompt, voiceModeBlock } from "./voice-mode.js";
|
|
20
21
|
import {
|
|
@@ -51,7 +52,7 @@ import type {
|
|
|
51
52
|
} from "../../../shared/src/index.js";
|
|
52
53
|
import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
|
|
53
54
|
import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
|
|
54
|
-
import { AGENT_DEFAULT, PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
|
|
55
|
+
import { AGENT_DEFAULT, PREFS_DEFAULT, agentSettings, bumpModelUsage, type PlanHintSettings } from "../settings.js";
|
|
55
56
|
|
|
56
57
|
// The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
|
|
57
58
|
// at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
|
|
@@ -964,6 +965,9 @@ export class SessionManager {
|
|
|
964
965
|
// this method — that is exactly what keeps a per-session pick from being silently undone.
|
|
965
966
|
await this.#applyDefaultMode(live);
|
|
966
967
|
this.#updateSession(live);
|
|
968
|
+
// #62-2:新建会话从 `starting` 起步,握手这 1–3 秒里操作者已经能在 rail 上点它并选模型
|
|
969
|
+
// (那时走冷分支写进 pending)。没有这一句,那个选择要等到几小时后被回收再 resume 才生效。
|
|
970
|
+
await this.#replayPendingModel(live.info.id);
|
|
967
971
|
} catch (err) {
|
|
968
972
|
child.kill("SIGKILL");
|
|
969
973
|
live.info.status = "error";
|
|
@@ -1213,7 +1217,12 @@ export class SessionManager {
|
|
|
1213
1217
|
}
|
|
1214
1218
|
}
|
|
1215
1219
|
live.info.status = "ready";
|
|
1220
|
+
// 新进程的 info 不会自带 pending:如果这里不补,下面 #updateSession 会把操作者在冷槽上
|
|
1221
|
+
// 选好的模型按"没有"持久化掉 —— 意图在下一次写库时静默消失。
|
|
1222
|
+
live.info.pendingModelId = row.pendingModelId ?? null;
|
|
1216
1223
|
this.#updateSession(live);
|
|
1224
|
+
// #62-2 重放:操作者在冷会话上选过的模型,现在有 agent 了,把它补发出去。
|
|
1225
|
+
await this.#replayPendingModel(id);
|
|
1217
1226
|
// a resumed slot is the same evidence as a fresh one: the row does work
|
|
1218
1227
|
this.#noteBackendCheck(
|
|
1219
1228
|
String(row.backend),
|
|
@@ -1354,12 +1363,59 @@ export class SessionManager {
|
|
|
1354
1363
|
}
|
|
1355
1364
|
|
|
1356
1365
|
/** One row in the shape list()/cold()/archived() produce, from a stored row. */
|
|
1366
|
+
/** #62-3:把这次选择写进 agent 自己的配置。失败只记日志 —— 会话已经在新模型上跑起来了,
|
|
1367
|
+
* 配置文件没写成是"下次新会话的默认没跟上",不该回头把一次成功的切换报成失败。 */
|
|
1368
|
+
/** #62-2 兑现"冷会话上选好的模型"。
|
|
1369
|
+
*
|
|
1370
|
+
* **现读 store,不用 resume 开头那个快照**:resume 从读行到 status=ready 要 10–40 秒,这期间
|
|
1371
|
+
* 操作者完全可能在界面上再点一个模型(走冷分支写进 pending)。快照重放会把新的那个覆盖掉,
|
|
1372
|
+
* 而且重放成功后 pending 被清空 —— 新选择就静默消失了。 */
|
|
1373
|
+
async #replayPendingModel(id: string): Promise<void> {
|
|
1374
|
+
const pending = this.#store.getSession(id)?.pendingModelId ?? null;
|
|
1375
|
+
if (!pending) return;
|
|
1376
|
+
try {
|
|
1377
|
+
await this.setModel(id, pending, { fromReplay: true });
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
const msg = errMessage(e);
|
|
1380
|
+
// 结构性拒绝(agent 不认这个模型)永远不会成功,留着 pending 只会每次 resume 再撞一次;
|
|
1381
|
+
// 传输类失败(连接刚断)才值得下一次再试。
|
|
1382
|
+
if (/unknown model/i.test(msg)) {
|
|
1383
|
+
this.#store.setPendingModel(id, null);
|
|
1384
|
+
const live = this.#sessions.get(id);
|
|
1385
|
+
if (live) live.info.pendingModelId = null;
|
|
1386
|
+
}
|
|
1387
|
+
console.log(`[agentus] pending model ${pending} for ${id} was refused: ${msg}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
#writeBackModelDefault(info: SessionInfo, modelId: string): void {
|
|
1392
|
+
try {
|
|
1393
|
+
const shape = this.#spawnShape(info.backend);
|
|
1394
|
+
if (!shape) return;
|
|
1395
|
+
const home = buildSpawnEnv(shape.spec).home;
|
|
1396
|
+
const desc = (info.models?.availableModels ?? []).find((m) => m.modelId === modelId)?.description ?? null;
|
|
1397
|
+
const r = writeModelDefault(info.backend, home, modelId, {
|
|
1398
|
+
provider: providerFromDescription(desc),
|
|
1399
|
+
// `hermes -p <name>` 的配置住在 `<HERMES_HOME>/profiles/<name>/config.yaml`:不传这一项
|
|
1400
|
+
// 就会改到默认 profile 的默认模型上,当前会话那个 profile 反而没变。
|
|
1401
|
+
profile: shape.spec.profile ?? null,
|
|
1402
|
+
});
|
|
1403
|
+
if (!r.ok) console.log(`[agentus] model default not written (${info.backend}): ${r.reason}`);
|
|
1404
|
+
else if (r.changed) {
|
|
1405
|
+
console.log(`[agentus] model default → ${modelId}${r.provider ? ` (provider ${r.provider})` : ""} for ${info.backend}`);
|
|
1406
|
+
}
|
|
1407
|
+
} catch (e) {
|
|
1408
|
+
console.log(`[agentus] model default write-back failed: ${errMessage(e)}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1357
1412
|
#infoFromRow(r: SessionRow): SessionInfo {
|
|
1358
1413
|
return {
|
|
1359
1414
|
id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
|
|
1360
1415
|
workspace: r.workspace ?? null,
|
|
1361
1416
|
models: (r.models ?? null) as SessionInfo["models"],
|
|
1362
1417
|
contextLimit: r.contextLimit ?? null,
|
|
1418
|
+
pendingModelId: r.pendingModelId ?? null,
|
|
1363
1419
|
title: r.title, autoTitle: r.autoTitle ?? null, titleBy: r.titleBy ?? null, status: r.status, pid: null,
|
|
1364
1420
|
createdAt: r.createdAt,
|
|
1365
1421
|
modes: (r.modes ?? null) as SessionModeState | null,
|
|
@@ -2998,15 +3054,45 @@ export class SessionManager {
|
|
|
2998
3054
|
* SDK's typed surface, so it goes through the generic request() overload (verified
|
|
2999
3055
|
* against hermes acp, which implements set_session_model). A cold slot has no agent
|
|
3000
3056
|
* to switch: resume it first, then pick (same rule as modes). */
|
|
3001
|
-
async setModel(id: string, modelId: string): Promise<SessionInfo> {
|
|
3002
|
-
const
|
|
3003
|
-
|
|
3057
|
+
async setModel(id: string, modelId: string, opts: { fromReplay?: boolean } = {}): Promise<SessionInfo> {
|
|
3058
|
+
const live = this.#sessions.get(id);
|
|
3059
|
+
// 「能不能真的发出去」不是"map 里有没有这一条",而是**进程还活着**:崩掉的槽位仍在 map 里、
|
|
3060
|
+
// conn 也还挂着,对它发请求只会得到 `ACP connection closed`。所以先问进程。
|
|
3061
|
+
const hot = Boolean(live && live.conn && live.info.acpSessionId && live.child
|
|
3062
|
+
&& live.child.exitCode === null && live.child.signalCode === null
|
|
3063
|
+
&& (live.info.status === "ready" || live.info.status === "running"));
|
|
3064
|
+
// #62-2 冷会话:没有 agent 可以发 session/set_model —— 但"打开会话前先把它要用的模型定好"
|
|
3065
|
+
// 恰恰是最常见的时机(夜里回收一批,早上逐个指定再开工)。所以把意图停在行上,resume 时重放;
|
|
3066
|
+
// 以前这里第一行就 throw "session not ready — resume it first",等于逼操作者先花 10–40 秒唤醒。
|
|
3067
|
+
if (!hot) {
|
|
3068
|
+
const row = this.#store.getSession(id);
|
|
3069
|
+
if (!row) throw new Error(`no such session: ${id}`);
|
|
3070
|
+
const known = ((row.models ?? null) as SessionInfo["models"])?.availableModels ?? [];
|
|
3071
|
+
if (known.length && !known.some((m) => m.modelId === modelId)) {
|
|
3072
|
+
throw new Error(`unknown model: ${modelId}`);
|
|
3073
|
+
}
|
|
3074
|
+
if (!this.#store.setPendingModel(id, modelId)) throw new Error(`no such session: ${id}`);
|
|
3075
|
+
this.#emitSessions();
|
|
3076
|
+
return this.#coldInfo(id);
|
|
3077
|
+
}
|
|
3078
|
+
const s = live as NonNullable<typeof live>;
|
|
3079
|
+
const conn = s.conn;
|
|
3080
|
+
if (!conn || !s.info.acpSessionId) throw new Error("session not ready — resume it first");
|
|
3004
3081
|
const known = s.info.models?.availableModels ?? [];
|
|
3005
3082
|
if (known.length && !known.some((m) => m.modelId === modelId)) {
|
|
3006
3083
|
throw new Error(`unknown model: ${modelId}`);
|
|
3007
3084
|
}
|
|
3008
|
-
await
|
|
3085
|
+
await conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
|
|
3009
3086
|
if (s.info.models) s.info.models = { ...s.info.models, currentModelId: modelId };
|
|
3087
|
+
if (s.info.pendingModelId) s.info.pendingModelId = null;
|
|
3088
|
+
this.#store.setPendingModel(id, null);
|
|
3089
|
+
// #62-1: 只有 agent **接受**过的切换才计数 —— 被拒的条目永不加分,于是"广告了但实际不可用"
|
|
3090
|
+
// 的模型会自己沉到列表底部,不需要任何端点探活。
|
|
3091
|
+
bumpModelUsage(`${s.info.backend}|${modelId}`);
|
|
3092
|
+
// #62-3:选定即成为 agent 的全局默认(设置里可关)。同样只在这一行之后 —— 被拒的模型不落盘。
|
|
3093
|
+
// **重放不算**:冷槽上留下的意图可能被一次无人值守的 completion 唤醒带上(resume <-
|
|
3094
|
+
// `this.resume(e.origin)`),那时代码里没有"操作者在选模型"这件事,不该改全局配置。
|
|
3095
|
+
if (!opts.fromReplay && agentSettings().modelWriteBack) this.#writeBackModelDefault(s.info, modelId);
|
|
3010
3096
|
this.#updateSession(s);
|
|
3011
3097
|
this.#emitSessions();
|
|
3012
3098
|
return s.info;
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
SUMMARY_MAX_INPUT_CHARS, SUMMARY_THINKING_BUDGET,
|
|
28
28
|
agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
|
|
29
29
|
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
|
|
30
|
+
modelUsage,
|
|
30
31
|
} from "./settings.js";
|
|
31
32
|
import { ObserverHub } from "./observer/hub.js";
|
|
32
33
|
import * as auth from "./auth.js";
|
|
@@ -1478,6 +1479,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1478
1479
|
}
|
|
1479
1480
|
// Operator settings: the page reads and writes this. Secrets only ever leave
|
|
1480
1481
|
// through a mask (publicSettings) — an unchanged mask round-trips as "keep".
|
|
1482
|
+
// #62-1: 模型使用计数。列表排序要跨会话、跨设备一致,所以它在 server 上;前端只读。
|
|
1483
|
+
if (url.pathname === "/api/model-usage" && req.method === "GET") {
|
|
1484
|
+
return send(res, 200, { usage: modelUsage() });
|
|
1485
|
+
}
|
|
1481
1486
|
if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
1482
1487
|
return send(res, 200, publicSettings());
|
|
1483
1488
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// 把"我刚选的模型"落成 agent 自己的全局默认(#62-3)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么 server 直接改配置文件:agentus 换模型只影响当前会话,而 agent 侧的默认值住在它自己的
|
|
4
|
+
// 配置里(Hermes 是 `<HERMES_HOME>/config.yaml` 的 `model.default` + `model.provider`)。桌面/
|
|
5
|
+
// 沙箱部署一人一环境,"最近一次选择 = 我想要的默认"是正确语义,所以选定即回写,不弹确认。
|
|
6
|
+
//
|
|
7
|
+
// 三条纪律:
|
|
8
|
+
// · 只在 server 的 setModel **成功之后**、且是操作者动作时调用(重放不算,见 session-manager)。
|
|
9
|
+
// · 写完必须能读回来:写盘前用真 YAML 解析器验一遍 `model.default` 是不是那个值,验不过就**拒写**。
|
|
10
|
+
// 这是别人的全局配置文件,写坏了下次 spawn 直接起不来 —— 宁可不动,也不能"改成功但改坏"。
|
|
11
|
+
// · 原子写(临时文件 + rename)并保持原权限;只动 `model.default` / `model.provider` 两行。
|
|
12
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
|
|
16
|
+
export interface WriteBackResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
changed: boolean;
|
|
19
|
+
/** 写进去的 provider;null = 没写(推不出或不安全) */
|
|
20
|
+
provider: string | null;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WriteBackOptions {
|
|
25
|
+
/** agent 在 `availableModels[].description` 里自己声明的 provider;推不出就别传 */
|
|
26
|
+
provider?: string | null;
|
|
27
|
+
/** hermes 的 `-p <profile>`:named profile 是**另一个** home,不能写到默认 profile 上 */
|
|
28
|
+
profile?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** agent 的配置文件在哪。
|
|
32
|
+
*
|
|
33
|
+
* `home` 是 `buildSpawnEnv().home`,对 hermes 而言**就是 HERMES_HOME 本身**(backends.ts 的
|
|
34
|
+
* `isolation.homeVar = "HERMES_HOME"`, `homeDefault = ~/.hermes`)—— 不能再拼一层 `.hermes`。
|
|
35
|
+
* 同仓的 `speech/route.ts` 也是 `path.join(home, "config.yaml")`。 */
|
|
36
|
+
function configPath(backend: string, home: string | null | undefined, profile: string | null | undefined): string | null {
|
|
37
|
+
if (backend !== "hermes" || !home) return null;
|
|
38
|
+
const root = profile ? path.join(home, "profiles", profile) : home;
|
|
39
|
+
return path.join(root, "config.yaml");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 从模型清单的 description("Provider: DeepSeek")里取 provider。
|
|
43
|
+
* 前缀猜(deepseek-* → deepseek)在共享 key 的多端点部署里是错的,agent 自己声明的那个才对。
|
|
44
|
+
*
|
|
45
|
+
* 但描述会带杂质:hermes 的 named catalog 拼的是 `Provider: <label> • <desc> • current`,
|
|
46
|
+
* 所以剥掉尾部的状态词之后还得**只接受标识符形状**(`Anthropic (Claude Sonnet 4.5)` 这种
|
|
47
|
+
* 写进 `model.provider` 会让 agent 起不来)—— 不合格就当没推出来,只写 default。 */
|
|
48
|
+
export function providerFromDescription(description: string | null | undefined): string | null {
|
|
49
|
+
const m = /^\s*provider:\s*(.+)$/i.exec(description ?? "");
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
const name = m[1]
|
|
52
|
+
.replace(/[•·|,-]?\s*\b(current|active|selected|default)\b\s*$/i, "")
|
|
53
|
+
.replace(/[•·|,\s-]+$/g, "")
|
|
54
|
+
.trim();
|
|
55
|
+
return /^[A-Za-z0-9._:-]+$/.test(name) ? name : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const headerAt = (lines: string[]): number => lines.findIndex((l) => /^model:\s*(#.*)?\r?$/.test(l));
|
|
59
|
+
|
|
60
|
+
/** 在 `model:` 块里定位某个键的行号;列 0 的 `#` 是注释,不结束块(夹在块里是合法的)。 */
|
|
61
|
+
function findInModelBlock(lines: string[], key: string): number {
|
|
62
|
+
const start = headerAt(lines);
|
|
63
|
+
if (start < 0) return -1;
|
|
64
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i].replace(/\r$/, "");
|
|
66
|
+
if (!line.trim()) continue;
|
|
67
|
+
if (/^#/.test(line)) continue; // 注释:不影响结构,继续找
|
|
68
|
+
if (/^\S/.test(line)) break; // 下一个顶层键
|
|
69
|
+
if (new RegExp(`^\\s+${key}:`).test(line)) return i;
|
|
70
|
+
}
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 块内第一个键的缩进(插入新键时用它,别硬编码 2 空格 —— 4 空格的文件会写出 nested mapping)。 */
|
|
75
|
+
function blockIndent(lines: string[]): string {
|
|
76
|
+
const start = headerAt(lines);
|
|
77
|
+
if (start < 0) return " ";
|
|
78
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i].replace(/\r$/, "");
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
if (/^#/.test(line)) continue;
|
|
82
|
+
if (/^\S/.test(line)) break;
|
|
83
|
+
const m = /^(\s+)\S/.exec(line);
|
|
84
|
+
if (m) return m[1];
|
|
85
|
+
}
|
|
86
|
+
return " ";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 只接受能安全裸写的标量;其余加单引号(`:`、`#`、`&`、`*` 会把标量改语义)。 */
|
|
90
|
+
function yamlScalar(value: string): string {
|
|
91
|
+
if (/^[A-Za-z0-9._:/@+-]+$/.test(value)) return value;
|
|
92
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function setKey(lines: string[], key: string, value: string, indent: string): string[] {
|
|
96
|
+
const eol = lines.some((l) => l.endsWith("\r")) ? "\r" : "";
|
|
97
|
+
const rendered = `${indent}${key}: ${yamlScalar(value)}`;
|
|
98
|
+
const at = findInModelBlock(lines, key);
|
|
99
|
+
if (at >= 0) {
|
|
100
|
+
if (lines[at].replace(/\r$/, "") === rendered) return lines;
|
|
101
|
+
const copy = [...lines];
|
|
102
|
+
copy[at] = `${rendered}${eol}`;
|
|
103
|
+
return copy;
|
|
104
|
+
}
|
|
105
|
+
const start = headerAt(lines);
|
|
106
|
+
if (start < 0) return [...lines, `model:${eol}`, `${rendered}${eol}`];
|
|
107
|
+
const copy = [...lines];
|
|
108
|
+
copy.splice(start + 1, 0, `${rendered}${eol}`);
|
|
109
|
+
return copy;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 把 modelId(以及可安全推断时的 provider)写进 agent 的配置文件。 */
|
|
113
|
+
export function writeModelDefault(
|
|
114
|
+
backend: string,
|
|
115
|
+
home: string | null | undefined,
|
|
116
|
+
modelId: string,
|
|
117
|
+
options: WriteBackOptions = {},
|
|
118
|
+
): WriteBackResult {
|
|
119
|
+
const profile = options.profile ? String(options.profile).trim() || null : null;
|
|
120
|
+
const file = configPath(backend, home, profile);
|
|
121
|
+
if (!file) return { ok: false, changed: false, provider: null, reason: `${backend}: no known config file` };
|
|
122
|
+
if (!existsSync(file)) return { ok: false, changed: false, provider: null, reason: `no config at ${file}` };
|
|
123
|
+
if (!modelId) return { ok: false, changed: false, provider: null, reason: "empty model id" };
|
|
124
|
+
|
|
125
|
+
let text: string;
|
|
126
|
+
let mode: number;
|
|
127
|
+
try {
|
|
128
|
+
text = readFileSync(file, "utf8");
|
|
129
|
+
mode = statSync(file).mode & 0o777;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { ok: false, changed: false, provider: null, reason: `unreadable: ${(e as Error).message}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines = text.split("\n");
|
|
135
|
+
// 行内 flow(`model: {…}`)和锚点(`model: &m`)都不是"块":追加会造出第二个顶层 `model:`。
|
|
136
|
+
// 这种形状一律拒写 —— 我们只改块,不重排别人的文件。
|
|
137
|
+
const header = lines.find((l) => /^model:/.test(l));
|
|
138
|
+
if (header !== undefined && headerAt(lines) < 0) {
|
|
139
|
+
return { ok: false, changed: false, provider: null, reason: `model: is not a plain block (${header.trim().slice(0, 40)})` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const provider = options.provider ? String(options.provider).trim() : null;
|
|
143
|
+
const indent = blockIndent(lines);
|
|
144
|
+
let work = lines;
|
|
145
|
+
if (provider) work = setKey(work, "provider", provider, indent);
|
|
146
|
+
work = setKey(work, "default", modelId, indent);
|
|
147
|
+
|
|
148
|
+
const next = work.join("\n");
|
|
149
|
+
if (next === text) return { ok: true, changed: false, provider };
|
|
150
|
+
|
|
151
|
+
// 写盘前的最后一道闸:真解析一遍,两个值都得读回来。任何一个不成立就一个字都不写。
|
|
152
|
+
let doc: unknown;
|
|
153
|
+
try {
|
|
154
|
+
doc = parseYaml(next);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { ok: false, changed: false, provider, reason: `refusing to write: result would not parse (${(e as Error).message.split("\n")[0]})` };
|
|
157
|
+
}
|
|
158
|
+
const model = (doc as { model?: { default?: unknown; provider?: unknown } } | null)?.model;
|
|
159
|
+
if (!model || model.default !== modelId) {
|
|
160
|
+
return { ok: false, changed: false, provider, reason: "refusing to write: model.default would not read back" };
|
|
161
|
+
}
|
|
162
|
+
if (provider && model.provider !== provider) {
|
|
163
|
+
return { ok: false, changed: false, provider, reason: "refusing to write: model.provider would not read back" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 配置里可能有 api_key:比 0600 宽的一律收紧。
|
|
167
|
+
const targetMode = mode & 0o077 ? 0o600 : mode;
|
|
168
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
169
|
+
try {
|
|
170
|
+
copyFileSync(file, `${file}.bak-${Date.now()}`); // 别人的配置,改之前留一份
|
|
171
|
+
writeFileSync(tmp, next, { mode: targetMode });
|
|
172
|
+
chmodSync(tmp, targetMode);
|
|
173
|
+
renameSync(tmp, file);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
176
|
+
return { ok: false, changed: false, provider, reason: `write failed: ${(e as Error).message}` };
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, changed: true, provider };
|
|
179
|
+
}
|
|
@@ -209,6 +209,9 @@ export interface AgentSettings {
|
|
|
209
209
|
* (`context`/`messages`/`plan`) is NOT gated: it cannot change anything, and it is what the
|
|
210
210
|
* 工作播报 observer runs on. */
|
|
211
211
|
cockpitSend: boolean;
|
|
212
|
+
/** #62-3:选定模型后回写 agent 自己的配置文件(`model.default`)。默认开 —— 一人一环境里
|
|
213
|
+
* "我最近选的模型就是我想要的默认"是正确语义;留这个开关只是为了能关掉别动别人的配置。 */
|
|
214
|
+
modelWriteBack: boolean;
|
|
212
215
|
/** Let a finished background process wake the agent with its own result (issue #22). ON by default:
|
|
213
216
|
* this is what the CLI and the gateway already do, and the failure it prevents is the silent one —
|
|
214
217
|
* a session that looks finished while a build is still running, whose result nobody ever reads. */
|
|
@@ -230,7 +233,7 @@ export interface AgentSettings {
|
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
export const AGENT_DEFAULT: AgentSettings = {
|
|
233
|
-
idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
|
|
236
|
+
idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true, modelWriteBack: true,
|
|
234
237
|
showSilentBackground: false, recallItemChars: 400, recallTotalChars: 12000,
|
|
235
238
|
};
|
|
236
239
|
|
|
@@ -427,9 +430,27 @@ export interface Settings {
|
|
|
427
430
|
agent: AgentSettings;
|
|
428
431
|
observer: ObserverSettings;
|
|
429
432
|
summary: SummarySettings;
|
|
433
|
+
/** 模型使用计数(#62-1):key = `<backend>|<provider>|<modelId>`,value = 成功切换的次数。
|
|
434
|
+
* 放 server 而不是前端 localStorage —— 排序要跨会话、跨设备一致,且所有切换入口都收敛
|
|
435
|
+
* 在 server 的 setModel 上,记这一处就不漏。被 agent 拒的条目永不计数。 */
|
|
436
|
+
modelUsage: Record<string, number>;
|
|
430
437
|
updatedAt: number;
|
|
431
438
|
}
|
|
432
439
|
|
|
440
|
+
/** 计数表的上限:一个 agent 广告几百个模型,键会脏(换过 provider 名字等),别让它无限长。 */
|
|
441
|
+
const MODEL_USAGE_MAX = 3000;
|
|
442
|
+
|
|
443
|
+
function pickModelUsage(raw: unknown): Record<string, number> {
|
|
444
|
+
const out: Record<string, number> = {};
|
|
445
|
+
if (!raw || typeof raw !== "object") return out;
|
|
446
|
+
const entries = Object.entries(raw as Record<string, unknown>)
|
|
447
|
+
.filter(([k, v]) => typeof k === "string" && k.length <= 200 && typeof v === "number" && Number.isFinite(v) && v > 0)
|
|
448
|
+
.sort((a, b) => Number(b[1]) - Number(a[1]))
|
|
449
|
+
.slice(0, MODEL_USAGE_MAX);
|
|
450
|
+
for (const [k, v] of entries) out[k] = Math.min(1e9, Math.round(Number(v)));
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
433
454
|
const DEFAULTS: VoiceSettings = {
|
|
434
455
|
provider: "dashscope",
|
|
435
456
|
baseUrl: "",
|
|
@@ -454,7 +475,7 @@ let legacyFile = "";
|
|
|
454
475
|
let cache: Settings = {
|
|
455
476
|
voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
|
|
456
477
|
prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT },
|
|
457
|
-
observer: { ...OBSERVER_DEFAULT }, summary: { ...SUMMARY_DEFAULT }, updatedAt: 0,
|
|
478
|
+
observer: { ...OBSERVER_DEFAULT }, summary: { ...SUMMARY_DEFAULT }, modelUsage: {}, updatedAt: 0,
|
|
458
479
|
};
|
|
459
480
|
/** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
|
|
460
481
|
let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
|
|
@@ -521,6 +542,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
|
|
|
521
542
|
agent: pickAgent(stored.agent),
|
|
522
543
|
observer: pickObserver(stored.observer),
|
|
523
544
|
summary: pickSummary(stored.summary),
|
|
545
|
+
modelUsage: pickModelUsage((stored as Partial<Settings>).modelUsage),
|
|
524
546
|
updatedAt: Number((stored as Settings).updatedAt ?? 0),
|
|
525
547
|
};
|
|
526
548
|
// an unset baseUrl/apiKey bootstrap from the environment: the operator said the
|
|
@@ -577,6 +599,7 @@ function pickAgent(raw: unknown): AgentSettings {
|
|
|
577
599
|
take("recallItemChars", AGENT_RECALL_ITEM_RANGE);
|
|
578
600
|
take("recallTotalChars", AGENT_RECALL_TOTAL_RANGE);
|
|
579
601
|
if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
|
|
602
|
+
if (typeof r.modelWriteBack === "boolean") out.modelWriteBack = r.modelWriteBack;
|
|
580
603
|
if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
|
|
581
604
|
if (typeof r.showSilentBackground === "boolean") out.showSilentBackground = r.showSilentBackground;
|
|
582
605
|
}
|
|
@@ -646,11 +669,27 @@ function persist(): void {
|
|
|
646
669
|
db.setSetting("agent", cache.agent);
|
|
647
670
|
db.setSetting("observer", cache.observer);
|
|
648
671
|
db.setSetting("summary", cache.summary);
|
|
672
|
+
db.setSetting("modelUsage", cache.modelUsage);
|
|
649
673
|
} catch (e) {
|
|
650
674
|
console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
|
|
651
675
|
}
|
|
652
676
|
}
|
|
653
677
|
|
|
678
|
+
/** 模型计数快照(#62-1)。前端拿它给模型列表排序 —— 只是读,改不了。 */
|
|
679
|
+
export function modelUsage(): Record<string, number> {
|
|
680
|
+
return cache.modelUsage;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** 一次**成功**的模型切换。调用点在 session-manager.setModel 里 agent 接受之后 —— 被拒的
|
|
684
|
+
* 条目不加分,于是"广告了但实际不可用"的模型会自己沉底,不需要任何端点探活。 */
|
|
685
|
+
export function bumpModelUsage(key: string): void {
|
|
686
|
+
if (!key) return;
|
|
687
|
+
const next = { ...cache.modelUsage };
|
|
688
|
+
next[key] = (next[key] ?? 0) + 1;
|
|
689
|
+
cache = { ...cache, modelUsage: pickModelUsage(next), updatedAt: Date.now() };
|
|
690
|
+
persist();
|
|
691
|
+
}
|
|
692
|
+
|
|
654
693
|
export function getSettings(): Settings {
|
|
655
694
|
return cache;
|
|
656
695
|
}
|
|
@@ -908,6 +947,10 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
|
908
947
|
if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
|
|
909
948
|
a.cockpitSend = patch.cockpitSend;
|
|
910
949
|
}
|
|
950
|
+
if ("modelWriteBack" in patch) {
|
|
951
|
+
if (typeof patch.modelWriteBack !== "boolean") throw new Error("invalid modelWriteBack: expected true or false");
|
|
952
|
+
a.modelWriteBack = patch.modelWriteBack;
|
|
953
|
+
}
|
|
911
954
|
if ("completionWake" in patch) {
|
|
912
955
|
if (typeof patch.completionWake !== "boolean") throw new Error("invalid completionWake: expected true or false");
|
|
913
956
|
a.completionWake = patch.completionWake;
|
|
@@ -168,6 +168,7 @@ export interface SessionRow {
|
|
|
168
168
|
/** Models the agent advertised (JSON) + the operator's context-window override. */
|
|
169
169
|
models?: unknown;
|
|
170
170
|
contextLimit?: number | null;
|
|
171
|
+
pendingModelId?: string | null;
|
|
171
172
|
/** The directory the cockpit works in for this slot: the file/terminal panel's
|
|
172
173
|
* root, and the cwd a cold slot is resumed with. Deliberately separate from
|
|
173
174
|
* `cwd` — a live ACP child cannot be re-cd'd (its cwd was fixed at newSession),
|
|
@@ -194,6 +195,7 @@ interface RawSessionRow {
|
|
|
194
195
|
workspace?: string | null;
|
|
195
196
|
home?: string | null;
|
|
196
197
|
models?: string | null; context_limit?: number | null;
|
|
198
|
+
pending_model_id?: string | null;
|
|
197
199
|
system?: number | null;
|
|
198
200
|
}
|
|
199
201
|
|
|
@@ -263,6 +265,7 @@ function rowToSession(r: RawSessionRow): SessionRow {
|
|
|
263
265
|
system: Boolean(r.system),
|
|
264
266
|
models: parseJson(r.models),
|
|
265
267
|
contextLimit: r.context_limit ?? null,
|
|
268
|
+
pendingModelId: r.pending_model_id ?? null,
|
|
266
269
|
};
|
|
267
270
|
}
|
|
268
271
|
|
|
@@ -479,6 +482,9 @@ export class Store {
|
|
|
479
482
|
this.#db.exec("update sessions set auto_title = title where auto_title is null or auto_title = ''");
|
|
480
483
|
// integer column, so it gets its own migration (the loop above assumes text)
|
|
481
484
|
if (!cols.has("context_limit")) this.#db.exec("alter table sessions add column context_limit integer");
|
|
485
|
+
// #62-2: a cold slot has no agent to send session/set_model to, so the operator's pick is
|
|
486
|
+
// PARKED here and replayed on the next resume. text, so it rides the loop above.
|
|
487
|
+
if (!cols.has("pending_model_id")) this.#db.exec("alter table sessions add column pending_model_id text");
|
|
482
488
|
// WHICH SESSIONS THE COCKPIT OWNS (issue #16): 1 = the 工作播报 observer, 0 = the operator's work.
|
|
483
489
|
// Its own column rather than a title convention, because this flag decides whether a row is
|
|
484
490
|
// allowed into the rail, the warm-slot ranking and the cockpit CLI's scope — a rename must not be
|
|
@@ -1702,6 +1708,14 @@ export class Store {
|
|
|
1702
1708
|
}
|
|
1703
1709
|
|
|
1704
1710
|
/** The operator's declared context window for a slot (null clears it). */
|
|
1711
|
+
/** #62-2: the model a cold slot should adopt when it next wakes. */
|
|
1712
|
+
setPendingModel(id: string, modelId: string | null): boolean {
|
|
1713
|
+
const info = this.#db
|
|
1714
|
+
.prepare("update sessions set pending_model_id = ? where id = ?")
|
|
1715
|
+
.run(modelId || null, id);
|
|
1716
|
+
return Number(info.changes ?? 0) > 0;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1705
1719
|
setContextLimit(id: string, limit: number | null): boolean {
|
|
1706
1720
|
const info = this.#db
|
|
1707
1721
|
.prepare("update sessions set context_limit = ? where id = ?")
|
|
@@ -88,6 +88,9 @@ export interface SessionInfo {
|
|
|
88
88
|
* what the agent reports via usage_update or this — the operator's declared window.
|
|
89
89
|
* null = use whatever the agent reports. */
|
|
90
90
|
contextLimit?: number | null;
|
|
91
|
+
/** 冷会话上「下一个模型」(#62-2):没有 agent 进程可以发 session/set_model,于是把意图存下来,
|
|
92
|
+
* resume 成功后重放一次并清空。null = 没有待生效的选择。 */
|
|
93
|
+
pendingModelId?: string | null;
|
|
91
94
|
/** The window the operator declared for the CURRENT model (remembered per model, the way
|
|
92
95
|
* hermes-studio stores a context length per provider+model). Precedence for the gauge:
|
|
93
96
|
* contextLimit (this session) → this → the agent's own usage_update.size. */
|