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 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.36** adds native interruption banner/status metadata, owner-scoped interruption acknowledgement and error dismissal, and guarded manual continuation with verified native plan-mode off. It retains the earlier safe diagnostics, question synchronization, authentication, reasoning streaming and native capability subsets.
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.36
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -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
- rows.push({ ...base, content: { text: block.text } });
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 { foldSessionEvent } from './dsh-message-fold.js';
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
- const rows=[];for(const event of events){const folded=foldSessionEvent(event,{sessionId:sid});folded.forEach((row,index)=>{const rowid=event.seq*65536+index;if(!Number.isSafeInteger(rowid))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Message order exceeds the safe protocol range');rows.push({...row,rowid});});}return rows.reverse();},
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);
@@ -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 { createReasoningStream } from './reasoning-stream.js';
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
- /** Fold one session's log into message entries, in log order. */
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 reasoning = createReasoningStream(sessionId);
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
- reasoning.consume(event);
398
- const diagnostic = turnDiagnosticRow(event, sessionId);
399
- if (diagnostic) {
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
- entry.reasoning.consume(event);
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
- * Grouping runs over the same cached transcript the raw window pages
691
- * (`readMessages.all`), so the two views can never disagree about what the session
692
- * contains — and neither of them re-reads the log to answer.
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
- rows: committedRows,
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
- * The history view's page budgets, mirrored from the Cindy contract.
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: `@cindy/maker-shared/message-window` publishes
6
- * `HISTORY_VIEW_PAGE_ITEMS = 20`, `HISTORY_VIEW_PAGE_BYTES = 256 * 1024`,
7
- * `HISTORY_DETAIL_PAGE_BYTES = 256 * 1024`, and `HISTORY_VIEW_VERSION = 1`. A change on
8
- * the Cindy side shows up as a failing test here instead of a page the phone silently
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. */
@@ -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 a session it cannot project (a transcript past the scan budget) — the same
33
- * lever the reference uses.
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
- if (streaming && run.length > 0) {
194
- const tail = run.slice(Math.max(0, run.length - WORK_PREVIEW_ROWS));
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 - `rows` (the transcript reader's whole-transcript accessor),
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 (!Array.isArray(all)) return { ok: false, code: 'INTERNAL', message: 'the transcript could not be read' };
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
- 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;
320
+ let endRow = read.rows.length;
314
321
  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
- }
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
- // 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);
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 = 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' };
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 = [];
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export const HOST_APP_VERSION = '0.1.82';
6
6
  /** Actual npm release, separate from the Cindy compatibility identity. */
7
- export const HOST_PLUGIN_VERSION = '0.1.36';
7
+ export const HOST_PLUGIN_VERSION = '0.1.37';
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) return;
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
- send(fitReplyToFrame(reply, frame));
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
- /** Coalesce token-sized updates; a final message synchronously retires its preview. */
4
- export function createReasoningPublisher({ push, invalidate, watched,
5
- setTimeout: schedule = setTimeout, clearTimeout: cancel = clearTimeout, intervalMs = 100 }) {
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 > 0) push(sessionId, 'local-db:messages:deleted', { sessionId, clientIds: removed });
21
- for (const message of state.rows) push(sessionId, 'local-db:messages:created', { sessionId, message });
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 === 0) drop(sessionId);
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(), timer: null };
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 === 0 && state.known.size === 0) { drop(sessionId); return; }
41
- if (!changed && event?.type !== 'assistant/message' && event?.type !== 'turn/end' && event?.type !== 'step/end') return;
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 if (state.timer === null) {
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
  }
@@ -44,15 +44,16 @@ export function createReasoningStream(sessionId) {
44
44
  return isDelta || isEnd;
45
45
  }
46
46
 
47
- function rows() {
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 id = `${sessionId}:reasoning-stream:${key}:${index}`;
52
- return [{ id, clientId: id, sessionId, role: 'thinking', content: { text: content, isStreaming: block.isStreaming },
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
- return { consume, rows, clear };
57
+ const rows = () => entries().map(entry => entry.row);
58
+ return { consume, rows, entries, clear };
58
59
  }