dsh-cindy-host 0.1.20
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/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read one DSH session's transcript as the message rows the Cindy phone renders.
|
|
3
|
+
*
|
|
4
|
+
* `sessionController.page()` would need a `throughSeq` the Host has no cheap way
|
|
5
|
+
* to learn, while `sessionQuery.readSession()` returns the whole log directly —
|
|
6
|
+
* so the fold is: log events → append-origin surface events → model messages →
|
|
7
|
+
* the phone's per-block rows.
|
|
8
|
+
*
|
|
9
|
+
* Why *append-origin* events rather than the model-visible surface: the surface
|
|
10
|
+
* deliberately shadows replaced ranges, so a landed replacement would erase
|
|
11
|
+
* conversation the user already saw. The transcript's durable source material is
|
|
12
|
+
* the append-origin events (`@deepseek-ai/dsh-session/surface`).
|
|
13
|
+
*
|
|
14
|
+
* The per-event projection is DSH's own pure `deriveEventMessage`, not a
|
|
15
|
+
* reimplementation: it is documented as THE per-node rule that delivery, durable
|
|
16
|
+
* history, and model requests all share, so a second copy here would drift.
|
|
17
|
+
*/
|
|
18
|
+
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
|
|
19
|
+
import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
|
|
20
|
+
|
|
21
|
+
/** Default page size when the controller does not ask for one. */
|
|
22
|
+
export const DEFAULT_MESSAGE_PAGE = 60;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rows the **newest** page is filled to when the controller asks for fewer.
|
|
26
|
+
*
|
|
27
|
+
* The controller's request that opens a session asks for **20** rows on purpose
|
|
28
|
+
* (`MESSAGE_PAGE_RETRY_LIMITS`, `apps/mobile/app/sessions/[sessionId].tsx`), because
|
|
29
|
+
* "On a slow mobile link an 80-row page can occupy the reliable stream past the
|
|
30
|
+
* request deadline" (`apps/mobile/src/session/messagePaging.ts`). That reasoning is
|
|
31
|
+
* about a page's **bytes**, and its measurement is recorded in the controller's
|
|
32
|
+
* invoke policy: 「the Android weak-link regression took ~18s to deliver 200KB」
|
|
33
|
+
* (`@cindy/device-link`'s `invokePolicy.ts`, which is also why
|
|
34
|
+
* `local-db:messages:list` gets a 30 s timeout there).
|
|
35
|
+
*
|
|
36
|
+
* A row here is one content block, so one agent turn — thinking, tool call, tool
|
|
37
|
+
* result, answer — can be twenty rows by itself. Measured on the conversation the
|
|
38
|
+
* user reported (画一只小猫): the newest page was 21 rows and covered exactly **one**
|
|
39
|
+
* turn of five, so opening the session showed 「只显示最后一个回复」; filling it to the
|
|
40
|
+
* controller's own 80-row cache window still missed 「我的第一条信息」, because that
|
|
41
|
+
* transcript is 81 rows.
|
|
42
|
+
*
|
|
43
|
+
* So the newest page is filled by **both** bounds: at most this many rows, and at
|
|
44
|
+
* most {@link NEWEST_WINDOW_TEXT_BYTES} of row JSON. Cursor-paged requests that
|
|
45
|
+
* follow still honour the requested limit exactly, and the controller compares a
|
|
46
|
+
* full page against its requested limit, so its "there is older history" affordance
|
|
47
|
+
* is unchanged.
|
|
48
|
+
*/
|
|
49
|
+
export const NEWEST_WINDOW_ROWS = 200;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Text budget for the newest page, before any image is inlined.
|
|
53
|
+
*
|
|
54
|
+
* 256 KiB is a quarter of the 1 MiB measured as a workable page on the worst link
|
|
55
|
+
* the controller has recorded (~18 s per 200 KB), which keeps the open request
|
|
56
|
+
* inside its 30 s deadline even when the page is all text, and leaves room for a
|
|
57
|
+
* picture from {@link MAX_INLINE_IMAGE_BYTES} on top.
|
|
58
|
+
*/
|
|
59
|
+
export const NEWEST_WINDOW_TEXT_BYTES = 256 * 1024;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How many sessions' folded transcripts are kept at once.
|
|
63
|
+
*
|
|
64
|
+
* The controller pages one session at a time (20 rows a request, `MESSAGE_FETCH_PAGE_SIZE`)
|
|
65
|
+
* and switches between a handful, so a small window covers the real access pattern.
|
|
66
|
+
* Each entry holds the rows of one whole transcript, which is why this is a handful
|
|
67
|
+
* rather than a directory.
|
|
68
|
+
*/
|
|
69
|
+
export const TRANSCRIPT_CACHE_SESSIONS = 3;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fold ONE live session event into the rows the controller appends.
|
|
73
|
+
*
|
|
74
|
+
* Used by the push path: the controller's `local-db:messages:created` handler
|
|
75
|
+
* takes `{ sessionId, message }` and appends it, so a live event becomes the same
|
|
76
|
+
* rows the transcript read produces — one fold, two callers.
|
|
77
|
+
* @param event - a `session/event` payload from the DSH session service.
|
|
78
|
+
* @param context - the session id and an injectable clock.
|
|
79
|
+
* @returns the rows to push, or an empty array when the event renders nothing.
|
|
80
|
+
*/
|
|
81
|
+
export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
|
|
82
|
+
if (event === null || typeof event !== 'object') return [];
|
|
83
|
+
if (!isAppendSurfaceEvent(event)) return [];
|
|
84
|
+
let message;
|
|
85
|
+
try {
|
|
86
|
+
message = deriveEventMessage(event);
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
if (message === null || message === undefined) return [];
|
|
91
|
+
// A harness notice is bookkeeping, not conversation. `tool-jobs` writes one when a background
|
|
92
|
+
// job finishes (`source: {kind: 'plugin', plugin: 'tool-jobs', form: 'notice'}`), and this
|
|
93
|
+
// projection was handing it to the phone as a **user bubble** — so the user read
|
|
94
|
+
// 「background job pwsh-1 (pwsh: …) finished [status: completed, exit code: 0]」 as if they had
|
|
95
|
+
// said it, and DSH's own inbox splice can steer the same text into a running turn
|
|
96
|
+
// (measured: `next-step inserted … plugin: tool-jobs`).
|
|
97
|
+
//
|
|
98
|
+
// Hidden, not dropped: the caller counts it (`suppressedNotices`), because a row this Host
|
|
99
|
+
// decides not to show has to stay discoverable. Only `kind === 'plugin'` is touched — a
|
|
100
|
+
// message a person sent, or any other producer's row, is unaffected.
|
|
101
|
+
if (messageSourceOf(message)?.kind === 'plugin') return [];
|
|
102
|
+
const createdAt = Number.isFinite(event.time) ? new Date(event.time).toISOString() : now().toISOString();
|
|
103
|
+
// The controller's own id for a submitted prompt travels with the row so it
|
|
104
|
+
// can retire the copy it showed optimistically.
|
|
105
|
+
return toCindyMessageRows(message, { sessionId, createdAt, now, promptClientId: promptRpcIdOf(event) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The `source` a message carries, whichever shape the surface handed over. */
|
|
109
|
+
function messageSourceOf(message) {
|
|
110
|
+
const source = message?.source;
|
|
111
|
+
return source !== null && typeof source === 'object' ? source : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether one event is harness bookkeeping rather than something a person said.
|
|
116
|
+
*
|
|
117
|
+
* Exported because the caller counts what this projection hides: a row the phone never sees must
|
|
118
|
+
* be observable somewhere, or "the Host silently dropped a message" and "there was nothing to
|
|
119
|
+
* show" would look the same from the desk.
|
|
120
|
+
* @param event - one `session/event` payload.
|
|
121
|
+
* @returns true for a `plugin`-sourced message.
|
|
122
|
+
*/
|
|
123
|
+
export function isHarnessNotice(event) {
|
|
124
|
+
// No `isAppendSurfaceEvent` gate: this asks "is this event a plugin-sourced message", and the
|
|
125
|
+
// caller may hand over an event the fold would not render for other reasons anyway. Gating here
|
|
126
|
+
// made the predicate answer *false* for a real notice, which is a worse failure than a redundant
|
|
127
|
+
// true.
|
|
128
|
+
if (event === null || typeof event !== 'object') return false;
|
|
129
|
+
try {
|
|
130
|
+
return messageSourceOf(deriveEventMessage(event))?.kind === 'plugin';
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The controller's own id for a prompt that just became a durable message.
|
|
138
|
+
*
|
|
139
|
+
* This Host sets each prompt's `requestId` to the controller's `clientId`, and
|
|
140
|
+
* DSH stores that as the message source's `rpcId`. The moment this message lands,
|
|
141
|
+
* the inbox entry is gone — so this is exactly when the controller's optimistic
|
|
142
|
+
* "队列中" row must be retired. It is also the only moment we can name that row
|
|
143
|
+
* without reading DSH's inbox projection, which the control stream does not push
|
|
144
|
+
* for us.
|
|
145
|
+
*
|
|
146
|
+
* @param event - one `session/event` payload.
|
|
147
|
+
* @returns the controller's id, or null when this event is not a submitted prompt.
|
|
148
|
+
*/
|
|
149
|
+
export function promptRpcIdOf(event) {
|
|
150
|
+
// The source rides inside `data` on a durable event, but a delivery-shaped
|
|
151
|
+
// event carries it at the top level; both are read rather than guessing.
|
|
152
|
+
const source = event?.data?.source ?? event?.source;
|
|
153
|
+
if (source === null || typeof source !== 'object') return null;
|
|
154
|
+
if (source.kind !== 'user') return null;
|
|
155
|
+
return typeof source.rpcId === 'string' && source.rpcId !== '' ? source.rpcId : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Cut one page out of a newest-first transcript.
|
|
160
|
+
*
|
|
161
|
+
* Extracted from the reader so the paging rule can be tested directly: the
|
|
162
|
+
* interesting part is the cursor, not the log read.
|
|
163
|
+
*
|
|
164
|
+
* **The cursor is a row id, not a timestamp.** The controller walks back with
|
|
165
|
+
* `oldestMessageCursor(loaded)` — literally the oldest loaded row's `id`
|
|
166
|
+
* (`apps/mobile/src/session/messagePaging.ts`) — and merges the answer only when
|
|
167
|
+
* that exact id is still in its window (`mergeEarlierMessages` looks the anchor
|
|
168
|
+
* up with `row.id === before` and returns false otherwise). Treating the cursor
|
|
169
|
+
* as a `createdAt` made every request return the newest page again: the string
|
|
170
|
+
* comparison `'2026-…' < 'session-…'` is true for every row, so a "load earlier"
|
|
171
|
+
* fetched nothing new, forever.
|
|
172
|
+
*
|
|
173
|
+
* An ISO-timestamp cursor is still honoured, because that is what this Host
|
|
174
|
+
* itself sent before the contract was read correctly and a controller can hold
|
|
175
|
+
* one across an upgrade.
|
|
176
|
+
*
|
|
177
|
+
* @param rows - the whole transcript, newest first.
|
|
178
|
+
* @param options - the controller's `before` cursor (a row id) and `limit`.
|
|
179
|
+
* @returns the page.
|
|
180
|
+
*/
|
|
181
|
+
export function pageRows(rows, options = {}) {
|
|
182
|
+
const before = typeof options?.before === 'string' && options.before !== '' ? options.before : null;
|
|
183
|
+
let candidates = rows;
|
|
184
|
+
let resumeAt = 0;
|
|
185
|
+
if (before !== null) {
|
|
186
|
+
const anchor = rows.findIndex((row) => row.id === before);
|
|
187
|
+
if (anchor >= 0) {
|
|
188
|
+
// Everything strictly older than the row the controller named.
|
|
189
|
+
resumeAt = anchor + 1;
|
|
190
|
+
} else if (rows.some((row) => row.createdAt === before)) {
|
|
191
|
+
// A timestamp cursor: the row that carried it is itself part of the next
|
|
192
|
+
// page, since the controller has not shown it yet.
|
|
193
|
+
const firstAtOrBelow = rows.findIndex((row) => row.createdAt < before);
|
|
194
|
+
resumeAt = firstAtOrBelow < 0 ? rows.length : firstAtOrBelow;
|
|
195
|
+
} else {
|
|
196
|
+
// An id this Host does not know: a retained row from another device, or a
|
|
197
|
+
// cursor minted before an id scheme change. The honest answer is the
|
|
198
|
+
// oldest page we have rather than the newest one again.
|
|
199
|
+
resumeAt = Math.max(0, rows.length - countForLimit(rows, options));
|
|
200
|
+
}
|
|
201
|
+
candidates = rows.slice(resumeAt);
|
|
202
|
+
}
|
|
203
|
+
const limit = Number.isFinite(options?.limit) && options.limit > 0 ? Math.floor(options.limit) : DEFAULT_MESSAGE_PAGE;
|
|
204
|
+
const page = candidates.slice(0, limit);
|
|
205
|
+
// Never split one message across two pages.
|
|
206
|
+
//
|
|
207
|
+
// A message's rows (thought, tool call, answer) all share one timestamp, so
|
|
208
|
+
// cutting a group in half would leave the rest of that group behind a cursor
|
|
209
|
+
// the controller computes from the page tail — those rows become unreachable
|
|
210
|
+
// and the transcript shows a hole the user cannot page past. Finishing the
|
|
211
|
+
// group is cheap, and the extra rows only ever make the page fuller than the
|
|
212
|
+
// controller asked for.
|
|
213
|
+
const boundary = page.length > 0 ? page[page.length - 1].createdAt : null;
|
|
214
|
+
if (boundary !== null) {
|
|
215
|
+
for (let index = page.length; index < candidates.length; index += 1) {
|
|
216
|
+
if (candidates[index].createdAt !== boundary) break;
|
|
217
|
+
page.push(candidates[index]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return page;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** How many rows a limit asks for, for the unknown-cursor fallback. */
|
|
224
|
+
function countForLimit(rows, options) {
|
|
225
|
+
return Number.isFinite(options?.limit) && options.limit > 0 ? Math.floor(options.limit) : DEFAULT_MESSAGE_PAGE;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whether this request is the "what is in this session" page rather than a cursor step. */
|
|
229
|
+
function isNewestRequest(options) {
|
|
230
|
+
return !(typeof options?.before === 'string' && options.before !== '');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** UTF-8 size of one row's JSON, which is what the controller has to carry. */
|
|
234
|
+
function rowByteLength(row) {
|
|
235
|
+
try {
|
|
236
|
+
const text = JSON.stringify(row);
|
|
237
|
+
return typeof text === 'string' ? Buffer.byteLength(text, 'utf8') : 0;
|
|
238
|
+
} catch {
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Cut the newest page down to {@link NEWEST_WINDOW_TEXT_BYTES}, without splitting a
|
|
245
|
+
* message's rows across the cut.
|
|
246
|
+
*
|
|
247
|
+
* The rows are newest first, so the cut keeps the newest ones — which is the end of
|
|
248
|
+
* the conversation the user is looking at — and the controller's "load earlier"
|
|
249
|
+
* affordance still reaches the rest, because the page is far longer than the twenty
|
|
250
|
+
* rows it asked for.
|
|
251
|
+
* @param rows - the newest-first page.
|
|
252
|
+
* @returns the page, possibly shorter.
|
|
253
|
+
*/
|
|
254
|
+
function fitNewestPageBytes(rows, maxBytes = NEWEST_WINDOW_TEXT_BYTES) {
|
|
255
|
+
let bytes = 0;
|
|
256
|
+
let keep = 0;
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
const size = rowByteLength(row);
|
|
259
|
+
// One row always travels: a page the controller cannot use is worse than a
|
|
260
|
+
// slightly oversized one, and the frame budget still guards the hard ceiling.
|
|
261
|
+
if (keep > 0 && bytes + size > maxBytes) break;
|
|
262
|
+
bytes += size;
|
|
263
|
+
keep += 1;
|
|
264
|
+
}
|
|
265
|
+
if (keep >= rows.length) return rows;
|
|
266
|
+
// Rows of one message share `createdAt`; cutting inside the group would leave the
|
|
267
|
+
// rest behind a cursor the controller computes from the page tail.
|
|
268
|
+
while (keep > 1 && rows[keep - 1]?.createdAt === rows[keep]?.createdAt) keep -= 1;
|
|
269
|
+
return rows.slice(0, keep);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The page options for one request, with the newest page widened.
|
|
274
|
+
*
|
|
275
|
+
* Only a request with **no cursor** is the "what is in this session" page; a request
|
|
276
|
+
* that names a `before` row is the controller walking backwards, one deliberate step
|
|
277
|
+
* at a time, and is answered with exactly the limit it asked for.
|
|
278
|
+
* @param options - the controller's `{ before, limit }`.
|
|
279
|
+
* @returns options for `pageRows`.
|
|
280
|
+
*/
|
|
281
|
+
function widenNewestPage(options) {
|
|
282
|
+
const before = typeof options?.before === 'string' && options.before !== '' ? options.before : null;
|
|
283
|
+
if (before !== null) return options;
|
|
284
|
+
const limit = Number.isFinite(options?.limit) && options.limit > 0 ? Math.floor(options.limit) : DEFAULT_MESSAGE_PAGE;
|
|
285
|
+
if (limit >= NEWEST_WINDOW_ROWS) return options;
|
|
286
|
+
return { ...options, limit: NEWEST_WINDOW_ROWS };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Render one DSH `todo/write` snapshot as the row the phone draws its plan card from.
|
|
291
|
+
*
|
|
292
|
+
* DSH's todo tool declares its payload as **log-only** state — 「Log-only UI state; never
|
|
293
|
+
* derived history」 — so the transcript fold deliberately drops it, and the desk renders it
|
|
294
|
+
* from the `todos` projection instead. The phone has no such projection surface: its card
|
|
295
|
+
* comes from a **`TodoWrite` tool row** in the transcript (`extractTodosFromMessage` /
|
|
296
|
+
* `agentPlanSource`, `@cindy/maker-shared/message-render`). So a plan that only ever
|
|
297
|
+
* reached DSH as `todo/write` — which is exactly how the claw workflow mirrors its plan
|
|
298
|
+
* onto the native todo dock (`session.append('todo/write', { todos })` in
|
|
299
|
+
* `@veewo/dsh-claw-kit`) — was invisible on the handset: 你走 claw 流程是有 ToDo 同步的,
|
|
300
|
+
* 但手机上只有一句也看不到.
|
|
301
|
+
*
|
|
302
|
+
* The translation invents nothing: the same `{ content, status }` items DSH folded are
|
|
303
|
+
* handed over in the tool-call shape the phone already knows (`todo_write` is mapped to
|
|
304
|
+
* `TodoWrite` by name in `cindy-message-row.js`), stamped with the snapshot's own time so
|
|
305
|
+
* the card sits where the write happened and updates as the plan progresses.
|
|
306
|
+
*
|
|
307
|
+
* An **empty** snapshot is skipped on purpose: the adapter clears the dock by writing an
|
|
308
|
+
* empty list, and an empty *card* is not the same thing as no card.
|
|
309
|
+
*
|
|
310
|
+
* @param event - one session event.
|
|
311
|
+
* @returns the transcript entry, or null when this is not a renderable todo snapshot.
|
|
312
|
+
*/
|
|
313
|
+
export function entryForTodoWrite(event) {
|
|
314
|
+
if (event?.type !== 'todo/write') return null;
|
|
315
|
+
const todos = Array.isArray(event?.data?.todos) ? event.data.todos : null;
|
|
316
|
+
if (todos === null || todos.length === 0) return null;
|
|
317
|
+
const items = todos
|
|
318
|
+
.filter((todo) => todo !== null && typeof todo === 'object' && typeof todo.content === 'string' && typeof todo.status === 'string')
|
|
319
|
+
.map((todo) => ({ content: todo.content, status: todo.status }));
|
|
320
|
+
if (items.length === 0) return null;
|
|
321
|
+
const id = `todo-${Number.isFinite(event.seq) ? event.seq : 'snapshot'}`;
|
|
322
|
+
return {
|
|
323
|
+
message: {
|
|
324
|
+
id,
|
|
325
|
+
role: 'assistant',
|
|
326
|
+
content: [{ type: 'tool-call', id, name: 'todo_write', arguments: JSON.stringify({ todos: items }) }],
|
|
327
|
+
},
|
|
328
|
+
createdAt: Number.isFinite(event.time) ? new Date(event.time).toISOString() : new Date().toISOString(),
|
|
329
|
+
promptClientId: null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Build a reader over one DSH session.
|
|
335
|
+
*
|
|
336
|
+
* **Why there is a cache in here.** `sessionQuery.readSession()` reads and
|
|
337
|
+
* replay-validates the *whole* log, and the fold then projects every event, so a
|
|
338
|
+
* transcript read costs the same whether the controller asked for 1 row or 500 —
|
|
339
|
+
* measured on the 17 MB conversation this Host was reported slow on: 216 ms warm,
|
|
340
|
+
* 2.3 s while the session is live. The controller does not ask once either: its
|
|
341
|
+
* "load earlier" page is 20 rows (`MESSAGE_FETCH_PAGE_SIZE`), so scrolling back
|
|
342
|
+
* through a long conversation is a dozen reads, each one repaying that full cost —
|
|
343
|
+
* which is exactly what the user reported as 加载更早消息,每次只加载一点点,会导致需要频繁加载.
|
|
344
|
+
*
|
|
345
|
+
* So one full read *seeds* a per-session cache of finished rows, and the live
|
|
346
|
+
* `session/event` stream keeps it current (`noteEvent`). A page then costs O(page).
|
|
347
|
+
*
|
|
348
|
+
* The cache is only trusted while it is provably contiguous with the log: every
|
|
349
|
+
* appended event carries a monotonic `seq`, so a gap, a reused seq (rewind/clear),
|
|
350
|
+
* or an event arriving while the seed read was in flight all drop the entry and the
|
|
351
|
+
* next read re-seeds from the log. A stale cache is therefore never served — the
|
|
352
|
+
* worst case is one extra full read.
|
|
353
|
+
*
|
|
354
|
+
* @param options - the raw-log reader, an injectable clock, and the attachment store's
|
|
355
|
+
* image reader (absent on a profile that composes no attachment service, in which
|
|
356
|
+
* case images stay file chips).
|
|
357
|
+
* @returns `(sessionId, { before, limit }) => Promise<CindyMessageRow[]>`, with
|
|
358
|
+
* `noteEvent`, `invalidate`, and `stats` hung off it.
|
|
359
|
+
*/
|
|
360
|
+
export function createMessageReader({
|
|
361
|
+
readSessionLog,
|
|
362
|
+
now = () => new Date(),
|
|
363
|
+
readImageAttachment,
|
|
364
|
+
maxInlineImageBytes,
|
|
365
|
+
maxInlineImageTotalBytes,
|
|
366
|
+
cacheSessions = TRANSCRIPT_CACHE_SESSIONS,
|
|
367
|
+
}) {
|
|
368
|
+
if (typeof readSessionLog !== 'function') throw new Error('createMessageReader requires readSessionLog');
|
|
369
|
+
/** `sessionId -> { rows, seq, at }`, in least-recently-used order. */
|
|
370
|
+
const transcripts = new Map();
|
|
371
|
+
/** `sessionId -> { promise, dirty }`; count, view and page share one seed flight. */
|
|
372
|
+
const seeding = new Map();
|
|
373
|
+
let clock = 0;
|
|
374
|
+
|
|
375
|
+
/** Fold one session's log into message entries, in log order. */
|
|
376
|
+
async function entriesFor(sessionId) {
|
|
377
|
+
const snapshot = await readSessionLog(sessionId);
|
|
378
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
379
|
+
const entries = [];
|
|
380
|
+
let lastSeq = null;
|
|
381
|
+
for (const event of events) {
|
|
382
|
+
if (event === null || typeof event !== 'object') continue;
|
|
383
|
+
// Every event's seq counts, not only the ones that render: contiguity is a
|
|
384
|
+
// property of the log, and a caller comparing against renders would read a
|
|
385
|
+
// gap as "nothing happened here".
|
|
386
|
+
if (Number.isFinite(event.seq)) lastSeq = event.seq;
|
|
387
|
+
const todo = entryForTodoWrite(event);
|
|
388
|
+
if (todo !== null) {
|
|
389
|
+
entries.push(todo);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!isAppendSurfaceEvent(event)) continue;
|
|
393
|
+
let message;
|
|
394
|
+
try {
|
|
395
|
+
message = deriveEventMessage(event);
|
|
396
|
+
} catch {
|
|
397
|
+
// One unprojectable event must not cost the whole transcript; the same
|
|
398
|
+
// rule the surface fold applies per node.
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (message === null || message === undefined) continue;
|
|
402
|
+
entries.push({
|
|
403
|
+
message,
|
|
404
|
+
createdAt: Number.isFinite(event.time) ? new Date(event.time).toISOString() : now().toISOString(),
|
|
405
|
+
// Same reason as the live fold: the durable read has to identify a user
|
|
406
|
+
// turn by the id the controller minted, or its optimistic copy survives.
|
|
407
|
+
promptClientId: promptRpcIdOf(event),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return { entries, lastSeq };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Drop the least recently used transcripts, keeping the window bounded. */
|
|
414
|
+
function evict() {
|
|
415
|
+
while (transcripts.size > Math.max(1, cacheSessions)) {
|
|
416
|
+
const oldest = transcripts.keys().next();
|
|
417
|
+
if (oldest.done === true) break;
|
|
418
|
+
transcripts.delete(oldest.value);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Note one transcript as most recently used. */
|
|
423
|
+
function touch(sessionId, entry) {
|
|
424
|
+
transcripts.delete(sessionId);
|
|
425
|
+
entry.at = ++clock;
|
|
426
|
+
transcripts.set(sessionId, entry);
|
|
427
|
+
evict();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function readMessages(sessionId, options = {}) {
|
|
431
|
+
const all = await ensureTranscript(sessionId);
|
|
432
|
+
const page = isNewestRequest(options)
|
|
433
|
+
? fitNewestPageBytes(pageRows(all, widenNewestPage(options)))
|
|
434
|
+
: pageRows(all, options);
|
|
435
|
+
// Hydrate **after** paging: only the rows actually being served pay for reading
|
|
436
|
+
// image bytes, and nothing is materialized for a page nobody asked for.
|
|
437
|
+
//
|
|
438
|
+
// The pictures carry their own budget (`MAX_INLINE_IMAGE_TOTAL_BYTES`) instead of
|
|
439
|
+
// sharing one with the rows: the row side is bounded here already
|
|
440
|
+
// ({@link NEWEST_WINDOW_TEXT_BYTES} for the newest page, the controller's own
|
|
441
|
+
// limit for a cursor page), so the two caps simply add up — text ≤ 256 KiB plus
|
|
442
|
+
// pictures ≤ 1 MiB stays inside both the relay's 2 MiB frame ceiling and this
|
|
443
|
+
// channel's 30 s deadline, and a text-heavy page never costs the user a picture.
|
|
444
|
+
return hydrateImageAttachments(page, {
|
|
445
|
+
readImage: readImageAttachment,
|
|
446
|
+
maxBytes: maxInlineImageBytes,
|
|
447
|
+
maxTotalBytes: maxInlineImageTotalBytes,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The cached (or freshly seeded) transcript rows for one session.
|
|
453
|
+
* @param sessionId - the session.
|
|
454
|
+
* @returns the whole transcript, newest first.
|
|
455
|
+
*/
|
|
456
|
+
async function ensureTranscript(sessionId) {
|
|
457
|
+
const entry = transcripts.get(sessionId);
|
|
458
|
+
if (entry !== undefined) {
|
|
459
|
+
touch(sessionId, entry);
|
|
460
|
+
return entry.rows;
|
|
461
|
+
}
|
|
462
|
+
const pending = seeding.get(sessionId);
|
|
463
|
+
if (pending !== undefined) return pending.promise;
|
|
464
|
+
const flight = { promise: null, dirty: false };
|
|
465
|
+
// Publish the flight before calling the log reader, including a reader that
|
|
466
|
+
// synchronously emits an event or invalidates the session on entry.
|
|
467
|
+
seeding.set(sessionId, flight);
|
|
468
|
+
flight.promise = Promise.resolve().then(async () => {
|
|
469
|
+
try {
|
|
470
|
+
const read = await entriesFor(sessionId);
|
|
471
|
+
const rows = toCindyMessages(read.entries, { sessionId, now });
|
|
472
|
+
// A live event during replay makes the snapshot unsafe to cache. Explicit
|
|
473
|
+
// invalidation also detaches this flight so a post-invalidation read starts
|
|
474
|
+
// fresh; an older completion must never overwrite that newer generation.
|
|
475
|
+
if (seeding.get(sessionId) === flight && !flight.dirty) {
|
|
476
|
+
touch(sessionId, { rows, seq: read.lastSeq, at: clock });
|
|
477
|
+
}
|
|
478
|
+
return transcripts.get(sessionId)?.rows ?? rows;
|
|
479
|
+
} finally {
|
|
480
|
+
// Failure is not an authoritative empty transcript. Release this flight
|
|
481
|
+
// on success or rejection, without deleting a newer read's ownership.
|
|
482
|
+
if (seeding.get(sessionId) === flight) seeding.delete(sessionId);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
return flight.promise;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* How many rows one session's transcript holds, without materializing a page.
|
|
490
|
+
*
|
|
491
|
+
* The controller's `_count.messages` is this number, and it is asked for on the
|
|
492
|
+
* same screen that asks for the first page — so this must not hydrate images
|
|
493
|
+
* (the bytes would be read and thrown away) and must not fold the log twice.
|
|
494
|
+
* @param sessionId - the session.
|
|
495
|
+
* @returns the row count.
|
|
496
|
+
*/
|
|
497
|
+
async function countRows(sessionId) {
|
|
498
|
+
const rows = await ensureTranscript(sessionId);
|
|
499
|
+
return rows.length;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Fold one live session event into the cached transcript.
|
|
504
|
+
*
|
|
505
|
+
* Called for **every** appended event, watchers or not: the cache's whole value is
|
|
506
|
+
* that it is complete without re-reading the log, and that only holds if nothing
|
|
507
|
+
* was allowed to pass unobserved.
|
|
508
|
+
* @param sessionId - the session the event belongs to.
|
|
509
|
+
* @param event - the DSH session event.
|
|
510
|
+
* @returns whether the cache was updated (false when there is nothing cached yet,
|
|
511
|
+
* or when the event proved the cache stale and it was dropped instead).
|
|
512
|
+
*/
|
|
513
|
+
function noteEvent(sessionId, event) {
|
|
514
|
+
if (typeof sessionId !== 'string' || sessionId === '') return false;
|
|
515
|
+
const entry = transcripts.get(sessionId);
|
|
516
|
+
if (entry === undefined) {
|
|
517
|
+
const flight = seeding.get(sessionId);
|
|
518
|
+
if (flight !== undefined) flight.dirty = true;
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
const seq = Number.isFinite(event?.seq) ? event.seq : null;
|
|
522
|
+
const last = Number.isFinite(entry.seq) ? entry.seq : null;
|
|
523
|
+
// A missing, repeated, or skipped seq means this cache and the log have parted
|
|
524
|
+
// ways (a rewind, a clear, a session restored from elsewhere). Re-seeding is
|
|
525
|
+
// cheap; serving a transcript with a hole is not.
|
|
526
|
+
if (seq === null || last === null || seq <= last || seq !== last + 1) {
|
|
527
|
+
transcripts.delete(sessionId);
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
entry.seq = seq;
|
|
531
|
+
entry.at = ++clock;
|
|
532
|
+
// A live todo snapshot becomes the same `TodoWrite` row the durable read produces, so
|
|
533
|
+
// the phone's plan card updates while the plan is being worked rather than only when
|
|
534
|
+
// the transcript is re-read.
|
|
535
|
+
const todoEntry = entryForTodoWrite(event);
|
|
536
|
+
const rows = todoEntry === null
|
|
537
|
+
? foldSessionEvent(event, { sessionId, now })
|
|
538
|
+
: toCindyMessageRows(todoEntry.message, { sessionId, createdAt: todoEntry.createdAt, now });
|
|
539
|
+
// Newest first: this event is the newest thing in the log, so its rows are the
|
|
540
|
+
// new head of the array, in block order.
|
|
541
|
+
if (rows.length > 0) entry.rows = [...rows, ...entry.rows];
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Forget one session's cached transcript and detach any pre-invalidation seed. */
|
|
546
|
+
function invalidate(sessionId) {
|
|
547
|
+
// Existing waiters may finish their read, but new callers must not join it.
|
|
548
|
+
// Identity checks in ensureTranscript prevent it from caching after this point.
|
|
549
|
+
seeding.delete(sessionId);
|
|
550
|
+
return transcripts.delete(sessionId);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** What the cache is doing, for the status route. */
|
|
554
|
+
function stats() {
|
|
555
|
+
let rows = 0;
|
|
556
|
+
for (const entry of transcripts.values()) rows += entry.rows.length;
|
|
557
|
+
return { sessions: transcripts.size, rows, seeding: seeding.size };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
readMessages.noteEvent = noteEvent;
|
|
561
|
+
readMessages.invalidate = invalidate;
|
|
562
|
+
readMessages.count = countRows;
|
|
563
|
+
/** Exact project-grouping evidence derived from the same folded transcript. */
|
|
564
|
+
readMessages.activity = async (sessionId) => {
|
|
565
|
+
const rows = await ensureTranscript(sessionId);
|
|
566
|
+
let userSendAt = null;
|
|
567
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
568
|
+
const row = rows[index];
|
|
569
|
+
if (row?.role === 'user' && typeof row.createdAt === 'string' && row.createdAt !== '') {
|
|
570
|
+
userSendAt = row.createdAt;
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { messageCount: rows.length, userSendAt };
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* The whole cached transcript, newest first.
|
|
578
|
+
*
|
|
579
|
+
* For a reader that has to *group* the transcript rather than page it
|
|
580
|
+
* (`local-db:messages:view`), and therefore cannot go through the newest-page budget.
|
|
581
|
+
* The array is the cache's own, so callers treat it as read-only.
|
|
582
|
+
*/
|
|
583
|
+
readMessages.all = (sessionId) => ensureTranscript(sessionId);
|
|
584
|
+
readMessages.stats = stats;
|
|
585
|
+
return readMessages;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Count the rows one session's transcript folds to.
|
|
590
|
+
*
|
|
591
|
+
* The controller only lights its "load earlier" entry point when it knows the
|
|
592
|
+
* total and the total exceeds what it has loaded
|
|
593
|
+
* (`hasOlderMessagesByServerCount`: an unknown total is deliberately treated
|
|
594
|
+
* as "no"), and it reads that total from the session row's `_count.messages`.
|
|
595
|
+
* This Host used to answer `_count: null`, so the entry point never lit up.
|
|
596
|
+
*
|
|
597
|
+
* It counts **rows**, not events, so it must fold the same way the transcript read
|
|
598
|
+
* does or the two sides compare different units — which is why it borrows the
|
|
599
|
+
* reader's fold (and its cache) instead of folding the log a second time.
|
|
600
|
+
*
|
|
601
|
+
* @param reader - a `createMessageReader` result.
|
|
602
|
+
* @returns `(sessionId) => Promise<number>`.
|
|
603
|
+
*/
|
|
604
|
+
export function createMessageCounter({ reader } = {}) {
|
|
605
|
+
if (typeof reader !== 'function') throw new Error('createMessageCounter requires a message reader');
|
|
606
|
+
if (typeof reader.count !== 'function') throw new Error('createMessageCounter requires a reader built by createMessageReader');
|
|
607
|
+
return (sessionId) => reader.count(sessionId);
|
|
608
|
+
}
|