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