agentus 0.9.0 → 0.10.1

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
@@ -53,10 +53,11 @@ depth**, a permission mode that survives a restart, the **context-budget** field
53
53
  channel** (memory recall/save hints in the message stream), per-process skill roots, **project
54
54
  skills made visible and switchable** (a repo's `.agents/skills` shows up as a `project_skills`
55
55
  option instead of being skipped in silence), **background-job completion wake-ups** (a finished
56
- background process comes back as a turn of its own), and a **DeepSeek web backend** (search +
56
+ background process comes back as a turn of its own), an **ACP session that writes its own log files** (`agent.log`/`errors.log`, so a
57
+ cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
57
58
  extract on the model key) — come from the fork's integration build instead. That is a five-line install:
58
59
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
59
- `agentus/v0.22.0/merge-20261021-1400`, on Hermes `v0.22.0`.
60
+ `agentus/v0.22.0/merge-20261022-2226`, on Hermes `v0.22.0`.
60
61
 
61
62
  The first slot you start will use the Hermes home **your own agent already uses**
62
63
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -47,10 +47,11 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
47
47
  **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道**、按进程指定的技能根、
48
48
  **项目 skill 可见可设**(仓库的 `.agents/skills` 会以 `project_skills` 选项出现,而不是被无声跳过)、
49
49
  **后台任务完成唤醒**(后台进程跑完会自己开一个回合回来),
50
+ **ACP 会话自己写日志**(`agent.log`/`errors.log`,驾驶舱里出的问题事后可查),
50
51
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
51
52
  装法就五行:
52
53
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
53
- `agentus/v0.22.0/merge-20261021-1400`,基座 Hermes `v0.22.0`。
54
+ `agentus/v0.22.0/merge-20261022-2226`,基座 Hermes `v0.22.0`。
54
55
 
55
56
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
56
57
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
60
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
61
61
  last segment sorts, so "newest" is a `sort -V`.
62
62
 
63
- **Current integration build: `agentus/v0.22.0/merge-20261021-1400`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261022-2226`** — the newest tag on the fork,
64
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
65
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
66
66
  ever looks stale.
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
52
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
53
53
  可排序,所以「最新」就是一次 `sort -V`。
54
54
 
55
- **当前集成构建:`agentus/v0.22.0/merge-20261021-1400`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261022-2226`** —— fork 上最新的那个 tag,基座 Hermes
56
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
57
57
  以它为准。
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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": {
@@ -78,12 +78,15 @@
78
78
  "live-stream": "tsx scripts/qa/live-stream.mts",
79
79
  "open-target": "tsx scripts/qa/open-target.mts",
80
80
  "rail-recent": "tsx scripts/qa/rail-recent.mts",
81
+ "rail-breakpoints": "node scripts/qa/rail-breakpoints.mjs",
81
82
  "permission-echo": "node scripts/qa/permission-echo.mjs",
82
83
  "turn-pulse": "tsx scripts/qa/turn-pulse.mts",
83
84
  "resume-tail": "tsx scripts/qa/resume-tail.mts",
84
85
  "render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
85
86
  "replay-window": "node scripts/qa/replay-window.mjs",
86
- "think-cadence": "node scripts/qa/think-cadence.mjs"
87
+ "think-cadence": "node scripts/qa/think-cadence.mjs",
88
+ "vad": "tsx scripts/qa/vad.mts",
89
+ "vad-silero": "tsx scripts/qa/vad-silero.mts"
87
90
  },
88
91
  "engines": {
89
92
  "node": ">=22.5"
@@ -62,7 +62,7 @@ export const THEME_DEFAULT: ThemeSettings = { mode: "system", accent: "" };
62
62
  * what a phone's own loudspeaker leaks back into its microphone is nothing like a headset
63
63
  * in a quiet room — so the defaults are a starting point, not a truth. The call page owns
64
64
  * the panel; the settings page reads the same numbers. */
65
- export type InterruptMode = "voice" | "button";
65
+ export type InterruptMode = "voice" | "button" | "keyword";
66
66
 
67
67
  export interface CallSettings {
68
68
  /** WHO gets to interrupt the agent, and the operator picks:
@@ -85,6 +85,10 @@ export interface CallSettings {
85
85
  * as a turn (1 = no limit). A tap on the orb always sends what is there — that gesture
86
86
  * is an explicit instruction, not a guess about where a sentence ended. */
87
87
  minChars: number;
88
+ /** 关键词打断 — the phrase that must be HEARD before the floor changes hands (opt-in; empty means
89
+ * the mode is inert). No shipped default on purpose: nobody should be able to interrupt this
90
+ * agent by guessing a word we chose for them. */
91
+ wakePhrase: string;
88
92
  }
89
93
 
90
94
  /** How the operator wants to TALK to the agent. These were browser-local (localStorage) and
@@ -150,14 +154,32 @@ export const PREFS_DEFAULT: PrefsSettings = {
150
154
  planHint: { on: true, everyTurn: true, instruction: "" },
151
155
  };
152
156
 
157
+ /** The longest wake phrase, in CODE POINTS (an emoji is one, not two). One number, one place — the
158
+ * web side has its own copy (`WAKE_MAX` in callSettings.ts) and the two must keep saying 24. */
159
+ const WAKE_MAX = 24;
160
+
161
+ /** Drop half-pairs left by a client that counted UTF-16 units and cut in the middle of an emoji.
162
+ * `for...of` walks code points, so a lone surrogate arrives as a one-character string and is easy
163
+ * to spot; storing it would put U+FFFD in the database. */
164
+ function stripLone(s: string): string {
165
+ let out = "";
166
+ for (const ch of s) {
167
+ const cp = ch.codePointAt(0) ?? 0;
168
+ if (cp >= 0xD800 && cp <= 0xDFFF) continue; // a surrogate that did not pair with its other half
169
+ out += ch;
170
+ }
171
+ return out;
172
+ }
173
+
153
174
  export const CALL_DEFAULT: CallSettings = {
154
175
  /* 说完停顿 1.5s (was 1.2): the operator reads a pause as "it stopped listening" before 1.2 s,
155
176
  and a long sentence with a breath in the middle got cut. Both ends of the range still let
156
177
  the room decide — this is only where a fresh install starts. */
157
178
  interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1500, minChars: 3,
179
+ wakePhrase: "",
158
180
  };
159
181
 
160
- export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
182
+ export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button", "keyword"];
161
183
 
162
184
  /** How the cockpit treats AGENT PROCESSES — as opposed to what a conversation contains. AionUi
163
185
  * ships the same knob in 设置 → 系统 as 「Agent 空闲超时(分钟)」 (default 5, 1–60, step 5) with the
@@ -369,7 +391,11 @@ function callAtShippedDefaults(r: Record<string, unknown>): boolean {
369
391
  // The mode counts too: a row that only says `interruptMode: "button"` IS a deliberate choice and
370
392
  // must not be reset along with a section that happens to still carry the old numbers.
371
393
  const modeShipped = r.interruptMode === undefined || r.interruptMode === CALL_DEFAULT.interruptMode;
372
- return sens && modeShipped && Number(r.bargeMs) === 300 && Number(r.silenceMs) === 1200 && Number(r.minChars) === 1;
394
+ // …and a stored wake phrase is a deliberate choice too: without this, setting one and switching
395
+ // back to 语音打断 would make the whole section look untouched and the phrase would be dropped.
396
+ const wakeShipped = r.wakePhrase === undefined || r.wakePhrase === CALL_DEFAULT.wakePhrase;
397
+ return sens && modeShipped && wakeShipped
398
+ && Number(r.bargeMs) === 300 && Number(r.silenceMs) === 1200 && Number(r.minChars) === 1;
373
399
  }
374
400
 
375
401
  /** What each knob may be. The ranges are the panel's slider ends; the server is the one
@@ -503,6 +529,12 @@ function pickCall(raw: unknown): CallSettings {
503
529
  if (callAtShippedDefaults(raw as Record<string, unknown>)) return out;
504
530
  const mode = (raw as Record<string, unknown>).interruptMode;
505
531
  if (INTERRUPT_MODES.includes(mode as InterruptMode)) out.interruptMode = mode as InterruptMode;
532
+ const wake = (raw as Record<string, unknown>).wakePhrase;
533
+ // NOT length-capped here on purpose: this is what somebody ELSE stored (curl, a script, a previous
534
+ // version). Cutting it on the way out would then be written straight back by the `persist()` that
535
+ // runs at boot — silently destroying the operator's phrase with no log and nothing on screen.
536
+ // Length belongs to the write path (`saveCall`), which is where a human typed it.
537
+ if (typeof wake === "string") out.wakePhrase = wake.replace(/\s+/g, " ").trim();
506
538
  for (const k of CALL_NUMERIC) {
507
539
  const v = Number((raw as Record<string, unknown>)[k]);
508
540
  const [lo, hi] = CALL_RANGE[k];
@@ -734,10 +766,24 @@ export function saveCall(patch: Record<string, unknown>): Settings {
734
766
  if ("interruptMode" in patch) {
735
767
  const mode = patch.interruptMode;
736
768
  if (!INTERRUPT_MODES.includes(mode as InterruptMode)) {
737
- throw new Error(`invalid interruptMode: ${JSON.stringify(mode)} (expected voice|button)`);
769
+ throw new Error(`invalid interruptMode: ${JSON.stringify(mode)} (expected voice|button|keyword)`);
738
770
  }
739
771
  c.interruptMode = mode as InterruptMode;
740
772
  }
773
+ if ("wakePhrase" in patch) {
774
+ if (typeof patch.wakePhrase !== "string") throw new Error("invalid wakePhrase: expected a string");
775
+ // Truncated, not rejected: this field arrives in the same body as the sliders, and refusing it
776
+ // would throw away the operator's OTHER changes with it (and the panel would then say "saved
777
+ // locally, not on the server" for a phrase nobody was trying to abuse). Same rule as pickCall.
778
+ const wake = stripLone(patch.wakePhrase.replace(/\s+/g, " ").trim()); // an old client can slice a surrogate in half
779
+ const kept = [...wake];
780
+ if (kept.length > WAKE_MAX) {
781
+ // Truncated rather than refused, but never silently: one line in the log is the difference
782
+ // between 「我设过这个词」 and 「它自己变短了」 when a script writes 40 characters.
783
+ console.warn(`[agentus] wakePhrase truncated: ${kept.length} → ${WAKE_MAX} characters`);
784
+ }
785
+ c.wakePhrase = kept.slice(0, WAKE_MAX).join("");
786
+ }
741
787
  for (const key of CALL_NUMERIC) {
742
788
  if (!(key in patch)) continue;
743
789
  const raw = typeof patch[key] === "number" ? patch[key] as number : Number(patch[key]);