@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.
- package/README.i18n.yaml +2 -2
- package/README.md +47 -26
- package/README.zh.md +47 -26
- package/dist/app.d.ts +16 -1
- package/dist/app.js +450 -73
- package/dist/cli.js +6 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.js +3 -0
- package/dist/controller.d.ts +80 -3
- package/dist/controller.js +350 -75
- package/dist/cost-view.js +3 -1
- package/dist/history-view.d.ts +8 -0
- package/dist/history-view.js +12 -0
- package/dist/history.d.ts +29 -12
- package/dist/history.js +172 -27
- package/dist/input-history.d.ts +19 -0
- package/dist/input-history.js +43 -0
- package/dist/input.d.ts +3 -1
- package/dist/input.js +3 -1
- package/dist/memory.d.ts +13 -0
- package/dist/memory.js +22 -0
- package/dist/mouse.d.ts +3 -1
- package/dist/mouse.js +15 -4
- package/dist/navigation.d.ts +1 -1
- package/dist/navigation.js +2 -2
- package/dist/status.d.ts +3 -1
- package/dist/status.js +63 -42
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +9 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +15 -0
- package/dist/transcript.d.ts +91 -6
- package/dist/transcript.js +393 -49
- package/dsht.png +0 -0
- package/package.json +4 -2
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Human transcript projection from durable events and ephemeral assistant chunks. */
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
|
28
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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 (
|
|
543
|
+
if (this.legacyPosition !== nextPosition) {
|
|
228
544
|
this.blocks.clear();
|
|
229
|
-
|
|
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.
|
|
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
|
},
|