@spexcode/transcript 0.7.0-next.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/dist/frames.d.ts +44 -0
- package/dist/frames.js +85 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/live.d.ts +16 -0
- package/dist/live.js +65 -0
- package/dist/parsers.d.ts +47 -0
- package/dist/parsers.js +285 -0
- package/dist/readers.d.ts +10 -0
- package/dist/readers.js +296 -0
- package/dist/turns.d.ts +42 -0
- package/dist/turns.js +11 -0
- package/package.json +30 -0
package/dist/frames.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type TranscriptRead, type TranscriptReader, type TranscriptTool, type TranscriptTurn } from './turns.js';
|
|
2
|
+
export type StreamTool = Readonly<Omit<TranscriptTool, 'output'> & {
|
|
3
|
+
output?: null;
|
|
4
|
+
}>;
|
|
5
|
+
export type StreamTurn = Readonly<Omit<TranscriptTurn, 'tools'> & {
|
|
6
|
+
tools?: readonly StreamTool[];
|
|
7
|
+
}>;
|
|
8
|
+
export type StreamFrame = Readonly<Omit<TranscriptRead, 'turns'> & {
|
|
9
|
+
kind: 'full' | 'delta';
|
|
10
|
+
turns: readonly StreamTurn[];
|
|
11
|
+
removed?: readonly string[];
|
|
12
|
+
}>;
|
|
13
|
+
export type TranscriptErrorFrame = Readonly<{
|
|
14
|
+
revision: string;
|
|
15
|
+
from: number;
|
|
16
|
+
to: number;
|
|
17
|
+
error: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}>;
|
|
20
|
+
export type TranscriptFrame = StreamFrame | TranscriptErrorFrame;
|
|
21
|
+
export declare const isErrorFrame: (frame: TranscriptFrame) => frame is TranscriptErrorFrame;
|
|
22
|
+
export declare const ABSENT_REVISION = "absent";
|
|
23
|
+
export declare const withheld: (read: TranscriptRead) => Omit<StreamFrame, "kind">;
|
|
24
|
+
export declare class FrameProducer {
|
|
25
|
+
private primed;
|
|
26
|
+
private sent;
|
|
27
|
+
private counters;
|
|
28
|
+
next(read: TranscriptRead): StreamFrame | null;
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare const absentFrame: (from: number, to: number) => StreamFrame;
|
|
32
|
+
export type FrameStream = Readonly<{
|
|
33
|
+
publish(now?: number): Promise<TranscriptFrame | null>;
|
|
34
|
+
close(): void;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function openFrameStream(reader: TranscriptReader, threadId: string, from: number, clock?: () => number): FrameStream;
|
|
37
|
+
export type HeldTranscript = Readonly<{
|
|
38
|
+
turns: readonly StreamTurn[];
|
|
39
|
+
}>;
|
|
40
|
+
export type MergedFrame = Readonly<{
|
|
41
|
+
state: HeldTranscript;
|
|
42
|
+
payload: TranscriptErrorFrame | (Omit<StreamFrame, 'kind' | 'removed'>);
|
|
43
|
+
}>;
|
|
44
|
+
export declare function mergeTranscriptFrame(state: HeldTranscript, frame: TranscriptFrame): MergedFrame;
|
package/dist/frames.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { TranscriptReadError } from './turns.js';
|
|
2
|
+
export const isErrorFrame = (frame) => 'error' in frame;
|
|
3
|
+
// The revision an absent source reports: the thread has not started writing. Not an error.
|
|
4
|
+
export const ABSENT_REVISION = 'absent';
|
|
5
|
+
export const withheld = (read) => ({
|
|
6
|
+
...read,
|
|
7
|
+
turns: read.turns.map((turn) => turn.tools
|
|
8
|
+
? { ...turn, tools: turn.tools.map((tool) => tool.output === undefined ? tool : { ...tool, output: null }) }
|
|
9
|
+
: turn),
|
|
10
|
+
});
|
|
11
|
+
// THE PRODUCER'S HALF. Feed it every read of the open interval; it returns the frame that read is worth — the
|
|
12
|
+
// first one is the whole interval (`full`), every later one a `delta` holding only the turns that are new or
|
|
13
|
+
// changed since the previous frame (a turn changes when a call in it gains its result) and the ids the turn cap
|
|
14
|
+
// evicted; the counters are always absolute — or `null` when the read changed nothing the subscriber holds.
|
|
15
|
+
export class FrameProducer {
|
|
16
|
+
primed = false; // a `full` frame has been delivered on this stream
|
|
17
|
+
sent = new Map(); // turn id → the serialized turn the subscriber holds
|
|
18
|
+
counters = ''; // the absolute counters as last sent
|
|
19
|
+
next(read) {
|
|
20
|
+
const held = withheld(read);
|
|
21
|
+
const next = new Map(held.turns.map((turn) => [turn.id, JSON.stringify(turn)]));
|
|
22
|
+
const changed = held.turns.filter((turn) => this.sent.get(turn.id) !== next.get(turn.id));
|
|
23
|
+
const removed = [...this.sent.keys()].filter((turnId) => !next.has(turnId));
|
|
24
|
+
const counters = `${held.truncated}:${held.omittedTurns}:${held.omittedBytes}:${held.outOfOrderEvents}`;
|
|
25
|
+
if (this.primed && !changed.length && !removed.length && counters === this.counters)
|
|
26
|
+
return null;
|
|
27
|
+
const frame = this.primed ? { ...held, kind: 'delta', turns: changed, removed } : { ...held, kind: 'full' };
|
|
28
|
+
this.sent = next;
|
|
29
|
+
this.counters = counters;
|
|
30
|
+
this.primed = true;
|
|
31
|
+
return frame;
|
|
32
|
+
}
|
|
33
|
+
// the next frame is `full` again — a subscriber that reconnects, or a source that vanished, holds nothing
|
|
34
|
+
reset() { this.primed = false; this.sent = new Map(); this.counters = ''; }
|
|
35
|
+
}
|
|
36
|
+
export const absentFrame = (from, to) => ({ kind: 'full', revision: ABSENT_REVISION, from, to, turns: [], truncated: false, omittedTurns: 0, omittedBytes: 0, outOfOrderEvents: 0 });
|
|
37
|
+
export function openFrameStream(reader, threadId, from, clock = () => Date.now()) {
|
|
38
|
+
let last;
|
|
39
|
+
let tail = null; // opened on the first non-absent revision
|
|
40
|
+
const producer = new FrameProducer();
|
|
41
|
+
return {
|
|
42
|
+
async publish(now = clock()) {
|
|
43
|
+
const revision = reader.revision(threadId) ?? ABSENT_REVISION;
|
|
44
|
+
if (revision === last)
|
|
45
|
+
return null;
|
|
46
|
+
last = revision;
|
|
47
|
+
const to = Math.max(from + 1, now);
|
|
48
|
+
if (revision === ABSENT_REVISION) {
|
|
49
|
+
producer.reset();
|
|
50
|
+
return absentFrame(from, to);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return producer.next(await (tail ??= reader.tail(threadId, from)).advance(to));
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!(error instanceof TranscriptReadError))
|
|
57
|
+
throw error;
|
|
58
|
+
return { revision, from, to, error: error.message, reason: error.reason };
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
close() { tail?.close(); tail = null; },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function mergeTranscriptFrame(state, frame) {
|
|
65
|
+
if (isErrorFrame(frame))
|
|
66
|
+
return { state, payload: frame };
|
|
67
|
+
const { kind, removed, turns: incoming, ...rest } = frame;
|
|
68
|
+
if (kind !== 'delta') {
|
|
69
|
+
const turns = [...(incoming || [])];
|
|
70
|
+
return { state: { turns }, payload: { ...rest, turns } };
|
|
71
|
+
}
|
|
72
|
+
const gone = new Set(removed || []);
|
|
73
|
+
const turns = state.turns.filter((turn) => !gone.has(turn.id));
|
|
74
|
+
const index = new Map(turns.map((turn, at) => [turn.id, at]));
|
|
75
|
+
for (const turn of incoming || []) {
|
|
76
|
+
const at = index.get(turn.id);
|
|
77
|
+
if (at === undefined) {
|
|
78
|
+
index.set(turn.id, turns.length);
|
|
79
|
+
turns.push(turn);
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
turns[at] = turn;
|
|
83
|
+
}
|
|
84
|
+
return { state: { turns }, payload: { ...rest, turns } };
|
|
85
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type TranscriptRange, type TranscriptRead, type TranscriptReader, type TranscriptTail } from './turns.js';
|
|
2
|
+
import { type Parse } from './parsers.js';
|
|
3
|
+
export declare class LiveTranscript implements TranscriptReader {
|
|
4
|
+
private readonly parse;
|
|
5
|
+
readonly threadId: string;
|
|
6
|
+
private readonly events;
|
|
7
|
+
private writes;
|
|
8
|
+
private readonly listeners;
|
|
9
|
+
constructor(parse: Parse, threadId: string);
|
|
10
|
+
push(native: unknown): boolean;
|
|
11
|
+
onChange(listener: () => void): () => void;
|
|
12
|
+
private own;
|
|
13
|
+
revision(threadId: string): string | null;
|
|
14
|
+
read(threadId: string, range: TranscriptRange): Promise<TranscriptRead>;
|
|
15
|
+
tail(threadId: string, from: number): TranscriptTail;
|
|
16
|
+
}
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { TranscriptReadError } from './turns.js';
|
|
2
|
+
import { IntervalCollector } from './parsers.js';
|
|
3
|
+
// THE IN-MEMORY SOURCE. A headless controller already holds the harness's native events as they stream past —
|
|
4
|
+
// Claude's `stream-json` lines, an app-server's notifications — so a transcript need not be re-read from the
|
|
5
|
+
// file the harness also writes. Push each native event here, through the same parser the file reader uses, and
|
|
6
|
+
// this is a `TranscriptReader` like any other: the frame protocol, the interval read, and every renderer work
|
|
7
|
+
// unchanged, and `onChange` lets a producer publish on arrival instead of on a tick. One instance is one thread.
|
|
8
|
+
export class LiveTranscript {
|
|
9
|
+
parse;
|
|
10
|
+
threadId;
|
|
11
|
+
events = [];
|
|
12
|
+
writes = 0;
|
|
13
|
+
listeners = new Set();
|
|
14
|
+
constructor(parse, threadId) {
|
|
15
|
+
this.parse = parse;
|
|
16
|
+
this.threadId = threadId;
|
|
17
|
+
}
|
|
18
|
+
// `true` when the record meant something to the parser; an unrecognized record is not an error — a native
|
|
19
|
+
// stream carries plenty the transcript does not show (control replies, usage, the result envelope)
|
|
20
|
+
push(native) {
|
|
21
|
+
const event = this.parse(native);
|
|
22
|
+
if (!event)
|
|
23
|
+
return false;
|
|
24
|
+
this.events.push(event);
|
|
25
|
+
this.writes++;
|
|
26
|
+
for (const listener of this.listeners)
|
|
27
|
+
listener();
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
onChange(listener) {
|
|
31
|
+
this.listeners.add(listener);
|
|
32
|
+
return () => { this.listeners.delete(listener); };
|
|
33
|
+
}
|
|
34
|
+
own(threadId) {
|
|
35
|
+
if (threadId !== this.threadId)
|
|
36
|
+
throw new TranscriptReadError('missing', `live transcript holds thread ${this.threadId}, not ${threadId}`);
|
|
37
|
+
}
|
|
38
|
+
// `null` until the first event lands: the thread has not started, which the frame protocol reads as absent
|
|
39
|
+
revision(threadId) {
|
|
40
|
+
this.own(threadId);
|
|
41
|
+
return this.writes ? `${this.writes}` : null;
|
|
42
|
+
}
|
|
43
|
+
async read(threadId, range) {
|
|
44
|
+
this.own(threadId);
|
|
45
|
+
const collector = new IntervalCollector(range);
|
|
46
|
+
for (const event of this.events)
|
|
47
|
+
collector.add(event);
|
|
48
|
+
return collector.finish(`${this.writes}`, 'live');
|
|
49
|
+
}
|
|
50
|
+
// the cursor is an index into the event list: each advance collects only what was pushed since the last one
|
|
51
|
+
tail(threadId, from) {
|
|
52
|
+
this.own(threadId);
|
|
53
|
+
const collector = new IntervalCollector({ from, to: from });
|
|
54
|
+
let consumed = 0;
|
|
55
|
+
return {
|
|
56
|
+
advance: async (to) => {
|
|
57
|
+
collector.extend(to);
|
|
58
|
+
for (; consumed < this.events.length; consumed++)
|
|
59
|
+
collector.add(this.events[consumed]);
|
|
60
|
+
return collector.finish(`${this.writes}`, 'live');
|
|
61
|
+
},
|
|
62
|
+
close: () => { },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type TranscriptRange, type TranscriptRead } from './turns.js';
|
|
2
|
+
export declare const MAX_TURNS = 200;
|
|
3
|
+
export declare const MAX_OUTPUT_BYTES: number;
|
|
4
|
+
export type MutableTool = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
input?: string;
|
|
8
|
+
output?: string;
|
|
9
|
+
outputLines: number;
|
|
10
|
+
outputBytes: number;
|
|
11
|
+
};
|
|
12
|
+
export type MutableTurn = {
|
|
13
|
+
id: string | null;
|
|
14
|
+
at: number;
|
|
15
|
+
role: 'user' | 'assistant';
|
|
16
|
+
text?: string;
|
|
17
|
+
tools: MutableTool[];
|
|
18
|
+
};
|
|
19
|
+
export type ParsedEvent = {
|
|
20
|
+
at: number | null;
|
|
21
|
+
turn: MutableTurn | null;
|
|
22
|
+
toolOutputs?: readonly {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
};
|
|
27
|
+
export type Parse = (value: unknown) => ParsedEvent | null;
|
|
28
|
+
export declare function claudeEvent(value: unknown): ParsedEvent | null;
|
|
29
|
+
export declare function codexEvent(value: unknown): ParsedEvent | null;
|
|
30
|
+
export declare function piEvent(value: unknown): ParsedEvent | null;
|
|
31
|
+
export declare function opencodeEvents(value: unknown): ParsedEvent[];
|
|
32
|
+
export declare class IntervalCollector {
|
|
33
|
+
readonly turns: MutableTurn[];
|
|
34
|
+
private readonly byTool;
|
|
35
|
+
private readonly evicted;
|
|
36
|
+
private readonly synthesized;
|
|
37
|
+
sawTimestamp: boolean;
|
|
38
|
+
omittedTurns: number;
|
|
39
|
+
omittedBytes: number;
|
|
40
|
+
outOfOrderEvents: number;
|
|
41
|
+
private pastRange;
|
|
42
|
+
private readonly range;
|
|
43
|
+
constructor(range: TranscriptRange);
|
|
44
|
+
extend(to: number): void;
|
|
45
|
+
add(event: ParsedEvent): boolean;
|
|
46
|
+
finish(revision: string, harness: string): TranscriptRead;
|
|
47
|
+
}
|
package/dist/parsers.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { TranscriptReadError } from './turns.js';
|
|
2
|
+
// ONE PARSER PER HARNESS. Each function turns one native record — a line of Claude's project JSONL (which is
|
|
3
|
+
// also exactly what its `--output-format stream-json` prints), a Codex rollout line, a pi session line, an
|
|
4
|
+
// OpenCode export — into the normalized event the interval collector consumes. The same parser serves a file
|
|
5
|
+
// being tailed ([[transcript-reader]]) and an event stream held in memory ([[live-transcript]]): the source is
|
|
6
|
+
// where bytes come from, the parser is what they mean, and neither knows the other.
|
|
7
|
+
export const MAX_TURNS = 200;
|
|
8
|
+
export const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
9
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
10
|
+
const items = (value) => Array.isArray(value) ? value : [];
|
|
11
|
+
const string = (value) => typeof value === 'string' && value.trim() ? value : null;
|
|
12
|
+
const idOf = (value) => {
|
|
13
|
+
if (!value)
|
|
14
|
+
return null;
|
|
15
|
+
for (const key of ['id', 'uuid', 'message_id', 'messageId', 'call_id', 'callId', 'client_id', 'clientId']) {
|
|
16
|
+
const found = string(value[key]);
|
|
17
|
+
if (found)
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
const timestamp = (value) => {
|
|
23
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
24
|
+
return value;
|
|
25
|
+
if (typeof value !== 'string' || !value.trim())
|
|
26
|
+
return null;
|
|
27
|
+
const numeric = Number(value);
|
|
28
|
+
if (Number.isFinite(numeric))
|
|
29
|
+
return numeric;
|
|
30
|
+
const parsed = Date.parse(value);
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
32
|
+
};
|
|
33
|
+
const at = (value) => {
|
|
34
|
+
if (!value)
|
|
35
|
+
return null;
|
|
36
|
+
for (const key of ['timestamp', 'created_at', 'createdAt', 'created', 'time']) {
|
|
37
|
+
const candidate = timestamp(value[key]);
|
|
38
|
+
if (candidate !== null)
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
};
|
|
43
|
+
const compact = (value) => {
|
|
44
|
+
if (typeof value === 'string')
|
|
45
|
+
return value;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.stringify(value) ?? String(value);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const lineCount = (value) => value ? value.split(/\r?\n/).length : 0;
|
|
54
|
+
// --- the four native shapes --------------------------------------------------------------------------------
|
|
55
|
+
export function claudeEvent(value) {
|
|
56
|
+
const entry = object(value);
|
|
57
|
+
const message = object(entry?.message);
|
|
58
|
+
if (!entry || !message)
|
|
59
|
+
return null;
|
|
60
|
+
const eventAt = at(entry) ?? at(message);
|
|
61
|
+
if (eventAt === null)
|
|
62
|
+
return { at: null, turn: null };
|
|
63
|
+
if (entry.type === 'user' && message.role === 'user') {
|
|
64
|
+
const blocks = items(message.content);
|
|
65
|
+
const text = typeof message.content === 'string'
|
|
66
|
+
? string(message.content)
|
|
67
|
+
: blocks.map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
|
|
68
|
+
const outputs = blocks.flatMap((block) => {
|
|
69
|
+
const b = object(block);
|
|
70
|
+
const id = string(b?.tool_use_id);
|
|
71
|
+
return b?.type === 'tool_result' && id ? [{ id, text: compact(b?.content ?? '') }] : [];
|
|
72
|
+
});
|
|
73
|
+
if (outputs.length)
|
|
74
|
+
return { at: eventAt, turn: null, toolOutputs: outputs };
|
|
75
|
+
if (text)
|
|
76
|
+
return { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } };
|
|
77
|
+
}
|
|
78
|
+
if (entry.type === 'assistant' && message.role === 'assistant') {
|
|
79
|
+
const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
|
|
80
|
+
for (const blockValue of items(message.content)) {
|
|
81
|
+
const block = object(blockValue);
|
|
82
|
+
if (block?.type === 'text')
|
|
83
|
+
turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
|
|
84
|
+
if (block?.type === 'tool_use') {
|
|
85
|
+
const id = string(block.id) ?? `tool-${turn.tools.length}`;
|
|
86
|
+
turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.input === undefined ? undefined : compact(block.input), outputLines: 0, outputBytes: 0 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { at: eventAt, turn };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
export function codexEvent(value) {
|
|
94
|
+
const entry = object(value);
|
|
95
|
+
const payload = object(entry?.payload);
|
|
96
|
+
if (!entry || !payload)
|
|
97
|
+
return null;
|
|
98
|
+
const eventAt = at(payload) ?? at(entry);
|
|
99
|
+
if (eventAt === null)
|
|
100
|
+
return { at: null, turn: null };
|
|
101
|
+
const type = string(payload.type);
|
|
102
|
+
if ((entry.type === 'event_msg' && type === 'user_message')
|
|
103
|
+
|| (entry.type === 'response_item' && (type === 'message' || type === 'input_message') && payload.role === 'user')) {
|
|
104
|
+
const text = typeof payload.message === 'string' ? payload.message : compact(payload.message ?? payload.content ?? '');
|
|
105
|
+
return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'user', text, tools: [] } } : null;
|
|
106
|
+
}
|
|
107
|
+
// commentary AND the final answer are both what the agent said; only structured reasoning stays private
|
|
108
|
+
if (entry.type === 'event_msg' && type === 'agent_message') {
|
|
109
|
+
const text = string(payload.message ?? payload.text);
|
|
110
|
+
return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', text, tools: [] } } : null;
|
|
111
|
+
}
|
|
112
|
+
if (entry.type === 'response_item' && (type === 'custom_tool_call' || type === 'function_call')) {
|
|
113
|
+
const id = string(payload.call_id ?? payload.id) ?? 'tool';
|
|
114
|
+
return { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [{ id, name: string(payload.name ?? payload.tool_name) ?? 'tool', input: payload.input === undefined && payload.arguments === undefined ? undefined : compact(payload.input ?? payload.arguments), outputLines: 0, outputBytes: 0 }] } };
|
|
115
|
+
}
|
|
116
|
+
if (entry.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
|
|
117
|
+
const id = string(payload.call_id ?? payload.id);
|
|
118
|
+
const output = payload.output ?? payload.result ?? '';
|
|
119
|
+
return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output) }] } : null;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const blockText = (content) => typeof content === 'string'
|
|
124
|
+
? string(content)
|
|
125
|
+
: items(content).map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
|
|
126
|
+
export function piEvent(value) {
|
|
127
|
+
const entry = object(value);
|
|
128
|
+
const message = object(entry?.message);
|
|
129
|
+
if (!entry || entry.type !== 'message' || !message)
|
|
130
|
+
return null;
|
|
131
|
+
const eventAt = at(entry) ?? at(message);
|
|
132
|
+
if (eventAt === null)
|
|
133
|
+
return { at: null, turn: null };
|
|
134
|
+
if (message.role === 'user') {
|
|
135
|
+
const text = blockText(message.content);
|
|
136
|
+
return text ? { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
|
|
137
|
+
}
|
|
138
|
+
if (message.role === 'assistant') {
|
|
139
|
+
const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
|
|
140
|
+
for (const blockValue of items(message.content)) {
|
|
141
|
+
const block = object(blockValue);
|
|
142
|
+
if (block?.type === 'text')
|
|
143
|
+
turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
|
|
144
|
+
if (block?.type === 'toolCall') {
|
|
145
|
+
const id = string(block.id) ?? `tool-${turn.tools.length}`;
|
|
146
|
+
turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { at: eventAt, turn };
|
|
150
|
+
}
|
|
151
|
+
if (message.role === 'toolResult') {
|
|
152
|
+
const id = string(message.toolCallId);
|
|
153
|
+
return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? '') }] } : null;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// OpenCode's export is one JSON document, not a line stream: every message arrives with its parts, and a tool
|
|
158
|
+
// part already carries its own result — so its turn is complete on arrival, and a part still running simply has
|
|
159
|
+
// no output yet.
|
|
160
|
+
export function opencodeEvents(value) {
|
|
161
|
+
const events = [];
|
|
162
|
+
for (const messageValue of items(object(value)?.messages)) {
|
|
163
|
+
const message = object(messageValue);
|
|
164
|
+
const info = object(message?.info);
|
|
165
|
+
if (!message || !info)
|
|
166
|
+
continue;
|
|
167
|
+
const eventAt = at(object(info.time)) ?? at(info);
|
|
168
|
+
if (eventAt === null) {
|
|
169
|
+
events.push({ at: null, turn: null });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const role = info.role === 'user' ? 'user' : info.role === 'assistant' ? 'assistant' : null;
|
|
173
|
+
if (!role)
|
|
174
|
+
continue;
|
|
175
|
+
const turn = { id: idOf(info) ?? idOf(message), at: eventAt, role, tools: [] };
|
|
176
|
+
for (const partValue of items(message.parts)) {
|
|
177
|
+
const part = object(partValue);
|
|
178
|
+
if (part?.type === 'text')
|
|
179
|
+
turn.text = [turn.text, string(part.text)].filter(Boolean).join('\n') || undefined;
|
|
180
|
+
if (part?.type === 'tool' && role === 'assistant') {
|
|
181
|
+
const state = object(part.state);
|
|
182
|
+
const status = (string(state?.status) ?? '').toLowerCase();
|
|
183
|
+
const tool = { id: string(part.callID ?? part.id) ?? `tool-${turn.tools.length}`, name: string(part.tool) ?? 'tool', input: state?.input === undefined ? undefined : compact(state.input), outputLines: 0, outputBytes: 0 };
|
|
184
|
+
if (/completed|error|cancelled/.test(status)) {
|
|
185
|
+
const output = compact(state?.output ?? state?.error ?? '');
|
|
186
|
+
tool.output = output.slice(0, MAX_OUTPUT_BYTES);
|
|
187
|
+
tool.outputBytes = Buffer.byteLength(output);
|
|
188
|
+
tool.outputLines = lineCount(output);
|
|
189
|
+
}
|
|
190
|
+
turn.tools.push(tool);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (role === 'user' && !turn.text)
|
|
194
|
+
continue;
|
|
195
|
+
events.push({ at: eventAt, turn });
|
|
196
|
+
}
|
|
197
|
+
return events;
|
|
198
|
+
}
|
|
199
|
+
// --- reading an interval ------------------------------------------------------------------------------------
|
|
200
|
+
// Collects the interval's turns from any event source. Caps keep the NEWEST turns, because a live tail and a
|
|
201
|
+
// closed stretch are both read for what happened last; every dropped turn or byte is counted, never hidden.
|
|
202
|
+
export class IntervalCollector {
|
|
203
|
+
turns = [];
|
|
204
|
+
byTool = new Map();
|
|
205
|
+
evicted = new Set();
|
|
206
|
+
synthesized = new Map(); // `<role>@<at>` → how many turns already wore it
|
|
207
|
+
sawTimestamp = false;
|
|
208
|
+
omittedTurns = 0;
|
|
209
|
+
omittedBytes = 0;
|
|
210
|
+
outOfOrderEvents = 0;
|
|
211
|
+
pastRange = false;
|
|
212
|
+
range;
|
|
213
|
+
constructor(range) { this.range = { from: range.from, to: range.to }; }
|
|
214
|
+
// the open interval's end is "now" and moves; extending it never revisits what was already collected
|
|
215
|
+
extend(to) { if (to > this.range.to)
|
|
216
|
+
this.range.to = to; }
|
|
217
|
+
// returns true once the source has moved past `to` (the caller may then bound its lookahead)
|
|
218
|
+
add(event) {
|
|
219
|
+
const eventAt = event.at;
|
|
220
|
+
if (eventAt === null)
|
|
221
|
+
return this.pastRange;
|
|
222
|
+
this.sawTimestamp = true;
|
|
223
|
+
if (!this.pastRange && eventAt > this.range.to)
|
|
224
|
+
this.pastRange = true;
|
|
225
|
+
else if (this.pastRange && eventAt <= this.range.to)
|
|
226
|
+
this.outOfOrderEvents++;
|
|
227
|
+
if (eventAt < this.range.from || eventAt > this.range.to)
|
|
228
|
+
return this.pastRange;
|
|
229
|
+
if (event.toolOutputs) {
|
|
230
|
+
for (const output of event.toolOutputs) {
|
|
231
|
+
const bytes = Buffer.byteLength(output.text);
|
|
232
|
+
const tool = this.byTool.get(output.id);
|
|
233
|
+
if (!tool || this.evicted.has(output.id)) {
|
|
234
|
+
this.omittedBytes += bytes;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
tool.outputBytes += bytes;
|
|
238
|
+
tool.outputLines += lineCount(output.text);
|
|
239
|
+
if (tool.output === undefined)
|
|
240
|
+
tool.output = '';
|
|
241
|
+
const remaining = Math.max(0, MAX_OUTPUT_BYTES - Buffer.byteLength(tool.output));
|
|
242
|
+
tool.output += output.text.slice(0, remaining);
|
|
243
|
+
if (bytes > remaining)
|
|
244
|
+
this.omittedBytes += bytes - remaining;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else if (event.turn) {
|
|
248
|
+
// the parsed event is never written to: an in-memory source ([[live-transcript]]) collects the same
|
|
249
|
+
// event again on every read, so the collector works on its own copy of the turn and its calls
|
|
250
|
+
const turn = { ...event.turn, tools: event.turn.tools.map((tool) => ({ ...tool })) };
|
|
251
|
+
// a turn without a native id gets one from its place in the thread — deterministic across re-reads of an
|
|
252
|
+
// append-only source, which is what lets a subscriber match it between frames
|
|
253
|
+
if (turn.id === null) {
|
|
254
|
+
const base = `${turn.role}@${turn.at}`;
|
|
255
|
+
const seen = this.synthesized.get(base) ?? 0;
|
|
256
|
+
this.synthesized.set(base, seen + 1);
|
|
257
|
+
turn.id = seen ? `${base}#${seen}` : base;
|
|
258
|
+
}
|
|
259
|
+
this.turns.push(turn);
|
|
260
|
+
for (const tool of turn.tools)
|
|
261
|
+
this.byTool.set(tool.id, tool);
|
|
262
|
+
if (this.turns.length > MAX_TURNS) {
|
|
263
|
+
const dropped = this.turns.shift();
|
|
264
|
+
for (const tool of dropped.tools)
|
|
265
|
+
this.evicted.add(tool.id);
|
|
266
|
+
this.omittedTurns++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return this.pastRange;
|
|
270
|
+
}
|
|
271
|
+
finish(revision, harness) {
|
|
272
|
+
if (!this.sawTimestamp)
|
|
273
|
+
throw new TranscriptReadError('invalid', `${harness} transcript has no reliable timestamps; interval reads are unavailable`);
|
|
274
|
+
return {
|
|
275
|
+
revision,
|
|
276
|
+
from: this.range.from,
|
|
277
|
+
to: this.range.to,
|
|
278
|
+
turns: this.turns.map((turn) => ({ ...turn, id: turn.id, tools: turn.tools.length ? turn.tools.map((tool) => ({ ...tool })) : undefined })),
|
|
279
|
+
truncated: this.omittedTurns > 0 || this.omittedBytes > 0 || this.outOfOrderEvents > 0,
|
|
280
|
+
omittedTurns: this.omittedTurns,
|
|
281
|
+
omittedBytes: this.omittedBytes,
|
|
282
|
+
outOfOrderEvents: this.outOfOrderEvents,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type TranscriptReader } from './turns.js';
|
|
2
|
+
export declare function claudeTranscriptPath(threadId: string, root?: string): string | null;
|
|
3
|
+
export declare function codexRolloutPath(threadId: string, root?: string): string | null;
|
|
4
|
+
export declare function piSessionPath(threadId: string, root?: string): string | null;
|
|
5
|
+
export declare const claudeTranscript: TranscriptReader;
|
|
6
|
+
export declare const codexTranscript: TranscriptReader;
|
|
7
|
+
export declare const piTranscript: TranscriptReader;
|
|
8
|
+
export declare function opencodeTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
|
|
9
|
+
export declare const opencodeTranscript: TranscriptReader;
|
|
10
|
+
export declare function unsupportedTranscript(harness: string): TranscriptReader;
|
package/dist/readers.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { TranscriptReadError } from './turns.js';
|
|
6
|
+
import { IntervalCollector, claudeEvent, codexEvent, opencodeEvents, piEvent } from './parsers.js';
|
|
7
|
+
// THE NATIVE-THREAD READERS. Each harness keeps its conversation somewhere private — Claude's project JSONL,
|
|
8
|
+
// Codex's rollout, pi's session JSONL, OpenCode's store behind `opencode export` — and this module is the only
|
|
9
|
+
// place that knows where. It answers exactly one question for every harness: "what happened in this thread
|
|
10
|
+
// between `from` and `to`?", as normalized turns, through the three reader verbs of [[transcript]].
|
|
11
|
+
const POST_RANGE_LOOKAHEAD_LINES = 256;
|
|
12
|
+
const children = (dir) => { try {
|
|
13
|
+
return readdirSync(dir).sort().reverse();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
} };
|
|
18
|
+
// --- where each harness keeps the thread ------------------------------------------------------------------
|
|
19
|
+
const projectTranscriptRoot = () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
|
|
20
|
+
export function claudeTranscriptPath(threadId, root = projectTranscriptRoot()) {
|
|
21
|
+
for (const project of children(root)) {
|
|
22
|
+
const path = join(root, project, `${threadId}.jsonl`);
|
|
23
|
+
try {
|
|
24
|
+
if (statSync(path).isFile())
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
catch { /* try next */ }
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
|
|
32
|
+
// Walk newest day first and return on the first hit; the walk is exhaustive rather than capped, because
|
|
33
|
+
// future-dated junk under sessions/ sorts above every real day and a cap once masked every real rollout.
|
|
34
|
+
export function codexRolloutPath(threadId, root = codexSessionsDir()) {
|
|
35
|
+
for (const year of children(root))
|
|
36
|
+
for (const month of children(join(root, year)))
|
|
37
|
+
for (const day of children(join(root, year, month))) {
|
|
38
|
+
const dir = join(root, year, month, day);
|
|
39
|
+
const file = children(dir).find((name) => name.includes(threadId));
|
|
40
|
+
if (file)
|
|
41
|
+
return join(dir, file);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const piSessionsRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions');
|
|
46
|
+
const piSessionPaths = new Map();
|
|
47
|
+
export function piSessionPath(threadId, root = piSessionsRoot()) {
|
|
48
|
+
const key = `${root}:${threadId}`;
|
|
49
|
+
const cached = piSessionPaths.get(key);
|
|
50
|
+
if (cached) {
|
|
51
|
+
try {
|
|
52
|
+
if (statSync(cached).isFile())
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
piSessionPaths.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const directory of children(root))
|
|
60
|
+
for (const file of children(join(root, directory))) {
|
|
61
|
+
if (!file.endsWith('.jsonl'))
|
|
62
|
+
continue;
|
|
63
|
+
const path = join(root, directory, file);
|
|
64
|
+
try {
|
|
65
|
+
const header = JSON.parse(readFileSync(path, 'utf8').split('\n', 1)[0]);
|
|
66
|
+
if (header && typeof header === 'object' && header.type === 'session' && header.id === threadId) {
|
|
67
|
+
piSessionPaths.set(key, path);
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* unreadable entries are not a match */ }
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const opencodeStoreRoot = () => process.env.SPEXCODE_OPENCODE_DATA_DIR
|
|
76
|
+
|| join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode');
|
|
77
|
+
function opencodeStoreRevision(root) {
|
|
78
|
+
try {
|
|
79
|
+
const database = statSync(join(root, 'opencode.db'));
|
|
80
|
+
let writeAheadLog = '0:0';
|
|
81
|
+
try {
|
|
82
|
+
const stat = statSync(join(root, 'opencode.db-wal'));
|
|
83
|
+
writeAheadLog = `${stat.size}:${Math.floor(stat.mtimeMs)}`;
|
|
84
|
+
}
|
|
85
|
+
catch { /* a checkpointed database has no separate write-ahead log */ }
|
|
86
|
+
return `${database.size}:${Math.floor(database.mtimeMs)}:${writeAheadLog}`;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// The export is read RAW: `--sanitize` replaces every prose and tool-output part with a `[redacted:…]` token,
|
|
93
|
+
// which made the whole conversation unreadable; the reader hands over the same local bytes the other harnesses'
|
|
94
|
+
// files hold, and nothing here leaves the machine that ran the thread.
|
|
95
|
+
function opencodeExport(threadId) {
|
|
96
|
+
return execFileSync(process.env.SPEXCODE_OPENCODE_CMD || 'opencode', ['export', threadId], {
|
|
97
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const fileRevision = (path) => {
|
|
101
|
+
try {
|
|
102
|
+
const stat = statSync(path);
|
|
103
|
+
return `${stat.size}:${Math.floor(stat.mtimeMs)}`;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// @@@interval-seek - a native file is append-only, so the byte where an interval's first event sits never
|
|
110
|
+
// moves. The open tail re-reads its interval on every change; remembering that offset per (file, from) turns
|
|
111
|
+
// each re-read into "parse the current stretch" instead of "parse the whole thread again".
|
|
112
|
+
const intervalOffsets = new Map();
|
|
113
|
+
// One pass over the bytes from `scan.position` to the end of the file. Every complete line is parsed as JSON
|
|
114
|
+
// and handed to `onLine` with its byte offset; `onLine` returning true stops the scan early (a bounded
|
|
115
|
+
// lookahead), which abandons the rest — only a one-shot read does that.
|
|
116
|
+
function scanLines(harness, fd, scan, onLine) {
|
|
117
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
118
|
+
let { position, carry } = scan;
|
|
119
|
+
let lineStart = position - carry.length;
|
|
120
|
+
while (true) {
|
|
121
|
+
const read = readSync(fd, chunk, 0, chunk.length, position);
|
|
122
|
+
if (read <= 0)
|
|
123
|
+
break;
|
|
124
|
+
position += read;
|
|
125
|
+
const buffer = carry.length ? Buffer.concat([carry, chunk.subarray(0, read)]) : Buffer.from(chunk.subarray(0, read));
|
|
126
|
+
let cut = 0;
|
|
127
|
+
for (let index = 0; index < buffer.length; index++) {
|
|
128
|
+
if (buffer[index] !== 10)
|
|
129
|
+
continue;
|
|
130
|
+
const line = buffer.subarray(cut, index).toString('utf8');
|
|
131
|
+
const lineOffset = lineStart;
|
|
132
|
+
lineStart += index - cut + 1;
|
|
133
|
+
cut = index + 1;
|
|
134
|
+
if (!line.trim())
|
|
135
|
+
continue;
|
|
136
|
+
let value;
|
|
137
|
+
try {
|
|
138
|
+
value = JSON.parse(line);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
throw new TranscriptReadError('invalid', `${harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
142
|
+
}
|
|
143
|
+
if (onLine(value, lineOffset))
|
|
144
|
+
return { position, carry: Buffer.alloc(0) };
|
|
145
|
+
}
|
|
146
|
+
carry = Buffer.from(buffer.subarray(cut));
|
|
147
|
+
}
|
|
148
|
+
return { position, carry };
|
|
149
|
+
}
|
|
150
|
+
// The cursor over one interval of one line file. A one-shot `read` is a cursor advanced once and dropped; the
|
|
151
|
+
// open interval's `tail` keeps it, so each advance parses only what the harness appended since the last one.
|
|
152
|
+
class LineFileCursor {
|
|
153
|
+
harness;
|
|
154
|
+
path;
|
|
155
|
+
parse;
|
|
156
|
+
from;
|
|
157
|
+
collector;
|
|
158
|
+
scan = { position: 0, carry: Buffer.alloc(0) };
|
|
159
|
+
started = false;
|
|
160
|
+
seekKey;
|
|
161
|
+
constructor(harness, path, parse, from) {
|
|
162
|
+
this.harness = harness;
|
|
163
|
+
this.path = path;
|
|
164
|
+
this.parse = parse;
|
|
165
|
+
this.from = from;
|
|
166
|
+
this.seekKey = `${path}\n${from}`;
|
|
167
|
+
this.collector = new IntervalCollector({ from, to: from });
|
|
168
|
+
}
|
|
169
|
+
restart(size) {
|
|
170
|
+
const seek = intervalOffsets.get(this.seekKey) ?? 0;
|
|
171
|
+
const start = seek > 0 && seek < size ? seek : 0;
|
|
172
|
+
this.collector = new IntervalCollector({ from: this.from, to: this.from });
|
|
173
|
+
// a seek lands on the interval's first event, so the timestamps before it are known to exist
|
|
174
|
+
if (start > 0)
|
|
175
|
+
this.collector.sawTimestamp = true;
|
|
176
|
+
this.scan = { position: start, carry: Buffer.alloc(0) };
|
|
177
|
+
}
|
|
178
|
+
advance(to, lookahead = Number.POSITIVE_INFINITY) {
|
|
179
|
+
let size = 0;
|
|
180
|
+
try {
|
|
181
|
+
size = statSync(this.path).size;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
throw new TranscriptReadError('unreadable', `${this.harness} transcript is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
185
|
+
}
|
|
186
|
+
if (size <= 0)
|
|
187
|
+
throw new TranscriptReadError('unreadable', `${this.harness} transcript is unreadable: file is empty`);
|
|
188
|
+
// a source that shrank was rewritten underneath the cursor: forget the position and read the interval afresh
|
|
189
|
+
if (!this.started || size < this.scan.position) {
|
|
190
|
+
this.restart(size);
|
|
191
|
+
this.started = true;
|
|
192
|
+
}
|
|
193
|
+
this.collector.extend(to);
|
|
194
|
+
let fd = null;
|
|
195
|
+
try {
|
|
196
|
+
fd = openSync(this.path, 'r');
|
|
197
|
+
let postRangeLines = 0;
|
|
198
|
+
this.scan = scanLines(this.harness, fd, this.scan, (value, offset) => {
|
|
199
|
+
const event = this.parse(value);
|
|
200
|
+
if (!event)
|
|
201
|
+
return false;
|
|
202
|
+
const inRange = event.at !== null && event.at >= this.from && event.at <= to;
|
|
203
|
+
if (inRange && !intervalOffsets.has(this.seekKey))
|
|
204
|
+
intervalOffsets.set(this.seekKey, offset);
|
|
205
|
+
const pastRange = this.collector.add(event);
|
|
206
|
+
return pastRange && ++postRangeLines >= lookahead;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error instanceof TranscriptReadError)
|
|
211
|
+
throw error;
|
|
212
|
+
throw new TranscriptReadError('unreadable', `${this.harness} transcript could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
if (fd !== null)
|
|
216
|
+
closeSync(fd);
|
|
217
|
+
}
|
|
218
|
+
return this.collector.finish(fileRevision(this.path) ?? `${size}`, this.harness);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function lineFileReader(harness, locate, parse) {
|
|
222
|
+
const find = (threadId) => {
|
|
223
|
+
const path = locate(threadId);
|
|
224
|
+
if (!path)
|
|
225
|
+
throw new TranscriptReadError('missing', `${harness} transcript for ${threadId} is unavailable: file was not found`);
|
|
226
|
+
return path;
|
|
227
|
+
};
|
|
228
|
+
return {
|
|
229
|
+
revision: (threadId) => { const path = locate(threadId); return path ? fileRevision(path) : null; },
|
|
230
|
+
// after passing `to`, a one-shot read scans a fixed lookahead window for timestamp disorder before stopping
|
|
231
|
+
read: async (threadId, range) => new LineFileCursor(harness, find(threadId), parse, range.from).advance(range.to, POST_RANGE_LOOKAHEAD_LINES),
|
|
232
|
+
tail: (threadId, from) => {
|
|
233
|
+
let cursor = null;
|
|
234
|
+
return {
|
|
235
|
+
// the file is located on the first advance, so a tail opened before the thread exists fails as `missing`
|
|
236
|
+
// there rather than at construction
|
|
237
|
+
advance: async (to) => (cursor ??= new LineFileCursor(harness, find(threadId), parse, from)).advance(to),
|
|
238
|
+
close: () => { cursor = null; },
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export const claudeTranscript = lineFileReader('claude', (threadId) => claudeTranscriptPath(threadId), claudeEvent);
|
|
244
|
+
export const codexTranscript = lineFileReader('codex', (threadId) => codexRolloutPath(threadId), codexEvent);
|
|
245
|
+
export const piTranscript = lineFileReader('pi', (threadId) => piSessionPath(threadId), piEvent);
|
|
246
|
+
// OpenCode has no per-thread file: the store's revision is the change token, and one export per
|
|
247
|
+
// revision is parsed and kept, so repeated interval reads of a quiet thread cost nothing new.
|
|
248
|
+
const opencodeExports = new Map();
|
|
249
|
+
export function opencodeTranscriptReader(root = opencodeStoreRoot(), load = opencodeExport) {
|
|
250
|
+
const reader = {
|
|
251
|
+
revision: () => opencodeStoreRevision(root),
|
|
252
|
+
read: async (threadId, range) => {
|
|
253
|
+
const revision = opencodeStoreRevision(root);
|
|
254
|
+
if (!revision)
|
|
255
|
+
throw new TranscriptReadError('missing', `opencode transcript for ${threadId} is unavailable: store was not found`);
|
|
256
|
+
const key = `${root}:${threadId}`;
|
|
257
|
+
let cached = opencodeExports.get(key);
|
|
258
|
+
if (!cached || cached.revision !== revision) {
|
|
259
|
+
let exported;
|
|
260
|
+
try {
|
|
261
|
+
exported = load(threadId);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
throw new TranscriptReadError('unreadable', `opencode transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
|
|
265
|
+
}
|
|
266
|
+
let value;
|
|
267
|
+
try {
|
|
268
|
+
value = JSON.parse(exported);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
throw new TranscriptReadError('invalid', `opencode transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
272
|
+
}
|
|
273
|
+
cached = { revision, events: opencodeEvents(value) };
|
|
274
|
+
opencodeExports.set(key, cached);
|
|
275
|
+
}
|
|
276
|
+
const collector = new IntervalCollector(range);
|
|
277
|
+
for (const event of cached.events)
|
|
278
|
+
collector.add(event);
|
|
279
|
+
return collector.finish(revision, 'opencode');
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
...reader,
|
|
284
|
+
// no file grows here: an open interval is re-collected from the cached export, which is one export per revision
|
|
285
|
+
tail: (threadId, from) => ({ advance: (to) => reader.read(threadId, { from, to }), close: () => { } }),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export const opencodeTranscript = opencodeTranscriptReader();
|
|
289
|
+
export function unsupportedTranscript(harness) {
|
|
290
|
+
const refuse = async () => { throw new TranscriptReadError('unsupported', `${harness} does not support transcript access`); };
|
|
291
|
+
return {
|
|
292
|
+
revision: () => null,
|
|
293
|
+
read: refuse,
|
|
294
|
+
tail: () => ({ advance: refuse, close: () => { } }),
|
|
295
|
+
};
|
|
296
|
+
}
|
package/dist/turns.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type TranscriptRange = Readonly<{
|
|
2
|
+
from: number;
|
|
3
|
+
to: number;
|
|
4
|
+
}>;
|
|
5
|
+
export type TranscriptTool = Readonly<{
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
input?: string;
|
|
9
|
+
output?: string;
|
|
10
|
+
outputLines: number;
|
|
11
|
+
outputBytes: number;
|
|
12
|
+
}>;
|
|
13
|
+
export type TranscriptTurn = Readonly<{
|
|
14
|
+
id: string;
|
|
15
|
+
at: number;
|
|
16
|
+
role: 'user' | 'assistant';
|
|
17
|
+
text?: string;
|
|
18
|
+
tools?: readonly TranscriptTool[];
|
|
19
|
+
}>;
|
|
20
|
+
export type TranscriptRead = Readonly<{
|
|
21
|
+
revision: string;
|
|
22
|
+
from: number;
|
|
23
|
+
to: number;
|
|
24
|
+
turns: readonly TranscriptTurn[];
|
|
25
|
+
truncated: boolean;
|
|
26
|
+
omittedTurns: number;
|
|
27
|
+
omittedBytes: number;
|
|
28
|
+
outOfOrderEvents: number;
|
|
29
|
+
}>;
|
|
30
|
+
export type TranscriptTail = Readonly<{
|
|
31
|
+
advance(to: number): Promise<TranscriptRead>;
|
|
32
|
+
close(): void;
|
|
33
|
+
}>;
|
|
34
|
+
export type TranscriptReader = Readonly<{
|
|
35
|
+
revision(threadId: string): string | null;
|
|
36
|
+
read(threadId: string, range: TranscriptRange): Promise<TranscriptRead>;
|
|
37
|
+
tail(threadId: string, from: number): TranscriptTail;
|
|
38
|
+
}>;
|
|
39
|
+
export declare class TranscriptReadError extends Error {
|
|
40
|
+
readonly reason: 'unsupported' | 'missing' | 'unreadable' | 'invalid';
|
|
41
|
+
constructor(reason: 'unsupported' | 'missing' | 'unreadable' | 'invalid', message: string);
|
|
42
|
+
}
|
package/dist/turns.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// THE NORMALIZED TRANSCRIPT. Every harness keeps its conversation in a private shape; this is the one shape
|
|
2
|
+
// every surface reads instead: user and assistant prose, tool calls with their input, and each call's output
|
|
3
|
+
// once the harness recorded it. Nothing here imports Node, so a browser renderer and a Node reader share it.
|
|
4
|
+
export class TranscriptReadError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.name = 'TranscriptReadError';
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spexcode/transcript",
|
|
3
|
+
"version": "0.7.0-next.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Normalized agent transcripts: one parser per harness, a bounded interval reader over a native thread file or an in-memory event stream, and the full/delta frame protocol every transport and renderer share.",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js",
|
|
11
|
+
"./frames": "./dist/frames.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "node ../../scripts/build-dist.mjs",
|
|
22
|
+
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
23
|
+
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^18.19.0",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"typescript": "^5.6.3"
|
|
29
|
+
}
|
|
30
|
+
}
|