omnirush 0.7.0 → 0.8.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/assets/collect-once.ts +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -13
- package/assets/extensions/omnirush/agents.ts +19 -24
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +539 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +655 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +30 -5
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* which is exactly how /grant and /verify broke the first time.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readSessionLedger, SESSION_LEDGER_FILE } from "./collector
|
|
20
|
+
import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-collector";
|
|
21
21
|
import { deviceMe, omniDir, resolveOrigin } from "./auth";
|
|
22
22
|
import { sharedRefresher } from "./refresh";
|
|
23
23
|
import { formatCollectorLine, formatStatusLines } from "./status-lib";
|
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// The CLI's session, in the desktop app's engine message format.
|
|
2
|
+
//
|
|
3
|
+
// The desktop collector records the conversation as the engine (opencode)
|
|
4
|
+
// keeps it: one message per user prompt and per model step,
|
|
5
|
+
//
|
|
6
|
+
// {info: {id, sessionID, role, time: {created, completed}, modelID,
|
|
7
|
+
// providerID, agent, mode, variant, path, cost, tokens, finish,
|
|
8
|
+
// error?},
|
|
9
|
+
// parts: [step-start, reasoning, text, tool, patch, file, compaction,
|
|
10
|
+
// step-finish, ...]}
|
|
11
|
+
//
|
|
12
|
+
// and the omnirush.ai backend rebuilds the viewer's turns from exactly that
|
|
13
|
+
// (public_trace.py). The pi agent keeps its session as a tree of entries
|
|
14
|
+
// (user, assistant and toolResult messages, compactions, summaries, model
|
|
15
|
+
// and thinking-level changes). This module maps the entries on the active
|
|
16
|
+
// branch onto engine messages, one to one and without inventing content:
|
|
17
|
+
//
|
|
18
|
+
// user message -> user message: text parts, image file parts
|
|
19
|
+
// (bytes not embedded, as the desktop's
|
|
20
|
+
// engine.request records attachments)
|
|
21
|
+
// assistant message -> assistant message: step-start, reasoning
|
|
22
|
+
// (the model's thinking), text and tool parts
|
|
23
|
+
// in the model's order, a patch part for the
|
|
24
|
+
// files its edit/write tools changed, and
|
|
25
|
+
// step-finish with the step's token usage and
|
|
26
|
+
// cost; model, provider, variant (thinking
|
|
27
|
+
// effort) and finish reason on its info
|
|
28
|
+
// toolResult message -> the output (or error) of its call's tool
|
|
29
|
+
// part, with the call's start and end time
|
|
30
|
+
// bashExecution (`!cmd`) -> user message with the engine's shell-command
|
|
31
|
+
// marker text, answered by an assistant message
|
|
32
|
+
// with the bash tool part (as the engine keeps
|
|
33
|
+
// a command the user ran)
|
|
34
|
+
// custom / branch summary -> user message with synthetic text parts (what
|
|
35
|
+
// the model was shown; never a turn opener)
|
|
36
|
+
// compaction -> user message with a compaction part and the
|
|
37
|
+
// summary as an assistant `summary` message
|
|
38
|
+
// system prompt -> not a message (the desktop records it in
|
|
39
|
+
// the prompt's engine.request event)
|
|
40
|
+
//
|
|
41
|
+
// Ids are stable across processes (derived from the pi entry ids), so a
|
|
42
|
+
// resumed session or a repeated flush merges instead of duplicating.
|
|
43
|
+
//
|
|
44
|
+
// Also here, pure and shared with the tests: the helpers the desktop's turn
|
|
45
|
+
// observer (apps/server/src/collector-observer.ts) applies to the engine's
|
|
46
|
+
// messages when a turn settles: the turn's model, the message budget of a
|
|
47
|
+
// flush and its split into `turn.messages` parts.
|
|
48
|
+
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
import { isAbsolute, resolve } from "node:path";
|
|
51
|
+
|
|
52
|
+
export type EngineMessage = { info: Record<string, unknown>; parts: Array<Record<string, unknown>> };
|
|
53
|
+
|
|
54
|
+
/** One pi session entry (loosely typed: the files are parsed without validation). */
|
|
55
|
+
export type PiEntry = {
|
|
56
|
+
type?: string;
|
|
57
|
+
id?: string;
|
|
58
|
+
parentId?: string | null;
|
|
59
|
+
timestamp?: string;
|
|
60
|
+
message?: Record<string, any>;
|
|
61
|
+
[key: string]: any;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type EngineConvertOptions = {
|
|
65
|
+
/** The session the messages belong to (info.sessionID). */
|
|
66
|
+
sessionId: string;
|
|
67
|
+
/** info.agent / info.mode of every message: "omnirush-cli" for a root session, the role for a sub-agent. */
|
|
68
|
+
agent: string;
|
|
69
|
+
/** The session's working directory (info.path; relative tool paths resolve against it). */
|
|
70
|
+
cwd: string;
|
|
71
|
+
/** Tool call start times seen live (toolCallId -> epoch ms), for the calls of this process. */
|
|
72
|
+
toolStarts?: ReadonlyMap<string, number>;
|
|
73
|
+
/** The session's model before its first model_change entry, if known. */
|
|
74
|
+
initialModel?: { providerID: string; modelID: string } | null;
|
|
75
|
+
/** The session's thinking level before its first thinking_level_change entry, if known. */
|
|
76
|
+
initialThinkingLevel?: string | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The shell-command marker the engine writes on a user message holding a command the user ran (public_trace.py `_USER_SHELL_TEXT`). */
|
|
80
|
+
export const USER_SHELL_TEXT = "The following tool was executed by the user";
|
|
81
|
+
/** Tools whose successful calls change files (the patch part of their step). */
|
|
82
|
+
const FILE_TOOLS = new Set(["edit", "write"]);
|
|
83
|
+
/** A tool result's details are kept as the tool part's metadata up to this size. */
|
|
84
|
+
const MAX_TOOL_METADATA_BYTES = 64 * 1024;
|
|
85
|
+
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function epochMs(value: unknown): number | null {
|
|
91
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
92
|
+
if (typeof value === "string") {
|
|
93
|
+
const parsed = Date.parse(value);
|
|
94
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The engine id of the message made from pi entry `entryId` (ascending ids are not needed: the backend orders by time). */
|
|
100
|
+
export function engineMessageId(entryId: string, suffix = ""): string {
|
|
101
|
+
return `msg_${entryId}${suffix}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function partId(messageId: string, index: number): string {
|
|
105
|
+
return `prt_${messageId.slice(4)}_${index.toString().padStart(3, "0")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The text of a content value (a string, or text blocks), non-text blocks aside. */
|
|
109
|
+
function contentText(content: unknown): string {
|
|
110
|
+
if (typeof content === "string") return content;
|
|
111
|
+
if (!Array.isArray(content)) return "";
|
|
112
|
+
return content
|
|
113
|
+
.filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
|
|
114
|
+
.map((block) => block.text as string)
|
|
115
|
+
.join("\n\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const IMAGE_EXTENSIONS: Record<string, string> = {
|
|
119
|
+
"image/png": "png",
|
|
120
|
+
"image/jpeg": "jpg",
|
|
121
|
+
"image/gif": "gif",
|
|
122
|
+
"image/webp": "webp",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A pi image block as an engine file part. The bytes are not embedded: the
|
|
127
|
+
* desktop records attachments by identity in their own `attachment` trace
|
|
128
|
+
* events, and replaces inline data with this marker in its request log.
|
|
129
|
+
*/
|
|
130
|
+
function imagePart(block: Record<string, any>, index: number): Record<string, unknown> {
|
|
131
|
+
const mime = typeof block.mimeType === "string" && block.mimeType ? block.mimeType : "application/octet-stream";
|
|
132
|
+
const data = typeof block.data === "string" ? block.data : "";
|
|
133
|
+
return {
|
|
134
|
+
type: "file",
|
|
135
|
+
mime,
|
|
136
|
+
filename: `image-${index + 1}.${IMAGE_EXTENSIONS[mime] ?? "bin"}`,
|
|
137
|
+
url: `data:${mime};omitted`,
|
|
138
|
+
omitted_url_chars: data.length,
|
|
139
|
+
...(data ? { sha256: createHash("sha256").update(Buffer.from(data, "base64")).digest("hex"), bytes: Buffer.from(data, "base64").length } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Content blocks (user or tool result) as engine parts: text parts and file parts. */
|
|
144
|
+
function contentParts(content: unknown, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> {
|
|
145
|
+
if (typeof content === "string") return content ? [{ type: "text", text: content, ...extra }] : [];
|
|
146
|
+
if (!Array.isArray(content)) return [];
|
|
147
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
148
|
+
let images = 0;
|
|
149
|
+
for (const block of content) {
|
|
150
|
+
if (typeof block === "string") {
|
|
151
|
+
if (block) parts.push({ type: "text", text: block, ...extra });
|
|
152
|
+
} else if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
|
|
153
|
+
if (block.text) parts.push({ type: "text", text: block.text, ...extra });
|
|
154
|
+
} else if (isRecord(block) && block.type === "image") {
|
|
155
|
+
parts.push(imagePart(block, images));
|
|
156
|
+
images += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return parts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** pi stop reasons as the engine's finish reasons (the AI SDK's). */
|
|
163
|
+
function finishReason(stopReason: unknown): string | undefined {
|
|
164
|
+
switch (stopReason) {
|
|
165
|
+
case "stop":
|
|
166
|
+
return "stop";
|
|
167
|
+
case "length":
|
|
168
|
+
return "length";
|
|
169
|
+
case "toolUse":
|
|
170
|
+
return "tool-calls";
|
|
171
|
+
case "error":
|
|
172
|
+
return "error";
|
|
173
|
+
default:
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The engine's error record of a failed or aborted step (the backend reads only its name). */
|
|
179
|
+
function assistantError(message: Record<string, any>): Record<string, unknown> | undefined {
|
|
180
|
+
if (message.stopReason === "aborted") {
|
|
181
|
+
return { name: "MessageAbortedError", data: { message: typeof message.errorMessage === "string" ? message.errorMessage : "aborted" } };
|
|
182
|
+
}
|
|
183
|
+
if (message.stopReason === "error") {
|
|
184
|
+
return { name: "APIError", data: { message: typeof message.errorMessage === "string" ? message.errorMessage : "error" } };
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** pi usage as the engine's step tokens: input excludes cache reads and writes in both. */
|
|
190
|
+
function stepTokens(usage: unknown): Record<string, unknown> {
|
|
191
|
+
const value = isRecord(usage) ? usage : {};
|
|
192
|
+
const number = (key: string) => (typeof value[key] === "number" && Number.isFinite(value[key]) ? (value[key] as number) : 0);
|
|
193
|
+
return {
|
|
194
|
+
total: number("totalTokens"),
|
|
195
|
+
input: number("input"),
|
|
196
|
+
output: number("output"),
|
|
197
|
+
reasoning: number("reasoning"),
|
|
198
|
+
cache: { read: number("cacheRead"), write: number("cacheWrite") },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stepCost(usage: unknown): number {
|
|
203
|
+
const cost = isRecord(usage) && isRecord(usage.cost) ? usage.cost.total : 0;
|
|
204
|
+
return typeof cost === "number" && Number.isFinite(cost) ? cost : 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A tool result's details as tool-part metadata, when small enough to keep. */
|
|
208
|
+
function toolMetadata(details: unknown): Record<string, unknown> | undefined {
|
|
209
|
+
if (details === undefined || details === null) return undefined;
|
|
210
|
+
let text: string;
|
|
211
|
+
try {
|
|
212
|
+
text = JSON.stringify(details);
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
if (text === undefined || Buffer.byteLength(text) > MAX_TOOL_METADATA_BYTES) return undefined;
|
|
217
|
+
return isRecord(details) ? { ...details } : { details };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The sub-agent session ids a spawn_agents result names (details.results[].session_id). */
|
|
221
|
+
export function spawnedSessionIds(details: unknown): string[] {
|
|
222
|
+
if (!isRecord(details) || !Array.isArray(details.results)) return [];
|
|
223
|
+
const ids: string[] = [];
|
|
224
|
+
for (const result of details.results) {
|
|
225
|
+
const id = isRecord(result) ? result.session_id : undefined;
|
|
226
|
+
if (typeof id === "string" && id && !ids.includes(id)) ids.push(id);
|
|
227
|
+
}
|
|
228
|
+
return ids;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type ToolResult = { message: Record<string, any>; at: number | null };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The engine messages of a pi session branch (entries root to leaf, as
|
|
235
|
+
* SessionManager.getBranch() returns them or a session file lists them).
|
|
236
|
+
*/
|
|
237
|
+
export function engineMessagesFromEntries(entries: readonly PiEntry[], options: EngineConvertOptions): EngineMessage[] {
|
|
238
|
+
const { sessionId, agent, cwd } = options;
|
|
239
|
+
// Tool results by call id: they fill in the calls of the step before them.
|
|
240
|
+
const results = new Map<string, ToolResult>();
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
const message = entry.type === "message" && isRecord(entry.message) ? entry.message : null;
|
|
243
|
+
if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
|
|
244
|
+
results.set(message.toolCallId, { message, at: epochMs(message.timestamp) ?? epochMs(entry.timestamp) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// The model a prompt went to: the model that answered it (its turn's first
|
|
248
|
+
// assistant message: pi records no model change for a `--model` flag on
|
|
249
|
+
// a resumed session), else the latest model change before it.
|
|
250
|
+
const nextAssistantModel = new Map<number, { providerID: string; modelID: string }>();
|
|
251
|
+
let upcoming: { providerID: string; modelID: string } | null = null;
|
|
252
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
253
|
+
const message = entries[index]?.type === "message" && isRecord(entries[index]!.message) ? entries[index]!.message! : null;
|
|
254
|
+
if (message?.role === "assistant" && typeof message.provider === "string" && typeof message.model === "string") {
|
|
255
|
+
upcoming = { providerID: message.provider, modelID: message.model };
|
|
256
|
+
}
|
|
257
|
+
if (upcoming) nextAssistantModel.set(index, upcoming);
|
|
258
|
+
// An earlier prompt was answered by its own turn, never by this one's.
|
|
259
|
+
if (message?.role === "user") upcoming = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const out: EngineMessage[] = [];
|
|
263
|
+
let model: { providerID: string; modelID: string } | null = options.initialModel ?? null;
|
|
264
|
+
let thinkingLevel: string | null = options.initialThinkingLevel ?? null;
|
|
265
|
+
let lastUserId: string | null = null;
|
|
266
|
+
const variant = () => (thinkingLevel && thinkingLevel !== "off" ? thinkingLevel : null);
|
|
267
|
+
|
|
268
|
+
const userMessage = (id: string, created: number | null, parts: Array<Record<string, unknown>>, index: number, extraInfo: Record<string, unknown> = {}): void => {
|
|
269
|
+
const messageModel = nextAssistantModel.get(index) ?? model ?? null;
|
|
270
|
+
const info: Record<string, unknown> = {
|
|
271
|
+
id,
|
|
272
|
+
sessionID: sessionId,
|
|
273
|
+
role: "user",
|
|
274
|
+
time: { created: created ?? 0 },
|
|
275
|
+
agent,
|
|
276
|
+
...(messageModel ? { model: { providerID: messageModel.providerID, modelID: messageModel.modelID } } : {}),
|
|
277
|
+
...(variant() ? { variant: variant() } : {}),
|
|
278
|
+
...extraInfo,
|
|
279
|
+
};
|
|
280
|
+
out.push({
|
|
281
|
+
info,
|
|
282
|
+
parts: parts.map((part, partIndex) => ({ id: partId(id, partIndex), sessionID: sessionId, messageID: id, ...part })),
|
|
283
|
+
});
|
|
284
|
+
lastUserId = id;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
entries.forEach((entry, index) => {
|
|
288
|
+
const entryId = typeof entry.id === "string" && entry.id ? entry.id : null;
|
|
289
|
+
if (!entryId) return;
|
|
290
|
+
const at = epochMs(entry.timestamp);
|
|
291
|
+
if (entry.type === "model_change") {
|
|
292
|
+
if (typeof entry.provider === "string" && typeof entry.modelId === "string") model = { providerID: entry.provider, modelID: entry.modelId };
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (entry.type === "thinking_level_change") {
|
|
296
|
+
if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (entry.type === "compaction") {
|
|
300
|
+
const id = engineMessageId(entryId);
|
|
301
|
+
userMessage(id, at, [{ type: "compaction" }], index);
|
|
302
|
+
const summaryId = engineMessageId(entryId, "_summary");
|
|
303
|
+
const summaryParts: Array<Record<string, unknown>> = [];
|
|
304
|
+
if (typeof entry.summary === "string" && entry.summary) summaryParts.push({ type: "text", text: entry.summary });
|
|
305
|
+
if (isRecord(entry.usage)) summaryParts.push({ type: "step-finish", reason: "stop", tokens: stepTokens(entry.usage), cost: stepCost(entry.usage) });
|
|
306
|
+
out.push({
|
|
307
|
+
info: {
|
|
308
|
+
id: summaryId,
|
|
309
|
+
sessionID: sessionId,
|
|
310
|
+
role: "assistant",
|
|
311
|
+
parentID: id,
|
|
312
|
+
time: { created: at ?? 0, completed: at ?? 0 },
|
|
313
|
+
summary: true,
|
|
314
|
+
agent: "compaction",
|
|
315
|
+
mode: "compaction",
|
|
316
|
+
path: { cwd, root: cwd },
|
|
317
|
+
...(isRecord(entry.usage) ? { tokens: stepTokens(entry.usage), cost: stepCost(entry.usage) } : {}),
|
|
318
|
+
finish: "stop",
|
|
319
|
+
},
|
|
320
|
+
parts: summaryParts.map((part, partIndex) => ({ id: partId(summaryId, partIndex), sessionID: sessionId, messageID: summaryId, ...part })),
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (entry.type === "branch_summary") {
|
|
325
|
+
if (typeof entry.summary === "string" && entry.summary) {
|
|
326
|
+
userMessage(engineMessageId(entryId), at, [{ type: "text", text: entry.summary, synthetic: true, metadata: { branchSummary: true } }], index);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (entry.type === "custom_message") {
|
|
331
|
+
const parts = contentParts(entry.content, { synthetic: true, metadata: { customType: entry.customType ?? null } });
|
|
332
|
+
if (parts.length > 0) userMessage(engineMessageId(entryId), at, parts, index);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (entry.type !== "message" || !isRecord(entry.message)) return;
|
|
336
|
+
const message = entry.message;
|
|
337
|
+
const id = engineMessageId(entryId);
|
|
338
|
+
const created = epochMs(message.timestamp) ?? at;
|
|
339
|
+
switch (message.role) {
|
|
340
|
+
case "user": {
|
|
341
|
+
const parts = contentParts(message.content);
|
|
342
|
+
if (parts.length > 0) userMessage(id, created, parts, index);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case "custom": {
|
|
346
|
+
const parts = contentParts(message.content, { synthetic: true, metadata: { customType: message.customType ?? null } });
|
|
347
|
+
if (parts.length > 0) userMessage(id, created, parts, index);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
case "branchSummary":
|
|
351
|
+
case "compactionSummary": {
|
|
352
|
+
if (typeof message.summary === "string" && message.summary) {
|
|
353
|
+
userMessage(id, created, [{ type: "text", text: message.summary, synthetic: true, metadata: { [message.role]: true } }], index);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
case "bashExecution": {
|
|
358
|
+
// As the engine records a command the user ran: a user message with
|
|
359
|
+
// its shell-command marker, answered by an assistant message holding
|
|
360
|
+
// the bash tool call and its output (no model step, no tokens).
|
|
361
|
+
const command = typeof message.command === "string" ? message.command : "";
|
|
362
|
+
const output = typeof message.output === "string" ? message.output : "";
|
|
363
|
+
userMessage(id, created, [{ type: "text", text: USER_SHELL_TEXT, synthetic: true }], index);
|
|
364
|
+
const shellId = engineMessageId(entryId, "_shell");
|
|
365
|
+
const shellModel = model ?? nextAssistantModel.get(index) ?? null;
|
|
366
|
+
const time = created ?? 0;
|
|
367
|
+
const parts: Array<Record<string, unknown>> = [
|
|
368
|
+
{
|
|
369
|
+
type: "tool",
|
|
370
|
+
callID: `user_bash_${entryId}`,
|
|
371
|
+
tool: "bash",
|
|
372
|
+
state: {
|
|
373
|
+
status: "completed",
|
|
374
|
+
input: { command },
|
|
375
|
+
output,
|
|
376
|
+
metadata: {
|
|
377
|
+
...(typeof message.exitCode === "number" ? { exit: message.exitCode } : {}),
|
|
378
|
+
...(message.truncated === true ? { truncated: true } : {}),
|
|
379
|
+
...(message.cancelled === true ? { cancelled: true } : {}),
|
|
380
|
+
...(message.excludeFromContext === true ? { excludeFromContext: true } : {}),
|
|
381
|
+
},
|
|
382
|
+
time: { start: time, end: time },
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
out.push({
|
|
387
|
+
info: {
|
|
388
|
+
id: shellId,
|
|
389
|
+
sessionID: sessionId,
|
|
390
|
+
role: "assistant",
|
|
391
|
+
parentID: id,
|
|
392
|
+
time: { created: time, completed: time },
|
|
393
|
+
...(shellModel ? { modelID: shellModel.modelID, providerID: shellModel.providerID } : {}),
|
|
394
|
+
agent,
|
|
395
|
+
mode: agent,
|
|
396
|
+
path: { cwd, root: cwd },
|
|
397
|
+
cost: 0,
|
|
398
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
399
|
+
finish: "stop",
|
|
400
|
+
},
|
|
401
|
+
parts: parts.map((part, partIndex) => ({ id: partId(shellId, partIndex), sessionID: sessionId, messageID: shellId, ...part })),
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
case "assistant": {
|
|
406
|
+
const completed = at ?? created;
|
|
407
|
+
const parts: Array<Record<string, unknown>> = [{ type: "step-start" }];
|
|
408
|
+
const changed: string[] = [];
|
|
409
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
410
|
+
for (const block of content) {
|
|
411
|
+
if (!isRecord(block)) continue;
|
|
412
|
+
if (block.type === "thinking") {
|
|
413
|
+
if (typeof block.thinking === "string" && block.thinking.trim()) {
|
|
414
|
+
parts.push({ type: "reasoning", text: block.thinking, ...(block.redacted === true ? { metadata: { redacted: true } } : {}) });
|
|
415
|
+
}
|
|
416
|
+
} else if (block.type === "text") {
|
|
417
|
+
if (typeof block.text === "string" && block.text) parts.push({ type: "text", text: block.text });
|
|
418
|
+
} else if (block.type === "toolCall") {
|
|
419
|
+
const callId = typeof block.id === "string" ? block.id : "";
|
|
420
|
+
const input = isRecord(block.arguments) ? block.arguments : {};
|
|
421
|
+
const result = callId ? results.get(callId) : undefined;
|
|
422
|
+
const start = (callId ? options.toolStarts?.get(callId) : undefined) ?? completed ?? 0;
|
|
423
|
+
let state: Record<string, unknown>;
|
|
424
|
+
if (!result) {
|
|
425
|
+
state = { status: "running", input, time: { start } };
|
|
426
|
+
} else {
|
|
427
|
+
const text = contentText(result.message.content);
|
|
428
|
+
const attachments = contentParts(result.message.content).filter((part) => part.type === "file");
|
|
429
|
+
const metadata = toolMetadata(result.message.details);
|
|
430
|
+
const spawned = block.name === "spawn_agents" ? spawnedSessionIds(result.message.details) : [];
|
|
431
|
+
const meta = {
|
|
432
|
+
...(metadata ?? {}),
|
|
433
|
+
// The viewer links a tool part to its sub-agent through metadata.sessionId.
|
|
434
|
+
...(spawned.length === 1 ? { sessionId: spawned[0] } : {}),
|
|
435
|
+
...(spawned.length > 0 ? { sessionIds: spawned } : {}),
|
|
436
|
+
};
|
|
437
|
+
const time = { start, end: result.at ?? start };
|
|
438
|
+
state = result.message.isError === true
|
|
439
|
+
? { status: "error", input, error: text, ...(Object.keys(meta).length > 0 ? { metadata: meta } : {}), time }
|
|
440
|
+
: {
|
|
441
|
+
status: "completed",
|
|
442
|
+
input,
|
|
443
|
+
output: text,
|
|
444
|
+
...(Object.keys(meta).length > 0 ? { metadata: meta } : {}),
|
|
445
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
446
|
+
time,
|
|
447
|
+
};
|
|
448
|
+
const target = typeof input.path === "string" ? input.path : null;
|
|
449
|
+
if (result.message.isError !== true && FILE_TOOLS.has(String(block.name)) && target) {
|
|
450
|
+
const absolute = isAbsolute(target) ? target : resolve(cwd, target);
|
|
451
|
+
if (!changed.includes(absolute)) changed.push(absolute);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
parts.push({ type: "tool", callID: callId, tool: typeof block.name === "string" ? block.name : "tool", state });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (changed.length > 0) parts.push({ type: "patch", files: changed });
|
|
458
|
+
const finish = finishReason(message.stopReason);
|
|
459
|
+
const tokens = stepTokens(message.usage);
|
|
460
|
+
const cost = stepCost(message.usage);
|
|
461
|
+
parts.push({ type: "step-finish", ...(finish ? { reason: finish } : {}), tokens, cost });
|
|
462
|
+
if (typeof message.provider === "string" && typeof message.model === "string") {
|
|
463
|
+
model = model ?? { providerID: message.provider, modelID: message.model };
|
|
464
|
+
}
|
|
465
|
+
const error = assistantError(message);
|
|
466
|
+
const effort = typeof message.providerThinkingLevel === "string" && message.providerThinkingLevel ? message.providerThinkingLevel : variant();
|
|
467
|
+
out.push({
|
|
468
|
+
info: {
|
|
469
|
+
id,
|
|
470
|
+
sessionID: sessionId,
|
|
471
|
+
role: "assistant",
|
|
472
|
+
...(lastUserId ? { parentID: lastUserId } : {}),
|
|
473
|
+
time: { created: created ?? 0, completed: completed ?? 0 },
|
|
474
|
+
...(typeof message.model === "string" ? { modelID: message.model } : {}),
|
|
475
|
+
...(typeof message.provider === "string" ? { providerID: message.provider } : {}),
|
|
476
|
+
...(typeof message.responseModel === "string" && message.responseModel && message.responseModel !== message.model
|
|
477
|
+
? { responseModelID: message.responseModel }
|
|
478
|
+
: {}),
|
|
479
|
+
agent,
|
|
480
|
+
mode: agent,
|
|
481
|
+
...(effort ? { variant: effort } : {}),
|
|
482
|
+
path: { cwd, root: cwd },
|
|
483
|
+
cost,
|
|
484
|
+
tokens,
|
|
485
|
+
...(finish ? { finish } : {}),
|
|
486
|
+
...(error ? { error } : {}),
|
|
487
|
+
},
|
|
488
|
+
parts: parts.map((part, partIndex) => ({ id: partId(id, partIndex), sessionID: sessionId, messageID: id, ...part })),
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
default:
|
|
493
|
+
// toolResult messages fill in their calls above; system prompts are
|
|
494
|
+
// recorded with the prompt's engine.request event.
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The active branch of a pi session file's entries: from the last entry up its parent chain. */
|
|
502
|
+
export function branchOfEntries(entries: readonly PiEntry[]): PiEntry[] {
|
|
503
|
+
const withIds = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
|
|
504
|
+
if (withIds.length === 0) return [];
|
|
505
|
+
const byId = new Map(withIds.map((entry) => [entry.id as string, entry]));
|
|
506
|
+
const branch: PiEntry[] = [];
|
|
507
|
+
const seen = new Set<string>();
|
|
508
|
+
let current: PiEntry | undefined = withIds[withIds.length - 1];
|
|
509
|
+
while (current && !seen.has(current.id as string)) {
|
|
510
|
+
seen.add(current.id as string);
|
|
511
|
+
branch.push(current);
|
|
512
|
+
current = typeof current.parentId === "string" ? byId.get(current.parentId) : undefined;
|
|
513
|
+
}
|
|
514
|
+
return branch.reverse();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** A pi session file (JSON lines) as its header and entries; malformed lines are skipped. */
|
|
518
|
+
export function parseSessionFile(text: string): { header: PiEntry | null; entries: PiEntry[] } {
|
|
519
|
+
let header: PiEntry | null = null;
|
|
520
|
+
const entries: PiEntry[] = [];
|
|
521
|
+
for (const line of text.split("\n")) {
|
|
522
|
+
if (!line.trim()) continue;
|
|
523
|
+
let value: unknown;
|
|
524
|
+
try {
|
|
525
|
+
value = JSON.parse(line);
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (!isRecord(value)) continue;
|
|
530
|
+
if (value.type === "session") header ??= value as PiEntry;
|
|
531
|
+
else entries.push(value as PiEntry);
|
|
532
|
+
}
|
|
533
|
+
return { header, entries };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- the turn observer's helpers (collector-observer.ts) -------------------------
|
|
537
|
+
|
|
538
|
+
/** Latest model selection of a turn, as the collector's session.model event records it. */
|
|
539
|
+
export type TurnModel = { provider_id: string | null; model_id: string | null; variant: string | null; agent: string | null };
|
|
540
|
+
|
|
541
|
+
function optionalText(value: unknown): string | null {
|
|
542
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The model the turn ran on, read from the turn's first assistant message
|
|
547
|
+
* (providerID / modelID / mode or agent); the user message that opened the
|
|
548
|
+
* turn supplies the variant and agent when the assistant record lacks them.
|
|
549
|
+
* (collector-observer.ts `turnModelFromMessages`.)
|
|
550
|
+
*/
|
|
551
|
+
export function turnModelFromMessages(messages: unknown): TurnModel | null {
|
|
552
|
+
if (!Array.isArray(messages)) return null;
|
|
553
|
+
let userVariant: string | null = null;
|
|
554
|
+
let userAgent: string | null = null;
|
|
555
|
+
for (const message of messages) {
|
|
556
|
+
if (!isRecord(message)) continue;
|
|
557
|
+
const info = isRecord(message.info) ? message.info : message;
|
|
558
|
+
const model = isRecord(info.model) ? info.model : {};
|
|
559
|
+
const role = info.role ?? info.type;
|
|
560
|
+
if (role === "user") {
|
|
561
|
+
userVariant = optionalText(info.variant) ?? optionalText(model.variant) ?? userVariant;
|
|
562
|
+
userAgent = optionalText(info.agent) ?? userAgent;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (role !== "assistant") continue;
|
|
566
|
+
const providerId = optionalText(info.providerID) ?? optionalText(model.providerID);
|
|
567
|
+
const modelId = optionalText(info.modelID) ?? optionalText(model.modelID) ?? optionalText(model.id);
|
|
568
|
+
if (!providerId && !modelId) continue;
|
|
569
|
+
return {
|
|
570
|
+
provider_id: providerId,
|
|
571
|
+
model_id: modelId,
|
|
572
|
+
variant: optionalText(info.variant) ?? optionalText(model.variant) ?? userVariant,
|
|
573
|
+
agent: optionalText(info.agent) ?? optionalText(info.mode) ?? userAgent,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The messages one trace flush carries at most (a trace document holds 16 MiB in all). */
|
|
580
|
+
export const MAX_TURN_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
581
|
+
/** The messages after the checkpoint kept whole when a settled turn is read, the newest first. */
|
|
582
|
+
export const MAX_BACKLOG_MESSAGE_BYTES = 4 * MAX_TURN_MESSAGE_BYTES;
|
|
583
|
+
|
|
584
|
+
export type TurnHistory = {
|
|
585
|
+
/** The messages after the checkpoint, oldest first: the newest of them, up to the budget, plus every older user prompt that fits on its own. */
|
|
586
|
+
delta: EngineMessage[];
|
|
587
|
+
/** The JSON size of each message in `delta`. */
|
|
588
|
+
sizes: number[];
|
|
589
|
+
/** Messages after the checkpoint left out of `delta` for the budget. */
|
|
590
|
+
omitted: number;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The part of a session's messages a settled turn records: every message
|
|
595
|
+
* after the checkpoint message (all of them without one, or when the
|
|
596
|
+
* checkpoint is not on the branch), newest first up to `budget` bytes, and
|
|
597
|
+
* once the budget is spent only the user's own prompts. (collector-observer.ts
|
|
598
|
+
* `readEngineHistory`, over messages already in memory.)
|
|
599
|
+
*/
|
|
600
|
+
export function turnHistory(messages: readonly EngineMessage[], checkpoint: string | undefined, budget = MAX_BACKLOG_MESSAGE_BYTES): TurnHistory {
|
|
601
|
+
let start = 0;
|
|
602
|
+
if (checkpoint) {
|
|
603
|
+
const at = messages.findIndex((message) => message.info.id === checkpoint);
|
|
604
|
+
if (at >= 0) start = at + 1;
|
|
605
|
+
}
|
|
606
|
+
const delta: EngineMessage[] = [];
|
|
607
|
+
const sizes: number[] = [];
|
|
608
|
+
let used = 0;
|
|
609
|
+
let spent = false;
|
|
610
|
+
let omitted = 0;
|
|
611
|
+
for (let index = messages.length - 1; index >= start; index -= 1) {
|
|
612
|
+
const message = messages[index]!;
|
|
613
|
+
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
614
|
+
const user = message.info.role === "user";
|
|
615
|
+
if (!spent && used + bytes <= budget) {
|
|
616
|
+
delta.push(message);
|
|
617
|
+
sizes.push(bytes);
|
|
618
|
+
used += bytes;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
spent = true;
|
|
622
|
+
if (user && bytes <= budget) {
|
|
623
|
+
delta.push(message);
|
|
624
|
+
sizes.push(bytes);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
omitted += 1;
|
|
628
|
+
}
|
|
629
|
+
return { delta: delta.reverse(), sizes: sizes.reverse(), omitted };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export type MessagePart = { messages: unknown[]; bytes: number };
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* A settled turn's messages (oldest first) cut into the parts its trace
|
|
636
|
+
* flushes carry, oldest part first: the newest messages up to `maxBytes` make
|
|
637
|
+
* the last part, and each earlier part takes the next older ones. One part,
|
|
638
|
+
* empty, when there are no messages. (collector-observer.ts `messageParts`.)
|
|
639
|
+
*/
|
|
640
|
+
export function messageParts(messages: readonly unknown[], sizes: readonly number[], maxBytes = MAX_TURN_MESSAGE_BYTES): MessagePart[] {
|
|
641
|
+
const parts: MessagePart[] = [];
|
|
642
|
+
let part: MessagePart = { messages: [], bytes: 0 };
|
|
643
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
644
|
+
const bytes = sizes[index] ?? 0;
|
|
645
|
+
if (part.messages.length > 0 && part.bytes + bytes > maxBytes) {
|
|
646
|
+
parts.push(part);
|
|
647
|
+
part = { messages: [], bytes: 0 };
|
|
648
|
+
}
|
|
649
|
+
part.messages.push(messages[index]);
|
|
650
|
+
part.bytes += bytes;
|
|
651
|
+
}
|
|
652
|
+
parts.push(part);
|
|
653
|
+
for (const each of parts) each.messages.reverse();
|
|
654
|
+
return parts.reverse();
|
|
655
|
+
}
|