agentus 0.12.2 → 0.13.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.
@@ -176,7 +176,9 @@ export const CALL_DEFAULT: CallSettings = {
176
176
  and a long sentence with a breath in the middle got cut. Both ends of the range still let
177
177
  the room decide — this is only where a fresh install starts. */
178
178
  interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1500, minChars: 3,
179
- wakePhrase: "",
179
+ /* Tus mode forces keyword interruption, so the shipped phrase has to exist: an empty starter
180
+ made the mode inert until somebody typed one. Matching strips spaces/punctuation/case. */
181
+ wakePhrase: "hi, Tus",
180
182
  };
181
183
 
182
184
  export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button", "keyword"];
@@ -393,9 +395,19 @@ function callAtShippedDefaults(r: Record<string, unknown>): boolean {
393
395
  const modeShipped = r.interruptMode === undefined || r.interruptMode === CALL_DEFAULT.interruptMode;
394
396
  // …and a stored wake phrase is a deliberate choice too: without this, setting one and switching
395
397
  // back to 语音打断 would make the whole section look untouched and the phrase would be dropped.
396
- const wakeShipped = r.wakePhrase === undefined || r.wakePhrase === CALL_DEFAULT.wakePhrase;
398
+ // The empty string is ALSO shipped: it was the default until 2026-10-24, and a row that still
399
+ // carries it together with the old numbers is a row nobody configured. (A row that was cleared
400
+ // by hand after the new default is indistinguishable — it gets the default back once.)
401
+ const wakeShipped = r.wakePhrase === undefined || r.wakePhrase === CALL_DEFAULT.wakePhrase
402
+ || r.wakePhrase === "";
403
+ // Two generations of numbers count as untouched: the pre-rename row (bargeLevel 0.2 / 1200 / 1)
404
+ // and the row that already holds what ships today (60 / 300 / 1500 / 3). Without the second one
405
+ // a new default can never reach an existing install — its row is already at the current numbers,
406
+ // so it fails the fossil test and keeps the old value (that is how 「hi, Tus」 stayed inert).
407
+ const silenceShipped = Number(r.silenceMs) === 1200 || Number(r.silenceMs) === CALL_DEFAULT.silenceMs;
408
+ const minCharsShipped = Number(r.minChars) === 1 || Number(r.minChars) === CALL_DEFAULT.minChars;
397
409
  return sens && modeShipped && wakeShipped
398
- && Number(r.bargeMs) === 300 && Number(r.silenceMs) === 1200 && Number(r.minChars) === 1;
410
+ && Number(r.bargeMs) === 300 && silenceShipped && minCharsShipped;
399
411
  }
400
412
 
401
413
  /** What each knob may be. The ranges are the panel's slider ends; the server is the one
@@ -2,6 +2,7 @@
2
2
  // Design: sessions row carries `pid` (orphan-reclaim anchor, AC5);
3
3
  // messages carry monotonic per-session `seq` (reconnect replay anchor, AC6).
4
4
  import fs from "node:fs";
5
+ import path from "node:path";
5
6
  import { DatabaseSync } from "node:sqlite";
6
7
  import type { BackendId, PlanSnapshot, SessionStatus, StatusNoteItem, StoredMessage } from "../../../shared/src/index.js";
7
8
  import type { BackendHandshake, BackendHealth, BackendKind, BackendRow } from "../acp/registry.js";
@@ -42,6 +43,70 @@ const ANON_COVER = 0.6;
42
43
  /** How far back an anonymous re-emission is looked for. */
43
44
  const ANON_SCAN_ROWS = 80;
44
45
 
46
+ /**
47
+ * Is this row a FRAGMENT of a message rather than a message?
48
+ *
49
+ * The store has to tell "one more frame of the reply being built" from "the next message". With no
50
+ * messageId (qoder) and no turn id (rows written before it existed) the only honest signal left is
51
+ * shape: a streamed frame is a few characters and never contains a line break. Two short messages in
52
+ * a row are still two messages, because a tool call or the operator's turn sits between them — and
53
+ * this is only ever used to decide whether to glue a row onto the row directly above it.
54
+ */
55
+ /** Print the fold state once per store FILE per process (tests open several). */
56
+ const foldStateLogged = new Set<string>();
57
+
58
+ function isFragmentText(text: string): boolean {
59
+ return text.length > 0 && text.length <= 24 && !text.includes("\n");
60
+ }
61
+
62
+ /**
63
+ * How far apart two id-less fragments can be written and still belong to one streamed message.
64
+ *
65
+ * Frames of one reply land milliseconds apart; a conversation's two short messages are minutes or
66
+ * hours apart. This is the second half of the pair test — see `sameStream`.
67
+ */
68
+ const FRAGMENT_WINDOW_MS = 2_000;
69
+
70
+ /** Where "a full stop" stops being a frame boundary and starts being the end of a message. */
71
+ const FRAGMENT_BOUNDARY_MS = 750;
72
+
73
+ /** Text that closes a sentence — a plausible end of a message, never the middle of one. */
74
+ const SENTENCE_FINAL = /[。..!?!?…;;::]\s*$/;
75
+ /** A frame that is nothing but punctuation: a stream really does end "…works" + "." */
76
+ const PUNCT_ONLY = /^[\s\p{P}\p{S}]+$/u;
77
+
78
+ /**
79
+ * Is this row provably the next frame of the SAME streamed message as the row above it?
80
+ *
81
+ * Adjacency in the table is NOT adjacency in the stream: the replay dedupe (`#dedupeReplayedRows`)
82
+ * and the live tool-call upsert both remove rows that used to sit between two messages, so on every
83
+ * real store two unrelated short messages can end up neighbours. Measured on the operator's own DB:
84
+ * 24 rows ended up holding two messages ("Operation interrupted." ×2, hours apart) because of it.
85
+ *
86
+ * So a pair needs a second witness — the turn both rows belong to, or, for rows written before the
87
+ * turn id existed, a tight time window plus the fact that no chunk boundary lands after a full stop.
88
+ */
89
+ function sameStream(prev: RawMessageRow, prevAt: number, cur: RawMessageRow, prevFrame: string, text: string): boolean {
90
+ // The clock is measured from the LAST FRAME, not from the row's first one: `prev` is the surviving
91
+ // row and its `created_at` never moves, so using it means "how long has this message been growing"
92
+ // — which cuts a long stream at its first full stop after 750 ms and unconditionally after 2 s.
93
+ const gap = Math.abs(cur.created_at - prevAt);
94
+ // The clock is the one witness that cannot lie. Frames of one reply are milliseconds apart; two
95
+ // messages a minute apart are two messages — whatever their turn ids say, because a turn holds
96
+ // MANY messages and the rows between them are exactly what the replay cleanup deletes.
97
+ if (gap > FRAGMENT_WINDOW_MS) return false;
98
+ const a = prev.turn_id;
99
+ const b = cur.turn_id;
100
+ // A matching turn id is a second witness, never a substitute for the clock: `turnSeq` restarts at 0
101
+ // after a server restart, so turn 1 of this run and turn 1 of the last run compare equal.
102
+ if (a !== null && a !== undefined && b !== null && b !== undefined && a !== b) return false;
103
+ // Judged on the previous FRAME, never on the accumulated text: a reply passes many full stops, and
104
+ // a full stop deep inside the accumulation says nothing about where this frame begins. A sentence
105
+ // end is evidence of a boundary only when the write also took long enough to be a real pause.
106
+ const looksLikeBoundary = SENTENCE_FINAL.test(prevFrame) && !PUNCT_ONLY.test(text);
107
+ return !(looksLikeBoundary && gap > FRAGMENT_BOUNDARY_MS);
108
+ }
109
+
45
110
  /**
46
111
  * Is `incoming` the same block as `blockText` rather than new content?
47
112
  *
@@ -145,6 +210,7 @@ function parseJson(v: string | null | undefined): unknown {
145
210
  interface RawMessageRow {
146
211
  seq: number; session_id: string; kind: StoredMessage["kind"];
147
212
  payload: string; tool_call_id: string | null; block_key?: string | null; created_at: number;
213
+ turn_id?: number | null;
148
214
  }
149
215
 
150
216
  function rowToMessage(r: RawMessageRow): StoredMessage {
@@ -387,6 +453,14 @@ export class Store {
387
453
  thought_touched integer not null default 0, -- operator changed depth during the call
388
454
  updated_at integer not null
389
455
  );
456
+ -- Which one-time repairs have run, and at what version. The user_version pragma is a single
457
+ -- number, so two passes that both write it overwrite each other's answer: the store ended up
458
+ -- at 4 with the fold recorded as done while 311,638 unfolded rows were still in it.
459
+ create table if not exists repairs (
460
+ name text primary key,
461
+ version integer not null,
462
+ at integer not null
463
+ );
390
464
  `);
391
465
  // The DB now holds the speech endpoint's key, so it is the operator's secret material:
392
466
  // 0600 like the file it replaced (a default 0644 sqlite file would be a downgrade).
@@ -450,6 +524,12 @@ export class Store {
450
524
  );
451
525
  if (!mcols.has("block_key")) this.#db.exec("alter table messages add column block_key text");
452
526
  this.#db.exec("create index if not exists idx_messages_block on messages (session_id, block_key)");
527
+ // Which turn a row belongs to (issue #56). Qoder's live stream carries no `messageId`, so the
528
+ // only identity an id-less chunk has is "the message being built in THIS turn" — without it a
529
+ // reply is one row per token (measured: 311,638 thought rows in one session, 186 MB store), and
530
+ // folding on "same kind as the row above" alone would merge two genuinely separate replies.
531
+ if (!mcols.has("turn_id")) this.#db.exec("alter table messages add column turn_id integer");
532
+ this.#db.exec("create index if not exists idx_messages_turn on messages (session_id, turn_id)");
453
533
  // plans: the one-line TITLE a completion notification reads (issue #11). Additive, and null for
454
534
  // every existing plan — a card that has never been given a title falls back to its remark, and
455
535
  // then to the last step it closed, so an old store keeps behaving exactly as before.
@@ -479,6 +559,7 @@ export class Store {
479
559
  if (!beCols.has("last_check_latency_ms")) {
480
560
  this.#db.exec("alter table backends add column last_check_latency_ms integer");
481
561
  }
562
+ this.#migrateLegacyRepairVersions();
482
563
  this.#foldStreamedChunks();
483
564
  // …then the one-time removal of what the REPLAYS already wrote (see #dedupeReplayedRows), and the
484
565
  // clock for a store that predates the column — computed after the cleanup, so a session's time is
@@ -487,6 +568,57 @@ export class Store {
487
568
  if (!hadActivityClock) this.#backfillActivity();
488
569
  }
489
570
 
571
+ /**
572
+ * Has this one-time repair already run, and at which version?
573
+ *
574
+ * Repairs used to share `pragma user_version`, which is ONE number for the whole file: the fold
575
+ * set it to 2, the replay cleanup then set it to 4, and the next fold read 4 — "already done" —
576
+ * while the 311,638 unfolded rows it was written for were still in the table. Each pass keeps its
577
+ * own row here now. `legacy` is what a store that predates the table reports (it only ever had
578
+ * user_version to go by), so upgrading in place does not re-walk an already-folded file from 0.
579
+ */
580
+ #repairVersion(name: string, legacy: number): number {
581
+ const row = this.#db.prepare("select version from repairs where name = ?").get(name) as
582
+ | { version?: number } | undefined;
583
+ if (row && typeof row.version === "number") return row.version;
584
+ // Past the one-time migration below, a missing row really does mean "this pass never ran here".
585
+ return Math.min(this.#legacyUv(), legacy);
586
+ }
587
+
588
+ #legacyUv(): number {
589
+ const uv = this.#db.prepare("pragma user_version").get() as { user_version?: number } | undefined;
590
+ return Number(uv?.user_version ?? 0);
591
+ }
592
+
593
+ /**
594
+ * `pragma user_version` was the shared counter of the pre-`repairs` era, and one pass's number
595
+ * could satisfy another's — a store whose counter read 4 "had run" the cleanup even when only the
596
+ * fold had ever recorded anything. Read it as legacy exactly once, write what it implies into
597
+ * per-pass rows, and leave a marker so the reading never happens again. Without the marker, a
598
+ * destructive pass runs on a store whose record it cannot interpret (measured on the operator's
599
+ * file: 767 rows and 15 session clocks moved on a boot that had nothing to do).
600
+ */
601
+ #migrateLegacyRepairVersions(): void {
602
+ const marker = this.#db.prepare("select version from repairs where name = ?").get("per-pass-rows");
603
+ if (marker) return;
604
+ const any = this.#db.prepare("select count(*) as c from repairs").get() as { c?: number } | undefined;
605
+ if (Number(any?.c ?? 0) === 0 && this.#legacyUv() === 0) return; // brand-new store: nothing to seed
606
+ const uv = this.#legacyUv();
607
+ const ins = this.#db.prepare("insert into repairs (name, version, at) values (?,?,?) on conflict(name) do nothing");
608
+ ins.run("fold", Math.min(uv, 2), Date.now());
609
+ ins.run("dedupe", uv >= 4 ? 4 : 0, Date.now());
610
+ ins.run("per-pass-rows", 1, Date.now());
611
+ }
612
+
613
+ #setRepairVersion(name: string, version: number): void {
614
+ this.#db.prepare("insert into repairs (name, version, at) values (?,?,?) on conflict(name) do update set version = excluded.version, at = excluded.at")
615
+ .run(name, version, Date.now());
616
+ // …and keep the old reading monotonic for a build that still asks user_version.
617
+ const uv = this.#db.prepare("pragma user_version").get() as { user_version?: number } | undefined;
618
+ const cur = Number(uv?.user_version ?? 0);
619
+ if (version > cur) this.#db.prepare(`pragma user_version = ${version}`).run();
620
+ }
621
+
490
622
  /**
491
623
  * One-time repair of transcripts written by the chunk-per-row era.
492
624
  *
@@ -503,13 +635,40 @@ export class Store {
503
635
  #foldStreamedChunks(): void {
504
636
  // v2: the anonymous-re-emission rule got sharper (a short block is no longer a fold candidate,
505
637
  // but a ≥24-char one that covers most of a block is) — an already-folded DB is worth re-walking.
506
- const FOLD_VERSION = 2;
507
- const v = this.#db.prepare("pragma user_version").get() as { user_version?: number } | undefined;
508
- if (Number(v?.user_version ?? 0) >= FOLD_VERSION) return;
509
- if (!this.#backup("fold")) return;
510
- const sessions = this.#db.prepare("select distinct session_id as id from messages").all() as unknown as { id: string }[];
638
+ // v3: id-less FRAGMENTS are glued onto the row directly above (the qoder word-per-line era).
639
+ // v4: that rule was too eager (it merged messages that only LOOKED adjacent, once the replay
640
+ // cleanup had removed the rows between them) and too timid (it stopped folding after 24
641
+ // accumulated characters). A re-walk cannot un-merge what v3 glued — that is
642
+ // `scripts/repair-glued-rows.py`'s job — but it does re-join rows v3 split and stops new damage.
643
+ const FOLD_VERSION = 4;
644
+ // A store that predates the repairs table recorded its fold in user_version (2 = the rules
645
+ // shipped with it). Anything at 0/1 never folded at all.
646
+ // Two different questions: whether the pass must run again (the version gate below) and whether
647
+ // this file was EVER folded — the un-windowed replay rule is only safe on a store that never was,
648
+ // and it has no time window, so a genuine repeat reads as a copy and the second message is
649
+ // deleted. `user_version >= 2` is the legacy record of a fold that never wrote a `repairs` row
650
+ // (a boot whose backup failed returns before recording anything).
651
+ const seenFold = this.#repairVersion("fold", 2) >= 2 || this.#legacyUv() >= 2;
652
+ if (this.#repairVersion("fold", 2) >= FOLD_VERSION) {
653
+ // v3 shipped a rule that could glue two DISTINCT messages when the replay cleanup had removed
654
+ // the rows between them; a store folded by it keeps those rows — no re-run can un-merge them
655
+ // (the second half's row is gone). Say the version once per process so "clean" and "folded
656
+ // before the fix" are not indistinguishable in the log; the split lives in
657
+ // tasks/20261001-agentus/scripts/repair-glued-rows.py.
658
+ if (!foldStateLogged.has(this.#path)) {
659
+ foldStateLogged.add(this.#path);
660
+ console.log(`[store] 分片折叠:v${FOLD_VERSION}(已是当前版本,本次不改动历史行)`);
661
+ }
662
+ return;
663
+ }
664
+ if (!this.#backup("fold", FOLD_VERSION)) return;
665
+ // One transaction for the whole walk: this is a per-row update pass over every message in the
666
+ // file (430k rows on the operator's store) and autocommit is what made it 46 seconds of boot.
667
+ this.#db.exec("begin");
511
668
  let before = 0;
512
669
  let after = 0;
670
+ try {
671
+ const sessions = this.#db.prepare("select distinct session_id as id from messages").all() as unknown as { id: string }[];
513
672
  const upd = this.#db.prepare("update messages set payload = ?, block_key = ? where session_id = ? and seq = ?");
514
673
  const del = this.#db.prepare("delete from messages where session_id = ? and seq = ?");
515
674
  for (const { id } of sessions) {
@@ -517,16 +676,35 @@ export class Store {
517
676
  .prepare("select * from messages where session_id = ? order by seq asc")
518
677
  .all(id) as unknown as RawMessageRow[];
519
678
  before += rows.length;
520
- const kept: { row: RawMessageRow; text: string | null }[] = [];
679
+ const kept: { row: RawMessageRow; text: string | null; frame: string; frameAt: number }[] = [];
521
680
  const doomed: number[] = [];
522
681
  for (const r of rows) {
523
682
  if (r.kind !== "agent" && r.kind !== "thought") {
524
- kept.push({ row: r, text: null });
683
+ kept.push({ row: r, text: null, frame: "", frameAt: r.created_at });
525
684
  continue;
526
685
  }
527
686
  const payload = parseJson(r.payload);
528
687
  const text = textOf(payload);
529
688
  const key = blockKeyOf(r.kind, payload);
689
+ // A word-per-line stream: rows written before the turn id existed (qoder sends no messageId,
690
+ // so the era's rows are "There" / " are" / ")" — 311,638 of them in one session). Both sides
691
+ // must be fragments and the target must be the row immediately above: a real message that
692
+ // happens to be short twice in a row is separated by the tool/operator row between them.
693
+ const prev = kept[kept.length - 1];
694
+ // "Is this a fragment" is a property of ONE FRAME, so it is judged on the last frame that went
695
+ // into the row above, not on the row's accumulated text: otherwise folding stops as soon as
696
+ // the message passes 24 characters, which is every message worth folding (measured on the
697
+ // operator's store: 311,638 one-word thought frames only reached ~6 per row for this reason).
698
+ if (!key && prev && prev.row.kind === r.kind && prev.text !== null
699
+ && isFragmentText(prev.frame) && isFragmentText(text)
700
+ && blockKeyOf(prev.row.kind, parseJson(prev.row.payload)) === null
701
+ && sameStream(prev.row, prev.frameAt, r, prev.frame, text)) {
702
+ prev.text += text;
703
+ prev.frame = text;
704
+ prev.frameAt = r.created_at;
705
+ doomed.push(r.seq);
706
+ continue;
707
+ }
530
708
  // Walk back to the block this row belongs to: the same messageId, or a block whose text
531
709
  // already contains this one (the agent replays its own text without an id).
532
710
  let hitIdx = -1;
@@ -535,17 +713,27 @@ export class Store {
535
713
  if (cand.text === null || cand.row.kind !== r.kind) continue;
536
714
  const candText: string = cand.text;
537
715
  if (key && blockKeyOf(cand.row.kind, parseJson(cand.row.payload)) === key) { hitIdx = i; break; }
538
- if (!key && reemissionTarget(candText, text)) { hitIdx = i; break; }
716
+ // Replays are only judged here for a store that has never been folded. On a store that has,
717
+ // the evidence-based pass (#dedupeReplayedRows) owns them — this walk has no time window,
718
+ // so a genuine repeat would read as a copy.
719
+ if (!key && !seenFold && reemissionTarget(candText, text)) { hitIdx = i; break; }
539
720
  if (kept.length - i > ANON_SCAN_ROWS) break;
540
721
  }
541
722
  if (hitIdx >= 0) {
542
723
  const target = kept[hitIdx];
543
724
  const delta = key ? appendedTextDelta(target.text ?? "", text) : "";
725
+ // Same id, same text: that is a REPLAY, and judging replays is #dedupeReplayedRows' job
726
+ // (it has the time window and the evidence). Deleting here would also eat a genuine short
727
+ // repeat — "好的" sent twice is a conversation.
728
+ if (key && !delta) {
729
+ kept.push({ row: r, text, frame: text, frameAt: r.created_at });
730
+ continue;
731
+ }
544
732
  if (delta) target.text = (target.text ?? "") + delta;
545
733
  doomed.push(r.seq);
546
734
  continue;
547
735
  }
548
- kept.push({ row: r, text });
736
+ kept.push({ row: r, text, frame: text, frameAt: r.created_at });
549
737
  }
550
738
  for (const seq of doomed) del.run(id, seq);
551
739
  for (const k of kept) {
@@ -556,7 +744,12 @@ export class Store {
556
744
  }
557
745
  after += kept.length;
558
746
  }
559
- this.#db.prepare(`pragma user_version = ${FOLD_VERSION}`).run();
747
+ this.#setRepairVersion("fold", FOLD_VERSION);
748
+ this.#db.exec("commit");
749
+ } catch (e) {
750
+ this.#db.exec("rollback");
751
+ throw e;
752
+ }
560
753
  console.log(`[store] 历史分片已折叠:${before} 行 → ${after} 行`);
561
754
  }
562
755
 
@@ -564,12 +757,38 @@ export class Store {
564
757
  * Copy the DB aside ONCE, before a destructive repair pass. False means no backup could be made
565
758
  * (a read-only filesystem, a locked file) — the caller must then leave the data alone.
566
759
  */
567
- #backup(tag: string): boolean {
568
- const bak = `${this.#path}.pre-${tag}.bak`;
760
+ #backup(tag: string, version: number): boolean {
761
+ // The name carries the pass version AND the moment: a per-tag name (the old behaviour) meant the
762
+ // second-ever destructive pass silently reused the first one's copy. Measured on the operator's
763
+ // machine: the fold that rewrote 430,318 rows "backed up" to a file from 19 days earlier that did
764
+ // not even contain the session being folded, so a bad rule had no safety net at all.
765
+ const bak = `${this.#path}.pre-${tag}-v${version}-${new Date().toISOString().replace(/[:.]/g, "-")}.bak`;
569
766
  try {
570
- if (!fs.existsSync(bak)) {
571
- this.#db.exec(`vacuum into '${bak.replace(/'/g, "''")}'`);
572
- console.log(`[store] 修复前已备份:${bak}`);
767
+ this.#db.exec(`vacuum into '${bak.replace(/'/g, "''")}'`);
768
+ console.log(`[store] 修复前已备份:${bak}`);
769
+ // Keep the newest copy of every pass version (the run before a rule change is the one that may
770
+ // hold rows the newer rule no longer produces) plus the newest three overall; a 400k-row store
771
+ // copies to hundreds of MB, so this must not grow with every version bump.
772
+ try {
773
+ const dir = path.dirname(this.#path);
774
+ const base = path.basename(this.#path);
775
+ const mine = fs
776
+ .readdirSync(dir)
777
+ // per tag AND per pass version: bumping the version must not evict the backup of the run
778
+ // that came before it (the one that may hold the rows the newer rule no longer produces)
779
+ .filter((f) => f.startsWith(`${base}.pre-${tag}-v`))
780
+ .map((f) => ({ f, at: fs.statSync(path.join(dir, f)).mtimeMs }))
781
+ .sort((a, b) => b.at - a.at);
782
+ const keep = new Set(mine.slice(0, 3).map((x) => x.f));
783
+ const perVersion = new Set<string>();
784
+ for (const x of mine) {
785
+ const m = /-v(\d+)-/.exec(x.f);
786
+ const v = m?.[1] ?? "";
787
+ if (!perVersion.has(v)) { perVersion.add(v); keep.add(x.f); }
788
+ }
789
+ for (const old of mine) if (!keep.has(old.f)) fs.unlinkSync(path.join(dir, old.f));
790
+ } catch {
791
+ /* pruning is a courtesy — never fail the repair over it */
573
792
  }
574
793
  return true;
575
794
  } catch (e) {
@@ -605,9 +824,10 @@ export class Store {
605
824
  // already carries v3 therefore still holds them, so the walk is worth repeating; v4 also puts
606
825
  // back the activity clock the same replay bumped (see the tail of this method).
607
826
  const DEDUPE_VERSION = 4;
608
- const v = this.#db.prepare("pragma user_version").get() as { user_version?: number } | undefined;
609
- if (Number(v?.user_version ?? 0) >= DEDUPE_VERSION) return;
610
- if (!this.#backup("dedupe")) return;
827
+ // Legacy: this pass wrote its own number into user_version (3, then 4) before the repairs table
828
+ // existed, so a store carrying 4 has already run it.
829
+ if (this.#repairVersion("dedupe", 4) >= DEDUPE_VERSION) return;
830
+ if (!this.#backup("dedupe", DEDUPE_VERSION)) return;
611
831
  // The store's own floor for "this is a block, not an utterance" (`reemissionTarget`): below it a
612
832
  // repeat is legitimately two messages ("好的" twice) and never a replay of one block.
613
833
  const MIN_DUP_CHARS = 24;
@@ -618,6 +838,7 @@ export class Store {
618
838
  let toolsDropped = 0;
619
839
  let textDropped = 0;
620
840
  let kept = 0;
841
+ const doomedSessions = new Set<string>();
621
842
  const sessions = this.#db
622
843
  .prepare("select distinct session_id as id from messages")
623
844
  .all() as unknown as { id: string }[];
@@ -674,6 +895,7 @@ export class Store {
674
895
  textDropped += 1;
675
896
  }
676
897
  for (const seq of doomed) del.run(id, seq);
898
+ if (doomed.size > 0) doomedSessions.add(id);
677
899
  after += rows.length - doomed.size;
678
900
  }
679
901
  // The same replay also bumped the session's ACTIVITY clock — `#touch` fires on any write, so a
@@ -681,15 +903,23 @@ export class Store {
681
903
  // eleven sessions at once, 10:44:52 → 10:45:20). With the copies now gone, a clock that sits
682
904
  // AHEAD of the session's newest surviving row is provably that bump and nothing else, so put it
683
905
  // back to the row that is actually there. A session with no messages left keeps what it has.
684
- const clock = this.#db
685
- .prepare(
686
- `update sessions set last_activity_at =
687
- (select max(m.created_at) from messages m where m.session_id = sessions.id)
688
- where last_activity_at is not null
689
- and last_activity_at > (select max(m.created_at) from messages m where m.session_id = sessions.id)`,
690
- )
691
- .run();
692
- this.#db.prepare(`pragma user_version = ${DEDUPE_VERSION}`).run();
906
+ // …and only where rows were actually removed. A session's clock legitimately sits ahead of its
907
+ // newest row after any long turn (`#touch` at turn end), so an unscoped version of this rewrote
908
+ // the rail order of every session in the file — 15 clocks moved on a boot that deleted from 5.
909
+ let clock = { changes: 0 as number | bigint };
910
+ if (doomedSessions.size > 0) {
911
+ const ids = [...doomedSessions];
912
+ clock = this.#db
913
+ .prepare(
914
+ `update sessions set last_activity_at =
915
+ (select max(m.created_at) from messages m where m.session_id = sessions.id)
916
+ where id in (${ids.map(() => "?").join(",")})
917
+ and last_activity_at is not null
918
+ and last_activity_at > (select max(m.created_at) from messages m where m.session_id = sessions.id)`,
919
+ )
920
+ .run(...ids);
921
+ }
922
+ this.#setRepairVersion("dedupe", DEDUPE_VERSION);
693
923
  if (before !== after) {
694
924
  console.log(
695
925
  `[store] 重放行清理:${before} 行 → ${after} 行(工具行 ${toolsDropped}、文本行 ${textDropped};`
@@ -1124,7 +1354,7 @@ export class Store {
1124
1354
  * the frame was a pure re-emission (nothing to emit); an existing row with a non-empty delta means
1125
1355
  * "this row grew" — the client appends instead of inserting a second bubble.
1126
1356
  */
1127
- appendTextChunk(m: Omit<StoredMessage, "seq"> & { kind: "agent" | "thought" }): {
1357
+ appendTextChunk(m: Omit<StoredMessage, "seq"> & { kind: "agent" | "thought" }, turnId?: number): {
1128
1358
  message: StoredMessage;
1129
1359
  delta: string;
1130
1360
  isNew: boolean;
@@ -1146,13 +1376,41 @@ export class Store {
1146
1376
  if (!key) {
1147
1377
  const row = this.#rowContaining(m.sessionId, m.kind, text);
1148
1378
  if (row) return this.#foldInto(row, text); // "grew" appends the tail, "same" is a no-op
1379
+ // 3. an id-less DELTA of a message this same turn is still building (issue #56, qoder). Its
1380
+ // frames carry no messageId at all, so "same kind, same turn, still the newest row" is the
1381
+ // only identity available — and the turn id is what keeps two separate replies apart
1382
+ // (a new turn starts a new row, and a tool/plan/user row in between does too).
1383
+ if (turnId !== undefined) {
1384
+ const open = this.#openTurnRow(m.sessionId, m.kind, turnId);
1385
+ if (open) return this.#foldInto(open, text);
1386
+ }
1149
1387
  }
1150
1388
  const stored = this.appendMessage(m);
1151
1389
  if (key) this.#db.prepare("update messages set block_key = ? where session_id = ? and seq = ?")
1152
1390
  .run(key, m.sessionId, stored.seq);
1391
+ if (turnId !== undefined || key) {
1392
+ this.#db.prepare("update messages set turn_id = ? where session_id = ? and seq = ?")
1393
+ .run(turnId ?? null, m.sessionId, stored.seq);
1394
+ }
1153
1395
  return { message: stored, delta: text, isNew: true };
1154
1396
  }
1155
1397
 
1398
+ /** The row an id-less delta continues: same kind, same turn, still the newest row of the session. */
1399
+ #openTurnRow(sessionId: string, kind: string, turnId: number): StoredMessage | undefined {
1400
+ const r = this.#db
1401
+ .prepare("select * from messages where session_id = ? and kind = ? order by seq desc limit 1")
1402
+ .get(sessionId, kind) as unknown as RawMessageRow | undefined;
1403
+ if (!r) return undefined;
1404
+ if ((r as unknown as { turn_id?: number | null }).turn_id !== turnId) return undefined;
1405
+ if ((r as unknown as { block_key?: string | null }).block_key) return undefined;
1406
+ // …and nothing was appended after it (a tool call, a plan, the operator) — that ends the message.
1407
+ const newest = this.#db
1408
+ .prepare("select seq from messages where session_id = ? order by seq desc limit 1")
1409
+ .get(sessionId) as { seq: number } | undefined;
1410
+ if (!newest || newest.seq !== r.seq) return undefined;
1411
+ return rowToMessage(r);
1412
+ }
1413
+
1156
1414
  /** Append to the row that already accumulates this message (or report it as a re-emission). */
1157
1415
  #foldInto(row: StoredMessage, incoming: string): { message: StoredMessage; delta: string; isNew: boolean } {
1158
1416
  const existing = textOf(row.payload);
@@ -16,6 +16,28 @@
16
16
  /** Backend ids are registry rows now (M6): the three builtin ones plus anything the operator
17
17
  * adds in the cockpit ("hermes-fork", "hermes-live", …). The literal union keeps autocomplete
18
18
  * for the builtins while letting a row id through. */
19
+ /** 通话表情标签的**规范词表** —— 服务端把它写进提示词,页面按它决定画哪张脸。
20
+ *
21
+ * One list, two consumers: the server composes the instruction, the browser parses what comes
22
+ * back. Keeping them apart is how you get a prompt that offers `[emo:excited]` while the face
23
+ * silently ignores it, so the vocabulary lives here and the web side carries a superset of
24
+ * synonyms on top (see `EMO_MOODS` in tusMood.ts, guarded by the `tus-mood` test). */
25
+ export const EMO_TAG_VALUES = [
26
+ // Grok Bot 的九个官方表情名(2026-10-24 起提示词照官方词表)
27
+ "neutral", "happy", "surprised", "inspecting", "sad", "sleepy", "squinted", "calm", "confused",
28
+ // 我们自己的四张:想、好奇、顶住、害羞(官方没有,或语义不同)
29
+ "curious", "thinking", "angry", "shy",
30
+ ] as const;
31
+
32
+ /** The sentence the call prompt carries. Kept next to the vocabulary so they cannot drift. */
33
+ export function emoTagHint(): string {
34
+ return (
35
+ "(通话界面会跟着你的情绪换表情:想表达情绪时在回复最开头加一个隐藏标记,例如 [emo:happy];" +
36
+ `可用值:${EMO_TAG_VALUES.join("/")}。这个标记不会被念出来、也不会显示给用户,` +
37
+ "所以不必解释它;一轮最多一个,别写进句子中间,也别用代码块或反引号包起来。)"
38
+ );
39
+ }
40
+
19
41
  export type BackendId = "hermes" | "qoder" | "mock" | (string & {});
20
42
 
21
43
  export type SessionStatus =