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,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `local-db:messages:view` — the work-grouped history window the controller prefers.
|
|
3
|
+
*
|
|
4
|
+
* Why this channel exists at all: the phone's raw transcript path pages **20 rows at a
|
|
5
|
+
* time** (`MESSAGE_FETCH_PAGE_SIZE`) and, on a full-page sync, deliberately drops the
|
|
6
|
+
* older history it cannot prove is contiguous (`setLatestMessageWindow` +
|
|
7
|
+
* `sessionWindowCoverage` in `apps/mobile/src/session/remoteSessionStore.ts`). The result
|
|
8
|
+
* is 「每次重新进入会话都重新拉取、丢掉之前翻出来的历史」 — the client's own rule, and the
|
|
9
|
+
* reason the reference controlled end serves a *history view* instead: it returns
|
|
10
|
+
* **work items** with their own cursor, so the controller keeps a projection it can trust
|
|
11
|
+
* across re-entries rather than guessing at raw row continuity.
|
|
12
|
+
*
|
|
13
|
+
* Contract (read off `@cindy/maker-shared/message-window`, the desktop reader
|
|
14
|
+
* `apps/desktop/src/main/localDb/ipc/historyViewReader.ts`, and the phone's transport):
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* page(sessionId, before?) → { version: 1, items, nextCursor, hasMore }
|
|
18
|
+
* details(sessionId, ref, after?) → { version: 1, messages, nextCursor, hasMore }
|
|
19
|
+
* intent(sessionId, refs) → true
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* - **A page's `items` are chronological** (oldest → newest); `nextCursor` is the
|
|
23
|
+
* **oldest** id of that page (`firstId(items[0])`) and is passed back as `before` to
|
|
24
|
+
* walk further back. `hasMore` is what lights the controller's "load older".
|
|
25
|
+
* - A `work` item carries a `HistoryWorkSummary`; a `messages` item is a plain row the
|
|
26
|
+
* controller renders on its own.
|
|
27
|
+
* - `details` reads the inclusive range `firstMessageId…lastMessageId`, one
|
|
28
|
+
* `HISTORY_DETAIL_PAGE_BYTES` page at a time, with `after` = the last row already sent.
|
|
29
|
+
* - `intent` replaces the set of expanded work keys wholesale; it is advisory.
|
|
30
|
+
* - An error whose text matches `isHistoryViewUnavailable` sends the controller back to
|
|
31
|
+
* the raw window. `UNSUPPORTED_CAPABILITY` is therefore how this Host *deliberately*
|
|
32
|
+
* declines a session it cannot project (a transcript past the scan budget) — the same
|
|
33
|
+
* lever the reference uses.
|
|
34
|
+
*
|
|
35
|
+
* Grouping here is by **DSH turn boundary as the controller can see it**: a row whose role
|
|
36
|
+
* is `user` starts a work group, and everything up to the next one belongs to it. The
|
|
37
|
+
* desktop groups more finely (sealed answers and their own activity runs, from row shapes
|
|
38
|
+
* this Host does not have), but the contract is about the *page*, not the grouping: a
|
|
39
|
+
* coarser work item is still a work item, and `preview`/`children` are optional.
|
|
40
|
+
*
|
|
41
|
+
* @module dsh-cindy-host/host-history-view
|
|
42
|
+
*/
|
|
43
|
+
import {
|
|
44
|
+
HISTORY_DETAIL_PAGE_BYTES,
|
|
45
|
+
HISTORY_PAGE_BYTES,
|
|
46
|
+
HISTORY_PAGE_ITEMS,
|
|
47
|
+
HISTORY_VIEW_VERSION,
|
|
48
|
+
} from './host-history-view-limits.js';
|
|
49
|
+
|
|
50
|
+
/** One history page's byte ceiling is the contract's, not this Host's choice. */
|
|
51
|
+
export { HISTORY_DETAIL_PAGE_BYTES, HISTORY_PAGE_BYTES, HISTORY_PAGE_ITEMS, HISTORY_VIEW_VERSION };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Longest transcript this Host will project — **no longer a ceiling**.
|
|
55
|
+
*
|
|
56
|
+
* Kept only so the removed refusal is visible where it used to be: the budget did not save the
|
|
57
|
+
* read (it had already happened and been cached) and refusing poisoned the controller's view
|
|
58
|
+
* permanently. See the comment in `transcriptFor` and ADR-0007's decision evolution. Nothing
|
|
59
|
+
* reads this constant; a future "this is too big" rule must find a retryable answer instead.
|
|
60
|
+
*/
|
|
61
|
+
export const HISTORY_SCAN_MAX_ROWS = 20_000;
|
|
62
|
+
|
|
63
|
+
/** FNV-1a over the group's rows: the revision changes whenever the group's body does. */
|
|
64
|
+
function revisionOf(rows) {
|
|
65
|
+
let hash = 2166136261;
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
const text = JSON.stringify(row);
|
|
68
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
69
|
+
hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return hash >>> 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A row's own key in the controller's vocabulary. */
|
|
76
|
+
function keyOf(row) {
|
|
77
|
+
return row.clientId || row.id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The id a page's cursor is built from: a work item's first row, or a row itself. */
|
|
81
|
+
export function firstIdOf(item) {
|
|
82
|
+
return item.type === 'work' ? item.summary.firstMessageId : item.messages[0]?.id ?? '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rows one work item may carry before the run is split.
|
|
87
|
+
*
|
|
88
|
+
* A work item is *collapsed* until the controller expands it, so an item that swallows a
|
|
89
|
+
* whole turn hides the conversation: measured on this session, grouping a turn as one item
|
|
90
|
+
* produced a page of twenty collapsed items with **zero** readable rows, one of them
|
|
91
|
+
* carrying 329 rows — 「进入会话,只显示一号折叠的会话,然后每次展开十来分钟,又要继续展开」.
|
|
92
|
+
* The cap keeps every expansion to roughly one detail page.
|
|
93
|
+
*/
|
|
94
|
+
export const WORK_ITEM_MAX_ROWS = 40;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether a row is the plan card rather than agent activity.
|
|
98
|
+
*
|
|
99
|
+
* A `TodoWrite` row is a **card**, not a step: the controller lifts those rows into their
|
|
100
|
+
* own top-level item (the desktop projection's own tests list them as
|
|
101
|
+
* `['message', 'work_group', 'todo', 'message']`). Leaving one inside an activity run hides
|
|
102
|
+
* it behind an expansion — which is what happened as soon as the phone switched to this
|
|
103
|
+
* view: 「我没有看到你刚刚这个小任务的 to do」, because the plan rows only ever arrived
|
|
104
|
+
* inside collapsed segments.
|
|
105
|
+
*/
|
|
106
|
+
function isTodoRow(row) {
|
|
107
|
+
return row?.role === 'tool_use' && row?.content?.toolName === 'TodoWrite';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether a row is agent *activity* (folded into a work item) rather than prose or a card. */
|
|
111
|
+
function isActivityRow(row) {
|
|
112
|
+
if (isTodoRow(row)) return false;
|
|
113
|
+
return row?.role === 'thinking' || row?.role === 'tool_use' || row?.role === 'tool_result';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Group one transcript (in either order) into work items and readable rows.
|
|
118
|
+
*
|
|
119
|
+
* The split follows the renderer contract, not a turn's extent:
|
|
120
|
+
*
|
|
121
|
+
* - a **user prompt** and an **assistant answer** stay top-level `messages` items, because
|
|
122
|
+
* those are what a person reads — the desktop's projection keeps them visible too
|
|
123
|
+
* (`['message', 'work_group', 'todo', 'message']` in its own tests);
|
|
124
|
+
* - the **activity in between** (thinking, tool calls, tool results) collapses into `work`
|
|
125
|
+
* items, split every {@link WORK_ITEM_MAX_ROWS} rows so one expansion is one page.
|
|
126
|
+
*
|
|
127
|
+
* @param rows - the transcript rows, **newest first** (the reader's order).
|
|
128
|
+
* @param options - `running` says whether the session is in a turn right now; `null` (the
|
|
129
|
+
* default) derives it: a transcript that does not end in an answer is still being written.
|
|
130
|
+
* @returns items in **chronological** order, oldest first.
|
|
131
|
+
*/
|
|
132
|
+
export function groupHistoryItems(rows, { running = null, maxWorkRows = WORK_ITEM_MAX_ROWS } = {}) {
|
|
133
|
+
const chronological = [...rows].reverse();
|
|
134
|
+
const items = [];
|
|
135
|
+
let run = [];
|
|
136
|
+
|
|
137
|
+
/** Emit the buffered activity run as work items, newest one marked when it is live. */
|
|
138
|
+
const flushRun = (streaming) => {
|
|
139
|
+
while (run.length > 0) {
|
|
140
|
+
const chunk = maxWorkRows > 0 ? run.slice(0, maxWorkRows) : run;
|
|
141
|
+
run = run.slice(chunk.length);
|
|
142
|
+
items.push(workItem(chunk, streaming && run.length === 0));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
for (const row of chronological) {
|
|
147
|
+
if (isActivityRow(row)) {
|
|
148
|
+
run.push(row);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Prose ends the run before it, and stays readable itself.
|
|
152
|
+
flushRun(false);
|
|
153
|
+
items.push({ type: 'messages', key: keyOf(row), messages: [row] });
|
|
154
|
+
}
|
|
155
|
+
const derivedRunning = running === null ? chronological[chronological.length - 1]?.role !== 'assistant' : running === true;
|
|
156
|
+
flushRun(derivedRunning);
|
|
157
|
+
return items;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Rows of the live tail a streaming work item shows without being expanded. */
|
|
161
|
+
export const WORK_PREVIEW_ROWS = 5;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One work item for a run of activity rows.
|
|
165
|
+
*
|
|
166
|
+
* @param run - the run's rows, in log order.
|
|
167
|
+
* @param streaming - whether this run is the one still being written.
|
|
168
|
+
* @returns the item the controller renders.
|
|
169
|
+
*/
|
|
170
|
+
function workItem(run, streaming) {
|
|
171
|
+
const first = run[0];
|
|
172
|
+
const last = run[run.length - 1];
|
|
173
|
+
const summary = {
|
|
174
|
+
key: `work-${keyOf(first)}`,
|
|
175
|
+
anchorClientId: keyOf(first),
|
|
176
|
+
firstMessageId: first.id,
|
|
177
|
+
lastMessageId: last.id,
|
|
178
|
+
startedAtMs: Date.parse(first.createdAt) || 0,
|
|
179
|
+
endedAtMs: Date.parse(last.createdAt) || 0,
|
|
180
|
+
isStreaming: streaming,
|
|
181
|
+
messageCount: run.length,
|
|
182
|
+
toolCount: run.filter((row) => row.role === 'tool_use').length,
|
|
183
|
+
revision: `${last.id}:${run.length}:${revisionOf(run)}`,
|
|
184
|
+
};
|
|
185
|
+
// A running group gets a **preview**: the tail the controller can show inside the
|
|
186
|
+
// collapsed row, which is the difference between 「正在干活」 and 「卡住了」.
|
|
187
|
+
//
|
|
188
|
+
// This is the contract's own field (`HistoryWorkSummary.preview`, "Existing detail
|
|
189
|
+
// endpoint can read just the visible desktop tail") and the reference sets it for every
|
|
190
|
+
// streaming group. Without it, folding activity into work items meant a long turn showed
|
|
191
|
+
// nothing but a spinner: measured, the phone sat on 「一直在思考中转圈」 for minutes while
|
|
192
|
+
// this Host pushed 100+ rows that were all inside a collapsed group.
|
|
193
|
+
if (streaming && run.length > 0) {
|
|
194
|
+
const tail = run.slice(Math.max(0, run.length - WORK_PREVIEW_ROWS));
|
|
195
|
+
const previewFirst = tail[0];
|
|
196
|
+
const previewLast = tail[tail.length - 1];
|
|
197
|
+
summary.preview = {
|
|
198
|
+
key: `preview-${summary.key}`,
|
|
199
|
+
anchorClientId: keyOf(previewFirst),
|
|
200
|
+
firstMessageId: previewFirst.id,
|
|
201
|
+
lastMessageId: previewLast.id,
|
|
202
|
+
startedAtMs: Date.parse(previewFirst.createdAt) || 0,
|
|
203
|
+
endedAtMs: Date.parse(previewLast.createdAt) || 0,
|
|
204
|
+
isStreaming: true,
|
|
205
|
+
messageCount: tail.length,
|
|
206
|
+
toolCount: tail.filter((row) => row.role === 'tool_use').length,
|
|
207
|
+
revision: `${previewLast.id}:${tail.length}:${revisionOf(tail)}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { type: 'work', key: summary.key, summary };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** UTF-8 size of one item, which is what the page budgets count. */
|
|
214
|
+
function itemBytes(item) {
|
|
215
|
+
try {
|
|
216
|
+
return Buffer.byteLength(JSON.stringify(item), 'utf8');
|
|
217
|
+
} catch {
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build the reader behind the three view channels.
|
|
224
|
+
*
|
|
225
|
+
* @param options - `rows` (the transcript reader's whole-transcript accessor),
|
|
226
|
+
* `sessionRunning` (whether the session is in a turn right now, for `isStreaming`),
|
|
227
|
+
* and the page/scan limits.
|
|
228
|
+
* @returns `{ page, details, intent, expandedFor }`.
|
|
229
|
+
*/
|
|
230
|
+
export function createHistoryViewController({
|
|
231
|
+
rows,
|
|
232
|
+
sessionRunning = () => false,
|
|
233
|
+
pageItems = HISTORY_PAGE_ITEMS,
|
|
234
|
+
pageBytes = HISTORY_PAGE_BYTES,
|
|
235
|
+
detailBytes = HISTORY_DETAIL_PAGE_BYTES,
|
|
236
|
+
hydrate,
|
|
237
|
+
} = {}) {
|
|
238
|
+
/**
|
|
239
|
+
* Turn the served rows' image handles into bytes, in place.
|
|
240
|
+
*
|
|
241
|
+
* A view page is a **second** read path, and the reason this hook exists: image hydration was
|
|
242
|
+
* wired into `local-db:messages:list` alone, so when the controller moved onto this view
|
|
243
|
+
* (`history-view-v1`) every user photo silently went back to a path-less file chip — the
|
|
244
|
+
* 「发送的照片看不到」 regression, on a path nothing hydrated. Anything that serves rows to the
|
|
245
|
+
* phone has to run this.
|
|
246
|
+
*
|
|
247
|
+
* Called **after** the page's items are chosen, so only the rows being served pay for reading
|
|
248
|
+
* image bytes, and the page's own byte budget still counts text only (the pictures carry their
|
|
249
|
+
* own budget inside the hydrator).
|
|
250
|
+
*
|
|
251
|
+
* @param servedRows - the rows of the page about to be returned.
|
|
252
|
+
*/
|
|
253
|
+
async function hydrateServed(servedRows) {
|
|
254
|
+
if (typeof hydrate !== 'function' || servedRows.length === 0) return;
|
|
255
|
+
await hydrate(servedRows);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `sessionId` → the work keys the controller currently has expanded. */
|
|
259
|
+
const expanded = new Map();
|
|
260
|
+
|
|
261
|
+
/** The transcript for one session, or a refusal the controller understands. */
|
|
262
|
+
async function transcriptFor(sessionId) {
|
|
263
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
264
|
+
return { ok: false, code: 'BAD_REQUEST', message: 'history view needs a session' };
|
|
265
|
+
}
|
|
266
|
+
if (typeof rows !== 'function') {
|
|
267
|
+
return { ok: false, code: 'NOT_AVAILABLE', message: 'this DSH Host cannot project session history' };
|
|
268
|
+
}
|
|
269
|
+
let all;
|
|
270
|
+
try {
|
|
271
|
+
all = await rows(sessionId);
|
|
272
|
+
} catch {
|
|
273
|
+
return { ok: false, code: 'NOT_FOUND', message: 'this session has no readable transcript' };
|
|
274
|
+
}
|
|
275
|
+
if (!Array.isArray(all)) return { ok: false, code: 'INTERNAL', message: 'the transcript could not be read' };
|
|
276
|
+
// Long transcripts are **served**, not refused.
|
|
277
|
+
//
|
|
278
|
+
// This was `all.length > scanMaxRows → UNSUPPORTED_CAPABILITY`, on the reasoning that the
|
|
279
|
+
// scan would time out on the phone and the controller recognises that code as "no view
|
|
280
|
+
// here". Both halves are wrong, and the cost is permanent:
|
|
281
|
+
//
|
|
282
|
+
// - it saves no scan. `rows` is the reader's transcript (`readMessages.all`, i.e.
|
|
283
|
+
// `ensureTranscript`), so the full read has **already happened and been cached** before
|
|
284
|
+
// this function is entered; `page()` below only windows that array in O(page).
|
|
285
|
+
// - it does not degrade, it kills. The controller's `historyViewController.refresh()`
|
|
286
|
+
// returns immediately **forever** once its error matches `UNSUPPORTED_CAPABILITY`
|
|
287
|
+
// (`packages/maker-shared/src/historyViewController.ts:95`), and only `reset()` clears
|
|
288
|
+
// that error — while re-entry does not reliably reset, and the row count is a permanent
|
|
289
|
+
// property of the session, so a long session's view is dead for the life of the screen.
|
|
290
|
+
// `maker:history-view-changed` is the controller's only refresh trigger, so every push
|
|
291
|
+
// we send lands on that dead path.
|
|
292
|
+
//
|
|
293
|
+
// `UNSUPPORTED_CAPABILITY` and `CHANNEL_NOT_ALLOWED` are therefore reserved for "this Host
|
|
294
|
+
// has no projection capability at all" — a deployment fact — and never for a property of
|
|
295
|
+
// one session. See ADR-0007's decision evolution.
|
|
296
|
+
return { ok: true, rows: all };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One page of work items, newest window by default.
|
|
301
|
+
* @param sessionId - the session.
|
|
302
|
+
* @param before - a cursor from a previous page: the oldest id that page returned.
|
|
303
|
+
*/
|
|
304
|
+
async function page(sessionId, before = undefined) {
|
|
305
|
+
const read = await transcriptFor(sessionId);
|
|
306
|
+
if (read.ok !== true) return read;
|
|
307
|
+
const items = groupHistoryItems(read.rows, {
|
|
308
|
+
running: typeof sessionRunning === 'function' ? sessionRunning(sessionId) === true : null,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// `before` names a row inside the older part of the transcript: everything at or after
|
|
312
|
+
// it has already been sent, so the next page starts strictly older than that item.
|
|
313
|
+
let end = items.length;
|
|
314
|
+
if (typeof before === 'string' && before !== '') {
|
|
315
|
+
const index = items.findIndex((item) => firstIdOf(item) === before
|
|
316
|
+
|| (item.type === 'work' && item.summary.lastMessageId === before));
|
|
317
|
+
if (index >= 0) end = index;
|
|
318
|
+
else {
|
|
319
|
+
// A cursor this Host cannot place: answer the oldest page we have rather than the
|
|
320
|
+
// newest one again, which is what re-serving the same rows would amount to.
|
|
321
|
+
end = Math.min(items.length, pageItems);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const selected = [];
|
|
325
|
+
let bytes = 1024;
|
|
326
|
+
for (let index = end - 1; index >= 0; index -= 1) {
|
|
327
|
+
const size = itemBytes(items[index]);
|
|
328
|
+
if (selected.length > 0 && (selected.length >= pageItems || bytes + size > pageBytes)) break;
|
|
329
|
+
selected.unshift(items[index]);
|
|
330
|
+
bytes += size;
|
|
331
|
+
}
|
|
332
|
+
const hasMore = selected.length > 0 && end - selected.length > 0;
|
|
333
|
+
await hydrateServed(selected.flatMap((item) => (item.type === 'messages' ? item.messages ?? [] : [])));
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
result: {
|
|
337
|
+
version: HISTORY_VIEW_VERSION,
|
|
338
|
+
items: selected,
|
|
339
|
+
hasMore,
|
|
340
|
+
nextCursor: hasMore && selected.length > 0 ? firstIdOf(selected[0]) : null,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The rows of one work range, one page at a time.
|
|
347
|
+
* @param sessionId - the session.
|
|
348
|
+
* @param ref - the controller's `HistoryWorkReference` (its `firstMessageId`/`lastMessageId`).
|
|
349
|
+
* @param after - the last row id already sent, for the next page.
|
|
350
|
+
*/
|
|
351
|
+
async function details(sessionId, ref, after = undefined) {
|
|
352
|
+
const read = await transcriptFor(sessionId);
|
|
353
|
+
if (read.ok !== true) return read;
|
|
354
|
+
const firstId = typeof ref?.firstMessageId === 'string' ? ref.firstMessageId : '';
|
|
355
|
+
const lastId = typeof ref?.lastMessageId === 'string' ? ref.lastMessageId : '';
|
|
356
|
+
if (firstId === '' || lastId === '') return { ok: false, code: 'BAD_REQUEST', message: 'a work range needs both endpoints' };
|
|
357
|
+
// The transcript is newest first; the range is inclusive in log order.
|
|
358
|
+
const chronological = [...read.rows].reverse();
|
|
359
|
+
const from = chronological.findIndex((row) => row.id === firstId);
|
|
360
|
+
const to = chronological.findIndex((row) => row.id === lastId);
|
|
361
|
+
if (from < 0 || to < 0 || to < from) return { ok: false, code: 'NOT_FOUND', message: 'this work range is no longer in the transcript' };
|
|
362
|
+
const range = chronological.slice(from, to + 1);
|
|
363
|
+
let start = 0;
|
|
364
|
+
if (typeof after === 'string' && after !== '') {
|
|
365
|
+
const index = range.findIndex((row) => row.id === after);
|
|
366
|
+
if (index < 0) return { ok: false, code: 'BAD_REQUEST', message: 'the detail cursor is outside this work range' };
|
|
367
|
+
start = index + 1;
|
|
368
|
+
}
|
|
369
|
+
const messages = [];
|
|
370
|
+
let bytes = 1024;
|
|
371
|
+
for (let index = start; index < range.length; index += 1) {
|
|
372
|
+
const row = range[index];
|
|
373
|
+
const size = itemBytes(row);
|
|
374
|
+
if (messages.length > 0 && bytes + size > detailBytes) break;
|
|
375
|
+
messages.push(row);
|
|
376
|
+
bytes += size;
|
|
377
|
+
}
|
|
378
|
+
const lastSent = messages[messages.length - 1];
|
|
379
|
+
const hasMore = messages.length > 0 && start + messages.length < range.length;
|
|
380
|
+
await hydrateServed(messages);
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
result: {
|
|
384
|
+
version: HISTORY_VIEW_VERSION,
|
|
385
|
+
messages,
|
|
386
|
+
hasMore,
|
|
387
|
+
nextCursor: hasMore && lastSent !== undefined ? lastSent.id : null,
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Replace the controller's expanded work set.
|
|
394
|
+
* @param sessionId - the session.
|
|
395
|
+
* @param refs - the expanded summaries, as the controller sends them.
|
|
396
|
+
*/
|
|
397
|
+
async function intent(sessionId, refs) {
|
|
398
|
+
if (typeof sessionId !== 'string' || sessionId === '') return { ok: false, code: 'BAD_REQUEST', message: 'history intent needs a session' };
|
|
399
|
+
if (!Array.isArray(refs) || refs.length > 100) return { ok: false, code: 'BAD_REQUEST', message: 'invalid expanded work groups' };
|
|
400
|
+
const keys = [];
|
|
401
|
+
for (const ref of refs) {
|
|
402
|
+
const key = typeof ref?.key === 'string' ? ref.key.replace(/^preview-/, '') : '';
|
|
403
|
+
if (key === '') return { ok: false, code: 'BAD_REQUEST', message: 'an expanded work group needs a key' };
|
|
404
|
+
keys.push(key);
|
|
405
|
+
}
|
|
406
|
+
if (keys.length === 0) expanded.delete(sessionId);
|
|
407
|
+
else expanded.set(sessionId, new Set(keys));
|
|
408
|
+
return { ok: true, result: true };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** What the controller has expanded for one session (advisory; used by the status route). */
|
|
412
|
+
function expandedFor(sessionId) {
|
|
413
|
+
return [...(expanded.get(sessionId) ?? new Set())];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { page, details, intent, expandedFor };
|
|
417
|
+
}
|