agentus 0.6.0 → 0.7.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 +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/installation.md +7 -2
- package/docs/installation.zh-CN.md +5 -2
- package/package.json +2 -1
- package/packages/server/mock/agent.mjs +19 -1
- package/packages/server/src/acp/cold-page.ts +82 -0
- package/packages/server/src/acp/session-manager.ts +57 -13
- package/packages/server/src/cockpit/api.ts +14 -2
- package/packages/server/src/dashscope.ts +29 -18
- package/packages/server/src/index.ts +31 -4
- package/packages/server/src/settings.ts +31 -2
- package/packages/server/src/speech/route.ts +31 -5
- package/packages/shared/src/index.ts +5 -0
- package/packages/web/dist/assets/{index-DDGyKK0Y.js → index-CVRWqhK2.js} +52 -52
- package/packages/web/dist/assets/index-D118Mu9V.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-Cr0Hw9eP.css +0 -32
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ option instead of being skipped in silence), **background-job completion wake-up
|
|
|
56
56
|
background process comes back as a turn of its own), and a **DeepSeek web backend** (search +
|
|
57
57
|
extract on the model key) — come from the fork's integration build instead. That is a five-line install:
|
|
58
58
|
[§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
|
|
59
|
-
`agentus/v0.22.0/merge-
|
|
59
|
+
`agentus/v0.22.0/merge-20261020-2337`, on Hermes `v0.22.0`.
|
|
60
60
|
|
|
61
61
|
The first slot you start will use the Hermes home **your own agent already uses**
|
|
62
62
|
(`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
|
package/README.zh-CN.md
CHANGED
|
@@ -50,7 +50,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
|
|
|
50
50
|
以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
|
|
51
51
|
装法就五行:
|
|
52
52
|
[§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
|
|
53
|
-
`agentus/v0.22.0/merge-
|
|
53
|
+
`agentus/v0.22.0/merge-20261020-2337`,基座 Hermes `v0.22.0`。
|
|
54
54
|
|
|
55
55
|
你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
|
|
56
56
|
指定了另一个 —— 见下面的「HERMES_HOME」。
|
package/docs/installation.md
CHANGED
|
@@ -39,7 +39,12 @@ mode that survives a restart** and `session/load`, a free-form **context budget*
|
|
|
39
39
|
cockpit's message stream, **per-process skill roots** (`HERMES_EXTERNAL_SKILLS_DIRS`, which is how a
|
|
40
40
|
slot sees the cockpit's own skill without anything being written into your `~/.hermes`),
|
|
41
41
|
**background-job completion wake-ups** (a finished `terminal(background=true)` job is relayed back as
|
|
42
|
-
a turn of its own, so the agent resumes instead of the turn simply ending), and
|
|
42
|
+
a turn of its own, so the agent resumes instead of the turn simply ending), and each row of the running
|
|
43
|
+
snapshot now says whether that process is the kind that wakes the session (`notifyOnComplete`) — which
|
|
44
|
+
is how the cockpit shows only the ones that mean 「not finished yet」 instead of a wall of noise, **project skills the agent
|
|
45
|
+
can see, trust and edit** (the cockpit reports which project skills a repo has and which tier you
|
|
46
|
+
trust, and writes the trust level back to config — it rides the same status channel; a released
|
|
47
|
+
Hermes silently skips untrusted project skills without saying so), and a
|
|
43
48
|
**DeepSeek web backend** (`search` + `extract` on top of `DEEPSEEK_API_KEY` — DeepSeek has no separate
|
|
44
49
|
search key).
|
|
45
50
|
They live on the ACP branches of the fork
|
|
@@ -55,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
|
|
|
55
60
|
ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
|
|
56
61
|
last segment sorts, so "newest" is a `sort -V`.
|
|
57
62
|
|
|
58
|
-
**Current integration build: `agentus/v0.22.0/merge-
|
|
63
|
+
**Current integration build: `agentus/v0.22.0/merge-20261020-2337`** — the newest tag on the fork,
|
|
59
64
|
built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
|
|
60
65
|
`TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
|
|
61
66
|
ever looks stale.
|
|
@@ -35,7 +35,10 @@ brew install node # 或者 https://nodejs.org/en/download
|
|
|
35
35
|
(带 256K/512K/768K/1M 预设)、把记忆召回 / 存记忆提示送进驾驶舱消息流的 **status 通道**,以及
|
|
36
36
|
**按进程指定的技能根**(`HERMES_EXTERNAL_SKILLS_DIRS` —— 驾驶舱靠它让槽位看见自己的 skill,
|
|
37
37
|
不往你的 `~/.hermes` 写任何东西)、**后台任务完成唤醒**(`terminal(background=true)` 的进程跑完会被
|
|
38
|
-
中继成一个新回合,agent
|
|
38
|
+
中继成一个新回合,agent 自己接着干,而不是回合就这么结束),快照里的每个进程还会带
|
|
39
|
+
`notifyOnComplete` —— 驾驶舱据此只显示「会唤醒会话」的那些,而不是一屏噪音、**项目 skill 的可见 / 可信任 / 可写**
|
|
40
|
+
(驾驶舱会告诉 agent 这个仓库有哪些项目 skill、你信任哪个层级,并能把信任层级写回配置 —— 信号走
|
|
41
|
+
同一条 status 通道;裸装 Hermes 遇到没信任的项目 skill 是悄悄跳过、不吭声的),以及 **DeepSeek 的 web 后端**
|
|
39
42
|
(`search` + `extract` 都复用
|
|
40
43
|
`DEEPSEEK_API_KEY` —— DeepSeek 没有单独的搜索密钥)。它们在 fork
|
|
41
44
|
[`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
|
|
@@ -49,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
|
|
|
49
52
|
tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
|
|
50
53
|
可排序,所以「最新」就是一次 `sort -V`。
|
|
51
54
|
|
|
52
|
-
**当前集成构建:`agentus/v0.22.0/merge-
|
|
55
|
+
**当前集成构建:`agentus/v0.22.0/merge-20261020-2337`** —— fork 上最新的那个 tag,基座 Hermes
|
|
53
56
|
`v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
|
|
54
57
|
以它为准。
|
|
55
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"terminal-palette": "tsx scripts/qa/terminal-palette.mts",
|
|
60
60
|
"android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
|
|
61
61
|
"cards-persist": "node scripts/qa/cards-persist.mjs",
|
|
62
|
+
"cold-paging": "tsx scripts/qa/cold-paging.mts",
|
|
62
63
|
"plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
|
|
63
64
|
"plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
|
|
64
65
|
"plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
|
|
@@ -480,9 +480,13 @@ const agent = () => ({
|
|
|
480
480
|
_meta: { hermes: { kind: "background", tasks: rows } },
|
|
481
481
|
content: { type: "text", text: "" },
|
|
482
482
|
});
|
|
483
|
+
// `notifyOnComplete: true` because this fixture IS the wake shape: it ends with a completion
|
|
484
|
+
// carrier, i.e. the agent is waiting for that result (issue #35). The cockpit filters silent
|
|
485
|
+
// processes out by default, so a fixture without the flag would render no row at all — and the
|
|
486
|
+
// wake sweeps would fail for the right reason but the wrong cause.
|
|
483
487
|
const running = [{
|
|
484
488
|
id: taskId, command: "npm run build --workspace packages/web", pid: 4242,
|
|
485
|
-
status: "running", uptimeSeconds: 12,
|
|
489
|
+
status: "running", uptimeSeconds: 12, notifyOnComplete: true,
|
|
486
490
|
}];
|
|
487
491
|
void send(agent._conn, sessionId, snapshot(running)).catch(() => undefined);
|
|
488
492
|
setTimeout(() => {
|
|
@@ -503,6 +507,20 @@ const agent = () => ({
|
|
|
503
507
|
}, runMs + 1200);
|
|
504
508
|
}
|
|
505
509
|
|
|
510
|
+
// `[bgquiet]` — a SILENT background process: running, no notify, no completion carrier. This is
|
|
511
|
+
// what the agent's own polling looks like, and the cockpit hides it by default (issue #35).
|
|
512
|
+
if (/\[bgquiet\]/.test(text)) {
|
|
513
|
+
const quietId = `bgq-${sessionId}-1`;
|
|
514
|
+
void send(agent._conn, sessionId, {
|
|
515
|
+
sessionUpdate: "agent_thought_chunk",
|
|
516
|
+
_meta: { hermes: { kind: "background", tasks: [{
|
|
517
|
+
id: quietId, command: "tail -f /tmp/agentus-qa.log", pid: 4343,
|
|
518
|
+
status: "running", uptimeSeconds: 5, notifyOnComplete: false,
|
|
519
|
+
}] } },
|
|
520
|
+
content: { type: "text", text: "" },
|
|
521
|
+
}).catch(() => undefined);
|
|
522
|
+
}
|
|
523
|
+
|
|
506
524
|
// `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
|
|
507
525
|
// Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
|
|
508
526
|
// relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Paging for the COLD session list — the rows that exist on disk with no process behind them.
|
|
2
|
+
*
|
|
3
|
+
* Issue #34: the page used to be ONE global top-N by `lastAt`. Every consumer (rail grouping, the
|
|
4
|
+
* rail's 展开其余 counter, search, `cockpit session list --workspace`) then treated that page as the
|
|
5
|
+
* complete set, so the busiest workspace ate the whole page and every other workspace's cold
|
|
6
|
+
* sessions vanished. It is not a cosmetic loss: a cold row is the ONLY handle that can wake that
|
|
7
|
+
* session, so "not in the page" means "unreachable from the UI, even though the transcript is fine".
|
|
8
|
+
*
|
|
9
|
+
* The intent behind the old cap is still real — crash debris accumulates without anyone choosing it
|
|
10
|
+
* — so it is kept, but moved to the right axis: **N per workspace** (the same axis the rail already
|
|
11
|
+
* groups by) plus a much larger total ceiling as the anti-bloat backstop.
|
|
12
|
+
*
|
|
13
|
+
* Pure on purpose: the caller decides which rows EXIST (the cockpit's own scope rule), this decides
|
|
14
|
+
* which of them survive the page. That way the rule is tested without a server or a browser. */
|
|
15
|
+
|
|
16
|
+
import type { SessionInfo } from "@agentus/shared";
|
|
17
|
+
|
|
18
|
+
/** How many cold rows a single workspace keeps by default. The rail shows `RAIL_RECENT` (5) before
|
|
19
|
+
* 「展开其余」, so 20 is the expand depth, not the visible depth. */
|
|
20
|
+
export const COLD_PER_WORKSPACE = 20;
|
|
21
|
+
|
|
22
|
+
/** Hard ceiling across all workspaces. Chosen to be far above any real installation's workspace
|
|
23
|
+
* count × 20 while still bounding what a crash-storm can push into one WS frame. */
|
|
24
|
+
export const COLD_TOTAL_MAX = 400;
|
|
25
|
+
|
|
26
|
+
/** The cockpit CLI asks for a deeper page than the rail: an agent asking "what is going on in this
|
|
27
|
+
* workspace" is not browsing, and it filters by `--workspace` on top. */
|
|
28
|
+
export const COLD_PER_WORKSPACE_COCKPIT = 50;
|
|
29
|
+
|
|
30
|
+
export interface ColdPage<T> {
|
|
31
|
+
/** Newest-first, at most `perWorkspace` per workspace and `totalMax` overall. */
|
|
32
|
+
rows: T[];
|
|
33
|
+
/** How many cold rows existed before paging — so a caller can tell an empty cockpit from a
|
|
34
|
+
* truncated one. */
|
|
35
|
+
total: number;
|
|
36
|
+
/** `total - rows.length`: the rows this page left out. Non-zero means "this is a partial view". */
|
|
37
|
+
omitted: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Which bucket a cold row belongs to: the workspace the operator sees in the rail, falling back to
|
|
41
|
+
* the cwd exactly like `sessionRow()` does — the two must not disagree, or a row would be paged by
|
|
42
|
+
* one key and displayed under another. */
|
|
43
|
+
export function coldKeyOf(row: Pick<SessionInfo, "workspace" | "cwd">): string {
|
|
44
|
+
return row.workspace || row.cwd || "/";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sort rule shared with `list()`: recent activity first, ties broken by creation. */
|
|
48
|
+
export function byActivity(a: Pick<SessionInfo, "lastAt" | "createdAt">, b: Pick<SessionInfo, "lastAt" | "createdAt">): number {
|
|
49
|
+
return (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bucket-aware cold paging.
|
|
54
|
+
*
|
|
55
|
+
* `sorted` must already be in the caller's display order (newest first). Each workspace keeps the
|
|
56
|
+
* first `perWorkspace` rows of that order; the survivors are merged and re-sorted so the overall list
|
|
57
|
+
* still reads newest-first, then cut at `totalMax` only if the merge exceeds it.
|
|
58
|
+
*/
|
|
59
|
+
export function coldPage<T extends Pick<SessionInfo, "workspace" | "cwd" | "lastAt" | "createdAt">>(
|
|
60
|
+
sorted: readonly T[],
|
|
61
|
+
perWorkspace: number = COLD_PER_WORKSPACE,
|
|
62
|
+
totalMax: number = COLD_TOTAL_MAX,
|
|
63
|
+
): ColdPage<T> {
|
|
64
|
+
const total = sorted.length;
|
|
65
|
+
const per = Math.max(1, Math.floor(perWorkspace));
|
|
66
|
+
const cap = Math.max(per, Math.floor(totalMax));
|
|
67
|
+
|
|
68
|
+
const buckets = new Map<string, T[]>();
|
|
69
|
+
for (const row of sorted) {
|
|
70
|
+
const key = coldKeyOf(row);
|
|
71
|
+
const bucket = buckets.get(key);
|
|
72
|
+
if (!bucket) buckets.set(key, [row]);
|
|
73
|
+
else if (bucket.length < per) bucket.push(row);
|
|
74
|
+
// else: this workspace already has its page — the rest of its rows are the ones we omit
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const kept: T[] = [];
|
|
78
|
+
for (const bucket of buckets.values()) kept.push(...bucket);
|
|
79
|
+
kept.sort(byActivity);
|
|
80
|
+
const rows = kept.length > cap ? kept.slice(0, cap) : kept;
|
|
81
|
+
return { rows, total, omitted: total - rows.length };
|
|
82
|
+
}
|
|
@@ -14,6 +14,14 @@ import type {
|
|
|
14
14
|
RequestPermissionResponse,
|
|
15
15
|
} from "@agentclientprotocol/sdk";
|
|
16
16
|
import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
|
|
17
|
+
import {
|
|
18
|
+
byActivity,
|
|
19
|
+
coldPage as pageCold,
|
|
20
|
+
COLD_PER_WORKSPACE,
|
|
21
|
+
COLD_PER_WORKSPACE_COCKPIT,
|
|
22
|
+
COLD_TOTAL_MAX,
|
|
23
|
+
type ColdPage,
|
|
24
|
+
} from "./cold-page.js";
|
|
17
25
|
import { SKILLS_ROOT, installSkills, resolveSkillsDir } from "../skills/install.js";
|
|
18
26
|
import { classifyError, checkedHealth, effectiveArgs, handshakeFrom, rowToSpec } from "./registry.js";
|
|
19
27
|
import type { BackendHandshake, CheckErrorCode } from "./registry.js";
|
|
@@ -39,7 +47,7 @@ import type {
|
|
|
39
47
|
} from "../../../shared/src/index.js";
|
|
40
48
|
import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
|
|
41
49
|
import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
|
|
42
|
-
import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
|
|
50
|
+
import { PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
|
|
43
51
|
|
|
44
52
|
// The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
|
|
45
53
|
// at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
|
|
@@ -945,17 +953,26 @@ export class SessionManager {
|
|
|
945
953
|
* reachable in the rail (the old rule showed them under 已归档; the older one dropped them out of
|
|
946
954
|
* the list entirely, which is how a transcript became unreachable while still on disk).
|
|
947
955
|
*
|
|
948
|
-
*
|
|
949
|
-
* has
|
|
950
|
-
cold
|
|
951
|
-
|
|
956
|
+
* Paged **per workspace** (#34), unlike `archived()` which is complete: crash debris accumulates
|
|
957
|
+
* without anyone choosing it, so a cap is still right — but the axis has to be the one the rail
|
|
958
|
+
* groups by. A global top-N let the busiest workspace eat the page, and since a cold row is the
|
|
959
|
+
* only handle that can wake its session, everything it pushed out became unreachable from the UI
|
|
960
|
+
* while the transcript sat fine on disk. See `cold-page.ts`. */
|
|
961
|
+
cold(perWorkspace = COLD_PER_WORKSPACE, totalMax = COLD_TOTAL_MAX): SessionInfo[] {
|
|
962
|
+
return this.coldPage(perWorkspace, totalMax).rows;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The same page plus `total`/`omitted`, so a caller can say "this is a partial view" instead of
|
|
966
|
+
* presenting a truncated list as the whole cockpit. */
|
|
967
|
+
coldPage(perWorkspace = COLD_PER_WORKSPACE, totalMax = COLD_TOTAL_MAX): ColdPage<SessionInfo> {
|
|
968
|
+
const sorted = this.#store
|
|
952
969
|
.listSessions(true)
|
|
953
970
|
// system rows are the cockpit's own (see list()): never offered as a slot to wake
|
|
954
971
|
.filter((r) => r.archivedAt == null && !r.system && !this.#sessions.has(r.id))
|
|
955
972
|
.map((r) => this.#infoFromRow(r))
|
|
956
973
|
// cold rows by the same rule as live ones (recent chat first), THEN take the page
|
|
957
|
-
.sort(
|
|
958
|
-
|
|
974
|
+
.sort(byActivity);
|
|
975
|
+
return pageCold(sorted, perWorkspace, totalMax);
|
|
959
976
|
}
|
|
960
977
|
|
|
961
978
|
/** One row in the shape list()/cold()/archived() produce, from a stored row. */
|
|
@@ -1811,7 +1828,10 @@ export class SessionManager {
|
|
|
1811
1828
|
* reasons, e.g. a title change) then wiped a row the page had just drawn from a `background` frame.
|
|
1812
1829
|
* An explicit `[]` is the same discipline the three rail buckets use. */
|
|
1813
1830
|
backgroundSnapshots(): { sessionId: string; tasks: BackgroundTask[] }[] {
|
|
1814
|
-
return [...this.#sessions.values()].map((s) => ({
|
|
1831
|
+
return [...this.#sessions.values()].map((s) => ({
|
|
1832
|
+
sessionId: s.info.id,
|
|
1833
|
+
tasks: visibleBackgroundTasks(s.background),
|
|
1834
|
+
}));
|
|
1815
1835
|
}
|
|
1816
1836
|
|
|
1817
1837
|
#emitSessions(): void {
|
|
@@ -1916,7 +1936,7 @@ export class SessionManager {
|
|
|
1916
1936
|
const bgTasks = backgroundTasksOf(u);
|
|
1917
1937
|
if (bgTasks) {
|
|
1918
1938
|
live.background = bgTasks;
|
|
1919
|
-
this.#emit({ t: "background", sessionId: live.info.id, tasks: bgTasks });
|
|
1939
|
+
this.#emit({ t: "background", sessionId: live.info.id, tasks: visibleBackgroundTasks(bgTasks) });
|
|
1920
1940
|
return;
|
|
1921
1941
|
}
|
|
1922
1942
|
const finishedText = completionTextOf(u);
|
|
@@ -2233,10 +2253,17 @@ export class SessionManager {
|
|
|
2233
2253
|
/** The cockpit CLI's read view (issue #15): the sessions the OPERATOR can see, in one list —
|
|
2234
2254
|
* live and cold, never 已归档 (that is the operator's shelf, not work in flight).
|
|
2235
2255
|
*
|
|
2236
|
-
* Cold rows are
|
|
2237
|
-
*
|
|
2256
|
+
* Cold rows are paged per workspace by `cold()`, deeper than the rail's own expand depth: an agent
|
|
2257
|
+
* asking "what is going on" is not browsing, and it filters by `--workspace` on top — but it still
|
|
2258
|
+
* must not receive a scrollback of every session this install ever made. */
|
|
2238
2259
|
cockpitRows(): SessionInfo[] {
|
|
2239
|
-
return [...this.list(), ...this.cold(
|
|
2260
|
+
return [...this.list(), ...this.cold(COLD_PER_WORKSPACE_COCKPIT)];
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** The cockpit's cold page together with what it left out, so `session list` can report
|
|
2264
|
+
* `capped` honestly instead of presenting a truncated view as the whole cockpit (#34). */
|
|
2265
|
+
cockpitCold(): ColdPage<SessionInfo> {
|
|
2266
|
+
return this.coldPage(COLD_PER_WORKSPACE_COCKPIT);
|
|
2240
2267
|
}
|
|
2241
2268
|
|
|
2242
2269
|
/** Approvals a session is WAITING on right now — the one fact worth waking another agent for. */
|
|
@@ -2674,7 +2701,7 @@ function completionTextOf(u: Record<string, unknown>): string | null {
|
|
|
2674
2701
|
|
|
2675
2702
|
/** The agent's running-process snapshot (#22). Dropped (not defaulted) when `tasks` is not an array:
|
|
2676
2703
|
* a malformed frame must never blank a row the operator is reading. */
|
|
2677
|
-
function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
|
|
2704
|
+
export function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
|
|
2678
2705
|
if (hermesKindOf(u) !== "background") return null;
|
|
2679
2706
|
const raw = hermesPayload(u).tasks;
|
|
2680
2707
|
if (!Array.isArray(raw)) return null;
|
|
@@ -2692,11 +2719,28 @@ function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null
|
|
|
2692
2719
|
status,
|
|
2693
2720
|
uptimeSeconds: typeof r.uptimeSeconds === "number" ? r.uptimeSeconds : undefined,
|
|
2694
2721
|
exitCode: typeof r.exitCode === "number" ? r.exitCode : null,
|
|
2722
|
+
// Explicit boolean, never `undefined` by omission (#35): the page filters on this, and an
|
|
2723
|
+
// old agent that does not send it must land on `false` (silent) rather than a third state
|
|
2724
|
+
// nobody renders.
|
|
2725
|
+
notifyOnComplete: r.notifyOnComplete === true,
|
|
2695
2726
|
});
|
|
2696
2727
|
}
|
|
2697
2728
|
return out;
|
|
2698
2729
|
}
|
|
2699
2730
|
|
|
2731
|
+
/** 默认降噪 (#35): 只留下「完成后会唤醒 agent」的进程。静默进程是 agent 自己轮询的活,
|
|
2732
|
+
* 操作者不需要看见 —— 而唤醒型才是「这个会话还没收工」的强信号。开关打开才回到全量,
|
|
2733
|
+
* 保留 fork 原本「快照是这个进程存在过的唯一见证」的设计价值, 但决定权交给操作者。
|
|
2734
|
+
*
|
|
2735
|
+
* WHY THIS IS A SEPARATE STEP FROM PARSING (measured while running the reap/background sweep):
|
|
2736
|
+
* `live.background` feeds more than rendering — the idle reaper treats a running child as
|
|
2737
|
+
* 「not idle」 and refuses to SIGTERM the agent that owns it. Filtering at parse time made every
|
|
2738
|
+
* silent process invisible to the reaper too, i.e. a slot could be reaped while it still had a
|
|
2739
|
+
* live child (the exact orphan #22 removed). So: store everything, hide at the wire. */
|
|
2740
|
+
export function visibleBackgroundTasks(tasks: BackgroundTask[]): BackgroundTask[] {
|
|
2741
|
+
return agentSettings().showSilentBackground ? tasks : tasks.filter((t) => t.notifyOnComplete === true);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2700
2744
|
function statusKindOf(u: Record<string, unknown>): string | undefined {
|
|
2701
2745
|
const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
|
|
2702
2746
|
const kind = meta?.hermes?.statusKind;
|
|
@@ -195,6 +195,10 @@ export interface ListQuery {
|
|
|
195
195
|
/** bucket the page instead of returning it flat. `workspace` is the only dimension today because it
|
|
196
196
|
* is the one the operator's rail groups by — anything else would be an invented taxonomy. */
|
|
197
197
|
by?: "workspace";
|
|
198
|
+
/** How many cold rows the caller's own page left out (issue #34). Set by the route from
|
|
199
|
+
* `cockpitCold()`: absent/0 means "you are looking at everything", non-zero means the view is
|
|
200
|
+
* partial and the answer must say so. Never a wire parameter. */
|
|
201
|
+
coldOmitted?: number;
|
|
198
202
|
/** fixed clock for tests; never set by a caller over the wire */
|
|
199
203
|
now?: number;
|
|
200
204
|
}
|
|
@@ -207,7 +211,12 @@ export interface ListResult {
|
|
|
207
211
|
meta: {
|
|
208
212
|
total: number;
|
|
209
213
|
returned: number;
|
|
214
|
+
/** TRUE means "this is not the whole answer" — either the list limit cut it, or (`cold_omitted`)
|
|
215
|
+
* the cold page itself did. Before #34 only the first was visible, so an agent could not tell a
|
|
216
|
+
* complete view from a truncated one. */
|
|
210
217
|
capped: boolean;
|
|
218
|
+
/** cold rows that existed but never reached this function (0 = nothing was hidden) */
|
|
219
|
+
cold_omitted: number;
|
|
211
220
|
/** the filters that were actually applied, echoed back so a caller cannot misread an empty list
|
|
212
221
|
* as "there is nothing" when what happened is "your filter matched nothing" */
|
|
213
222
|
applied: Record<string, string | number | boolean>;
|
|
@@ -308,7 +317,10 @@ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): List
|
|
|
308
317
|
const limit = Math.max(1, Math.min(LIST_LIMIT_MAX, Math.round(q.limit ?? LIST_LIMIT_DEFAULT)));
|
|
309
318
|
applied.limit = limit;
|
|
310
319
|
const total = out.length;
|
|
311
|
-
const
|
|
320
|
+
const coldOmitted = Math.max(0, Math.floor(q.coldOmitted ?? 0));
|
|
321
|
+
// `capped` answers "can I trust this as the whole picture?", so a cold page that dropped rows counts
|
|
322
|
+
// even when the list limit itself did not bite (#34).
|
|
323
|
+
const capped = total > limit || coldOmitted > 0;
|
|
312
324
|
const page = out.slice(0, limit);
|
|
313
325
|
|
|
314
326
|
let groups: ListResult["groups"];
|
|
@@ -331,7 +343,7 @@ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): List
|
|
|
331
343
|
}));
|
|
332
344
|
}
|
|
333
345
|
|
|
334
|
-
return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, applied } };
|
|
346
|
+
return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, cold_omitted: coldOmitted, applied } };
|
|
335
347
|
}
|
|
336
348
|
|
|
337
349
|
function normPath(p: string): string {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// The base URL is the operator's — every Bailian workspace id is different, and the
|
|
16
16
|
// whole point is that this is NOT the coding-plan endpoint. No URLs or keys are baked in.
|
|
17
17
|
import WebSocket from "ws";
|
|
18
|
-
import { getSettings, dashscopeUrls } from "./settings.js";
|
|
18
|
+
import { getSettings, dashscopeUrls, SUMMARY_MODEL_DEFAULT } from "./settings.js";
|
|
19
19
|
|
|
20
20
|
export class DashscopeError extends Error {
|
|
21
21
|
constructor(message: string, readonly code: "not_configured" | "upstream" | "timeout" | "bad_audio") {
|
|
@@ -241,8 +241,10 @@ export function openDashscopeStream(opts: {
|
|
|
241
241
|
* the operator's existing voice endpoint and key are enough — there is no second model to
|
|
242
242
|
* configure and no agent, no session and no history involved.
|
|
243
243
|
*
|
|
244
|
-
* The input is
|
|
245
|
-
*
|
|
244
|
+
* The input is NOT truncated any more (#36). It used to be capped at `maxChars × 20` characters,
|
|
245
|
+
* which punished exactly the case this feature exists for — a long agent reply — while the case
|
|
246
|
+
* the cap protected against (a pasted 200 KB log) is rare and is now refused outright by the
|
|
247
|
+
* caller with a visible 409. Half a summary that looks finished is worse than an error.
|
|
246
248
|
*/
|
|
247
249
|
export async function summarizeForSpeech(opts: {
|
|
248
250
|
text: string;
|
|
@@ -251,18 +253,20 @@ export async function summarizeForSpeech(opts: {
|
|
|
251
253
|
model: string;
|
|
252
254
|
timeoutSec: number;
|
|
253
255
|
/** Where to send it. Absent = the voice endpoint (settings → 语音), which is the old, always-on
|
|
254
|
-
* route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for
|
|
255
|
-
* summary to be billed to whatever model he is actually talking to, not to a second vendor. */
|
|
256
|
+
* route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for
|
|
257
|
+
* the summary to be billed to whatever model he is actually talking to, not to a second vendor. */
|
|
256
258
|
endpoint?: { baseUrl: string; apiKey: string };
|
|
257
|
-
|
|
259
|
+
/** qwen3-family only: `enable_thinking: true` + `thinking_budget: N`. The operator's rule is
|
|
260
|
+
* 「开着思考,但只给一点点」 — a capped think reads the message better than a non-reasoning
|
|
261
|
+
* model, without the 235-token wander measured on the default budget (4000). Sending this to
|
|
262
|
+
* a non-qwen3 model would be a lie, so the caller decides and we just transmit. */
|
|
263
|
+
thinkingBudget?: number;
|
|
264
|
+
}): Promise<{ text: string; ms: number }> {
|
|
258
265
|
const { key, compat } = opts.endpoint
|
|
259
266
|
? { key: opts.endpoint.apiKey, compat: opts.endpoint.baseUrl.replace(/\/+$/, "") }
|
|
260
267
|
: creds();
|
|
261
268
|
const v = getSettings().voice;
|
|
262
|
-
const
|
|
263
|
-
const body = opts.text.trim();
|
|
264
|
-
const truncated = body.length > limit;
|
|
265
|
-
const input = truncated ? `${body.slice(0, limit)}\n…(后面还有 ${body.length - limit} 字没发)` : body;
|
|
269
|
+
const input = opts.text.trim();
|
|
266
270
|
const ac = new AbortController();
|
|
267
271
|
const timer = setTimeout(() => ac.abort(), Math.max(5, opts.timeoutSec) * 1000);
|
|
268
272
|
const started = Date.now();
|
|
@@ -271,18 +275,25 @@ export async function summarizeForSpeech(opts: {
|
|
|
271
275
|
method: "POST",
|
|
272
276
|
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
273
277
|
body: JSON.stringify({
|
|
274
|
-
model: opts.model || (opts.endpoint ? "" :
|
|
278
|
+
model: opts.model || (opts.endpoint ? "" : SUMMARY_MODEL_DEFAULT),
|
|
275
279
|
messages: [
|
|
276
280
|
{ role: "system", content: opts.prompt.replace(/\{maxChars\}/g, String(opts.maxChars)) },
|
|
277
281
|
{ role: "user", content: input },
|
|
278
282
|
],
|
|
279
283
|
// No temperature knob: this surface is not a playground, and the default has produced
|
|
280
|
-
// the steadiest length compliance. `
|
|
281
|
-
//
|
|
282
|
-
// deepseek-v4-flash, a 480-token
|
|
283
|
-
//
|
|
284
|
-
// reasoning model answer
|
|
285
|
-
|
|
284
|
+
// the steadiest length compliance. `max_completion_tokens` (NOT `max_tokens`): it bounds
|
|
285
|
+
// thinking and answer TOGETHER and has no 32768 ceiling, so a capped-think call cannot
|
|
286
|
+
// come back empty with finish_reason=length. Measured on deepseek-v4-flash, a 480-token
|
|
287
|
+
// cap was spent entirely on reasoning_content and the visible answer was empty; 1200 is
|
|
288
|
+
// the floor that makes a reasoning model answer, and for a plain one it is only a ceiling
|
|
289
|
+
// nobody reaches. The think budget is added on top so capping the think never eats the
|
|
290
|
+
// answer.
|
|
291
|
+
max_completion_tokens: Math.max(1200, opts.maxChars * 6) + (opts.thinkingBudget ?? 0),
|
|
292
|
+
// qwen3 family (#36): thinking ON, budget 200. `thinking_budget` and `reasoning_effort`
|
|
293
|
+
// must never travel together (the vendor rejects the pair), so this is the only knob.
|
|
294
|
+
...(opts.thinkingBudget
|
|
295
|
+
? { enable_thinking: true, thinking_budget: opts.thinkingBudget }
|
|
296
|
+
: {}),
|
|
286
297
|
}),
|
|
287
298
|
signal: ac.signal,
|
|
288
299
|
});
|
|
@@ -305,7 +316,7 @@ export async function summarizeForSpeech(opts: {
|
|
|
305
316
|
throw new DashscopeError(why, "upstream");
|
|
306
317
|
}
|
|
307
318
|
void v;
|
|
308
|
-
return { text: out,
|
|
319
|
+
return { text: out, ms: Date.now() - started };
|
|
309
320
|
} catch (e) {
|
|
310
321
|
if ((e as Error).name === "AbortError") {
|
|
311
322
|
throw new DashscopeError(`summary timed out after ${opts.timeoutSec}s`, "timeout");
|
|
@@ -23,6 +23,7 @@ import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
|
23
23
|
import { openDashscopeStream, summarizeForSpeech } from "./dashscope.js";
|
|
24
24
|
import {
|
|
25
25
|
SUMMARY_CHARS_RANGE, SUMMARY_MODEL_DEFAULT, SUMMARY_PROMPT_DEFAULT,
|
|
26
|
+
SUMMARY_MAX_INPUT_CHARS, SUMMARY_THINKING_BUDGET,
|
|
26
27
|
agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
|
|
27
28
|
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
|
|
28
29
|
} from "./settings.js";
|
|
@@ -635,7 +636,9 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
635
636
|
const isOperator = !caller && auth.verifyMachineToken(/^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? ""))?.[1] ?? undefined);
|
|
636
637
|
if (!caller && !isOperator) return send(res, 403, { error: COCKPIT_ERRORS.badToken });
|
|
637
638
|
// Scope: what the operator can see. 已归档 is out, on purpose — see `cockpitRows`.
|
|
638
|
-
|
|
639
|
+
// The cold page is taken once so its `omitted` count can travel with the answer (#34).
|
|
640
|
+
const cold = mgr.cockpitCold();
|
|
641
|
+
const rows = [...mgr.list(), ...cold.rows];
|
|
639
642
|
const rowOf = (id: string): SessionInfo | null => rows.find((s) => s.id === id) ?? null;
|
|
640
643
|
const summarize = (s: SessionInfo) => sessionRow(s, {
|
|
641
644
|
plan: store.getPlan(s.id),
|
|
@@ -673,6 +676,7 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
673
676
|
all: sp.get("all") === "1" || sp.get("all") === "true",
|
|
674
677
|
includeSelf: sp.get("include_self") === "1" || sp.get("include_self") === "true",
|
|
675
678
|
by: by as ListQuery["by"],
|
|
679
|
+
coldOmitted: cold.omitted,
|
|
676
680
|
};
|
|
677
681
|
if (query.sort && !["last", "created", "name"].includes(query.sort)) {
|
|
678
682
|
return send(res, 400, { error: COCKPIT_ERRORS.badRequest, message: `--sort wants last|created|name, got "${query.sort}"` });
|
|
@@ -1589,12 +1593,19 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1589
1593
|
const row = store.getSession(id);
|
|
1590
1594
|
if (!row) return { route: null, why: "会话不在库里" };
|
|
1591
1595
|
const be = store.listBackends().find((b) => b.id === row.backend);
|
|
1592
|
-
|
|
1596
|
+
// LIVE first, store second (#36). The ACP handshake reports the model, and the live
|
|
1597
|
+
// session object holds it — but `sessions.models` is flushed later (observed: JSON null
|
|
1598
|
+
// for every row on this box), so reading the store alone answered 「agent 未握手」 and
|
|
1599
|
+
// every summary silently fell back to the voice endpoint's qwen-flash.
|
|
1600
|
+
const live = mgr.list().find((s) => s.id === id);
|
|
1601
|
+
const modelId = live?.models?.currentModelId
|
|
1602
|
+
?? ((row.models ?? {}) as { currentModelId?: string | null }).currentModelId
|
|
1603
|
+
?? null;
|
|
1593
1604
|
// The provider keys live in this box's agent home, which is a property of the runtime —
|
|
1594
1605
|
// not of the vendor that happens to be driving this session. A backend that owns no home
|
|
1595
1606
|
// (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
|
|
1596
1607
|
const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
|
|
1597
|
-
return routeForModel(
|
|
1608
|
+
return routeForModel(modelId, home);
|
|
1598
1609
|
};
|
|
1599
1610
|
// 语音总结 (the message speaker's long press): ONE stored message, made listenable.
|
|
1600
1611
|
//
|
|
@@ -1624,6 +1635,18 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1624
1635
|
if (spoken.length <= maxChars) {
|
|
1625
1636
|
return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
|
|
1626
1637
|
}
|
|
1638
|
+
// Over the ceiling we REFUSE, loudly (#36). The old behaviour summarised the head and said
|
|
1639
|
+
// so in a footnote — but a summary that drops the second half is worse than none, because
|
|
1640
|
+
// that is where the conclusion lives. 20k chars is far above any reply worth listening to
|
|
1641
|
+
// and still stops a pasted 200 KB log from becoming an invoice.
|
|
1642
|
+
if (spoken.length > SUMMARY_MAX_INPUT_CHARS) {
|
|
1643
|
+
return send(res, 409, {
|
|
1644
|
+
error: `这条消息 ${spoken.length} 字,超出语音总结上限 ${SUMMARY_MAX_INPUT_CHARS} 字 —— 直接朗读,或截取要听的那段`,
|
|
1645
|
+
code: "too_long",
|
|
1646
|
+
chars: spoken.length,
|
|
1647
|
+
limit: SUMMARY_MAX_INPUT_CHARS,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1627
1650
|
try {
|
|
1628
1651
|
// The operator's rule: bill this to the model he is already talking to. The voice
|
|
1629
1652
|
// endpoint stays as the fallback (and is the whole route when followSession is off).
|
|
@@ -1635,6 +1658,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1635
1658
|
prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
|
|
1636
1659
|
model,
|
|
1637
1660
|
timeoutSec: cfg.timeoutSec,
|
|
1661
|
+
// Thinking stays ON for qwen3 models, capped at 200 tokens (operator's call, #36):
|
|
1662
|
+
// a little reasoning reads the sentence better than none, and 200 keeps it from
|
|
1663
|
+
// wandering the way the default 4000 did (measured 235 output tokens on a 40-char ask).
|
|
1664
|
+
thinkingBudget: /^qwen3/i.test(model) ? SUMMARY_THINKING_BUDGET : undefined,
|
|
1638
1665
|
endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
|
|
1639
1666
|
});
|
|
1640
1667
|
// Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
|
|
@@ -1646,7 +1673,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1646
1673
|
// echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
|
|
1647
1674
|
// configured number would make the menu look broken
|
|
1648
1675
|
maxChars,
|
|
1649
|
-
ms: r.ms,
|
|
1676
|
+
ms: r.ms, model,
|
|
1650
1677
|
// which account this call was billed to — the note prints it, and a fallback that
|
|
1651
1678
|
// silently used the OTHER provider would be a lie about the cost
|
|
1652
1679
|
route: own.route ? "session" : "voice",
|
|
@@ -189,10 +189,16 @@ export interface AgentSettings {
|
|
|
189
189
|
* this is what the CLI and the gateway already do, and the failure it prevents is the silent one —
|
|
190
190
|
* a session that looks finished while a build is still running, whose result nobody ever reads. */
|
|
191
191
|
completionWake: boolean;
|
|
192
|
+
/** 显示不唤醒的后台进程 (issue #35). OFF by default: the row and the rail badge answer
|
|
193
|
+
* 「这个会话还在干活吗」, and a silent process the agent is polling itself is noise — the
|
|
194
|
+
* agent knows it is there, the operator does not need to. Turned ON, every running process
|
|
195
|
+
* shows, which is what the fork's original "a snapshot is the only witness" design wanted. */
|
|
196
|
+
showSilentBackground: boolean;
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
export const AGENT_DEFAULT: AgentSettings = {
|
|
195
200
|
idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
|
|
201
|
+
showSilentBackground: false,
|
|
196
202
|
};
|
|
197
203
|
|
|
198
204
|
/** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
|
|
@@ -298,8 +304,26 @@ export const SUMMARY_CHARS_RANGE: [number, number] = [40, 400];
|
|
|
298
304
|
export const SUMMARY_TIMEOUT_RANGE: [number, number] = [5, 60];
|
|
299
305
|
/** Measured against this operator's workspace, 2026-10-18: `qwen-flash` answered a 40-character
|
|
300
306
|
* 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
|
-
|
|
307
|
+
* not exist"), and `qwen3.8-flash` spent 235 output tokens thinking about the same sentence.
|
|
308
|
+
*
|
|
309
|
+
* 2026-10-20 (operator's call): the fallback IS `qwen3.8-flash` anyway — it is the model this
|
|
310
|
+
* workspace already talks to, and `qwen-flash` was a second vendor's answer to a problem that has
|
|
311
|
+
* a proper knob. The 235-token overspend is what `SUMMARY_THINKING_BUDGET` below exists for:
|
|
312
|
+
* thinking stays ON (it reads the message better) but is capped at 200 tokens. */
|
|
313
|
+
export const SUMMARY_MODEL_DEFAULT = "qwen3.8-flash";
|
|
314
|
+
|
|
315
|
+
/** Thinking cap for qwen3-family models, in tokens (`thinking_budget`). 200 is deliberate: enough
|
|
316
|
+
* to hold the sentence together, too small to wander — measured 235 → 200 keeps the visible
|
|
317
|
+
* answer intact at a fifth of the default 4000. NOT used for non-qwen3 models (DeepSeek ignores
|
|
318
|
+
* the field, and sending it there would be a lie about what we asked for). */
|
|
319
|
+
export const SUMMARY_THINKING_BUDGET = 200;
|
|
320
|
+
|
|
321
|
+
/** The most characters this feature will ever hand a model. Above it the call is REFUSED with a
|
|
322
|
+
* visible 409 instead of quietly summarising the head (#36) — a summary that silently drops the
|
|
323
|
+
* second half is worse than no summary, because the conclusion usually lives there. 20k ≈ 10k
|
|
324
|
+
* tokens: longer than any agent reply worth listening to, short enough that a pasted 200 KB log
|
|
325
|
+
* cannot turn one long press into a bill. */
|
|
326
|
+
export const SUMMARY_MAX_INPUT_CHARS = 20_000;
|
|
303
327
|
|
|
304
328
|
export const SUMMARY_PROMPT_DEFAULT = [
|
|
305
329
|
"下面是我在一个工作会话里拿到的一段输出。请把它改写成一段「用嘴念出来」的摘要:",
|
|
@@ -492,6 +516,7 @@ function pickAgent(raw: unknown): AgentSettings {
|
|
|
492
516
|
take("warmTtlHours", AGENT_WARM_TTL_RANGE);
|
|
493
517
|
if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
|
|
494
518
|
if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
|
|
519
|
+
if (typeof r.showSilentBackground === "boolean") out.showSilentBackground = r.showSilentBackground;
|
|
495
520
|
}
|
|
496
521
|
return out;
|
|
497
522
|
}
|
|
@@ -807,6 +832,10 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
|
807
832
|
if (typeof patch.completionWake !== "boolean") throw new Error("invalid completionWake: expected true or false");
|
|
808
833
|
a.completionWake = patch.completionWake;
|
|
809
834
|
}
|
|
835
|
+
if ("showSilentBackground" in patch) {
|
|
836
|
+
if (typeof patch.showSilentBackground !== "boolean") throw new Error("invalid showSilentBackground: expected true or false");
|
|
837
|
+
a.showSilentBackground = patch.showSilentBackground;
|
|
838
|
+
}
|
|
810
839
|
cache = { ...cache, agent: a, updatedAt: Date.now() };
|
|
811
840
|
persist();
|
|
812
841
|
return cache;
|