@reefclaw/openclaw-plugin 0.1.25 → 0.1.27
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/bridge/gateway/gateway-ws-client.d.ts +2 -0
- package/bridge/gateway/gateway-ws-client.js +6 -0
- package/bridge/heartbeat-runs-state.d.ts +16 -0
- package/bridge/heartbeat-runs-state.js +58 -0
- package/bridge/heartbeat-runs.d.ts +99 -0
- package/bridge/heartbeat-runs.js +300 -0
- package/bridge/heartbeat-transcript.d.ts +209 -0
- package/bridge/heartbeat-transcript.js +688 -0
- package/bridge/index.js +14 -0
- package/bridge/model-health.d.ts +37 -0
- package/bridge/model-health.js +97 -0
- package/bridge/provider.d.ts +5 -1
- package/bridge/providers/gateway.d.ts +25 -1
- package/bridge/providers/gateway.js +167 -2
- package/bridge/providers/mock.js +1 -0
- package/bridge/types.d.ts +41 -0
- package/index.js +7 -0
- package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
- package/ingest/reconcile-db-vs-exchange.js +23 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/paper-adapter.js +8 -0
- package/signals/types.js +1 -1
- package/simulator/exchange-simulator.d.ts +7 -0
- package/simulator/exchange-simulator.js +30 -0
- package/simulator/types.d.ts +1 -1
- package/tools/create-order.js +1 -1
- package/tools/reentry-cooldown.js +2 -2
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// Heartbeat flight recorder — TRANSCRIPT READER (network-free).
|
|
2
|
+
//
|
|
3
|
+
// Every heartbeat runs as an ISOLATED OpenClaw cron session: the gateway
|
|
4
|
+
// mints a fresh session id per beat and writes its transcript to
|
|
5
|
+
// ~/.openclaw/agents/main/sessions/<sessionId>.jsonl
|
|
6
|
+
// with a `.trajectory.jsonl` sidecar beside it. This module turns one such
|
|
7
|
+
// pair into a compact, size-capped, token-redacted `HeartbeatRunRecord`:
|
|
8
|
+
// which tools the agent called (with truncated args + results), what it
|
|
9
|
+
// reported, which model answered, how many tokens it burned (raw counts —
|
|
10
|
+
// deliberately NO currency translation), and any model-level trouble
|
|
11
|
+
// (fallback / thinking-level / error events). The dashboard renders these as
|
|
12
|
+
// the per-beat "what did it actually do" record that the journal never had
|
|
13
|
+
// (tool calls were only ever visible by reading the box's transcript).
|
|
14
|
+
//
|
|
15
|
+
// Shapes verified against a live 2026.8.1 box (codex harness) on 2026-09-06
|
|
16
|
+
// and the vendored 2026.6.11 source (`openclaw-main/`):
|
|
17
|
+
// line 1 {type:"session", version, id, timestamp, cwd}
|
|
18
|
+
// then {type:"message", id, parentId, timestamp, message:{...}}
|
|
19
|
+
// {type:"model_change", provider, modelId}
|
|
20
|
+
// {type:"thinking_level_change", thinkingLevel}
|
|
21
|
+
// (compaction / branch_summary / custom / label / leaf …)
|
|
22
|
+
// message.role "user" | "assistant" | "toolResult"
|
|
23
|
+
// assistant content[] of {type:"text"|"thinking"|"toolCall"},
|
|
24
|
+
// api / provider / model / usage / stopReason / errorMessage
|
|
25
|
+
// toolResult toolCallId / toolName / isError / content[] (the codex
|
|
26
|
+
// harness nests {type:"toolResult", text, content} items —
|
|
27
|
+
// the vendored shape is {type:"text", text}; both handled)
|
|
28
|
+
// ★ Under the codex harness every per-message `usage` is ZERO — token
|
|
29
|
+
// counts come from the cron run log (`cron.runs`) first, the trajectory's
|
|
30
|
+
// `model.completed` event second, and the transcript sum last.
|
|
31
|
+
// ★ The first user message starts with `[cron:<jobId> <jobName>] …` — that
|
|
32
|
+
// marker (or a `:cron:` trajectory sessionKey) is how a heartbeat session
|
|
33
|
+
// is told apart from the main chat session in the same directory.
|
|
34
|
+
//
|
|
35
|
+
// ★ THIS MODULE MUST NEVER IMPORT NETWORK CODE (no fetch, no ws, no relay, no
|
|
36
|
+
// provider). ClawHub's static analyzer flags a sensitive-looking file read
|
|
37
|
+
// paired with a network send IN THE SAME FILE as potential exfiltration (see
|
|
38
|
+
// utils/skills-snapshot-invalidation.ts). The POST lives in heartbeat-runs.ts.
|
|
39
|
+
import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import { homedir } from 'node:os';
|
|
42
|
+
import { redactTokens } from '@reefclaw/shared';
|
|
43
|
+
// ---- Size caps (keep a record well under the relay/webapp body limits) -----
|
|
44
|
+
/** Max tool calls kept per record (the census still counts every call). */
|
|
45
|
+
export const TOOL_CALLS_MAX = 250;
|
|
46
|
+
/** Max chars of a tool call's JSON arguments. */
|
|
47
|
+
export const ARGS_MAX = 300;
|
|
48
|
+
/** Max chars of a tool result's text. */
|
|
49
|
+
export const RESULT_MAX = 400;
|
|
50
|
+
/** Max chars of the agent's final report text. */
|
|
51
|
+
export const SUMMARY_MAX = 4000;
|
|
52
|
+
/** Max model events kept. */
|
|
53
|
+
export const MODEL_EVENTS_MAX = 50;
|
|
54
|
+
/** Target upper bound for a serialized record (bytes). */
|
|
55
|
+
export const RECORD_MAX_BYTES = 200_000;
|
|
56
|
+
/** Tools whose arguments must never be captured (credentials in flight). */
|
|
57
|
+
const SENSITIVE_TOOL_RE = /credential|secret|private|password|api_?key|token|wallet|passphrase/i;
|
|
58
|
+
// ---- Paths ---------------------------------------------------------------
|
|
59
|
+
/** OpenClaw's state dir — `OPENCLAW_STATE_DIR` is the real override (the
|
|
60
|
+
* bridge historically read a non-existent OPENCLAW_HOME; honour both). */
|
|
61
|
+
export function resolveOpenClawStateDir(env = process.env) {
|
|
62
|
+
const explicit = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_HOME?.trim();
|
|
63
|
+
if (explicit)
|
|
64
|
+
return explicit;
|
|
65
|
+
return join(homedir(), '.openclaw');
|
|
66
|
+
}
|
|
67
|
+
export function resolveSessionsDir(agentId = 'main', env = process.env) {
|
|
68
|
+
return join(resolveOpenClawStateDir(env), 'agents', agentId, 'sessions');
|
|
69
|
+
}
|
|
70
|
+
const SESSION_FILE_RE = /^([a-z0-9][a-z0-9._-]{0,127})\.jsonl$/i;
|
|
71
|
+
/** Every `<sessionId>.jsonl` transcript in `dir` (not sidecars, checkpoints,
|
|
72
|
+
* topic files or retired copies) modified at/after `minMtimeMs`, excluding
|
|
73
|
+
* `exclude`d session ids. Never throws — a missing dir yields []. */
|
|
74
|
+
export function listSessionCandidates(dir, opts = {}) {
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = readdirSync(dir);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
const m = SESSION_FILE_RE.exec(name);
|
|
85
|
+
if (!m)
|
|
86
|
+
continue;
|
|
87
|
+
const sessionId = m[1];
|
|
88
|
+
if (sessionId === 'sessions')
|
|
89
|
+
continue; // sessions.json is not .jsonl, but be explicit
|
|
90
|
+
if (/\.(trajectory|checkpoint\.[^.]+)$/i.test(sessionId))
|
|
91
|
+
continue;
|
|
92
|
+
if (/-topic-/i.test(sessionId))
|
|
93
|
+
continue;
|
|
94
|
+
if (opts.exclude?.has(sessionId))
|
|
95
|
+
continue;
|
|
96
|
+
const file = join(dir, name);
|
|
97
|
+
let st;
|
|
98
|
+
try {
|
|
99
|
+
st = statSync(file);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!st.isFile())
|
|
105
|
+
continue;
|
|
106
|
+
if (opts.minMtimeMs !== undefined && st.mtimeMs < opts.minMtimeMs)
|
|
107
|
+
continue;
|
|
108
|
+
const traj = join(dir, `${sessionId}.trajectory.jsonl`);
|
|
109
|
+
out.push({
|
|
110
|
+
sessionId,
|
|
111
|
+
file,
|
|
112
|
+
trajectoryFile: existsSync(traj) ? traj : null,
|
|
113
|
+
mtimeMs: st.mtimeMs,
|
|
114
|
+
sizeBytes: st.size,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
// ---- Cron marker (cheap head read) ----------------------------------------
|
|
120
|
+
const CRON_MARKER_RE = /^\s*\[cron:([^\s\]]+)\s*([^\]]*)\]/;
|
|
121
|
+
export function parseCronMarker(text) {
|
|
122
|
+
if (typeof text !== 'string')
|
|
123
|
+
return null;
|
|
124
|
+
const m = CRON_MARKER_RE.exec(text);
|
|
125
|
+
if (!m)
|
|
126
|
+
return null;
|
|
127
|
+
const jobName = m[2]?.trim();
|
|
128
|
+
return { jobId: m[1], jobName: jobName ? jobName : null };
|
|
129
|
+
}
|
|
130
|
+
/** Read only the head of a transcript and report whether its first user
|
|
131
|
+
* message carries the `[cron:…]` marker. `null` = inconclusive (no user
|
|
132
|
+
* message within the head) — callers then fall back to the trajectory key. */
|
|
133
|
+
export function readCronMarker(file, headBytes = 32 * 1024) {
|
|
134
|
+
let head;
|
|
135
|
+
let readAll = false;
|
|
136
|
+
try {
|
|
137
|
+
const fd = openSync(file, 'r');
|
|
138
|
+
try {
|
|
139
|
+
const buf = Buffer.alloc(headBytes);
|
|
140
|
+
const n = readSync(fd, buf, 0, headBytes, 0);
|
|
141
|
+
head = buf.subarray(0, n).toString('utf8');
|
|
142
|
+
readAll = n < headBytes; // the whole file fit — no line can be torn
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const lines = head.split('\n');
|
|
152
|
+
// The last line may be cut mid-record when the file exceeds the head; only
|
|
153
|
+
// parse complete lines in that case.
|
|
154
|
+
const complete = readAll || head.endsWith('\n') ? lines : lines.slice(0, -1);
|
|
155
|
+
for (const line of complete) {
|
|
156
|
+
if (!line.trim())
|
|
157
|
+
continue;
|
|
158
|
+
let rec;
|
|
159
|
+
try {
|
|
160
|
+
rec = JSON.parse(line);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const r = rec;
|
|
166
|
+
if (r?.type !== 'message' || r.message?.role !== 'user')
|
|
167
|
+
continue;
|
|
168
|
+
return parseCronMarker(firstText(r.message.content));
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
function num(v) {
|
|
173
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
174
|
+
}
|
|
175
|
+
function epochMs(v) {
|
|
176
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
177
|
+
return v;
|
|
178
|
+
if (typeof v === 'string') {
|
|
179
|
+
const t = Date.parse(v);
|
|
180
|
+
return Number.isFinite(t) ? t : null;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
function clip(s, max) {
|
|
185
|
+
if (s.length <= max)
|
|
186
|
+
return { text: s, clipped: false };
|
|
187
|
+
return { text: `${s.slice(0, max)}…(+${s.length - max})`, clipped: true };
|
|
188
|
+
}
|
|
189
|
+
/** Text of a content block / content array in every shape OpenClaw writes. */
|
|
190
|
+
export function textOf(content, depth = 0) {
|
|
191
|
+
if (content == null || depth > 4)
|
|
192
|
+
return '';
|
|
193
|
+
if (typeof content === 'string')
|
|
194
|
+
return content;
|
|
195
|
+
if (Array.isArray(content))
|
|
196
|
+
return content.map((c) => textOf(c, depth + 1)).filter(Boolean).join('\n');
|
|
197
|
+
if (typeof content === 'object') {
|
|
198
|
+
const o = content;
|
|
199
|
+
if (typeof o.text === 'string')
|
|
200
|
+
return o.text;
|
|
201
|
+
if (typeof o.content === 'string')
|
|
202
|
+
return o.content;
|
|
203
|
+
if (Array.isArray(o.content))
|
|
204
|
+
return textOf(o.content, depth + 1);
|
|
205
|
+
if (o.type === 'image')
|
|
206
|
+
return '[image]';
|
|
207
|
+
if (o.type === 'thinking')
|
|
208
|
+
return '';
|
|
209
|
+
try {
|
|
210
|
+
return JSON.stringify(o);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return '';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return String(content);
|
|
217
|
+
}
|
|
218
|
+
/** The first text of a user message (string or [{type:'text',text}]). */
|
|
219
|
+
function firstText(content) {
|
|
220
|
+
if (typeof content === 'string')
|
|
221
|
+
return content;
|
|
222
|
+
if (Array.isArray(content)) {
|
|
223
|
+
for (const c of content) {
|
|
224
|
+
const t = textOf(c);
|
|
225
|
+
if (t)
|
|
226
|
+
return t;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function providerModel(provider, model) {
|
|
232
|
+
const m = typeof model === 'string' && model.trim() ? model.trim() : null;
|
|
233
|
+
if (!m)
|
|
234
|
+
return null;
|
|
235
|
+
const p = typeof provider === 'string' && provider.trim() ? provider.trim() : null;
|
|
236
|
+
return p && !m.includes('/') ? `${p}/${m}` : m;
|
|
237
|
+
}
|
|
238
|
+
export function parseSessionTranscript(text) {
|
|
239
|
+
const out = {
|
|
240
|
+
sessionId: null,
|
|
241
|
+
headerAtMs: null,
|
|
242
|
+
cron: null,
|
|
243
|
+
firstUserAtMs: null,
|
|
244
|
+
lastAtMs: null,
|
|
245
|
+
toolCalls: [],
|
|
246
|
+
toolCallTotal: 0,
|
|
247
|
+
toolErrorCount: 0,
|
|
248
|
+
toolCensus: {},
|
|
249
|
+
assistantCount: 0,
|
|
250
|
+
finalText: null,
|
|
251
|
+
models: [],
|
|
252
|
+
api: null,
|
|
253
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
254
|
+
usageNonZero: false,
|
|
255
|
+
lastStopReason: null,
|
|
256
|
+
lastErrorMessage: null,
|
|
257
|
+
modelEvents: [],
|
|
258
|
+
thinkingLevel: null,
|
|
259
|
+
complete: false,
|
|
260
|
+
truncated: false,
|
|
261
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
262
|
+
};
|
|
263
|
+
const byCallId = new Map();
|
|
264
|
+
const seenIds = new Set();
|
|
265
|
+
let lastAssistantText = null;
|
|
266
|
+
let lastAnyText = null;
|
|
267
|
+
let firstUserSeen = false;
|
|
268
|
+
for (const raw of text.split('\n')) {
|
|
269
|
+
const line = raw.trim();
|
|
270
|
+
if (!line)
|
|
271
|
+
continue;
|
|
272
|
+
let rec;
|
|
273
|
+
try {
|
|
274
|
+
rec = JSON.parse(line);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
continue; // a torn tail line while the gateway is still writing
|
|
278
|
+
}
|
|
279
|
+
if (!rec || typeof rec !== 'object')
|
|
280
|
+
continue;
|
|
281
|
+
// Dedup by record id (a tree can carry the same entry via `leaf` replays).
|
|
282
|
+
if (typeof rec.id === 'string') {
|
|
283
|
+
if (seenIds.has(rec.id))
|
|
284
|
+
continue;
|
|
285
|
+
seenIds.add(rec.id);
|
|
286
|
+
}
|
|
287
|
+
const at = epochMs(rec.timestamp);
|
|
288
|
+
if (at !== null)
|
|
289
|
+
out.lastAtMs = out.lastAtMs === null ? at : Math.max(out.lastAtMs, at);
|
|
290
|
+
switch (rec.type) {
|
|
291
|
+
case 'session': {
|
|
292
|
+
out.sessionId = typeof rec.id === 'string' ? rec.id : null;
|
|
293
|
+
out.headerAtMs = at;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case 'model_change': {
|
|
297
|
+
const pm = providerModel(rec.provider, rec.modelId);
|
|
298
|
+
pushEvent(out, { kind: 'model_change', at, detail: pm ?? 'model changed' });
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case 'thinking_level_change': {
|
|
302
|
+
const lvl = typeof rec.thinkingLevel === 'string' ? rec.thinkingLevel : null;
|
|
303
|
+
out.thinkingLevel = lvl;
|
|
304
|
+
pushEvent(out, { kind: 'thinking_level_change', at, detail: lvl ?? 'thinking level changed' });
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case 'message': {
|
|
308
|
+
const m = rec.message;
|
|
309
|
+
if (!m || typeof m !== 'object')
|
|
310
|
+
break;
|
|
311
|
+
const mAt = epochMs(m.timestamp) ?? at;
|
|
312
|
+
if (mAt !== null)
|
|
313
|
+
out.lastAtMs = out.lastAtMs === null ? mAt : Math.max(out.lastAtMs, mAt);
|
|
314
|
+
const role = m.role;
|
|
315
|
+
if (role === 'user') {
|
|
316
|
+
if (!firstUserSeen) {
|
|
317
|
+
firstUserSeen = true;
|
|
318
|
+
out.firstUserAtMs = mAt;
|
|
319
|
+
out.cron = parseCronMarker(firstText(m.content));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else if (role === 'assistant') {
|
|
323
|
+
out.assistantCount++;
|
|
324
|
+
const pm = providerModel(m.provider, m.responseModel ?? m.model);
|
|
325
|
+
if (pm && !out.models.includes(pm))
|
|
326
|
+
out.models.push(pm);
|
|
327
|
+
if (typeof m.api === 'string' && !out.api)
|
|
328
|
+
out.api = m.api;
|
|
329
|
+
const u = m.usage;
|
|
330
|
+
if (u && typeof u === 'object') {
|
|
331
|
+
out.usage.input += num(u.input) ?? 0;
|
|
332
|
+
out.usage.output += num(u.output) ?? 0;
|
|
333
|
+
out.usage.cacheRead += num(u.cacheRead) ?? 0;
|
|
334
|
+
out.usage.cacheWrite += num(u.cacheWrite) ?? 0;
|
|
335
|
+
out.usage.total += num(u.totalTokens) ?? num(u.total) ?? 0;
|
|
336
|
+
}
|
|
337
|
+
const stop = typeof m.stopReason === 'string' ? m.stopReason : null;
|
|
338
|
+
out.lastStopReason = stop;
|
|
339
|
+
if (stop === 'error' || typeof m.errorMessage === 'string') {
|
|
340
|
+
const msg = typeof m.errorMessage === 'string' ? m.errorMessage : 'assistant error';
|
|
341
|
+
out.lastErrorMessage = msg;
|
|
342
|
+
pushEvent(out, { kind: 'error', at: mAt, detail: clip(msg, 300).text });
|
|
343
|
+
}
|
|
344
|
+
const texts = [];
|
|
345
|
+
if (Array.isArray(m.content)) {
|
|
346
|
+
for (const b of m.content) {
|
|
347
|
+
if (!b || typeof b !== 'object')
|
|
348
|
+
continue;
|
|
349
|
+
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim())
|
|
350
|
+
texts.push(b.text);
|
|
351
|
+
if (b.type === 'toolCall') {
|
|
352
|
+
out.toolCallTotal++;
|
|
353
|
+
const name = typeof b.name === 'string' ? b.name : 'unknown';
|
|
354
|
+
out.toolCensus[name] = (out.toolCensus[name] ?? 0) + 1;
|
|
355
|
+
if (out.toolCalls.length >= TOOL_CALLS_MAX) {
|
|
356
|
+
out.truncated = true;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const id = typeof b.id === 'string' ? b.id : `${name}#${out.toolCallTotal}`;
|
|
360
|
+
let args = '';
|
|
361
|
+
if (SENSITIVE_TOOL_RE.test(name))
|
|
362
|
+
args = '[redacted]';
|
|
363
|
+
else {
|
|
364
|
+
const rawArgs = b.arguments ?? b.input;
|
|
365
|
+
let s = '';
|
|
366
|
+
try {
|
|
367
|
+
s = typeof rawArgs === 'string' ? rawArgs : rawArgs == null ? '' : JSON.stringify(rawArgs);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
s = '[unserializable]';
|
|
371
|
+
}
|
|
372
|
+
const c = clip(s, ARGS_MAX);
|
|
373
|
+
if (c.clipped)
|
|
374
|
+
out.truncated = true;
|
|
375
|
+
args = c.text;
|
|
376
|
+
}
|
|
377
|
+
const call = { id, name, at: mAt, ok: null, args, result: '', durationMs: null };
|
|
378
|
+
out.toolCalls.push(call);
|
|
379
|
+
byCallId.set(id, call);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (typeof m.content === 'string' && m.content.trim()) {
|
|
384
|
+
texts.push(m.content);
|
|
385
|
+
}
|
|
386
|
+
if (texts.length) {
|
|
387
|
+
lastAssistantText = texts.join('\n');
|
|
388
|
+
lastAnyText = lastAssistantText;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (role === 'toolResult') {
|
|
392
|
+
const id = typeof m.toolCallId === 'string' ? m.toolCallId : null;
|
|
393
|
+
const call = id ? byCallId.get(id) : undefined;
|
|
394
|
+
const isErr = m.isError === true;
|
|
395
|
+
if (isErr)
|
|
396
|
+
out.toolErrorCount++;
|
|
397
|
+
if (call) {
|
|
398
|
+
call.ok = !isErr;
|
|
399
|
+
const c = clip(textOf(m.content), RESULT_MAX);
|
|
400
|
+
if (c.clipped)
|
|
401
|
+
out.truncated = true;
|
|
402
|
+
call.result = c.text;
|
|
403
|
+
if (mAt !== null && call.at !== null && mAt >= call.at)
|
|
404
|
+
call.durationMs = mAt - call.at;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
out.finalText = lastAssistantText ?? lastAnyText;
|
|
414
|
+
out.usageNonZero = out.usage.input > 0 || out.usage.output > 0 || out.usage.total > 0;
|
|
415
|
+
// A turn that ended on a tool call is still mid-chain; anything else
|
|
416
|
+
// ("stop", "error", "aborted", "length") is terminal.
|
|
417
|
+
out.complete = out.assistantCount > 0 && out.lastStopReason !== null && out.lastStopReason !== 'toolUse';
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
function pushEvent(out, ev) {
|
|
421
|
+
if (out.modelEvents.length >= MODEL_EVENTS_MAX) {
|
|
422
|
+
out.truncated = true;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
out.modelEvents.push(ev);
|
|
426
|
+
}
|
|
427
|
+
export function parseTrajectory(text) {
|
|
428
|
+
const out = {
|
|
429
|
+
sessionKey: null,
|
|
430
|
+
runId: null,
|
|
431
|
+
provider: null,
|
|
432
|
+
modelId: null,
|
|
433
|
+
modelApi: null,
|
|
434
|
+
toolCount: null,
|
|
435
|
+
trigger: null,
|
|
436
|
+
thinkLevel: null,
|
|
437
|
+
ended: null,
|
|
438
|
+
usage: null,
|
|
439
|
+
fallbackSteps: [],
|
|
440
|
+
toolCallEvents: 0,
|
|
441
|
+
firstTs: null,
|
|
442
|
+
lastTs: null,
|
|
443
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
444
|
+
};
|
|
445
|
+
for (const raw of text.split('\n')) {
|
|
446
|
+
const line = raw.trim();
|
|
447
|
+
if (!line)
|
|
448
|
+
continue;
|
|
449
|
+
let ev;
|
|
450
|
+
try {
|
|
451
|
+
ev = JSON.parse(line);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (!ev || typeof ev !== 'object')
|
|
457
|
+
continue;
|
|
458
|
+
const ts = epochMs(ev.ts);
|
|
459
|
+
if (ts !== null) {
|
|
460
|
+
out.firstTs = out.firstTs === null ? ts : Math.min(out.firstTs, ts);
|
|
461
|
+
out.lastTs = out.lastTs === null ? ts : Math.max(out.lastTs, ts);
|
|
462
|
+
}
|
|
463
|
+
if (typeof ev.sessionKey === 'string' && !out.sessionKey)
|
|
464
|
+
out.sessionKey = ev.sessionKey;
|
|
465
|
+
if (typeof ev.runId === 'string' && !out.runId)
|
|
466
|
+
out.runId = ev.runId;
|
|
467
|
+
if (typeof ev.provider === 'string' && !out.provider)
|
|
468
|
+
out.provider = ev.provider;
|
|
469
|
+
if (typeof ev.modelId === 'string' && !out.modelId)
|
|
470
|
+
out.modelId = ev.modelId;
|
|
471
|
+
if (typeof ev.modelApi === 'string' && !out.modelApi)
|
|
472
|
+
out.modelApi = ev.modelApi;
|
|
473
|
+
const data = (ev.data && typeof ev.data === 'object' ? ev.data : {});
|
|
474
|
+
switch (ev.type) {
|
|
475
|
+
case 'session.started':
|
|
476
|
+
out.toolCount = num(data.toolCount);
|
|
477
|
+
if (typeof data.trigger === 'string')
|
|
478
|
+
out.trigger = data.trigger;
|
|
479
|
+
break;
|
|
480
|
+
case 'trace.metadata':
|
|
481
|
+
if (typeof data.trigger === 'string' && !out.trigger)
|
|
482
|
+
out.trigger = data.trigger;
|
|
483
|
+
if (typeof data.thinkLevel === 'string')
|
|
484
|
+
out.thinkLevel = data.thinkLevel;
|
|
485
|
+
break;
|
|
486
|
+
case 'tool.call':
|
|
487
|
+
out.toolCallEvents++;
|
|
488
|
+
break;
|
|
489
|
+
case 'model.completed': {
|
|
490
|
+
// A truncated event is {truncated, originalBytes, limitBytes, reason}
|
|
491
|
+
// with no usage — the run log / transcript sum cover that case.
|
|
492
|
+
const u = data.usage;
|
|
493
|
+
if (u && typeof u === 'object' && data.truncated !== true) {
|
|
494
|
+
out.usage = {
|
|
495
|
+
input: num(u.input) ?? 0,
|
|
496
|
+
output: num(u.output) ?? 0,
|
|
497
|
+
cacheRead: num(u.cacheRead) ?? 0,
|
|
498
|
+
cacheWrite: num(u.cacheWrite) ?? 0,
|
|
499
|
+
total: num(u.total) ?? num(u.totalTokens) ?? 0,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case 'session.ended':
|
|
505
|
+
out.ended = {
|
|
506
|
+
status: typeof data.status === 'string' ? data.status : null,
|
|
507
|
+
timedOut: data.timedOut === true,
|
|
508
|
+
promptError: typeof data.promptError === 'string' && data.promptError ? data.promptError : null,
|
|
509
|
+
};
|
|
510
|
+
break;
|
|
511
|
+
case 'model.fallback_step': {
|
|
512
|
+
if (out.fallbackSteps.length >= MODEL_EVENTS_MAX)
|
|
513
|
+
break;
|
|
514
|
+
out.fallbackSteps.push({ kind: 'fallback_step', at: ts, detail: describeFallbackStep(data) });
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
default:
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
/** Compact one-line description of a model.fallback_step payload whose exact
|
|
524
|
+
* shape is not pinned by the vendored source — pick the fields that matter
|
|
525
|
+
* when present, else a clipped JSON dump. */
|
|
526
|
+
function describeFallbackStep(d) {
|
|
527
|
+
const parts = [];
|
|
528
|
+
const from = providerModel(d.fromProvider ?? d.provider, d.fromModel ?? d.model);
|
|
529
|
+
const to = providerModel(d.toProvider ?? d.nextProvider, d.toModel ?? d.nextModel ?? d.next);
|
|
530
|
+
if (from)
|
|
531
|
+
parts.push(from);
|
|
532
|
+
if (to)
|
|
533
|
+
parts.push(`→ ${to}`);
|
|
534
|
+
if (typeof d.reason === 'string')
|
|
535
|
+
parts.push(`reason=${d.reason}`);
|
|
536
|
+
else if (typeof d.errorReason === 'string')
|
|
537
|
+
parts.push(`reason=${d.errorReason}`);
|
|
538
|
+
if (typeof d.error === 'string')
|
|
539
|
+
parts.push(clip(d.error, 120).text);
|
|
540
|
+
if (parts.length)
|
|
541
|
+
return parts.join(' ');
|
|
542
|
+
try {
|
|
543
|
+
return clip(JSON.stringify(d), 200).text;
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return 'fallback step';
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
export function isCronSessionKey(key) {
|
|
550
|
+
return typeof key === 'string' && /(^|:)cron:/i.test(key);
|
|
551
|
+
}
|
|
552
|
+
function clean(s) {
|
|
553
|
+
if (typeof s !== 'string')
|
|
554
|
+
return null;
|
|
555
|
+
const t = s.trim();
|
|
556
|
+
return t ? redactTokens(t) : null;
|
|
557
|
+
}
|
|
558
|
+
export function buildHeartbeatRunRecord(i) {
|
|
559
|
+
const t = i.transcript;
|
|
560
|
+
const tj = i.trajectory;
|
|
561
|
+
const rl = i.runLog;
|
|
562
|
+
const cronFromKey = tj?.sessionKey && isCronSessionKey(tj.sessionKey)
|
|
563
|
+
? /(?:^|:)cron:([^:]+)/i.exec(tj.sessionKey)?.[1] ?? null
|
|
564
|
+
: null;
|
|
565
|
+
const cronJobId = t.cron?.jobId ?? cronFromKey;
|
|
566
|
+
const trigger = cronJobId ? 'cron' : 'unknown';
|
|
567
|
+
// Status: the run log is authoritative, then the trajectory's terminal
|
|
568
|
+
// event, then what the transcript itself shows.
|
|
569
|
+
let status;
|
|
570
|
+
if (rl?.status === 'ok')
|
|
571
|
+
status = 'ok';
|
|
572
|
+
else if (rl?.status === 'error' || rl?.status === 'skipped')
|
|
573
|
+
status = 'error';
|
|
574
|
+
else if (tj?.ended)
|
|
575
|
+
status = tj.ended.status === 'success' || tj.ended.status === 'ok' ? 'ok' : 'error';
|
|
576
|
+
else if (t.lastStopReason === 'error' || t.lastErrorMessage)
|
|
577
|
+
status = 'error';
|
|
578
|
+
else if (t.complete)
|
|
579
|
+
status = 'ok';
|
|
580
|
+
else
|
|
581
|
+
status = t.assistantCount > 0 ? 'incomplete' : 'unknown';
|
|
582
|
+
const startedAtMs = rl?.runAtMs ?? t.headerAtMs ?? t.firstUserAtMs ?? tj?.firstTs ?? i.fallbackStartedAtMs;
|
|
583
|
+
const endedAtMs = tj?.lastTs != null && t.lastAtMs != null ? Math.max(tj.lastTs, t.lastAtMs) : (tj?.lastTs ?? t.lastAtMs);
|
|
584
|
+
const durationMs = rl?.durationMs ?? (endedAtMs != null && endedAtMs >= startedAtMs ? endedAtMs - startedAtMs : null);
|
|
585
|
+
let tokens;
|
|
586
|
+
if (rl?.usage && (rl.usage.total != null || rl.usage.input != null || rl.usage.output != null)) {
|
|
587
|
+
tokens = { ...rl.usage, source: 'run_log' };
|
|
588
|
+
}
|
|
589
|
+
else if (tj?.usage) {
|
|
590
|
+
tokens = { ...tj.usage, source: 'trajectory' };
|
|
591
|
+
}
|
|
592
|
+
else if (t.usageNonZero) {
|
|
593
|
+
tokens = { ...t.usage, source: 'transcript' };
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
tokens = { input: null, output: null, cacheRead: null, cacheWrite: null, total: null, source: 'none' };
|
|
597
|
+
}
|
|
598
|
+
if (tokens.total == null && tokens.input != null && tokens.output != null) {
|
|
599
|
+
tokens.total = tokens.input + tokens.output + (tokens.cacheRead ?? 0);
|
|
600
|
+
}
|
|
601
|
+
const model = providerModel(rl?.provider, rl?.model) ??
|
|
602
|
+
(tj ? providerModel(tj.provider, tj.modelId) : null) ??
|
|
603
|
+
(t.models.length ? t.models[t.models.length - 1] : null);
|
|
604
|
+
const modelEvents = [...t.modelEvents, ...(tj?.fallbackSteps ?? [])]
|
|
605
|
+
.map((e) => ({ ...e, detail: redactTokens(e.detail) }))
|
|
606
|
+
.sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
|
|
607
|
+
.slice(0, MODEL_EVENTS_MAX);
|
|
608
|
+
const errorText = clean(rl?.error) ?? clean(tj?.ended?.promptError) ?? clean(t.lastErrorMessage);
|
|
609
|
+
const summaryClip = t.finalText ? clip(redactTokens(t.finalText), SUMMARY_MAX) : null;
|
|
610
|
+
const toolCalls = t.toolCalls.map((c) => ({
|
|
611
|
+
...c,
|
|
612
|
+
args: redactTokens(c.args),
|
|
613
|
+
result: redactTokens(c.result),
|
|
614
|
+
}));
|
|
615
|
+
return {
|
|
616
|
+
runId: i.sessionId,
|
|
617
|
+
cronJobId,
|
|
618
|
+
cronJobName: t.cron?.jobName ?? null,
|
|
619
|
+
trigger,
|
|
620
|
+
status,
|
|
621
|
+
startedAtMs,
|
|
622
|
+
endedAtMs: endedAtMs ?? null,
|
|
623
|
+
durationMs,
|
|
624
|
+
model,
|
|
625
|
+
tokens,
|
|
626
|
+
toolCallCount: t.toolCallTotal,
|
|
627
|
+
toolErrorCount: t.toolErrorCount,
|
|
628
|
+
toolCensus: t.toolCensus,
|
|
629
|
+
toolCalls,
|
|
630
|
+
summary: summaryClip?.text ?? null,
|
|
631
|
+
modelEvents,
|
|
632
|
+
error: errorText,
|
|
633
|
+
errorReason: clean(rl?.errorReason),
|
|
634
|
+
mode: i.mode,
|
|
635
|
+
meta: {
|
|
636
|
+
transcriptBytes: t.bytes,
|
|
637
|
+
trajectoryBytes: tj?.bytes ?? null,
|
|
638
|
+
assistantMessages: t.assistantCount,
|
|
639
|
+
truncated: t.truncated || (summaryClip?.clipped ?? false),
|
|
640
|
+
toolCount: tj?.toolCount ?? null,
|
|
641
|
+
api: t.api ?? tj?.modelApi ?? null,
|
|
642
|
+
thinkingLevel: t.thinkingLevel ?? tj?.thinkLevel ?? null,
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/** Shrink a record until its JSON fits `maxBytes`: first shorter tool
|
|
647
|
+
* results, then no results, then fewer tool calls. Pure; returns a copy. */
|
|
648
|
+
export function shrinkRecord(record, maxBytes = RECORD_MAX_BYTES) {
|
|
649
|
+
const size = (r) => Buffer.byteLength(JSON.stringify(r), 'utf8');
|
|
650
|
+
if (size(record) <= maxBytes)
|
|
651
|
+
return record;
|
|
652
|
+
let r = {
|
|
653
|
+
...record,
|
|
654
|
+
toolCalls: record.toolCalls.map((c) => ({ ...c, result: clip(c.result, 120).text, args: clip(c.args, 120).text })),
|
|
655
|
+
meta: { ...record.meta, truncated: true },
|
|
656
|
+
};
|
|
657
|
+
if (size(r) <= maxBytes)
|
|
658
|
+
return r;
|
|
659
|
+
r = { ...r, toolCalls: r.toolCalls.map((c) => ({ ...c, result: '' })) };
|
|
660
|
+
if (size(r) <= maxBytes)
|
|
661
|
+
return r;
|
|
662
|
+
let keep = r.toolCalls.length;
|
|
663
|
+
while (keep > 0 && size({ ...r, toolCalls: r.toolCalls.slice(0, keep) }) > maxBytes)
|
|
664
|
+
keep = Math.floor(keep / 2);
|
|
665
|
+
return { ...r, toolCalls: r.toolCalls.slice(0, keep), summary: r.summary ? clip(r.summary, 1000).text : null };
|
|
666
|
+
}
|
|
667
|
+
/** Read + parse a candidate's transcript and (when present) its trajectory.
|
|
668
|
+
* Never throws on a missing/torn file — a missing transcript yields null. */
|
|
669
|
+
export function readSession(c) {
|
|
670
|
+
let text;
|
|
671
|
+
try {
|
|
672
|
+
text = readFileSync(c.file, 'utf8');
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
const transcript = parseSessionTranscript(text);
|
|
678
|
+
let trajectory = null;
|
|
679
|
+
if (c.trajectoryFile) {
|
|
680
|
+
try {
|
|
681
|
+
trajectory = parseTrajectory(readFileSync(c.trajectoryFile, 'utf8'));
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
trajectory = null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { transcript, trajectory };
|
|
688
|
+
}
|