@harness-nexus/cli 0.1.0-alpha.2 → 0.1.0-alpha.3
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/dist/daemon/acp/adapters.d.ts.map +1 -1
- package/dist/daemon/acp/adapters.js +9 -3
- package/dist/daemon/acp/adapters.js.map +1 -1
- package/dist/daemon/acp/agent-connection.d.ts +30 -1
- package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
- package/dist/daemon/acp/agent-connection.js +59 -6
- package/dist/daemon/acp/agent-connection.js.map +1 -1
- package/dist/daemon/chat.d.ts +9 -1
- package/dist/daemon/chat.d.ts.map +1 -1
- package/dist/daemon/chat.js +642 -37
- package/dist/daemon/chat.js.map +1 -1
- package/dist/daemon/client.d.ts +10 -2
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +99 -23
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/config-view.d.ts +42 -0
- package/dist/daemon/config-view.d.ts.map +1 -0
- package/dist/daemon/config-view.js +159 -0
- package/dist/daemon/config-view.js.map +1 -0
- package/dist/daemon/dsh-sessions.d.ts +161 -0
- package/dist/daemon/dsh-sessions.d.ts.map +1 -0
- package/dist/daemon/dsh-sessions.js +674 -0
- package/dist/daemon/dsh-sessions.js.map +1 -0
- package/dist/daemon/dsh-tap/index.mjs +99 -0
- package/dist/daemon/dsh-tap-listener.d.ts +62 -0
- package/dist/daemon/dsh-tap-listener.d.ts.map +1 -0
- package/dist/daemon/dsh-tap-listener.js +196 -0
- package/dist/daemon/dsh-tap-listener.js.map +1 -0
- package/dist/daemon/jobs.d.ts +8 -5
- package/dist/daemon/jobs.d.ts.map +1 -1
- package/dist/daemon/jobs.js +28 -8
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/runtime-config.d.ts +34 -0
- package/dist/daemon/runtime-config.d.ts.map +1 -0
- package/dist/daemon/runtime-config.js +343 -0
- package/dist/daemon/runtime-config.js.map +1 -0
- package/dist/daemon/runtime.d.ts +49 -0
- package/dist/daemon/runtime.d.ts.map +1 -0
- package/dist/daemon/runtime.js +218 -0
- package/dist/daemon/runtime.js.map +1 -0
- package/dist/daemon/sessions.d.ts +21 -0
- package/dist/daemon/sessions.d.ts.map +1 -0
- package/dist/daemon/sessions.js +125 -0
- package/dist/daemon/sessions.js.map +1 -0
- package/dist/daemon/workspace.d.ts +3 -0
- package/dist/daemon/workspace.d.ts.map +1 -0
- package/dist/daemon/workspace.js +27 -0
- package/dist/daemon/workspace.js.map +1 -0
- package/dist/install/adapters/deepseek.d.ts +4 -0
- package/dist/install/adapters/deepseek.d.ts.map +1 -1
- package/dist/install/adapters/deepseek.js +4 -2
- package/dist/install/adapters/deepseek.js.map +1 -1
- package/dist/inventory/common.d.ts +2 -0
- package/dist/inventory/common.d.ts.map +1 -1
- package/dist/inventory/common.js.map +1 -1
- package/dist/inventory/runtime.d.ts +40 -0
- package/dist/inventory/runtime.d.ts.map +1 -0
- package/dist/inventory/runtime.js +114 -0
- package/dist/inventory/runtime.js.map +1 -0
- package/dist/inventory/scanners/claude-code.d.ts +2 -0
- package/dist/inventory/scanners/claude-code.d.ts.map +1 -1
- package/dist/inventory/scanners/claude-code.js +103 -39
- package/dist/inventory/scanners/claude-code.js.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import * as zlib from 'node:zlib';
|
|
2
|
+
/**
|
|
3
|
+
* dsh native session store reader (Phase 9 W7).
|
|
4
|
+
* docs/design/phase-9-w7-native-sessions.md § ground truth.
|
|
5
|
+
*
|
|
6
|
+
* dsh persists every session at `~/.dsh/sessions/<cwd-slug>/<uuid>/
|
|
7
|
+
* session.jsonl.zstd` as MULTI-FRAME zstd — one frame per write batch (the
|
|
8
|
+
* engine appends every LLM chunk as a `assistant/chunk` session event; a
|
|
9
|
+
* bounded write-behind window coalesces them ~100–300ms apart). Node's
|
|
10
|
+
* one-shot AND stream zstd decoders stop after the first frame, so the reader
|
|
11
|
+
* scans the frame magic (`28 B5 2F FD`), slices, and decodes each frame
|
|
12
|
+
* separately. Delta runs reach disk in TWO shapes — packed `text-chunks`/
|
|
13
|
+
* `reasoning-chunks`/`tool-call-chunks` rows (runs of ≥3 seq-contiguous
|
|
14
|
+
* same-block deltas) and verbatim `assistant/chunk` events below that
|
|
15
|
+
* (MIN_RUN=3 fall-through; shapes verified against dsh 0.1.2-rc.1 source).
|
|
16
|
+
* Everything here is pure/bounded and injectable for tests; the zstd binding
|
|
17
|
+
* is feature-detected (needs Node ≥ 22.15 — machines running dsh already
|
|
18
|
+
* require it, but the daemon degrades gracefully below it).
|
|
19
|
+
*
|
|
20
|
+
* dsh's ACP adapter surfaces only COMMITTED updates — a whole reasoning/text
|
|
21
|
+
* block arrives as ONE chunk when the turn ends, so there is no streaming
|
|
22
|
+
* over the protocol. The transcript file, however, is appended LIVE with the
|
|
23
|
+
* token batches, which is what `TranscriptTail` turns into real-time deltas.
|
|
24
|
+
*/
|
|
25
|
+
/** zstd frame magic, big-endian as stored. */
|
|
26
|
+
const ZSTD_MAGIC = 0x28_b5_2f_fd;
|
|
27
|
+
/** The native `zlib.zstdDecompressSync`, or null on Node < 22.15. */
|
|
28
|
+
export function nativeZstd() {
|
|
29
|
+
const fn = zlib.zstdDecompressSync;
|
|
30
|
+
return typeof fn === 'function' ? fn : null;
|
|
31
|
+
}
|
|
32
|
+
/** Slice a concatenated-zstd buffer into its frames (magic scan). */
|
|
33
|
+
export function splitZstdFrames(buf) {
|
|
34
|
+
const frames = [];
|
|
35
|
+
let start = -1;
|
|
36
|
+
for (let i = 0; i + 4 <= buf.length; i++) {
|
|
37
|
+
if (buf.readUInt32BE(i) === ZSTD_MAGIC) {
|
|
38
|
+
if (start !== -1)
|
|
39
|
+
frames.push(buf.subarray(start, i));
|
|
40
|
+
start = i;
|
|
41
|
+
i += 3;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (start !== -1)
|
|
45
|
+
frames.push(buf.subarray(start));
|
|
46
|
+
return frames;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Decode every frame and parse the JSONL entries. Undecodable frames (a magic
|
|
50
|
+
* hit inside compressed data — ~2^-32 per position) and non-JSON lines are
|
|
51
|
+
* skipped, never fatal. Entries are capped so a runaway transcript can't
|
|
52
|
+
* exhaust memory.
|
|
53
|
+
*/
|
|
54
|
+
export function decodeTranscript(buf, decompress, maxEntries = 20000) {
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const frame of splitZstdFrames(buf)) {
|
|
57
|
+
let text;
|
|
58
|
+
try {
|
|
59
|
+
text = decompress(frame).toString('utf8');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const line of text.split('\n')) {
|
|
65
|
+
if (line.trim() === '')
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(line);
|
|
69
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
70
|
+
entries.push(parsed);
|
|
71
|
+
if (entries.length >= maxEntries)
|
|
72
|
+
return entries;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// non-JSON noise — skip
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Header + derived title + pinned model from the FRONT of a transcript
|
|
84
|
+
* (dsh's own `session/list` exposes none of these). Stops as soon as all are
|
|
85
|
+
* found; bounded read so a huge transcript costs at most `maxBytes`. NB: the
|
|
86
|
+
* `session` HEADER entry carries its fields at the TOP level (no `data`
|
|
87
|
+
* wrapper), unlike every other entry type.
|
|
88
|
+
*/
|
|
89
|
+
export function scanSummary(buf, decompress, maxBytes = 64 * 1024) {
|
|
90
|
+
const bounded = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
|
|
91
|
+
let header = null;
|
|
92
|
+
let title = null;
|
|
93
|
+
let model = null;
|
|
94
|
+
// A real user turn (`agent/inbox/spliced`) — the marker that separates a
|
|
95
|
+
// LIVED-IN session from dsh's config-preamble-only files (every fresh
|
|
96
|
+
// session writes session/permission-preset/sandbox-mode/approval-policy
|
|
97
|
+
// rows before any conversation, so header presence is NOT enough).
|
|
98
|
+
let hasTurn = false;
|
|
99
|
+
for (const frame of splitZstdFrames(bounded)) {
|
|
100
|
+
let text;
|
|
101
|
+
try {
|
|
102
|
+
text = decompress(frame).toString('utf8');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const line of text.split('\n')) {
|
|
108
|
+
if (line.trim() === '')
|
|
109
|
+
continue;
|
|
110
|
+
try {
|
|
111
|
+
const e = JSON.parse(line);
|
|
112
|
+
if (e['type'] === 'session' && header === null) {
|
|
113
|
+
header = e; // top-level shape — see the doc comment
|
|
114
|
+
}
|
|
115
|
+
else if (e['type'] === 'session/title') {
|
|
116
|
+
const t = e['data']?.['title'];
|
|
117
|
+
if (typeof t === 'string' && t !== '')
|
|
118
|
+
title = t;
|
|
119
|
+
}
|
|
120
|
+
else if (e['type'] === 'request/header') {
|
|
121
|
+
const hdr = e['data']?.['header'];
|
|
122
|
+
const config = isRecord(hdr) ? hdr['config'] : undefined;
|
|
123
|
+
const m = isRecord(config) ? config['model'] : undefined;
|
|
124
|
+
if (typeof m === 'string' && m !== '')
|
|
125
|
+
model = m;
|
|
126
|
+
}
|
|
127
|
+
else if (e['type'] === 'agent/inbox/spliced') {
|
|
128
|
+
hasTurn = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// skip
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (header !== null && title !== null && model !== null && hasTurn)
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
return { header, title, model, hasTurn };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The model ids of OUR managed provider region in `~/.dsh/settings.yaml`
|
|
142
|
+
* (the W3 writer's own format — a `models:` list of `- id:` entries). Null
|
|
143
|
+
* when the region is absent/unparseable: the caller then annotates nothing.
|
|
144
|
+
*/
|
|
145
|
+
export function currentCatalogModels(settingsYaml) {
|
|
146
|
+
const region = settingsYaml.match(/# BEGIN harness-nexus \(managed\)([\s\S]*?)# END harness-nexus \(managed\)/);
|
|
147
|
+
if (region === null)
|
|
148
|
+
return null;
|
|
149
|
+
const ids = new Set();
|
|
150
|
+
for (const m of region[1].matchAll(/- id:\s*"?([^"\s\n]+)"?/g))
|
|
151
|
+
ids.add(m[1]);
|
|
152
|
+
return ids.size > 0 ? ids : null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* List dsh's ROOT sessions (subagent sessions carry `parentSession`/depth and
|
|
156
|
+
* are dsh's own resume-exclusions). Pure given the fs shims; newest first.
|
|
157
|
+
*/
|
|
158
|
+
export function dshListSessions(sessionsDir, fs, decompress, opts = {}) {
|
|
159
|
+
const max = opts.maxSessions ?? 100;
|
|
160
|
+
const out = [];
|
|
161
|
+
let slugs;
|
|
162
|
+
try {
|
|
163
|
+
slugs = fs.readdir(sessionsDir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return []; // no store yet — an honest empty listing
|
|
167
|
+
}
|
|
168
|
+
for (const slug of slugs) {
|
|
169
|
+
let ids;
|
|
170
|
+
try {
|
|
171
|
+
ids = fs.readdir(`${sessionsDir}/${slug}`);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const id of ids) {
|
|
177
|
+
if (out.length >= max)
|
|
178
|
+
break;
|
|
179
|
+
// Post-W8: sessions with a live channel are LISTED (no longer hidden) —
|
|
180
|
+
// the server marks them `open` and the rail offers a rejoin. dsh itself
|
|
181
|
+
// still refuses a fresh resume of an active session; that failure path
|
|
182
|
+
// is exactly why the row must rejoin the existing channel instead.
|
|
183
|
+
const file = `${sessionsDir}/${slug}/${id}/session.jsonl.zstd`;
|
|
184
|
+
let buf;
|
|
185
|
+
try {
|
|
186
|
+
buf = fs.readFile(file);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const { header, title, model, hasTurn } = scanSummary(buf, decompress);
|
|
192
|
+
// Header-only/config-preamble transcripts are NOT conversations: every
|
|
193
|
+
// dsh spawn writes them before any turn, and the -32605 startup-race
|
|
194
|
+
// retry leaves one behind per lost race (the "two unnamed sessions"
|
|
195
|
+
// double). Listing only lived-in sessions matches claude-code's
|
|
196
|
+
// transcript-on-first-message semantics; a live-but-still-empty channel
|
|
197
|
+
// still shows via the server's synthesized open row.
|
|
198
|
+
if (!hasTurn)
|
|
199
|
+
continue;
|
|
200
|
+
if (header === null)
|
|
201
|
+
continue;
|
|
202
|
+
if (typeof header.cwd !== 'string' || header.cwd === '' || !header.cwd.startsWith('/')) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (header.parentSession !== undefined || header.delegationDepth !== 0)
|
|
206
|
+
continue;
|
|
207
|
+
let mtimeMs;
|
|
208
|
+
try {
|
|
209
|
+
mtimeMs = fs.stat(file).mtimeMs;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
mtimeMs = 0;
|
|
213
|
+
}
|
|
214
|
+
out.push({
|
|
215
|
+
sessionId: id,
|
|
216
|
+
cwd: header.cwd,
|
|
217
|
+
title,
|
|
218
|
+
model,
|
|
219
|
+
createdAt: typeof header.createdAt === 'number' && Number.isFinite(header.createdAt)
|
|
220
|
+
? new Date(header.createdAt).toISOString()
|
|
221
|
+
: null,
|
|
222
|
+
updatedAt: mtimeMs > 0 ? new Date(mtimeMs).toISOString() : null,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const stamp = (s) => Date.parse(s.updatedAt ?? '') || Date.parse(s.createdAt ?? '') || 0;
|
|
227
|
+
out.sort((a, b) => stamp(b) - stamp(a) || b.sessionId.localeCompare(a.sessionId));
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
231
|
+
const OUTPUT_MAX = 100_000;
|
|
232
|
+
function bounded(raw, max) {
|
|
233
|
+
if (typeof raw !== 'string' || raw === '')
|
|
234
|
+
return undefined;
|
|
235
|
+
return raw.length <= max ? raw : raw.slice(0, max);
|
|
236
|
+
}
|
|
237
|
+
/** Parse dsh's tool `arguments` (a JSON string) into a bounded rawInput map. */
|
|
238
|
+
function parseArguments(raw) {
|
|
239
|
+
if (typeof raw !== 'string' || raw === '')
|
|
240
|
+
return undefined;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(raw);
|
|
243
|
+
if (isRecord(parsed))
|
|
244
|
+
return parsed;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// malformed — the toolName/title still render
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
/** The `tool/call` shape — in_progress with parsed arguments (shared: history + live tail). */
|
|
252
|
+
function toolStartEvent(callId, name, args) {
|
|
253
|
+
if (typeof callId !== 'string' || callId === '')
|
|
254
|
+
return null;
|
|
255
|
+
return {
|
|
256
|
+
kind: 'tool_call',
|
|
257
|
+
call: {
|
|
258
|
+
toolCallId: callId,
|
|
259
|
+
...(typeof name === 'string' && name !== '' ? { toolName: bounded(name, 128) } : {}),
|
|
260
|
+
status: 'in_progress',
|
|
261
|
+
...(parseArguments(args) !== undefined
|
|
262
|
+
? { rawInput: parseArguments(args) }
|
|
263
|
+
: {}),
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** The `tool/result` shape — completed with the joined output text (shared). */
|
|
268
|
+
function toolResultEvent(message) {
|
|
269
|
+
const source = message !== null && isRecord(message['source']) ? message['source'] : null;
|
|
270
|
+
const callId = source !== null ? source['callId'] : undefined;
|
|
271
|
+
if (typeof callId !== 'string' || callId === '')
|
|
272
|
+
return null;
|
|
273
|
+
const texts = [];
|
|
274
|
+
const content = message !== null ? message['content'] : undefined;
|
|
275
|
+
if (Array.isArray(content)) {
|
|
276
|
+
for (const c of content) {
|
|
277
|
+
if (!isRecord(c))
|
|
278
|
+
continue;
|
|
279
|
+
const inner = c['content'];
|
|
280
|
+
if (!Array.isArray(inner))
|
|
281
|
+
continue;
|
|
282
|
+
for (const piece of inner) {
|
|
283
|
+
if (isRecord(piece) && piece['type'] === 'text' && typeof piece['text'] === 'string') {
|
|
284
|
+
texts.push(piece['text']);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
kind: 'tool_call',
|
|
291
|
+
call: {
|
|
292
|
+
toolCallId: callId,
|
|
293
|
+
status: 'completed',
|
|
294
|
+
...(texts.length > 0 ? { output: bounded(texts.join('\n'), OUTPUT_MAX) } : {}),
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** The `turn:step` dedup key, or null when the entry carries no coordinates. */
|
|
299
|
+
function stepKey(data) {
|
|
300
|
+
const turn = data['turn'];
|
|
301
|
+
const step = data['step'];
|
|
302
|
+
return typeof turn === 'number' && typeof step === 'number' ? `${turn}:${step}` : null;
|
|
303
|
+
}
|
|
304
|
+
/** A usage event from a commit's `usage` payload (null when absent/malformed). */
|
|
305
|
+
function usageEvent(raw) {
|
|
306
|
+
if (!isRecord(raw))
|
|
307
|
+
return null;
|
|
308
|
+
const input = raw['inputTokens'];
|
|
309
|
+
const output = raw['outputTokens'];
|
|
310
|
+
if (typeof input !== 'number' && typeof output !== 'number')
|
|
311
|
+
return null;
|
|
312
|
+
return {
|
|
313
|
+
kind: 'usage',
|
|
314
|
+
...(typeof input === 'number' ? { inputTokens: input } : {}),
|
|
315
|
+
...(typeof output === 'number' ? { outputTokens: output } : {}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** The usage marker of a verbatim `assistant/chunk` row (history path). */
|
|
319
|
+
export function usageFromChunk(entry) {
|
|
320
|
+
const data = isRecord(entry['data']) ? entry['data'] : null;
|
|
321
|
+
if (data === null)
|
|
322
|
+
return null;
|
|
323
|
+
const chunk = isRecord(data['chunk']) ? data['chunk'] : null;
|
|
324
|
+
if (chunk === null || chunk['type'] !== 'usage')
|
|
325
|
+
return null;
|
|
326
|
+
return usageEvent(chunk['usage']);
|
|
327
|
+
}
|
|
328
|
+
export function createDshLiveMapper() {
|
|
329
|
+
const stepsWithDeltas = new Set();
|
|
330
|
+
return (entry) => {
|
|
331
|
+
const data = isRecord(entry['data']) ? entry['data'] : null;
|
|
332
|
+
if (data === null)
|
|
333
|
+
return [];
|
|
334
|
+
switch (entry['type']) {
|
|
335
|
+
case 'text-chunks':
|
|
336
|
+
case 'reasoning-chunks': {
|
|
337
|
+
const texts = data['texts'];
|
|
338
|
+
if (!Array.isArray(texts))
|
|
339
|
+
return [];
|
|
340
|
+
const delta = bounded(texts.filter((t) => typeof t === 'string').join(''), 100_000);
|
|
341
|
+
if (delta === undefined || delta === '')
|
|
342
|
+
return [];
|
|
343
|
+
const key = stepKey(data);
|
|
344
|
+
if (key !== null)
|
|
345
|
+
stepsWithDeltas.add(key);
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
kind: entry['type'] === 'reasoning-chunks' ? 'thought_delta' : 'message_delta',
|
|
349
|
+
delta,
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
case 'assistant/chunk': {
|
|
354
|
+
// Verbatim delta events (runs below the codec's MIN_RUN). The usage /
|
|
355
|
+
// block-start / block-end / finish / tool-call-delta chunks carry no
|
|
356
|
+
// renderable text — usage rides the commit instead.
|
|
357
|
+
const chunk = isRecord(data['chunk']) ? data['chunk'] : null;
|
|
358
|
+
if (chunk === null)
|
|
359
|
+
return [];
|
|
360
|
+
const kind = chunk['type'] === 'text-delta'
|
|
361
|
+
? 'message_delta'
|
|
362
|
+
: chunk['type'] === 'reasoning-delta'
|
|
363
|
+
? 'thought_delta'
|
|
364
|
+
: null;
|
|
365
|
+
if (kind === null)
|
|
366
|
+
return [];
|
|
367
|
+
const text = bounded(chunk['text'], 100_000);
|
|
368
|
+
if (text === undefined || text === '')
|
|
369
|
+
return [];
|
|
370
|
+
const key = stepKey(data);
|
|
371
|
+
if (key !== null)
|
|
372
|
+
stepsWithDeltas.add(key);
|
|
373
|
+
return [{ kind, delta: text }];
|
|
374
|
+
}
|
|
375
|
+
case 'assistant/message': {
|
|
376
|
+
const out = [];
|
|
377
|
+
const key = stepKey(data);
|
|
378
|
+
const streamed = key !== null && stepsWithDeltas.delete(key);
|
|
379
|
+
if (!streamed) {
|
|
380
|
+
const message = isRecord(data['message']) ? data['message'] : null;
|
|
381
|
+
const content = message !== null ? message['content'] : undefined;
|
|
382
|
+
if (Array.isArray(content)) {
|
|
383
|
+
for (const rawBlock of content) {
|
|
384
|
+
if (!isRecord(rawBlock))
|
|
385
|
+
continue;
|
|
386
|
+
const block = rawBlock;
|
|
387
|
+
if (block['type'] !== 'text' && block['type'] !== 'reasoning')
|
|
388
|
+
continue;
|
|
389
|
+
const text = bounded(block['text'], 100_000);
|
|
390
|
+
if (text !== undefined && text !== '') {
|
|
391
|
+
out.push({
|
|
392
|
+
kind: block['type'] === 'reasoning' ? 'thought_delta' : 'message_delta',
|
|
393
|
+
delta: text,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const usage = usageEvent(data['usage']);
|
|
400
|
+
if (usage !== null)
|
|
401
|
+
out.push(usage);
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
case 'tool/call': {
|
|
405
|
+
const event = toolStartEvent(data['callId'], data['name'], data['arguments']);
|
|
406
|
+
return event === null ? [] : [event];
|
|
407
|
+
}
|
|
408
|
+
case 'tool/result': {
|
|
409
|
+
const message = isRecord(data['message']) ? data['message'] : null;
|
|
410
|
+
const event = toolResultEvent(message);
|
|
411
|
+
return event === null ? [] : [event];
|
|
412
|
+
}
|
|
413
|
+
default:
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Map dsh transcript entries onto history items (user blocks + ordinary
|
|
420
|
+
* stream events — ONE fold path in the browser). Genuine user turns are the
|
|
421
|
+
* `agent/inbox/spliced` inserts; `user/message` echoes and the synthesized
|
|
422
|
+
* runtime-context messages are skipped.
|
|
423
|
+
*/
|
|
424
|
+
export function dshHistoryItems(entries) {
|
|
425
|
+
const items = [];
|
|
426
|
+
for (const entry of entries) {
|
|
427
|
+
const type = entry['type'];
|
|
428
|
+
const data = isRecord(entry['data']) ? entry['data'] : null;
|
|
429
|
+
if (data === null)
|
|
430
|
+
continue;
|
|
431
|
+
switch (type) {
|
|
432
|
+
case 'agent/inbox/spliced': {
|
|
433
|
+
const inserted = data['inserted'];
|
|
434
|
+
if (!Array.isArray(inserted))
|
|
435
|
+
break;
|
|
436
|
+
for (const raw of inserted) {
|
|
437
|
+
if (!isRecord(raw))
|
|
438
|
+
continue;
|
|
439
|
+
const source = isRecord(raw['source']) ? raw['source'] : null;
|
|
440
|
+
if (source === null || source['kind'] !== 'user')
|
|
441
|
+
continue;
|
|
442
|
+
const blocks = toPromptBlocks(raw['content']);
|
|
443
|
+
if (blocks.length > 0)
|
|
444
|
+
items.push({ type: 'user', blocks });
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
case 'assistant/message': {
|
|
449
|
+
const message = isRecord(data['message']) ? data['message'] : null;
|
|
450
|
+
const content = message !== null ? message['content'] : data['content'];
|
|
451
|
+
if (!Array.isArray(content))
|
|
452
|
+
break;
|
|
453
|
+
for (const rawBlock of content) {
|
|
454
|
+
if (!isRecord(rawBlock))
|
|
455
|
+
continue;
|
|
456
|
+
const block = rawBlock;
|
|
457
|
+
if (block['type'] === 'reasoning') {
|
|
458
|
+
const text = bounded(block['text'], 100_000);
|
|
459
|
+
if (text !== undefined && text !== '') {
|
|
460
|
+
items.push({ type: 'event', event: { kind: 'thought_delta', delta: text } });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else if (block['type'] === 'text') {
|
|
464
|
+
const text = bounded(block['text'], 100_000);
|
|
465
|
+
if (text !== undefined && text !== '') {
|
|
466
|
+
items.push({ type: 'event', event: { kind: 'message_delta', delta: text } });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else if (block['type'] === 'tool-call') {
|
|
470
|
+
const event = toolStartEvent(block['id'], block['name'], block['arguments']);
|
|
471
|
+
if (event !== null)
|
|
472
|
+
items.push({ type: 'event', event });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case 'tool/call': {
|
|
478
|
+
const event = toolStartEvent(data['callId'], data['name'], data['arguments']);
|
|
479
|
+
if (event !== null)
|
|
480
|
+
items.push({ type: 'event', event });
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
case 'tool/result': {
|
|
484
|
+
const message = isRecord(data['message']) ? data['message'] : null;
|
|
485
|
+
const event = toolResultEvent(message);
|
|
486
|
+
if (event !== null)
|
|
487
|
+
items.push({ type: 'event', event });
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case 'assistant/chunk': {
|
|
491
|
+
// Only the usage marker rides the history (the text chunks are
|
|
492
|
+
// superseded by assistant/message's complete blocks).
|
|
493
|
+
const event = usageFromChunk(entry);
|
|
494
|
+
if (event !== null)
|
|
495
|
+
items.push({ type: 'event', event });
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case 'turn/end': {
|
|
499
|
+
const reason = isRecord(data['reason']) ? data['reason'] : null;
|
|
500
|
+
items.push({
|
|
501
|
+
type: 'event',
|
|
502
|
+
event: {
|
|
503
|
+
kind: 'turn_result',
|
|
504
|
+
stopReason: reason?.['kind'] === 'cancelled' ? 'cancelled' : 'end_turn',
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
default:
|
|
510
|
+
break; // presets, request/*, step markers, deltas, title — not history
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return items;
|
|
514
|
+
}
|
|
515
|
+
/** dsh content blocks → prompt blocks (text only in v1, matching our wire). */
|
|
516
|
+
function toPromptBlocks(content) {
|
|
517
|
+
const blocks = [];
|
|
518
|
+
if (!Array.isArray(content))
|
|
519
|
+
return blocks;
|
|
520
|
+
for (const raw of content) {
|
|
521
|
+
if (isRecord(raw) && raw['type'] === 'text' && typeof raw['text'] === 'string') {
|
|
522
|
+
if (raw['text'] !== '')
|
|
523
|
+
blocks.push({ type: 'text', text: bounded(raw['text'], 32_000) });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return blocks;
|
|
527
|
+
}
|
|
528
|
+
// ---- live transcript tail (dsh's streaming source) ----
|
|
529
|
+
/** Locate a session's transcript by id across the slug dirs (null = absent). */
|
|
530
|
+
export function findTranscript(sessionsDir, sessionId, readdir) {
|
|
531
|
+
let slugs;
|
|
532
|
+
try {
|
|
533
|
+
slugs = readdir(sessionsDir);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
for (const slug of slugs) {
|
|
539
|
+
const candidate = `${sessionsDir}/${slug}/${sessionId}/session.jsonl.zstd`;
|
|
540
|
+
try {
|
|
541
|
+
readdir(`${sessionsDir}/${slug}/${sessionId}`);
|
|
542
|
+
return candidate; // the session dir exists — the file follows on first write
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// not under this slug
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Tails a dsh transcript for LIVE token streaming. Ground truth (dsh
|
|
552
|
+
* 0.1.2-rc.1 write path): every engine `assistant/chunk` event rides the
|
|
553
|
+
* session bus into a bounded write-behind batch (~100–300ms window) and lands
|
|
554
|
+
* as one zstd frame per batch — so polling the file yields token batches
|
|
555
|
+
* roughly one write window behind the model, while dsh's ACP adapter stays
|
|
556
|
+
* silent until commit. New bytes are split into frames, decoded, and mapped
|
|
557
|
+
* through a stateful `createDshLiveMapper` (packed rows AND verbatim chunk
|
|
558
|
+
* events, with the `turn:step` committed-dedup — see its doc).
|
|
559
|
+
*
|
|
560
|
+
* A trailing frame cut mid-write decodes only once its remaining bytes
|
|
561
|
+
* arrive: the last undecodable slice is buffered for the next poll (the
|
|
562
|
+
* writer completes it on the next batch or truncates it back on rollback).
|
|
563
|
+
* `start()` skips existing bytes (history was already rendered);
|
|
564
|
+
* `flush()` drains once more so a turn's final tokens stay ordered before
|
|
565
|
+
* its turn_result. A BOUNDED frame that fails to decode is corruption —
|
|
566
|
+
* `onFatal` fires and the tail stops (the committed path takes over).
|
|
567
|
+
*/
|
|
568
|
+
export class TranscriptTail {
|
|
569
|
+
file;
|
|
570
|
+
fs;
|
|
571
|
+
decompress;
|
|
572
|
+
onEvent;
|
|
573
|
+
opts;
|
|
574
|
+
pending = Buffer.alloc(0);
|
|
575
|
+
timer = null;
|
|
576
|
+
stopped = false;
|
|
577
|
+
offset = 0;
|
|
578
|
+
mapper = createDshLiveMapper();
|
|
579
|
+
/** Wall-clock of the last `turn/end` row seen (0 = none). */
|
|
580
|
+
lastTurnEndAt = 0;
|
|
581
|
+
constructor(file, fs, decompress, onEvent, opts = {}) {
|
|
582
|
+
this.file = file;
|
|
583
|
+
this.fs = fs;
|
|
584
|
+
this.decompress = decompress;
|
|
585
|
+
this.onEvent = onEvent;
|
|
586
|
+
this.opts = opts;
|
|
587
|
+
}
|
|
588
|
+
/** Begin polling. `replayFromBeginning` re-maps the whole file instead of
|
|
589
|
+
* skipping existing bytes — valid ONLY when nothing in it was ever
|
|
590
|
+
* rendered (a lazily-materialized new-session transcript attached mid-turn:
|
|
591
|
+
* replaying recovers the deltas that landed before the attach; the
|
|
592
|
+
* caller guarantees no earlier turn ran through the committed path). */
|
|
593
|
+
start(replayFromBeginning = false) {
|
|
594
|
+
this.offset = replayFromBeginning ? 0 : (this.fs.size(this.file) ?? 0);
|
|
595
|
+
this.timer = setInterval(() => this.poll(), this.opts.intervalMs ?? 250);
|
|
596
|
+
this.timer.unref?.();
|
|
597
|
+
}
|
|
598
|
+
/** Whether a `turn/end` row landed since `since` (the settle signal). */
|
|
599
|
+
turnEndSeenSince(since) {
|
|
600
|
+
return this.lastTurnEndAt >= since;
|
|
601
|
+
}
|
|
602
|
+
/** One synchronous drain (call before settling a turn). */
|
|
603
|
+
flush() {
|
|
604
|
+
if (!this.stopped)
|
|
605
|
+
this.poll();
|
|
606
|
+
}
|
|
607
|
+
stop() {
|
|
608
|
+
this.stopped = true;
|
|
609
|
+
if (this.timer !== null)
|
|
610
|
+
clearInterval(this.timer);
|
|
611
|
+
this.timer = null;
|
|
612
|
+
this.pending = Buffer.alloc(0);
|
|
613
|
+
}
|
|
614
|
+
poll() {
|
|
615
|
+
const size = this.fs.size(this.file);
|
|
616
|
+
if (size === null || size <= this.offset)
|
|
617
|
+
return;
|
|
618
|
+
let chunk;
|
|
619
|
+
try {
|
|
620
|
+
chunk = this.fs.readEnd(this.file, this.offset);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.offset += chunk.length;
|
|
626
|
+
this.pending = Buffer.concat([this.pending, chunk]);
|
|
627
|
+
this.drain();
|
|
628
|
+
}
|
|
629
|
+
drain() {
|
|
630
|
+
const frames = splitZstdFrames(this.pending);
|
|
631
|
+
let consumed = 0;
|
|
632
|
+
for (let i = 0; i < frames.length; i++) {
|
|
633
|
+
const frame = frames[i];
|
|
634
|
+
let text;
|
|
635
|
+
try {
|
|
636
|
+
text = this.decompress(frame).toString('utf8');
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
if (i === frames.length - 1)
|
|
640
|
+
break; // partial trailing frame — wait
|
|
641
|
+
// A bounded frame that won't decode is mid-file corruption: frames
|
|
642
|
+
// are self-terminating, so the mapper can no longer be trusted to
|
|
643
|
+
// see every commit. Stop and let the committed wire path take over.
|
|
644
|
+
const reason = `transcript corrupt at byte ${this.offset}: undecodable frame`;
|
|
645
|
+
this.stop();
|
|
646
|
+
this.opts.onFatal?.(reason);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
consumed += frame.length;
|
|
650
|
+
this.emitLines(text);
|
|
651
|
+
}
|
|
652
|
+
this.pending = this.pending.subarray(consumed);
|
|
653
|
+
}
|
|
654
|
+
emitLines(text) {
|
|
655
|
+
for (const line of text.split('\n')) {
|
|
656
|
+
if (line.trim() === '')
|
|
657
|
+
continue;
|
|
658
|
+
let entry;
|
|
659
|
+
try {
|
|
660
|
+
entry = JSON.parse(line);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (!isRecord(entry))
|
|
666
|
+
continue;
|
|
667
|
+
if (entry['type'] === 'turn/end')
|
|
668
|
+
this.lastTurnEndAt = Date.now();
|
|
669
|
+
for (const event of this.mapper(entry))
|
|
670
|
+
this.onEvent(event);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=dsh-sessions.js.map
|