@zeph-to/cli 2.15.0 → 2.17.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.
- package/README.md +48 -4
- package/dist/cli.js +7 -2
- package/dist/gate.d.ts +8 -3
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +6 -5
- package/dist/listener.d.ts +7 -0
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +71 -6
- package/dist/presence.d.ts +14 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +84 -0
- package/dist/remote-agents.d.ts +35 -0
- package/dist/remote-agents.d.ts.map +1 -1
- package/dist/remote-agents.js +60 -1
- package/dist/session-registry.d.ts +8 -1
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +7 -14
- package/dist/templates.d.ts +3 -1
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +132 -6
- package/dist/test-state-dir.d.ts +10 -0
- package/dist/test-state-dir.d.ts.map +1 -0
- package/dist/test-state-dir.js +32 -0
- package/dist/transcript-tail.d.ts +147 -0
- package/dist/transcript-tail.d.ts.map +1 -0
- package/dist/transcript-tail.js +397 -0
- package/dist/turn-ring.d.ts +85 -0
- package/dist/turn-ring.d.ts.map +1 -0
- package/dist/turn-ring.js +280 -0
- package/dist/turn-watch.d.ts +151 -0
- package/dist/turn-watch.d.ts.map +1 -0
- package/dist/turn-watch.js +502 -0
- package/package.json +1 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code session transcript (`~/.claude/projects/<hash>/<uuid>.jsonl`) →
|
|
4
|
+
* the small events the agent chat timeline draws.
|
|
5
|
+
*
|
|
6
|
+
* Two pure pieces, deliberately split: reading bytes off a growing file, and
|
|
7
|
+
* deciding what of it is worth putting on the wire. Neither touches the socket,
|
|
8
|
+
* so both are testable without tmux, WebSocket, or a clock.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is sized by what a real transcript actually contains, measured
|
|
11
|
+
* 2026-09-08 over 1443 files in `~/.claude/projects`: the largest file was
|
|
12
|
+
* 75.9 MB (p90 1.1 MB) and the longest single line 915,292 bytes. Those two
|
|
13
|
+
* numbers are why the reader starts near the end and refuses oversized lines
|
|
14
|
+
* before parsing them — a tail that starts at byte 0, or that hands a ~1 MB
|
|
15
|
+
* string to `JSON.parse` and only then decides to drop it, has already paid the
|
|
16
|
+
* cost the cap exists to avoid.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.projectTranscriptEntries = exports.readTranscriptDelta = exports.initialTailState = exports.MAX_EVENT_TEXT_CHARS = exports.MAX_EVENT_FIELD_CHARS = exports.MAX_TRANSCRIPT_LINE_CHARS = exports.MAX_TAIL_BYTES_PER_TICK = exports.TRANSCRIPT_BACKFILL_BYTES = void 0;
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
/**
|
|
22
|
+
* How far back from the end of the file the first read starts.
|
|
23
|
+
*
|
|
24
|
+
* Not zero: a fresh viewer that sees nothing until the *next* tool call is a
|
|
25
|
+
* blank screen for whoever just picked up their phone mid-turn. Not the whole
|
|
26
|
+
* file either — that is up to 75.9 MB. A fixed window from the end costs the
|
|
27
|
+
* same on an 8 KB transcript and a 76 MB one.
|
|
28
|
+
*/
|
|
29
|
+
exports.TRANSCRIPT_BACKFILL_BYTES = 256 * 1024;
|
|
30
|
+
/** Ceiling on one tick's read, so a burst of appends cannot be swallowed whole. */
|
|
31
|
+
exports.MAX_TAIL_BYTES_PER_TICK = 256 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* A line longer than this is abandoned unparsed, the reader skipping to the next
|
|
34
|
+
* newline. The measured worst case is a 915 KB `tool_result`, whose body this
|
|
35
|
+
* module drops anyway — buffering one to completion would be paying megabytes to
|
|
36
|
+
* learn a tool id.
|
|
37
|
+
*/
|
|
38
|
+
exports.MAX_TRANSCRIPT_LINE_CHARS = 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Ceiling on a tool's label — its name, and the short string saying what it
|
|
41
|
+
* acted on. A path or a one-line description; anything longer is a runaway
|
|
42
|
+
* input, not a label.
|
|
43
|
+
*/
|
|
44
|
+
exports.MAX_EVENT_FIELD_CHARS = 512;
|
|
45
|
+
/**
|
|
46
|
+
* Ceiling on prose — what the agent wrote, and what the person asked.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately far above the label cap: 512 characters is a filename, and
|
|
49
|
+
* applying it here cut every reply off mid-sentence. 5000 is what the
|
|
50
|
+
* completion push already carries for the same text (`zeph-stop.sh`), so the
|
|
51
|
+
* live lane and the push that replaces it agree on how much of a reply is
|
|
52
|
+
* worth sending.
|
|
53
|
+
*/
|
|
54
|
+
exports.MAX_EVENT_TEXT_CHARS = 5000;
|
|
55
|
+
/**
|
|
56
|
+
* How much of `buffer[0..read)` ends on a UTF-8 character boundary.
|
|
57
|
+
*
|
|
58
|
+
* A sequence is at most 4 bytes, so at most 3 trailing bytes can belong to a
|
|
59
|
+
* character whose remainder has not arrived. Walk back over continuation bytes
|
|
60
|
+
* (`10xxxxxx`) to the lead byte and ask how long its character should be; if the
|
|
61
|
+
* buffer holds all of it, the read is already complete.
|
|
62
|
+
*/
|
|
63
|
+
const completeUtf8Length = (buffer, read) => {
|
|
64
|
+
for (let back = 1; back <= 3 && back <= read; back++) {
|
|
65
|
+
const byte = buffer[read - back];
|
|
66
|
+
if ((byte & 0b1100_0000) === 0b1000_0000)
|
|
67
|
+
continue; // continuation — keep walking
|
|
68
|
+
const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
|
|
69
|
+
return back >= width ? read : read - back;
|
|
70
|
+
}
|
|
71
|
+
return read;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* No state yet. `null` rather than a sentinel-filled record: "never read this
|
|
75
|
+
* file" and "read it and got nothing" are different, and an out-of-band value
|
|
76
|
+
* that has to stay below every real offset is a trap for the next `>=`.
|
|
77
|
+
*/
|
|
78
|
+
const initialTailState = () => null;
|
|
79
|
+
exports.initialTailState = initialTailState;
|
|
80
|
+
/**
|
|
81
|
+
* Read what was appended since `prev`, or `null` when there is nothing to do —
|
|
82
|
+
* the file is unreadable, or neither its size nor its mtime moved.
|
|
83
|
+
*
|
|
84
|
+
* `null` is the common case (an idle session) and is deliberately allocation
|
|
85
|
+
* free: no buffer, no string, no array.
|
|
86
|
+
*/
|
|
87
|
+
const readTranscriptDelta = (path, prev) => {
|
|
88
|
+
let stat;
|
|
89
|
+
try {
|
|
90
|
+
stat = (0, node_fs_1.statSync)(path);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const { size, mtimeMs, ino } = stat;
|
|
96
|
+
// Caught up AND untouched. `offset >= size` matters on its own: a tick that
|
|
97
|
+
// stopped at MAX_TAIL_BYTES_PER_TICK leaves bytes behind on a file that has
|
|
98
|
+
// not changed since, and skipping there would strand them until the next
|
|
99
|
+
// append. `ino` matters here too, not only below: a replacement that matched
|
|
100
|
+
// the old size and mtime would otherwise skip out before the rotation check
|
|
101
|
+
// downstream ever ran.
|
|
102
|
+
if (prev && prev.offset >= size && size === prev.size && mtimeMs === prev.mtimeMs && ino === prev.ino) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
// A new inode at the same path is a different file, and a file that shrank
|
|
106
|
+
// below the offset was truncated in place. Size alone misses the first case:
|
|
107
|
+
// a replacement that happens to be longer than the old read position reads as
|
|
108
|
+
// an ordinary append, and the offset then points into the middle of a record
|
|
109
|
+
// that was never there.
|
|
110
|
+
//
|
|
111
|
+
// Neither covers a `/clear`, which writes a file under a *different* name —
|
|
112
|
+
// the old one simply stops growing. Following that means re-resolving the
|
|
113
|
+
// session id, which is the caller's job (`turn-watch` re-resolves on a
|
|
114
|
+
// cadence) because only it knows which session this path was for.
|
|
115
|
+
//
|
|
116
|
+
// Either way — first attach or rotation — everything remembered about the old
|
|
117
|
+
// contents is meaningless, so the read restarts from a window off the end.
|
|
118
|
+
const restart = !prev || size < prev.offset || ino !== prev.ino;
|
|
119
|
+
const start = restart ? Math.max(0, size - exports.TRANSCRIPT_BACKFILL_BYTES) : prev.offset;
|
|
120
|
+
// A window that starts mid-line yields a fragment of a record that began
|
|
121
|
+
// before it, so that fragment is dropped rather than parsed.
|
|
122
|
+
const truncatedStart = restart && start > 0;
|
|
123
|
+
const carry = restart ? '' : prev.carry;
|
|
124
|
+
let resyncing = restart ? false : prev.resyncing;
|
|
125
|
+
const want = Math.min(size - start, exports.MAX_TAIL_BYTES_PER_TICK);
|
|
126
|
+
if (want <= 0) {
|
|
127
|
+
return {
|
|
128
|
+
lines: [],
|
|
129
|
+
state: { offset: start, size, mtimeMs, ino, carry, resyncing },
|
|
130
|
+
bytesRead: 0,
|
|
131
|
+
droppedLines: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const buffer = Buffer.allocUnsafe(want);
|
|
135
|
+
let read = 0;
|
|
136
|
+
let fd;
|
|
137
|
+
try {
|
|
138
|
+
fd = (0, node_fs_1.openSync)(path, 'r');
|
|
139
|
+
read = (0, node_fs_1.readSync)(fd, buffer, 0, want, start);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
if (fd !== undefined)
|
|
146
|
+
(0, node_fs_1.closeSync)(fd);
|
|
147
|
+
}
|
|
148
|
+
// A tick that stops at MAX_TAIL_BYTES_PER_TICK can land inside a multi-byte
|
|
149
|
+
// character. Decoding to that boundary turns both halves into U+FFFD, and the
|
|
150
|
+
// line then fails JSON.parse and vanishes without even being counted as
|
|
151
|
+
// dropped — a silent hole that only shows up on non-ASCII transcripts under
|
|
152
|
+
// bursty appends. Back the read up to the last complete sequence instead; the
|
|
153
|
+
// bytes are not lost, the next tick starts on them.
|
|
154
|
+
const usable = read < want ? read : completeUtf8Length(buffer, read);
|
|
155
|
+
const chunk = buffer.toString('utf8', 0, usable);
|
|
156
|
+
const lines = [];
|
|
157
|
+
let droppedLines = 0;
|
|
158
|
+
let pending = carry;
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
while (cursor <= chunk.length) {
|
|
161
|
+
const nl = chunk.indexOf('\n', cursor);
|
|
162
|
+
if (nl === -1) {
|
|
163
|
+
pending += chunk.slice(cursor);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const complete = pending + chunk.slice(cursor, nl);
|
|
167
|
+
cursor = nl + 1;
|
|
168
|
+
pending = '';
|
|
169
|
+
if (resyncing) {
|
|
170
|
+
// This newline ends the oversized line; the next one starts clean.
|
|
171
|
+
resyncing = false;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (complete.length > exports.MAX_TRANSCRIPT_LINE_CHARS) {
|
|
175
|
+
droppedLines += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
lines.push(complete);
|
|
179
|
+
}
|
|
180
|
+
// The window began mid-line, so whatever came before the first newline is a
|
|
181
|
+
// fragment of a record that started before `start`.
|
|
182
|
+
if (truncatedStart && lines.length > 0)
|
|
183
|
+
lines.shift();
|
|
184
|
+
if (pending.length > exports.MAX_TRANSCRIPT_LINE_CHARS) {
|
|
185
|
+
// Still no newline and already past the cap — stop growing the buffer and
|
|
186
|
+
// discard bytes until the line ends. Holding it to completion would cost
|
|
187
|
+
// megabytes for a record whose body never reaches the wire anyway.
|
|
188
|
+
droppedLines += 1;
|
|
189
|
+
pending = '';
|
|
190
|
+
resyncing = true;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
lines,
|
|
194
|
+
state: { offset: start + usable, size, mtimeMs, ino, carry: pending, resyncing },
|
|
195
|
+
bytesRead: usable,
|
|
196
|
+
droppedLines,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
exports.readTranscriptDelta = readTranscriptDelta;
|
|
200
|
+
/**
|
|
201
|
+
* Which input field reads as "what this call is about". Ordered, first match
|
|
202
|
+
* wins: `description` leads because it is the sentence a human wrote about the
|
|
203
|
+
* call, which is exactly what a collapsed timeline row wants.
|
|
204
|
+
*
|
|
205
|
+
* `command` is deliberately absent. It would only ever be reached for a Bash
|
|
206
|
+
* call with no `description`, and measured over the 60 most recent transcripts
|
|
207
|
+
* that is 0 of 2985 calls — while a command line is the single field here most
|
|
208
|
+
* likely to carry a secret (an `Authorization` header, a token in a URL, a
|
|
209
|
+
* profile name). Zero measured value against the worst downside on the list.
|
|
210
|
+
*
|
|
211
|
+
* This list is Claude Code's vocabulary. Another agent names its inputs
|
|
212
|
+
* differently, so a second agent means a second projector, not a longer list —
|
|
213
|
+
* see the note on `projectTranscriptEntries`.
|
|
214
|
+
*/
|
|
215
|
+
const TARGET_KEYS = ['description', 'file_path', 'path', 'pattern', 'query', 'url', 'skill'];
|
|
216
|
+
const clamp = (value, max = exports.MAX_EVENT_FIELD_CHARS) => value.length > max ? value.slice(0, max) : value;
|
|
217
|
+
const targetOf = (input) => {
|
|
218
|
+
if (!input || typeof input !== 'object')
|
|
219
|
+
return undefined;
|
|
220
|
+
const record = input;
|
|
221
|
+
for (const key of TARGET_KEYS) {
|
|
222
|
+
const value = record[key];
|
|
223
|
+
if (typeof value === 'string' && value.trim())
|
|
224
|
+
return clamp(value.trim());
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
};
|
|
228
|
+
const contentBlocks = (entry) => {
|
|
229
|
+
const message = entry.message;
|
|
230
|
+
if (!message || typeof message !== 'object')
|
|
231
|
+
return [];
|
|
232
|
+
const content = message.content;
|
|
233
|
+
if (!Array.isArray(content))
|
|
234
|
+
return [];
|
|
235
|
+
return content.filter((b) => !!b && typeof b === 'object');
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* A user entry whose content is a plain string is something the person typed.
|
|
239
|
+
* One carrying an array of `tool_result` blocks is the harness answering the
|
|
240
|
+
* model, and counting it as a prompt would cut every turn into fragments —
|
|
241
|
+
* the same distinction `plugin/hooks/zeph-stop.sh` draws to scope a turn.
|
|
242
|
+
*/
|
|
243
|
+
/**
|
|
244
|
+
* Harness plumbing that Claude Code writes into the conversation as if a person
|
|
245
|
+
* had said it: background-task notifications, injected reminders, the caveat
|
|
246
|
+
* wrapper around a slash command's own output.
|
|
247
|
+
*
|
|
248
|
+
* Dropping these is not cosmetic. A `<task-notification>` rendered as a prompt
|
|
249
|
+
* bubble puts internal ids, tool-use ids and whatever a tool reported on a
|
|
250
|
+
* phone screen, attributed to the user — and there is no bound on what a future
|
|
251
|
+
* notification carries.
|
|
252
|
+
*/
|
|
253
|
+
const SYNTHETIC_BLOCKS = /<(task-notification|system-reminder|local-command-caveat|local-command-stdout|command-message|command-name|command-args)>[\s\S]*?<\/\1>/g;
|
|
254
|
+
/** `<command-name>/simplify</command-name>` — the one wrapper worth keeping, as what it names. */
|
|
255
|
+
const COMMAND_NAME = /<command-name>([^<]*)<\/command-name>/;
|
|
256
|
+
/**
|
|
257
|
+
* Whether this entry is a person speaking.
|
|
258
|
+
*
|
|
259
|
+
* `origin.kind` is the honest answer where Claude Code writes it: `human` for
|
|
260
|
+
* both a typed message and a slash command, something else for everything the
|
|
261
|
+
* harness injects. Absent — an older version — it falls through to the tag
|
|
262
|
+
* check below, so a build that does not stamp provenance still cannot leak a
|
|
263
|
+
* notification it happens to phrase as a user turn.
|
|
264
|
+
*/
|
|
265
|
+
const isHumanTurn = (entry) => {
|
|
266
|
+
if (entry.promptSource === 'system')
|
|
267
|
+
return false;
|
|
268
|
+
const origin = entry.origin;
|
|
269
|
+
if (origin && typeof origin === 'object') {
|
|
270
|
+
const kind = origin.kind;
|
|
271
|
+
if (typeof kind === 'string')
|
|
272
|
+
return kind === 'human';
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
};
|
|
276
|
+
const promptTextOf = (entry) => {
|
|
277
|
+
if (entry.type !== 'user' || entry.isMeta)
|
|
278
|
+
return null;
|
|
279
|
+
if (!isHumanTurn(entry))
|
|
280
|
+
return null;
|
|
281
|
+
const message = entry.message;
|
|
282
|
+
if (!message || typeof message !== 'object')
|
|
283
|
+
return null;
|
|
284
|
+
const content = message.content;
|
|
285
|
+
if (typeof content === 'string')
|
|
286
|
+
return nonEmpty(stripAttachmentMarkers(content));
|
|
287
|
+
// An array is usually the harness answering the model, but the same shape
|
|
288
|
+
// carries what a person typed when they attached something — the prompt
|
|
289
|
+
// arrives as text blocks beside the attachment. Take those; a message with
|
|
290
|
+
// no text block at all is a pure tool_result carrier and not a prompt.
|
|
291
|
+
if (!Array.isArray(content))
|
|
292
|
+
return null;
|
|
293
|
+
const text = content
|
|
294
|
+
.filter((b) => !!b && typeof b === 'object' && b.type === 'text')
|
|
295
|
+
.map((b) => (typeof b.text === 'string' ? b.text : ''))
|
|
296
|
+
.join('\n')
|
|
297
|
+
.trim();
|
|
298
|
+
return text ? nonEmpty(stripAttachmentMarkers(text)) : null;
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Drop the `[Image: source: /abs/path]` markers Claude Code substitutes for an
|
|
302
|
+
* attached image.
|
|
303
|
+
*
|
|
304
|
+
* They are a local filesystem path and nothing else: useless on the phone, which
|
|
305
|
+
* cannot open it, and a directory listing of this machine if it goes anywhere
|
|
306
|
+
* else. The attachment itself is not on this wire — showing that one exists is a
|
|
307
|
+
* separate feature, not a reason to ship the path.
|
|
308
|
+
*/
|
|
309
|
+
const stripAttachmentMarkers = (text) => {
|
|
310
|
+
const command = text.match(COMMAND_NAME)?.[1]?.trim();
|
|
311
|
+
const body = text.replace(SYNTHETIC_BLOCKS, '');
|
|
312
|
+
// A slash command is a person's turn, and its name is the whole of what they
|
|
313
|
+
// said — the wrapper around it is not.
|
|
314
|
+
const withCommand = command ? `${command}\n${body}` : body;
|
|
315
|
+
return withCommand.replace(/\[Image:[^\]]*\]/g, '').replace(/\n{3,}/g, '\n\n');
|
|
316
|
+
};
|
|
317
|
+
/** Trimmed, or null when nothing survived — a message that was only an attachment. */
|
|
318
|
+
const nonEmpty = (text) => {
|
|
319
|
+
const trimmed = text.trim();
|
|
320
|
+
return trimmed ? trimmed : null;
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Turn raw JSONL lines into wire events.
|
|
324
|
+
*
|
|
325
|
+
* Nothing a tool read or wrote survives this function: only the tool's name, a
|
|
326
|
+
* short label for what it acted on, and whether it worked. That is the whole
|
|
327
|
+
* privacy story of the feature — the transcript holds file contents and command
|
|
328
|
+
* output, and this is the one place that decides none of it leaves the machine.
|
|
329
|
+
*
|
|
330
|
+
* `sinceLastPrompt` is for the backfill window: finished turns already exist in
|
|
331
|
+
* the chat as their completion pushes, so replaying them would double every
|
|
332
|
+
* message. Only the turn still in flight is new information.
|
|
333
|
+
*
|
|
334
|
+
* The prompt a person typed IS carried, unlike anything a tool read or wrote.
|
|
335
|
+
* It is their own words, it is the same class of content the completion push
|
|
336
|
+
* already sends, and without it the timeline is a list of tool names with no
|
|
337
|
+
* record of what was asked.
|
|
338
|
+
*
|
|
339
|
+
* Everything here — the block shapes, the entry types, `TARGET_KEYS` — is Claude
|
|
340
|
+
* Code's transcript format. Supporting another agent (pi, Codex) means a
|
|
341
|
+
* projector of its own alongside this one, reached the way `REMOTE_AGENTS`
|
|
342
|
+
* already reaches per-agent session resolvers; `turn-watch` takes the reader as
|
|
343
|
+
* a dependency and needs no change for it.
|
|
344
|
+
*/
|
|
345
|
+
const projectTranscriptEntries = (lines, opts = {}) => {
|
|
346
|
+
const events = [];
|
|
347
|
+
for (const raw of lines) {
|
|
348
|
+
let entry;
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(raw);
|
|
351
|
+
if (!parsed || typeof parsed !== 'object')
|
|
352
|
+
continue;
|
|
353
|
+
entry = parsed;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
// A subagent's own tool calls belong to the Task/Agent call that spawned
|
|
359
|
+
// it, which the parent transcript already shows as one row.
|
|
360
|
+
if (entry.isSidechain)
|
|
361
|
+
continue;
|
|
362
|
+
const at = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
|
|
363
|
+
const prompt = promptTextOf(entry);
|
|
364
|
+
if (prompt !== null) {
|
|
365
|
+
events.push({ kind: 'prompt', text: clamp(prompt, exports.MAX_EVENT_TEXT_CHARS), ...(at ? { at } : {}) });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
for (const block of contentBlocks(entry)) {
|
|
369
|
+
if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
|
370
|
+
const target = targetOf(block.input);
|
|
371
|
+
events.push({ kind: 'tool', id: block.id, name: clamp(block.name), ...(target ? { target } : {}), ...(at ? { at } : {}) });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
|
|
375
|
+
events.push({ kind: 'tool_result', id: block.tool_use_id, ok: block.is_error !== true, ...(at ? { at } : {}) });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
// Prose, only from the model. A user entry reaching here has already
|
|
379
|
+
// been offered to `promptTextOf`; treating its text blocks as
|
|
380
|
+
// assistant output is how an attachment marker ends up rendered as
|
|
381
|
+
// the agent's own words.
|
|
382
|
+
if (entry.type === 'assistant' &&
|
|
383
|
+
block.type === 'text' &&
|
|
384
|
+
typeof block.text === 'string' &&
|
|
385
|
+
block.text.trim()) {
|
|
386
|
+
events.push({ kind: 'text', text: clamp(block.text, exports.MAX_EVENT_TEXT_CHARS), ...(at ? { at } : {}) });
|
|
387
|
+
}
|
|
388
|
+
// `thinking` falls through on purpose — the timeline shows what the
|
|
389
|
+
// agent did, not what it considered.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!opts.sinceLastPrompt)
|
|
393
|
+
return events;
|
|
394
|
+
const lastPrompt = events.map((e) => e.kind).lastIndexOf('prompt');
|
|
395
|
+
return lastPrompt === -1 ? events : events.slice(lastPrompt);
|
|
396
|
+
};
|
|
397
|
+
exports.projectTranscriptEntries = projectTranscriptEntries;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { TurnEvent } from './transcript-tail.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the daemon has already sent a chat viewer, kept on disk so a
|
|
4
|
+
* re-subscribe can replay it.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists at all: the live lane is ephemeral, and the transcript
|
|
7
|
+
* backfill deliberately replays only since the last prompt — finished turns are
|
|
8
|
+
* supposed to already exist in the chat as their completion pushes. Under the
|
|
9
|
+
* `quiet` push dial they do not exist, because that dial suppresses every
|
|
10
|
+
* auto-push without a `high` marker. A turn's closing words were therefore
|
|
11
|
+
* visible while it ran and gone the moment the viewer re-armed, with no record
|
|
12
|
+
* on the phone, on the server, or in the 256KB backfill window.
|
|
13
|
+
*
|
|
14
|
+
* Re-deriving those turns from the transcript is not an option: measured over
|
|
15
|
+
* this machine's transcripts larger than the window, the last 256KB holds a
|
|
16
|
+
* median of one human prompt and none at all in 8 of 19 files — a single
|
|
17
|
+
* `tool_result` line can be most of a megabyte. Projected events are a tool
|
|
18
|
+
* name, a short label and a verdict, so the same bytes hold turns rather than
|
|
19
|
+
* fragments of one.
|
|
20
|
+
*
|
|
21
|
+
* It lives beside `known-sessions.json` under `stateDir()` rather than in
|
|
22
|
+
* `~/.zeph`, for the same reason that file does: this is derived session state
|
|
23
|
+
* that expires, not configuration or keys.
|
|
24
|
+
*
|
|
25
|
+
* Everything here is best-effort. A ring that cannot be written costs the
|
|
26
|
+
* scrollback, never the live send — the daemon's job is to keep talking.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* One ring file. Trimming is by bytes and not by event count on purpose: a
|
|
30
|
+
* prose event carries up to `MAX_EVENT_TEXT_CHARS` (5000) while a tool event's
|
|
31
|
+
* label is clamped at `MAX_EVENT_FIELD_CHARS` (512), so a count would bound two
|
|
32
|
+
* things an order of magnitude apart.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_TURN_RING_BYTES: number;
|
|
35
|
+
/** Sessions with a ring. Past this the oldest by mtime are dropped. */
|
|
36
|
+
export declare const MAX_TURN_RING_FILES = 20;
|
|
37
|
+
/** A week unwatched. Scrollback older than that is history, not context. */
|
|
38
|
+
export declare const TURN_RING_TTL_MS: number;
|
|
39
|
+
export declare const turnRingDir: () => string;
|
|
40
|
+
/**
|
|
41
|
+
* The session name is chosen by whoever sent the watch request, so it never
|
|
42
|
+
* reaches the filesystem — a hash does. Same move as `hashListenerId`, at twice
|
|
43
|
+
* the width: 64 bits, because these names collide within one machine's
|
|
44
|
+
* directory rather than identifying one device.
|
|
45
|
+
*/
|
|
46
|
+
export declare const turnRingPath: (sessionName: string) => string;
|
|
47
|
+
/**
|
|
48
|
+
* Record events this watch just sent. Call it with the same array that went out
|
|
49
|
+
* — the ring's whole meaning is "what the viewer already received", and a replay
|
|
50
|
+
* is that array sent again.
|
|
51
|
+
*/
|
|
52
|
+
export declare const appendTurnRing: (sessionName: string, events: readonly TurnEvent[]) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* What this session's viewers have already been sent, oldest first.
|
|
55
|
+
*
|
|
56
|
+
* An expired ring is deleted here rather than merely ignored: returning an empty
|
|
57
|
+
* array and leaving the file would let the next append land on top of pre-expiry
|
|
58
|
+
* content, and the TTL would bound nothing.
|
|
59
|
+
*/
|
|
60
|
+
export declare const readTurnRing: (sessionName: string, now?: number) => TurnEvent[];
|
|
61
|
+
/** Drop expired rings, then the oldest ones past `MAX_TURN_RING_FILES`. */
|
|
62
|
+
export declare const sweepTurnRings: (now?: number) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Forget one session's scrollback outright — the machine was told to forget the
|
|
65
|
+
* session. False when the file is still there, which the caller has to say out
|
|
66
|
+
* loud: answering "forgotten" while a week of prompts and tool targets sits on
|
|
67
|
+
* disk is the one wrong answer here.
|
|
68
|
+
*/
|
|
69
|
+
export declare const removeTurnRing: (sessionName: string) => boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The three operations a watcher needs, as one injectable surface.
|
|
72
|
+
*
|
|
73
|
+
* The watcher takes this rather than importing the module so its tests can hold
|
|
74
|
+
* the ring in memory — what the disk does is settled here, and re-proving it
|
|
75
|
+
* through a socket-and-transcript harness would only make those tests slower and
|
|
76
|
+
* no more truthful.
|
|
77
|
+
*/
|
|
78
|
+
export interface TurnRing {
|
|
79
|
+
read: (sessionName: string) => TurnEvent[];
|
|
80
|
+
/** False when nothing was written — a full disk, a directory that is not ours. */
|
|
81
|
+
append: (sessionName: string, events: readonly TurnEvent[]) => boolean;
|
|
82
|
+
sweep: () => void;
|
|
83
|
+
}
|
|
84
|
+
export declare const diskTurnRing: TurnRing;
|
|
85
|
+
//# sourceMappingURL=turn-ring.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-ring.d.ts","sourceRoot":"","sources":["../src/turn-ring.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,QAAa,CAAC;AAE9C,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,QAA0B,CAAC;AAExD,eAAO,MAAM,WAAW,QAAO,MAAmC,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,aAAa,MAAM,KAAG,MACoD,CAAC;AAyExG;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,aAAa,MAAM,EAAE,QAAQ,SAAS,SAAS,EAAE,KAAG,OAwBlF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,aAAa,MAAM,EAAE,MAAK,MAAmB,KAAG,SAAS,EAkCrF,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,cAAc,GAAI,MAAK,MAAmB,KAAG,IAgCzD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,aAAa,MAAM,KAAG,OAgBpD,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACrB,IAAI,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;IAC3C,kFAAkF;IAClF,MAAM,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,SAAS,EAAE,KAAK,OAAO,CAAC;IACvE,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,eAAO,MAAM,YAAY,EAAE,QAI1B,CAAC"}
|