agentus 0.6.1 → 0.8.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.
@@ -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";
@@ -760,7 +761,26 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
760
761
  if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
761
762
  const target = rowOf(to);
762
763
  if (!target) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
763
- if (target.cold) return send(res, 409, { error: COCKPIT_ERRORS.targetCold });
764
+ // #41: a cold target used to end the conversation here. Waking is now EXPLICIT (`--wake`),
765
+ // because it costs a process — but refusing every cold send made cross-session work
766
+ // unusable, since the idle reaper keeps targets cold by design.
767
+ const wantWake = body.wake === true;
768
+ let woke = false;
769
+ if (target.cold) {
770
+ if (!wantWake) {
771
+ return send(res, 409, {
772
+ error: COCKPIT_ERRORS.targetCold,
773
+ message: "the target has no running process; pass --wake (wake:true) to start it, then the message is delivered in the same call",
774
+ wakeable: !target.archivedAt,
775
+ });
776
+ }
777
+ try {
778
+ await mgr.resume(to);
779
+ woke = true;
780
+ } catch (e) {
781
+ return send(res, 409, { error: COCKPIT_ERRORS.wakeFailed, message: String((e as Error)?.message ?? e) });
782
+ }
783
+ }
764
784
  // Deliberately NOT `interrupt`: a cross-session message must never take the floor from a turn
765
785
  // the OPERATOR started. A busy target answers `target_busy` and the caller reports it as such.
766
786
  try {
@@ -774,7 +794,24 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
774
794
  const why = String((e as Error)?.message ?? e);
775
795
  return send(res, 409, { error: why.includes("turn already running") ? COCKPIT_ERRORS.targetBusy : why });
776
796
  }
777
- return send(res, 200, { delivered: true, to, code: "delivered" });
797
+ return send(res, 200, { delivered: true, to, code: "delivered", ...(woke ? { delivered_after_wake: true } : {}) });
798
+ }
799
+ if (url.pathname === "/api/cockpit/wake" && req.method === "POST") {
800
+ const body = await readJson(req);
801
+ const id = String(body.id ?? "");
802
+ if (!id) return send(res, 400, { error: COCKPIT_ERRORS.badRequest });
803
+ // Same gate as send (#41): waking is what makes a send possible, so the two answers must
804
+ // never disagree about whether an agent may pull another session's process up.
805
+ if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
806
+ const target = rowOf(id);
807
+ if (!target) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
808
+ if (!target.cold) return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, woke: false, already_live: true, id });
809
+ try {
810
+ const info = await mgr.resume(id);
811
+ return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, woke: true, id, status: info.status });
812
+ } catch (e) {
813
+ return send(res, 409, { error: COCKPIT_ERRORS.wakeFailed, message: String((e as Error)?.message ?? e) });
814
+ }
778
815
  }
779
816
  return send(res, 404, { error: "unknown cockpit command" });
780
817
  }
@@ -1592,12 +1629,19 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1592
1629
  const row = store.getSession(id);
1593
1630
  if (!row) return { route: null, why: "会话不在库里" };
1594
1631
  const be = store.listBackends().find((b) => b.id === row.backend);
1595
- const models = (row.models ?? {}) as { currentModelId?: string | null };
1632
+ // LIVE first, store second (#36). The ACP handshake reports the model, and the live
1633
+ // session object holds it — but `sessions.models` is flushed later (observed: JSON null
1634
+ // for every row on this box), so reading the store alone answered 「agent 未握手」 and
1635
+ // every summary silently fell back to the voice endpoint's qwen-flash.
1636
+ const live = mgr.list().find((s) => s.id === id);
1637
+ const modelId = live?.models?.currentModelId
1638
+ ?? ((row.models ?? {}) as { currentModelId?: string | null }).currentModelId
1639
+ ?? null;
1596
1640
  // The provider keys live in this box's agent home, which is a property of the runtime —
1597
1641
  // not of the vendor that happens to be driving this session. A backend that owns no home
1598
1642
  // (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
1599
1643
  const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
1600
- return routeForModel(models.currentModelId ?? null, home);
1644
+ return routeForModel(modelId, home);
1601
1645
  };
1602
1646
  // 语音总结 (the message speaker's long press): ONE stored message, made listenable.
1603
1647
  //
@@ -1627,6 +1671,18 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1627
1671
  if (spoken.length <= maxChars) {
1628
1672
  return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
1629
1673
  }
1674
+ // Over the ceiling we REFUSE, loudly (#36). The old behaviour summarised the head and said
1675
+ // so in a footnote — but a summary that drops the second half is worse than none, because
1676
+ // that is where the conclusion lives. 20k chars is far above any reply worth listening to
1677
+ // and still stops a pasted 200 KB log from becoming an invoice.
1678
+ if (spoken.length > SUMMARY_MAX_INPUT_CHARS) {
1679
+ return send(res, 409, {
1680
+ error: `这条消息 ${spoken.length} 字,超出语音总结上限 ${SUMMARY_MAX_INPUT_CHARS} 字 —— 直接朗读,或截取要听的那段`,
1681
+ code: "too_long",
1682
+ chars: spoken.length,
1683
+ limit: SUMMARY_MAX_INPUT_CHARS,
1684
+ });
1685
+ }
1630
1686
  try {
1631
1687
  // The operator's rule: bill this to the model he is already talking to. The voice
1632
1688
  // endpoint stays as the fallback (and is the whole route when followSession is off).
@@ -1638,6 +1694,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1638
1694
  prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
1639
1695
  model,
1640
1696
  timeoutSec: cfg.timeoutSec,
1697
+ // Thinking stays ON for qwen3 models, capped at 200 tokens (operator's call, #36):
1698
+ // a little reasoning reads the sentence better than none, and 200 keeps it from
1699
+ // wandering the way the default 4000 did (measured 235 output tokens on a 40-char ask).
1700
+ thinkingBudget: /^qwen3/i.test(model) ? SUMMARY_THINKING_BUDGET : undefined,
1641
1701
  endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
1642
1702
  });
1643
1703
  // Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
@@ -1649,7 +1709,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1649
1709
  // echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
1650
1710
  // configured number would make the menu look broken
1651
1711
  maxChars,
1652
- ms: r.ms, truncated: r.truncated, model,
1712
+ ms: r.ms, model,
1653
1713
  // which account this call was billed to — the note prints it, and a fallback that
1654
1714
  // silently used the OTHER provider would be a lie about the cost
1655
1715
  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
- export const SUMMARY_MODEL_DEFAULT = "qwen-flash";
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;
@@ -56,8 +56,10 @@ const BUILTIN: Record<string, { baseUrl: string; keys: string[]; baseEnv?: strin
56
56
 
57
57
  /**
58
58
  * ACP model ids arrive as `deepseek:deepseek-v4-flash` (provider prefix, wire form) or
59
- * `openrouter:anthropic/claude-x`; older agents use `provider/model`. Returns "" for a bare id
60
- * with no prefix — the caller then has to ask config.yaml who owns it.
59
+ * `openrouter:anthropic/claude-x`. Returns "" for a bare id with no prefix — the caller then has to
60
+ * ask config.yaml who owns it. The `provider/model` form is NOT handled here (a prefix here can be
61
+ * a vendor namespace rather than a provider); `slashProvider()` below resolves it with the provider
62
+ * tables in hand (#36).
61
63
  */
62
64
  export function splitModelId(raw: string | null | undefined): { provider: string; model: string } {
63
65
  const id = String(raw ?? "").trim();
@@ -91,6 +93,21 @@ function readConfig(home: string | null): Record<string, unknown> {
91
93
  const asString = (v: unknown): string => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "");
92
94
  const trimSlash = (u: string): string => u.replace(/\/+$/, "");
93
95
 
96
+ /**
97
+ * `provider/model` — the second id form a session can report. Only stripped when the prefix really
98
+ * names a provider this box knows (declared in config.yaml or in the builtin table): a catalogue
99
+ * slug like `anthropic/claude-x` must survive intact, or an OpenRouter model id would be mangled
100
+ * into provider `anthropic` + model `claude-x`.
101
+ */
102
+ function slashProvider(cfgProviders: Record<string, unknown>, model: string): { provider: string; model: string } {
103
+ const slash = model.indexOf("/");
104
+ if (slash <= 0) return { provider: "", model };
105
+ const cand = model.slice(0, slash).trim().toLowerCase();
106
+ if (!(cand in BUILTIN) && !(cand in cfgProviders)) return { provider: "", model };
107
+ const rest = model.slice(slash + 1).trim();
108
+ return rest ? { provider: cand, model: rest } : { provider: "", model };
109
+ }
110
+
94
111
  /**
95
112
  * Resolve the route for one session's model. `home` is the home of the backend that session runs
96
113
  * on (`planFor(row).home`) — reading a DIFFERENT agent's config would hand this call a key its
@@ -101,10 +118,19 @@ export function routeForModel(modelId: string | null | undefined, home: string |
101
118
  why: string;
102
119
  } {
103
120
  const parsed = splitModelId(modelId);
104
- const model = parsed.model;
105
- if (!model) return { route: null, why: "会话还没报出模型(agent 未握手)" };
121
+ if (!parsed.model) return { route: null, why: "会话还没报出模型(agent 未握手)" };
106
122
 
107
123
  const cfg = readConfig(home);
124
+ // Slash form is a LAST resort after the colon form (#36): `deepseek/deepseek-v4-flash` is what
125
+ // this session reports, and before this the split returned no provider at all, so the summary
126
+ // silently fell back to the voice endpoint on `qwen-flash`.
127
+ const slash = parsed.provider
128
+ ? { provider: parsed.provider, model: parsed.model }
129
+ : slashProvider(
130
+ (cfg.providers && typeof cfg.providers === "object" ? cfg.providers : {}) as Record<string, unknown>,
131
+ parsed.model,
132
+ );
133
+ const model = slash.model;
108
134
  // An alias row may rename the model as well as point at a provider (`lab-qwen38-27b` →
109
135
  // `qwen38-27b`); the wire id is not the name the vendor knows.
110
136
  const aliasRow = (aliases: Record<string, unknown>): Record<string, unknown> | null => {
@@ -121,7 +147,7 @@ export function routeForModel(modelId: string | null | undefined, home: string |
121
147
  // Who owns this model? The id's own prefix wins; otherwise config.yaml's default provider, and
122
148
  // only when the model really is that default (a session can be running a model the default no
123
149
  // longer names — then guessing the provider would send the request to the wrong vendor).
124
- let provider = parsed.provider;
150
+ let provider = slash.provider;
125
151
  if (!provider) {
126
152
  if (aliasNow) provider = asString(aliasNow.provider).toLowerCase();
127
153
  }
@@ -3,7 +3,7 @@
3
3
  // messages carry monotonic per-session `seq` (reconnect replay anchor, AC6).
4
4
  import fs from "node:fs";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import type { BackendId, PlanSnapshot, SessionStatus, StoredMessage } from "../../../shared/src/index.js";
6
+ import type { BackendId, PlanSnapshot, SessionStatus, StatusNoteItem, StoredMessage } from "../../../shared/src/index.js";
7
7
  import type { BackendHandshake, BackendHealth, BackendKind, BackendRow } from "../acp/registry.js";
8
8
  import { normalizeItems } from "../plan/plan.js";
9
9
  import { DEFAULT_NATIVE_PLAN_SOURCE } from "../acp/backends.js";
@@ -965,7 +965,11 @@ export class Store {
965
965
  * (说明这中间没有别的消息落下),就更新它,而不是再加一行;位置变了(新消息进来了)就另起一行。
966
966
  * 于是「压缩中 → 完成」收敛成一行(保留开始时间),一个回合一次回忆也只留一行。
967
967
  */
968
- appendNote(sessionId: string, note: { category: string; text: string; createdAt?: number }): StoredMessage {
968
+ appendNote(sessionId: string, note: {
969
+ category: string; text: string; createdAt?: number;
970
+ /** memory 行的条目(issue #37)—— 只在 category==="memory" 时有意义。 */
971
+ items?: StatusNoteItem[]; truncated?: boolean;
972
+ }): StoredMessage {
969
973
  const createdAt = note.createdAt ?? Date.now();
970
974
  const edge = this.#db
971
975
  .prepare("select coalesce(max(seq),0) as seq from messages where session_id = ?")
@@ -974,9 +978,18 @@ export class Store {
974
978
  .prepare("select seq, payload, created_at from messages where session_id = ? and kind = 'note' order by seq desc limit 1")
975
979
  .get(sessionId) as { seq: number; payload: string; created_at: number } | undefined;
976
980
  if (last && last.seq === edge.seq) {
977
- const prev = JSON.parse(last.payload) as { category?: string; text?: string; hits?: number };
981
+ const prev = JSON.parse(last.payload) as {
982
+ category?: string; text?: string; hits?: number; items?: StatusNoteItem[]; truncated?: boolean;
983
+ };
978
984
  if (prev.category === note.category) {
979
- const payload = { category: note.category, text: note.text, hits: (prev.hits ?? 1) + 1 };
985
+ // 折叠只发生在「位置上没有别的消息插进来」时(压缩的开始/心跳/完成)。条目取**最新一次**:
986
+ // 折叠出来的是同一行,这行的内容是最后一次召回,而不是第一次的残留。
987
+ const payload: Record<string, unknown> = {
988
+ category: note.category, text: note.text, hits: (prev.hits ?? 1) + 1,
989
+ };
990
+ const items = note.items ?? prev.items;
991
+ if (items?.length) payload.items = items;
992
+ if (note.truncated ?? prev.truncated) payload.truncated = true;
980
993
  // `created_at` stays: it answers "这一轮什么时候开始回忆/压缩的", and the heartbeat is not news.
981
994
  this.#db
982
995
  .prepare("update messages set payload = ? where session_id = ? and seq = ?")
@@ -986,9 +999,10 @@ export class Store {
986
999
  };
987
1000
  }
988
1001
  }
989
- const row = this.appendMessage({
990
- sessionId, kind: "note", payload: { category: note.category, text: note.text, hits: 1 }, createdAt,
991
- });
1002
+ const payload: Record<string, unknown> = { category: note.category, text: note.text, hits: 1 };
1003
+ if (note.items?.length) payload.items = note.items;
1004
+ if (note.truncated) payload.truncated = true;
1005
+ const row = this.appendMessage({ sessionId, kind: "note", payload, createdAt });
992
1006
  // Bounded: a session left open for weeks must not keep every recall it ever did. 80 is well past
993
1007
  // what any transcript page shows, and a note is ~120 bytes.
994
1008
  const over = this.#db
@@ -263,6 +263,21 @@ export interface BackgroundTask {
263
263
  status: "running" | "exited";
264
264
  uptimeSeconds?: number;
265
265
  exitCode?: number | null;
266
+ /** What this row IS (issue #39). `process` = a `terminal(background=true)` child in the process
267
+ * registry; `delegation` = a live `delegate_task(background=true)` subagent, which never appears
268
+ * there. Absent on frames from an older agent, and absent must read as `process`: that is what
269
+ * every field above has always meant. */
270
+ kind?: "process" | "delegation";
271
+ /** Delegations only: the ledger's own phase (`running` / `stalling` / `finalizing`). `status`
272
+ * stays the host's two-state vocabulary, so a `finalizing` child still reads as running. */
273
+ phase?: string;
274
+ /** Delegations only: how many children one row stands for (a batch of N counts N). */
275
+ taskCount?: number;
276
+ /** Does this process OWE the session a wake-up (`notify_on_complete`)? Issue #35: that is the
277
+ * difference between 「agent 已经收工」 and 「agent 还在等这次唤醒才能继续」. Optional — an
278
+ * older agent never sends it, and `undefined` must read as "silent", never as "unknown so
279
+ * show it anyway". */
280
+ notifyOnComplete?: boolean;
266
281
  }
267
282
 
268
283
  export interface WebNotice {
@@ -429,6 +444,18 @@ export type ClientCommand =
429
444
  signature?: string; // "<kind>:<toolCallTitle>" for always-allow memory
430
445
  };
431
446
 
447
+ /** 一条记忆召回条目 —— `note` 行 `payload.items` 的元素(issue #37)。
448
+ *
449
+ * 「recalled 8 memories」只说记忆被用上了,没说用上的是哪几条,于是「召回错了/召回旧了」和
450
+ * 「召回得好」在屏幕上长得一模一样。条目由 provider 侧截断(200 字符/条、6K/轮),server 再按
451
+ * 自己的上限(20 条 / 300 字符)限一次,因为这条数据来自 agent 进程并且要落库。
452
+ */
453
+ export interface StatusNoteItem {
454
+ text: string;
455
+ tags?: string[];
456
+ type?: string;
457
+ }
458
+
432
459
  export interface StoredMessage {
433
460
  seq: number; // monotonic per session — reconnect dedup anchor
434
461
  sessionId: string;