agentus 0.0.0-stage → 0.2.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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,1238 @@
1
+ // SQLite persistence via node:sqlite (Node >=22.5, zero deps).
2
+ // Design: sessions row carries `pid` (orphan-reclaim anchor, AC5);
3
+ // messages carry monotonic per-session `seq` (reconnect replay anchor, AC6).
4
+ import fs from "node:fs";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import type { BackendId, PlanSnapshot, SessionStatus, StoredMessage } from "../../../shared/src/index.js";
7
+ import type { BackendHandshake, BackendHealth, BackendKind, BackendRow } from "../acp/registry.js";
8
+ import { normalizeItems } from "../plan/plan.js";
9
+ import { DEFAULT_NATIVE_PLAN_SOURCE } from "../acp/backends.js";
10
+
11
+ /** Text that is genuinely NEW in `incoming`, given what we already accumulated.
12
+ *
13
+ * Borrowed from hermes-studio (`agent-runner/coding-agent-run-manager.appendedTextDelta`), which
14
+ * hit the same upstream behaviour: the agent re-sends text it has already sent — a full snapshot of
15
+ * the message so far, or a re-emission of an earlier message as one block — so a naive append both
16
+ * duplicates content and splits one message into several rows.
17
+ *
18
+ * · `next` starts with `existing` → a snapshot: the new part is the tail
19
+ * · `next` overlaps `existing`'s end → drop the overlap (≥16 chars, so a coincidence is not one)
20
+ * · otherwise → a genuine delta
21
+ */
22
+ function appendedTextDelta(existing: string, next: string): string {
23
+ if (!existing || !next) return next;
24
+ if (next.startsWith(existing)) return next.slice(existing.length);
25
+ const max = Math.min(existing.length, next.length);
26
+ for (let length = max; length >= 16; length--) {
27
+ if (existing.endsWith(next.slice(0, length))) return next.slice(length);
28
+ }
29
+ return next;
30
+ }
31
+
32
+ /** All whitespace removed: the agent's re-emissions differ from our accumulation by formatting only
33
+ * (a leading blank line, a trailing space), so containment has to be tested on the content. */
34
+ function normalizeText(s: string): string {
35
+ return s.replace(/\s+/g, "");
36
+ }
37
+
38
+ /** Below this, an id-less frame is a fragment, not a re-sent message — see `reemissionTarget`. */
39
+ const ANON_MIN = 24;
40
+ /** …and it has to cover this much of the block it matches, or it is a coincidence, not a re-send. */
41
+ const ANON_COVER = 0.6;
42
+ /** How far back an anonymous re-emission is looked for. */
43
+ const ANON_SCAN_ROWS = 80;
44
+
45
+ /**
46
+ * Is `incoming` the same block as `blockText` rather than new content?
47
+ *
48
+ * Both directions matter: the agent re-sends an earlier message untouched (the incoming is contained
49
+ * in what we hold) and sometimes re-sends it with a little more text (it contains what we hold). A
50
+ * SHORT id-less frame is never folded — "好的" arriving twice is two messages, and it would otherwise
51
+ * match every long block that happens to contain it.
52
+ */
53
+ function reemissionTarget(blockText: string, incoming: string): "same" | "grew" | null {
54
+ const a = normalizeText(blockText);
55
+ const b = normalizeText(incoming);
56
+ if (b.length < ANON_MIN) return null;
57
+ if (a === b) return "same";
58
+ if (a.includes(b) && b.length >= ANON_COVER * a.length) return "same";
59
+ if (b.includes(a) && a.length >= ANON_COVER * b.length) return "grew";
60
+ return null;
61
+ }
62
+ /** A page also stops on bytes, so one 30 KB message cannot overflow a phone-sized fetch. */
63
+ const PAGE_BYTES = 256 * 1024;
64
+
65
+ export interface SessionRow {
66
+ id: string;
67
+ backend: BackendId;
68
+ acpSessionId: string | null;
69
+ cwd: string;
70
+ title: string;
71
+ /** the generated name, kept aside so a rename is reversible and an automatic title can
72
+ * never stomp the operator's own (see the interface comment in @agentus/shared) */
73
+ autoTitle?: string | null;
74
+ status: SessionStatus;
75
+ pid: number | null;
76
+ createdAt: number;
77
+ closedAt: number | null;
78
+ /** JSON round-trip of the ACP mode state + config options, so a resumed
79
+ * session (M4-lite) comes back with the operator's mode/effort intact. */
80
+ modes?: unknown;
81
+ configOptions?: unknown;
82
+ /** context-usage gauge + advertised slash commands, persisted so a reload or a
83
+ * cold-slot resume shows the same picture (AionUi F-DISPLAY-07/10). */
84
+ usage?: unknown;
85
+ commands?: unknown;
86
+ /** Models the agent advertised (JSON) + the operator's context-window override. */
87
+ models?: unknown;
88
+ contextLimit?: number | null;
89
+ /** The directory the cockpit works in for this slot: the file/terminal panel's
90
+ * root, and the cwd a cold slot is resumed with. Deliberately separate from
91
+ * `cwd` — a live ACP child cannot be re-cd'd (its cwd was fixed at newSession),
92
+ * so re-pointing a *running* slot changes the panels and the next resume, not
93
+ * the process. hermes-studio draws the same line (session.workspace).
94
+ * Empty/undefined = "same as cwd". */
95
+ workspace?: string | null;
96
+ /** The agent HOME (HERMES_HOME) this session's agent-side state lives in — resolved at spawn
97
+ * and written ONCE, at creation. It is what makes "can this cold slot come back?" answerable:
98
+ * the agent looks its session up in whatever home it is given NOW, so a row whose home has
99
+ * changed (or a session made under a home that is gone) can never be restored. Measured
100
+ * 2026-10-06: `session/load` answered nothing, `session/prompt` answered `refusal`, and the
101
+ * slot sat there saying "ready" while every message vanished without a trace. */
102
+ home?: string | null;
103
+ }
104
+
105
+ interface RawSessionRow {
106
+ id: string; backend: BackendId; acp_session_id: string | null; cwd: string;
107
+ title: string; auto_title?: string | null; status: SessionStatus; pid: number | null;
108
+ created_at: number; closed_at: number | null;
109
+ modes?: string | null; config_options?: string | null;
110
+ usage?: string | null; commands?: string | null;
111
+ workspace?: string | null;
112
+ home?: string | null;
113
+ models?: string | null; context_limit?: number | null;
114
+ }
115
+
116
+ function parseJson(v: string | null | undefined): unknown {
117
+ if (v == null || v === "") return null;
118
+ try {
119
+ return JSON.parse(v);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /** One row of `messages`, as it comes back from SQLite. */
126
+ interface RawMessageRow {
127
+ seq: number; session_id: string; kind: StoredMessage["kind"];
128
+ payload: string; tool_call_id: string | null; block_key?: string | null; created_at: number;
129
+ }
130
+
131
+ function rowToMessage(r: RawMessageRow): StoredMessage {
132
+ return {
133
+ seq: r.seq,
134
+ sessionId: r.session_id,
135
+ kind: r.kind,
136
+ payload: parseJson(r.payload),
137
+ toolCallId: r.tool_call_id ?? undefined,
138
+ createdAt: r.created_at,
139
+ };
140
+ }
141
+
142
+ /** The streamed text inside an `agent`/`thought` payload (ACP: `content.text`). */
143
+ export function textOf(payload: unknown): string {
144
+ const c = (payload as { content?: unknown } | null)?.content;
145
+ if (c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string") {
146
+ return (c as { text: string }).text;
147
+ }
148
+ // some frames put the text at the top level
149
+ const t = (payload as { text?: unknown } | null)?.text;
150
+ return typeof t === "string" ? t : "";
151
+ }
152
+
153
+ /** Same payload with the streamed text replaced (everything else preserved). */
154
+ export function withText(payload: unknown, text: string): unknown {
155
+ const p = (payload ?? {}) as Record<string, unknown>;
156
+ const c = p.content;
157
+ if (c && typeof c === "object") return { ...p, content: { ...(c as object), text } };
158
+ return { ...p, content: { type: "text", text } };
159
+ }
160
+
161
+ /** The identity of one logical message: `kind:messageId`. Absent when the agent sent no id (its
162
+ * history recaps arrive that way) — those are folded by content instead (see `#rowContaining`). */
163
+ function blockKeyOf(kind: string, payload: unknown): string | null {
164
+ const mid = (payload as { messageId?: unknown } | null)?.messageId;
165
+ return typeof mid === "string" && mid ? `${kind}:${mid}` : null;
166
+ }
167
+
168
+ function rowToSession(r: RawSessionRow): SessionRow {
169
+ return {
170
+ id: r.id, backend: r.backend, acpSessionId: r.acp_session_id, cwd: r.cwd,
171
+ title: r.title, autoTitle: r.auto_title ?? null, status: r.status, pid: r.pid,
172
+ createdAt: r.created_at, closedAt: r.closed_at,
173
+ modes: parseJson(r.modes), configOptions: parseJson(r.config_options) ?? [],
174
+ usage: parseJson(r.usage), commands: parseJson(r.commands) ?? [],
175
+ workspace: r.workspace ?? null,
176
+ home: r.home ?? null,
177
+ models: parseJson(r.models),
178
+ contextLimit: r.context_limit ?? null,
179
+ };
180
+ }
181
+
182
+ interface RawBackendRow {
183
+ id: string; label: string; kind: string; cmd: string; args: string; env: string;
184
+ home: string | null; profile: string | null; cwd: string | null; notes: string;
185
+ native_plan_source: string | null;
186
+ builtin: number; created_at: number; updated_at: number;
187
+ // health snapshot (M6.1) — system-written, see registry.BackendHealth
188
+ last_check_status: string | null; last_check_kind: string | null;
189
+ last_check_error_code: string | null; last_check_error_message: string | null;
190
+ last_check_guidance: string | null; last_check_latency_ms: number | null;
191
+ last_check_at: number | null; last_success_at: number | null; last_failure_at: number | null;
192
+ // cached ACP handshake (M6.1) — what the agent advertised the last time a slot started
193
+ handshake: string | null; handshake_at: number | null;
194
+ }
195
+
196
+ function rowToBackend(r: RawBackendRow): BackendRow {
197
+ return {
198
+ id: r.id,
199
+ label: r.label,
200
+ kind: r.kind as BackendKind,
201
+ // A row predating this column (or one written by an older build) says nothing, so it follows the
202
+ // same default as every other backend: MCP-driven. Deriving it from the kind here would smuggle
203
+ // the old "Hermes is special" rule back in behind the registry — a NULL row would report `acp`
204
+ // while the intent (and every freshly seeded row) says `none`, i.e. the flip would silently not
205
+ // apply to exactly the rows an upgrade leaves behind.
206
+ nativePlanSource: r.native_plan_source === "none" || r.native_plan_source === "acp"
207
+ ? r.native_plan_source
208
+ : DEFAULT_NATIVE_PLAN_SOURCE,
209
+ cmd: r.cmd,
210
+ args: (parseJson(r.args) as string[] | null) ?? [],
211
+ env: (parseJson(r.env) as Record<string, string> | null) ?? {},
212
+ home: r.home ?? null,
213
+ profile: r.profile ?? null,
214
+ cwd: r.cwd ?? null,
215
+ notes: r.notes ?? "",
216
+ builtin: r.builtin === 1,
217
+ health: {
218
+ status: (r.last_check_status as BackendHealth["status"] | null) ?? "unchecked",
219
+ kind: (r.last_check_kind as BackendHealth["kind"]) ?? null,
220
+ errorCode: (r.last_check_error_code as BackendHealth["errorCode"]) ?? null,
221
+ message: r.last_check_error_message ?? null,
222
+ guidance: r.last_check_guidance ?? null,
223
+ latencyMs: r.last_check_latency_ms ?? null,
224
+ at: r.last_check_at ?? null,
225
+ lastSuccessAt: r.last_success_at ?? null,
226
+ lastFailureAt: r.last_failure_at ?? null,
227
+ },
228
+ handshake: (parseJson(r.handshake) as BackendRow["handshake"]) ?? null,
229
+ createdAt: r.created_at,
230
+ updatedAt: r.updated_at,
231
+ };
232
+ }
233
+
234
+ interface RawPlanRow {
235
+ session_id: string;
236
+ revision: number;
237
+ items: string;
238
+ explanation: string | null;
239
+ source: string;
240
+ terminal: string | null;
241
+ updated_at: number;
242
+ }
243
+
244
+ /** What a plan write carries. `explanation: undefined` means "keep the remark that is there"
245
+ * (only an agent writes one, and most plan updates carry none) — see `upsertPlan`. */
246
+ export interface PlanPatch {
247
+ items: unknown;
248
+ explanation?: string | null;
249
+ source: PlanSnapshot["source"];
250
+ terminal?: string | null;
251
+ }
252
+
253
+ function rowToPlan(r: RawPlanRow): PlanSnapshot {
254
+ return {
255
+ sessionId: r.session_id,
256
+ revision: r.revision,
257
+ items: normalizeItems(parseJson(r.items)),
258
+ explanation: r.explanation,
259
+ source: r.source === "acp" || r.source === "mcp" ? r.source : "server",
260
+ terminal: r.terminal,
261
+ updatedAt: r.updated_at,
262
+ };
263
+ }
264
+
265
+ export class Store {
266
+ #db: DatabaseSync;
267
+ /** Kept for the one-time fold's backup file (see #foldStreamedChunks). */
268
+ #path: string;
269
+
270
+ constructor(path: string) {
271
+ this.#path = path;
272
+ this.#db = new DatabaseSync(path);
273
+ this.#db.exec(`
274
+ pragma journal_mode = wal;
275
+ create table if not exists sessions (
276
+ id text primary key, backend text not null, acp_session_id text,
277
+ cwd text not null, title text not null, status text not null,
278
+ pid integer, created_at integer not null, closed_at integer, home text
279
+ );
280
+ create table if not exists messages (
281
+ seq integer not null, session_id text not null, kind text not null,
282
+ payload text not null, tool_call_id text, created_at integer not null,
283
+ primary key (session_id, seq)
284
+ );
285
+ create index if not exists idx_messages_ts on messages (session_id, seq);
286
+ -- the operator's declared window per MODEL (studio keeps one per provider+model; the
287
+ -- model id already carries the provider here). ACP has no setter, so this is a
288
+ -- remembered declaration, not a request to the agent.
289
+ create table if not exists model_context (
290
+ model_id text primary key, context_limit integer not null, updated_at integer not null
291
+ );
292
+ -- Operator settings (voice / theme / call / prefs), one row per section, as JSON. They
293
+ -- used to be a 0600 file beside the DB; anything the operator changes belongs in the
294
+ -- store, not in a browser's localStorage (that copy is a first-frame mirror only).
295
+ create table if not exists settings (
296
+ key text primary key, value text not null, updated_at integer not null
297
+ );
298
+ -- Operator-managed backends (M6): which COMMAND to spawn, which HERMES_HOME it gets and
299
+ -- which hermes profile it runs as (hermes -p PROFILE acp). Before this, "which hermes"
300
+ -- was two env vars fixed at server start (AGENTUS_HERMES_CMD / AGENTUS_HERMES_HOME),
301
+ -- so isolating a slot meant restarting the cockpit. Seeded once from the builtin rows, so
302
+ -- an existing cockpit keeps behaving exactly as before until a row is edited.
303
+ create table if not exists backends (
304
+ id text primary key, label text not null, kind text not null,
305
+ cmd text not null, args text not null default '[]', env text not null default '{}',
306
+ home text, profile text, cwd text, notes text not null default '',
307
+ builtin integer not null default 0,
308
+ created_at integer not null, updated_at integer not null
309
+ );
310
+ -- The session's plan, ONE row per session. Deliberately not derived from the message log:
311
+ -- a plan frame in the transcript records what an agent once SAID, while this row is what the
312
+ -- session HAS — the difference is the restart case, where the agent's own todo state died with
313
+ -- its process and the frames on screen are stale (design-plan-service.md §1).
314
+ create table if not exists plans (
315
+ session_id text primary key, revision integer not null default 0,
316
+ items text not null, explanation text, source text not null,
317
+ terminal text, updated_at integer not null
318
+ );
319
+ `);
320
+ // The DB now holds the speech endpoint's key, so it is the operator's secret material:
321
+ // 0600 like the file it replaced (a default 0644 sqlite file would be a downgrade).
322
+ try {
323
+ fs.chmodSync(path, 0o600);
324
+ } catch { /* someone else's filesystem: nothing to tighten */ }
325
+ // additive migration: mode/config persistence for resume (M4-lite)
326
+ const cols = new Set(
327
+ (this.#db.prepare("pragma table_info(sessions)").all() as { name: string }[]).map((c) => c.name),
328
+ );
329
+ for (const col of ["modes", "config_options", "usage", "commands", "workspace", "models", "auto_title", "home"]) {
330
+ if (!cols.has(col)) this.#db.exec(`alter table sessions add column ${col} text`);
331
+ }
332
+ // `title` is the DISPLAY title (the operator's name once they rename it);
333
+ // `auto_title` keeps the generated one so a rename can be cleared back to it.
334
+ this.#db.exec("update sessions set auto_title = title where auto_title is null or auto_title = ''");
335
+ // integer column, so it gets its own migration (the loop above assumes text)
336
+ if (!cols.has("context_limit")) this.#db.exec("alter table sessions add column context_limit integer");
337
+ // The session's own ACTIVITY clock — when the operator or the agent last really said something
338
+ // in it. A COLUMN, not a derived `max(messages.created_at)` (AionUi's model: `modified_at` on
339
+ // the conversation row, indexed, which is what its sidebar sorts by): every ingestion path
340
+ // writes rows, so a derived max() counts a history REPLAY as activity. Measured 2026-10-08 in
341
+ // the operator's own store — after one restart the rail said 「刚刚」 for eleven sessions at once,
342
+ // ordered by the order their agents happened to be resumed (10:44:52, :54, :58, 10:45:01, …).
343
+ const hadActivityClock = cols.has("last_activity_at");
344
+ if (!hadActivityClock) this.#db.exec("alter table sessions add column last_activity_at integer");
345
+ // messages: the identity of one streamed block, so the chunks of one message accumulate into one
346
+ // row instead of one row per token (see appendTextChunk).
347
+ const mcols = new Set(
348
+ (this.#db.prepare("pragma table_info(messages)").all() as { name: string }[]).map((c) => c.name),
349
+ );
350
+ if (!mcols.has("block_key")) this.#db.exec("alter table messages add column block_key text");
351
+ this.#db.exec("create index if not exists idx_messages_block on messages (session_id, block_key)");
352
+ // additive migration (M6.1): the health snapshot + cached handshake on a backend row. Kept
353
+ // as its own column block so a row insert/update never has to carry them (they are written
354
+ // by recordBackendCheck alone, and by nothing else).
355
+ const beCols = new Set(
356
+ (this.#db.prepare("pragma table_info(backends)").all() as { name: string }[]).map((c) => c.name),
357
+ );
358
+ for (const col of [
359
+ "last_check_status", "last_check_kind", "last_check_error_code", "last_check_error_message",
360
+ "last_check_guidance", "last_check_at", "last_success_at", "last_failure_at",
361
+ "handshake", "handshake_at",
362
+ // P1 (plan service): which channel carries this backend's plan, `acp` | `none`. Nullable on
363
+ // purpose — a row that predates it follows the same default as a seeded one (see rowToBackend).
364
+ "native_plan_source",
365
+ ]) {
366
+ if (!beCols.has(col)) this.#db.exec(`alter table backends add column ${col} text`);
367
+ }
368
+ if (!beCols.has("last_check_latency_ms")) {
369
+ this.#db.exec("alter table backends add column last_check_latency_ms integer");
370
+ }
371
+ this.#foldStreamedChunks();
372
+ // …then the one-time removal of what the REPLAYS already wrote (see #dedupeReplayedRows), and the
373
+ // clock for a store that predates the column — computed after the cleanup, so a session's time is
374
+ // when it was last talked to and not when its agent was last resumed.
375
+ this.#dedupeReplayedRows();
376
+ if (!hadActivityClock) this.#backfillActivity();
377
+ }
378
+
379
+ /**
380
+ * One-time repair of transcripts written by the chunk-per-row era.
381
+ *
382
+ * Until `appendTextChunk` existed, every `agent_message_chunk` (1–3 characters) was its own row: a
383
+ * single reply was 1259 rows, which made "show earlier messages" cut a markdown table in half at an
384
+ * arbitrary byte offset, and made the agent's re-emissions of its own text show up as extra
385
+ * messages. This folds those rows in place — the surviving row keeps its `seq`, so paging anchors
386
+ * stay valid and nothing is renumbered — after copying the DB aside once.
387
+ *
388
+ * Deliberately conservative: a row is only folded away when it is provably the same block (same
389
+ * `messageId`) or a whitespace-insensitive re-emission of a block we already hold. Anything else is
390
+ * left exactly as it is.
391
+ */
392
+ #foldStreamedChunks(): void {
393
+ // v2: the anonymous-re-emission rule got sharper (a short block is no longer a fold candidate,
394
+ // but a ≥24-char one that covers most of a block is) — an already-folded DB is worth re-walking.
395
+ const FOLD_VERSION = 2;
396
+ const v = this.#db.prepare("pragma user_version").get() as { user_version?: number } | undefined;
397
+ if (Number(v?.user_version ?? 0) >= FOLD_VERSION) return;
398
+ if (!this.#backup("fold")) return;
399
+ const sessions = this.#db.prepare("select distinct session_id as id from messages").all() as unknown as { id: string }[];
400
+ let before = 0;
401
+ let after = 0;
402
+ const upd = this.#db.prepare("update messages set payload = ?, block_key = ? where session_id = ? and seq = ?");
403
+ const del = this.#db.prepare("delete from messages where session_id = ? and seq = ?");
404
+ for (const { id } of sessions) {
405
+ const rows = this.#db
406
+ .prepare("select * from messages where session_id = ? order by seq asc")
407
+ .all(id) as unknown as RawMessageRow[];
408
+ before += rows.length;
409
+ const kept: { row: RawMessageRow; text: string | null }[] = [];
410
+ const doomed: number[] = [];
411
+ for (const r of rows) {
412
+ if (r.kind !== "agent" && r.kind !== "thought") {
413
+ kept.push({ row: r, text: null });
414
+ continue;
415
+ }
416
+ const payload = parseJson(r.payload);
417
+ const text = textOf(payload);
418
+ const key = blockKeyOf(r.kind, payload);
419
+ // Walk back to the block this row belongs to: the same messageId, or a block whose text
420
+ // already contains this one (the agent replays its own text without an id).
421
+ let hitIdx = -1;
422
+ for (let i = kept.length - 1; i >= 0; i--) {
423
+ const cand = kept[i];
424
+ if (cand.text === null || cand.row.kind !== r.kind) continue;
425
+ const candText: string = cand.text;
426
+ if (key && blockKeyOf(cand.row.kind, parseJson(cand.row.payload)) === key) { hitIdx = i; break; }
427
+ if (!key && reemissionTarget(candText, text)) { hitIdx = i; break; }
428
+ if (kept.length - i > ANON_SCAN_ROWS) break;
429
+ }
430
+ if (hitIdx >= 0) {
431
+ const target = kept[hitIdx];
432
+ const delta = key ? appendedTextDelta(target.text ?? "", text) : "";
433
+ if (delta) target.text = (target.text ?? "") + delta;
434
+ doomed.push(r.seq);
435
+ continue;
436
+ }
437
+ kept.push({ row: r, text });
438
+ }
439
+ for (const seq of doomed) del.run(id, seq);
440
+ for (const k of kept) {
441
+ if (k.text === null) continue;
442
+ const payload = withText(parseJson(k.row.payload), k.text);
443
+ const key = blockKeyOf(k.row.kind, payload);
444
+ upd.run(JSON.stringify(payload), key, id, k.row.seq);
445
+ }
446
+ after += kept.length;
447
+ }
448
+ this.#db.prepare(`pragma user_version = ${FOLD_VERSION}`).run();
449
+ console.log(`[store] 历史分片已折叠:${before} 行 → ${after} 行`);
450
+ }
451
+
452
+ /**
453
+ * Copy the DB aside ONCE, before a destructive repair pass. False means no backup could be made
454
+ * (a read-only filesystem, a locked file) — the caller must then leave the data alone.
455
+ */
456
+ #backup(tag: string): boolean {
457
+ const bak = `${this.#path}.pre-${tag}.bak`;
458
+ try {
459
+ if (!fs.existsSync(bak)) {
460
+ this.#db.exec(`vacuum into '${bak.replace(/'/g, "''")}'`);
461
+ console.log(`[store] 修复前已备份:${bak}`);
462
+ }
463
+ return true;
464
+ } catch (e) {
465
+ console.log(`[store] 备份失败(${tag}),跳过这次修复以免损坏历史:${String(e)}`);
466
+ return false;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * One-time removal of the rows a HISTORY REPLAY wrote as if they were fresh output.
472
+ *
473
+ * Whenever a slot re-attaches, the agent re-sends the tail of the conversation it holds as ordinary
474
+ * `session/update` notifications. The streaming kinds are folded by id/content, but a `tool_call`
475
+ * used to be APPENDED like new output, so one call became one row per re-attach. Measured
476
+ * 2026-10-08 in the operator's own store: 1224 calls replayed, **7507 extra rows**, the worst
477
+ * sitting in the transcript 17 times — every copy byte-identical, at timestamps that trace each
478
+ * restart (10-06 13:59 → 10-08 10:44). Text blocks the agent re-sent without a `messageId` did the
479
+ * same: 6487 rows repeated an earlier block of the same kind, character for character.
480
+ *
481
+ * Both are provable, so both are removed — with the discipline of the legacy fold: a backup first,
482
+ * a row only deleted when an EARLIER row of the same session holds the same bytes, and a repeat
483
+ * that does NOT match exactly left alone and reported (it may carry an update the live row never
484
+ * got).
485
+ *
486
+ * A legitimate repeat (the agent really says the same thing twice) is separated from a replay by
487
+ * one observable fact: a replay writes its copies in a BURST — several repeats inside the same
488
+ * second — while a live turn writes its new rows spread over the turn. So a text row is only
489
+ * removed when its second looks like that burst.
490
+ */
491
+ #dedupeReplayedRows(): void {
492
+ // v4: the live store ran v3 DURING the release restart — the migration walked the rows, set its
493
+ // version, and only THEN did the resize/resume replays write the 1240 duplicates. A store that
494
+ // already carries v3 therefore still holds them, so the walk is worth repeating; v4 also puts
495
+ // back the activity clock the same replay bumped (see the tail of this method).
496
+ const DEDUPE_VERSION = 4;
497
+ const v = this.#db.prepare("pragma user_version").get() as { user_version?: number } | undefined;
498
+ if (Number(v?.user_version ?? 0) >= DEDUPE_VERSION) return;
499
+ if (!this.#backup("dedupe")) return;
500
+ // The store's own floor for "this is a block, not an utterance" (`reemissionTarget`): below it a
501
+ // repeat is legitimately two messages ("好的" twice) and never a replay of one block.
502
+ const MIN_DUP_CHARS = 24;
503
+ const BURST_REPEATS_PER_SECOND = 3;
504
+ const del = this.#db.prepare("delete from messages where session_id = ? and seq = ?");
505
+ let before = 0;
506
+ let after = 0;
507
+ let toolsDropped = 0;
508
+ let textDropped = 0;
509
+ let kept = 0;
510
+ const sessions = this.#db
511
+ .prepare("select distinct session_id as id from messages")
512
+ .all() as unknown as { id: string }[];
513
+ for (const { id } of sessions) {
514
+ const rows = this.#db
515
+ .prepare("select * from messages where session_id = ? order by seq asc")
516
+ .all(id) as unknown as RawMessageRow[];
517
+ before += rows.length;
518
+ const doomed = new Set<number>();
519
+ // 1. one tool call is ONE row: a later row with the same `tool_call_id` and the same bytes is
520
+ // that call replayed — never a second call.
521
+ const byCall = new Map<string, RawMessageRow>();
522
+ for (const r of rows) {
523
+ if (r.kind !== "tool" || !r.tool_call_id) continue;
524
+ const first = byCall.get(r.tool_call_id);
525
+ if (!first) {
526
+ byCall.set(r.tool_call_id, r);
527
+ continue;
528
+ }
529
+ if (normalizeText(first.payload) === normalizeText(r.payload)) {
530
+ doomed.add(r.seq);
531
+ toolsDropped += 1;
532
+ } else {
533
+ kept += 1;
534
+ }
535
+ }
536
+ // 2. an id-less text block the agent re-sent (its own recap): the copy is not a new message.
537
+ const repeats: RawMessageRow[] = [];
538
+ const seenText = new Set<string>();
539
+ for (const r of rows) {
540
+ if (r.kind !== "agent" && r.kind !== "thought") continue;
541
+ const text = normalizeText(textOf(parseJson(r.payload)));
542
+ if (!text) continue;
543
+ const key = `${r.kind}\u0000${text}`;
544
+ if (seenText.has(key)) repeats.push(r);
545
+ else seenText.add(key);
546
+ }
547
+ const perSecond = new Map<number, number>();
548
+ for (const r of repeats) {
549
+ const second = Math.floor(r.created_at / 1000);
550
+ perSecond.set(second, (perSecond.get(second) ?? 0) + 1);
551
+ }
552
+ for (const r of repeats) {
553
+ const text = normalizeText(textOf(parseJson(r.payload)));
554
+ if (text.length < MIN_DUP_CHARS) {
555
+ kept += 1;
556
+ continue;
557
+ }
558
+ if ((perSecond.get(Math.floor(r.created_at / 1000)) ?? 0) <= BURST_REPEATS_PER_SECOND) {
559
+ kept += 1;
560
+ continue;
561
+ }
562
+ doomed.add(r.seq);
563
+ textDropped += 1;
564
+ }
565
+ for (const seq of doomed) del.run(id, seq);
566
+ after += rows.length - doomed.size;
567
+ }
568
+ // The same replay also bumped the session's ACTIVITY clock — `#touch` fires on any write, so a
569
+ // resumed slot's rail time jumped to the moment its agent was re-attached (measured on live:
570
+ // eleven sessions at once, 10:44:52 → 10:45:20). With the copies now gone, a clock that sits
571
+ // AHEAD of the session's newest surviving row is provably that bump and nothing else, so put it
572
+ // back to the row that is actually there. A session with no messages left keeps what it has.
573
+ const clock = this.#db
574
+ .prepare(
575
+ `update sessions set last_activity_at =
576
+ (select max(m.created_at) from messages m where m.session_id = sessions.id)
577
+ where last_activity_at is not null
578
+ and last_activity_at > (select max(m.created_at) from messages m where m.session_id = sessions.id)`,
579
+ )
580
+ .run();
581
+ this.#db.prepare(`pragma user_version = ${DEDUPE_VERSION}`).run();
582
+ if (before !== after) {
583
+ console.log(
584
+ `[store] 重放行清理:${before} 行 → ${after} 行(工具行 ${toolsDropped}、文本行 ${textDropped};`
585
+ + `另有 ${kept} 行内容并不完全相同,原样保留)`,
586
+ );
587
+ }
588
+ if (clock.changes > 0) {
589
+ console.log(`[store] 活动时钟复位:${clock.changes} 个会话的时间被重放推到了重启那一刻,已按真实最后一行改回`);
590
+ }
591
+ this.#repairReplayClocks();
592
+ }
593
+
594
+ /**
595
+ * The residue of the same replay, for sessions the conservative dedupe had to leave alone.
596
+ *
597
+ * A replayed block that is not byte-identical to what the store already held is KEPT (it may carry
598
+ * an update the stored copy never got) — and with the row kept, `#touch`'s clock stays at the
599
+ * restart too. Measured 2026-10-08 on the live store: eight sessions' rail times all sat inside one
600
+ * 30-second window (the moment their agents were re-attached), which is the signature this reads.
601
+ *
602
+ * The rule is the observable one — real conversations do not end eight at a time: a 90-second
603
+ * window holding four or more sessions' clocks is a mass resume, and a session whose clock falls in
604
+ * such a window goes back to its newest row written BEFORE the window opened. A session in the
605
+ * window with no earlier row is reported, never guessed at. Running it on a store with no such
606
+ * window is a no-op, so it is safe to leave standing.
607
+ */
608
+ #repairReplayClocks(): void {
609
+ const WINDOW_MS = 90_000;
610
+ const MIN_SESSIONS = 4;
611
+ const rows = this.#db
612
+ .prepare("select id, last_activity_at as clock from sessions where last_activity_at is not null order by last_activity_at asc")
613
+ .all() as unknown as { id: string; clock: number }[];
614
+ // The densest windows first: an anchor at each clock, merged into maximal groups.
615
+ const groups: { lo: number; hi: number; ids: Set<string> }[] = [];
616
+ for (const anchor of rows) {
617
+ const inWindow = rows.filter((r) => r.clock >= anchor.clock && r.clock <= anchor.clock + WINDOW_MS);
618
+ if (inWindow.length < MIN_SESSIONS) continue;
619
+ const lo = anchor.clock;
620
+ const hi = anchor.clock + WINDOW_MS;
621
+ const hit = groups.find((g) => lo <= g.hi && hi >= g.lo);
622
+ if (hit) {
623
+ hit.lo = Math.min(hit.lo, lo);
624
+ hit.hi = Math.max(hit.hi, hi);
625
+ inWindow.forEach((r) => hit.ids.add(r.id));
626
+ } else {
627
+ groups.push({ lo, hi, ids: new Set(inWindow.map((r) => r.id)) });
628
+ }
629
+ }
630
+ if (!groups.length) return;
631
+ const earlier = this.#db.prepare(
632
+ "select max(created_at) as m from messages where session_id = ? and created_at < ?",
633
+ );
634
+ const upd = this.#db.prepare("update sessions set last_activity_at = ? where id = ?");
635
+ let moved = 0;
636
+ let stuck = 0;
637
+ for (const g of groups) {
638
+ for (const id of g.ids) {
639
+ const to = (earlier.get(id, g.lo) as { m: number | null }).m;
640
+ if (to === null) {
641
+ stuck += 1;
642
+ continue;
643
+ }
644
+ upd.run(to, id);
645
+ moved += 1;
646
+ }
647
+ }
648
+ if (moved || stuck) {
649
+ console.log(
650
+ `[store] 重放时钟再修:${moved} 个会话的时间被按「重启前最后一行」改回(${groups.length} 段集中重连窗口)`
651
+ + (stuck ? `;${stuck} 个会话在这之前没有行,保持原样` : ""),
652
+ );
653
+ }
654
+ }
655
+
656
+ /** Give every session that predates the activity column its real last-activity time — computed
657
+ * AFTER the replay cleanup, so it is when the conversation was last talked to and not when its
658
+ * agent was last resumed. A session with no messages keeps NULL and falls back to creation. */
659
+ #backfillActivity(): void {
660
+ this.#db.exec(
661
+ `update sessions set last_activity_at =
662
+ (select max(m.created_at) from messages m where m.session_id = sessions.id)
663
+ where last_activity_at is null`,
664
+ );
665
+ }
666
+
667
+ upsertSession(s: SessionRow): void {
668
+ const exist = this.#db.prepare("select id from sessions where id = ?").get(s.id);
669
+ if (exist) {
670
+ // `home` is deliberately NOT in this SET: which data directory a session's agent-side
671
+ // state lives in is decided once, at creation, and every later write happens while the
672
+ // session still lives there. Letting this update rewrite it would silently "re-home" a
673
+ // session whose agent has never seen it — the exact confusion the resume check exists to
674
+ // catch. (Same shape as `created_at`: written on insert only.)
675
+ this.#db
676
+ .prepare(
677
+ `update sessions set backend=?, acp_session_id=?, cwd=?, title=?, status=?, pid=?, closed_at=?, modes=?, config_options=?, usage=?, commands=?, models=?, auto_title=coalesce(auto_title, ?) where id=?`,
678
+ )
679
+ .run(s.backend, s.acpSessionId, s.cwd, s.title, s.status, s.pid, s.closedAt,
680
+ JSON.stringify(s.modes ?? null), JSON.stringify(s.configOptions ?? []),
681
+ JSON.stringify(s.usage ?? null), JSON.stringify(s.commands ?? []),
682
+ JSON.stringify(s.models ?? null), s.title, s.id);
683
+ } else {
684
+ this.#db
685
+ .prepare(
686
+ `insert into sessions (id, backend, acp_session_id, cwd, title, status, pid, created_at, closed_at, modes, config_options, usage, commands, models, auto_title, home)
687
+ values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
688
+ )
689
+ .run(s.id, s.backend, s.acpSessionId, s.cwd, s.title, s.status, s.pid, s.createdAt, s.closedAt,
690
+ JSON.stringify(s.modes ?? null), JSON.stringify(s.configOptions ?? []),
691
+ JSON.stringify(s.usage ?? null), JSON.stringify(s.commands ?? []),
692
+ JSON.stringify(s.models ?? null), s.title, s.home ?? null);
693
+ }
694
+ }
695
+
696
+ /** Record (or adopt) the home a session was created under. Used when a legacy row — created
697
+ * before the column existed — is resumed successfully: the successful load is itself the
698
+ * proof of which home holds its state, so the row stops being ambiguous. */
699
+ setSessionHome(sessionId: string, home: string | null): void {
700
+ this.#db.prepare("update sessions set home = ? where id = ?").run(home, sessionId);
701
+ }
702
+
703
+ listSessions(includeClosed = true): SessionRow[] {
704
+ const where = includeClosed ? "" : "where status != 'closed'";
705
+ const rows = this.#db
706
+ .prepare(`select * from sessions ${where} order by created_at desc`)
707
+ .all() as unknown as RawSessionRow[];
708
+ return rows.map(rowToSession);
709
+ }
710
+
711
+ /** Distinct working directories the operator has used, most recent first. Feeds the
712
+ * "recent" list in the new-slot picker — no new table: every slot row already
713
+ * remembers where it ran. */
714
+ recentCwds(limit = 8): string[] {
715
+ const rows = this.#db
716
+ .prepare(
717
+ `select cwd, max(coalesce(closed_at, created_at)) as at from sessions
718
+ where cwd is not null and cwd <> '' group by cwd order by at desc limit ?`,
719
+ )
720
+ .all(limit) as unknown as { cwd: string }[];
721
+ return rows.map((r) => r.cwd);
722
+ }
723
+
724
+ getSession(id: string): SessionRow | undefined {
725
+ const r = this.#db.prepare("select * from sessions where id = ?").get(id) as
726
+ | RawSessionRow
727
+ | undefined;
728
+ return r ? rowToSession(r) : undefined;
729
+ }
730
+
731
+ /** Purge a cold slot: drop the row and its transcript. Used by the rail's ✕ on
732
+ * cold slots — without it the "on disk · N" list only ever grows. */
733
+ deleteSession(sessionId: string): boolean {
734
+ const row = this.#db.prepare("select id from sessions where id = ?").get(sessionId);
735
+ if (!row) return false;
736
+ this.#db.prepare("delete from messages where session_id = ?").run(sessionId);
737
+ this.#db.prepare("delete from sessions where id = ?").run(sessionId);
738
+ return true;
739
+ }
740
+
741
+ /** When each session last saw REAL activity, as a wall clock so sessions CAN be ordered
742
+ * against each other — `seq` is a per-session counter (primary key is session_id+seq), so
743
+ * comparing it across sessions was meaningless. The stored column wins (see the migration);
744
+ * the rows are only a fallback for a session whose clock was never written. */
745
+ lastActivityAt(): Map<string, number> {
746
+ const rows = this.#db
747
+ .prepare(
748
+ `select s.id as id,
749
+ coalesce(s.last_activity_at,
750
+ (select max(m.created_at) from messages m where m.session_id = s.id),
751
+ s.created_at) as at
752
+ from sessions s`,
753
+ )
754
+ .all() as unknown as { id: string; at: number }[];
755
+ return new Map(rows.map((r) => [r.id, r.at]));
756
+ }
757
+
758
+ /** Advance a session's activity clock. Called for REAL activity only — a prompt the operator sent,
759
+ * output a running agent produced. Deliberately never for a history replay: that is the whole
760
+ * reason the clock lives in its own column instead of being derived from `messages`. */
761
+ touchActivity(sessionId: string, at: number): void {
762
+ this.#db
763
+ .prepare(
764
+ "update sessions set last_activity_at = ? where id = ? and (last_activity_at is null or last_activity_at < ?)",
765
+ )
766
+ .run(at, sessionId, at);
767
+ }
768
+
769
+ /** Highest persisted seq for a session (0 when empty) — the resume anchor. */
770
+ maxSeq(sessionId: string): number {
771
+ const row = this.#db
772
+ .prepare("select max(seq) as m from messages where session_id = ?")
773
+ .get(sessionId) as { m: number | null };
774
+ return row.m ?? 0;
775
+ }
776
+
777
+ nextSeq(sessionId: string): number {
778
+ const row = this.#db
779
+ .prepare("select max(seq) as m from messages where session_id = ?")
780
+ .get(sessionId) as { m: number | null };
781
+ return (row.m ?? 0) + 1;
782
+ }
783
+
784
+ /** The session's plan object, or null when it never made one.
785
+ *
786
+ * This — not the transcript — is what the cockpit's card renders, because the two are not the
787
+ * same thing: a plan frame that survives in the message log is a record of what an agent once
788
+ * said; this row is what the session actually has. The restart case is exactly where they part
789
+ * (design-plan-service §1): the frames died with the agent's process, this did not. */
790
+ getPlan(sessionId: string): PlanSnapshot | null {
791
+ const row = this.#db
792
+ .prepare(
793
+ `select session_id, revision, items, explanation, source, terminal, updated_at
794
+ from plans where session_id = ?`,
795
+ )
796
+ .get(sessionId) as RawPlanRow | undefined;
797
+ return row ? rowToPlan(row) : null;
798
+ }
799
+
800
+ /** Write the plan object; returns the stored snapshot.
801
+ *
802
+ * `revision` moves on EVERY write (a terminal-only stamp included) so a client can treat it as a
803
+ * cursor. `explanation: undefined` keeps the remark already there; `null` clears it. */
804
+ upsertPlan(sessionId: string, patch: PlanPatch): PlanSnapshot {
805
+ const prev = this.getPlan(sessionId);
806
+ const explanation =
807
+ patch.explanation === undefined ? (prev?.explanation ?? null) : patch.explanation;
808
+ this.#db
809
+ .prepare(
810
+ `insert into plans (session_id, revision, items, explanation, source, terminal, updated_at)
811
+ values (?, ?, ?, ?, ?, ?, ?)
812
+ on conflict(session_id) do update set
813
+ revision = excluded.revision, items = excluded.items, explanation = excluded.explanation,
814
+ source = excluded.source, terminal = excluded.terminal, updated_at = excluded.updated_at`,
815
+ )
816
+ .run(
817
+ sessionId,
818
+ (prev?.revision ?? 0) + 1,
819
+ JSON.stringify(normalizeItems(patch.items)),
820
+ explanation,
821
+ patch.source,
822
+ patch.terminal ?? null,
823
+ Date.now(),
824
+ );
825
+ return this.getPlan(sessionId) as PlanSnapshot;
826
+ }
827
+
828
+ appendMessage(m: Omit<StoredMessage, "seq">): StoredMessage {
829
+ const seq = this.nextSeq(m.sessionId);
830
+ this.#db
831
+ .prepare(
832
+ `insert into messages (seq, session_id, kind, payload, tool_call_id, block_key, created_at)
833
+ values (?,?,?,?,?,?,?)`,
834
+ )
835
+ .run(seq, m.sessionId, m.kind, JSON.stringify(m.payload), m.toolCallId ?? null, null, m.createdAt);
836
+ return { ...m, seq };
837
+ }
838
+
839
+ /**
840
+ * The streaming half of the transcript: one logical message, one row.
841
+ *
842
+ * The agent emits `agent_message_chunk` / `agent_thought_chunk` per token, so appending a row per
843
+ * frame is what made a single reply 1259 rows and made "show earlier messages" cut a markdown table
844
+ * in half (the page boundary was a byte offset inside a 3.8-character row). Chunks of the SAME
845
+ * `messageId` therefore accumulate into the row that already holds that message, and a frame that
846
+ * re-sends text we already have adds nothing.
847
+ *
848
+ * Returns the row to hand to the client plus the text that is genuinely new. `delta === ""` means
849
+ * the frame was a pure re-emission (nothing to emit); an existing row with a non-empty delta means
850
+ * "this row grew" — the client appends instead of inserting a second bubble.
851
+ */
852
+ appendTextChunk(m: Omit<StoredMessage, "seq"> & { kind: "agent" | "thought" }): {
853
+ message: StoredMessage;
854
+ delta: string;
855
+ isNew: boolean;
856
+ } {
857
+ const text = textOf(m.payload);
858
+ const key = blockKeyOf(m.kind, m.payload);
859
+ if (!text) {
860
+ const stored = this.appendMessage(m);
861
+ return { message: stored, delta: "", isNew: true };
862
+ }
863
+ // 1. the same logical message: fold into its row, wherever that row is
864
+ if (key) {
865
+ const row = this.#rowByKey(m.sessionId, key);
866
+ if (row) return this.#foldInto(row, text);
867
+ }
868
+ // 2. an anonymous block that re-sends something we already hold. Hermes replays its own text as
869
+ // complete blocks with no messageId (a recap on every re-attach); persisted naively that is
870
+ // the same answer stored — and rendered — six times.
871
+ if (!key) {
872
+ const row = this.#rowContaining(m.sessionId, m.kind, text);
873
+ if (row) return this.#foldInto(row, text); // "grew" appends the tail, "same" is a no-op
874
+ }
875
+ const stored = this.appendMessage(m);
876
+ if (key) this.#db.prepare("update messages set block_key = ? where session_id = ? and seq = ?")
877
+ .run(key, m.sessionId, stored.seq);
878
+ return { message: stored, delta: text, isNew: true };
879
+ }
880
+
881
+ /** Append to the row that already accumulates this message (or report it as a re-emission). */
882
+ #foldInto(row: StoredMessage, incoming: string): { message: StoredMessage; delta: string; isNew: boolean } {
883
+ const existing = textOf(row.payload);
884
+ // the same block with different whitespace (a re-send with an extra blank line) is NOT growth:
885
+ // appendedTextDelta is whitespace-sensitive and would otherwise re-append the whole text
886
+ if (normalizeText(existing) === normalizeText(incoming)) return { message: row, delta: "", isNew: false };
887
+ const delta = appendedTextDelta(existing, incoming);
888
+ if (!delta) return { message: row, delta: "", isNew: false };
889
+ const payload = withText(row.payload, existing + delta);
890
+ this.#db
891
+ .prepare("update messages set payload = ? where session_id = ? and seq = ?")
892
+ .run(JSON.stringify(payload), row.sessionId, row.seq);
893
+ return { message: { ...row, payload }, delta, isNew: false };
894
+ }
895
+
896
+ #rowByKey(sessionId: string, key: string): StoredMessage | undefined {
897
+ const r = this.#db
898
+ .prepare("select * from messages where session_id = ? and block_key = ? order by seq desc limit 1")
899
+ .get(sessionId, key) as unknown as RawMessageRow | undefined;
900
+ return r ? rowToMessage(r) : undefined;
901
+ }
902
+
903
+ /** A recent row of the same kind that this id-less block re-sends. */
904
+ #rowContaining(sessionId: string, kind: string, text: string): StoredMessage | undefined {
905
+ const rows = this.#db
906
+ .prepare("select * from messages where session_id = ? and kind = ? order by seq desc limit ?")
907
+ .all(sessionId, kind, ANON_SCAN_ROWS) as unknown as RawMessageRow[];
908
+ for (const r of rows) {
909
+ if (reemissionTarget(textOf(parseJson(r.payload)), text)) return rowToMessage(r);
910
+ }
911
+ return undefined;
912
+ }
913
+
914
+ /**
915
+ * A tool call is ONE row, wherever the frame came from.
916
+ *
917
+ * The agent re-sends every call it still holds whenever it re-attaches (`session/load` on a
918
+ * resume), and those frames used to be appended like fresh output: measured 2026-10-08 in the
919
+ * operator's own store, 1224 calls had 7507 extra rows — the worst sitting in the transcript 17
920
+ * times, once per restart, each copy byte-identical. A call is identified by `toolCallId`, so a
921
+ * call we already hold is not a new call: the later frame is DROPPED (`isNew: false`).
922
+ *
923
+ * Deliberately NOT merged into the existing row, even though `tool_call_update` does exactly
924
+ * that: a replay carries the call as the agent remembers it, and letting an `in_progress`
925
+ * snapshot overwrite a live `completed` would walk the card backwards. Real updates arrive as
926
+ * `tool_call_update` (see upsertToolMessage); a replay that differs is reported by the one-time
927
+ * cleanup (#dedupeReplayedRows) rather than silently folded in here.
928
+ */
929
+ appendToolCall(m: Omit<StoredMessage, "seq"> & { kind: "tool"; toolCallId: string }): {
930
+ message: StoredMessage;
931
+ isNew: boolean;
932
+ } {
933
+ if (m.toolCallId) {
934
+ const exist = this.#db
935
+ .prepare(
936
+ `select * from messages where session_id = ? and tool_call_id = ? and kind = 'tool'
937
+ order by seq asc limit 1`,
938
+ )
939
+ .get(m.sessionId, m.toolCallId) as unknown as RawMessageRow | undefined;
940
+ if (exist) return { message: rowToMessage(exist), isNew: false };
941
+ }
942
+ return { message: this.appendMessage(m), isNew: true };
943
+ }
944
+
945
+ upsertToolMessage(sessionId: string, toolCallId: string, payload: unknown): StoredMessage | null {
946
+ // tool_call_update must merge into the original tool row (ACP semantics),
947
+ // never append a duplicate bubble.
948
+ const exist = this.#db
949
+ .prepare(
950
+ `select seq, session_id, kind, payload, tool_call_id, created_at from messages
951
+ where session_id = ? and tool_call_id = ? and kind = 'tool' order by seq desc limit 1`,
952
+ )
953
+ .get(sessionId, toolCallId) as
954
+ | { seq: number; session_id: string; kind: string; payload: string; tool_call_id: string; created_at: number }
955
+ | undefined;
956
+ if (!exist) return null;
957
+ let merged: Record<string, unknown> = {};
958
+ try {
959
+ merged = { ...(JSON.parse(exist.payload) as object), ...(payload as object) };
960
+ } catch {
961
+ merged = payload as Record<string, unknown>;
962
+ }
963
+ this.#db
964
+ .prepare("update messages set payload = ? where session_id = ? and seq = ?")
965
+ .run(JSON.stringify(merged), sessionId, exist.seq);
966
+ return {
967
+ seq: exist.seq,
968
+ sessionId: exist.session_id,
969
+ kind: "tool" as StoredMessage["kind"],
970
+ payload: merged,
971
+ toolCallId: exist.tool_call_id,
972
+ createdAt: exist.created_at,
973
+ };
974
+ }
975
+
976
+ /** The NEWEST page, oldest-first: what a fresh view of a long slot should open on.
977
+ * `hasOlder` says whether the transcript continues above it (the "load earlier"
978
+ * affordance). Distinct from messagesAfter, which walks FORWARD from a replay anchor. */
979
+ messagesTail(sessionId: string, limit = 500): { messages: StoredMessage[]; hasOlder: boolean } {
980
+ const page = this.messagesBefore(sessionId, Number.MAX_SAFE_INTEGER, limit);
981
+ return { messages: page.messages, hasOlder: page.hasMore };
982
+ }
983
+
984
+ /** Page BACKWARDS: the newest rows strictly before `beforeSeq`, returned oldest-first so the
985
+ * client can prepend without re-sorting, capped by BOTH a row count and a byte budget (one
986
+ * accumulated message can be tens of KB, so "500 rows" means nothing on its own).
987
+ * `hasMore` tells the UI whether an older page still exists. */
988
+ messagesBefore(sessionId: string, beforeSeq: number, limit = 200): { messages: StoredMessage[]; hasMore: boolean } {
989
+ const rows = this.#db
990
+ .prepare(
991
+ `select * from messages where session_id = ? and seq < ? order by seq desc limit ?`,
992
+ )
993
+ .all(sessionId, beforeSeq, limit + 1) as unknown as RawMessageRow[];
994
+ const hasMore = rows.length > limit;
995
+ const capped = hasMore ? rows.slice(0, limit) : rows;
996
+ // then the byte budget: always keep at least one row, so a single huge message is still readable
997
+ const page: RawMessageRow[] = [];
998
+ let bytes = 0;
999
+ for (const r of capped) {
1000
+ bytes += r.payload.length;
1001
+ page.push(r);
1002
+ if (bytes > PAGE_BYTES) break;
1003
+ }
1004
+ return { hasMore: hasMore || page.length < capped.length, messages: page.reverse().map(rowToMessage) };
1005
+ }
1006
+
1007
+ messagesAfter(sessionId: string, afterSeq: number, limit = 500): StoredMessage[] {
1008
+ const rows = this.#db
1009
+ .prepare(
1010
+ `select * from messages where session_id = ? and seq > ? order by seq asc limit ?`,
1011
+ )
1012
+ .all(sessionId, afterSeq, limit) as unknown as RawMessageRow[];
1013
+ return rows.map(rowToMessage);
1014
+ }
1015
+
1016
+ close(): void {
1017
+ this.#db.close();
1018
+ }
1019
+ /** Re-point a slot's workspace. Not part of upsertSession: a live-status write
1020
+ * must never clobber a choice the operator made in the panel. */
1021
+ setWorkspace(id: string, workspace: string | null): boolean {
1022
+ const info = this.#db
1023
+ .prepare("update sessions set workspace = ? where id = ?")
1024
+ .run(workspace && workspace.length ? workspace : null, id);
1025
+ return Number(info.changes ?? 0) > 0;
1026
+ }
1027
+
1028
+ /** Rename a slot: `title` = the operator's name, `null` = back to the generated one.
1029
+ * Only `title` changes — `auto_title` is what the generated name survives in, so a
1030
+ * rename is always reversible (studio keeps the two apart the same way: a null title
1031
+ * falls back to the first-message preview). Returns false when the id is unknown. */
1032
+ renameSession(id: string, title: string | null): boolean {
1033
+ const row = this.#db.prepare("select title, auto_title from sessions where id = ?").get(id) as
1034
+ | { title: string; auto_title: string | null }
1035
+ | undefined;
1036
+ if (!row) return false;
1037
+ const next = title == null || !title.length ? (row.auto_title ?? row.title) : title;
1038
+ const info = this.#db.prepare("update sessions set title = ? where id = ?").run(next, id);
1039
+ return Number(info.changes ?? 0) > 0;
1040
+ }
1041
+
1042
+ /** Record a GENERATED name.
1043
+ *
1044
+ * Two writes, one rule: `auto_title` always takes it (so the generated name is the one a
1045
+ * cleared rename falls back to), while the DISPLAY title only moves when the operator has
1046
+ * not named this session themselves — i.e. while `title` still equals `auto_title`. That
1047
+ * is what makes an automatic title safe to fire at any time: after a rename it updates
1048
+ * the fallback and leaves the visible row alone. `force` is for the operator asking for a
1049
+ * new name on purpose (the rail's 重新生成), where the new title is the point.
1050
+ * Returns the row it ended up with, so callers can broadcast exactly what changed. */
1051
+ setAutoTitle(id: string, title: string, { force = false }: { force?: boolean } = {}): SessionRow | null {
1052
+ const row = this.#db.prepare("select title, auto_title from sessions where id = ?").get(id) as
1053
+ | { title: string; auto_title: string | null }
1054
+ | undefined;
1055
+ if (!row) return null;
1056
+ const clean = title.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120).trim();
1057
+ if (!clean) return null;
1058
+ const wasAuto = row.auto_title == null || row.auto_title === "" || row.title === row.auto_title;
1059
+ const nextTitle = force || wasAuto ? clean : row.title;
1060
+ this.#db
1061
+ .prepare("update sessions set auto_title = ?, title = ? where id = ?")
1062
+ .run(clean, nextTitle, id);
1063
+ return this.getSession(id) ?? null;
1064
+ }
1065
+
1066
+ /** Window declared for a model (null = nothing remembered). */
1067
+ getModelLimit(modelId: string | null | undefined): number | null {
1068
+ if (!modelId) return null;
1069
+ const row = this.#db
1070
+ .prepare("select context_limit from model_context where model_id = ?")
1071
+ .get(modelId) as { context_limit?: number } | undefined;
1072
+ return row?.context_limit ?? null;
1073
+ }
1074
+
1075
+ setModelLimit(modelId: string, limit: number | null): void {
1076
+ if (!modelId) return;
1077
+ if (limit && limit > 0) {
1078
+ this.#db
1079
+ .prepare(
1080
+ `insert into model_context (model_id, context_limit, updated_at) values (?, ?, ?)
1081
+ on conflict(model_id) do update set context_limit = excluded.context_limit, updated_at = excluded.updated_at`,
1082
+ )
1083
+ .run(modelId, Math.floor(limit), Date.now());
1084
+ } else {
1085
+ this.#db.prepare("delete from model_context where model_id = ?").run(modelId);
1086
+ }
1087
+ }
1088
+
1089
+ /** One settings section (voice / theme / call / prefs). A missing key means "nothing
1090
+ * stored yet", which settings.ts turns into the defaults. */
1091
+ getSetting(key: string): unknown {
1092
+ const row = this.#db.prepare("select value from settings where key = ?").get(key) as { value?: string } | undefined;
1093
+ if (!row?.value) return null;
1094
+ try {
1095
+ return JSON.parse(row.value) as unknown;
1096
+ } catch {
1097
+ return null;
1098
+ }
1099
+ }
1100
+
1101
+ setSetting(key: string, value: unknown): void {
1102
+ this.#db
1103
+ .prepare(
1104
+ `insert into settings (key, value, updated_at) values (?, ?, ?)
1105
+ on conflict(key) do update set value = excluded.value, updated_at = excluded.updated_at`,
1106
+ )
1107
+ .run(key, JSON.stringify(value ?? null), Date.now());
1108
+ }
1109
+
1110
+ /** Every stored section at once (the settings payload is read as one object). */
1111
+ listSettings(): Record<string, unknown> {
1112
+ const rows = this.#db.prepare("select key, value from settings").all() as { key: string; value: string }[];
1113
+ const out: Record<string, unknown> = {};
1114
+ for (const r of rows) {
1115
+ try {
1116
+ out[r.key] = JSON.parse(r.value) as unknown;
1117
+ } catch { /* unreadable row: treated as absent */ }
1118
+ }
1119
+ return out;
1120
+ }
1121
+
1122
+ listModelLimits(): { modelId: string; limit: number }[] {
1123
+ return this.#db
1124
+ .prepare("select model_id as modelId, context_limit as \"limit\" from model_context order by updated_at desc")
1125
+ .all() as { modelId: string; limit: number }[];
1126
+ }
1127
+
1128
+ /** The operator's declared context window for a slot (null clears it). */
1129
+ setContextLimit(id: string, limit: number | null): boolean {
1130
+ const info = this.#db
1131
+ .prepare("update sessions set context_limit = ? where id = ?")
1132
+ .run(limit && limit > 0 ? Math.floor(limit) : null, id);
1133
+ return Number(info.changes ?? 0) > 0;
1134
+ }
1135
+
1136
+ // ---- backends (M6): the operator-managed "which command / which home / which profile" -----
1137
+
1138
+ listBackends(): BackendRow[] {
1139
+ const rows = this.#db
1140
+ .prepare("select * from backends order by builtin desc, id")
1141
+ .all() as unknown as RawBackendRow[];
1142
+ return rows.map(rowToBackend);
1143
+ }
1144
+
1145
+ getBackend(id: string): BackendRow | null {
1146
+ const row = this.#db.prepare("select * from backends where id = ?").get(id) as unknown as
1147
+ | RawBackendRow
1148
+ | undefined;
1149
+ return row ? rowToBackend(row) : null;
1150
+ }
1151
+
1152
+ upsertBackend(row: BackendRow): void {
1153
+ this.#db
1154
+ .prepare(
1155
+ `insert into backends (id, label, kind, cmd, args, env, home, profile, cwd, notes,
1156
+ native_plan_source,
1157
+ builtin, created_at, updated_at,
1158
+ last_check_status, last_check_kind, last_check_error_code,
1159
+ last_check_error_message, last_check_guidance, last_check_latency_ms,
1160
+ last_check_at, last_success_at, last_failure_at, handshake, handshake_at)
1161
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1162
+ on conflict(id) do update set
1163
+ label = excluded.label, kind = excluded.kind, cmd = excluded.cmd, args = excluded.args,
1164
+ env = excluded.env, home = excluded.home, profile = excluded.profile, cwd = excluded.cwd,
1165
+ notes = excluded.notes,
1166
+ native_plan_source = excluded.native_plan_source,
1167
+ updated_at = excluded.updated_at,
1168
+ -- the evidence travels with the row: a spawn-relevant edit replaces it with a cleared
1169
+ -- snapshot (coerceRow decides that), and a row that was never checked writes nulls.
1170
+ last_check_status = excluded.last_check_status, last_check_kind = excluded.last_check_kind,
1171
+ last_check_error_code = excluded.last_check_error_code,
1172
+ last_check_error_message = excluded.last_check_error_message,
1173
+ last_check_guidance = excluded.last_check_guidance,
1174
+ last_check_latency_ms = excluded.last_check_latency_ms,
1175
+ last_check_at = excluded.last_check_at, last_success_at = excluded.last_success_at,
1176
+ last_failure_at = excluded.last_failure_at,
1177
+ handshake = excluded.handshake, handshake_at = excluded.handshake_at`,
1178
+ )
1179
+ .run(
1180
+ row.id, row.label, row.kind, row.cmd, JSON.stringify(row.args), JSON.stringify(row.env ?? {}),
1181
+ row.home, row.profile, row.cwd, row.notes ?? "",
1182
+ row.nativePlanSource ?? null,
1183
+ row.builtin ? 1 : 0, row.createdAt, row.updatedAt,
1184
+ row.health?.status ?? null, row.health?.kind ?? null, row.health?.errorCode ?? null,
1185
+ row.health?.message ?? null, row.health?.guidance ?? null, row.health?.latencyMs ?? null,
1186
+ row.health?.at ?? null, row.health?.lastSuccessAt ?? null, row.health?.lastFailureAt ?? null,
1187
+ row.handshake ? JSON.stringify(row.handshake) : null, row.handshake?.at ?? null,
1188
+ );
1189
+ }
1190
+
1191
+ deleteBackend(id: string): boolean {
1192
+ const info = this.#db.prepare("delete from backends where id = ?").run(id);
1193
+ return Number(info.changes ?? 0) > 0;
1194
+ }
1195
+
1196
+ /**
1197
+ * Write back a health snapshot (and optionally a handshake) for one row. Deliberately a
1198
+ * column-scoped UPDATE rather than upsertBackend: a check must never resurrect a stale copy of
1199
+ * the row (the operator may be editing cmd/home in the UI while the probe runs), and it must
1200
+ * not touch updated_at — "the row changed" and "the row was measured" are different facts.
1201
+ */
1202
+ recordBackendCheck(id: string, health: BackendHealth, handshake?: BackendHandshake | null): void {
1203
+ this.#db
1204
+ .prepare(
1205
+ `update backends set
1206
+ last_check_status = ?, last_check_kind = ?, last_check_error_code = ?,
1207
+ last_check_error_message = ?, last_check_guidance = ?, last_check_latency_ms = ?,
1208
+ last_check_at = ?, last_success_at = ?, last_failure_at = ?,
1209
+ handshake = coalesce(?, handshake), handshake_at = coalesce(?, handshake_at)
1210
+ where id = ?`,
1211
+ )
1212
+ .run(
1213
+ health.status, health.kind, health.errorCode, health.message, health.guidance,
1214
+ health.latencyMs, health.at, health.lastSuccessAt, health.lastFailureAt,
1215
+ handshake ? JSON.stringify(handshake) : null,
1216
+ handshake ? handshake.at : null,
1217
+ id,
1218
+ );
1219
+ }
1220
+
1221
+ /** Sessions still OPEN on this backend — a closed (archived) session keeps its backend id
1222
+ * for display, so only open ones make a row undeletable. */
1223
+ countOpenSessionsForBackend(id: string): number {
1224
+ const row = this.#db
1225
+ .prepare("select count(*) as n from sessions where backend = ? and status != 'closed'")
1226
+ .get(id) as { n: number } | undefined;
1227
+ return Number(row?.n ?? 0);
1228
+ }
1229
+
1230
+ /** First boot on an empty registry: copy the builtin rows in (0 rows = already seeded). */
1231
+ seedBackendsIfEmpty(rows: BackendRow[]): number {
1232
+ const row = this.#db.prepare("select count(*) as n from backends").get() as { n: number } | undefined;
1233
+ if (Number(row?.n ?? 0) > 0) return 0;
1234
+ for (const r of rows) this.upsertBackend(r);
1235
+ return rows.length;
1236
+ }
1237
+
1238
+ }