@deepseek-harness-tui/dsh-tui 0.7.1 → 0.7.2

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.
Files changed (84) hide show
  1. package/README.md +6 -4
  2. package/bin/dsh-tui.js +37 -1
  3. package/cordis.patch.yml +10 -0
  4. package/lib/types/commands.js +1 -1
  5. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  6. package/lib/types/components/LoadedContextPanel.js +1 -1
  7. package/lib/types/components/design-system/ThemedText.d.ts +2 -2
  8. package/lib/types/components/design-system/ThemedText.d.ts.map +1 -1
  9. package/lib/types/components/design-system/ThemedText.js +1 -3
  10. package/lib/types/components/sessions/SessionListRow.d.ts +26 -0
  11. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -0
  12. package/lib/types/components/sessions/SessionListRow.js +35 -0
  13. package/lib/types/components/sessions/SessionPreview.d.ts +25 -0
  14. package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -0
  15. package/lib/types/components/sessions/SessionPreview.js +60 -0
  16. package/lib/types/dsh-adapter/channel.d.ts +9 -4
  17. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  18. package/lib/types/dsh-adapter/channel.js +31 -73
  19. package/lib/types/dsh-adapter/compat/index.d.ts +1 -1
  20. package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
  21. package/lib/types/dsh-adapter/compat/index.js +1 -1
  22. package/lib/types/dsh-adapter/compat/sessionLog.d.ts +8 -0
  23. package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
  24. package/lib/types/dsh-adapter/compat/sessionLog.js +1 -1
  25. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  26. package/lib/types/dsh-adapter/index.js +10 -1
  27. package/lib/types/dsh-adapter/packaged-presets.d.ts +22 -0
  28. package/lib/types/dsh-adapter/packaged-presets.d.ts.map +1 -0
  29. package/lib/types/dsh-adapter/packaged-presets.js +90 -0
  30. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  31. package/lib/types/dsh-adapter/plugin.js +46 -4
  32. package/lib/types/dsh-adapter/presets.d.ts +16 -0
  33. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  34. package/lib/types/dsh-adapter/presets.js +23 -0
  35. package/lib/types/dsh-adapter/sessions/digest.d.ts +29 -0
  36. package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -0
  37. package/lib/types/dsh-adapter/sessions/digest.js +224 -0
  38. package/lib/types/dsh-adapter/sessions/frames.d.ts +100 -0
  39. package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -0
  40. package/lib/types/dsh-adapter/sessions/frames.js +246 -0
  41. package/lib/types/dsh-adapter/sessions/header.d.ts +50 -0
  42. package/lib/types/dsh-adapter/sessions/header.d.ts.map +1 -0
  43. package/lib/types/dsh-adapter/sessions/header.js +62 -0
  44. package/lib/types/dsh-adapter/sessions/index.d.ts +17 -0
  45. package/lib/types/dsh-adapter/sessions/index.d.ts.map +1 -0
  46. package/lib/types/dsh-adapter/sessions/index.js +15 -0
  47. package/lib/types/dsh-adapter/sessions/list.d.ts +41 -0
  48. package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -0
  49. package/lib/types/dsh-adapter/sessions/list.js +213 -0
  50. package/lib/types/dsh-adapter/sessions/store.d.ts +48 -0
  51. package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -0
  52. package/lib/types/dsh-adapter/sessions/store.js +164 -0
  53. package/lib/types/dsh-adapter/sessions/types.d.ts +142 -0
  54. package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -0
  55. package/lib/types/dsh-adapter/sessions/types.js +14 -0
  56. package/lib/types/i18n.d.ts +118 -14
  57. package/lib/types/i18n.d.ts.map +1 -1
  58. package/lib/types/i18n.js +36 -6
  59. package/lib/types/screens/Chat.d.ts +3 -2
  60. package/lib/types/screens/Chat.d.ts.map +1 -1
  61. package/lib/types/screens/Chat.js +28 -144
  62. package/lib/types/screens/SessionBrowser.d.ts +37 -0
  63. package/lib/types/screens/SessionBrowser.d.ts.map +1 -0
  64. package/lib/types/screens/SessionBrowser.js +437 -0
  65. package/lib/types/sessionHistory.d.ts +0 -8
  66. package/lib/types/sessionHistory.d.ts.map +1 -1
  67. package/lib/types/sessions/format.d.ts +117 -0
  68. package/lib/types/sessions/format.d.ts.map +1 -0
  69. package/lib/types/sessions/format.js +244 -0
  70. package/lib/types/sessions/view.d.ts +125 -0
  71. package/lib/types/sessions/view.d.ts.map +1 -0
  72. package/lib/types/sessions/view.js +222 -0
  73. package/package.json +6 -2
  74. package/presets/liangshen/.dsh-tui-managed.json +5 -0
  75. package/presets/liangshen/agent.cordis.yml +389 -0
  76. package/presets/liangshen/compaction-epoch.mjs +81 -0
  77. package/presets/liangshen/custom-bash.mjs +126 -0
  78. package/presets/liangshen/instruction-hint.mjs +181 -0
  79. package/presets/liangshen/preset.yml +3 -0
  80. package/presets/liangshen/skill-search.mjs +142 -0
  81. package/presets/liangshen/tool-bootstrap.mjs +300 -0
  82. package/lib/types/components/ResumePicker.d.ts +0 -26
  83. package/lib/types/components/ResumePicker.d.ts.map +0 -1
  84. package/lib/types/components/ResumePicker.js +0 -50
@@ -0,0 +1,224 @@
1
+ /**
2
+ * What a bounded read can learn about a session log.
3
+ *
4
+ * Two windows, one at each end, and never anything in between:
5
+ *
6
+ * - The HEAD holds the session envelope, the boot policy events, and the
7
+ * opening prompt. Measured across a real corpus, the first user prompt lands
8
+ * within 8,107 bytes of the start (524 in its `agent/inbox/spliced` form),
9
+ * so a 64 KB window carries an eightfold margin.
10
+ * - The TAIL holds whatever was appended most recently: the current title
11
+ * (titles are re-emitted, and the last one wins), the model of the last
12
+ * request, and the last exchanges for the preview.
13
+ *
14
+ * Titles carry their own provenance, so this module does not have to guess.
15
+ * A title written by a provider records `source.kind: 'provider'`; the TUI's
16
+ * own rename paths append `{ title }` with no source at all. That difference
17
+ * is the evidence behind {@link SessionTitle.source}, which is why the picker
18
+ * can dim a fallback and explain a name instead of merely displaying one.
19
+ *
20
+ * @module @deepseek-harness-tui/dsh-tui/sessions/digest
21
+ */
22
+ import { basename } from 'node:path';
23
+ import { decodeFrames, decodeTail, readWindow, walkFrames } from './frames.js';
24
+ /** Head window budget. Eight times the measured worst-case prompt offset. */
25
+ export const HEAD_WINDOW_BYTES = 64 * 1024;
26
+ /** Head frame ceiling — a cost bound independent of how the bytes compress. */
27
+ export const HEAD_MAX_FRAMES = 128;
28
+ /** Tail window budget. Wider than the head: trailing frames carry payloads. */
29
+ export const TAIL_WINDOW_BYTES = 128 * 1024;
30
+ /** Longest preview excerpt kept per message, in characters. */
31
+ const PREVIEW_CHARS = 400;
32
+ /** The first text block of a message `content` payload. */
33
+ function textOfContent(content) {
34
+ if (typeof content === 'string')
35
+ return content.trim() || undefined;
36
+ if (!Array.isArray(content))
37
+ return undefined;
38
+ for (const block of content) {
39
+ if (block === null || typeof block !== 'object')
40
+ continue;
41
+ const record = block;
42
+ if (record['type'] !== 'text')
43
+ continue;
44
+ const value = record['text'];
45
+ if (typeof value === 'string' && value.trim().length > 0)
46
+ return value.trim();
47
+ }
48
+ return undefined;
49
+ }
50
+ /**
51
+ * Whether a message's `source` marks it as typed by the person at the
52
+ * keyboard. Plugin injections, instruction snapshots, skill catalogues and
53
+ * sub-agent reports all arrive as user-role messages too, and counting them
54
+ * would report a conversation where none happened.
55
+ */
56
+ function isHumanSource(source) {
57
+ if (source === undefined || source === null)
58
+ return true;
59
+ if (typeof source !== 'object')
60
+ return false;
61
+ return source['kind'] === 'user';
62
+ }
63
+ /** The human prompt carried by one log line, in either of its two forms. */
64
+ function humanPrompt(line) {
65
+ const data = line['data'];
66
+ if (data === null || typeof data !== 'object')
67
+ return undefined;
68
+ const record = data;
69
+ if (line['type'] === 'user/message') {
70
+ return isHumanSource(record['source']) ? textOfContent(record['content']) : undefined;
71
+ }
72
+ // The inbox splice precedes the durable user/message and reaches the log
73
+ // several frames earlier, which is what keeps the head window small.
74
+ if (line['type'] === 'agent/inbox/spliced') {
75
+ const inserted = record['inserted'];
76
+ if (!Array.isArray(inserted))
77
+ return undefined;
78
+ for (const message of inserted) {
79
+ if (message === null || typeof message !== 'object')
80
+ continue;
81
+ const entry = message;
82
+ if (entry['role'] !== 'user' || !isHumanSource(entry['source']))
83
+ continue;
84
+ const text = textOfContent(entry['content']);
85
+ if (text !== undefined)
86
+ return text;
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ /** A `session/title` payload, with the provenance that classifies it. */
92
+ function titleOf(line) {
93
+ if (line['type'] !== 'session/title')
94
+ return undefined;
95
+ const data = line['data'];
96
+ if (data === null || typeof data !== 'object')
97
+ return undefined;
98
+ const record = data;
99
+ const text = record['title'];
100
+ if (typeof text !== 'string' || text.trim().length === 0)
101
+ return undefined;
102
+ const source = record['source'];
103
+ const byProvider = source !== null &&
104
+ typeof source === 'object' &&
105
+ source['kind'] === 'provider';
106
+ return { text: text.trim(), source: byProvider ? 'auto' : 'renamed' };
107
+ }
108
+ /** The route recorded by a `request/context` event. */
109
+ function modelOf(line) {
110
+ if (line['type'] !== 'request/context')
111
+ return undefined;
112
+ const data = line['data'];
113
+ if (data === null || typeof data !== 'object')
114
+ return undefined;
115
+ const model = data['model'];
116
+ return typeof model === 'string' && model.length > 0 ? model : undefined;
117
+ }
118
+ /** The label a delegated run was started under. */
119
+ function labelOf(line) {
120
+ if (line['type'] !== 'subagent/descriptor')
121
+ return undefined;
122
+ const data = line['data'];
123
+ if (data === null || typeof data !== 'object')
124
+ return undefined;
125
+ const label = data['label'];
126
+ return typeof label === 'string' && label.trim().length > 0 ? label.trim() : undefined;
127
+ }
128
+ /** Epoch-ms of a log line, when it carries one. */
129
+ function timeOf(line) {
130
+ const time = line['time'];
131
+ return typeof time === 'number' && Number.isFinite(time) ? time : undefined;
132
+ }
133
+ /**
134
+ * Read both windows of one session log.
135
+ *
136
+ * @param path - Absolute artifact path.
137
+ * @param cwd - Working directory, for the last-resort title.
138
+ * @returns The digest. An unreadable log still yields a usable record: the
139
+ * title falls back to the directory basename and says so through its source.
140
+ */
141
+ export function digestSession(path, cwd) {
142
+ const head = readWindow(path, HEAD_WINDOW_BYTES);
143
+ if (head === undefined) {
144
+ return { title: undefined, hasPrompt: false, model: undefined, label: undefined };
145
+ }
146
+ const headLines = decodeFrames(head.buffer, walkFrames(head.buffer, 0, HEAD_MAX_FRAMES));
147
+ let prompt;
148
+ let headTitle;
149
+ let label;
150
+ for (const line of headLines) {
151
+ prompt ??= humanPrompt(line);
152
+ headTitle ??= titleOf(line);
153
+ label ??= labelOf(line);
154
+ }
155
+ // Absence of a prompt only means "empty" when the window actually saw the
156
+ // whole log. A log too large for the window has a conversation in it by
157
+ // construction, and erring toward listing it is the safe direction: hiding
158
+ // a real session is a defect, showing a boot artifact is a nuisance.
159
+ const hasPrompt = prompt !== undefined || !head.whole;
160
+ // A head window that already covered the whole log IS the tail.
161
+ const tail = head.whole ? undefined : readWindow(path, TAIL_WINDOW_BYTES, true);
162
+ const tailLines = tail === undefined ? headLines : decodeTail(tail);
163
+ let tailTitle;
164
+ let model;
165
+ for (const line of tailLines) {
166
+ const title = titleOf(line);
167
+ if (title !== undefined)
168
+ tailTitle = title;
169
+ const route = modelOf(line);
170
+ if (route !== undefined)
171
+ model = route;
172
+ }
173
+ const resolved = tailTitle ??
174
+ headTitle ??
175
+ (prompt === undefined ? undefined : { text: prompt, source: 'prompt' });
176
+ return {
177
+ title: resolved ?? { text: basename(cwd), source: 'fallback' },
178
+ hasPrompt,
179
+ model,
180
+ label,
181
+ };
182
+ }
183
+ /**
184
+ * The last exchanges of a session, for the browser's preview pane.
185
+ *
186
+ * Bounded like everything else here: the preview shows the end of the
187
+ * conversation because that is what the tail window holds, and because the end
188
+ * is what tells you whether this is the session you meant.
189
+ *
190
+ * @param path - Absolute artifact path.
191
+ * @param limit - How many entries to keep, newest last.
192
+ * @returns Entries in log order.
193
+ */
194
+ export function previewSession(path, limit) {
195
+ const window = readWindow(path, TAIL_WINDOW_BYTES, true);
196
+ if (window === undefined)
197
+ return [];
198
+ const lines = decodeTail(window);
199
+ const entries = [];
200
+ for (const line of lines) {
201
+ const data = line['data'];
202
+ if (data === null || typeof data !== 'object')
203
+ continue;
204
+ const record = data;
205
+ if (line['type'] === 'user/message') {
206
+ if (!isHumanSource(record['source']))
207
+ continue;
208
+ const text = textOfContent(record['content']);
209
+ if (text !== undefined)
210
+ entries.push({ role: 'user', text: text.slice(0, PREVIEW_CHARS), at: timeOf(line) });
211
+ continue;
212
+ }
213
+ if (line['type'] === 'assistant/message') {
214
+ const message = record['message'];
215
+ if (message === null || typeof message !== 'object')
216
+ continue;
217
+ const text = textOfContent(message['content']);
218
+ if (text !== undefined) {
219
+ entries.push({ role: 'assistant', text: text.slice(0, PREVIEW_CHARS), at: timeOf(line) });
220
+ }
221
+ }
222
+ }
223
+ return entries.slice(-limit);
224
+ }
@@ -0,0 +1,100 @@
1
+ /** Byte range of one structurally complete frame; `end` is exclusive. */
2
+ export interface FrameRange {
3
+ readonly start: number;
4
+ readonly end: number;
5
+ }
6
+ /**
7
+ * Locate the end of the frame starting at `start`, without decompressing it.
8
+ *
9
+ * The walk reads the Frame_Header (descriptor, optional window/dictionary/
10
+ * content-size fields) and then each Block_Header in turn — a 3-byte
11
+ * little-endian word carrying `last_block` (1 bit), `block_type` (2 bits) and
12
+ * `block_size` (21 bits) — until the block marked last. A `Reserved` block
13
+ * type means these bytes are not a frame at all, which is how a coincidental
14
+ * magic gets rejected.
15
+ *
16
+ * @param buffer - Bytes available to the reader (may end mid-frame).
17
+ * @param start - Offset of the candidate frame's magic.
18
+ * @returns The frame's exclusive end offset, or -1 when the bytes at `start`
19
+ * are not a structurally complete frame within `buffer`.
20
+ */
21
+ export declare function frameEnd(buffer: Buffer, start: number): number;
22
+ /**
23
+ * Walk complete frames forward from `from`.
24
+ *
25
+ * @param buffer - Bytes to walk.
26
+ * @param from - Offset to start at (must be a frame boundary).
27
+ * @param maxFrames - Stop after this many frames; the reader's cost ceiling.
28
+ * @returns Complete frames in file order. A window that ends mid-frame simply
29
+ * yields one fewer frame — the partial tail is never reported as complete.
30
+ */
31
+ export declare function walkFrames(buffer: Buffer, from?: number, maxFrames?: number): FrameRange[];
32
+ /**
33
+ * Re-synchronize on a frame boundary inside a window that starts mid-frame.
34
+ *
35
+ * A tail window has no boundary to start from, so the only anchor is the one
36
+ * structural fact we know about the whole file: its last frame ends exactly at
37
+ * EOF. Every magic candidate is tried in file order, and the first one whose
38
+ * frame chain lands precisely on the window's end is the true boundary — a
39
+ * coincidental magic would have to spell a valid block chain of exactly the
40
+ * right total length to be mistaken for one.
41
+ *
42
+ * @param buffer - A window whose last byte is the file's last byte.
43
+ * @returns Frames from the earliest recoverable boundary, or [] when the
44
+ * window holds no complete frame.
45
+ */
46
+ export declare function resyncFrames(buffer: Buffer): FrameRange[];
47
+ /** One decoded log line, still untyped — the caller owns interpretation. */
48
+ export type LogLine = Record<string, unknown>;
49
+ /**
50
+ * Decode frames to JSON log lines, tolerantly.
51
+ *
52
+ * A frame that fails to decompress or a line that fails to parse is skipped
53
+ * rather than thrown: a log being appended to right now can hold a frame
54
+ * flushed without its final checksum, and a torn tail is the backend's own
55
+ * documented recovery case. A picker label is read-only UI state — degrading
56
+ * to a fallback title beats refusing to list the session.
57
+ *
58
+ * @param buffer - Bytes the frames index into.
59
+ * @param frames - Complete frame ranges within `buffer`.
60
+ * @returns Parsed envelopes in log order.
61
+ */
62
+ export declare function decodeFrames(buffer: Buffer, frames: readonly FrameRange[]): LogLine[];
63
+ /** A file's size and last-write time, read once for both. */
64
+ export interface FileFacts {
65
+ readonly bytes: number;
66
+ readonly modifiedAt: number;
67
+ }
68
+ /**
69
+ * Size and mtime of a log, or undefined when it is gone.
70
+ * @param path - Absolute artifact path.
71
+ */
72
+ export declare function fileFacts(path: string): FileFacts | undefined;
73
+ /**
74
+ * Read a window from one end of a file without loading the whole thing.
75
+ *
76
+ * @param path - Absolute artifact path.
77
+ * @param bytes - Window budget; the whole file is read when it is smaller.
78
+ * @param end - Read the last `bytes` instead of the first.
79
+ * @returns The window, plus whether it covers the entire file (which tells a
80
+ * head reader that its last frame cannot be truncated).
81
+ */
82
+ export declare function readWindow(path: string, bytes: number, end?: boolean): {
83
+ buffer: Buffer;
84
+ whole: boolean;
85
+ } | undefined;
86
+ /**
87
+ * Decode a window read from the END of a file.
88
+ *
89
+ * A tail window has no frame boundary to start from unless it happens to
90
+ * cover the whole file, so it re-synchronizes; a whole-file window is simply
91
+ * walked.
92
+ *
93
+ * @param window - A window whose last byte is the file's last byte.
94
+ * @returns Log lines from the trailing frames, oldest first.
95
+ */
96
+ export declare function decodeTail(window: {
97
+ buffer: Buffer;
98
+ whole: boolean;
99
+ }): LogLine[];
100
+ //# sourceMappingURL=frames.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frames.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/frames.ts"],"names":[],"mappings":"AAuCA,yEAAyE;AACzE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAsC9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,MAAM,EACd,IAAI,SAAI,EACR,SAAS,SAA2B,GACnC,UAAU,EAAE,CAUd;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,EAAE,CAQzD;AAED,4EAA4E;AAC5E,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAE7C;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,OAAO,EAAE,CAsBrF;AAED,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAC5B;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAO7D;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,GAAG,UAAQ,GACV;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAwBhD;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,EAAE,CAKhF"}
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Bounded reads over the JSONL backend's concatenated-Zstandard session logs.
3
+ *
4
+ * The backend stores one session as a chain of independently decodable zstd
5
+ * frames — one per durable append batch — so a log grows by concatenation and
6
+ * never rewrites committed bytes. That container is what makes a *bounded*
7
+ * read possible at all: the first few frames hold the session header and its
8
+ * opening prompt, the last few hold whatever was appended most recently, and
9
+ * nothing in between has to be touched to learn either.
10
+ *
11
+ * Why this module walks frames structurally instead of scanning for the frame
12
+ * magic: a magic scan is a heuristic (any four bytes of compressed payload can
13
+ * spell `FD2FB528`), it costs a comparison per byte, and it cannot answer the
14
+ * one question a bounded reader must ask — "is the frame at the end of my
15
+ * window complete, or did I cut it in half?". Walking the frame header and its
16
+ * block chain, as RFC 8878 §3.1.1 defines them, answers that exactly and jumps
17
+ * frame-to-frame instead of byte-to-byte. Measured over a real 31 MB corpus of
18
+ * 49 logs and 75,263 frames: identical frame set to a magic scan, zero false
19
+ * positives, and 4× faster (51 ms vs 215 ms).
20
+ *
21
+ * Why not the backend's own `scanZstdFrames`: it exists, but the package's
22
+ * `exports` map publishes only the service class, so reaching it would mean
23
+ * importing through `lib/` past the export map — coupling to a private path
24
+ * that upstream is free to move. The frame container is a published format
25
+ * (RFC 8878), so re-deriving the walk against the spec is the stable choice.
26
+ *
27
+ * Why not Node's own zstd APIs: both `zstdDecompressSync` and
28
+ * `createZstdDecompress` stop at the end of the FIRST frame — a 4.2 MB,
29
+ * 14k-event log decodes to exactly one line through either. They decode a
30
+ * frame; they do not traverse a chain.
31
+ *
32
+ * @module @deepseek-harness-tui/dsh-tui/sessions/frames
33
+ */
34
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
35
+ import { zstdDecompressSync } from 'node:zlib';
36
+ /** Zstandard frame magic, little-endian (RFC 8878 §3.1.1.1). */
37
+ const ZSTD_MAGIC = 0xfd2fb528;
38
+ /**
39
+ * Locate the end of the frame starting at `start`, without decompressing it.
40
+ *
41
+ * The walk reads the Frame_Header (descriptor, optional window/dictionary/
42
+ * content-size fields) and then each Block_Header in turn — a 3-byte
43
+ * little-endian word carrying `last_block` (1 bit), `block_type` (2 bits) and
44
+ * `block_size` (21 bits) — until the block marked last. A `Reserved` block
45
+ * type means these bytes are not a frame at all, which is how a coincidental
46
+ * magic gets rejected.
47
+ *
48
+ * @param buffer - Bytes available to the reader (may end mid-frame).
49
+ * @param start - Offset of the candidate frame's magic.
50
+ * @returns The frame's exclusive end offset, or -1 when the bytes at `start`
51
+ * are not a structurally complete frame within `buffer`.
52
+ */
53
+ export function frameEnd(buffer, start) {
54
+ let at = start;
55
+ if (at < 0 || at + 5 > buffer.length)
56
+ return -1;
57
+ if (buffer.readUInt32LE(at) !== ZSTD_MAGIC)
58
+ return -1;
59
+ at += 4;
60
+ const descriptor = buffer[at];
61
+ at += 1;
62
+ const contentSizeFlag = descriptor >> 6;
63
+ const singleSegment = (descriptor >> 5) & 1;
64
+ const hasChecksum = (descriptor >> 2) & 1;
65
+ const dictionaryIdFlag = descriptor & 3;
66
+ // Window_Descriptor is present only when the frame is not single-segment.
67
+ if (singleSegment === 0)
68
+ at += 1;
69
+ at += [0, 1, 2, 4][dictionaryIdFlag];
70
+ // Frame_Content_Size: absent (0) unless single-segment, where it is 1 byte.
71
+ at += contentSizeFlag === 0 ? singleSegment : [0, 2, 4, 8][contentSizeFlag];
72
+ if (at > buffer.length)
73
+ return -1;
74
+ for (;;) {
75
+ if (at + 3 > buffer.length)
76
+ return -1;
77
+ const header = buffer[at] | (buffer[at + 1] << 8) | (buffer[at + 2] << 16);
78
+ at += 3;
79
+ const isLast = header & 1;
80
+ const blockType = (header >> 1) & 3;
81
+ const blockSize = header >>> 3;
82
+ // 3 = Reserved. Never emitted by an encoder, so this is not a frame.
83
+ if (blockType === 3)
84
+ return -1;
85
+ // An RLE block stores one byte and repeats it `blockSize` times; Raw and
86
+ // Compressed blocks store `blockSize` bytes verbatim.
87
+ at += blockType === 1 ? 1 : blockSize;
88
+ if (at > buffer.length)
89
+ return -1;
90
+ if (isLast === 1)
91
+ break;
92
+ }
93
+ if (hasChecksum === 1)
94
+ at += 4;
95
+ return at <= buffer.length ? at : -1;
96
+ }
97
+ /**
98
+ * Walk complete frames forward from `from`.
99
+ *
100
+ * @param buffer - Bytes to walk.
101
+ * @param from - Offset to start at (must be a frame boundary).
102
+ * @param maxFrames - Stop after this many frames; the reader's cost ceiling.
103
+ * @returns Complete frames in file order. A window that ends mid-frame simply
104
+ * yields one fewer frame — the partial tail is never reported as complete.
105
+ */
106
+ export function walkFrames(buffer, from = 0, maxFrames = Number.POSITIVE_INFINITY) {
107
+ const frames = [];
108
+ let at = from;
109
+ while (at < buffer.length && frames.length < maxFrames) {
110
+ const end = frameEnd(buffer, at);
111
+ if (end < 0)
112
+ break;
113
+ frames.push({ start: at, end });
114
+ at = end;
115
+ }
116
+ return frames;
117
+ }
118
+ /**
119
+ * Re-synchronize on a frame boundary inside a window that starts mid-frame.
120
+ *
121
+ * A tail window has no boundary to start from, so the only anchor is the one
122
+ * structural fact we know about the whole file: its last frame ends exactly at
123
+ * EOF. Every magic candidate is tried in file order, and the first one whose
124
+ * frame chain lands precisely on the window's end is the true boundary — a
125
+ * coincidental magic would have to spell a valid block chain of exactly the
126
+ * right total length to be mistaken for one.
127
+ *
128
+ * @param buffer - A window whose last byte is the file's last byte.
129
+ * @returns Frames from the earliest recoverable boundary, or [] when the
130
+ * window holds no complete frame.
131
+ */
132
+ export function resyncFrames(buffer) {
133
+ for (let at = 0; at + 4 <= buffer.length; at++) {
134
+ if (buffer.readUInt32LE(at) !== ZSTD_MAGIC)
135
+ continue;
136
+ const frames = walkFrames(buffer, at);
137
+ const last = frames[frames.length - 1];
138
+ if (last !== undefined && last.end === buffer.length)
139
+ return frames;
140
+ }
141
+ return [];
142
+ }
143
+ /**
144
+ * Decode frames to JSON log lines, tolerantly.
145
+ *
146
+ * A frame that fails to decompress or a line that fails to parse is skipped
147
+ * rather than thrown: a log being appended to right now can hold a frame
148
+ * flushed without its final checksum, and a torn tail is the backend's own
149
+ * documented recovery case. A picker label is read-only UI state — degrading
150
+ * to a fallback title beats refusing to list the session.
151
+ *
152
+ * @param buffer - Bytes the frames index into.
153
+ * @param frames - Complete frame ranges within `buffer`.
154
+ * @returns Parsed envelopes in log order.
155
+ */
156
+ export function decodeFrames(buffer, frames) {
157
+ const lines = [];
158
+ for (const frame of frames) {
159
+ let text;
160
+ try {
161
+ text = zstdDecompressSync(buffer.subarray(frame.start, frame.end)).toString('utf8');
162
+ }
163
+ catch {
164
+ continue; // incomplete flush or torn frame — the rest of the log stands
165
+ }
166
+ for (const line of text.split('\n')) {
167
+ if (line.length === 0)
168
+ continue;
169
+ try {
170
+ const parsed = JSON.parse(line);
171
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
172
+ lines.push(parsed);
173
+ }
174
+ }
175
+ catch {
176
+ // A half-written line at the tail; earlier lines remain valid.
177
+ }
178
+ }
179
+ }
180
+ return lines;
181
+ }
182
+ /**
183
+ * Size and mtime of a log, or undefined when it is gone.
184
+ * @param path - Absolute artifact path.
185
+ */
186
+ export function fileFacts(path) {
187
+ try {
188
+ const stats = statSync(path);
189
+ return { bytes: stats.size, modifiedAt: stats.mtimeMs };
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ /**
196
+ * Read a window from one end of a file without loading the whole thing.
197
+ *
198
+ * @param path - Absolute artifact path.
199
+ * @param bytes - Window budget; the whole file is read when it is smaller.
200
+ * @param end - Read the last `bytes` instead of the first.
201
+ * @returns The window, plus whether it covers the entire file (which tells a
202
+ * head reader that its last frame cannot be truncated).
203
+ */
204
+ export function readWindow(path, bytes, end = false) {
205
+ const facts = fileFacts(path);
206
+ if (facts === undefined)
207
+ return undefined;
208
+ const length = Math.min(bytes, facts.bytes);
209
+ if (length === 0)
210
+ return { buffer: Buffer.alloc(0), whole: true };
211
+ const buffer = Buffer.alloc(length);
212
+ let handle;
213
+ try {
214
+ handle = openSync(path, 'r');
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ let read;
220
+ try {
221
+ read = readSync(handle, buffer, 0, length, end ? facts.bytes - length : 0);
222
+ }
223
+ catch {
224
+ return undefined;
225
+ }
226
+ finally {
227
+ closeSync(handle);
228
+ }
229
+ // A short read is not an error: the frame walk simply sees fewer bytes and
230
+ // reports one fewer complete frame. Reporting `whole` honestly is what
231
+ // matters — a tail reader must know whether it may assume a boundary.
232
+ return { buffer: read === length ? buffer : buffer.subarray(0, read), whole: read === facts.bytes };
233
+ }
234
+ /**
235
+ * Decode a window read from the END of a file.
236
+ *
237
+ * A tail window has no frame boundary to start from unless it happens to
238
+ * cover the whole file, so it re-synchronizes; a whole-file window is simply
239
+ * walked.
240
+ *
241
+ * @param window - A window whose last byte is the file's last byte.
242
+ * @returns Log lines from the trailing frames, oldest first.
243
+ */
244
+ export function decodeTail(window) {
245
+ return decodeFrames(window.buffer, window.whole ? walkFrames(window.buffer) : resyncFrames(window.buffer));
246
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Reading the persistence header — total functions over foreign data.
3
+ *
4
+ * `sessionPersistence.list()` is a service resolved from the running context,
5
+ * so its results are foreign values that happen to be typed. Every accessor
6
+ * here therefore narrows structurally and returns a fallback rather than
7
+ * throwing: one malformed header must cost that session its metadata, never
8
+ * the whole listing. This mirrors the discipline the trajectory guards already
9
+ * apply to session events.
10
+ *
11
+ * @module @deepseek-harness-tui/dsh-tui/sessions/header
12
+ */
13
+ import type { SessionKind } from './types.js';
14
+ /** The header fields this feature reads, all optional at runtime. */
15
+ export interface RawSessionHeader {
16
+ readonly id: string;
17
+ readonly cwd: string | undefined;
18
+ readonly createdAt: number | undefined;
19
+ readonly parentSession: string | undefined;
20
+ readonly origin: string | undefined;
21
+ readonly delegationDepth: number | undefined;
22
+ readonly seedLength: number | undefined;
23
+ readonly agentPreset: string | undefined;
24
+ }
25
+ /**
26
+ * Narrow one listed header.
27
+ *
28
+ * @param value - A header as the persistence service returned it.
29
+ * @returns The fields this feature uses, or undefined when the value carries
30
+ * no usable session id — the one field nothing can substitute for.
31
+ */
32
+ export declare function readHeader(value: unknown): RawSessionHeader | undefined;
33
+ /**
34
+ * Decide what a session is, from its header alone.
35
+ *
36
+ * Precedence is `origin` first, lineage second, and that order is the whole
37
+ * correctness argument: a `/rewind` fork records `parentSession` exactly like
38
+ * a delegated run does, and only `origin` separates them. Upstream documents
39
+ * `origin` as "coarse product classification for a session created as a
40
+ * subagent child … presentation metadata", which is precisely this decision.
41
+ *
42
+ * `delegationDepth` is optional upstream, so a sub-agent whose header omits it
43
+ * is reported at depth 1: it is a delegated child by `origin`, and 1 is the
44
+ * shallowest depth that can be.
45
+ *
46
+ * @param header - A narrowed header.
47
+ * @returns The session's kind.
48
+ */
49
+ export declare function classify(header: RawSessionHeader): SessionKind;
50
+ //# sourceMappingURL=header.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"header.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,qEAAqE;AACrE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAA;IACtC,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1C,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,SAAS,CAAA;IAC5C,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;CACzC;AAYD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CAevE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,GAAG,WAAW,CAY9D"}