agentus 0.12.3 → 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.
- package/bin/cli.mjs +108 -8
- package/docs/dev/04-recover.md +5 -3
- package/docs/transcript-folding.md +21 -1
- package/package.json +8 -2
- package/packages/server/mock/agent.mjs +39 -2
- package/packages/server/src/acp/backends.ts +22 -0
- package/packages/server/src/acp/session-manager.ts +229 -23
- package/packages/server/src/acp/voice-mode.ts +7 -1
- package/packages/server/src/cockpit/api.ts +3 -1
- package/packages/server/src/index.ts +129 -5
- package/packages/server/src/settings.ts +15 -3
- package/packages/server/src/store/store.ts +286 -28
- package/packages/shared/src/index.ts +22 -0
- package/packages/web/dist/assets/index-BEpVrnpI.js +131 -0
- package/packages/web/dist/assets/{index-Csk40Rv7.css → index-vVv5fr7N.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-BKrG0ftW.js +0 -131
|
@@ -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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
if (
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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 =
|