@zhuxixi/pi-agent-board 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduce Pi JSON-mode worker events into an evolving RunStatus.
|
|
3
|
+
*
|
|
4
|
+
* Event vocabulary (verified against dist `agent-session.ts`; see docs/EXPLORATION.md §4):
|
|
5
|
+
* first line: session header {type:"session",...}
|
|
6
|
+
* message_start | message_update | message_end (.message is an AgentMessage)
|
|
7
|
+
* tool_execution_start {toolCallId,toolName,args}
|
|
8
|
+
* tool_execution_end {toolCallId,toolName,result,isError}
|
|
9
|
+
* turn_start | turn_end | agent_start | agent_end
|
|
10
|
+
* NOTE: `tool_result_end` (used by the subagent example) is never emitted — do not rely on it.
|
|
11
|
+
*/
|
|
12
|
+
import { deriveSummary, finalizeSemanticState } from "./derive.mjs";
|
|
13
|
+
import { assistantText, detectNeedsInput, toolPath, toolSummary, truncate } from "./heuristics.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("./types.mjs").RunConfig} RunConfig */
|
|
16
|
+
/** @typedef {import("./types.mjs").RunStatus} RunStatus */
|
|
17
|
+
|
|
18
|
+
const PREVIEW_MAX = 240;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the initial status for a freshly-launched run.
|
|
22
|
+
* @param {RunConfig} config
|
|
23
|
+
* @param {number|null} pid
|
|
24
|
+
* @param {number} now
|
|
25
|
+
* @returns {RunStatus}
|
|
26
|
+
*/
|
|
27
|
+
export function createRunStatus(config, pid, now) {
|
|
28
|
+
/** @type {RunStatus} */
|
|
29
|
+
const status = {
|
|
30
|
+
version: 1,
|
|
31
|
+
runId: config.runId,
|
|
32
|
+
viewId: config.viewId,
|
|
33
|
+
pid,
|
|
34
|
+
startedAt: now,
|
|
35
|
+
endedAt: null,
|
|
36
|
+
exitCode: null,
|
|
37
|
+
kind: config.kind,
|
|
38
|
+
prompt: config.prompt,
|
|
39
|
+
model: config.model ?? null,
|
|
40
|
+
semanticState: "queued",
|
|
41
|
+
processState: "alive",
|
|
42
|
+
summary: "Queued",
|
|
43
|
+
lastActivityAt: now,
|
|
44
|
+
currentTool: null,
|
|
45
|
+
latestAssistantPreview: "",
|
|
46
|
+
question: null,
|
|
47
|
+
pendingQuestions: [],
|
|
48
|
+
error: null,
|
|
49
|
+
lastAgentActivityAt: null,
|
|
50
|
+
stopReason: null,
|
|
51
|
+
stoppedByUser: false,
|
|
52
|
+
turns: 0,
|
|
53
|
+
toolCount: 0,
|
|
54
|
+
eventCount: 0,
|
|
55
|
+
lastEventAt: null,
|
|
56
|
+
usage: null,
|
|
57
|
+
stallReason: null,
|
|
58
|
+
evidenceSummary: null,
|
|
59
|
+
autoState: null,
|
|
60
|
+
};
|
|
61
|
+
return status;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply a single worker event to `status` (mutates in place).
|
|
66
|
+
* @param {RunStatus} status
|
|
67
|
+
* @param {any} event
|
|
68
|
+
* @param {number} now
|
|
69
|
+
* @param {{ interactive?: boolean }} [opts]
|
|
70
|
+
* @returns {boolean} whether this event produced a user-visible change worth persisting.
|
|
71
|
+
*/
|
|
72
|
+
export function reduceEvent(status, event, now, opts = {}) {
|
|
73
|
+
if (!event || typeof event !== "object" || typeof event.type !== "string") return false;
|
|
74
|
+
let meaningful = false;
|
|
75
|
+
status.eventCount = (status.eventCount ?? 0) + 1;
|
|
76
|
+
status.lastEventAt = now;
|
|
77
|
+
|
|
78
|
+
switch (event.type) {
|
|
79
|
+
case "tool_execution_start": {
|
|
80
|
+
const name = event.toolName ?? event.args?.name ?? "tool";
|
|
81
|
+
const args = event.args ?? {};
|
|
82
|
+
status.toolCount += 1;
|
|
83
|
+
if (opts.interactive && name === "ask_questions") {
|
|
84
|
+
upsertPendingQuestion(status, event.toolCallId, questionFromArgs(args));
|
|
85
|
+
} else if (pendingQuestions(status).length === 0) {
|
|
86
|
+
status.currentTool = { name, path: toolPath(args), summary: toolSummary(name, args) };
|
|
87
|
+
status.semanticState = "working";
|
|
88
|
+
}
|
|
89
|
+
preservePendingQuestion(status);
|
|
90
|
+
status.lastActivityAt = now;
|
|
91
|
+
// Unread is keyed off the latest assistant message, not intermediate tool churn.
|
|
92
|
+
meaningful = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "tool_execution_end": {
|
|
96
|
+
if (event.isError) status.error = `Tool ${event.toolName ?? ""} failed`.trim();
|
|
97
|
+
if (opts.interactive && event.toolName === "ask_questions") removePendingQuestion(status, event.toolCallId);
|
|
98
|
+
status.currentTool = null;
|
|
99
|
+
status.semanticState = "working";
|
|
100
|
+
status.question = null;
|
|
101
|
+
preservePendingQuestion(status);
|
|
102
|
+
status.lastActivityAt = now;
|
|
103
|
+
// Unread is keyed off the latest assistant message, not intermediate tool churn.
|
|
104
|
+
meaningful = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "message_start": {
|
|
108
|
+
if (event.message?.role === "assistant") {
|
|
109
|
+
status.semanticState = "working";
|
|
110
|
+
preservePendingQuestion(status);
|
|
111
|
+
status.lastActivityAt = now;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "message_end": {
|
|
116
|
+
const msg = event.message;
|
|
117
|
+
if (msg?.usage || event.usage) status.usage = mergeUsage(status.usage ?? null, msg?.usage ?? event.usage);
|
|
118
|
+
if (msg?.role === "assistant") {
|
|
119
|
+
status.turns += 1;
|
|
120
|
+
if (msg.model && !status.model) status.model = msg.model;
|
|
121
|
+
if (msg.stopReason) status.stopReason = msg.stopReason;
|
|
122
|
+
if (msg.errorMessage) status.error = msg.errorMessage;
|
|
123
|
+
else if (msg.stopReason === "stop") status.error = null;
|
|
124
|
+
const text = assistantText(msg);
|
|
125
|
+
if (text) {
|
|
126
|
+
// Store the full latest text (truncated) so peek shows meaningful output;
|
|
127
|
+
// deriveSummary() condenses it to a first sentence for the row.
|
|
128
|
+
status.latestAssistantPreview = truncate(text, PREVIEW_MAX);
|
|
129
|
+
const nb = detectNeedsInput(text);
|
|
130
|
+
status.question = nb.question;
|
|
131
|
+
}
|
|
132
|
+
status.semanticState = "working";
|
|
133
|
+
preservePendingQuestion(status);
|
|
134
|
+
status.lastActivityAt = now;
|
|
135
|
+
status.lastAgentActivityAt = now;
|
|
136
|
+
meaningful = true;
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "agent_start":
|
|
141
|
+
case "turn_start":
|
|
142
|
+
case "turn_end":
|
|
143
|
+
status.lastActivityAt = now;
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (meaningful) status.summary = deriveSummary(status);
|
|
150
|
+
return meaningful;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Mark a run finished and compute its terminal state + summary.
|
|
155
|
+
* @param {RunStatus} status
|
|
156
|
+
* @param {{ exitCode:number|null, stoppedByUser?:boolean, openEnded?:boolean }} opts
|
|
157
|
+
* @param {number} now
|
|
158
|
+
* @returns {RunStatus}
|
|
159
|
+
*/
|
|
160
|
+
export function finalizeRun(status, opts, now) {
|
|
161
|
+
status.endedAt = now;
|
|
162
|
+
status.exitCode = opts.exitCode;
|
|
163
|
+
status.processState = "exited";
|
|
164
|
+
status.pid = null;
|
|
165
|
+
status.stoppedByUser = Boolean(opts.stoppedByUser);
|
|
166
|
+
status.currentTool = null;
|
|
167
|
+
|
|
168
|
+
const hadPendingQuestions = pendingQuestions(status).length > 0;
|
|
169
|
+
status.pendingQuestions = [];
|
|
170
|
+
const nb = detectNeedsInput(status.latestAssistantPreview);
|
|
171
|
+
// An exited interactive tool can no longer accept an answer. Keep only an
|
|
172
|
+
// independently detected assistant question, never the stale tool prompt.
|
|
173
|
+
if (hadPendingQuestions) status.question = nb.question;
|
|
174
|
+
const needsInput = nb.needsInput || Boolean(status.question);
|
|
175
|
+
if (needsInput && !status.question) status.question = nb.question;
|
|
176
|
+
|
|
177
|
+
status.semanticState = finalizeSemanticState({
|
|
178
|
+
exitCode: status.exitCode,
|
|
179
|
+
stopReason: status.stopReason,
|
|
180
|
+
stoppedByUser: status.stoppedByUser,
|
|
181
|
+
needsInput,
|
|
182
|
+
openEnded: opts.openEnded,
|
|
183
|
+
});
|
|
184
|
+
status.lastActivityAt = now;
|
|
185
|
+
status.summary = deriveSummary(status);
|
|
186
|
+
return status;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Project a RunStatus into the row-level ViewState written to `state.json`.
|
|
191
|
+
* @param {RunStatus} status
|
|
192
|
+
* @param {number} now
|
|
193
|
+
* @returns {import("./types.mjs").ViewState}
|
|
194
|
+
*/
|
|
195
|
+
export function projectViewState(status, now, previousState = null) {
|
|
196
|
+
return {
|
|
197
|
+
version: 1,
|
|
198
|
+
viewId: status.viewId,
|
|
199
|
+
currentRunId: status.runId,
|
|
200
|
+
semanticState: status.semanticState,
|
|
201
|
+
processState: status.processState,
|
|
202
|
+
summary: status.summary,
|
|
203
|
+
lastActivityAt: status.lastActivityAt,
|
|
204
|
+
updatedAt: now,
|
|
205
|
+
needsInput: status.semanticState === "needs_input",
|
|
206
|
+
hasError: status.semanticState === "failed",
|
|
207
|
+
latestAssistantPreview: status.latestAssistantPreview,
|
|
208
|
+
latestTool: status.currentTool ? { name: status.currentTool.name, path: status.currentTool.path } : null,
|
|
209
|
+
question: status.question,
|
|
210
|
+
pendingQuestions: pendingQuestions(status),
|
|
211
|
+
error: status.error,
|
|
212
|
+
lastVisitedAt: previousState?.lastVisitedAt ?? null,
|
|
213
|
+
lastAgentActivityAt: status.lastAgentActivityAt ?? previousState?.lastAgentActivityAt ?? null,
|
|
214
|
+
review: status.evidenceSummary ?? previousState?.review,
|
|
215
|
+
diagnostics: previousState?.diagnostics,
|
|
216
|
+
autoState: status.autoState ?? previousState?.autoState ?? null,
|
|
217
|
+
followUps: previousState?.followUps,
|
|
218
|
+
steering: previousState?.steering,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {import("./types.mjs").RunStatus} status */
|
|
223
|
+
function pendingQuestions(status) {
|
|
224
|
+
if (!Array.isArray(status.pendingQuestions)) status.pendingQuestions = [];
|
|
225
|
+
return status.pendingQuestions;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function questionFromArgs(args) {
|
|
229
|
+
for (const item of Array.isArray(args?.questions) ? args.questions : []) {
|
|
230
|
+
const question = String(item?.question ?? "").trim();
|
|
231
|
+
if (question) return question;
|
|
232
|
+
}
|
|
233
|
+
return "Answer the pending question";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function upsertPendingQuestion(status, toolCallId, question) {
|
|
237
|
+
const id = String(toolCallId ?? "ask_questions");
|
|
238
|
+
const pending = pendingQuestions(status);
|
|
239
|
+
const existing = pending.find((item) => item.toolCallId === id);
|
|
240
|
+
if (existing) existing.question = question;
|
|
241
|
+
else pending.push({ toolCallId: id, question });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function removePendingQuestion(status, toolCallId) {
|
|
245
|
+
const id = String(toolCallId ?? "ask_questions");
|
|
246
|
+
status.pendingQuestions = pendingQuestions(status).filter((item) => item.toolCallId !== id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function preservePendingQuestion(status) {
|
|
250
|
+
const first = pendingQuestions(status)[0];
|
|
251
|
+
if (!first) return;
|
|
252
|
+
status.currentTool = null;
|
|
253
|
+
status.semanticState = "needs_input";
|
|
254
|
+
status.question = first.question;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @param {import("./types.mjs").EvidenceUsage|null} current @param {any} usage */
|
|
258
|
+
function mergeUsage(current, usage) {
|
|
259
|
+
if (!usage || typeof usage !== "object") return current;
|
|
260
|
+
const input = Number(usage.inputTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
|
|
261
|
+
const output = Number(usage.outputTokens ?? usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
|
|
262
|
+
const total = Number(usage.totalTokens ?? usage.total_tokens ?? input + output) || input + output;
|
|
263
|
+
return {
|
|
264
|
+
inputTokens: (current?.inputTokens ?? 0) + input,
|
|
265
|
+
outputTokens: (current?.outputTokens ?? 0) + output,
|
|
266
|
+
totalTokens: (current?.totalTokens ?? 0) + total,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/** Review evidence extraction and persistence helpers. */
|
|
2
|
+
import { assistantText, classifyCommand, toolFileOperation, truncate } from "./heuristics.mjs";
|
|
3
|
+
import { atomicWriteJson, readJson } from "./atomic.mjs";
|
|
4
|
+
import * as P from "./paths.mjs";
|
|
5
|
+
|
|
6
|
+
/** @param {{ viewId:string, runId?:string|null, now?:number, source?:string }} opts @returns {import("./types.mjs").EvidenceSnapshot} */
|
|
7
|
+
export function emptyEvidenceSnapshot(opts) {
|
|
8
|
+
const now = opts.now ?? Date.now();
|
|
9
|
+
return {
|
|
10
|
+
version: 1,
|
|
11
|
+
viewId: opts.viewId,
|
|
12
|
+
runId: opts.runId ?? null,
|
|
13
|
+
updatedAt: now,
|
|
14
|
+
outcome: "unknown",
|
|
15
|
+
ready: false,
|
|
16
|
+
summary: "",
|
|
17
|
+
commands: [],
|
|
18
|
+
fileChanges: [],
|
|
19
|
+
errors: [],
|
|
20
|
+
assistantEvidence: [],
|
|
21
|
+
usage: null,
|
|
22
|
+
eventCount: 0,
|
|
23
|
+
source: opts.source ?? "events",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {any} snapshot @param {{ viewId:string, runId?:string|null }} fallback @returns {import("./types.mjs").EvidenceSnapshot} */
|
|
28
|
+
export function normalizeEvidenceSnapshot(snapshot, fallback) {
|
|
29
|
+
const base = emptyEvidenceSnapshot({ viewId: fallback.viewId, runId: fallback.runId ?? null });
|
|
30
|
+
if (!snapshot || typeof snapshot !== "object") return base;
|
|
31
|
+
return {
|
|
32
|
+
...base,
|
|
33
|
+
...snapshot,
|
|
34
|
+
viewId: typeof snapshot.viewId === "string" ? snapshot.viewId : base.viewId,
|
|
35
|
+
runId: typeof snapshot.runId === "string" ? snapshot.runId : (snapshot.runId === null ? null : base.runId),
|
|
36
|
+
commands: Array.isArray(snapshot.commands) ? snapshot.commands : [],
|
|
37
|
+
fileChanges: Array.isArray(snapshot.fileChanges) ? snapshot.fileChanges : [],
|
|
38
|
+
errors: Array.isArray(snapshot.errors) ? snapshot.errors : [],
|
|
39
|
+
assistantEvidence: Array.isArray(snapshot.assistantEvidence) ? snapshot.assistantEvidence : [],
|
|
40
|
+
usage: snapshot.usage && typeof snapshot.usage === "object" ? snapshot.usage : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} root @param {string} viewId */
|
|
45
|
+
export function readEvidence(root, viewId) {
|
|
46
|
+
return normalizeEvidenceSnapshot(readJson(P.evidencePath(root, viewId), null), { viewId });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {string} root @param {import("./types.mjs").EvidenceSnapshot} snapshot */
|
|
50
|
+
export function writeEvidence(root, snapshot) {
|
|
51
|
+
const normalized = normalizeEvidenceSnapshot(snapshot, { viewId: snapshot.viewId, runId: snapshot.runId ?? null });
|
|
52
|
+
normalized.updatedAt = Date.now();
|
|
53
|
+
atomicWriteJson(P.evidencePath(root, normalized.viewId), normalized);
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
58
|
+
export function readRunEvidence(root, viewId, runId) {
|
|
59
|
+
return normalizeEvidenceSnapshot(readJson(P.runEvidencePath(root, viewId, runId), null), { viewId, runId });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} root @param {import("./types.mjs").EvidenceSnapshot} snapshot */
|
|
63
|
+
export function writeRunEvidence(root, snapshot) {
|
|
64
|
+
const normalized = normalizeEvidenceSnapshot(snapshot, { viewId: snapshot.viewId, runId: snapshot.runId ?? null });
|
|
65
|
+
normalized.updatedAt = Date.now();
|
|
66
|
+
if (normalized.runId) atomicWriteJson(P.runEvidencePath(root, normalized.viewId, normalized.runId), normalized);
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @returns {import("./types.mjs").ReviewSummary} */
|
|
71
|
+
export function summarizeEvidence(snapshot) {
|
|
72
|
+
const s = normalizeEvidenceSnapshot(snapshot, { viewId: snapshot?.viewId ?? "" });
|
|
73
|
+
const failedCommandCount = s.commands.filter((c) => c.status === "failed").length;
|
|
74
|
+
const latestAssistantEvidence = s.assistantEvidence[s.assistantEvidence.length - 1]?.text ?? "";
|
|
75
|
+
return {
|
|
76
|
+
ready: Boolean(s.ready),
|
|
77
|
+
outcome: s.outcome ?? "unknown",
|
|
78
|
+
fileChangeCount: s.fileChanges.length,
|
|
79
|
+
commandCount: s.commands.length,
|
|
80
|
+
failedCommandCount,
|
|
81
|
+
errorCount: s.errors.length,
|
|
82
|
+
latestAssistantEvidence: truncate(latestAssistantEvidence, 180),
|
|
83
|
+
updatedAt: s.updatedAt ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @param {Partial<import("./types.mjs").EvidenceCommand>} command */
|
|
88
|
+
export function upsertEvidenceCommand(snapshot, command) {
|
|
89
|
+
const id = command.id ?? `cmd_${snapshot.commands.length + 1}`;
|
|
90
|
+
const existing = snapshot.commands.find((c) => c.id === id);
|
|
91
|
+
const commandText = String(command.command || existing?.command || "");
|
|
92
|
+
const next = {
|
|
93
|
+
id,
|
|
94
|
+
at: command.at ?? existing?.at ?? Date.now(),
|
|
95
|
+
command: commandText,
|
|
96
|
+
kind: command.kind ?? existing?.kind ?? classifyCommand(commandText),
|
|
97
|
+
status: command.status ?? existing?.status ?? "unknown",
|
|
98
|
+
exitCode: command.exitCode ?? existing?.exitCode ?? null,
|
|
99
|
+
durationMs: command.durationMs ?? existing?.durationMs ?? null,
|
|
100
|
+
outputPreview: command.outputPreview ?? existing?.outputPreview ?? "",
|
|
101
|
+
};
|
|
102
|
+
if (existing) Object.assign(existing, next);
|
|
103
|
+
else snapshot.commands.push(next);
|
|
104
|
+
snapshot.updatedAt = Date.now();
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @param {Partial<import("./types.mjs").EvidenceFileChange>} change */
|
|
109
|
+
export function upsertEvidenceFileChange(snapshot, change) {
|
|
110
|
+
const p = String(change.path ?? "").trim();
|
|
111
|
+
if (!p) return null;
|
|
112
|
+
const existing = snapshot.fileChanges.find((f) => f.path === p);
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (existing) {
|
|
115
|
+
existing.lastSeenAt = now;
|
|
116
|
+
existing.count = (existing.count ?? 0) + 1;
|
|
117
|
+
if (change.action) existing.action = change.action;
|
|
118
|
+
if (change.toolName) existing.toolName = change.toolName;
|
|
119
|
+
snapshot.updatedAt = now;
|
|
120
|
+
return existing;
|
|
121
|
+
}
|
|
122
|
+
const next = {
|
|
123
|
+
path: p,
|
|
124
|
+
action: change.action ?? "unknown",
|
|
125
|
+
toolName: change.toolName ?? null,
|
|
126
|
+
firstSeenAt: change.firstSeenAt ?? now,
|
|
127
|
+
lastSeenAt: change.lastSeenAt ?? now,
|
|
128
|
+
count: change.count ?? 1,
|
|
129
|
+
};
|
|
130
|
+
snapshot.fileChanges.push(next);
|
|
131
|
+
snapshot.updatedAt = now;
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @param {Partial<import("./types.mjs").EvidenceError>} error */
|
|
136
|
+
export function recordEvidenceError(snapshot, error) {
|
|
137
|
+
const next = {
|
|
138
|
+
at: error.at ?? Date.now(),
|
|
139
|
+
source: error.source ?? "event",
|
|
140
|
+
message: String(error.message ?? "Unknown error"),
|
|
141
|
+
toolName: error.toolName ?? null,
|
|
142
|
+
};
|
|
143
|
+
snapshot.errors.push(next);
|
|
144
|
+
snapshot.updatedAt = next.at;
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @param {string} text @param {{ at?:number }} [opts] */
|
|
149
|
+
export function recordAssistantEvidence(snapshot, text, opts = {}) {
|
|
150
|
+
const cleaned = String(text || "").replace(/\s+/g, " ").trim();
|
|
151
|
+
if (!cleaned) return null;
|
|
152
|
+
const next = { at: opts.at ?? Date.now(), text: truncate(cleaned, 12_000) };
|
|
153
|
+
snapshot.assistantEvidence.push(next);
|
|
154
|
+
snapshot.summary = truncate(cleaned, 240);
|
|
155
|
+
snapshot.updatedAt = next.at;
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} base @param {import("./types.mjs").EvidenceSnapshot} incoming */
|
|
160
|
+
export function mergeEvidenceSnapshot(base, incoming) {
|
|
161
|
+
for (const c of incoming.commands ?? []) upsertEvidenceCommand(base, c);
|
|
162
|
+
for (const f of incoming.fileChanges ?? []) upsertEvidenceFileChange(base, f);
|
|
163
|
+
for (const e of incoming.errors ?? []) recordEvidenceError(base, e);
|
|
164
|
+
for (const a of incoming.assistantEvidence ?? []) recordAssistantEvidence(base, a.text, { at: a.at });
|
|
165
|
+
base.outcome = incoming.outcome ?? base.outcome;
|
|
166
|
+
base.ready = Boolean(incoming.ready || base.ready);
|
|
167
|
+
base.usage = mergeUsage(base.usage, incoming.usage);
|
|
168
|
+
base.updatedAt = Date.now();
|
|
169
|
+
return base;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @param {any} event @param {number} now */
|
|
173
|
+
export function reduceEvidence(snapshot, event, now = Date.now()) {
|
|
174
|
+
if (!event || typeof event !== "object") return false;
|
|
175
|
+
snapshot.eventCount = (snapshot.eventCount ?? 0) + 1;
|
|
176
|
+
let changed = false;
|
|
177
|
+
if (event.type === "tool_execution_start") {
|
|
178
|
+
const name = event.toolName ?? event.args?.name ?? "tool";
|
|
179
|
+
const args = event.args ?? {};
|
|
180
|
+
if (name === "bash" && typeof args.command === "string") {
|
|
181
|
+
upsertEvidenceCommand(snapshot, { id: event.toolCallId, at: now, command: args.command, kind: classifyCommand(args.command), status: "started" });
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
const fileOp = toolFileOperation(name, args);
|
|
185
|
+
if (fileOp) {
|
|
186
|
+
upsertEvidenceFileChange(snapshot, { path: fileOp.path, action: fileOp.action, toolName: name, firstSeenAt: now, lastSeenAt: now });
|
|
187
|
+
changed = true;
|
|
188
|
+
}
|
|
189
|
+
} else if (event.type === "tool_execution_end") {
|
|
190
|
+
const name = event.toolName ?? "tool";
|
|
191
|
+
if (name === "bash") {
|
|
192
|
+
upsertEvidenceCommand(snapshot, {
|
|
193
|
+
id: event.toolCallId,
|
|
194
|
+
at: now,
|
|
195
|
+
command: event.args?.command ?? "",
|
|
196
|
+
status: event.isError ? "failed" : "passed",
|
|
197
|
+
exitCode: event.isError ? 1 : 0,
|
|
198
|
+
outputPreview: truncate(String(event.result ?? ""), 500),
|
|
199
|
+
});
|
|
200
|
+
changed = true;
|
|
201
|
+
}
|
|
202
|
+
if (event.isError) {
|
|
203
|
+
recordEvidenceError(snapshot, { at: now, source: "tool", toolName: name, message: `Tool ${name} failed` });
|
|
204
|
+
changed = true;
|
|
205
|
+
}
|
|
206
|
+
} else if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
207
|
+
const text = assistantText(event.message);
|
|
208
|
+
if (text) {
|
|
209
|
+
recordAssistantEvidence(snapshot, text, { at: now });
|
|
210
|
+
changed = true;
|
|
211
|
+
}
|
|
212
|
+
snapshot.usage = mergeUsage(snapshot.usage, event.message?.usage ?? event.usage ?? null);
|
|
213
|
+
} else if (event.usage) {
|
|
214
|
+
snapshot.usage = mergeUsage(snapshot.usage, event.usage);
|
|
215
|
+
changed = true;
|
|
216
|
+
}
|
|
217
|
+
if (changed) snapshot.updatedAt = now;
|
|
218
|
+
return changed;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {import("./types.mjs").EvidenceSnapshot} snapshot @param {import("./types.mjs").RunStatus} status @param {number} now */
|
|
222
|
+
export function finalizeEvidence(snapshot, status, now = Date.now()) {
|
|
223
|
+
snapshot.outcome = status.semanticState === "idle" ? "ready" : status.semanticState;
|
|
224
|
+
snapshot.ready = status.semanticState === "idle" || status.semanticState === "completed";
|
|
225
|
+
snapshot.usage = mergeUsage(snapshot.usage, status.usage ?? null);
|
|
226
|
+
snapshot.updatedAt = now;
|
|
227
|
+
return snapshot;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {import("./types.mjs").EvidenceUsage|null} a @param {any} b */
|
|
231
|
+
function mergeUsage(a, b) {
|
|
232
|
+
if (!b || typeof b !== "object") return a ?? null;
|
|
233
|
+
const current = a ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
234
|
+
const input = Number(b.inputTokens ?? b.input_tokens ?? b.prompt_tokens ?? 0) || 0;
|
|
235
|
+
const output = Number(b.outputTokens ?? b.output_tokens ?? b.completion_tokens ?? 0) || 0;
|
|
236
|
+
const total = Number(b.totalTokens ?? b.total_tokens ?? input + output) || input + output;
|
|
237
|
+
return {
|
|
238
|
+
inputTokens: (current.inputTokens ?? 0) + input,
|
|
239
|
+
outputTokens: (current.outputTokens ?? 0) + output,
|
|
240
|
+
totalTokens: (current.totalTokens ?? 0) + total,
|
|
241
|
+
};
|
|
242
|
+
}
|