@itookit/dsht 0.2.1 → 0.2.4

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.
@@ -1,5 +1,5 @@
1
1
  /** Human transcript projection from durable events and ephemeral assistant chunks. */
2
- import wrapAnsi from 'wrap-ansi';
2
+ import sliceAnsi from 'slice-ansi';
3
3
  import { array, object, safeText, string } from "./wire.js";
4
4
  function toolSummary(block) {
5
5
  let args = {};
@@ -11,9 +11,13 @@ function toolSummary(block) {
11
11
  }
12
12
  catch { /* Partial or non-JSON arguments have no operation summary. */ }
13
13
  }
14
+ if (typeof block.operation === 'string')
15
+ return { name: string(block.name), operation: block.operation, ...(typeof block.command === 'string' ? { command: block.command } : {}) };
14
16
  const operation = [args.description, args.command, args.cmd, args.path, args.filePath, args.file_path, args.query]
15
17
  .find(value => typeof value === 'string' && value.trim());
16
- return { name: string(block.name), ...(typeof operation === 'string' ? { operation } : {}) };
18
+ const command = [args.command, args.cmd].find(value => typeof value === 'string' && value.trim());
19
+ return { name: string(block.name), ...(typeof operation === 'string' ? { operation } : {}),
20
+ ...(typeof command === 'string' && command !== operation ? { command: command.split(/\r?\n/)[0] } : {}) };
17
21
  }
18
22
  /** Fit a tool operation to one terminal row without exposing the result body.
19
23
  * @param text - Tool name, status icon and optional operation.
@@ -24,37 +28,68 @@ export function toolLine(text, width) {
24
28
  const clean = safeText(text).replace(/\s+/gu, ' ').trim();
25
29
  if (width < 2)
26
30
  return width === 1 ? '…' : '';
27
- const options = { hard: true, wordWrap: false, trim: false };
28
- return wrapAnsi(clean, width, options).includes('\n')
29
- ? wrapAnsi(clean, width - 1, options).split('\n')[0] + '…' : clean;
31
+ const clipped = sliceAnsi(clean, 0, width);
32
+ return clipped.length < clean.length ? sliceAnsi(clean, 0, width - 1) + '…' : clean;
30
33
  }
31
- /** Render known content blocks and preserve unknown plugin blocks as JSON. */
32
- export function contentText(content, tools, width = 100) {
34
+ /** Render known content blocks and preserve unknown plugin blocks as JSON.
35
+ * @param content - Message content blocks.
36
+ * @param tools - Tool summaries by call ID, when retained history provides them.
37
+ * @param width - Available terminal columns.
38
+ * @param reasoning - `full` keeps streamed reasoning at length; `row` folds a committed block into one row.
39
+ * @returns Blocks joined by newlines.
40
+ */
41
+ export function contentText(content, tools, width = 100, reasoning = 'full') {
33
42
  return array(content).map(value => {
34
43
  const block = object(value);
35
44
  switch (block.type) {
36
45
  case 'text': return string(block.text);
37
- case 'reasoning': return `◇ ${string(block.text)}`;
46
+ case 'reasoning': return reasoning === 'full' ? `◇ ${string(block.text)}` : toolLine(`◇ ${string(block.text)}`, width);
38
47
  case 'tool-call': {
39
48
  const tool = toolSummary(block);
40
- return toolLine(`⚙ ${tool.name}${tool.operation ? ` · ${tool.operation}` : ''}`, width);
49
+ return toolLine(`⚙ ${tool.name}${tool.operation ? ` · ${tool.operation.split(/\r?\n/)[0]}` : ''}`, width)
50
+ + (tool.command ? `\n${' '.repeat(Math.min(2, width))}${toolLine(`$ ${tool.command}`, Math.max(0, width - 2))}` : '');
41
51
  }
42
52
  case 'tool-result': {
43
- const tool = tools?.get(String(block.toolCallId));
53
+ const tool = tools?.get(String(block.toolCallId)) ?? (typeof block.name === 'string' ? toolSummary(block) : undefined);
44
54
  return toolLine(`${block.isError ? '✗' : '✓'} ${tool?.name ?? 'tool'} · ${tool?.operation ?? (block.isError ? 'failed' : 'completed')}`, width);
45
55
  }
46
56
  default: return JSON.stringify(block);
47
57
  }
48
58
  }).map(safeText).join('\n');
49
59
  }
60
+ /** Event types that contribute a displayed message; other retained events only affect live state. */
61
+ const DISPLAY_EVENTS = new Set(['user/message', 'assistant/message', 'tool/result']);
50
62
  /** Opening snapshots replace all state; durable events are deduplicated by sequence. */
51
63
  export class Transcript {
64
+ sizes = new Map();
65
+ storedBytes = 0;
66
+ throughSeq = -1;
67
+ promptBeforeWindow;
68
+ disposed = false;
69
+ /** Increments when cached bodies are evicted, so view state can drop obsolete references. */
70
+ memoryRevision = 0;
71
+ transientSeqs = new Set();
52
72
  events = new Map();
53
73
  blocks = new Map();
74
+ thoughtIndex;
75
+ liveProjection = new WeakMap();
76
+ closedBlocks = new Set();
77
+ oldestSeq;
78
+ turnMarker;
54
79
  attempt;
55
80
  nextIndex = 0;
56
81
  revision = 0;
57
82
  legacyStream = false;
83
+ keysDirty = true;
84
+ sortedSeqs = [];
85
+ projectionRevision = 0;
86
+ projection;
87
+ displaySeqsRevision = -1;
88
+ displayed = [];
89
+ displayedMessages = new WeakMap();
90
+ legacyDirty = true;
91
+ legacyPosition = '';
92
+ legacySeq = -1;
58
93
  /** Changes whenever a follow frame or older page can affect the rendered transcript. */
59
94
  version = 0;
60
95
  cursor = -1;
@@ -62,13 +97,27 @@ export class Transcript {
62
97
  ready = false;
63
98
  /** Apply one follow frame; reject stream gaps so callers can reopen a snapshot. */
64
99
  accept(value) {
100
+ if (this.disposed)
101
+ return;
65
102
  this.version++;
66
103
  const frame = object(value);
67
104
  switch (frame.type) {
68
105
  case 'snapshot': {
69
106
  this.ready = false;
70
107
  this.events.clear();
108
+ this.sizes.clear();
109
+ this.storedBytes = 0;
110
+ this.throughSeq = -1;
111
+ this.promptBeforeWindow = undefined;
112
+ this.dropProjections();
113
+ this.transientSeqs.clear();
114
+ this.closedBlocks.clear();
115
+ this.oldestSeq = undefined;
116
+ this.turnMarker = undefined;
71
117
  this.blocks.clear();
118
+ this.keysDirty = true;
119
+ this.projectionRevision++;
120
+ this.legacyDirty = true;
72
121
  this.legacyStream = frame.assistantStream === undefined;
73
122
  this.cursor = number(frame.cursor);
74
123
  this.hasMore = frame.hasMore === true;
@@ -109,6 +158,7 @@ export class Transcript {
109
158
  this.revision = number(live.revision);
110
159
  if (live.type === 'start') {
111
160
  this.blocks.clear();
161
+ this.closedBlocks.clear();
112
162
  this.attempt = string(live.attemptId);
113
163
  this.nextIndex = 0;
114
164
  }
@@ -134,60 +184,257 @@ export class Transcript {
134
184
  }
135
185
  /** Add an older page without replacing the live tail. */
136
186
  addPage(value) {
187
+ if (this.disposed)
188
+ return;
137
189
  this.version++;
138
190
  const page = object(value);
139
191
  this.addRecords(array(page.records));
140
192
  this.hasMore = page.hasMore === true;
141
193
  }
194
+ /** Durable read cutoff includes followed records that were not present in the opening snapshot. */
195
+ get readThrough() { return Math.max(this.cursor, this.throughSeq); }
196
+ /** Estimated UTF-16 JSON payload bytes; excludes object overhead and unfinished stream blocks. */
197
+ get retainedBytes() { return this.storedBytes; }
198
+ /** Release reloadable prefix records with hysteresis, retaining a recent tail and active legacy chunks.
199
+ * @param limits - Soft record and payload budgets; callers suspend this operation while reading history.
200
+ * @returns Number of removed records. Paging remains possible at the advanced durable cutoff.
201
+ */
202
+ trimHistory(limits) {
203
+ if (this.disposed || (this.events.size <= limits.maxRecords && this.storedBytes <= limits.maxBytes))
204
+ return 0;
205
+ const keys = this.sortedKeys();
206
+ const keep = Math.min(32, Math.max(1, Math.floor(limits.maxRecords / 4)));
207
+ const targetRecords = Math.max(keep, Math.floor(limits.maxRecords * 0.75));
208
+ const targetBytes = limits.maxBytes * 0.75;
209
+ let count = 0, bytes = this.storedBytes;
210
+ while (count < keys.length - keep && (keys.length - count > targetRecords || bytes > targetBytes)) {
211
+ const seq = keys[count];
212
+ // The active legacy attempt may need its earliest chunks when an older page is replayed.
213
+ if (this.transientSeqs.has(seq))
214
+ break;
215
+ bytes -= this.sizes.get(seq) ?? 0;
216
+ count++;
217
+ }
218
+ if (!count)
219
+ return 0;
220
+ const cutoff = keys[count];
221
+ const tools = new Map();
222
+ for (const seq of keys) {
223
+ const event = this.events.get(seq);
224
+ if (!DISPLAY_EVENTS.has(string(event.type)) || event.surfaceOp !== 'append')
225
+ continue;
226
+ const data = object(event.data);
227
+ if (event.type === 'user/message') {
228
+ if (seq < cutoff && (!data.source || object(data.source).kind === 'user'))
229
+ this.promptBeforeWindow = { seq, text: toolLine(contentText(data.content), 120) };
230
+ continue;
231
+ }
232
+ const blocks = array(object(data.message).content).map(object);
233
+ for (const block of blocks)
234
+ if (block.type === 'tool-call')
235
+ tools.set(string(block.id), toolSummary(block));
236
+ if (seq >= cutoff && event.type === 'tool/result') {
237
+ const content = blocks.map(block => block.type === 'tool-result' && tools.has(string(block.toolCallId))
238
+ ? { ...block, ...tools.get(string(block.toolCallId)) } : block);
239
+ this.storeEvent(seq, { ...event, data: { message: { content } } });
240
+ }
241
+ }
242
+ for (let index = 0; index < count; index++)
243
+ this.deleteEvent(keys[index]);
244
+ this.oldestSeq = cutoff;
245
+ this.cursor = Math.max(this.cursor, this.throughSeq);
246
+ this.hasMore = true;
247
+ this.keysDirty = true;
248
+ this.projectionRevision++;
249
+ this.version++;
250
+ this.memoryRevision++;
251
+ this.dropProjections();
252
+ return count;
253
+ }
254
+ /** Empty an inactive session immediately; late frames cannot repopulate a disposed transcript. */
255
+ dispose() {
256
+ this.disposed = true;
257
+ this.events.clear();
258
+ this.sizes.clear();
259
+ this.storedBytes = 0;
260
+ this.blocks.clear();
261
+ this.closedBlocks.clear();
262
+ this.transientSeqs.clear();
263
+ this.sortedSeqs = [];
264
+ this.displayed = [];
265
+ this.promptBeforeWindow = undefined;
266
+ this.oldestSeq = undefined;
267
+ this.turnMarker = undefined;
268
+ this.attempt = undefined;
269
+ this.legacyPosition = '';
270
+ this.cursor = -1;
271
+ this.throughSeq = -1;
272
+ this.hasMore = false;
273
+ this.ready = false;
274
+ this.dropProjections();
275
+ this.version++;
276
+ this.memoryRevision++;
277
+ }
278
+ dropProjections() {
279
+ this.projection = undefined;
280
+ this.thoughtIndex = undefined;
281
+ this.sortedSeqs = [];
282
+ this.displayed = [];
283
+ this.keysDirty = true;
284
+ this.displaySeqsRevision = -1;
285
+ this.displayedMessages = new WeakMap();
286
+ this.liveProjection = new WeakMap();
287
+ }
288
+ storeEvent(seq, event) {
289
+ const bytes = JSON.stringify(event).length * 2;
290
+ this.storedBytes += bytes - (this.sizes.get(seq) ?? 0);
291
+ this.sizes.set(seq, bytes);
292
+ this.events.set(seq, event);
293
+ }
294
+ deleteEvent(seq) {
295
+ this.storedBytes -= this.sizes.get(seq) ?? 0;
296
+ this.sizes.delete(seq);
297
+ this.events.delete(seq);
298
+ }
142
299
  /** Start of the open durable turn, when its timestamp is present in the retained window. */
143
300
  get activeTurnStartedAt() {
144
- let start;
145
- for (const [, event] of [...this.events].sort(([a], [b]) => a - b)) {
146
- if (event.type === 'turn/start')
147
- start = typeof event.time === 'number' && Number.isFinite(event.time) ? event.time : undefined;
148
- else if (event.type === 'turn/end')
149
- start = undefined;
301
+ return this.turnMarker?.start;
302
+ }
303
+ /** Cached prompt/reasoning summaries, oldest first; stream deltas never invalidate this index. */
304
+ get thoughts() {
305
+ if (this.thoughtIndex?.revision === this.projectionRevision)
306
+ return this.thoughtIndex.entries;
307
+ const entries = [];
308
+ let prompt = this.promptBeforeWindow?.text ?? 'Prompt precedes loaded history';
309
+ let promptSeq = this.promptBeforeWindow?.seq;
310
+ for (const seq of this.displaySeqs()) {
311
+ const event = this.events.get(seq);
312
+ if (event.surfaceOp !== 'append')
313
+ continue;
314
+ const data = object(event.data);
315
+ if (event.type === 'user/message') {
316
+ if (!data.source || object(data.source).kind === 'user') {
317
+ prompt = toolLine(contentText(data.content), 120);
318
+ promptSeq = seq;
319
+ }
320
+ }
321
+ else if (event.type === 'assistant/message') {
322
+ const reasoning = array(object(data.message).content).map(object).find(block => block.type === 'reasoning');
323
+ if (reasoning)
324
+ entries.push({ seq, promptSeq, prompt, preview: toolLine(string(reasoning.text), 120) });
325
+ }
326
+ }
327
+ this.thoughtIndex = { revision: this.projectionRevision, entries, prompt };
328
+ return entries;
329
+ }
330
+ /** Latest loaded user prompt summary, sharing the durable thought index. */
331
+ get latestPrompt() { void this.thoughts; return this.thoughtIndex.prompt; }
332
+ /** Number of semantic records and unfinished legacy chunks held by the client. */
333
+ get retainedRecordCount() { return this.events.size; }
334
+ /** Earliest loaded record, used with the opening cursor for backward paging. */
335
+ get beforeSeq() { return this.oldestSeq; }
336
+ /** Retained sequences in ascending order; resorted only when a retained event changes. */
337
+ sortedKeys() {
338
+ if (this.keysDirty) {
339
+ this.sortedSeqs = [...this.events.keys()].sort((a, b) => a - b);
340
+ this.keysDirty = false;
150
341
  }
151
- return start;
342
+ return this.sortedSeqs;
343
+ }
344
+ /** Sequences of the event types that contribute a displayed message. */
345
+ displaySeqs() {
346
+ if (this.displaySeqsRevision !== this.projectionRevision) {
347
+ this.displayed = this.sortedKeys().filter(seq => DISPLAY_EVENTS.has(string(this.events.get(seq).type)));
348
+ this.displaySeqsRevision = this.projectionRevision;
349
+ }
350
+ return this.displayed;
152
351
  }
153
- /** Earliest retained record, used with the opening cursor for backward paging. */
154
- get beforeSeq() { return [...this.events.keys()].sort((a, b) => a - b)[0]; }
155
352
  /** Return append-origin conversation only; model-only replacement copies stay hidden. */
156
353
  get messages() { return this.messagesForWidth(100); }
157
354
  /** Project history with single-row tool operations at the caller's terminal width.
158
355
  * @param width - Available terminal columns.
159
- * @returns Conversation messages with compact tool-only rows.
356
+ * @returns Conversation messages with compact tool-only rows; the array is shared and must not be mutated.
160
357
  */
161
358
  messagesForWidth(width) {
359
+ if (this.projection?.revision === this.projectionRevision && this.projection.width === width)
360
+ return this.projection.messages;
361
+ const messages = this.project(width);
362
+ this.projection = { revision: this.projectionRevision, width, messages };
363
+ return messages;
364
+ }
365
+ /** Build the message list, reusing each unchanged event's earlier projection at the same width. */
366
+ project(width) {
162
367
  const result = [];
163
368
  const tools = new Map();
164
- for (const [seq, event] of [...this.events].sort(([a], [b]) => a - b)) {
369
+ const outcomes = new Map();
370
+ const calls = new Set();
371
+ for (const seq of this.displaySeqs()) {
372
+ const event = this.events.get(seq);
373
+ if (event.surfaceOp !== 'append' || event.type === 'user/message')
374
+ continue;
375
+ for (const block of array(object(object(event.data).message).content).map(object)) {
376
+ if (block.type === 'tool-call')
377
+ calls.add(string(block.id));
378
+ if (block.type === 'tool-result')
379
+ outcomes.set(string(block.toolCallId), block.isError === true);
380
+ }
381
+ }
382
+ for (const seq of this.displaySeqs()) {
383
+ const event = this.events.get(seq);
165
384
  if (event.surfaceOp !== 'append')
166
385
  continue;
167
386
  const data = object(event.data);
168
- if (event.type === 'user/message') {
169
- const source = data.source ? object(data.source) : undefined;
170
- result.push({ seq, role: source && source.kind !== 'user' ? 'Context' : 'You', text: contentText(data.content, tools, width) });
387
+ const isUser = event.type === 'user/message';
388
+ const blocks = isUser ? [] : array(object(data.message).content).map(object);
389
+ for (const block of blocks) {
390
+ if (block.type === 'tool-call')
391
+ tools.set(string(block.id), toolSummary(block));
171
392
  }
172
- else if (event.type === 'assistant/message' || event.type === 'tool/result') {
173
- const message = object(data.message);
174
- const blocks = array(message.content).map(object);
175
- for (const block of blocks) {
176
- if (block.type === 'tool-call')
177
- tools.set(string(block.id), toolSummary(block));
178
- }
179
- const text = event.type === 'tool/result'
180
- ? contentText(blocks.filter(block => block.type === 'tool-result'), tools, width) || toolLine('✓ tool · completed', width)
181
- : contentText(message.content, tools, width);
182
- if (text)
183
- result.push({ seq, role: event.type === 'tool/result' ? 'Tool' : 'Assistant', text,
184
- compact: event.type === 'tool/result' || blocks.every(block => block.type === 'tool-call') });
393
+ const status = blocks.map(block => block.type === 'tool-call' ? String(outcomes.get(string(block.id)))
394
+ : block.type === 'tool-result' ? String(calls.has(string(block.toolCallId))) : '').join(',');
395
+ const cached = this.displayedMessages.get(event);
396
+ if (cached && cached.width === width && cached.prefix === tools.size && cached.status === status) {
397
+ if (cached.message)
398
+ result.push(cached.message);
399
+ continue;
185
400
  }
401
+ const role = isUser ? (data.source && object(data.source).kind !== 'user' ? 'Context' : 'You')
402
+ : event.type === 'tool/result' ? 'Tool' : 'Assistant';
403
+ const content = isUser ? array(data.content).map(object) : event.type === 'tool/result' ? blocks.filter(block => block.type === 'tool-result' && !calls.has(string(block.toolCallId))) : blocks;
404
+ const parts = content.map(block => {
405
+ const outcome = block.type === 'tool-call' ? outcomes.get(string(block.id)) : undefined;
406
+ if (outcome !== undefined)
407
+ return {
408
+ kind: outcome ? 'error' : 'success',
409
+ text: contentText([block], tools, width).replace(/^⚙/, outcome ? '✗' : '✓'),
410
+ };
411
+ return ({
412
+ kind: block.type === 'reasoning' ? 'reasoning' : block.type === 'tool-call' ? 'tool'
413
+ : block.type === 'tool-result' ? block.isError ? 'error' : 'success' : 'text',
414
+ text: contentText([block], tools, width),
415
+ });
416
+ }).filter(part => part.text);
417
+ if (!parts.length && event.type === 'tool/result' && !blocks.some(block => block.type === 'tool-result'))
418
+ parts.push({ kind: 'success', text: toolLine('✓ tool · completed', width) });
419
+ const message = parts.length ? { seq, role, parts,
420
+ get text() { return parts.map(part => part.text).join('\n'); },
421
+ get folded() {
422
+ return parts.some(part => part.kind === 'reasoning')
423
+ ? parts.map(part => part.kind === 'reasoning' ? toolLine(part.text, width) : part.text).join('\n') : undefined;
424
+ },
425
+ ...(isUser ? {} : { compact: event.type === 'tool/result' || blocks.every(block => block.type === 'tool-call') }) } : undefined;
426
+ this.displayedMessages.set(event, { width, prefix: tools.size, status, message });
427
+ if (message)
428
+ result.push(message);
186
429
  }
187
430
  return result;
188
431
  }
189
432
  /** Text from the active attempt is transient and never duplicated into durable history. */
190
433
  get liveText() { return this.liveTextForWidth(100); }
434
+ /** Attempt identity resets the view's live fold preference between responses. */
435
+ get liveAttemptKey() { return this.attempt ?? (this.legacyPosition || undefined); }
436
+ /** Whether an active attempt has content, without joining its potentially long reasoning. */
437
+ get hasLiveContent() { return this.blocks.size > 0; }
191
438
  /** Whether the active stream contains only tool operations, with no assistant prose. */
192
439
  get liveToolOnly() { return this.blocks.size > 0 && [...this.blocks.values()].every(block => block.type === 'tool-call'); }
193
440
  /** Render live tool operations within a terminal row.
@@ -195,9 +442,28 @@ export class Transcript {
195
442
  * @returns Transient assistant text with clipped tool rows.
196
443
  */
197
444
  liveTextForWidth(width) {
198
- return contentText([...this.blocks].sort(([a], [b]) => a - b).map(([, b]) => b), undefined, width);
445
+ return contentText([...this.blocks].sort(([a], [b]) => a - b).map(([, b]) => b), undefined, width, 'full');
446
+ }
447
+ /** Live semantic blocks; completed reasoning folds independently of the unfinished answer.
448
+ * @param width - Terminal width used for compact tool summaries.
449
+ * @returns Ordered blocks without terminal style escapes.
450
+ */
451
+ liveParts(width) {
452
+ return [...this.blocks].sort(([a], [b]) => a - b).map(([index, block]) => {
453
+ const closed = this.closedBlocks.has(index);
454
+ const cached = this.liveProjection.get(block);
455
+ if (cached?.width === width && cached.part.closed === closed)
456
+ return cached.part;
457
+ const part = {
458
+ kind: block.type === 'reasoning' ? 'reasoning' : block.type === 'tool-call' ? 'tool' : 'text',
459
+ text: cached?.width === width ? cached.part.text : contentText([block], undefined, width), closed,
460
+ };
461
+ this.liveProjection.set(block, { width, part });
462
+ return part;
463
+ });
199
464
  }
200
465
  addRecords(records) {
466
+ const added = [];
201
467
  for (const record of records) {
202
468
  const entry = object(record);
203
469
  if (entry.type !== 'event' && entry.type !== 'chunks') {
@@ -207,26 +473,78 @@ export class Transcript {
207
473
  if (entry.type === 'chunks' && !['chunkrow/text-chunks', 'chunkrow/reasoning-chunks', 'chunkrow/tool-call-chunks'].includes(string(event.type))) {
208
474
  throw new Error(`Unsupported packed history event: ${string(event.type).slice(0, 100)}`);
209
475
  }
210
- this.events.set(number(event.seq), event);
476
+ const seq = number(event.seq);
477
+ this.oldestSeq = Math.min(this.oldestSeq ?? seq, seq);
478
+ this.throughSeq = Math.max(this.throughSeq, seq);
479
+ if ((event.type === 'turn/start' || event.type === 'turn/end') && seq >= (this.turnMarker?.seq ?? -1)) {
480
+ this.turnMarker = { seq, start: event.type === 'turn/start' && typeof event.time === 'number' && Number.isFinite(event.time) ? event.time : undefined };
481
+ }
482
+ const current = this.events.get(seq);
483
+ this.storeEvent(seq, retainedEvent(event));
484
+ if (!DISPLAY_EVENTS.has(string(event.type)))
485
+ this.transientSeqs.add(seq);
486
+ else
487
+ this.transientSeqs.delete(seq);
488
+ if (current === event)
489
+ continue;
490
+ added.push(event);
491
+ if (this.keysDirty || seq <= (this.sortedSeqs[this.sortedSeqs.length - 1] ?? -1))
492
+ this.keysDirty = true;
493
+ else
494
+ this.sortedSeqs.push(seq);
495
+ if (DISPLAY_EVENTS.has(string(event.type)))
496
+ this.projectionRevision++;
497
+ if (seq <= this.legacySeq)
498
+ this.legacyDirty = true;
211
499
  }
212
500
  if (this.legacyStream)
213
- this.rebuildLegacyStream();
501
+ this.foldLegacy(added);
502
+ // Only the unfinished legacy attempt needs chunk payloads for an older-page replay.
503
+ let through;
504
+ for (const event of added)
505
+ if (['step/start', 'step/end', 'turn/end', 'assistant/message', 'assistant/attempt'].includes(string(event.type)))
506
+ through = Math.max(through ?? -1, number(event.seq));
507
+ if (!this.legacyStream || through !== undefined) {
508
+ for (const seq of this.transientSeqs) {
509
+ if (!this.legacyStream || seq < through) {
510
+ this.deleteEvent(seq);
511
+ this.transientSeqs.delete(seq);
512
+ this.keysDirty = true;
513
+ }
514
+ }
515
+ }
214
516
  }
215
- /** Older hosts log chunks directly; replay only the unfinished attempt for live display. */
216
- rebuildLegacyStream() {
217
- this.blocks.clear();
218
- let position = '';
219
- for (const [, event] of [...this.events].sort(([a], [b]) => a - b)) {
517
+ /** Older hosts log chunks directly; fold the unfinished attempt into the live tail for display.
518
+ * A page behind the folded position, or a replaced event, forces a full re-fold; streamed chunks extend it.
519
+ */
520
+ foldLegacy(added) {
521
+ let events;
522
+ if (this.legacyDirty) {
523
+ this.blocks.clear();
524
+ this.closedBlocks.clear();
525
+ this.legacyPosition = '';
526
+ this.legacySeq = -1;
527
+ this.legacyDirty = false;
528
+ events = this.sortedKeys().map(seq => this.events.get(seq));
529
+ }
530
+ else {
531
+ events = added.filter(event => number(event.seq) > this.legacySeq).sort((a, b) => number(a.seq) - number(b.seq));
532
+ }
533
+ for (const event of events) {
534
+ this.legacySeq = Math.max(this.legacySeq, number(event.seq));
220
535
  if (['step/start', 'step/end', 'turn/end', 'assistant/message', 'assistant/attempt'].includes(string(event.type))) {
221
536
  this.blocks.clear();
222
- position = '';
537
+ this.closedBlocks.clear();
538
+ this.legacyPosition = '';
223
539
  }
224
540
  else if (event.type === 'assistant/chunk' || string(event.type).startsWith('chunkrow/')) {
225
541
  const data = object(event.data);
226
542
  const nextPosition = `${number(data.turn)}:${number(data.step)}`;
227
- if (position !== nextPosition)
543
+ if (this.legacyPosition !== nextPosition) {
228
544
  this.blocks.clear();
229
- position = nextPosition;
545
+ this.closedBlocks.clear();
546
+ }
547
+ this.legacyPosition = nextPosition;
230
548
  switch (event.type) {
231
549
  case 'assistant/chunk':
232
550
  this.chunk(object(data.chunk));
@@ -255,6 +573,11 @@ export class Transcript {
255
573
  throw new Error('Invalid packed history member count');
256
574
  }
257
575
  chunk(chunk) {
576
+ if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
577
+ for (const [index, block] of this.blocks)
578
+ if (block.type === 'reasoning' && index !== chunk.index)
579
+ this.closedBlocks.add(index);
580
+ }
258
581
  if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
259
582
  const index = number(chunk.index);
260
583
  const block = this.blocks.get(index);
@@ -268,8 +591,10 @@ export class Transcript {
268
591
  name: typeof chunk.name === 'string' ? chunk.name : block?.name ?? '',
269
592
  arguments: '' });
270
593
  }
271
- else if (chunk.type === 'block-end')
594
+ else if (chunk.type === 'block-end') {
272
595
  this.blocks.set(number(chunk.index), object(chunk.block));
596
+ this.closedBlocks.add(number(chunk.index));
597
+ }
273
598
  }
274
599
  }
275
600
  function number(value) {
@@ -277,3 +602,22 @@ function number(value) {
277
602
  throw new Error('Invalid sequence number');
278
603
  return value;
279
604
  }
605
+ /** Keep only user-visible content in durable client memory; the host owns raw tool results. */
606
+ function retainedEvent(event) {
607
+ if (!DISPLAY_EVENTS.has(string(event.type)))
608
+ return event;
609
+ if (event.surfaceOp !== 'append')
610
+ return { seq: event.seq, type: event.type };
611
+ const data = object(event.data);
612
+ const clean = (value) => {
613
+ const block = object(value);
614
+ if (block.type === 'tool-call')
615
+ return { type: 'tool-call', id: block.id, ...toolSummary(block) };
616
+ if (block.type === 'tool-result')
617
+ return { type: 'tool-result', toolCallId: block.toolCallId, isError: block.isError === true };
618
+ return block;
619
+ };
620
+ return { seq: event.seq, type: event.type, surfaceOp: 'append', data: event.type === 'user/message'
621
+ ? { content: data.content, ...(data.source ? { source: data.source } : {}) }
622
+ : { message: { content: array(object(data.message).content).filter(block => event.type !== 'tool/result' || object(block).type === 'tool-result').map(clean) } } };
623
+ }
package/dsht.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itookit/dsht",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
6
6
  "engines": {
@@ -33,7 +33,8 @@
33
33
  "test:package": "node scripts/test-package.mjs",
34
34
  "prepack": "npm run build",
35
35
  "prepublishOnly": "npm run typecheck && npm run test:terminal",
36
- "bench:input": "tsx scripts/benchmark-input.tsx"
36
+ "bench:input": "tsx scripts/benchmark-input.tsx",
37
+ "bench:history": "node --import tsx scripts/benchmark-history.tsx"
37
38
  },
38
39
  "keywords": [
39
40
  "deepseek",
@@ -67,6 +68,7 @@
67
68
  "dependencies": {
68
69
  "ink": "^6.8.0",
69
70
  "react": "^19.2.0",
71
+ "slice-ansi": "^8.0.0",
70
72
  "wrap-ansi": "^9.0.2",
71
73
  "ws": "^8.21.0"
72
74
  },