agentus 0.4.0 → 0.4.2

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 CHANGED
@@ -49,10 +49,12 @@ first, else `newSession` fails with `-32000`). Without one the cockpit still run
49
49
  logs in — you just have no backend to start a session on.
50
50
 
51
51
  A plain Hermes install drives sessions; the cockpit's own extras — per-session **thinking
52
- depth**, a permission mode that survives a restart, the **context-budget** field — come from the
53
- fork's integration build instead. That is a five-line install:
52
+ depth**, a permission mode that survives a restart, the **context-budget** field, the **status
53
+ channel** (memory recall/save hints in the message stream), per-process skill roots, and a
54
+ **DeepSeek web backend** (search + extract on the model key) — come from the fork's integration
55
+ build instead. That is a five-line install:
54
56
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
55
- `agentus/v0.22.0/merge-20261015-0014`, on Hermes `v0.22.0`.
57
+ `agentus/v0.22.0/merge-20261018-1005`, on Hermes `v0.22.0`.
56
58
 
57
59
  The first slot you start will use the Hermes home **your own agent already uses**
58
60
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -44,9 +44,11 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
44
44
  照样能跑、照样能登录 —— 只是没有后端可以开会话。
45
45
 
46
46
  裸装的 Hermes 就能跑会话;驾驶舱自己的那几项能力 —— 按会话切换的**思考深度**、跨重启存活的**权限模式**、
47
- **上下文预算**输入框 —— 来自 fork 的集成构建(tag)。装法就五行:
47
+ **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道**、按进程指定的技能根,
48
+ 以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
49
+ 装法就五行:
48
50
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
49
- `agentus/v0.22.0/merge-20261015-0014`,基座 Hermes `v0.22.0`。
51
+ `agentus/v0.22.0/merge-20261018-1005`,基座 Hermes `v0.22.0`。
50
52
 
51
53
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
52
54
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -34,8 +34,13 @@ brew install node # or https://nodejs.org/en/download
34
34
 
35
35
  Plain Hermes is enough to drive sessions. The capabilities this cockpit itself leans on are **not**
36
36
  in the released build — a per-session **thinking depth** (ACP `reasoning_effort`), a **permission
37
- mode that survives a restart** and `session/load`, and a free-form **context budget** (with
38
- 256K/512K/768K/1M presets). They live on the ACP branches of the fork
37
+ mode that survives a restart** and `session/load`, a free-form **context budget** (with
38
+ 256K/512K/768K/1M presets), the **status channel** that carries memory-recall / save hints into the
39
+ cockpit's message stream, **per-process skill roots** (`HERMES_EXTERNAL_SKILLS_DIRS`, which is how a
40
+ slot sees the cockpit's own skill without anything being written into your `~/.hermes`), and a
41
+ **DeepSeek web backend** (`search` + `extract` on top of `DEEPSEEK_API_KEY` — DeepSeek has no separate
42
+ search key).
43
+ They live on the ACP branches of the fork
39
44
  [`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent), and reach the cockpit
40
45
  through one **tag per integration build**, in a namespace of its own:
41
46
 
@@ -48,7 +53,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
48
53
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
49
54
  last segment sorts, so "newest" is a `sort -V`.
50
55
 
51
- **Current integration build: `agentus/v0.22.0/merge-20261015-0014`** — the newest tag on the fork,
56
+ **Current integration build: `agentus/v0.22.0/merge-20261018-1005`** — the newest tag on the fork,
52
57
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
53
58
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
54
59
  ever looks stale.
@@ -31,8 +31,11 @@ brew install node # 或者 https://nodejs.org/en/download
31
31
  ### 该跑哪份 Hermes
32
32
 
33
33
  裸装的 Hermes 足够跑会话。但驾驶舱自己依赖的那几项能力**不在发布版里** —— 按会话切换的**思考深度**
34
- (ACP `reasoning_effort`)、**跨重启 / `session/load` 存活的权限模式**,以及可自由填写的**上下文预算**
35
- (带 256K/512K/768K/1M 预设)。它们在 fork
34
+ (ACP `reasoning_effort`)、**跨重启 / `session/load` 存活的权限模式**、可自由填写的**上下文预算**
35
+ (带 256K/512K/768K/1M 预设)、把记忆召回 / 存记忆提示送进驾驶舱消息流的 **status 通道**,以及
36
+ **按进程指定的技能根**(`HERMES_EXTERNAL_SKILLS_DIRS` —— 驾驶舱靠它让槽位看见自己的 skill,
37
+ 不往你的 `~/.hermes` 写任何东西),以及 **DeepSeek 的 web 后端**(`search` + `extract` 都复用
38
+ `DEEPSEEK_API_KEY` —— DeepSeek 没有单独的搜索密钥)。它们在 fork
36
39
  [`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
37
40
  按基座版本各有一个**集成构建的 tag**,自成命名空间:
38
41
 
@@ -44,7 +47,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
44
47
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
45
48
  可排序,所以「最新」就是一次 `sort -V`。
46
49
 
47
- **当前集成构建:`agentus/v0.22.0/merge-20261015-0014`** —— fork 上最新的那个 tag,基座 Hermes
50
+ **当前集成构建:`agentus/v0.22.0/merge-20261018-1005`** —— fork 上最新的那个 tag,基座 Hermes
48
51
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
49
52
  以它为准。
50
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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": {
@@ -430,6 +430,19 @@ const agent = () => ({
430
430
  }, Math.min(slow * 2, 1200));
431
431
  }
432
432
 
433
+ // `[blank]` — the SAME carrier with its `_meta` stripped, i.e. the shape a status line takes when
434
+ // the sentence is lost before us (measured 2026-10-17: hermes set `update._meta` while the ACP
435
+ // model's field is `field_meta`, so every status shipped as a bare empty thought). The cockpit must
436
+ // store nothing and draw nothing for it — an unreadable carrier is not 「思考 0 字」.
437
+ if (/\[blank\]/.test(text)) {
438
+ setTimeout(() => {
439
+ void send(agent._conn, sessionId, {
440
+ sessionUpdate: "agent_thought_chunk",
441
+ content: { type: "text", text: "" },
442
+ }).catch(() => undefined);
443
+ }, Math.min(slow * 2, 1000));
444
+ }
445
+
433
446
  // `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
434
447
  // Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
435
448
  // relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
@@ -615,6 +628,20 @@ const agent = () => ({
615
628
  }
616
629
  }
617
630
 
631
+ // `[plan-long]` — the same frame with steps long enough to wrap on a phone. The operator's
632
+ // complaint (2026-10-18) is about exactly this: a step that runs onto a second line on a 360px
633
+ // screen. Short English steps hide it, so the fixture has to carry the long shape.
634
+ if (/\[plan-long\]/.test(text)) {
635
+ await send(agent._conn, sessionId, {
636
+ sessionUpdate: "plan",
637
+ entries: [
638
+ { content: "读一遍失败用例,确认它失败在断言上而不是环境上", status: "completed", priority: "medium" },
639
+ { content: "把时间相关的 flaky 断言改成注入时钟,然后跑完整套件确认不再抖动", status: "in_progress", priority: "high" },
640
+ { content: "run full suite", status: "pending", priority: "medium" },
641
+ ],
642
+ });
643
+ }
644
+
618
645
  if (wantPlan) {
619
646
  // ACP requires content+priority+status on every PlanEntry: @agentclientprotocol/sdk
620
647
  // silently DROPS entries that fail validation (spec-conformant, but hostile — the
@@ -20,7 +20,7 @@ import type { BackendHandshake, CheckErrorCode } from "./registry.js";
20
20
  import {
21
21
  cleanAgentTitle, deriveTitle, titleFromLatestPrompt, TITLE_INSTRUCTION,
22
22
  } from "../title.js";
23
- import type { Store, SessionRow } from "../store/store.js";
23
+ import type { Store, SessionRow, TitleSrc } from "../store/store.js";
24
24
  import { textOf, withText } from "../store/store.js";
25
25
  import type {
26
26
  AttachmentSummary,
@@ -1786,6 +1786,12 @@ export class SessionManager {
1786
1786
  msg = { sessionId: live.info.id, kind: "agent", payload: u, createdAt: Date.now() };
1787
1787
  break;
1788
1788
  case "agent_thought_chunk":
1789
+ // An EMPTY thought is not a thought. A status carrier we cannot read (its `_meta` lost on the
1790
+ // way out, an agent using a carrier we do not know) lands here as "" — persisting it drew a
1791
+ // 「思考 0 字」 bubble that never filled, and a row in the store that says the agent thought
1792
+ // nothing. The honest degradation for an unknown extension is to render NOTHING (that is what
1793
+ // hermes' own carrier docstring promises a client), so the frame is dropped rather than stored.
1794
+ if (!textOf(u).trim()) return;
1789
1795
  msg = { sessionId: live.info.id, kind: "thought", payload: u, createdAt: Date.now() };
1790
1796
  break;
1791
1797
  case "tool_call": {
@@ -1861,7 +1867,10 @@ export class SessionManager {
1861
1867
  // provenance updates where the title is unchanged — a no-op here). This is why the
1862
1868
  // cockpit shows generated names at all: the intelligence stays in the CLI.
1863
1869
  const t = cleanAgentTitle((u as { title?: string | null }).title ?? null);
1864
- if (t) this.#applyAutoTitle(live.info.id, t, false);
1870
+ // ONE name per session. Hermes re-generates a title on every turn (auxiliary `title_generation`
1871
+ // in `agent/turn_context.py`), and this frame is how it arrives — subscribing to all of them was
1872
+ // the churn: the rail kept renaming a conversation the operator was reading.
1873
+ if (t) this.#applyAutoTitle(live.info.id, t, false, "agent");
1865
1874
  return;
1866
1875
  }
1867
1876
  case "usage_update": {
@@ -2115,12 +2124,16 @@ export class SessionManager {
2115
2124
  if (row.autoTitle && row.autoTitle !== placeholder) return;
2116
2125
  const t = deriveTitle(text);
2117
2126
  if (!t || t === row.autoTitle) return;
2118
- this.#applyAutoTitle(s.info.id, t, false);
2127
+ this.#applyAutoTitle(s.info.id, t, false, "derived");
2119
2128
  }
2120
2129
 
2121
- /** Write a generated title and tell everyone who renders a row about it. */
2122
- #applyAutoTitle(id: string, title: string, force: boolean): SessionRow | null {
2123
- const row = this.#store.setAutoTitle(id, title, { force });
2130
+ /** Write a generated title and tell everyone who renders a row about it.
2131
+ *
2132
+ * `src` is the provenance the store ranks on (derived < agent < operator): a name only moves the
2133
+ * row when it comes from a better source, which is what keeps Hermes' per-turn regeneration from
2134
+ * renaming a running conversation. */
2135
+ #applyAutoTitle(id: string, title: string, force: boolean, src: TitleSrc = "agent"): SessionRow | null {
2136
+ const row = this.#store.setAutoTitle(id, title, { force, src });
2124
2137
  if (!row) return null;
2125
2138
  const live = this.#sessions.get(id);
2126
2139
  if (live) {
@@ -2165,7 +2178,7 @@ export class SessionManager {
2165
2178
  }
2166
2179
  if (!title) title = titleFromLatestPrompt(msgs);
2167
2180
  if (!title) throw new Error("这个会话还没有可以用来生成标题的对话内容");
2168
- if (!this.#applyAutoTitle(id, title, true)) throw new Error(`no such session: ${id}`);
2181
+ if (!this.#applyAutoTitle(id, title, true, via)) throw new Error(`no such session: ${id}`);
2169
2182
  this.lastTitleVia = via;
2170
2183
  const info = this.#sessions.get(id)?.info ?? this.#coldInfo(id);
2171
2184
  this.#emit({ t: "session", session: info });
@@ -48,7 +48,11 @@ const WORD_RE = /[A-Za-z][A-Za-z0-9_+.-]{2,}|[一-龥]{2,8}/g;
48
48
  /** Term-frequency over the operator's recent text, filtered and ranked. Returns at most
49
49
  * `limit` terms; fixed hotwords always survive the cap separately. */
50
50
  export function extractDynamicHotwords(texts: string[], limit = 20): Hotword[] {
51
- const counts = new Map<string, { n: number; sample: string }>();
51
+ // One entry per WORD, not per spelling: `Release` / `release` are one word to a recogniser, so
52
+ // counting them apart spends two of the operator's slots on one term and sends both as separate
53
+ // vocabulary keys. Keyed lowercase; the emitted spelling is the one seen most often (ties keep
54
+ // whichever appeared first — usually the sentence that introduced the term).
55
+ const counts = new Map<string, { n: number; forms: Map<string, number> }>();
52
56
  for (const text of texts) {
53
57
  for (const m of text.matchAll(WORD_RE)) {
54
58
  const raw = m[0];
@@ -62,25 +66,73 @@ export function extractDynamicHotwords(texts: string[], limit = 20): Hotword[] {
62
66
  } else if (raw.length > 8) {
63
67
  continue;
64
68
  }
65
- const prev = counts.get(raw);
66
- if (prev) prev.n += 1;
67
- else counts.set(raw, { n: 1, sample: raw });
69
+ // Base16 fragments (`a551e`, `9f2c1ab`) pass every length/letters test but are never spoken:
70
+ // they are commit shas and ids the transcript cites. Require a digit so real hex-ish words
71
+ // (`LITE`, `NVDA`, `ACP`) survive.
72
+ if (/^(?=.*\d)[0-9a-f]{5,}$/i.test(raw)) continue;
73
+ const prev = counts.get(lower);
74
+ if (prev) {
75
+ prev.n += 1;
76
+ prev.forms.set(raw, (prev.forms.get(raw) ?? 0) + 1);
77
+ } else {
78
+ counts.set(lower, { n: 1, forms: new Map([[raw, 1]]) });
79
+ }
68
80
  }
69
81
  }
82
+ const spelled = (forms: Map<string, number>): string => {
83
+ let best = "";
84
+ let bestN = 0;
85
+ for (const [form, n] of forms) if (n > bestN) { best = form; bestN = n; }
86
+ return best;
87
+ };
70
88
  const ranked = [...counts.entries()]
71
89
  .filter(([, v]) => v.n >= 2)
72
- .sort((a, b) => b[1].n - a[1].n || a[0].localeCompare(b[0]))
90
+ .map(([lower, v]) => ({ lower, word: spelled(v.forms), n: v.n }))
91
+ .sort((a, b) => b.n - a.n || a.lower.localeCompare(b.lower))
73
92
  .slice(0, limit);
74
- return ranked.map(([word, v]) => ({
93
+ return ranked.map(({ word, n }) => ({
75
94
  word,
76
- weight: v.n >= 5 ? 5 : 4,
95
+ weight: n >= 5 ? 5 : 4,
77
96
  origin: "dynamic" as const,
78
97
  }));
79
98
  }
80
99
 
100
+ /** The natural-language text of one stored message, wherever its kind puts it.
101
+ *
102
+ * Each shape has already been read wrong once, so they are spelled out here instead of being
103
+ * re-guessed at every call site:
104
+ * * `user` rows carry `payload.text`;
105
+ * * ACP rows (`agent`, `thought`) carry the frame as `payload.content = { text }` — reading
106
+ * `payload.text` gives undefined, and `String(payload.content)` gives `"[object Object]"`
107
+ * (which is how `object` ended up in the word pool);
108
+ * * `tool` rows carry `payload.title`, and that title is the COMMAND (`execute_code: import
109
+ * sqlite3 …`). A tool command is not something the operator is about to say, so this
110
+ * returns "" for them — identifiers like `input_mouse`/`emulation`/`https` came in from here.
111
+ *
112
+ * Callers still choose which kinds to feed (see `contextTexts`); this only answers "where is
113
+ * this row's text", never "is this row worth reading". */
114
+ export function rowText(row: { kind: string; payload: unknown }): string {
115
+ const p = row.payload as { text?: unknown; content?: { text?: unknown } | unknown } | null;
116
+ if (row.kind === "user") return typeof p?.text === "string" ? p.text : "";
117
+ if (row.kind === "agent" || row.kind === "thought") {
118
+ const c = p?.content as { text?: unknown } | undefined;
119
+ return typeof c?.text === "string" ? c.text : "";
120
+ }
121
+ return "";
122
+ }
123
+
124
+ /** Drop fenced code blocks before mining: a model pasting `input_mouse` into a snippet is not
125
+ * the model about to SAY `input_mouse`. Inline `code spans` are deliberately kept — they are
126
+ * usually the very terms worth biasing (`kasmvnc`, `betabox`). */
127
+ export function stripFencedCode(text: string): string {
128
+ return text.replace(/```[\s\S]*?```/g, " ").replace(/\s+/g, " ").trim();
129
+ }
130
+
81
131
  /** Recent transcript text to mine hotwords from: the given session first (its messages
82
132
  * are the conversation the operator is dictating INTO), then the other sessions so a
83
- * term used across the cockpit today also surfaces. */
133
+ * term used across the cockpit today also surfaces. Only natural-language rows feed the
134
+ * pool (`user` + `agent`): tool calls and reasoning carry identifiers the operator will
135
+ * never pronounce, and they used to fill most of the dynamic slots. */
84
136
  export function contextTexts(store: Store, sessionId?: string, budgetChars = 24_000): string[] {
85
137
  const out: string[] = [];
86
138
  let used = 0;
@@ -88,8 +140,8 @@ export function contextTexts(store: Store, sessionId?: string, budgetChars = 24_
88
140
  let n = 0;
89
141
  for (const r of rows) {
90
142
  if (n >= cap || used > budgetChars) break;
91
- const p = r.payload as { text?: string; title?: string; content?: string };
92
- const t = String(p?.text ?? p?.title ?? p?.content ?? "");
143
+ if (r.kind !== "user" && r.kind !== "agent") continue;
144
+ const t = stripFencedCode(rowText(r));
93
145
  if (t.length > 1 && t.length < 4000) {
94
146
  out.push(t);
95
147
  used += t.length;
@@ -97,27 +149,52 @@ export function contextTexts(store: Store, sessionId?: string, budgetChars = 24_
97
149
  }
98
150
  }
99
151
  };
100
- if (sessionId) take(store.messagesTail(sessionId, 60).messages, 40);
152
+ // Ask the store for natural-language rows; a row window (`tail(60)`) is routinely all tool calls.
153
+ if (sessionId) take(store.messagesLastKinds(sessionId, ["user", "agent"], 40), 40);
101
154
  for (const s of store.listSessions(false)) {
102
155
  if (s.id === sessionId) continue;
103
- take(store.messagesTail(s.id, 12).messages, 8);
156
+ take(store.messagesLastKinds(s.id, ["user", "agent"], 8), 8);
104
157
  }
105
158
  return out;
106
159
  }
107
160
 
161
+ /** The tail of a session for DashScope's context enhancement ("根据上下文"): user/assistant turns
162
+ * only, capped at 5 by the API.
163
+ *
164
+ * The window used to be measured on ROWS and then filtered by KIND (`tail(8)`, then skip everything
165
+ * that is not user/agent), which is empty almost always: dictation happens right after the agent
166
+ * finished a burst of work, and 40+ consecutive `tool`/`thought` rows are normal here (measured on a
167
+ * live session: zero natural-language rows in its last 40). So read a wide tail and take the last 5
168
+ * SURVIVORS. Lives next to the pool because both answer the same question with the same row shapes. */
169
+ export function dictationContext(store: Store, sessionId?: string, turns = 5): { role: "user" | "assistant"; text: string }[] {
170
+ if (!sessionId) return [];
171
+ // Read MORE than `turns` natural-language rows (a fenced-only reply can filter out) but never
172
+ // by row count: 200 rows of a busy session is 0 sentences, which is how this returned `[]` for
173
+ // a session with seven user turns in it.
174
+ const out: { role: "user" | "assistant"; text: string }[] = [];
175
+ for (const row of store.messagesLastKinds(sessionId, ["user", "agent"], turns + 5)) {
176
+ const text = stripFencedCode(rowText(row)).slice(0, 200);
177
+ if (text) out.push({ role: row.kind === "user" ? "user" : "assistant", text });
178
+ }
179
+ return out.slice(-turns);
180
+ }
181
+
108
182
  /** The full list an ASR request should carry: fixed first (they may use =50), dynamic
109
183
  * filling the rest up to the configured limit. */
110
184
  export function hotwordsFor(store: Store, sessionId?: string): Hotword[] {
111
185
  const v = getSettings().voice;
112
186
  const fixed = v.hotwords.map(parseHotword).filter((h): h is Hotword => Boolean(h));
113
- const seen = new Set(fixed.map((h) => h.word));
187
+ // lowercase on purpose: a dynamic `hermes` must not ride along beside a fixed `Hermes` (same word to
188
+ // the recogniser, two vocabulary keys and two slots for us)
189
+ const seen = new Set(fixed.map((h) => h.word.toLowerCase()));
114
190
  const limit = Math.max(0, Math.min(200, v.hotwordLimit));
115
191
  const out: Hotword[] = fixed.slice(0, limit);
116
192
  if (v.dynamicHotwords) {
117
193
  const dyn = extractDynamicHotwords(contextTexts(store, sessionId), Math.max(0, limit - out.length));
118
194
  for (const h of dyn) {
119
- if (seen.has(h.word)) continue;
120
- seen.add(h.word);
195
+ const key = h.word.toLowerCase();
196
+ if (seen.has(key)) continue;
197
+ seen.add(key);
121
198
  out.push(h);
122
199
  }
123
200
  }
@@ -3,6 +3,7 @@
3
3
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import type { Duplex } from "node:stream";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
+ import { execFileSync } from "node:child_process";
6
7
  import { homedir } from "node:os";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import path from "node:path";
@@ -17,7 +18,7 @@ import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnin
17
18
  import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
18
19
  import { terms } from "./term.js";
19
20
  import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
20
- import { hotwordsFor, vocabularyOf } from "./hotwords.js";
21
+ import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
21
22
  import { openDashscopeStream } from "./dashscope.js";
22
23
  import {
23
24
  agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
@@ -71,6 +72,23 @@ const DATA_DIR_SOURCE = ENV_DATA_DIR
71
72
  : DATA_DIR === REPO_DATA_DIR
72
73
  ? "in-repo packages/server/.data"
73
74
  : hasData(DEFAULT_DATA_DIR) ? "existing ~/.agentus" : "default ~/.agentus (created on this boot)";
75
+ // WHICH CODE THIS PROCESS IS RUNNING — resolved once, at boot, on purpose.
76
+ //
77
+ // The live server has no watcher, so the only thing that decides "is this release live?" is what
78
+ // this process loaded. A promote used to compare the live tree's previous checkout with the new
79
+ // tip, which stops being true the moment anything moves the tree first (an earlier promote that
80
+ // died after checkout, a manual `git checkout`, a re-run): every later promote then saw a zero
81
+ // diff, printed "no server-side change" and left the old process serving — the release looked
82
+ // green while the code was a commit behind. This is that fact, from the process itself: promote
83
+ // asks the server what it booted, and restarts because the answer is not the target commit.
84
+ const BOOT_COMMIT = (() => {
85
+ try {
86
+ const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
87
+ cwd: path.resolve(HERE, "../.."), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
88
+ }).trim();
89
+ return out || null;
90
+ } catch { return null; } // a published install has no .git — nothing to name
91
+ })();
74
92
  const WEB_DIST = process.env.AGENTUS_WEB_DIST ?? path.resolve(HERE, "../../web/dist");
75
93
  // The Android companion artifact (/notify links to it, /agentus-companion.apk streams it).
76
94
  // First candidate is relative to THIS FILE (src → packages/server → packages → repo root,
@@ -374,19 +392,6 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
374
392
  });
375
393
  }
376
394
 
377
- /** The tail of the session being dictated into, for DashScope's context enhancement
378
- * ("根据上下文"): user/assistant turns only, capped at 5 by the API. */
379
- function dictationContext(sessionId?: string): { role: "user" | "assistant"; text: string }[] {
380
- if (!sessionId) return [];
381
- const out: { role: "user" | "assistant"; text: string }[] = [];
382
- for (const row of store.messagesTail(sessionId, 8).messages) {
383
- if (row.kind !== "user" && row.kind !== "agent") continue;
384
- const text = String((row.payload as { text?: string })?.text ?? "").trim().slice(0, 200);
385
- if (text) out.push({ role: row.kind === "user" ? "user" : "assistant", text });
386
- }
387
- return out.slice(-5);
388
- }
389
-
390
395
  /**
391
396
  * Single prompt entry point (REST + WS both use it).
392
397
  * A failed turn must never be silent: the UI used to show a bare
@@ -1199,10 +1204,16 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1199
1204
  if (url.pathname === "/api/voice/hotwords" && req.method === "GET") {
1200
1205
  const sid = url.searchParams.get("sessionId") || undefined;
1201
1206
  const words = hotwordsFor(store, sid);
1207
+ // contextTurns is the half of the request the settings page could not see: it is the
1208
+ // count of natural-language turns the next recognition would carry, and it used to be
1209
+ // 0 forever because the window was applied before the kind filter (issue #21).
1210
+ const context = dictationContext(store, sid);
1202
1211
  return send(res, 200, {
1203
1212
  words: words.map((h) => ({ word: h.word, weight: h.weight, origin: h.origin })),
1204
1213
  fixed: words.filter((h) => h.origin === "fixed").length,
1205
1214
  dynamic: words.filter((h) => h.origin === "dynamic").length,
1215
+ contextTurns: context.length,
1216
+ context: context.map((c) => ({ role: c.role, text: c.text.slice(0, 80) })),
1206
1217
  });
1207
1218
  }
1208
1219
  // What the browser should offer: it can always speak/read text itself, and the
@@ -1536,7 +1547,9 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1536
1547
  // launcher sets neither, so live answers false. An instance-level fact, so it rides the
1537
1548
  // endpoint that already describes the instance rather than every backend row.
1538
1549
  const dev = Boolean(process.env.DEV_TREE) || process.env.AGENTUS_DEV === "1";
1539
- return send(res, 200, { asset, dev });
1550
+ // `commit`: the code THIS process booted from (see BOOT_COMMIT). It is the only honest answer
1551
+ // to "did the release actually land", because the tree can be moved under a running server.
1552
+ return send(res, 200, { asset, dev, commit: BOOT_COMMIT });
1540
1553
  }
1541
1554
  if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not_found" });
1542
1555
  return serveStatic(req, res);
@@ -1790,7 +1803,7 @@ asrWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1790
1803
  try {
1791
1804
  live = openDashscopeStream({
1792
1805
  vocabulary: vocabularyOf(words),
1793
- context: dictationContext(sessionId),
1806
+ context: dictationContext(store, sessionId),
1794
1807
  onPartial: (text) => reply({ t: "asr-partial", text }),
1795
1808
  onFinal: (text) => reply({ t: "asr-final", text }),
1796
1809
  onError: (error) => reply({ t: "asr-error", error }),
@@ -62,6 +62,11 @@ function reemissionTarget(blockText: string, incoming: string): "same" | "grew"
62
62
  /** A page also stops on bytes, so one 30 KB message cannot overflow a phone-sized fetch. */
63
63
  const PAGE_BYTES = 256 * 1024;
64
64
 
65
+ /** Provenance of a session's name, ranked: a name may only be overwritten by a BETTER source
66
+ * (or by the operator asking for a new one). See `setAutoTitle`. */
67
+ export type TitleSrc = "derived" | "agent" | "operator";
68
+ const TITLE_RANK: Record<TitleSrc, number> = { derived: 1, agent: 2, operator: 3 };
69
+
65
70
  export interface SessionRow {
66
71
  id: string;
67
72
  backend: BackendId;
@@ -73,6 +78,9 @@ export interface SessionRow {
73
78
  autoTitle?: string | null;
74
79
  /** session id of whoever renamed it through the cockpit CLI; null = the operator's own name */
75
80
  titleBy?: string | null;
81
+ /** Where the CURRENT name came from — see the `title_src` migration below. Not part of SessionInfo:
82
+ * it is bookkeeping that decides who may overwrite the row, not something the page draws. */
83
+ titleSrc?: TitleSrc | null;
76
84
  status: SessionStatus;
77
85
  pid: number | null;
78
86
  createdAt: number;
@@ -113,7 +121,8 @@ export interface SessionRow {
113
121
 
114
122
  interface RawSessionRow {
115
123
  id: string; backend: BackendId; acp_session_id: string | null; cwd: string;
116
- title: string; auto_title?: string | null; title_by?: string | null; status: SessionStatus; pid: number | null;
124
+ title: string; auto_title?: string | null; title_by?: string | null; title_src?: string | null;
125
+ status: SessionStatus; pid: number | null;
117
126
  created_at: number; closed_at: number | null; archived_at?: number | null;
118
127
  modes?: string | null; config_options?: string | null;
119
128
  usage?: string | null; commands?: string | null;
@@ -178,7 +187,8 @@ function blockKeyOf(kind: string, payload: unknown): string | null {
178
187
  function rowToSession(r: RawSessionRow): SessionRow {
179
188
  return {
180
189
  id: r.id, backend: r.backend, acpSessionId: r.acp_session_id, cwd: r.cwd,
181
- title: r.title, autoTitle: r.auto_title ?? null, titleBy: r.title_by ?? null, status: r.status, pid: r.pid,
190
+ title: r.title, autoTitle: r.auto_title ?? null, titleBy: r.title_by ?? null,
191
+ titleSrc: (r.title_src as TitleSrc | null) ?? null, status: r.status, pid: r.pid,
182
192
  createdAt: r.created_at, closedAt: r.closed_at, archivedAt: r.archived_at ?? null,
183
193
  modes: parseJson(r.modes), configOptions: parseJson(r.config_options) ?? [],
184
194
  usage: parseJson(r.usage), commands: parseJson(r.commands) ?? [],
@@ -376,6 +386,19 @@ export class Store {
376
386
  // rail would show it as fact. NULL = the operator (every existing row), a session id = that
377
387
  // session's agent renamed it through the cockpit CLI.
378
388
  if (!cols.has("title_by")) this.#db.exec("alter table sessions add column title_by text");
389
+ // WHERE THE CURRENT NAME CAME FROM, which `title_by` cannot answer: a generated name has no author
390
+ // (title_by is NULL) — exactly like a name the operator typed. Without this column the store cannot
391
+ // tell 「the operator named this」 from 「we generated this」, so every later generated name looked
392
+ // like the first one and moved the display title. Measured 2026-10-17: Hermes regenerates a title
393
+ // on EVERY turn (auxiliary `title_generation`), so a running conversation was renamed each time.
394
+ // 'derived' — truncated from the operator's first prompt (no model, instant)
395
+ // 'agent' — the agent announced a name (session_info_update / the 重新生成 fork)
396
+ // 'operator' — the operator typed it; nothing generated may move it
397
+ // NULL on existing rows = generated before this column existed; treated as 'agent'.
398
+ if (!cols.has("title_src")) this.#db.exec("alter table sessions add column title_src text");
399
+ // Existing rows get NULL here, i.e. "no generated name was ever recorded" — harmless: a legacy row
400
+ // that already wears a generated name is protected by `#autoTitleFrom`'s placeholder test, which
401
+ // only ever re-derives a name for a row still showing "Hermes @ tmp".
379
402
  // The session's own ACTIVITY clock — when the operator or the agent last really said something
380
403
  // in it. A COLUMN, not a derived `max(messages.created_at)` (AionUi's model: `modified_at` on
381
404
  // the conversation row, indexed, which is what its sidebar sorts by): every ingestion path
@@ -1069,6 +1092,23 @@ export class Store {
1069
1092
  return { hasMore: hasMore || page.length < capped.length, messages: page.reverse().map(rowToMessage) };
1070
1093
  }
1071
1094
 
1095
+ /** The newest rows of the given KINDS, ignoring whatever sits in between.
1096
+ *
1097
+ * A row window is the wrong unit when the question is "what did the humans say": one agentic
1098
+ * turn emits hundreds of `tool`/`thought` rows between two sentences, so `messagesTail(200)`
1099
+ * legitimately contains zero natural-language rows (measured on a live session: 275 rows, 153
1100
+ * tool + 106 thought, and every user/agent row older than the window). Filtering by kind in
1101
+ * SQL is the only way to ask for "the last 5 sentences" and actually get them.
1102
+ */
1103
+ messagesLastKinds(sessionId: string, kinds: string[], limit = 5): StoredMessage[] {
1104
+ if (!kinds.length || limit <= 0) return [];
1105
+ const marks = kinds.map(() => "?").join(",");
1106
+ const rows = this.#db
1107
+ .prepare(`select * from messages where session_id = ? and kind in (${marks}) order by seq desc limit ?`)
1108
+ .all(sessionId, ...kinds, limit) as unknown as RawMessageRow[];
1109
+ return rows.reverse().map(rowToMessage);
1110
+ }
1111
+
1072
1112
  messagesAfter(sessionId: string, afterSeq: number, limit = 500): StoredMessage[] {
1073
1113
  const rows = this.#db
1074
1114
  .prepare(
@@ -1099,10 +1139,15 @@ export class Store {
1099
1139
  | { title: string; auto_title: string | null }
1100
1140
  | undefined;
1101
1141
  if (!row) return false;
1102
- const next = title == null || !title.length ? (row.auto_title ?? row.title) : title;
1142
+ const named = title != null && title.length > 0;
1143
+ const next = named ? title : (row.auto_title ?? row.title);
1103
1144
  // `by` rides along on every write, including a reset: after `--auto` the name is generated again,
1104
1145
  // and the note that an agent once renamed it would be a lie about the CURRENT name.
1105
- const info = this.#db.prepare("update sessions set title = ?, title_by = ? where id = ?").run(next, by, id);
1146
+ // `title_src` is what PINS the row: a hand-written name must survive every later generated one,
1147
+ // while a reset hands the name back to whichever generator wrote `auto_title`.
1148
+ const src = named ? "operator" : (row.auto_title ? "agent" : null);
1149
+ const info = this.#db.prepare("update sessions set title = ?, title_by = ?, title_src = ? where id = ?")
1150
+ .run(next, by, src, id);
1106
1151
  return Number(info.changes ?? 0) > 0;
1107
1152
  }
1108
1153
 
@@ -1115,18 +1160,30 @@ export class Store {
1115
1160
  * the fallback and leaves the visible row alone. `force` is for the operator asking for a
1116
1161
  * new name on purpose (the rail's 重新生成), where the new title is the point.
1117
1162
  * Returns the row it ended up with, so callers can broadcast exactly what changed. */
1118
- setAutoTitle(id: string, title: string, { force = false }: { force?: boolean } = {}): SessionRow | null {
1119
- const row = this.#db.prepare("select title, auto_title from sessions where id = ?").get(id) as
1120
- | { title: string; auto_title: string | null }
1163
+ setAutoTitle(
1164
+ id: string, title: string, { force = false, src = "agent" }: { force?: boolean; src?: TitleSrc } = {},
1165
+ ): SessionRow | null {
1166
+ const row = this.#db.prepare("select title, auto_title, title_src from sessions where id = ?").get(id) as
1167
+ | { title: string; auto_title: string | null; title_src: string | null }
1121
1168
  | undefined;
1122
1169
  if (!row) return null;
1123
1170
  const clean = title.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120).trim();
1124
1171
  if (!clean) return null;
1125
- const wasAuto = row.auto_title == null || row.auto_title === "" || row.title === row.auto_title;
1126
- const nextTitle = force || wasAuto ? clean : row.title;
1172
+ // WHO WINS, by rank — derived < agent < operator. A name only moves the row when it comes from a
1173
+ // BETTER source than the one already on it (or when there is nothing yet, or the operator asked
1174
+ // for a new one). This is what stops 「跑着跑着标题就变了」: Hermes regenerates a title every turn,
1175
+ // and every one of those is an `agent` name arriving over an `agent` name — the same generator
1176
+ // saying the same thing again, not news. A `derived` guess still yields to the first real one.
1177
+ // `null` = nothing has NAMED this row yet. That is not the same as "the operator named it": every
1178
+ // row is INSERTED with `auto_title = title` (the placeholder "Hermes @ tmp"), so the column alone
1179
+ // cannot say whether a name was ever generated — only `title_src` can.
1180
+ const current = row.title_src as TitleSrc | null;
1181
+ const named = current != null && row.auto_title != null && row.auto_title !== "";
1182
+ const wins = force || !named || TITLE_RANK[src] > TITLE_RANK[current];
1183
+ if (!wins) return this.getSession(id) ?? null;
1127
1184
  this.#db
1128
- .prepare("update sessions set auto_title = ?, title = ? where id = ?")
1129
- .run(clean, nextTitle, id);
1185
+ .prepare("update sessions set auto_title = ?, title = ?, title_src = ? where id = ?")
1186
+ .run(clean, clean, src, id);
1130
1187
  return this.getSession(id) ?? null;
1131
1188
  }
1132
1189