@itookit/dsht 0.1.0

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.
@@ -0,0 +1,279 @@
1
+ /** Human transcript projection from durable events and ephemeral assistant chunks. */
2
+ import wrapAnsi from 'wrap-ansi';
3
+ import { array, object, safeText, string } from "./wire.js";
4
+ function toolSummary(block) {
5
+ let args = {};
6
+ if (typeof block.arguments === 'string' && block.arguments.trimStart().startsWith('{')) {
7
+ try {
8
+ const value = JSON.parse(block.arguments);
9
+ if (value !== null && typeof value === 'object' && !Array.isArray(value))
10
+ args = value;
11
+ }
12
+ catch { /* Partial or non-JSON arguments have no operation summary. */ }
13
+ }
14
+ const operation = [args.description, args.command, args.cmd, args.path, args.filePath, args.file_path, args.query]
15
+ .find(value => typeof value === 'string' && value.trim());
16
+ return { name: string(block.name), ...(typeof operation === 'string' ? { operation } : {}) };
17
+ }
18
+ /** Fit a tool operation to one terminal row without exposing the result body.
19
+ * @param text - Tool name, status icon and optional operation.
20
+ * @param width - Available terminal columns.
21
+ * @returns A single line with an ellipsis when shortened.
22
+ */
23
+ export function toolLine(text, width) {
24
+ const clean = safeText(text).replace(/\s+/gu, ' ').trim();
25
+ if (width < 2)
26
+ 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;
30
+ }
31
+ /** Render known content blocks and preserve unknown plugin blocks as JSON. */
32
+ export function contentText(content, tools, width = 100) {
33
+ return array(content).map(value => {
34
+ const block = object(value);
35
+ switch (block.type) {
36
+ case 'text': return string(block.text);
37
+ case 'reasoning': return `◇ ${string(block.text)}`;
38
+ case 'tool-call': {
39
+ const tool = toolSummary(block);
40
+ return toolLine(`⚙ ${tool.name}${tool.operation ? ` · ${tool.operation}` : ''}`, width);
41
+ }
42
+ case 'tool-result': {
43
+ const tool = tools?.get(String(block.toolCallId));
44
+ return toolLine(`${block.isError ? '✗' : '✓'} ${tool?.name ?? 'tool'} · ${tool?.operation ?? (block.isError ? 'failed' : 'completed')}`, width);
45
+ }
46
+ default: return JSON.stringify(block);
47
+ }
48
+ }).map(safeText).join('\n');
49
+ }
50
+ /** Opening snapshots replace all state; durable events are deduplicated by sequence. */
51
+ export class Transcript {
52
+ events = new Map();
53
+ blocks = new Map();
54
+ attempt;
55
+ nextIndex = 0;
56
+ revision = 0;
57
+ legacyStream = false;
58
+ /** Changes whenever a follow frame or older page can affect the rendered transcript. */
59
+ version = 0;
60
+ cursor = -1;
61
+ hasMore = false;
62
+ ready = false;
63
+ /** Apply one follow frame; reject stream gaps so callers can reopen a snapshot. */
64
+ accept(value) {
65
+ this.version++;
66
+ const frame = object(value);
67
+ switch (frame.type) {
68
+ case 'snapshot': {
69
+ this.ready = false;
70
+ this.events.clear();
71
+ this.blocks.clear();
72
+ this.legacyStream = frame.assistantStream === undefined;
73
+ this.cursor = number(frame.cursor);
74
+ this.hasMore = frame.hasMore === true;
75
+ this.addRecords(array(frame.records));
76
+ const baseline = object(frame.assistantStream ?? { revision: 0 });
77
+ this.revision = number(baseline.revision);
78
+ this.attempt = undefined;
79
+ if (baseline.activeAttempt) {
80
+ const attempt = object(baseline.activeAttempt);
81
+ this.attempt = string(attempt.attemptId);
82
+ this.nextIndex = number(attempt.nextIndex);
83
+ for (const value of array(attempt.stream)) {
84
+ const record = object(value);
85
+ if (record.type === 'chunk')
86
+ this.chunk(object(record.chunk));
87
+ else if (record.type === 'text-chunks' || record.type === 'reasoning-chunks') {
88
+ this.chunk({ type: record.type === 'text-chunks' ? 'text-delta' : 'reasoning-delta',
89
+ index: number(record.index), text: array(record.texts).map(string).join('') });
90
+ }
91
+ else if (record.type === 'tool-call-chunks') {
92
+ this.chunk({ type: 'tool-call-delta', index: number(record.index), id: string(record.id),
93
+ name: typeof record.name === 'string' ? record.name : '',
94
+ argumentsDelta: array(record.args).map(string).join('') });
95
+ }
96
+ }
97
+ }
98
+ this.ready = true;
99
+ break;
100
+ }
101
+ case 'event':
102
+ case 'chunks':
103
+ this.addRecords([frame]);
104
+ break;
105
+ case 'assistant-stream': {
106
+ const live = object(frame.frame);
107
+ if (number(live.revision) !== this.revision + 1)
108
+ throw new Error('Assistant stream revision gap');
109
+ this.revision = number(live.revision);
110
+ if (live.type === 'start') {
111
+ this.blocks.clear();
112
+ this.attempt = string(live.attemptId);
113
+ this.nextIndex = 0;
114
+ }
115
+ else {
116
+ if (live.attemptId !== this.attempt || number(live.index) !== this.nextIndex) {
117
+ throw new Error('Assistant stream chunk gap');
118
+ }
119
+ if (live.type === 'chunk') {
120
+ this.chunk(object(live.chunk));
121
+ this.nextIndex++;
122
+ }
123
+ else if (live.type === 'end') {
124
+ this.attempt = undefined;
125
+ this.blocks.clear();
126
+ }
127
+ else
128
+ throw new Error('Unknown assistant stream frame');
129
+ }
130
+ break;
131
+ }
132
+ default: throw new Error('Unknown session follow frame');
133
+ }
134
+ }
135
+ /** Add an older page without replacing the live tail. */
136
+ addPage(value) {
137
+ this.version++;
138
+ const page = object(value);
139
+ this.addRecords(array(page.records));
140
+ this.hasMore = page.hasMore === true;
141
+ }
142
+ /** Start of the open durable turn, when its timestamp is present in the retained window. */
143
+ 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;
150
+ }
151
+ return start;
152
+ }
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
+ /** Return append-origin conversation only; model-only replacement copies stay hidden. */
156
+ get messages() { return this.messagesForWidth(100); }
157
+ /** Project history with single-row tool operations at the caller's terminal width.
158
+ * @param width - Available terminal columns.
159
+ * @returns Conversation messages with compact tool-only rows.
160
+ */
161
+ messagesForWidth(width) {
162
+ const result = [];
163
+ const tools = new Map();
164
+ for (const [seq, event] of [...this.events].sort(([a], [b]) => a - b)) {
165
+ if (event.surfaceOp !== 'append')
166
+ continue;
167
+ 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) });
171
+ }
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') });
185
+ }
186
+ }
187
+ return result;
188
+ }
189
+ /** Text from the active attempt is transient and never duplicated into durable history. */
190
+ get liveText() { return this.liveTextForWidth(100); }
191
+ /** Whether the active stream contains only tool operations, with no assistant prose. */
192
+ get liveToolOnly() { return this.blocks.size > 0 && [...this.blocks.values()].every(block => block.type === 'tool-call'); }
193
+ /** Render live tool operations within a terminal row.
194
+ * @param width - Available terminal columns.
195
+ * @returns Transient assistant text with clipped tool rows.
196
+ */
197
+ liveTextForWidth(width) {
198
+ return contentText([...this.blocks].sort(([a], [b]) => a - b).map(([, b]) => b), undefined, width);
199
+ }
200
+ addRecords(records) {
201
+ for (const record of records) {
202
+ const entry = object(record);
203
+ if (entry.type !== 'event' && entry.type !== 'chunks') {
204
+ throw new Error(`Unsupported history record type: ${typeof entry.type === 'string' ? entry.type.slice(0, 100) : typeof entry.type}`);
205
+ }
206
+ const event = object(entry.event);
207
+ if (entry.type === 'chunks' && !['chunkrow/text-chunks', 'chunkrow/reasoning-chunks', 'chunkrow/tool-call-chunks'].includes(string(event.type))) {
208
+ throw new Error(`Unsupported packed history event: ${string(event.type).slice(0, 100)}`);
209
+ }
210
+ this.events.set(number(event.seq), event);
211
+ }
212
+ if (this.legacyStream)
213
+ this.rebuildLegacyStream();
214
+ }
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)) {
220
+ if (['step/start', 'step/end', 'turn/end', 'assistant/message', 'assistant/attempt'].includes(string(event.type))) {
221
+ this.blocks.clear();
222
+ position = '';
223
+ }
224
+ else if (event.type === 'assistant/chunk' || string(event.type).startsWith('chunkrow/')) {
225
+ const data = object(event.data);
226
+ const nextPosition = `${number(data.turn)}:${number(data.step)}`;
227
+ if (position !== nextPosition)
228
+ this.blocks.clear();
229
+ position = nextPosition;
230
+ switch (event.type) {
231
+ case 'assistant/chunk':
232
+ this.chunk(object(data.chunk));
233
+ break;
234
+ case 'chunkrow/text-chunks':
235
+ case 'chunkrow/reasoning-chunks': {
236
+ const texts = array(data.texts).map(string);
237
+ this.validatePackedTiming(data, texts.length);
238
+ this.chunk({ type: event.type === 'chunkrow/text-chunks' ? 'text-delta' : 'reasoning-delta',
239
+ index: number(data.index), text: texts.join('') });
240
+ break;
241
+ }
242
+ case 'chunkrow/tool-call-chunks': {
243
+ const args = array(data.args).map(string);
244
+ this.validatePackedTiming(data, args.length);
245
+ this.chunk({ type: 'tool-call-delta', index: number(data.index), id: string(data.id),
246
+ ...(data.name === undefined ? {} : { name: string(data.name) }), argumentsDelta: args.join('') });
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ validatePackedTiming(data, count) {
254
+ if (count === 0 || array(data.dt).length !== count - 1)
255
+ throw new Error('Invalid packed history member count');
256
+ }
257
+ chunk(chunk) {
258
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
259
+ const index = number(chunk.index);
260
+ const block = this.blocks.get(index);
261
+ this.blocks.set(index, { type: chunk.type === 'text-delta' ? 'text' : 'reasoning',
262
+ text: (block ? string(block.text) : '') + string(chunk.text) });
263
+ }
264
+ else if (chunk.type === 'tool-call-delta') {
265
+ const index = number(chunk.index);
266
+ const block = this.blocks.get(index);
267
+ this.blocks.set(index, { type: 'tool-call', id: string(chunk.id),
268
+ name: typeof chunk.name === 'string' ? chunk.name : block?.name ?? '',
269
+ arguments: '' });
270
+ }
271
+ else if (chunk.type === 'block-end')
272
+ this.blocks.set(number(chunk.index), object(chunk.block));
273
+ }
274
+ }
275
+ function number(value) {
276
+ if (typeof value !== 'number' || !Number.isSafeInteger(value))
277
+ throw new Error('Invalid sequence number');
278
+ return value;
279
+ }
package/dist/wire.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** JSON validation and terminal-safe presentation at the remote boundary. */
2
+ export type Json = null | boolean | number | string | Json[] | {
3
+ [key: string]: Json;
4
+ };
5
+ export type ObjectValue = {
6
+ [key: string]: Json;
7
+ };
8
+ /** Require an object from a decoded wire message. */
9
+ export declare function object(value: unknown): ObjectValue;
10
+ /** Require a string field rather than silently accepting protocol drift. */
11
+ export declare function string(value: unknown): string;
12
+ /** Require an array field from the server. */
13
+ export declare function array(value: unknown): Json[];
14
+ /** Remove terminal controls from remote text while retaining line breaks and tabs. */
15
+ export declare function safeText(value: string): string;
16
+ /** Return a displayable error without serializing request headers or credentials. */
17
+ export declare function errorText(error: unknown): string;
package/dist/wire.js ADDED
@@ -0,0 +1,27 @@
1
+ /** Require an object from a decoded wire message. */
2
+ export function object(value) {
3
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
4
+ throw new Error('Expected a JSON object from the server');
5
+ }
6
+ return value;
7
+ }
8
+ /** Require a string field rather than silently accepting protocol drift. */
9
+ export function string(value) {
10
+ if (typeof value !== 'string')
11
+ throw new Error('Expected a string from the server');
12
+ return value;
13
+ }
14
+ /** Require an array field from the server. */
15
+ export function array(value) {
16
+ if (!Array.isArray(value))
17
+ throw new Error('Expected an array from the server');
18
+ return value;
19
+ }
20
+ /** Remove terminal controls from remote text while retaining line breaks and tabs. */
21
+ export function safeText(value) {
22
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '');
23
+ }
24
+ /** Return a displayable error without serializing request headers or credentials. */
25
+ export function errorText(error) {
26
+ return safeText(error instanceof Error ? error.message : String(error));
27
+ }
package/dsht.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@itookit/dsht",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Standalone terminal client for the DeepSeek Harness HTTP gateway",
6
+ "engines": {
7
+ "node": ">=22.19"
8
+ },
9
+ "bin": {
10
+ "dsht": "dist/cli.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "README.zh.md",
16
+ "dsht.png"
17
+ ],
18
+ "main": "dist/client.js",
19
+ "types": "dist/client.d.ts",
20
+ "exports": {
21
+ ".": "./dist/client.js",
22
+ "./auth": "./dist/auth.js"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "start": "tsx src/cli.tsx",
29
+ "build": "tsc",
30
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
31
+ "test": "tsx --test tests/*.test.ts tests/*.test.tsx",
32
+ "test:terminal": "node scripts/test-terminal.mjs",
33
+ "test:package": "node scripts/test-package.mjs",
34
+ "prepack": "npm run build",
35
+ "prepublishOnly": "npm run typecheck && npm run test:terminal",
36
+ "bench:input": "tsx scripts/benchmark-input.tsx"
37
+ },
38
+ "keywords": [
39
+ "deepseek",
40
+ "deepseek-harness",
41
+ "tui",
42
+ "terminal",
43
+ "ink",
44
+ "cli",
45
+ "client",
46
+ "http",
47
+ "websocket",
48
+ "cost"
49
+ ],
50
+ "author": "lizlok@gmail.com",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/mushuanli/dsht.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/mushuanli/dsht/issues"
58
+ },
59
+ "homepage": "https://github.com/mushuanli/dsht#readme",
60
+ "dependencies": {
61
+ "ink": "^6.8.0",
62
+ "react": "^19.2.0",
63
+ "wrap-ansi": "^9.0.2",
64
+ "ws": "^8.21.0"
65
+ },
66
+ "devDependencies": {
67
+ "@types/node": "^22.19.0",
68
+ "@types/react": "^19.2.0",
69
+ "@types/ws": "^8.18.1",
70
+ "ink-testing-library": "^4.0.0",
71
+ "tsx": "^4.22.0",
72
+ "typescript": "^5.9.3"
73
+ }
74
+ }