@agentic-kit/run-log 0.5.0 → 0.7.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.
Files changed (64) hide show
  1. package/README.md +0 -4
  2. package/esm/file-store.d.ts +2 -2
  3. package/esm/index.d.ts +6 -0
  4. package/esm/index.js +5 -0
  5. package/esm/mirror.d.ts +6 -6
  6. package/esm/mirror.js +5 -2
  7. package/esm/projectors/events.d.ts +31 -0
  8. package/esm/projectors/events.js +23 -0
  9. package/esm/projectors/parts.d.ts +13 -10
  10. package/esm/projectors/parts.js +88 -105
  11. package/esm/projectors/session.d.ts +5 -0
  12. package/esm/projectors/session.js +6 -0
  13. package/esm/projectors/spans.d.ts +10 -9
  14. package/esm/projectors/spans.js +115 -111
  15. package/esm/projectors/tool-state.d.ts +4 -3
  16. package/esm/projectors/tool-state.js +28 -33
  17. package/esm/projectors/usage.d.ts +3 -2
  18. package/esm/projectors/usage.js +12 -18
  19. package/esm/record.d.ts +9 -6
  20. package/esm/record.js +15 -13
  21. package/esm/store.d.ts +5 -5
  22. package/esm/transcripts/entry.d.ts +20 -0
  23. package/esm/transcripts/entry.js +23 -0
  24. package/esm/transcripts/event.d.ts +118 -0
  25. package/esm/transcripts/event.js +15 -0
  26. package/esm/transcripts/pi-entry.d.ts +1 -0
  27. package/esm/transcripts/pi-reader.d.ts +16 -0
  28. package/esm/transcripts/pi-reader.js +137 -0
  29. package/esm/transcripts/reader.d.ts +45 -0
  30. package/esm/transcripts/reader.js +66 -0
  31. package/esm/transcripts/registry.d.ts +16 -0
  32. package/esm/transcripts/registry.js +17 -0
  33. package/file-store.d.ts +2 -2
  34. package/index.d.ts +6 -0
  35. package/index.js +15 -1
  36. package/mirror.d.ts +6 -6
  37. package/mirror.js +5 -2
  38. package/package.json +2 -2
  39. package/projectors/events.d.ts +31 -0
  40. package/projectors/events.js +27 -0
  41. package/projectors/parts.d.ts +13 -10
  42. package/projectors/parts.js +88 -105
  43. package/projectors/session.d.ts +5 -0
  44. package/projectors/session.js +6 -0
  45. package/projectors/spans.d.ts +10 -9
  46. package/projectors/spans.js +115 -111
  47. package/projectors/tool-state.d.ts +4 -3
  48. package/projectors/tool-state.js +28 -33
  49. package/projectors/usage.d.ts +3 -2
  50. package/projectors/usage.js +12 -18
  51. package/record.d.ts +9 -6
  52. package/record.js +14 -12
  53. package/store.d.ts +5 -5
  54. package/transcripts/entry.d.ts +20 -0
  55. package/transcripts/entry.js +28 -0
  56. package/transcripts/event.d.ts +118 -0
  57. package/transcripts/event.js +16 -0
  58. package/transcripts/pi-entry.d.ts +1 -0
  59. package/transcripts/pi-reader.d.ts +16 -0
  60. package/transcripts/pi-reader.js +141 -0
  61. package/transcripts/reader.d.ts +45 -0
  62. package/transcripts/reader.js +71 -0
  63. package/transcripts/registry.d.ts +16 -0
  64. package/transcripts/registry.js +21 -0
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The log answers *what* happened and the parts projection answers *in what
5
5
  * order*, but a trace view asks "how long did this take, and inside what" —
6
- * questions pi's entries only answer in pairs. Every entry is timestamped, so a
6
+ * questions a transcript only answers in pairs. Every entry is timestamped, so a
7
7
  * tool call and its result, or an approval request and its answer, bracket an
8
8
  * interval; nothing new has to be stored to know it.
9
9
  *
@@ -15,7 +15,7 @@
15
15
  * `open` with no duration rather than being closed at the last record: a run
16
16
  * still in flight must not look like one that finished.
17
17
  */
18
- import { contentText, isAssistantMessage, isPiMessageEntry, isToolResultMessage, toolCalls } from '../transcripts/pi-entry';
18
+ import { toEventGroups } from './events';
19
19
  import { APPROVAL_REQUEST_TYPE, APPROVAL_RESOLUTION_TYPE, assertGateDecisionDetails, GATE_DECISION_TYPE } from './tool-state';
20
20
  const ms = (from, to) => {
21
21
  const start = Date.parse(from);
@@ -32,63 +32,67 @@ const close = (span, seq, at) => {
32
32
  span.durationMs = duration;
33
33
  };
34
34
  const detailsOf = (value) => typeof value === 'object' && value !== null ? value : {};
35
+ const firstTimestamp = (events) => events.find((event) => typeof event.at === 'string')?.at;
35
36
  /**
36
37
  * Project records into spans. Pure, and like the other projectors it ignores what
37
- * it does not recognise — a log written by a newer pi still traces. A *registered*
38
- * type that is malformed is another matter and throws: a gate decision naming no
39
- * tool call is a writer bug, and a trace that quietly omitted a refusal would be
40
- * worse than one that fails.
38
+ * it does not recognise — a log written by a newer harness still traces. A
39
+ * *registered* type that is malformed is another matter and throws: a gate
40
+ * decision naming no tool call is a writer bug, and a trace that quietly omitted
41
+ * a refusal would be worse than one that fails.
41
42
  */
42
- export function projectSpans(records) {
43
+ export function projectSpans(records, options = {}) {
43
44
  const spans = [];
44
45
  const tools = new Map();
45
46
  const approvals = new Map();
46
47
  /**
47
48
  * The end of the previous entry, which is the earliest the next model call
48
- * can have begun — pi logs a response, never a request.
49
+ * can have begun — a transcript logs a response, never a request.
49
50
  */
50
51
  let previousAt;
51
- for (const { entry, seq } of records) {
52
- const at = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
52
+ for (const { events, seq } of toEventGroups(records, options)) {
53
+ const at = firstTimestamp(events);
53
54
  if (at === undefined)
54
55
  continue;
55
56
  const previous = previousAt;
56
57
  previousAt = at;
57
- if (!isPiMessageEntry(entry))
58
- continue;
59
- const message = entry.message;
60
- if (isAssistantMessage(message)) {
61
- const span = {
62
- id: `model:${entry.id}`,
63
- kind: 'model',
64
- name: message.model ?? 'model',
65
- // The turn began no earlier than the previous entry was written; with no
66
- // previous entry the interval is unknown and the span is a point.
67
- startedAt: previous ?? at,
68
- startSeq: seq,
69
- endSeq: seq,
70
- endedAt: at,
71
- timing: 'bounded',
72
- status: message.stopReason === 'error' ? 'error' : 'ok',
73
- ...(message.model ? { model: message.model } : {}),
74
- ...(message.provider ? { provider: message.provider } : {}),
75
- ...(typeof message.responseId === 'string' ? { responseId: message.responseId } : {}),
76
- ...(message.stopReason ? { stopReason: message.stopReason } : {}),
77
- ...(message.usage ? { usage: message.usage } : {}),
78
- ...(message.errorMessage ? { errorMessage: message.errorMessage } : {})
79
- };
80
- const duration = ms(span.startedAt, at);
81
- if (duration !== undefined)
82
- span.durationMs = duration;
83
- spans.push(span);
84
- for (const call of toolCalls(message)) {
58
+ /** The model call the entry's tool calls belong to. */
59
+ let modelSpan;
60
+ for (const event of events) {
61
+ if (event.kind === 'model-response') {
62
+ const span = {
63
+ id: `model:${event.entryId ?? String(seq)}`,
64
+ kind: 'model',
65
+ name: event.model ?? 'model',
66
+ // The turn began no earlier than the previous entry was written; with
67
+ // no previous entry the interval is unknown and the span is a point.
68
+ startedAt: previous ?? at,
69
+ startSeq: seq,
70
+ endSeq: seq,
71
+ endedAt: at,
72
+ timing: 'bounded',
73
+ status: event.stopReason === 'error' ? 'error' : 'ok',
74
+ ...(event.model ? { model: event.model } : {}),
75
+ ...(event.provider ? { provider: event.provider } : {}),
76
+ ...(event.responseId ? { responseId: event.responseId } : {}),
77
+ ...(event.stopReason ? { stopReason: event.stopReason } : {}),
78
+ ...(event.usage ? { usage: event.usage } : {}),
79
+ ...(event.errorMessage ? { errorMessage: event.errorMessage } : {})
80
+ };
81
+ const duration = ms(span.startedAt, at);
82
+ if (duration !== undefined)
83
+ span.durationMs = duration;
84
+ modelSpan = span;
85
+ spans.push(span);
86
+ continue;
87
+ }
88
+ if (event.kind === 'tool-call') {
85
89
  const tool = {
86
- id: `tool:${call.id}`,
90
+ id: `tool:${event.toolCallId}`,
87
91
  kind: 'tool',
88
- name: call.name,
89
- parentId: span.id,
90
- toolCallId: call.id,
91
- arguments: call.arguments ?? {},
92
+ name: event.name,
93
+ ...(modelSpan ? { parentId: modelSpan.id } : {}),
94
+ toolCallId: event.toolCallId,
95
+ arguments: event.arguments,
92
96
  startedAt: at,
93
97
  startSeq: seq,
94
98
  // A call and its result are two writes; anything between them —
@@ -96,79 +100,79 @@ export function projectSpans(records) {
96
100
  timing: 'bounded',
97
101
  status: 'open'
98
102
  };
99
- tools.set(call.id, tool);
103
+ tools.set(event.toolCallId, tool);
100
104
  spans.push(tool);
105
+ continue;
101
106
  }
102
- continue;
103
- }
104
- if (isToolResultMessage(message)) {
105
- const tool = tools.get(message.toolCallId);
106
- if (!tool)
107
+ if (event.kind === 'tool-result') {
108
+ const tool = tools.get(event.toolCallId);
109
+ if (!tool)
110
+ continue;
111
+ close(tool, seq, at);
112
+ tool.status = event.failed ? 'error' : 'ok';
113
+ tool.output = event.output;
114
+ if (event.details !== undefined)
115
+ tool.details = event.details;
116
+ const approval = approvals.get(event.toolCallId);
117
+ if (approval?.durationMs !== undefined)
118
+ tool.approvalWaitMs = approval.durationMs;
107
119
  continue;
108
- close(tool, seq, at);
109
- tool.status = message.isError ? 'error' : 'ok';
110
- tool.output = contentText(message.content);
111
- if (message.details !== undefined)
112
- tool.details = message.details;
113
- const approval = approvals.get(message.toolCallId);
114
- if (approval?.durationMs !== undefined)
115
- tool.approvalWaitMs = approval.durationMs;
116
- continue;
117
- }
118
- if (message.role !== 'custom')
119
- continue;
120
- const info = detailsOf(message.details);
121
- const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : undefined;
122
- if (toolCallId === undefined)
123
- continue;
124
- if (message.customType === APPROVAL_REQUEST_TYPE) {
125
- const tool = tools.get(toolCallId);
126
- const approval = {
127
- id: `approval:${toolCallId}`,
128
- kind: 'approval',
129
- name: tool?.name ?? 'approval',
130
- ...(tool ? { parentId: tool.id } : {}),
131
- toolCallId,
132
- prompt: contentText(message.content),
133
- startedAt: at,
134
- startSeq: seq,
135
- // Request and answer are exactly the interval a human held the run.
136
- timing: 'measured',
137
- status: 'open'
138
- };
139
- approvals.set(toolCallId, approval);
140
- spans.push(approval);
141
- continue;
142
- }
143
- if (message.customType === APPROVAL_RESOLUTION_TYPE) {
144
- const approval = approvals.get(toolCallId);
145
- if (!approval)
120
+ }
121
+ if (event.kind !== 'custom')
146
122
  continue;
147
- close(approval, seq, at);
148
- const approved = info.decision === 'approved';
149
- approval.decision = approved ? 'approved' : 'rejected';
150
- approval.status = approved ? 'ok' : 'denied';
151
- if (typeof info.actorId === 'string')
152
- approval.actorId = info.actorId;
153
- const tool = tools.get(toolCallId);
154
- if (tool && approval.durationMs !== undefined)
155
- tool.approvalWaitMs = approval.durationMs;
156
- continue;
157
- }
158
- if (message.customType === GATE_DECISION_TYPE) {
159
- // Throws on a malformed decision — see the note on this function.
160
- const decision = assertGateDecisionDetails(message.details, seq);
161
- const tool = tools.get(decision.toolCallId);
162
- if (!tool)
123
+ const info = detailsOf(event.details);
124
+ const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : undefined;
125
+ if (toolCallId === undefined)
163
126
  continue;
164
- tool.gateDecision = decision.decision;
165
- if (decision.reason !== undefined)
166
- tool.gateReason = decision.reason;
167
- // A denied call is never executed, so this write is the only thing that
168
- // will ever close its span.
169
- if (decision.decision === 'deny' && tool.status === 'open') {
170
- close(tool, seq, at);
171
- tool.status = 'denied';
127
+ if (event.customType === APPROVAL_REQUEST_TYPE) {
128
+ const tool = tools.get(toolCallId);
129
+ const approval = {
130
+ id: `approval:${toolCallId}`,
131
+ kind: 'approval',
132
+ name: tool?.name ?? 'approval',
133
+ ...(tool ? { parentId: tool.id } : {}),
134
+ toolCallId,
135
+ prompt: event.text,
136
+ startedAt: at,
137
+ startSeq: seq,
138
+ // Request and answer are exactly the interval a human held the run.
139
+ timing: 'measured',
140
+ status: 'open'
141
+ };
142
+ approvals.set(toolCallId, approval);
143
+ spans.push(approval);
144
+ continue;
145
+ }
146
+ if (event.customType === APPROVAL_RESOLUTION_TYPE) {
147
+ const approval = approvals.get(toolCallId);
148
+ if (!approval)
149
+ continue;
150
+ close(approval, seq, at);
151
+ const approved = info.decision === 'approved';
152
+ approval.decision = approved ? 'approved' : 'rejected';
153
+ approval.status = approved ? 'ok' : 'denied';
154
+ if (typeof info.actorId === 'string')
155
+ approval.actorId = info.actorId;
156
+ const tool = tools.get(toolCallId);
157
+ if (tool && approval.durationMs !== undefined)
158
+ tool.approvalWaitMs = approval.durationMs;
159
+ continue;
160
+ }
161
+ if (event.customType === GATE_DECISION_TYPE) {
162
+ // Throws on a malformed decision — see the note on this function.
163
+ const decision = assertGateDecisionDetails(event.details, seq);
164
+ const tool = tools.get(decision.toolCallId);
165
+ if (!tool)
166
+ continue;
167
+ tool.gateDecision = decision.decision;
168
+ if (decision.reason !== undefined)
169
+ tool.gateReason = decision.reason;
170
+ // A denied call is never executed, so this write is the only thing that
171
+ // will ever close its span.
172
+ if (decision.decision === 'deny' && tool.status === 'open') {
173
+ close(tool, seq, at);
174
+ tool.status = 'denied';
175
+ }
172
176
  }
173
177
  }
174
178
  }
@@ -2,13 +2,14 @@
2
2
  * Tool and approval projection: run log records → the state a UI needs to know
3
3
  * what is running and what is waiting on a human.
4
4
  *
5
- * Approvals ride in the log as pi `custom` messages rather than a side channel,
6
- * because "the run is blocked on you" is part of the run's history: a surface
5
+ * Approvals ride in the log as transcript `custom` entries rather than a side
6
+ * channel, because "the run is blocked on you" is part of the run's history: a surface
7
7
  * that reconnects hours later must be able to see a pending request without
8
8
  * having been present when it was raised, and both placements then behave the
9
9
  * same — the cloud already treats the conversation as the approval UI.
10
10
  */
11
11
  import type { RunEventRecord } from '../record';
12
+ import { type ProjectionOptions } from './events';
12
13
  /** `customType` of an approval request written by the gate extension. */
13
14
  export declare const APPROVAL_REQUEST_TYPE = "constructive.approval.request";
14
15
  /** `customType` of the human's answer to a request. */
@@ -63,7 +64,7 @@ export interface ToolStateProjection {
63
64
  /** In log order — the oldest unanswered request first. */
64
65
  pendingApprovals: ApprovalState[];
65
66
  }
66
- export declare function projectToolState(records: readonly RunEventRecord[]): ToolStateProjection;
67
+ export declare function projectToolState(records: readonly RunEventRecord[], options?: ProjectionOptions): ToolStateProjection;
67
68
  /** Narrow the `details` of a `constructive.gate.decision` entry. */
68
69
  export declare function assertGateDecisionDetails(value: unknown, seq?: number): GateDecisionDetails;
69
70
  /** Narrow the `details` of a `constructive.approval.request` entry. */
@@ -2,13 +2,13 @@
2
2
  * Tool and approval projection: run log records → the state a UI needs to know
3
3
  * what is running and what is waiting on a human.
4
4
  *
5
- * Approvals ride in the log as pi `custom` messages rather than a side channel,
6
- * because "the run is blocked on you" is part of the run's history: a surface
5
+ * Approvals ride in the log as transcript `custom` entries rather than a side
6
+ * channel, because "the run is blocked on you" is part of the run's history: a surface
7
7
  * that reconnects hours later must be able to see a pending request without
8
8
  * having been present when it was raised, and both placements then behave the
9
9
  * same — the cloud already treats the conversation as the approval UI.
10
10
  */
11
- import { contentText, isAssistantMessage, isPiMessageEntry, isToolResultMessage, toolCalls } from '../transcripts/pi-entry';
11
+ import { toEvents } from './events';
12
12
  /** `customType` of an approval request written by the gate extension. */
13
13
  export const APPROVAL_REQUEST_TYPE = 'constructive.approval.request';
14
14
  /** `customType` of the human's answer to a request. */
@@ -24,38 +24,33 @@ export const GATE_DECISION_TYPE = 'constructive.gate.decision';
24
24
  /** Where a bad entry is, when the caller is projecting a log rather than validating one value. */
25
25
  const at = (seq) => (seq === undefined ? '' : ` at seq ${String(seq)}`);
26
26
  const details = (value) => typeof value === 'object' && value !== null ? value : {};
27
- export function projectToolState(records) {
27
+ export function projectToolState(records, options = {}) {
28
28
  const tools = {};
29
29
  const approvals = new Map();
30
30
  const gateDecisions = {};
31
- for (const { entry, seq } of records) {
32
- if (!isPiMessageEntry(entry))
33
- continue;
34
- const message = entry.message;
35
- if (isAssistantMessage(message)) {
36
- for (const call of toolCalls(message)) {
37
- tools[call.id] = {
38
- toolCallId: call.id,
39
- name: call.name,
40
- arguments: call.arguments ?? {},
41
- status: 'requested',
42
- requestedSeq: seq
43
- };
44
- }
31
+ for (const { seq, event } of toEvents(records, options)) {
32
+ if (event.kind === 'tool-call') {
33
+ tools[event.toolCallId] = {
34
+ toolCallId: event.toolCallId,
35
+ name: event.name,
36
+ arguments: event.arguments,
37
+ status: 'requested',
38
+ requestedSeq: seq
39
+ };
45
40
  continue;
46
41
  }
47
- if (isToolResultMessage(message)) {
48
- const state = tools[message.toolCallId];
42
+ if (event.kind === 'tool-result') {
43
+ const state = tools[event.toolCallId];
49
44
  const settled = {
50
- status: message.isError ? 'failed' : 'completed',
45
+ status: event.failed ? 'failed' : 'completed',
51
46
  settledSeq: seq,
52
- output: contentText(message.content)
47
+ output: event.output
53
48
  };
54
- tools[message.toolCallId] = state
49
+ tools[event.toolCallId] = state
55
50
  ? { ...state, ...settled }
56
51
  : {
57
- toolCallId: message.toolCallId,
58
- name: message.toolName,
52
+ toolCallId: event.toolCallId,
53
+ name: event.name,
59
54
  arguments: {},
60
55
  requestedSeq: seq,
61
56
  status: settled.status,
@@ -64,14 +59,14 @@ export function projectToolState(records) {
64
59
  };
65
60
  continue;
66
61
  }
67
- if (message.role !== 'custom')
62
+ if (event.kind !== 'custom')
68
63
  continue;
69
- if (message.customType === APPROVAL_REQUEST_TYPE) {
70
- const { toolCallId } = assertApprovalRequestDetails(message.details, seq);
64
+ if (event.customType === APPROVAL_REQUEST_TYPE) {
65
+ const { toolCallId } = assertApprovalRequestDetails(event.details, seq);
71
66
  const approval = {
72
67
  toolCallId,
73
68
  requestedSeq: seq,
74
- prompt: contentText(message.content)
69
+ prompt: event.text
75
70
  };
76
71
  approvals.set(toolCallId, approval);
77
72
  const state = tools[toolCallId];
@@ -79,8 +74,8 @@ export function projectToolState(records) {
79
74
  tools[toolCallId] = { ...state, status: 'awaiting-approval', approval };
80
75
  continue;
81
76
  }
82
- if (message.customType === APPROVAL_RESOLUTION_TYPE) {
83
- const info = assertApprovalResolutionDetails(message.details, seq);
77
+ if (event.customType === APPROVAL_RESOLUTION_TYPE) {
78
+ const info = assertApprovalResolutionDetails(event.details, seq);
84
79
  const toolCallId = info.toolCallId;
85
80
  const approved = info.decision === 'approved';
86
81
  const existing = approvals.get(toolCallId);
@@ -101,8 +96,8 @@ export function projectToolState(records) {
101
96
  }
102
97
  continue;
103
98
  }
104
- if (message.customType === GATE_DECISION_TYPE) {
105
- const decision = assertGateDecisionDetails(message.details, seq);
99
+ if (event.customType === GATE_DECISION_TYPE) {
100
+ const decision = assertGateDecisionDetails(event.details, seq);
106
101
  gateDecisions[decision.toolCallId] = { ...decision, seq };
107
102
  const state = tools[decision.toolCallId];
108
103
  // A blocked call never produces a tool result, so the gate's `deny` is the
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Usage projection: run log records → token and cost totals.
3
3
  *
4
- * Every usage-bearing pi entry counts, not just assistant messages: a tool that
4
+ * Every usage-bearing event counts, not just model responses: a tool that
5
5
  * performed nested LLM work reports `usage` on its result, and compaction and
6
6
  * branch summaries are model calls the run paid for. Missing any of those makes
7
7
  * a run look cheaper than it was, which is exactly the kind of drift metering
@@ -12,6 +12,7 @@
12
12
  * `@agentic-kit/metering`).
13
13
  */
14
14
  import type { RunEventRecord } from '../record';
15
+ import { type ProjectionOptions } from './events';
15
16
  export interface UsageTotals {
16
17
  input: number;
17
18
  output: number;
@@ -32,4 +33,4 @@ export interface RunUsage extends UsageTotals {
32
33
  }
33
34
  export declare const modelKey: (provider: string, model: string) => string;
34
35
  /** Fold every usage-bearing entry in the records into run totals. */
35
- export declare function projectUsage(records: readonly RunEventRecord[]): RunUsage;
36
+ export declare function projectUsage(records: readonly RunEventRecord[], options?: ProjectionOptions): RunUsage;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Usage projection: run log records → token and cost totals.
3
3
  *
4
- * Every usage-bearing pi entry counts, not just assistant messages: a tool that
4
+ * Every usage-bearing event counts, not just model responses: a tool that
5
5
  * performed nested LLM work reports `usage` on its result, and compaction and
6
6
  * branch summaries are model calls the run paid for. Missing any of those makes
7
7
  * a run look cheaper than it was, which is exactly the kind of drift metering
@@ -11,7 +11,7 @@
11
11
  * never the billing authority — a gateway-observed record is (see
12
12
  * `@agentic-kit/metering`).
13
13
  */
14
- import { isAssistantMessage, isPiBranchSummaryEntry, isPiCompactionEntry, isPiMessageEntry, isToolResultMessage } from '../transcripts/pi-entry';
14
+ import { toEvents } from './events';
15
15
  const empty = () => ({
16
16
  input: 0,
17
17
  output: 0,
@@ -40,7 +40,7 @@ function fold(target, usage) {
40
40
  }
41
41
  export const modelKey = (provider, model) => `${provider}/${model}`;
42
42
  /** Fold every usage-bearing entry in the records into run totals. */
43
- export function projectUsage(records) {
43
+ export function projectUsage(records, options = {}) {
44
44
  const totals = { ...empty(), byModel: {} };
45
45
  const add = (usage, provider = 'unknown', model = 'unknown') => {
46
46
  if (!usage)
@@ -53,23 +53,17 @@ export function projectUsage(records) {
53
53
  };
54
54
  let lastProvider = 'unknown';
55
55
  let lastModel = 'unknown';
56
- for (const { entry } of records) {
57
- if (isPiMessageEntry(entry)) {
58
- const message = entry.message;
59
- if (isAssistantMessage(message)) {
60
- lastProvider = message.provider ?? lastProvider;
61
- lastModel = message.model ?? lastModel;
62
- add(message.usage, message.provider ?? 'unknown', message.model ?? 'unknown');
63
- }
64
- else if (isToolResultMessage(message)) {
65
- // Nested model work inside a tool: attributed to the run's current model,
66
- // which is the model that requested the tool.
67
- add(message.usage, lastProvider, lastModel);
68
- }
56
+ for (const { event } of toEvents(records, options)) {
57
+ if (event.kind === 'model-response') {
58
+ lastProvider = event.provider ?? lastProvider;
59
+ lastModel = event.model ?? lastModel;
60
+ add(event.usage, event.provider ?? 'unknown', event.model ?? 'unknown');
69
61
  continue;
70
62
  }
71
- if (isPiCompactionEntry(entry) || isPiBranchSummaryEntry(entry)) {
72
- add(entry.usage, lastProvider, lastModel);
63
+ // Nested model work inside a tool or a summary: attributed to the run's
64
+ // current model, which is the model that asked for it.
65
+ if (event.kind === 'tool-result' || event.kind === 'summary') {
66
+ add(event.usage, lastProvider, lastModel);
73
67
  }
74
68
  }
75
69
  return totals;
package/esm/record.d.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  * which reader can parse the entry, and `transcriptVersion` says which version
9
9
  * that reader must migrate from.
10
10
  */
11
+ import { type TranscriptEntry } from './transcripts/entry';
11
12
  import { type TranscriptFormat } from './transcripts/format';
12
- import { type PiSessionEntry } from './transcripts/pi-entry';
13
+ import type { TranscriptReaderRegistry } from './transcripts/reader';
13
14
  /** Version of the wrapper itself — bumped only if these fields change. */
14
15
  export declare const RUN_LOG_WRAPPER_VERSION = 1;
15
16
  export interface RunEventRecord {
@@ -24,15 +25,17 @@ export interface RunEventRecord {
24
25
  /** That transcript's format version at write time. */
25
26
  transcriptVersion: number;
26
27
  /** The harness's session entry, byte-for-byte as it was produced. */
27
- entry: PiSessionEntry;
28
+ entry: TranscriptEntry;
28
29
  }
29
30
  export interface WrapEntryOptions {
30
31
  runId: string;
31
32
  seq: number;
32
- entry: PiSessionEntry;
33
+ entry: TranscriptEntry;
33
34
  recordedAt?: string;
34
35
  transcriptFormat?: TranscriptFormat;
35
36
  transcriptVersion?: number;
37
+ /** Readers used to validate the entry. Defaults to the process registry. */
38
+ readers?: TranscriptReaderRegistry;
36
39
  }
37
40
  export declare function wrapEntry(options: WrapEntryOptions): RunEventRecord;
38
41
  /**
@@ -40,12 +43,12 @@ export declare function wrapEntry(options: WrapEntryOptions): RunEventRecord;
40
43
  * unreadable — a log that cannot be parsed must fail loudly, never silently
41
44
  * render as an empty conversation.
42
45
  */
43
- export declare function assertRunEventRecord(value: unknown): RunEventRecord;
46
+ export declare function assertRunEventRecord(value: unknown, readers?: TranscriptReaderRegistry): RunEventRecord;
44
47
  /**
45
- * The de-duplication key for an append. pi entry ids are unique within a
48
+ * The de-duplication key for an append. Transcript entry ids are unique within a
46
49
  * session, so a retried append (a Job restart re-emitting its tail, a
47
50
  * reconnecting writer) is recognised rather than duplicated.
48
51
  */
49
- export declare function idempotencyKey(runId: string, entry: PiSessionEntry): string;
52
+ export declare function idempotencyKey(runId: string, entry: TranscriptEntry): string;
50
53
  /** Records must be contiguous and in order before anything projects them. */
51
54
  export declare function assertOrdered(records: readonly RunEventRecord[]): void;
package/esm/record.js CHANGED
@@ -8,8 +8,8 @@
8
8
  * which reader can parse the entry, and `transcriptVersion` says which version
9
9
  * that reader must migrate from.
10
10
  */
11
- import { PI_TRANSCRIPT_FORMAT, SUPPORTED_PI_TRANSCRIPT_VERSION } from './transcripts/format';
12
- import { assertPiSessionEntry } from './transcripts/pi-entry';
11
+ import { PI_TRANSCRIPT_FORMAT } from './transcripts/format';
12
+ import { transcriptReaders } from './transcripts/registry';
13
13
  /** Version of the wrapper itself — bumped only if these fields change. */
14
14
  export const RUN_LOG_WRAPPER_VERSION = 1;
15
15
  export function wrapEntry(options) {
@@ -18,13 +18,17 @@ export function wrapEntry(options) {
18
18
  if (!Number.isInteger(options.seq) || options.seq < 1) {
19
19
  throw new TypeError(`run log seq must be a positive integer, received ${String(options.seq)}`);
20
20
  }
21
+ const format = options.transcriptFormat ?? PI_TRANSCRIPT_FORMAT;
22
+ const reader = (options.readers ?? transcriptReaders).require(format);
21
23
  return {
22
24
  runId: options.runId,
23
25
  seq: options.seq,
24
26
  recordedAt: options.recordedAt ?? new Date().toISOString(),
25
- transcriptFormat: options.transcriptFormat ?? PI_TRANSCRIPT_FORMAT,
26
- transcriptVersion: options.transcriptVersion ?? SUPPORTED_PI_TRANSCRIPT_VERSION,
27
- entry: assertPiSessionEntry(options.entry)
27
+ transcriptFormat: format,
28
+ transcriptVersion: options.transcriptVersion ?? reader.version,
29
+ // The entry is validated by the format's own reader: what makes an entry
30
+ // well-formed is the transcript's rule, not the wrapper's.
31
+ entry: reader.assertEntry(options.entry)
28
32
  };
29
33
  }
30
34
  /**
@@ -32,7 +36,7 @@ export function wrapEntry(options) {
32
36
  * unreadable — a log that cannot be parsed must fail loudly, never silently
33
37
  * render as an empty conversation.
34
38
  */
35
- export function assertRunEventRecord(value) {
39
+ export function assertRunEventRecord(value, readers = transcriptReaders) {
36
40
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
37
41
  throw new TypeError(`run log record must be an object, received ${typeof value}`);
38
42
  }
@@ -58,20 +62,18 @@ export function assertRunEventRecord(value) {
58
62
  recordedAt: record.recordedAt,
59
63
  transcriptFormat: record.transcriptFormat,
60
64
  transcriptVersion: record.transcriptVersion,
61
- entry: assertPiSessionEntry(record.entry)
65
+ entry: readers.require(record.transcriptFormat).assertEntry(record.entry)
62
66
  };
63
67
  }
64
68
  /**
65
- * The de-duplication key for an append. pi entry ids are unique within a
69
+ * The de-duplication key for an append. Transcript entry ids are unique within a
66
70
  * session, so a retried append (a Job restart re-emitting its tail, a
67
71
  * reconnecting writer) is recognised rather than duplicated.
68
72
  */
69
73
  export function idempotencyKey(runId, entry) {
70
- const id = typeof entry.id === 'string' ? entry.id : null;
71
- if (id)
72
- return `${runId}:${entry.type}:${id}`;
73
- // Session headers carry no tree id; there is exactly one per session file.
74
- return `${runId}:${entry.type}:${String(entry.id ?? 'header')}`;
74
+ // An opening entry may carry no id; there is exactly one per session.
75
+ const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : 'header';
76
+ return `${runId}:${entry.type}:${id}`;
75
77
  }
76
78
  /** Records must be contiguous and in order before anything projects them. */
77
79
  export function assertOrdered(records) {
package/esm/store.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * memory here for tests and for a run that has not been persisted yet.
7
7
  */
8
8
  import { type RunEventRecord } from './record';
9
+ import type { TranscriptEntry } from './transcripts/entry';
9
10
  import type { TranscriptFormat } from './transcripts/format';
10
- import type { PiSessionEntry } from './transcripts/pi-entry';
11
11
  /** Read position: `afterSeq` is exclusive, so `0` means "from the start". */
12
12
  export interface RunLogCursor {
13
13
  afterSeq: number;
@@ -28,10 +28,10 @@ export interface AppendOptions {
28
28
  export interface RunLogAppendStore {
29
29
  /**
30
30
  * Append entries to a run, returning the records actually written. Entries
31
- * already present (matched by pi entry id) are skipped, which makes an append
32
- * safe to retry.
31
+ * already present (matched by transcript entry id) are skipped, which makes an
32
+ * append safe to retry.
33
33
  */
34
- append(runId: string, entries: readonly PiSessionEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
34
+ append(runId: string, entries: readonly TranscriptEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
35
35
  }
36
36
  export interface RunLogReadStore {
37
37
  read(runId: string, cursor?: RunLogCursor, limit?: number): Promise<RunLogPage>;
@@ -41,7 +41,7 @@ export type RunLogStore = RunLogAppendStore & RunLogReadStore;
41
41
  export declare class MemoryRunLogStore implements RunLogStore {
42
42
  private readonly runs;
43
43
  private readonly seen;
44
- append(runId: string, entries: readonly PiSessionEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
44
+ append(runId: string, entries: readonly TranscriptEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
45
45
  read(runId: string, cursor?: RunLogCursor, limit?: number): Promise<RunLogPage>;
46
46
  /** Test/debug helper: every record of a run, ignoring cursors. */
47
47
  snapshot(runId: string): RunEventRecord[];