@agentic-kit/run-log 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +169 -0
- package/esm/file-store.d.ts +33 -0
- package/esm/file-store.js +85 -0
- package/esm/follow.d.ts +36 -0
- package/esm/follow.js +65 -0
- package/esm/index.d.ts +16 -0
- package/esm/index.js +16 -0
- package/esm/pi-entry.d.ts +157 -0
- package/esm/pi-entry.js +61 -0
- package/esm/projectors/parts.d.ts +75 -0
- package/esm/projectors/parts.js +135 -0
- package/esm/projectors/session.d.ts +34 -0
- package/esm/projectors/session.js +64 -0
- package/esm/projectors/tool-state.d.ts +77 -0
- package/esm/projectors/tool-state.js +127 -0
- package/esm/projectors/usage.d.ts +35 -0
- package/esm/projectors/usage.js +76 -0
- package/esm/record.d.ts +48 -0
- package/esm/record.js +83 -0
- package/esm/store.d.ts +46 -0
- package/esm/store.js +51 -0
- package/file-store.d.ts +33 -0
- package/file-store.js +90 -0
- package/follow.d.ts +36 -0
- package/follow.js +69 -0
- package/index.d.ts +16 -0
- package/index.js +49 -0
- package/package.json +39 -0
- package/pi-entry.d.ts +157 -0
- package/pi-entry.js +73 -0
- package/projectors/parts.d.ts +75 -0
- package/projectors/parts.js +138 -0
- package/projectors/session.d.ts +34 -0
- package/projectors/session.js +68 -0
- package/projectors/tool-state.d.ts +77 -0
- package/projectors/tool-state.js +133 -0
- package/projectors/usage.d.ts +35 -0
- package/projectors/usage.js +81 -0
- package/record.d.ts +48 -0
- package/record.js +90 -0
- package/store.d.ts +46 -0
- package/store.js +56 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderable projection: run log records → an ordered list of parts a UI draws.
|
|
3
|
+
*
|
|
4
|
+
* This is the projection that replaces per-host transcript encodings. A tool
|
|
5
|
+
* call and its later result collapse into one part, so a renderer never has to
|
|
6
|
+
* correlate two messages itself, and an unknown entry type becomes an
|
|
7
|
+
* `unknown` part rather than disappearing — a log written by a newer pi still
|
|
8
|
+
* renders, minus the detail this version understands.
|
|
9
|
+
*/
|
|
10
|
+
import { type PiSessionEntry } from '../pi-entry';
|
|
11
|
+
import type { RunEventRecord } from '../record';
|
|
12
|
+
export type ToolStatus = 'requested' | 'completed' | 'failed';
|
|
13
|
+
export interface PartBase {
|
|
14
|
+
/** Sequence of the record that introduced the part — a stable React key. */
|
|
15
|
+
seq: number;
|
|
16
|
+
entryId?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface TextPart extends PartBase {
|
|
19
|
+
kind: 'text';
|
|
20
|
+
role: 'user' | 'assistant';
|
|
21
|
+
text: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
provider?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ThinkingPart extends PartBase {
|
|
26
|
+
kind: 'thinking';
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ToolPart extends PartBase {
|
|
30
|
+
kind: 'tool';
|
|
31
|
+
toolCallId: string;
|
|
32
|
+
name: string;
|
|
33
|
+
arguments: Record<string, unknown>;
|
|
34
|
+
status: ToolStatus;
|
|
35
|
+
/** Text of the tool result, once one has been logged. */
|
|
36
|
+
output?: string;
|
|
37
|
+
details?: unknown;
|
|
38
|
+
/** Sequence of the record that settled the call. */
|
|
39
|
+
settledSeq?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface BashPart extends PartBase {
|
|
42
|
+
kind: 'bash';
|
|
43
|
+
command: string;
|
|
44
|
+
output: string;
|
|
45
|
+
exitCode?: number;
|
|
46
|
+
}
|
|
47
|
+
export interface CustomPart extends PartBase {
|
|
48
|
+
kind: 'custom';
|
|
49
|
+
customType: string;
|
|
50
|
+
text: string;
|
|
51
|
+
display: boolean;
|
|
52
|
+
details?: unknown;
|
|
53
|
+
}
|
|
54
|
+
export interface SummaryPart extends PartBase {
|
|
55
|
+
kind: 'summary';
|
|
56
|
+
reason: 'compaction' | 'branch';
|
|
57
|
+
summary: string;
|
|
58
|
+
}
|
|
59
|
+
export interface UnknownPart extends PartBase {
|
|
60
|
+
kind: 'unknown';
|
|
61
|
+
entryType: string;
|
|
62
|
+
entry: PiSessionEntry;
|
|
63
|
+
}
|
|
64
|
+
export type ConversationPart = TextPart | ThinkingPart | ToolPart | BashPart | CustomPart | SummaryPart | UnknownPart;
|
|
65
|
+
export interface Conversation {
|
|
66
|
+
parts: ConversationPart[];
|
|
67
|
+
/** From the session header, when the log carries one. */
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
cwd?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Project records into a conversation. Pure and total: the same records always
|
|
73
|
+
* produce the same parts, whichever host wrote them.
|
|
74
|
+
*/
|
|
75
|
+
export declare function projectParts(records: readonly RunEventRecord[]): Conversation;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderable projection: run log records → an ordered list of parts a UI draws.
|
|
3
|
+
*
|
|
4
|
+
* This is the projection that replaces per-host transcript encodings. A tool
|
|
5
|
+
* call and its later result collapse into one part, so a renderer never has to
|
|
6
|
+
* correlate two messages itself, and an unknown entry type becomes an
|
|
7
|
+
* `unknown` part rather than disappearing — a log written by a newer pi still
|
|
8
|
+
* renders, minus the detail this version understands.
|
|
9
|
+
*/
|
|
10
|
+
import { contentText, isAssistantMessage, isPiBranchSummaryEntry, isPiCompactionEntry, isPiMessageEntry, isPiSessionHeader, isToolResultMessage, toolCalls } from '../pi-entry';
|
|
11
|
+
/**
|
|
12
|
+
* Project records into a conversation. Pure and total: the same records always
|
|
13
|
+
* produce the same parts, whichever host wrote them.
|
|
14
|
+
*/
|
|
15
|
+
export function projectParts(records) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
const toolsByCallId = new Map();
|
|
18
|
+
let sessionId;
|
|
19
|
+
let cwd;
|
|
20
|
+
for (const record of records) {
|
|
21
|
+
const { entry, seq } = record;
|
|
22
|
+
if (isPiSessionHeader(entry)) {
|
|
23
|
+
sessionId = entry.id;
|
|
24
|
+
if (typeof entry.cwd === 'string')
|
|
25
|
+
cwd = entry.cwd;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (isPiCompactionEntry(entry)) {
|
|
29
|
+
parts.push({ kind: 'summary', reason: 'compaction', summary: entry.summary, seq, entryId: entry.id });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (isPiBranchSummaryEntry(entry)) {
|
|
33
|
+
parts.push({ kind: 'summary', reason: 'branch', summary: entry.summary, seq, entryId: entry.id });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!isPiMessageEntry(entry)) {
|
|
37
|
+
parts.push({ kind: 'unknown', entryType: entry.type, entry, seq, entryId: entry.id });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const message = entry.message;
|
|
41
|
+
const base = { seq, entryId: entry.id };
|
|
42
|
+
if (message.role === 'user') {
|
|
43
|
+
parts.push({ kind: 'text', role: 'user', text: contentText(message.content), ...base });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (isAssistantMessage(message)) {
|
|
47
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
48
|
+
if (block.type === 'text' && block.text.length > 0) {
|
|
49
|
+
parts.push({
|
|
50
|
+
kind: 'text',
|
|
51
|
+
role: 'assistant',
|
|
52
|
+
text: block.text,
|
|
53
|
+
...(message.model ? { model: message.model } : {}),
|
|
54
|
+
...(message.provider ? { provider: message.provider } : {}),
|
|
55
|
+
...base
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else if (block.type === 'thinking') {
|
|
59
|
+
parts.push({ kind: 'thinking', text: block.thinking, ...base });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const call of toolCalls(message)) {
|
|
63
|
+
const part = {
|
|
64
|
+
kind: 'tool',
|
|
65
|
+
toolCallId: call.id,
|
|
66
|
+
name: call.name,
|
|
67
|
+
arguments: call.arguments ?? {},
|
|
68
|
+
status: 'requested',
|
|
69
|
+
...base
|
|
70
|
+
};
|
|
71
|
+
toolsByCallId.set(call.id, part);
|
|
72
|
+
parts.push(part);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (isToolResultMessage(message)) {
|
|
77
|
+
const existing = toolsByCallId.get(message.toolCallId);
|
|
78
|
+
const output = contentText(message.content);
|
|
79
|
+
const status = message.isError ? 'failed' : 'completed';
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.status = status;
|
|
82
|
+
existing.output = output;
|
|
83
|
+
existing.settledSeq = seq;
|
|
84
|
+
if (message.details !== undefined)
|
|
85
|
+
existing.details = message.details;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// A result whose call is not in this window (paged read, forked branch).
|
|
89
|
+
parts.push({
|
|
90
|
+
kind: 'tool',
|
|
91
|
+
toolCallId: message.toolCallId,
|
|
92
|
+
name: message.toolName,
|
|
93
|
+
arguments: {},
|
|
94
|
+
status,
|
|
95
|
+
output,
|
|
96
|
+
settledSeq: seq,
|
|
97
|
+
...base
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (message.role === 'bashExecution') {
|
|
103
|
+
parts.push({
|
|
104
|
+
kind: 'bash',
|
|
105
|
+
command: message.command,
|
|
106
|
+
output: message.output,
|
|
107
|
+
...(typeof message.exitCode === 'number' ? { exitCode: message.exitCode } : {}),
|
|
108
|
+
...base
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (message.role === 'custom') {
|
|
113
|
+
parts.push({
|
|
114
|
+
kind: 'custom',
|
|
115
|
+
customType: message.customType,
|
|
116
|
+
text: contentText(message.content),
|
|
117
|
+
display: message.display !== false,
|
|
118
|
+
...(message.details !== undefined ? { details: message.details } : {}),
|
|
119
|
+
...base
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
parts.push({
|
|
124
|
+
kind: 'summary',
|
|
125
|
+
reason: message.role === 'branchSummary' ? 'branch' : 'compaction',
|
|
126
|
+
summary: message.summary ?? '',
|
|
127
|
+
...base
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
parts,
|
|
132
|
+
...(sessionId ? { sessionId } : {}),
|
|
133
|
+
...(cwd ? { cwd } : {})
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session projection: run log records → a pi session file.
|
|
3
|
+
*
|
|
4
|
+
* This is what makes a run resumable anywhere. The log is the source of truth;
|
|
5
|
+
* a `.jsonl` session is a derived artifact, so a run that started in the cloud
|
|
6
|
+
* can be continued locally (and vice versa) by projecting the log back into the
|
|
7
|
+
* only format pi's `SessionManager` reads.
|
|
8
|
+
*
|
|
9
|
+
* Returns a string rather than writing a file: the projection is pure, and the
|
|
10
|
+
* node-side write lives in `@agentic-kit/run-log/file-store`.
|
|
11
|
+
*/
|
|
12
|
+
import { type PiSessionEntry } from '../pi-entry';
|
|
13
|
+
import { type RunEventRecord } from '../record';
|
|
14
|
+
export interface SessionProjectionOptions {
|
|
15
|
+
/** Used when the log carries no session header (a run logged headerless). */
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
timestamp?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SessionProjection {
|
|
21
|
+
/** The full session file contents, newline-terminated. */
|
|
22
|
+
jsonl: string;
|
|
23
|
+
/** Entries in file order, header first. */
|
|
24
|
+
entries: PiSessionEntry[];
|
|
25
|
+
piSessionVersion: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Project records into a pi session file. Throws when the records cannot form a
|
|
29
|
+
* loadable session — an unresumable session must fail at projection time, not
|
|
30
|
+
* when pi later reads a truncated tree.
|
|
31
|
+
*/
|
|
32
|
+
export declare function projectSession(records: readonly RunEventRecord[], options?: SessionProjectionOptions): SessionProjection;
|
|
33
|
+
/** Parse a pi session file into entries — the inverse, for importing a session. */
|
|
34
|
+
export declare function parseSessionJsonl(jsonl: string): PiSessionEntry[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session projection: run log records → a pi session file.
|
|
3
|
+
*
|
|
4
|
+
* This is what makes a run resumable anywhere. The log is the source of truth;
|
|
5
|
+
* a `.jsonl` session is a derived artifact, so a run that started in the cloud
|
|
6
|
+
* can be continued locally (and vice versa) by projecting the log back into the
|
|
7
|
+
* only format pi's `SessionManager` reads.
|
|
8
|
+
*
|
|
9
|
+
* Returns a string rather than writing a file: the projection is pure, and the
|
|
10
|
+
* node-side write lives in `@agentic-kit/run-log/file-store`.
|
|
11
|
+
*/
|
|
12
|
+
import { isPiSessionHeader } from '../pi-entry';
|
|
13
|
+
import { assertOrdered, SUPPORTED_PI_SESSION_VERSION } from '../record';
|
|
14
|
+
/**
|
|
15
|
+
* Project records into a pi session file. Throws when the records cannot form a
|
|
16
|
+
* loadable session — an unresumable session must fail at projection time, not
|
|
17
|
+
* when pi later reads a truncated tree.
|
|
18
|
+
*/
|
|
19
|
+
export function projectSession(records, options = {}) {
|
|
20
|
+
assertOrdered(records);
|
|
21
|
+
const versions = new Set(records.map((record) => record.piSessionVersion));
|
|
22
|
+
if (versions.size > 1) {
|
|
23
|
+
throw new Error(`run log mixes pi session versions (${Array.from(versions).sort().join(', ')}); migrate the older entries before projecting a session`);
|
|
24
|
+
}
|
|
25
|
+
const piSessionVersion = records[0]?.piSessionVersion ?? SUPPORTED_PI_SESSION_VERSION;
|
|
26
|
+
const entries = records.map((record) => record.entry);
|
|
27
|
+
const headerIndex = entries.findIndex(isPiSessionHeader);
|
|
28
|
+
if (headerIndex > 0) {
|
|
29
|
+
throw new Error(`run log carries a session header at position ${String(headerIndex)}; a pi session file requires it first`);
|
|
30
|
+
}
|
|
31
|
+
const body = headerIndex === 0 ? entries.slice(1) : entries;
|
|
32
|
+
const header = headerIndex === 0
|
|
33
|
+
? entries[0]
|
|
34
|
+
: {
|
|
35
|
+
type: 'session',
|
|
36
|
+
version: piSessionVersion,
|
|
37
|
+
id: options.sessionId ?? records[0]?.runId ?? 'run-log',
|
|
38
|
+
timestamp: options.timestamp ?? records[0]?.recordedAt ?? new Date().toISOString(),
|
|
39
|
+
...(options.cwd ? { cwd: options.cwd } : {})
|
|
40
|
+
};
|
|
41
|
+
const ordered = [header, ...body];
|
|
42
|
+
return {
|
|
43
|
+
entries: ordered,
|
|
44
|
+
piSessionVersion,
|
|
45
|
+
jsonl: ordered.map((entry) => JSON.stringify(entry)).join('\n') + '\n'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Parse a pi session file into entries — the inverse, for importing a session. */
|
|
49
|
+
export function parseSessionJsonl(jsonl) {
|
|
50
|
+
const entries = [];
|
|
51
|
+
const lines = jsonl.split('\n');
|
|
52
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
53
|
+
const line = lines[i].trim();
|
|
54
|
+
if (line.length === 0)
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
entries.push(JSON.parse(line));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new Error(`pi session line ${String(i + 1)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool and approval projection: run log records → the state a UI needs to know
|
|
3
|
+
* what is running and what is waiting on a human.
|
|
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
|
|
7
|
+
* that reconnects hours later must be able to see a pending request without
|
|
8
|
+
* having been present when it was raised, and both placements then behave the
|
|
9
|
+
* same — the cloud already treats the conversation as the approval UI.
|
|
10
|
+
*/
|
|
11
|
+
import type { RunEventRecord } from '../record';
|
|
12
|
+
/** `customType` of an approval request written by the gate extension. */
|
|
13
|
+
export declare const APPROVAL_REQUEST_TYPE = "constructive.approval.request";
|
|
14
|
+
/** `customType` of the human's answer to a request. */
|
|
15
|
+
export declare const APPROVAL_RESOLUTION_TYPE = "constructive.approval.resolution";
|
|
16
|
+
export type ToolCallStatus = 'requested' | 'awaiting-approval' | 'rejected' | 'running' | 'completed' | 'failed';
|
|
17
|
+
export interface ToolCallState {
|
|
18
|
+
toolCallId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
arguments: Record<string, unknown>;
|
|
21
|
+
status: ToolCallStatus;
|
|
22
|
+
requestedSeq: number;
|
|
23
|
+
settledSeq?: number;
|
|
24
|
+
output?: string;
|
|
25
|
+
approval?: ApprovalState;
|
|
26
|
+
}
|
|
27
|
+
export interface ApprovalState {
|
|
28
|
+
toolCallId: string;
|
|
29
|
+
requestedSeq: number;
|
|
30
|
+
prompt: string;
|
|
31
|
+
resolvedSeq?: number;
|
|
32
|
+
decision?: 'approved' | 'rejected';
|
|
33
|
+
reason?: string;
|
|
34
|
+
actorId?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ToolStateProjection {
|
|
37
|
+
tools: Record<string, ToolCallState>;
|
|
38
|
+
/** In log order — the oldest unanswered request first. */
|
|
39
|
+
pendingApprovals: ApprovalState[];
|
|
40
|
+
}
|
|
41
|
+
export declare function projectToolState(records: readonly RunEventRecord[]): ToolStateProjection;
|
|
42
|
+
/** The parts of an approval request an extension needs to write one. */
|
|
43
|
+
export interface ApprovalRequestInput {
|
|
44
|
+
toolCallId: string;
|
|
45
|
+
prompt: string;
|
|
46
|
+
}
|
|
47
|
+
export interface ApprovalResolutionInput {
|
|
48
|
+
toolCallId: string;
|
|
49
|
+
decision: 'approved' | 'rejected';
|
|
50
|
+
reason?: string;
|
|
51
|
+
actorId?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Build the pi `custom` message an approval request is carried in. */
|
|
54
|
+
export declare const approvalRequestMessage: (input: ApprovalRequestInput) => {
|
|
55
|
+
role: "custom";
|
|
56
|
+
customType: string;
|
|
57
|
+
content: string;
|
|
58
|
+
display: boolean;
|
|
59
|
+
details: {
|
|
60
|
+
toolCallId: string;
|
|
61
|
+
};
|
|
62
|
+
timestamp: number;
|
|
63
|
+
};
|
|
64
|
+
/** Build the pi `custom` message a human's answer is carried in. */
|
|
65
|
+
export declare const approvalResolutionMessage: (input: ApprovalResolutionInput) => {
|
|
66
|
+
role: "custom";
|
|
67
|
+
customType: string;
|
|
68
|
+
content: string;
|
|
69
|
+
display: boolean;
|
|
70
|
+
details: {
|
|
71
|
+
actorId?: string;
|
|
72
|
+
reason?: string;
|
|
73
|
+
toolCallId: string;
|
|
74
|
+
decision: "rejected" | "approved";
|
|
75
|
+
};
|
|
76
|
+
timestamp: number;
|
|
77
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool and approval projection: run log records → the state a UI needs to know
|
|
3
|
+
* what is running and what is waiting on a human.
|
|
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
|
|
7
|
+
* that reconnects hours later must be able to see a pending request without
|
|
8
|
+
* having been present when it was raised, and both placements then behave the
|
|
9
|
+
* same — the cloud already treats the conversation as the approval UI.
|
|
10
|
+
*/
|
|
11
|
+
import { contentText, isAssistantMessage, isPiMessageEntry, isToolResultMessage, toolCalls } from '../pi-entry';
|
|
12
|
+
/** `customType` of an approval request written by the gate extension. */
|
|
13
|
+
export const APPROVAL_REQUEST_TYPE = 'constructive.approval.request';
|
|
14
|
+
/** `customType` of the human's answer to a request. */
|
|
15
|
+
export const APPROVAL_RESOLUTION_TYPE = 'constructive.approval.resolution';
|
|
16
|
+
const details = (value) => typeof value === 'object' && value !== null ? value : {};
|
|
17
|
+
export function projectToolState(records) {
|
|
18
|
+
const tools = {};
|
|
19
|
+
const approvals = new Map();
|
|
20
|
+
for (const { entry, seq } of records) {
|
|
21
|
+
if (!isPiMessageEntry(entry))
|
|
22
|
+
continue;
|
|
23
|
+
const message = entry.message;
|
|
24
|
+
if (isAssistantMessage(message)) {
|
|
25
|
+
for (const call of toolCalls(message)) {
|
|
26
|
+
tools[call.id] = {
|
|
27
|
+
toolCallId: call.id,
|
|
28
|
+
name: call.name,
|
|
29
|
+
arguments: call.arguments ?? {},
|
|
30
|
+
status: 'requested',
|
|
31
|
+
requestedSeq: seq
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (isToolResultMessage(message)) {
|
|
37
|
+
const state = tools[message.toolCallId];
|
|
38
|
+
const settled = {
|
|
39
|
+
status: message.isError ? 'failed' : 'completed',
|
|
40
|
+
settledSeq: seq,
|
|
41
|
+
output: contentText(message.content)
|
|
42
|
+
};
|
|
43
|
+
tools[message.toolCallId] = state
|
|
44
|
+
? { ...state, ...settled }
|
|
45
|
+
: {
|
|
46
|
+
toolCallId: message.toolCallId,
|
|
47
|
+
name: message.toolName,
|
|
48
|
+
arguments: {},
|
|
49
|
+
requestedSeq: seq,
|
|
50
|
+
status: settled.status,
|
|
51
|
+
settledSeq: seq,
|
|
52
|
+
output: settled.output
|
|
53
|
+
};
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (message.role !== 'custom')
|
|
57
|
+
continue;
|
|
58
|
+
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
|
+
}
|
|
64
|
+
const approval = {
|
|
65
|
+
toolCallId,
|
|
66
|
+
requestedSeq: seq,
|
|
67
|
+
prompt: contentText(message.content)
|
|
68
|
+
};
|
|
69
|
+
approvals.set(toolCallId, approval);
|
|
70
|
+
const state = tools[toolCallId];
|
|
71
|
+
if (state)
|
|
72
|
+
tools[toolCallId] = { ...state, status: 'awaiting-approval', approval };
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
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
|
+
}
|
|
81
|
+
const approved = info.decision === 'approved';
|
|
82
|
+
const existing = approvals.get(toolCallId);
|
|
83
|
+
const approval = {
|
|
84
|
+
...(existing ?? { toolCallId, requestedSeq: seq, prompt: '' }),
|
|
85
|
+
resolvedSeq: seq,
|
|
86
|
+
decision: approved ? 'approved' : 'rejected',
|
|
87
|
+
...(typeof info.reason === 'string' ? { reason: info.reason } : {}),
|
|
88
|
+
...(typeof info.actorId === 'string' ? { actorId: info.actorId } : {})
|
|
89
|
+
};
|
|
90
|
+
approvals.set(toolCallId, approval);
|
|
91
|
+
const state = tools[toolCallId];
|
|
92
|
+
if (state && state.status === 'awaiting-approval') {
|
|
93
|
+
tools[toolCallId] = { ...state, status: approved ? 'running' : 'rejected', approval };
|
|
94
|
+
}
|
|
95
|
+
else if (state) {
|
|
96
|
+
tools[toolCallId] = { ...state, approval };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const pendingApprovals = Array.from(approvals.values())
|
|
101
|
+
.filter((approval) => approval.resolvedSeq === undefined)
|
|
102
|
+
.sort((a, b) => a.requestedSeq - b.requestedSeq);
|
|
103
|
+
return { tools, pendingApprovals };
|
|
104
|
+
}
|
|
105
|
+
/** Build the pi `custom` message an approval request is carried in. */
|
|
106
|
+
export const approvalRequestMessage = (input) => ({
|
|
107
|
+
role: 'custom',
|
|
108
|
+
customType: APPROVAL_REQUEST_TYPE,
|
|
109
|
+
content: input.prompt,
|
|
110
|
+
display: true,
|
|
111
|
+
details: { toolCallId: input.toolCallId },
|
|
112
|
+
timestamp: Date.now()
|
|
113
|
+
});
|
|
114
|
+
/** Build the pi `custom` message a human's answer is carried in. */
|
|
115
|
+
export const approvalResolutionMessage = (input) => ({
|
|
116
|
+
role: 'custom',
|
|
117
|
+
customType: APPROVAL_RESOLUTION_TYPE,
|
|
118
|
+
content: input.reason ?? input.decision,
|
|
119
|
+
display: true,
|
|
120
|
+
details: {
|
|
121
|
+
toolCallId: input.toolCallId,
|
|
122
|
+
decision: input.decision,
|
|
123
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
124
|
+
...(input.actorId ? { actorId: input.actorId } : {})
|
|
125
|
+
},
|
|
126
|
+
timestamp: Date.now()
|
|
127
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage projection: run log records → token and cost totals.
|
|
3
|
+
*
|
|
4
|
+
* Every usage-bearing pi entry counts, not just assistant messages: a tool that
|
|
5
|
+
* performed nested LLM work reports `usage` on its result, and compaction and
|
|
6
|
+
* branch summaries are model calls the run paid for. Missing any of those makes
|
|
7
|
+
* a run look cheaper than it was, which is exactly the kind of drift metering
|
|
8
|
+
* exists to prevent.
|
|
9
|
+
*
|
|
10
|
+
* This projection describes what a run *observed*. It is reconciliation input,
|
|
11
|
+
* never the billing authority — a gateway-observed record is (see
|
|
12
|
+
* `@agentic-kit/pi-ext-metered-model`).
|
|
13
|
+
*/
|
|
14
|
+
import type { RunEventRecord } from '../record';
|
|
15
|
+
export interface UsageTotals {
|
|
16
|
+
input: number;
|
|
17
|
+
output: number;
|
|
18
|
+
cacheRead: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
/** Number of usage-bearing entries folded into these totals. */
|
|
23
|
+
calls: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ModelUsage extends UsageTotals {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RunUsage extends UsageTotals {
|
|
30
|
+
/** Per provider+model breakdown, keyed `provider/model`. */
|
|
31
|
+
byModel: Record<string, ModelUsage>;
|
|
32
|
+
}
|
|
33
|
+
export declare const modelKey: (provider: string, model: string) => string;
|
|
34
|
+
/** Fold every usage-bearing entry in the records into run totals. */
|
|
35
|
+
export declare function projectUsage(records: readonly RunEventRecord[]): RunUsage;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage projection: run log records → token and cost totals.
|
|
3
|
+
*
|
|
4
|
+
* Every usage-bearing pi entry counts, not just assistant messages: a tool that
|
|
5
|
+
* performed nested LLM work reports `usage` on its result, and compaction and
|
|
6
|
+
* branch summaries are model calls the run paid for. Missing any of those makes
|
|
7
|
+
* a run look cheaper than it was, which is exactly the kind of drift metering
|
|
8
|
+
* exists to prevent.
|
|
9
|
+
*
|
|
10
|
+
* This projection describes what a run *observed*. It is reconciliation input,
|
|
11
|
+
* never the billing authority — a gateway-observed record is (see
|
|
12
|
+
* `@agentic-kit/pi-ext-metered-model`).
|
|
13
|
+
*/
|
|
14
|
+
import { isAssistantMessage, isPiBranchSummaryEntry, isPiCompactionEntry, isPiMessageEntry, isToolResultMessage } from '../pi-entry';
|
|
15
|
+
const empty = () => ({
|
|
16
|
+
input: 0,
|
|
17
|
+
output: 0,
|
|
18
|
+
cacheRead: 0,
|
|
19
|
+
cacheWrite: 0,
|
|
20
|
+
totalTokens: 0,
|
|
21
|
+
cost: 0,
|
|
22
|
+
calls: 0
|
|
23
|
+
});
|
|
24
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
25
|
+
function fold(target, usage) {
|
|
26
|
+
const input = num(usage.input);
|
|
27
|
+
const output = num(usage.output);
|
|
28
|
+
const cacheRead = num(usage.cacheRead);
|
|
29
|
+
const cacheWrite = num(usage.cacheWrite);
|
|
30
|
+
target.input += input;
|
|
31
|
+
target.output += output;
|
|
32
|
+
target.cacheRead += cacheRead;
|
|
33
|
+
target.cacheWrite += cacheWrite;
|
|
34
|
+
// Providers that omit a total still have one: the parts they did report.
|
|
35
|
+
target.totalTokens += usage.totalTokens === undefined
|
|
36
|
+
? input + output + cacheRead + cacheWrite
|
|
37
|
+
: num(usage.totalTokens);
|
|
38
|
+
target.cost += num(usage.cost?.total);
|
|
39
|
+
target.calls += 1;
|
|
40
|
+
}
|
|
41
|
+
export const modelKey = (provider, model) => `${provider}/${model}`;
|
|
42
|
+
/** Fold every usage-bearing entry in the records into run totals. */
|
|
43
|
+
export function projectUsage(records) {
|
|
44
|
+
const totals = { ...empty(), byModel: {} };
|
|
45
|
+
const add = (usage, provider = 'unknown', model = 'unknown') => {
|
|
46
|
+
if (!usage)
|
|
47
|
+
return;
|
|
48
|
+
fold(totals, usage);
|
|
49
|
+
const key = modelKey(provider, model);
|
|
50
|
+
const bucket = totals.byModel[key] ?? { ...empty(), provider, model };
|
|
51
|
+
fold(bucket, usage);
|
|
52
|
+
totals.byModel[key] = bucket;
|
|
53
|
+
};
|
|
54
|
+
let lastProvider = 'unknown';
|
|
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
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (isPiCompactionEntry(entry) || isPiBranchSummaryEntry(entry)) {
|
|
72
|
+
add(entry.usage, lastProvider, lastModel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return totals;
|
|
76
|
+
}
|
package/esm/record.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run log record: four platform-owned fields around a verbatim pi entry.
|
|
3
|
+
*
|
|
4
|
+
* The wrapper exists to give an entry identity (which run) and order (which
|
|
5
|
+
* position) across hosts — nothing else. It deliberately does not re-encode pi's
|
|
6
|
+
* semantics, because pi already versions and migrates its own session format;
|
|
7
|
+
* `piSessionVersion` records which version an entry was written under so a
|
|
8
|
+
* reader can hand old entries to pi's migrations instead of guessing.
|
|
9
|
+
*/
|
|
10
|
+
import { type PiSessionEntry } from './pi-entry';
|
|
11
|
+
/** Version of the wrapper itself — bumped only if these four fields change. */
|
|
12
|
+
export declare const RUN_LOG_WRAPPER_VERSION = 1;
|
|
13
|
+
/** The pi session format version this package projects without migration. */
|
|
14
|
+
export declare const SUPPORTED_PI_SESSION_VERSION = 3;
|
|
15
|
+
export interface RunEventRecord {
|
|
16
|
+
/** The run this entry belongs to. */
|
|
17
|
+
runId: string;
|
|
18
|
+
/** 1-based position within the run. Gapless and strictly increasing. */
|
|
19
|
+
seq: number;
|
|
20
|
+
/** When the platform durably recorded the entry (ISO 8601). */
|
|
21
|
+
recordedAt: string;
|
|
22
|
+
/** pi's session format version at write time. */
|
|
23
|
+
piSessionVersion: number;
|
|
24
|
+
/** The pi session entry, byte-for-byte as pi produced it. */
|
|
25
|
+
entry: PiSessionEntry;
|
|
26
|
+
}
|
|
27
|
+
export interface WrapEntryOptions {
|
|
28
|
+
runId: string;
|
|
29
|
+
seq: number;
|
|
30
|
+
entry: PiSessionEntry;
|
|
31
|
+
recordedAt?: string;
|
|
32
|
+
piSessionVersion?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function wrapEntry(options: WrapEntryOptions): RunEventRecord;
|
|
35
|
+
/**
|
|
36
|
+
* Narrow an untrusted record (database row, HTTP body). Throws on anything
|
|
37
|
+
* unreadable — a log that cannot be parsed must fail loudly, never silently
|
|
38
|
+
* render as an empty conversation.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertRunEventRecord(value: unknown): RunEventRecord;
|
|
41
|
+
/**
|
|
42
|
+
* The de-duplication key for an append. pi entry ids are unique within a
|
|
43
|
+
* session, so a retried append (a Job restart re-emitting its tail, a
|
|
44
|
+
* reconnecting writer) is recognised rather than duplicated.
|
|
45
|
+
*/
|
|
46
|
+
export declare function idempotencyKey(runId: string, entry: PiSessionEntry): string;
|
|
47
|
+
/** Records must be contiguous and in order before anything projects them. */
|
|
48
|
+
export declare function assertOrdered(records: readonly RunEventRecord[]): void;
|