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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/agentus.mjs +3 -1
- package/bin/cli.mjs +40 -5
- package/docs/installation.md +4 -2
- package/docs/installation.zh-CN.md +3 -2
- package/package.json +1 -1
- package/packages/server/mock/agent.mjs +108 -1
- package/packages/server/src/acp/session-manager.ts +77 -5
- package/packages/server/src/cockpit/api.ts +15 -1
- package/packages/server/src/dashscope.ts +29 -18
- package/packages/server/src/index.ts +65 -5
- package/packages/server/src/settings.ts +31 -2
- package/packages/server/src/speech/route.ts +31 -5
- package/packages/server/src/store/store.ts +21 -7
- package/packages/shared/src/index.ts +27 -0
- package/packages/web/dist/assets/{index-DDGyKK0Y.js → index-DRw69enG.js} +57 -56
- package/packages/web/dist/assets/index-hcsgALow.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/skills/agentus-cockpit/SKILL.md +2 -2
- package/packages/web/dist/assets/index-Cr0Hw9eP.css +0 -32
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
60
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
990
|
-
|
|
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;
|