@agentic-kit/run-log 0.2.0 → 0.3.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.
@@ -13,6 +13,14 @@ import type { RunEventRecord } from '../record';
13
13
  export declare const APPROVAL_REQUEST_TYPE = "constructive.approval.request";
14
14
  /** `customType` of the human's answer to a request. */
15
15
  export declare const APPROVAL_RESOLUTION_TYPE = "constructive.approval.resolution";
16
+ /**
17
+ * `customType` of a settled gate decision.
18
+ *
19
+ * Unlike an approval, this is written for *every* decision including the ones
20
+ * no human saw: a policy rule that denies a tool call is otherwise invisible in
21
+ * the log, leaving "which tool calls did the gate block" unanswerable.
22
+ */
23
+ export declare const GATE_DECISION_TYPE = "constructive.gate.decision";
16
24
  export type ToolCallStatus = 'requested' | 'awaiting-approval' | 'rejected' | 'running' | 'completed' | 'failed';
17
25
  export interface ToolCallState {
18
26
  toolCallId: string;
@@ -33,12 +41,37 @@ export interface ApprovalState {
33
41
  reason?: string;
34
42
  actorId?: string;
35
43
  }
44
+ /** A settled gate decision as it is carried in the log. */
45
+ export interface GateDecisionDetails {
46
+ toolCallId: string;
47
+ toolName: string;
48
+ /** What the policy said: `allow`, `deny` or `ask`. */
49
+ verdict: string;
50
+ /** How the call was finally settled — `ask` resolves to one of these. */
51
+ decision: 'allow' | 'deny';
52
+ reason?: string;
53
+ actorId?: string;
54
+ decidedAt?: string;
55
+ }
56
+ export interface GateDecisionState extends GateDecisionDetails {
57
+ seq: number;
58
+ }
36
59
  export interface ToolStateProjection {
37
60
  tools: Record<string, ToolCallState>;
61
+ /** Settled gate decisions in log order, keyed by tool call id. */
62
+ gateDecisions: Record<string, GateDecisionState>;
38
63
  /** In log order — the oldest unanswered request first. */
39
64
  pendingApprovals: ApprovalState[];
40
65
  }
41
66
  export declare function projectToolState(records: readonly RunEventRecord[]): ToolStateProjection;
67
+ /** Narrow the `details` of a `constructive.gate.decision` entry. */
68
+ export declare function assertGateDecisionDetails(value: unknown, seq?: number): GateDecisionDetails;
69
+ /** Narrow the `details` of a `constructive.approval.request` entry. */
70
+ export declare function assertApprovalRequestDetails(value: unknown, seq?: number): {
71
+ toolCallId: string;
72
+ };
73
+ /** Narrow the `details` of a `constructive.approval.resolution` entry. */
74
+ export declare function assertApprovalResolutionDetails(value: unknown, seq?: number): ApprovalResolutionInput;
42
75
  /** The parts of an approval request an extension needs to write one. */
43
76
  export interface ApprovalRequestInput {
44
77
  toolCallId: string;
@@ -61,6 +94,23 @@ export declare const approvalRequestMessage: (input: ApprovalRequestInput) => {
61
94
  };
62
95
  timestamp: number;
63
96
  };
97
+ /** Build the pi `custom` message a settled gate decision is carried in. */
98
+ export declare const gateDecisionMessage: (input: GateDecisionDetails) => {
99
+ role: "custom";
100
+ customType: string;
101
+ content: string;
102
+ display: boolean;
103
+ details: {
104
+ decidedAt?: string;
105
+ actorId?: string;
106
+ reason?: string;
107
+ toolCallId: string;
108
+ toolName: string;
109
+ verdict: string;
110
+ decision: "allow" | "deny";
111
+ };
112
+ timestamp: number;
113
+ };
64
114
  /** Build the pi `custom` message a human's answer is carried in. */
65
115
  export declare const approvalResolutionMessage: (input: ApprovalResolutionInput) => {
66
116
  role: "custom";
@@ -13,10 +13,21 @@ import { contentText, isAssistantMessage, isPiMessageEntry, isToolResultMessage,
13
13
  export const APPROVAL_REQUEST_TYPE = 'constructive.approval.request';
14
14
  /** `customType` of the human's answer to a request. */
15
15
  export const APPROVAL_RESOLUTION_TYPE = 'constructive.approval.resolution';
16
+ /**
17
+ * `customType` of a settled gate decision.
18
+ *
19
+ * Unlike an approval, this is written for *every* decision including the ones
20
+ * no human saw: a policy rule that denies a tool call is otherwise invisible in
21
+ * the log, leaving "which tool calls did the gate block" unanswerable.
22
+ */
23
+ export const GATE_DECISION_TYPE = 'constructive.gate.decision';
24
+ /** Where a bad entry is, when the caller is projecting a log rather than validating one value. */
25
+ const at = (seq) => (seq === undefined ? '' : ` at seq ${String(seq)}`);
16
26
  const details = (value) => typeof value === 'object' && value !== null ? value : {};
17
27
  export function projectToolState(records) {
18
28
  const tools = {};
19
29
  const approvals = new Map();
30
+ const gateDecisions = {};
20
31
  for (const { entry, seq } of records) {
21
32
  if (!isPiMessageEntry(entry))
22
33
  continue;
@@ -56,11 +67,7 @@ export function projectToolState(records) {
56
67
  if (message.role !== 'custom')
57
68
  continue;
58
69
  if (message.customType === APPROVAL_REQUEST_TYPE) {
59
- const info = details(message.details);
60
- const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : null;
61
- if (!toolCallId) {
62
- throw new Error(`approval request at seq ${String(seq)} carries no toolCallId; the run log cannot attribute it`);
63
- }
70
+ const { toolCallId } = assertApprovalRequestDetails(message.details, seq);
64
71
  const approval = {
65
72
  toolCallId,
66
73
  requestedSeq: seq,
@@ -73,19 +80,16 @@ export function projectToolState(records) {
73
80
  continue;
74
81
  }
75
82
  if (message.customType === APPROVAL_RESOLUTION_TYPE) {
76
- const info = details(message.details);
77
- const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : null;
78
- if (!toolCallId) {
79
- throw new Error(`approval resolution at seq ${String(seq)} carries no toolCallId; the run log cannot attribute it`);
80
- }
83
+ const info = assertApprovalResolutionDetails(message.details, seq);
84
+ const toolCallId = info.toolCallId;
81
85
  const approved = info.decision === 'approved';
82
86
  const existing = approvals.get(toolCallId);
83
87
  const approval = {
84
88
  ...(existing ?? { toolCallId, requestedSeq: seq, prompt: '' }),
85
89
  resolvedSeq: seq,
86
- decision: approved ? 'approved' : 'rejected',
87
- ...(typeof info.reason === 'string' ? { reason: info.reason } : {}),
88
- ...(typeof info.actorId === 'string' ? { actorId: info.actorId } : {})
90
+ decision: info.decision,
91
+ ...(info.reason === undefined ? {} : { reason: info.reason }),
92
+ ...(info.actorId === undefined ? {} : { actorId: info.actorId })
89
93
  };
90
94
  approvals.set(toolCallId, approval);
91
95
  const state = tools[toolCallId];
@@ -95,12 +99,72 @@ export function projectToolState(records) {
95
99
  else if (state) {
96
100
  tools[toolCallId] = { ...state, approval };
97
101
  }
102
+ continue;
103
+ }
104
+ if (message.customType === GATE_DECISION_TYPE) {
105
+ const decision = assertGateDecisionDetails(message.details, seq);
106
+ gateDecisions[decision.toolCallId] = { ...decision, seq };
107
+ const state = tools[decision.toolCallId];
108
+ // A blocked call never produces a tool result, so the gate's `deny` is the
109
+ // only thing that will ever settle it.
110
+ if (state && decision.decision === 'deny' && state.settledSeq === undefined) {
111
+ tools[decision.toolCallId] = {
112
+ ...state,
113
+ status: 'rejected',
114
+ settledSeq: seq,
115
+ ...(decision.reason === undefined ? {} : { output: decision.reason })
116
+ };
117
+ }
98
118
  }
99
119
  }
100
120
  const pendingApprovals = Array.from(approvals.values())
101
121
  .filter((approval) => approval.resolvedSeq === undefined)
102
122
  .sort((a, b) => a.requestedSeq - b.requestedSeq);
103
- return { tools, pendingApprovals };
123
+ return { tools, gateDecisions, pendingApprovals };
124
+ }
125
+ /** Narrow the `details` of a `constructive.gate.decision` entry. */
126
+ export function assertGateDecisionDetails(value, seq) {
127
+ const info = details(value);
128
+ const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : null;
129
+ if (!toolCallId) {
130
+ throw new TypeError(`gate decision${at(seq)} carries no toolCallId; the run log cannot attribute it`);
131
+ }
132
+ if (info.decision !== 'allow' && info.decision !== 'deny') {
133
+ throw new TypeError(`gate decision${at(seq)} must settle to "allow" or "deny"`);
134
+ }
135
+ return {
136
+ toolCallId,
137
+ toolName: typeof info.toolName === 'string' ? info.toolName : '',
138
+ verdict: typeof info.verdict === 'string' ? info.verdict : info.decision,
139
+ decision: info.decision,
140
+ ...(typeof info.reason === 'string' ? { reason: info.reason } : {}),
141
+ ...(typeof info.actorId === 'string' ? { actorId: info.actorId } : {}),
142
+ ...(typeof info.decidedAt === 'string' ? { decidedAt: info.decidedAt } : {})
143
+ };
144
+ }
145
+ /** Narrow the `details` of a `constructive.approval.request` entry. */
146
+ export function assertApprovalRequestDetails(value, seq) {
147
+ const info = details(value);
148
+ if (typeof info.toolCallId !== 'string' || info.toolCallId.length === 0) {
149
+ throw new TypeError(`approval request${at(seq)} carries no toolCallId; the run log cannot attribute it`);
150
+ }
151
+ return { toolCallId: info.toolCallId };
152
+ }
153
+ /** Narrow the `details` of a `constructive.approval.resolution` entry. */
154
+ export function assertApprovalResolutionDetails(value, seq) {
155
+ const info = details(value);
156
+ if (typeof info.toolCallId !== 'string' || info.toolCallId.length === 0) {
157
+ throw new TypeError(`approval resolution${at(seq)} carries no toolCallId; the run log cannot attribute it`);
158
+ }
159
+ if (info.decision !== 'approved' && info.decision !== 'rejected') {
160
+ throw new TypeError(`approval resolution${at(seq)} must decide "approved" or "rejected"`);
161
+ }
162
+ return {
163
+ toolCallId: info.toolCallId,
164
+ decision: info.decision,
165
+ ...(typeof info.reason === 'string' ? { reason: info.reason } : {}),
166
+ ...(typeof info.actorId === 'string' ? { actorId: info.actorId } : {})
167
+ };
104
168
  }
105
169
  /** Build the pi `custom` message an approval request is carried in. */
106
170
  export const approvalRequestMessage = (input) => ({
@@ -111,6 +175,25 @@ export const approvalRequestMessage = (input) => ({
111
175
  details: { toolCallId: input.toolCallId },
112
176
  timestamp: Date.now()
113
177
  });
178
+ /** Build the pi `custom` message a settled gate decision is carried in. */
179
+ export const gateDecisionMessage = (input) => ({
180
+ role: 'custom',
181
+ customType: GATE_DECISION_TYPE,
182
+ content: `gate ${input.decision}: ${input.toolName}${input.reason ? ` — ${input.reason}` : ''}`,
183
+ // The model already learned of a denial through the blocked call's error; a
184
+ // second telling would only spend context.
185
+ display: false,
186
+ details: {
187
+ toolCallId: input.toolCallId,
188
+ toolName: input.toolName,
189
+ verdict: input.verdict,
190
+ decision: input.decision,
191
+ ...(input.reason ? { reason: input.reason } : {}),
192
+ ...(input.actorId ? { actorId: input.actorId } : {}),
193
+ ...(input.decidedAt ? { decidedAt: input.decidedAt } : {})
194
+ },
195
+ timestamp: Date.now()
196
+ });
114
197
  /** Build the pi `custom` message a human's answer is carried in. */
115
198
  export const approvalResolutionMessage = (input) => ({
116
199
  role: 'custom',
package/index.d.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  * reach for a node builtin. The filesystem store lives behind
7
7
  * `@agentic-kit/run-log/file-store`.
8
8
  */
9
+ export { constructiveEntryTypes, type EntryTypeDefinition, EntryTypeRegistry, type EntryVisibility } from './entry-types';
9
10
  export { follow, type FollowOptions, readAll } from './follow';
10
11
  export { assertPiSessionEntry, contentText, isAssistantMessage, isPiBranchSummaryEntry, isPiCompactionEntry, isPiMessageEntry, isPiSessionHeader, isToolResultMessage, type PiAssistantMessage, type PiBashExecutionMessage, type PiBranchSummaryEntry, type PiCompactionEntry, type PiContent, type PiCustomMessage, type PiEntryBase, type PiImageContent, type PiMessage, type PiMessageEntry, type PiOtherEntry, type PiSessionEntry, type PiSessionHeader, type PiSummaryMessage, type PiTextContent, type PiThinkingContent, type PiToolCallContent, type PiToolResultMessage, type PiUsage, type PiUsageCost, type PiUserMessage, toolCalls } from './pi-entry';
11
12
  export { type BashPart, type Conversation, type ConversationPart, type CustomPart, projectParts, type SummaryPart, type TextPart, type ThinkingPart, type ToolPart, type ToolStatus, type UnknownPart } from './projectors/parts';
12
13
  export { parseSessionJsonl, projectSession, type SessionProjection, type SessionProjectionOptions } from './projectors/session';
13
- export { APPROVAL_REQUEST_TYPE, APPROVAL_RESOLUTION_TYPE, type ApprovalRequestInput, approvalRequestMessage, type ApprovalResolutionInput, approvalResolutionMessage, type ApprovalState, projectToolState, type ToolCallState, type ToolCallStatus, type ToolStateProjection } from './projectors/tool-state';
14
+ export { type ApprovalSpan, type ModelSpan, projectSpans, type Span, type SpanBase, type SpanKind, type SpanProjection, type SpanStatus, type SpanTiming, type ToolSpan } from './projectors/spans';
15
+ export { APPROVAL_REQUEST_TYPE, APPROVAL_RESOLUTION_TYPE, type ApprovalRequestInput, approvalRequestMessage, type ApprovalResolutionInput, approvalResolutionMessage, type ApprovalState, assertApprovalRequestDetails, assertApprovalResolutionDetails, assertGateDecisionDetails, GATE_DECISION_TYPE, type GateDecisionDetails, gateDecisionMessage, type GateDecisionState, projectToolState, type ToolCallState, type ToolCallStatus, type ToolStateProjection } from './projectors/tool-state';
14
16
  export { modelKey, type ModelUsage, projectUsage, type RunUsage, type UsageTotals } from './projectors/usage';
15
17
  export { assertOrdered, assertRunEventRecord, idempotencyKey, RUN_LOG_WRAPPER_VERSION, type RunEventRecord, SUPPORTED_PI_SESSION_VERSION, wrapEntry, type WrapEntryOptions } from './record';
16
18
  export { type AppendOptions, cursorAfter, MemoryRunLogStore, type RunLogAppendStore, type RunLogCursor, type RunLogPage, type RunLogReadStore, type RunLogStore, START } from './store';
package/index.js CHANGED
@@ -8,7 +8,10 @@
8
8
  * `@agentic-kit/run-log/file-store`.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.START = exports.MemoryRunLogStore = exports.cursorAfter = exports.wrapEntry = exports.SUPPORTED_PI_SESSION_VERSION = exports.RUN_LOG_WRAPPER_VERSION = exports.idempotencyKey = exports.assertRunEventRecord = exports.assertOrdered = exports.projectUsage = exports.modelKey = exports.projectToolState = exports.approvalResolutionMessage = exports.approvalRequestMessage = exports.APPROVAL_RESOLUTION_TYPE = exports.APPROVAL_REQUEST_TYPE = exports.projectSession = exports.parseSessionJsonl = exports.projectParts = exports.toolCalls = exports.isToolResultMessage = exports.isPiSessionHeader = exports.isPiMessageEntry = exports.isPiCompactionEntry = exports.isPiBranchSummaryEntry = exports.isAssistantMessage = exports.contentText = exports.assertPiSessionEntry = exports.readAll = exports.follow = void 0;
11
+ exports.START = exports.MemoryRunLogStore = exports.cursorAfter = exports.wrapEntry = exports.SUPPORTED_PI_SESSION_VERSION = exports.RUN_LOG_WRAPPER_VERSION = exports.idempotencyKey = exports.assertRunEventRecord = exports.assertOrdered = exports.projectUsage = exports.modelKey = exports.projectToolState = exports.gateDecisionMessage = exports.GATE_DECISION_TYPE = exports.assertGateDecisionDetails = exports.assertApprovalResolutionDetails = exports.assertApprovalRequestDetails = exports.approvalResolutionMessage = exports.approvalRequestMessage = exports.APPROVAL_RESOLUTION_TYPE = exports.APPROVAL_REQUEST_TYPE = exports.projectSpans = exports.projectSession = exports.parseSessionJsonl = exports.projectParts = exports.toolCalls = exports.isToolResultMessage = exports.isPiSessionHeader = exports.isPiMessageEntry = exports.isPiCompactionEntry = exports.isPiBranchSummaryEntry = exports.isAssistantMessage = exports.contentText = exports.assertPiSessionEntry = exports.readAll = exports.follow = exports.EntryTypeRegistry = exports.constructiveEntryTypes = void 0;
12
+ var entry_types_1 = require("./entry-types");
13
+ Object.defineProperty(exports, "constructiveEntryTypes", { enumerable: true, get: function () { return entry_types_1.constructiveEntryTypes; } });
14
+ Object.defineProperty(exports, "EntryTypeRegistry", { enumerable: true, get: function () { return entry_types_1.EntryTypeRegistry; } });
12
15
  var follow_1 = require("./follow");
13
16
  Object.defineProperty(exports, "follow", { enumerable: true, get: function () { return follow_1.follow; } });
14
17
  Object.defineProperty(exports, "readAll", { enumerable: true, get: function () { return follow_1.readAll; } });
@@ -27,11 +30,18 @@ Object.defineProperty(exports, "projectParts", { enumerable: true, get: function
27
30
  var session_1 = require("./projectors/session");
28
31
  Object.defineProperty(exports, "parseSessionJsonl", { enumerable: true, get: function () { return session_1.parseSessionJsonl; } });
29
32
  Object.defineProperty(exports, "projectSession", { enumerable: true, get: function () { return session_1.projectSession; } });
33
+ var spans_1 = require("./projectors/spans");
34
+ Object.defineProperty(exports, "projectSpans", { enumerable: true, get: function () { return spans_1.projectSpans; } });
30
35
  var tool_state_1 = require("./projectors/tool-state");
31
36
  Object.defineProperty(exports, "APPROVAL_REQUEST_TYPE", { enumerable: true, get: function () { return tool_state_1.APPROVAL_REQUEST_TYPE; } });
32
37
  Object.defineProperty(exports, "APPROVAL_RESOLUTION_TYPE", { enumerable: true, get: function () { return tool_state_1.APPROVAL_RESOLUTION_TYPE; } });
33
38
  Object.defineProperty(exports, "approvalRequestMessage", { enumerable: true, get: function () { return tool_state_1.approvalRequestMessage; } });
34
39
  Object.defineProperty(exports, "approvalResolutionMessage", { enumerable: true, get: function () { return tool_state_1.approvalResolutionMessage; } });
40
+ Object.defineProperty(exports, "assertApprovalRequestDetails", { enumerable: true, get: function () { return tool_state_1.assertApprovalRequestDetails; } });
41
+ Object.defineProperty(exports, "assertApprovalResolutionDetails", { enumerable: true, get: function () { return tool_state_1.assertApprovalResolutionDetails; } });
42
+ Object.defineProperty(exports, "assertGateDecisionDetails", { enumerable: true, get: function () { return tool_state_1.assertGateDecisionDetails; } });
43
+ Object.defineProperty(exports, "GATE_DECISION_TYPE", { enumerable: true, get: function () { return tool_state_1.GATE_DECISION_TYPE; } });
44
+ Object.defineProperty(exports, "gateDecisionMessage", { enumerable: true, get: function () { return tool_state_1.gateDecisionMessage; } });
35
45
  Object.defineProperty(exports, "projectToolState", { enumerable: true, get: function () { return tool_state_1.projectToolState; } });
36
46
  var usage_1 = require("./projectors/usage");
37
47
  Object.defineProperty(exports, "modelKey", { enumerable: true, get: function () { return usage_1.modelKey; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentic-kit/run-log",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "The append-only agent run log — pi session entries stored verbatim under a run/seq wrapper, with projections for transcript, usage, tool state and resumable sessions",
6
6
  "main": "index.js",
@@ -35,5 +35,5 @@
35
35
  "run-log",
36
36
  "constructive"
37
37
  ],
38
- "gitHead": "d4bf3a37545daad7246505a96ae8a4b87f44d53c"
38
+ "gitHead": "6880388c8cd5fb21745decff8bd23c3a430338b3"
39
39
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Span projection: run log records → timed intervals with parents.
3
+ *
4
+ * The log answers *what* happened and the parts projection answers *in what
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
7
+ * tool call and its result, or an approval request and its answer, bracket an
8
+ * interval; nothing new has to be stored to know it.
9
+ *
10
+ * Two honesty rules shape the output. A duration derived from a *pair of writes*
11
+ * is a bound, not a measurement — the gap between a tool call and its result
12
+ * includes the wait for a human — so every span says which it is via `timing`,
13
+ * and a tool span carries its approval wait separately so a renderer can show
14
+ * "blocked on you" apart from "running". And an interval that never closed stays
15
+ * `open` with no duration rather than being closed at the last record: a run
16
+ * still in flight must not look like one that finished.
17
+ */
18
+ import { type PiUsage } from '../pi-entry';
19
+ import type { RunEventRecord } from '../record';
20
+ export type SpanKind = 'model' | 'tool' | 'approval';
21
+ export type SpanStatus = 'open' | 'ok' | 'error' | 'denied';
22
+ /**
23
+ * Whether the duration is the interval that was measured, or a bound that
24
+ * includes whatever else sat between the two writes.
25
+ */
26
+ export type SpanTiming = 'measured' | 'bounded';
27
+ export interface SpanBase {
28
+ /** Stable within a run — a renderer's key and a parent reference. */
29
+ id: string;
30
+ kind: SpanKind;
31
+ /** Row title: the tool name, the model, or the awaited tool. */
32
+ name: string;
33
+ parentId?: string;
34
+ startSeq: number;
35
+ /** Absent while the span is still open. */
36
+ endSeq?: number;
37
+ startedAt: string;
38
+ endedAt?: string;
39
+ durationMs?: number;
40
+ timing: SpanTiming;
41
+ status: SpanStatus;
42
+ }
43
+ export interface ModelSpan extends SpanBase {
44
+ kind: 'model';
45
+ model?: string;
46
+ provider?: string;
47
+ /** pi's provider response id — the join key to a metered inference row. */
48
+ responseId?: string;
49
+ stopReason?: string;
50
+ usage?: PiUsage;
51
+ errorMessage?: string;
52
+ }
53
+ export interface ToolSpan extends SpanBase {
54
+ kind: 'tool';
55
+ toolCallId: string;
56
+ arguments: Record<string, unknown>;
57
+ output?: string;
58
+ details?: unknown;
59
+ /** Of the total duration, how much was spent waiting on a human. */
60
+ approvalWaitMs?: number;
61
+ /** Set when the gate settled the call instead of an execution. */
62
+ gateDecision?: 'allow' | 'deny';
63
+ gateReason?: string;
64
+ }
65
+ export interface ApprovalSpan extends SpanBase {
66
+ kind: 'approval';
67
+ toolCallId: string;
68
+ prompt: string;
69
+ decision?: 'approved' | 'rejected';
70
+ actorId?: string;
71
+ }
72
+ export type Span = ModelSpan | ToolSpan | ApprovalSpan;
73
+ export interface SpanProjection {
74
+ /** In start order — the order a timeline draws them. */
75
+ spans: Span[];
76
+ }
77
+ /**
78
+ * Project records into spans. Pure, and like the other projectors it ignores what
79
+ * it does not recognise — a log written by a newer pi still traces. A *registered*
80
+ * type that is malformed is another matter and throws: a gate decision naming no
81
+ * tool call is a writer bug, and a trace that quietly omitted a refusal would be
82
+ * worse than one that fails.
83
+ */
84
+ export declare function projectSpans(records: readonly RunEventRecord[]): SpanProjection;
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ /**
3
+ * Span projection: run log records → timed intervals with parents.
4
+ *
5
+ * The log answers *what* happened and the parts projection answers *in what
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
8
+ * tool call and its result, or an approval request and its answer, bracket an
9
+ * interval; nothing new has to be stored to know it.
10
+ *
11
+ * Two honesty rules shape the output. A duration derived from a *pair of writes*
12
+ * is a bound, not a measurement — the gap between a tool call and its result
13
+ * includes the wait for a human — so every span says which it is via `timing`,
14
+ * and a tool span carries its approval wait separately so a renderer can show
15
+ * "blocked on you" apart from "running". And an interval that never closed stays
16
+ * `open` with no duration rather than being closed at the last record: a run
17
+ * still in flight must not look like one that finished.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.projectSpans = projectSpans;
21
+ const pi_entry_1 = require("../pi-entry");
22
+ const tool_state_1 = require("./tool-state");
23
+ const ms = (from, to) => {
24
+ const start = Date.parse(from);
25
+ const end = Date.parse(to);
26
+ if (Number.isNaN(start) || Number.isNaN(end))
27
+ return undefined;
28
+ return end - start;
29
+ };
30
+ const close = (span, seq, at) => {
31
+ span.endSeq = seq;
32
+ span.endedAt = at;
33
+ const duration = ms(span.startedAt, at);
34
+ if (duration !== undefined)
35
+ span.durationMs = duration;
36
+ };
37
+ const detailsOf = (value) => typeof value === 'object' && value !== null ? value : {};
38
+ /**
39
+ * 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.
44
+ */
45
+ function projectSpans(records) {
46
+ const spans = [];
47
+ const tools = new Map();
48
+ const approvals = new Map();
49
+ /**
50
+ * 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
+ */
53
+ let previousAt;
54
+ for (const { entry, seq } of records) {
55
+ const at = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
56
+ if (at === undefined)
57
+ continue;
58
+ const previous = previousAt;
59
+ 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)) {
88
+ const tool = {
89
+ id: `tool:${call.id}`,
90
+ kind: 'tool',
91
+ name: call.name,
92
+ parentId: span.id,
93
+ toolCallId: call.id,
94
+ arguments: call.arguments ?? {},
95
+ startedAt: at,
96
+ startSeq: seq,
97
+ // A call and its result are two writes; anything between them —
98
+ // approval, queueing — is inside the interval.
99
+ timing: 'bounded',
100
+ status: 'open'
101
+ };
102
+ tools.set(call.id, tool);
103
+ spans.push(tool);
104
+ }
105
+ continue;
106
+ }
107
+ if ((0, pi_entry_1.isToolResultMessage)(message)) {
108
+ const tool = tools.get(message.toolCallId);
109
+ if (!tool)
110
+ 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)
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 (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)
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
+ }
176
+ }
177
+ }
178
+ return { spans };
179
+ }
@@ -13,6 +13,14 @@ import type { RunEventRecord } from '../record';
13
13
  export declare const APPROVAL_REQUEST_TYPE = "constructive.approval.request";
14
14
  /** `customType` of the human's answer to a request. */
15
15
  export declare const APPROVAL_RESOLUTION_TYPE = "constructive.approval.resolution";
16
+ /**
17
+ * `customType` of a settled gate decision.
18
+ *
19
+ * Unlike an approval, this is written for *every* decision including the ones
20
+ * no human saw: a policy rule that denies a tool call is otherwise invisible in
21
+ * the log, leaving "which tool calls did the gate block" unanswerable.
22
+ */
23
+ export declare const GATE_DECISION_TYPE = "constructive.gate.decision";
16
24
  export type ToolCallStatus = 'requested' | 'awaiting-approval' | 'rejected' | 'running' | 'completed' | 'failed';
17
25
  export interface ToolCallState {
18
26
  toolCallId: string;
@@ -33,12 +41,37 @@ export interface ApprovalState {
33
41
  reason?: string;
34
42
  actorId?: string;
35
43
  }
44
+ /** A settled gate decision as it is carried in the log. */
45
+ export interface GateDecisionDetails {
46
+ toolCallId: string;
47
+ toolName: string;
48
+ /** What the policy said: `allow`, `deny` or `ask`. */
49
+ verdict: string;
50
+ /** How the call was finally settled — `ask` resolves to one of these. */
51
+ decision: 'allow' | 'deny';
52
+ reason?: string;
53
+ actorId?: string;
54
+ decidedAt?: string;
55
+ }
56
+ export interface GateDecisionState extends GateDecisionDetails {
57
+ seq: number;
58
+ }
36
59
  export interface ToolStateProjection {
37
60
  tools: Record<string, ToolCallState>;
61
+ /** Settled gate decisions in log order, keyed by tool call id. */
62
+ gateDecisions: Record<string, GateDecisionState>;
38
63
  /** In log order — the oldest unanswered request first. */
39
64
  pendingApprovals: ApprovalState[];
40
65
  }
41
66
  export declare function projectToolState(records: readonly RunEventRecord[]): ToolStateProjection;
67
+ /** Narrow the `details` of a `constructive.gate.decision` entry. */
68
+ export declare function assertGateDecisionDetails(value: unknown, seq?: number): GateDecisionDetails;
69
+ /** Narrow the `details` of a `constructive.approval.request` entry. */
70
+ export declare function assertApprovalRequestDetails(value: unknown, seq?: number): {
71
+ toolCallId: string;
72
+ };
73
+ /** Narrow the `details` of a `constructive.approval.resolution` entry. */
74
+ export declare function assertApprovalResolutionDetails(value: unknown, seq?: number): ApprovalResolutionInput;
42
75
  /** The parts of an approval request an extension needs to write one. */
43
76
  export interface ApprovalRequestInput {
44
77
  toolCallId: string;
@@ -61,6 +94,23 @@ export declare const approvalRequestMessage: (input: ApprovalRequestInput) => {
61
94
  };
62
95
  timestamp: number;
63
96
  };
97
+ /** Build the pi `custom` message a settled gate decision is carried in. */
98
+ export declare const gateDecisionMessage: (input: GateDecisionDetails) => {
99
+ role: "custom";
100
+ customType: string;
101
+ content: string;
102
+ display: boolean;
103
+ details: {
104
+ decidedAt?: string;
105
+ actorId?: string;
106
+ reason?: string;
107
+ toolCallId: string;
108
+ toolName: string;
109
+ verdict: string;
110
+ decision: "allow" | "deny";
111
+ };
112
+ timestamp: number;
113
+ };
64
114
  /** Build the pi `custom` message a human's answer is carried in. */
65
115
  export declare const approvalResolutionMessage: (input: ApprovalResolutionInput) => {
66
116
  role: "custom";