dsh-cindy-host 0.1.36 → 0.1.37
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 +6 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cindy-message-row.js +8 -2
- package/src/dsh-input-delivery.js +9 -2
- package/src/dsh-message-fold.js +26 -39
- package/src/dsh-plugin.js +8 -4
- package/src/history-delivery-state.js +32 -0
- package/src/history-snapshot.js +58 -0
- package/src/host-history-view-limits.js +5 -6
- package/src/host-history-view.js +39 -32
- package/src/host-version.js +1 -1
- package/src/host.js +42 -4
- package/src/reasoning-publisher.js +44 -17
- package/src/reasoning-stream.js +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.37 — 2026-11-06
|
|
4
|
+
|
|
5
|
+
- Use explicit source-order history snapshots for both validated persistence and the hot fallback reader. Preserve legacy raw order, reconcile live reasoning through native history-live client identities, and page by source cuts; unknown cursors now fail explicitly rather than silently skipping history.
|
|
6
|
+
- Fold raw thinking pushes only for the subscribed controller that successfully received a current history page. Reset this disposable state on read/send failure and lifecycle changes, preserve pending-read trailing refreshes, and fence replies against stale relay generations.
|
|
7
|
+
- Coalesce interim reasoning at 1s with latest-wins deduplication and serialized UTF-8 budgets (32 KiB/s, 64 KiB burst/snapshot). Bound automatic preview ranges to 5 rows/6 KiB without truncating full details or delaying terminal retirement. These are Host-side safeguards, not handset white-screen acceptance or proof that every device's failure is resolved.
|
|
8
|
+
|
|
3
9
|
## 0.1.36 — 2026-11-06
|
|
4
10
|
|
|
5
11
|
- Project proven native interruption identities and timestamp tuples consistently into session list/get, boundaries and reconnect updates. Clear non-pending/hidden/running presentation without fabricating transcript-clear or completion facts.
|
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
|
|
|
4
4
|
|
|
5
5
|
## Requirements and installation
|
|
6
6
|
|
|
7
|
-
Version **0.1.
|
|
7
|
+
Version **0.1.37** stabilizes source-order history pagination, per-controller history delivery state and bounded interim reasoning updates. It retains 0.1.36's interruption controls and the earlier diagnostics, question synchronization, authentication and native capability subsets. Host regressions do not establish that handset white-screen issues are resolved.
|
|
8
8
|
|
|
9
9
|
It supports the exact **DSH 0.2.2-alpha.2** carrier, while retaining `0.2.1-rc.3`, `0.2.1-rc.4` and `0.2.1-rc.5`. Alpha requires **Node.js >=24.9** (tested with 24.12); the older carriers retain this plugin's Node.js >=22.16 minimum. Agent/session/settings peers and alpha Schemastery are explicitly allowed, not a blanket promise for future prereleases. The full Web installation gate uses DSH **0.2.1-rc.5**; alpha core-contract checks do not establish alpha Web/relay/handset acceptance. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
dsh plugin --profile <profile> add dsh-cindy-host@0.1.
|
|
12
|
+
dsh plugin --profile <profile> add dsh-cindy-host@0.1.37
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
|
package/package.json
CHANGED
package/src/cindy-message-row.js
CHANGED
|
@@ -286,7 +286,7 @@ const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
|
|
|
286
286
|
* user turn — the `clientId` the controller minted for the prompt.
|
|
287
287
|
* @returns one row per renderable content block, in order.
|
|
288
288
|
*/
|
|
289
|
-
export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
|
|
289
|
+
export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null, reasoningIdentity = null }) {
|
|
290
290
|
const rows = [];
|
|
291
291
|
// Canonical DSH results use a tool-role message with root outcome/call ID,
|
|
292
292
|
// not the legacy user-role nested tool-result block. Keep both formats.
|
|
@@ -372,7 +372,12 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
372
372
|
if (typeof block.text !== 'string' || block.text === '') return;
|
|
373
373
|
// Thinking is a structured native role on both Cindy clients. Unlike
|
|
374
374
|
// ordinary prose, a string here is discarded by mobile normalizeThinking.
|
|
375
|
-
|
|
375
|
+
// Native history-live refs resolve by clientId after the durable id replaces the live id.
|
|
376
|
+
const identity = reasoningIdentity;
|
|
377
|
+
const logical = Number.isSafeInteger(identity?.turn) && identity.turn >= 0
|
|
378
|
+
&& Number.isSafeInteger(identity?.step) && identity.step >= 0
|
|
379
|
+
? `${sessionId}:reasoning-stream:${identity.turn}:${identity.step}:${index}` : clientId;
|
|
380
|
+
rows.push({ ...base, clientId: logical, content: { text: block.text } });
|
|
376
381
|
return;
|
|
377
382
|
case 'tool-call':
|
|
378
383
|
rows.push({
|
|
@@ -437,6 +442,7 @@ export function toCindyMessages(messages, { sessionId, now = () => new Date() })
|
|
|
437
442
|
createdAt: entry.createdAt,
|
|
438
443
|
now,
|
|
439
444
|
promptClientId: entry.promptClientId ?? null,
|
|
445
|
+
reasoningIdentity: entry.reasoningIdentity ?? null,
|
|
440
446
|
}));
|
|
441
447
|
const rows = [];
|
|
442
448
|
for (const group of perMessage.reverse()) rows.push(...group);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createAttachmentMaterializer, hostAttachmentsOf } from './host-attachments.js';
|
|
2
|
-
import {
|
|
2
|
+
import { historySnapshotFromEvents } from './dsh-message-fold.js';
|
|
3
3
|
import { interruptedTurnClosers } from '@deepseek-ai/dsh-session';
|
|
4
4
|
import { turnStateFromEvents } from './turn-error.js';
|
|
5
5
|
import { continuationKind } from './continuation-protocol.js';
|
|
@@ -57,7 +57,14 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
|
|
|
57
57
|
// session or agent must never be diagnosed as a crashed writer.
|
|
58
58
|
const cold=!s.sessions.get(sid)&&typeof s.agents?.get==='function'&&!s.agents.get(sid);
|
|
59
59
|
const events=cold?[...persisted,...interruptedTurnClosers(persisted)]:persisted;
|
|
60
|
-
|
|
60
|
+
// Preserve the raw newest-first order/rowid contract while including the same
|
|
61
|
+
// public live overlay as history. Receipt evidence still reads the native log only.
|
|
62
|
+
return historySnapshotFromEvents(events, sid, { rowids: true }).rows.reverse();},
|
|
63
|
+
async historySnapshot(sid) {
|
|
64
|
+
const persisted = await readLog(sid); const s = services();
|
|
65
|
+
const cold = !s.sessions.get(sid) && typeof s.agents?.get === 'function' && !s.agents.get(sid);
|
|
66
|
+
return historySnapshotFromEvents(cold ? [...persisted, ...interruptedTurnClosers(persisted)] : persisted, sid);
|
|
67
|
+
},
|
|
61
68
|
async evidence(sid){const events=await readLog(sid);const proof=durableInputEvidence(events,sid);const agent=getServices().agents?.get(sid);return {...proof,running:agent?.status==='running',live:agent!==undefined};},
|
|
62
69
|
async prepare(sid,row,{resolveAttachmentRef,mode='queue'}={}){
|
|
63
70
|
assertNoStructuredSessionReferences(row);
|
package/src/dsh-message-fold.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* history, and model requests all share, so a second copy here would drift.
|
|
17
17
|
*/
|
|
18
18
|
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
|
|
19
|
-
import {
|
|
19
|
+
import { createHistoryProjection } from './history-snapshot.js';
|
|
20
20
|
import { turnDiagnosticRow, turnStateFromEvents } from './turn-error.js';
|
|
21
21
|
import { advanceInterruptionMetadata } from './diagnostic-presentation.js';
|
|
22
22
|
import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
|
|
@@ -107,7 +107,8 @@ export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
|
|
|
107
107
|
const createdAt = Number.isFinite(event.time) ? new Date(event.time).toISOString() : now().toISOString();
|
|
108
108
|
// The controller's own id for a submitted prompt travels with the row so it
|
|
109
109
|
// can retire the copy it showed optimistically.
|
|
110
|
-
return toCindyMessageRows(message, { sessionId, createdAt, now, promptClientId: promptRpcIdOf(event)
|
|
110
|
+
return toCindyMessageRows(message, { sessionId, createdAt, now, promptClientId: promptRpcIdOf(event),
|
|
111
|
+
reasoningIdentity: event.type === 'assistant/message' ? event.data : null });
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** The `source` a message carries, whichever shape the surface handed over. */
|
|
@@ -291,6 +292,18 @@ function widenNewestPage(options) {
|
|
|
291
292
|
return { ...options, limit: NEWEST_WINDOW_ROWS };
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
/** Shared public display fold; never changes input delivery evidence. */
|
|
296
|
+
export function displayEventRows(event, context) {
|
|
297
|
+
const todo = entryForTodoWrite(event);
|
|
298
|
+
return todo ? toCindyMessageRows(todo.message, { ...context, createdAt: todo.createdAt }) : foldSessionEvent(event, context);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function historySnapshotFromEvents(events, sessionId, options = {}) {
|
|
302
|
+
const projection = createHistoryProjection(sessionId);
|
|
303
|
+
for (const event of events) projection.consume(event, displayEventRows(event, { sessionId }));
|
|
304
|
+
return projection.snapshot(options);
|
|
305
|
+
}
|
|
306
|
+
|
|
294
307
|
/**
|
|
295
308
|
* Render one DSH `todo/write` snapshot as the row the phone draws its plan card from.
|
|
296
309
|
*
|
|
@@ -377,53 +390,25 @@ export function createMessageReader({
|
|
|
377
390
|
const seeding = new Map();
|
|
378
391
|
let clock = 0;
|
|
379
392
|
|
|
380
|
-
/**
|
|
393
|
+
/** One fold feeds both the legacy raw order and the explicit history snapshot. */
|
|
381
394
|
async function entriesFor(sessionId) {
|
|
382
395
|
const snapshot = await readSessionLog(sessionId);
|
|
383
396
|
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
384
397
|
const entries = [];
|
|
385
|
-
const
|
|
398
|
+
const history = createHistoryProjection(sessionId);
|
|
386
399
|
let lastSeq = null;
|
|
387
400
|
let nativeMetadata = null;
|
|
388
401
|
let nativeValid = true;
|
|
389
402
|
for (const event of events) {
|
|
390
403
|
if (event === null || typeof event !== 'object') continue;
|
|
391
|
-
// Every event's seq counts, not only the ones that render: contiguity is a
|
|
392
|
-
// property of the log, and a caller comparing against renders would read a
|
|
393
|
-
// gap as "nothing happened here".
|
|
394
404
|
if (!Number.isSafeInteger(event.seq) || event.seq < 0 || (lastSeq !== null && event.seq !== lastSeq + 1)) nativeValid = false;
|
|
395
405
|
if (Number.isFinite(event.seq)) lastSeq = event.seq;
|
|
396
406
|
nativeMetadata = advanceInterruptionMetadata(nativeMetadata, event, sessionId);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
entries.push({ rows: [diagnostic] });
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
const todo = entryForTodoWrite(event);
|
|
404
|
-
if (todo !== null) {
|
|
405
|
-
entries.push(todo);
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
if (!isAppendSurfaceEvent(event)) continue;
|
|
409
|
-
let message;
|
|
410
|
-
try {
|
|
411
|
-
message = deriveEventMessage(event);
|
|
412
|
-
} catch {
|
|
413
|
-
// One unprojectable event must not cost the whole transcript; the same
|
|
414
|
-
// rule the surface fold applies per node.
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
if (message === null || message === undefined) continue;
|
|
418
|
-
entries.push({
|
|
419
|
-
message,
|
|
420
|
-
createdAt: Number.isFinite(event.time) ? new Date(event.time).toISOString() : now().toISOString(),
|
|
421
|
-
// Same reason as the live fold: the durable read has to identify a user
|
|
422
|
-
// turn by the id the controller minted, or its optimistic copy survives.
|
|
423
|
-
promptClientId: promptRpcIdOf(event),
|
|
424
|
-
});
|
|
407
|
+
const rows = displayEventRows(event, { sessionId, now });
|
|
408
|
+
history.consume(event, rows);
|
|
409
|
+
if (rows.length) entries.push({ rows });
|
|
425
410
|
}
|
|
426
|
-
return { entries, lastSeq, reasoning, nativeValid, nativeMetadata: nativeValid ? nativeMetadata : null };
|
|
411
|
+
return { entries, lastSeq, history, reasoning: history.reasoning, nativeValid, nativeMetadata: nativeValid ? nativeMetadata : null };
|
|
427
412
|
}
|
|
428
413
|
|
|
429
414
|
/** Drop the least recently used transcripts, keeping the window bounded. */
|
|
@@ -503,9 +488,9 @@ export function createMessageReader({
|
|
|
503
488
|
// invalidation also detaches this flight so a post-invalidation read starts
|
|
504
489
|
// fresh; an older completion must never overwrite that newer generation.
|
|
505
490
|
if (seeding.get(sessionId) === flight && !flight.dirty) {
|
|
506
|
-
touch(sessionId, { rows, reasoning: read.reasoning, seq: read.lastSeq, at: clock, nativeValid: read.nativeValid, nativeMetadata: read.nativeMetadata });
|
|
491
|
+
touch(sessionId, { rows, history: read.history, reasoning: read.reasoning, seq: read.lastSeq, at: clock, nativeValid: read.nativeValid, nativeMetadata: read.nativeMetadata });
|
|
507
492
|
}
|
|
508
|
-
return transcripts.get(sessionId) ?? { rows, reasoning: read.reasoning, nativeMetadata: read.nativeMetadata };
|
|
493
|
+
return transcripts.get(sessionId) ?? { rows, history: read.history, reasoning: read.reasoning, nativeMetadata: read.nativeMetadata };
|
|
509
494
|
} finally {
|
|
510
495
|
// Failure is not an authoritative empty transcript. Release this flight
|
|
511
496
|
// on success or rejection, without deleting a newer read's ownership.
|
|
@@ -560,7 +545,7 @@ export function createMessageReader({
|
|
|
560
545
|
if (entry.nativeValid !== false) entry.nativeMetadata = advanceInterruptionMetadata(entry.nativeMetadata, event, sessionId);
|
|
561
546
|
entry.seq = seq;
|
|
562
547
|
entry.at = ++clock;
|
|
563
|
-
|
|
548
|
+
// The history projection owns the raw overlay reducer too.
|
|
564
549
|
// A live todo snapshot becomes the same `TodoWrite` row the durable read produces, so
|
|
565
550
|
// the phone's plan card updates while the plan is being worked rather than only when
|
|
566
551
|
// the transcript is re-read.
|
|
@@ -568,6 +553,7 @@ export function createMessageReader({
|
|
|
568
553
|
const rows = todoEntry === null
|
|
569
554
|
? foldSessionEvent(event, { sessionId, now })
|
|
570
555
|
: toCindyMessageRows(todoEntry.message, { sessionId, createdAt: todoEntry.createdAt, now });
|
|
556
|
+
entry.history.consume(event, rows);
|
|
571
557
|
// Newest first: this event is the newest thing in the log, so its rows are the
|
|
572
558
|
// new head of the array, in block order.
|
|
573
559
|
if (rows.length > 0) entry.rows = [...rows, ...entry.rows];
|
|
@@ -615,6 +601,7 @@ export function createMessageReader({
|
|
|
615
601
|
* The array is the cache's own, so callers treat it as read-only.
|
|
616
602
|
*/
|
|
617
603
|
readMessages.all = async (sessionId) => visibleRows(await ensureTranscript(sessionId));
|
|
604
|
+
readMessages.historySnapshot = async (sessionId) => (await ensureTranscript(sessionId)).history.snapshot();
|
|
618
605
|
// Recovery deliberately bypasses the transcript cache: stale history cannot
|
|
619
606
|
// decide whether the *current* turn has ended. Runtime generation guards the read.
|
|
620
607
|
readMessages.turnState = async (sessionId) => {
|
package/src/dsh-plugin.js
CHANGED
|
@@ -687,9 +687,9 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
687
687
|
* The work-grouped history window the controller prefers once it has seen
|
|
688
688
|
* `history-view-v1`.
|
|
689
689
|
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
690
|
+
* History consumes an explicit source-order snapshot, not the raw window's legacy
|
|
691
|
+
* array order. Prefer the flushed/validated durable backend when available; the
|
|
692
|
+
* fallback reader shares its cached fold and reasoning reducer with its raw window.
|
|
693
693
|
*/
|
|
694
694
|
const deliveryServices = { sessions: undefined, persistence: undefined, agents: undefined };
|
|
695
695
|
ctx.inject(['sessions', 'sessionPersistence', 'agents'], deliveryCtx => {
|
|
@@ -709,7 +709,11 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
709
709
|
const historyView = readMessages === undefined
|
|
710
710
|
? undefined
|
|
711
711
|
: createHistoryViewController({
|
|
712
|
-
|
|
712
|
+
snapshot: async (sessionId) => {
|
|
713
|
+
const snapshot = await (inputDeliveryBackend.available()
|
|
714
|
+
? inputDeliveryBackend.historySnapshot(sessionId) : readMessages.historySnapshot(sessionId));
|
|
715
|
+
return { ...snapshot, rows: options.projectMessages?.(snapshot.rows) ?? snapshot.rows };
|
|
716
|
+
},
|
|
713
717
|
// The turn state has to be **wired** here; the controller's own default is `() => false`.
|
|
714
718
|
// That default is why a running turn was never marked `isStreaming`, which cost two things
|
|
715
719
|
// at once and looked like a client problem both times: the phone drew no live card, and
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Disposable transport state, scoped to an accepted controller and its subscription lease. */
|
|
2
|
+
export function createHistoryDeliveryState(canWatch) {
|
|
3
|
+
const controllers = new Map();
|
|
4
|
+
function forget(deviceId, sessionId) {
|
|
5
|
+
if (sessionId === undefined) controllers.delete(deviceId);
|
|
6
|
+
else { const sessions = controllers.get(deviceId); sessions?.delete(sessionId); if (!sessions?.size) controllers.delete(deviceId); }
|
|
7
|
+
}
|
|
8
|
+
function ready(deviceId, sessionId) {
|
|
9
|
+
if (!canWatch(deviceId, sessionId)) { forget(deviceId, sessionId); return false; }
|
|
10
|
+
return controllers.get(deviceId)?.get(sessionId)?.ready === true;
|
|
11
|
+
}
|
|
12
|
+
function begin(frame) {
|
|
13
|
+
if (frame.payload?.channel !== 'local-db:messages:view') return null;
|
|
14
|
+
const sessionId = frame.payload.args?.[0];
|
|
15
|
+
if (typeof sessionId !== 'string' || !canWatch(frame.src, sessionId)) return null;
|
|
16
|
+
const sessions = controllers.get(frame.src) ?? new Map();
|
|
17
|
+
const latest = !(typeof frame.payload.args?.[1]?.before === 'string' && frame.payload.args[1].before !== '');
|
|
18
|
+
const token = { deviceId: frame.src, sessionId, latest, dirty: false,
|
|
19
|
+
ready: !latest && sessions.get(sessionId)?.ready === true };
|
|
20
|
+
sessions.set(sessionId, token); controllers.set(frame.src, sessions);
|
|
21
|
+
return token;
|
|
22
|
+
}
|
|
23
|
+
function finish(token, success) {
|
|
24
|
+
if (!token || controllers.get(token.deviceId)?.get(token.sessionId) !== token) return false;
|
|
25
|
+
if (!success || !canWatch(token.deviceId, token.sessionId)) { forget(token.deviceId, token.sessionId); return false; }
|
|
26
|
+
if (token.latest) token.ready = true;
|
|
27
|
+
const dirty = token.dirty; token.dirty = false;
|
|
28
|
+
return dirty;
|
|
29
|
+
}
|
|
30
|
+
function changed(sessionId) { for (const sessions of controllers.values()) { const state = sessions.get(sessionId); if (state) state.dirty = true; } }
|
|
31
|
+
return { ready, begin, finish, changed, forget, clear: () => controllers.clear() };
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createReasoningStream } from './reasoning-stream.js';
|
|
2
|
+
|
|
3
|
+
/** Internal source-order projection. Cut metadata/retired bodies stay local; raw rowid is an existing wire field. */
|
|
4
|
+
export function createHistoryProjection(sessionId) {
|
|
5
|
+
const reasoning = createReasoningStream(sessionId);
|
|
6
|
+
const stored = new Map();
|
|
7
|
+
const retired = new Map();
|
|
8
|
+
let ordinal = 0;
|
|
9
|
+
let liveBase = 0;
|
|
10
|
+
let scope = null;
|
|
11
|
+
let live = [];
|
|
12
|
+
const compare = (a, b) => a[0] - b[0] || a[1] - b[1];
|
|
13
|
+
|
|
14
|
+
function consume(event, rows = []) {
|
|
15
|
+
const position = ordinal++;
|
|
16
|
+
const before = live;
|
|
17
|
+
reasoning.consume(event);
|
|
18
|
+
const entries = reasoning.entries();
|
|
19
|
+
const nextScope = entries[0]?.scope ?? null;
|
|
20
|
+
if (entries.length && scope !== nextScope) liveBase = position;
|
|
21
|
+
scope = nextScope;
|
|
22
|
+
live = entries.map(({ row, index }) => ({ row, position: [liveBase, index] }));
|
|
23
|
+
const current = new Set(live.map(entry => entry.row.clientId));
|
|
24
|
+
for (const entry of before) {
|
|
25
|
+
if (!current.has(entry.row.clientId)) retired.set(entry.row.clientId, entry.position);
|
|
26
|
+
}
|
|
27
|
+
rows.forEach((row, index) => stored.set(row.id, { row, position: [position, index] }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function snapshot({ rowids = false } = {}) {
|
|
31
|
+
const entries = [...stored.values(), ...live].sort((a, b) => compare(a.position, b.position));
|
|
32
|
+
const rows = entries.map(entry => {
|
|
33
|
+
if (!rowids) return entry.row;
|
|
34
|
+
const rowid = entry.position[0] * 65536 + entry.position[1];
|
|
35
|
+
if (!Number.isSafeInteger(rowid) || entry.position[1] >= 65536) throw Object.assign(new Error('Message order exceeds the safe protocol range'), { code: 'DELIVERY_EVIDENCE_CORRUPT' });
|
|
36
|
+
return { ...entry.row, rowid };
|
|
37
|
+
});
|
|
38
|
+
const ids = new Map();
|
|
39
|
+
rows.forEach((row, index) => {
|
|
40
|
+
ids.set(row.id, index);
|
|
41
|
+
if (row.role === 'thinking') {
|
|
42
|
+
ids.set(`history-live:${row.clientId}`, index);
|
|
43
|
+
// Pre-upgrade reasoning-stream IDs are the logical clientId itself.
|
|
44
|
+
ids.set(row.clientId, index);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const retiredCuts = new Map(retired);
|
|
48
|
+
return { rows, resolve(id, { cut = false } = {}) {
|
|
49
|
+
if (ids.has(id)) return ids.get(id);
|
|
50
|
+
const key = typeof id === 'string' && id.startsWith('history-live:') ? id.slice(13) : id;
|
|
51
|
+
const position = cut ? retiredCuts.get(key) : undefined;
|
|
52
|
+
if (!position) return -1;
|
|
53
|
+
const index = entries.findIndex(entry => compare(entry.position, position) >= 0);
|
|
54
|
+
return index < 0 ? rows.length : index;
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
return { consume, snapshot, reasoning };
|
|
58
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Conservative Host page budgets within the Cindy v1 contract.
|
|
3
3
|
*
|
|
4
4
|
* Stated here rather than imported because this Host composes the protocol over the wire,
|
|
5
|
-
* not the package
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* truncates.
|
|
5
|
+
* not the package. The current Cindy v1 view ceiling is 1 MiB, with 20 items and
|
|
6
|
+
* a 256 KiB detail page. This Host deliberately keeps its view budget at 256 KiB;
|
|
7
|
+
* a smaller service budget is compatible and avoids increasing mobile load. These
|
|
8
|
+
* values are Host budgets, not an assertion that all current upstream limits match.
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
/** Items one `local-db:messages:view` page may carry. */
|
package/src/host-history-view.js
CHANGED
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
* - `intent` replaces the set of expanded work keys wholesale; it is advisory.
|
|
30
30
|
* - An error whose text matches `isHistoryViewUnavailable` sends the controller back to
|
|
31
31
|
* the raw window. `UNSUPPORTED_CAPABILITY` is therefore how this Host *deliberately*
|
|
32
|
-
* declines
|
|
33
|
-
*
|
|
32
|
+
* declines an unavailable projection capability, never a large or stale session.
|
|
33
|
+
* Per-session missing references use retryable NOT_FOUND/BAD_REQUEST instead.
|
|
34
34
|
*
|
|
35
35
|
* Grouping here is by **DSH turn boundary as the controller can see it**: a row whose role
|
|
36
36
|
* is `user` starts a work group, and everything up to the next one belongs to it. The
|
|
@@ -129,8 +129,8 @@ function isActivityRow(row) {
|
|
|
129
129
|
* default) derives it: a transcript that does not end in an answer is still being written.
|
|
130
130
|
* @returns items in **chronological** order, oldest first.
|
|
131
131
|
*/
|
|
132
|
-
export function groupHistoryItems(rows, { running = null, maxWorkRows = WORK_ITEM_MAX_ROWS } = {}) {
|
|
133
|
-
const chronological = [...rows].reverse();
|
|
132
|
+
export function groupHistoryItems(rows, { running = null, maxWorkRows = WORK_ITEM_MAX_ROWS, chronological: inSourceOrder = false } = {}) {
|
|
133
|
+
const chronological = inSourceOrder ? rows : [...rows].reverse();
|
|
134
134
|
const items = [];
|
|
135
135
|
let run = [];
|
|
136
136
|
|
|
@@ -159,6 +159,7 @@ export function groupHistoryItems(rows, { running = null, maxWorkRows = WORK_ITE
|
|
|
159
159
|
|
|
160
160
|
/** Rows of the live tail a streaming work item shows without being expanded. */
|
|
161
161
|
export const WORK_PREVIEW_ROWS = 5;
|
|
162
|
+
export const WORK_PREVIEW_BYTES = 6 * 1024;
|
|
162
163
|
|
|
163
164
|
/**
|
|
164
165
|
* One work item for a run of activity rows.
|
|
@@ -190,8 +191,14 @@ function workItem(run, streaming) {
|
|
|
190
191
|
// streaming group. Without it, folding activity into work items meant a long turn showed
|
|
191
192
|
// nothing but a spinner: measured, the phone sat on 「一直在思考中转圈」 for minutes while
|
|
192
193
|
// this Host pushed 100+ rows that were all inside a collapsed group.
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
const tail = [];
|
|
195
|
+
let previewBytes = 0;
|
|
196
|
+
if (streaming) for (let index = run.length - 1; index >= 0 && tail.length < WORK_PREVIEW_ROWS; index -= 1) {
|
|
197
|
+
const size = itemBytes(run[index]);
|
|
198
|
+
if (previewBytes + size > WORK_PREVIEW_BYTES) break;
|
|
199
|
+
tail.unshift(run[index]); previewBytes += size;
|
|
200
|
+
}
|
|
201
|
+
if (tail.length > 0) {
|
|
195
202
|
const previewFirst = tail[0];
|
|
196
203
|
const previewLast = tail[tail.length - 1];
|
|
197
204
|
summary.preview = {
|
|
@@ -222,13 +229,15 @@ function itemBytes(item) {
|
|
|
222
229
|
/**
|
|
223
230
|
* Build the reader behind the three view channels.
|
|
224
231
|
*
|
|
225
|
-
* @param options - `
|
|
232
|
+
* @param options - `snapshot` (explicit source-order rows and reference resolver),
|
|
233
|
+
* or `rows` for legacy callers with a strictly newest-first array,
|
|
226
234
|
* `sessionRunning` (whether the session is in a turn right now, for `isStreaming`),
|
|
227
235
|
* and the page/scan limits.
|
|
228
236
|
* @returns `{ page, details, intent, expandedFor }`.
|
|
229
237
|
*/
|
|
230
238
|
export function createHistoryViewController({
|
|
231
239
|
rows,
|
|
240
|
+
snapshot,
|
|
232
241
|
sessionRunning = () => false,
|
|
233
242
|
pageItems = HISTORY_PAGE_ITEMS,
|
|
234
243
|
pageBytes = HISTORY_PAGE_BYTES,
|
|
@@ -263,16 +272,20 @@ export function createHistoryViewController({
|
|
|
263
272
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
264
273
|
return { ok: false, code: 'BAD_REQUEST', message: 'history view needs a session' };
|
|
265
274
|
}
|
|
266
|
-
if (typeof rows !== 'function') {
|
|
275
|
+
if (typeof rows !== 'function' && typeof snapshot !== 'function') {
|
|
267
276
|
return { ok: false, code: 'NOT_AVAILABLE', message: 'this DSH Host cannot project session history' };
|
|
268
277
|
}
|
|
269
278
|
let all;
|
|
270
279
|
try {
|
|
271
|
-
all = await rows(sessionId);
|
|
280
|
+
all = typeof snapshot === 'function' ? await snapshot(sessionId) : await rows(sessionId);
|
|
272
281
|
} catch {
|
|
273
282
|
return { ok: false, code: 'NOT_FOUND', message: 'this session has no readable transcript' };
|
|
274
283
|
}
|
|
275
|
-
if (
|
|
284
|
+
if (Array.isArray(all)) {
|
|
285
|
+
const chronological = [...all].reverse();
|
|
286
|
+
all = { rows: chronological, resolve: id => chronological.findIndex(row => row.id === id) };
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(all?.rows) || typeof all.resolve !== 'function') return { ok: false, code: 'INTERNAL', message: 'the transcript could not be read' };
|
|
276
289
|
// Long transcripts are **served**, not refused.
|
|
277
290
|
//
|
|
278
291
|
// This was `all.length > scanMaxRows → UNSUPPORTED_CAPABILITY`, on the reasoning that the
|
|
@@ -293,7 +306,7 @@ export function createHistoryViewController({
|
|
|
293
306
|
// `UNSUPPORTED_CAPABILITY` and `CHANNEL_NOT_ALLOWED` are therefore reserved for "this Host
|
|
294
307
|
// has no projection capability at all" — a deployment fact — and never for a property of
|
|
295
308
|
// one session. See ADR-0007's decision evolution.
|
|
296
|
-
return { ok: true, rows: all };
|
|
309
|
+
return { ok: true, rows: all.rows, resolve: all.resolve };
|
|
297
310
|
}
|
|
298
311
|
|
|
299
312
|
/**
|
|
@@ -304,23 +317,17 @@ export function createHistoryViewController({
|
|
|
304
317
|
async function page(sessionId, before = undefined) {
|
|
305
318
|
const read = await transcriptFor(sessionId);
|
|
306
319
|
if (read.ok !== true) return read;
|
|
307
|
-
|
|
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;
|
|
320
|
+
let endRow = read.rows.length;
|
|
314
321
|
if (typeof before === 'string' && before !== '') {
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
}
|
|
322
|
+
endRow = read.resolve(before, { cut: true });
|
|
323
|
+
if (endRow < 0) return { ok: false, code: 'NOT_FOUND', message: 'history cursor is no longer in this session' };
|
|
323
324
|
}
|
|
325
|
+
// Cut by source position before grouping: an anchor may now lie inside a merged work.
|
|
326
|
+
const items = groupHistoryItems(read.rows.slice(0, endRow), {
|
|
327
|
+
chronological: true,
|
|
328
|
+
running: !before && (typeof sessionRunning === 'function' ? sessionRunning(sessionId) === true : null),
|
|
329
|
+
});
|
|
330
|
+
const end = items.length;
|
|
324
331
|
const selected = [];
|
|
325
332
|
let bytes = 1024;
|
|
326
333
|
for (let index = end - 1; index >= 0; index -= 1) {
|
|
@@ -354,16 +361,16 @@ export function createHistoryViewController({
|
|
|
354
361
|
const firstId = typeof ref?.firstMessageId === 'string' ? ref.firstMessageId : '';
|
|
355
362
|
const lastId = typeof ref?.lastMessageId === 'string' ? ref.lastMessageId : '';
|
|
356
363
|
if (firstId === '' || lastId === '') return { ok: false, code: 'BAD_REQUEST', message: 'a work range needs both endpoints' };
|
|
357
|
-
//
|
|
358
|
-
const chronological =
|
|
359
|
-
const from =
|
|
360
|
-
const to =
|
|
364
|
+
// Both endpoints and the forward cursor resolve against one source-order snapshot.
|
|
365
|
+
const chronological = read.rows;
|
|
366
|
+
const from = read.resolve(firstId);
|
|
367
|
+
const to = read.resolve(lastId);
|
|
361
368
|
if (from < 0 || to < 0 || to < from) return { ok: false, code: 'NOT_FOUND', message: 'this work range is no longer in the transcript' };
|
|
362
369
|
const range = chronological.slice(from, to + 1);
|
|
363
370
|
let start = 0;
|
|
364
371
|
if (typeof after === 'string' && after !== '') {
|
|
365
|
-
const index =
|
|
366
|
-
if (index < 0) return { ok: false, code: 'BAD_REQUEST', message: 'the detail cursor is outside this work range' };
|
|
372
|
+
const index = read.resolve(after) - from;
|
|
373
|
+
if (index < 0 || index >= range.length) return { ok: false, code: 'BAD_REQUEST', message: 'the detail cursor is outside this work range' };
|
|
367
374
|
start = index + 1;
|
|
368
375
|
}
|
|
369
376
|
const messages = [];
|
package/src/host-version.js
CHANGED
package/src/host.js
CHANGED
|
@@ -36,6 +36,7 @@ import { SessionReadModel } from './session-read-model.js';
|
|
|
36
36
|
import { ProjectionReadModelSink } from './projection-read-model-sink.js';
|
|
37
37
|
import { publishLifecycle, runningStatusEvent, turnEventFor } from './session-publisher.js';
|
|
38
38
|
import { createReasoningPublisher } from './reasoning-publisher.js';
|
|
39
|
+
import { createHistoryDeliveryState } from './history-delivery-state.js';
|
|
39
40
|
import { turnDiagnosticRow, turnDiagnosticPayload } from './turn-error.js';
|
|
40
41
|
import { advanceInterruptionMetadata, diagnosticOwner, interruptionFields, projectDismissedErrors } from './diagnostic-presentation.js';
|
|
41
42
|
import { acceptLink } from './host-authorization.js';
|
|
@@ -315,6 +316,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
315
316
|
const subscribers = new Set();
|
|
316
317
|
/** Devices following one session's live stream: `sessionId -> Set<deviceId>`. */
|
|
317
318
|
const sessionSubscribers = new Map();
|
|
319
|
+
const historyDelivery = createHistoryDeliveryState((deviceId, sessionId) =>
|
|
320
|
+
acceptedControllers.has(deviceId) && policy.canAccept(deviceId) && !sessionIsHidden(sessionId)
|
|
321
|
+
&& sessionSubscribers.get(sessionId)?.has(deviceId) === true);
|
|
318
322
|
/**
|
|
319
323
|
* Sessions a controller has attached to at least once in this process.
|
|
320
324
|
*
|
|
@@ -710,7 +714,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
710
714
|
}
|
|
711
715
|
|
|
712
716
|
function send(frame) {
|
|
713
|
-
if (ws === null || typeof ws.send !== 'function') return;
|
|
717
|
+
if (ws === null || typeof ws.send !== 'function' || (ws.readyState !== undefined && ws.readyState !== 1)) return false;
|
|
714
718
|
// The relay **rejects** a frame over `MAX_FRAME_BYTES` outright — it does not
|
|
715
719
|
// truncate it, and the controller waiting on that frame is left until its own
|
|
716
720
|
// deadline. So an oversized frame is refused here, where it is observable, rather
|
|
@@ -721,12 +725,14 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
721
725
|
frameRefusals.push({ at: now().toISOString(), kind: typeof frame?.kind === 'string' ? frame.kind : null, bytes });
|
|
722
726
|
if (frameRefusals.length > FRAME_REFUSAL_LOG_LIMIT) frameRefusals.splice(0, frameRefusals.length - FRAME_REFUSAL_LOG_LIMIT);
|
|
723
727
|
frameRefusalTotal += 1;
|
|
724
|
-
return;
|
|
728
|
+
return false;
|
|
725
729
|
}
|
|
726
730
|
try {
|
|
727
731
|
ws.send(JSON.stringify(frame));
|
|
732
|
+
return true;
|
|
728
733
|
} catch {
|
|
729
734
|
// A send that throws belongs to the socket's own error path.
|
|
735
|
+
return false;
|
|
730
736
|
}
|
|
731
737
|
}
|
|
732
738
|
|
|
@@ -1125,6 +1131,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1125
1131
|
function markDeviceUnreachable(deviceId) {
|
|
1126
1132
|
if (typeof deviceId !== 'string' || deviceId === '') return;
|
|
1127
1133
|
offlineDevices.add(deviceId);
|
|
1134
|
+
historyDelivery.forget(deviceId);
|
|
1128
1135
|
for (const sessionId of watchedSessionsByDevice.get(deviceId) ?? []) renewTurnLease(deviceId, sessionId);
|
|
1129
1136
|
}
|
|
1130
1137
|
|
|
@@ -1229,6 +1236,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1229
1236
|
if (result.ok) {
|
|
1230
1237
|
directoryEpoch++;
|
|
1231
1238
|
acceptedControllers.delete(deviceId);
|
|
1239
|
+
historyDelivery.forget(deviceId);
|
|
1232
1240
|
observeRemindersSource();
|
|
1233
1241
|
subscribers.delete(deviceId);
|
|
1234
1242
|
status.removeDevice(deviceId);
|
|
@@ -1307,6 +1315,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1307
1315
|
if (topic === 'sessions') subscribers.delete(deviceId);
|
|
1308
1316
|
else if (topic.startsWith('session:')) {
|
|
1309
1317
|
const sessionId = topic.slice('session:'.length);
|
|
1318
|
+
historyDelivery.forget(deviceId, sessionId);
|
|
1310
1319
|
renewTurnLease(deviceId, sessionId);
|
|
1311
1320
|
const devices = sessionSubscribers.get(sessionId);
|
|
1312
1321
|
if (devices === undefined) continue;
|
|
@@ -1318,6 +1327,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1318
1327
|
|
|
1319
1328
|
/** Drop every subscription a device holds, on disconnect. */
|
|
1320
1329
|
function forgetSubscriptions(deviceId) {
|
|
1330
|
+
historyDelivery.forget(deviceId);
|
|
1321
1331
|
subscribers.delete(deviceId);
|
|
1322
1332
|
for (const [sessionId, devices] of sessionSubscribers) {
|
|
1323
1333
|
devices.delete(deviceId);
|
|
@@ -1448,9 +1458,14 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1448
1458
|
function pushSessionUpdate(sessionId, channel, payload) {
|
|
1449
1459
|
if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
|
|
1450
1460
|
const guarded = channel === 'local-db:messages:created' || channel === 'maker:event';
|
|
1461
|
+
const thinking = channel === 'local-db:messages:created' && payload?.message?.role === 'thinking';
|
|
1451
1462
|
const devices = guarded
|
|
1452
1463
|
? new Set([...(sessionSubscribers.get(sessionId) ?? [])].filter(id => !sessionIsHidden(sessionId) && acceptedControllers.has(id) && policy.canAccept(id)))
|
|
1453
1464
|
: sessionSubscribers.get(sessionId);
|
|
1465
|
+
// Only a successfully delivered view opts this controller out of raw thinking.
|
|
1466
|
+
if (thinking && devices) for (const deviceId of devices) {
|
|
1467
|
+
if (historyDelivery.ready(deviceId, sessionId)) devices.delete(deviceId);
|
|
1468
|
+
}
|
|
1454
1469
|
const watchers = devices === undefined ? 0 : devices.size;
|
|
1455
1470
|
const reachable = devices === undefined ? 0 : [...devices].filter((deviceId) => !offlineDevices.has(deviceId)).length;
|
|
1456
1471
|
recordPush(sessionId, channel, reachable, payload);
|
|
@@ -1476,6 +1491,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1476
1491
|
const reasoningPublisher = createReasoningPublisher({
|
|
1477
1492
|
push: pushSessionUpdate, invalidate: pushHistoryViewChanged,
|
|
1478
1493
|
watched: (sessionId) => watchersFor(sessionId) > 0,
|
|
1494
|
+
rawNeeded: (sessionId) => [...(sessionSubscribers.get(sessionId) ?? [])].some(deviceId =>
|
|
1495
|
+
acceptedControllers.has(deviceId) && policy.canAccept(deviceId) && !historyDelivery.ready(deviceId, sessionId)),
|
|
1496
|
+
now: () => Number(now()),
|
|
1479
1497
|
setTimeout: setTimeoutImpl, clearTimeout: clearTimeoutImpl,
|
|
1480
1498
|
});
|
|
1481
1499
|
|
|
@@ -1485,6 +1503,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1485
1503
|
*/
|
|
1486
1504
|
function pushHistoryViewChanged(sessionId) {
|
|
1487
1505
|
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
1506
|
+
historyDelivery.changed(sessionId);
|
|
1488
1507
|
if (watchersFor(sessionId) === 0) return;
|
|
1489
1508
|
if (historyViewPushes.has(sessionId)) return;
|
|
1490
1509
|
const timer = setTimeoutImpl(() => {
|
|
@@ -2052,6 +2071,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2052
2071
|
(channel, count, payload) => recordPush(sessionId, channel, count, payload));
|
|
2053
2072
|
}
|
|
2054
2073
|
function publishSessionMeta(sessionId, patch) {
|
|
2074
|
+
if (patch?.clearedAt !== undefined || patch?.status === 'deleted' || patch?.status === 'archived') {
|
|
2075
|
+
for (const deviceId of sessionSubscribers.get(sessionId) ?? []) historyDelivery.forget(deviceId, sessionId);
|
|
2076
|
+
}
|
|
2055
2077
|
publishRowPatch(sessionId, { ...patch, updatedAt: now().toISOString() });
|
|
2056
2078
|
}
|
|
2057
2079
|
|
|
@@ -2491,6 +2513,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2491
2513
|
case 'link-open': {
|
|
2492
2514
|
if (typeof frame.id !== 'string' || typeof frame.src !== 'string') return;
|
|
2493
2515
|
if (!policy.canAccept(frame.src)) return;
|
|
2516
|
+
historyDelivery.forget(frame.src);
|
|
2494
2517
|
for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
|
|
2495
2518
|
// A link-open is itself proof the relay has a route to this device.
|
|
2496
2519
|
markDeviceReachable(frame.src);
|
|
@@ -2521,6 +2544,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2521
2544
|
for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
|
|
2522
2545
|
filePeer.closePeer(frame.src);
|
|
2523
2546
|
acceptedControllers.delete(frame.src);
|
|
2547
|
+
historyDelivery.forget(frame.src);
|
|
2524
2548
|
observeRemindersSource();
|
|
2525
2549
|
status.upsertDevice(frame.src, { isController: false });
|
|
2526
2550
|
status.refreshConnectionState();
|
|
@@ -2553,18 +2577,28 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2553
2577
|
// Listing-only controllers never send `link-open`; an invoke is itself
|
|
2554
2578
|
// proof that the phone reached this Host, so it links the device too.
|
|
2555
2579
|
if (!acceptedControllers.has(frame.src)) linkController(frame.src);
|
|
2580
|
+
const historyRead = historyDelivery.begin(frame);
|
|
2581
|
+
const invokeGeneration = generation;
|
|
2582
|
+
const invokeSocket = ws;
|
|
2556
2583
|
void handleInvoke(frame).then(
|
|
2557
2584
|
(reply) => {
|
|
2558
2585
|
// `null` means the request was unaddressable; the relay guard above
|
|
2559
2586
|
// already rejects those, so this only defends the invariant.
|
|
2560
|
-
if (reply === null)
|
|
2587
|
+
if (reply === null || invokeGeneration !== generation || invokeSocket !== ws) {
|
|
2588
|
+
historyDelivery.finish(historyRead, false); return;
|
|
2589
|
+
}
|
|
2561
2590
|
// Fitting and sending are Host code on a reply that ultimately came from DSH, so
|
|
2562
2591
|
// they are exactly where an unexpected shape would surface — and a throw from
|
|
2563
2592
|
// inside a `.then` callback is an unhandled rejection, which this process treats
|
|
2564
2593
|
// as fatal. The controller still needs an answer, so it gets one.
|
|
2565
2594
|
try {
|
|
2566
|
-
|
|
2595
|
+
const fitted = fitReplyToFrame(reply, frame);
|
|
2596
|
+
const sent = send(fitted);
|
|
2597
|
+
const page = fitted?.payload?.result;
|
|
2598
|
+
const complete = sent && fitted?.payload?.ok === true && page?.version === 1 && Array.isArray(page.items);
|
|
2599
|
+
if (historyDelivery.finish(historyRead, complete)) pushHistoryViewChanged(historyRead.sessionId);
|
|
2567
2600
|
} catch (error) {
|
|
2601
|
+
historyDelivery.finish(historyRead, false);
|
|
2568
2602
|
recordHandlerError('invoke-reply', error);
|
|
2569
2603
|
send({
|
|
2570
2604
|
v: PROTOCOL_VERSION,
|
|
@@ -2579,6 +2613,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2579
2613
|
// Named with the channel: seven identical `TimeoutError`s in the diagnostics after one
|
|
2580
2614
|
// restart were unattributable, and "which read is missing its budget" is the whole
|
|
2581
2615
|
// question. `frame.payload.channel` is the only thing that answers it.
|
|
2616
|
+
historyDelivery.finish(historyRead, false);
|
|
2617
|
+
if (invokeGeneration !== generation || invokeSocket !== ws) return;
|
|
2582
2618
|
recordHandlerError(`invoke:${typeof frame?.payload?.channel === 'string' ? frame.payload.channel : '?'}`, error);
|
|
2583
2619
|
send({ v: PROTOCOL_VERSION, kind: 'invoke-result', id: frame.id, dst: frame.src, payload: { ok: false, error: { code: 'INTERNAL', message: 'DSH Host failed to serve this channel' } } });
|
|
2584
2620
|
},
|
|
@@ -2741,6 +2777,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2741
2777
|
// would push to a link that is gone.
|
|
2742
2778
|
clearHistoryViewPushes();
|
|
2743
2779
|
reasoningPublisher.clear();
|
|
2780
|
+
historyDelivery.clear();
|
|
2744
2781
|
acceptedControllers.clear();
|
|
2745
2782
|
subscribers.clear();
|
|
2746
2783
|
sessionSubscribers.clear();
|
|
@@ -2810,6 +2847,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2810
2847
|
// to have moved them. `replace` never writes back — see `session-flags.js`.
|
|
2811
2848
|
sessionFlags.replace(next?.sessionFlags);
|
|
2812
2849
|
policy.update(next);
|
|
2850
|
+
historyDelivery.clear();
|
|
2813
2851
|
observeRemindersSource();
|
|
2814
2852
|
filePeer.pruneUnauthorized();
|
|
2815
2853
|
fileExports.pruneUnauthorized();
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { createReasoningStream } from './reasoning-stream.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
export
|
|
5
|
-
|
|
4
|
+
export const REASONING_INTERVAL_MS = 1000;
|
|
5
|
+
export const REASONING_RATE_BYTES = 32 * 1024;
|
|
6
|
+
export const REASONING_BURST_BYTES = 64 * 1024;
|
|
7
|
+
export const REASONING_SNAPSHOT_BYTES = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Latest-wins interim snapshots. Full bodies remain readable; terminal deletion never waits. */
|
|
10
|
+
export function createReasoningPublisher({ push, invalidate, watched, rawNeeded = watched,
|
|
11
|
+
setTimeout: schedule = setTimeout, clearTimeout: cancel = clearTimeout, now = Date.now,
|
|
12
|
+
intervalMs = REASONING_INTERVAL_MS, rateBytes = REASONING_RATE_BYTES,
|
|
13
|
+
burstBytes = REASONING_BURST_BYTES, maxSnapshotBytes = REASONING_SNAPSHOT_BYTES }) {
|
|
6
14
|
const sessions = new Map();
|
|
7
15
|
|
|
8
16
|
function drop(sessionId) {
|
|
@@ -10,18 +18,42 @@ export function createReasoningPublisher({ push, invalidate, watched,
|
|
|
10
18
|
if (state?.timer !== null && state?.timer !== undefined) cancel(state.timer);
|
|
11
19
|
sessions.delete(sessionId);
|
|
12
20
|
}
|
|
13
|
-
|
|
21
|
+
function later(sessionId, state, delay = intervalMs) {
|
|
22
|
+
if (state.timer !== null) return;
|
|
23
|
+
state.timer = schedule(() => flush(sessionId, state), delay);
|
|
24
|
+
state.timer?.unref?.();
|
|
25
|
+
}
|
|
14
26
|
function flush(sessionId, state) {
|
|
15
27
|
if (state.timer !== null) cancel(state.timer);
|
|
16
28
|
state.timer = null;
|
|
29
|
+
if (sessions.get(sessionId) !== state) return;
|
|
17
30
|
if (!watched(sessionId)) { drop(sessionId); return; }
|
|
18
31
|
const ids = new Set(state.rows.map(row => row.clientId));
|
|
19
32
|
const removed = [...state.known].filter(id => !ids.has(id));
|
|
20
|
-
if (removed.length
|
|
21
|
-
for (const
|
|
33
|
+
if (removed.length) push(sessionId, 'local-db:messages:deleted', { sessionId, clientIds: removed });
|
|
34
|
+
for (const id of removed) state.sent.delete(id);
|
|
22
35
|
state.known = ids;
|
|
36
|
+
const clock = now();
|
|
37
|
+
state.tokens = Math.min(burstBytes, state.tokens + Math.max(0, clock - state.tokenAt) * rateBytes / 1000);
|
|
38
|
+
state.tokenAt = clock;
|
|
39
|
+
let retryMs = 0;
|
|
40
|
+
if (rawNeeded(sessionId)) for (const message of state.rows) {
|
|
41
|
+
const payload = { sessionId, message };
|
|
42
|
+
const serialized = JSON.stringify(payload);
|
|
43
|
+
const stamp = createHash('sha256').update(serialized).digest('hex');
|
|
44
|
+
if (state.sent.get(message.clientId) === stamp) continue;
|
|
45
|
+
const bytes = Buffer.byteLength(serialized, 'utf8');
|
|
46
|
+
// Do not cut an interim prefix and label it full content. A page/explicit details
|
|
47
|
+
// (or the durable final row) owns delivery of unusually large reasoning bodies.
|
|
48
|
+
if (bytes > maxSnapshotBytes || bytes > burstBytes) continue;
|
|
49
|
+
if (bytes > state.tokens) { retryMs = Math.max(retryMs, Math.ceil((bytes - state.tokens) * 1000 / rateBytes)); continue; }
|
|
50
|
+
push(sessionId, 'local-db:messages:created', payload);
|
|
51
|
+
state.tokens -= bytes;
|
|
52
|
+
state.sent.set(message.clientId, stamp);
|
|
53
|
+
}
|
|
23
54
|
invalidate(sessionId);
|
|
24
|
-
if (state.rows.length
|
|
55
|
+
if (!state.rows.length) drop(sessionId);
|
|
56
|
+
else if (retryMs > 0) later(sessionId, state, Math.max(intervalMs, retryMs));
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
function noteEvent(sessionId, event, cached = {}) {
|
|
@@ -29,25 +61,20 @@ export function createReasoningPublisher({ push, invalidate, watched,
|
|
|
29
61
|
let state = sessions.get(sessionId);
|
|
30
62
|
if (!state) {
|
|
31
63
|
if (event?.type !== 'assistant/chunk' && !(cached.before?.length > 0)) return;
|
|
32
|
-
state = { stream: createReasoningStream(sessionId), rows: [], known: new Set(),
|
|
64
|
+
state = { stream: createReasoningStream(sessionId), rows: [], known: new Set(), sent: new Map(),
|
|
65
|
+
timer: null, tokens: burstBytes, tokenAt: now() };
|
|
33
66
|
sessions.set(sessionId, state);
|
|
34
67
|
}
|
|
35
|
-
// A controller can open the session mid-block and read its preview before this
|
|
36
|
-
// publisher sees any chunks. Include those cached identities when retiring it.
|
|
37
68
|
for (const row of cached.before ?? []) state.known.add(row.clientId);
|
|
38
69
|
const changed = state.stream.consume(event);
|
|
39
70
|
const next = cached.after ?? state.stream.rows();
|
|
40
|
-
if (!changed && next.length
|
|
41
|
-
if (!changed &&
|
|
71
|
+
if (!changed && !next.length && !state.known.size) { drop(sessionId); return; }
|
|
72
|
+
if (!changed && !['assistant/message', 'turn/end', 'step/end'].includes(event?.type)) return;
|
|
42
73
|
state.rows = next;
|
|
43
74
|
const immediate = event?.type !== 'assistant/chunk' || event.data?.chunk?.type === 'block-end';
|
|
44
75
|
if (immediate) flush(sessionId, state);
|
|
45
|
-
else
|
|
46
|
-
state.timer = schedule(() => flush(sessionId, state), intervalMs);
|
|
47
|
-
state.timer?.unref?.();
|
|
48
|
-
}
|
|
76
|
+
else later(sessionId, state);
|
|
49
77
|
}
|
|
50
|
-
|
|
51
78
|
function clear() { for (const sessionId of sessions.keys()) drop(sessionId); }
|
|
52
79
|
return { noteEvent, clear };
|
|
53
80
|
}
|
package/src/reasoning-stream.js
CHANGED
|
@@ -44,15 +44,16 @@ export function createReasoningStream(sessionId) {
|
|
|
44
44
|
return isDelta || isEnd;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
48
|
-
return [...blocks].flatMap(([index, block]) => {
|
|
47
|
+
function entries() {
|
|
48
|
+
return [...blocks].sort(([a], [b]) => a - b).flatMap(([index, block]) => {
|
|
49
49
|
const content = block.chunks.join('');
|
|
50
50
|
if (!content) return [];
|
|
51
|
-
const
|
|
52
|
-
return [{
|
|
53
|
-
toolUseId: null, agentMeta: null, createdAt: block.createdAt, isStreaming: block.isStreaming }];
|
|
51
|
+
const clientId = `${sessionId}:reasoning-stream:${key}:${index}`;
|
|
52
|
+
return [{ scope: key, index, row: { id: `history-live:${clientId}`, clientId, sessionId, role: 'thinking', content: { text: content, isStreaming: block.isStreaming },
|
|
53
|
+
toolUseId: null, agentMeta: null, createdAt: block.createdAt, isStreaming: block.isStreaming } }];
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
const rows = () => entries().map(entry => entry.row);
|
|
58
|
+
return { consume, rows, entries, clear };
|
|
58
59
|
}
|