paseo-bm-plugin 0.0.0-placeholder.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn collector: the only writer of the trace store (WP-205, Dashboard Design
|
|
3
|
+
* §2.2 decision 1, §3.3, §12).
|
|
4
|
+
*
|
|
5
|
+
* It hangs off Paseo's own lifecycle hooks — `agent.turn_started` for the start
|
|
6
|
+
* mark and `agent.turn_ended` for the record — so there is no timer, no watcher
|
|
7
|
+
* and no polling loop; the "no background work" rule of the base design §8
|
|
8
|
+
* still holds. The plugin already uses `agent.turn_ended` for stop propagation
|
|
9
|
+
* (bm-wq6), so this is a known-good seam.
|
|
10
|
+
*
|
|
11
|
+
* The hard rule here is that this code runs **inside a real agent's turn**:
|
|
12
|
+
* nothing it does may throw into the host. Out of disk space, no permission, a
|
|
13
|
+
* store written by a newer paseo-bm, a lock it could not get in five seconds —
|
|
14
|
+
* all of them end the same way: the turn is dropped, one line is logged, and
|
|
15
|
+
* the agent is untouched (REQ-053c).
|
|
16
|
+
*
|
|
17
|
+
* SDK facts this relies on (checked against @getpaseo/plugin 0.8.0
|
|
18
|
+
* `server/lifecycle.d.ts` and @getpaseo/client 0.8.0, not guessed):
|
|
19
|
+
* - `on("agent.turn_started" | "agent.turn_ended", (event, { paseo }) => …)`;
|
|
20
|
+
* the ended event carries `agent`, `turnId`, `outcome.kind` and `timeline`.
|
|
21
|
+
* - **The hook `timeline` is the agent's WHOLE timeline with the timestamps
|
|
22
|
+
* stripped.** Verified against the shipped daemon (Paseo 0.8.0, WP-205.2):
|
|
23
|
+
* `publishAgentStream(..., this.timelineStore.getItems(agentId))` and
|
|
24
|
+
* `getItems(agentId) { return this.requireState(agentId).rows.map(row => row.item) }`
|
|
25
|
+
* — the store keeps `{ seq, timestamp, turnId, item }` rows but the hook only
|
|
26
|
+
* receives `row.item`, and it receives every row, not the ended turn's rows.
|
|
27
|
+
* That is assumptions A-1/A-2 of the PRD, and it decides the design here: the
|
|
28
|
+
* turn's own items come from one `timeline.refetch()` call, whose entries do
|
|
29
|
+
* carry `turnId` and `timestamp`, and the hook payload is only a fallback.
|
|
30
|
+
* Using the hook payload as the turn would put the entire conversation into
|
|
31
|
+
* every record and parse every old report again.
|
|
32
|
+
* - the `refetch` payload also carries an `agent` snapshot whose `lastUsage`
|
|
33
|
+
* gives tokens and (sometimes) cost, which is where a record's `usage` comes
|
|
34
|
+
* from.
|
|
35
|
+
*/
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
37
|
+
import { homedir } from "node:os";
|
|
38
|
+
import { basename } from "node:path";
|
|
39
|
+
import type { PluginLifecycleEvents, PluginServerContext } from "@getpaseo/plugin/server";
|
|
40
|
+
import { parseReports, parseReviews, requestIdFromText } from "./bm-report";
|
|
41
|
+
import { isPluginNotice } from "./notices";
|
|
42
|
+
import { stripNewRequestMarker } from "../shared/new-request";
|
|
43
|
+
import { resolveInstallHome } from "./install-home";
|
|
44
|
+
import { roleOfProvider } from "./agent-role";
|
|
45
|
+
import { providerId } from "./provider-id";
|
|
46
|
+
import {
|
|
47
|
+
TraceStoreLockTimeout,
|
|
48
|
+
appendRecord,
|
|
49
|
+
writeWorkspaceMeta,
|
|
50
|
+
type TraceStoreLocation,
|
|
51
|
+
} from "./trace-store";
|
|
52
|
+
import {
|
|
53
|
+
TRACE_STORE_SCHEMA_VERSION,
|
|
54
|
+
type Evidence,
|
|
55
|
+
type ParsedReport,
|
|
56
|
+
type ParsedReview,
|
|
57
|
+
type TraceMessage,
|
|
58
|
+
type TraceRecord,
|
|
59
|
+
type TraceRuntime,
|
|
60
|
+
type Usage,
|
|
61
|
+
} from "../shared/contracts";
|
|
62
|
+
|
|
63
|
+
/** Timeline entries read back per turn when timestamps have to be recovered. */
|
|
64
|
+
export const REFETCH_LIMIT = 200;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Secret masking, applied **before** anything reaches the disk (REQ-048b).
|
|
68
|
+
*
|
|
69
|
+
* Same rule set as the CLI's `src/redact.ts` (Design §7): the *values* of these
|
|
70
|
+
* environment variables, and the value following a password-shaped flag. The
|
|
71
|
+
* plugin payload cannot import `src/`, so the constants are duplicated; they
|
|
72
|
+
* are a short closed list by design.
|
|
73
|
+
*/
|
|
74
|
+
export const SECRET_ENV_VARS: readonly string[] = ["PASEO_PASSWORD", "PASEO_DAEMON_PASSWORD"];
|
|
75
|
+
export const SECRET_ARGV_FLAGS: readonly string[] = ["--password", "--token", "--secret"];
|
|
76
|
+
export const REDACTED = "[redacted]";
|
|
77
|
+
|
|
78
|
+
/** Masks every known secret in a string. Never lengthens it beyond the mask. */
|
|
79
|
+
export function redactText(text: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
80
|
+
if (typeof text !== "string" || text === "") return "";
|
|
81
|
+
let out = text;
|
|
82
|
+
for (const name of SECRET_ENV_VARS) {
|
|
83
|
+
const value = env[name];
|
|
84
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
85
|
+
out = out.split(value).join(REDACTED);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const flag of SECRET_ARGV_FLAGS) {
|
|
89
|
+
// `--password secret`, `--password=secret` and `--password "secret"`.
|
|
90
|
+
const pattern = new RegExp(`(${flag})(\\s*=\\s*|\\s+)("[^"]*"|'[^']*'|\\S+)`, "gi");
|
|
91
|
+
out = out.replace(pattern, `$1$2${REDACTED}`);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type TurnEndedEvent = PluginLifecycleEvents["agent.turn_ended"];
|
|
97
|
+
type TurnStartedEvent = PluginLifecycleEvents["agent.turn_started"];
|
|
98
|
+
type TimelineItem = TurnEndedEvent["timeline"][number];
|
|
99
|
+
|
|
100
|
+
/** One timeline entry with the timestamp the store needs. */
|
|
101
|
+
export interface TimedItem {
|
|
102
|
+
item: TimelineItem;
|
|
103
|
+
at: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The slice of the SDK the collector uses to recover timestamps and usage. */
|
|
107
|
+
export interface CollectorPaseo {
|
|
108
|
+
agents: {
|
|
109
|
+
ref(agentId: string): {
|
|
110
|
+
timeline: {
|
|
111
|
+
refetch(options: { direction?: string; limit?: number }): Promise<unknown>;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CollectorDeps {
|
|
118
|
+
/** Resolved trace store, or null when tracing is disabled (WP-202). */
|
|
119
|
+
location: TraceStoreLocation | null;
|
|
120
|
+
paseo?: CollectorPaseo;
|
|
121
|
+
now?: () => Date;
|
|
122
|
+
env?: NodeJS.ProcessEnv;
|
|
123
|
+
log?: (message: string) => void;
|
|
124
|
+
/** How long to wait for the workspace lock before dropping the turn (design §3.8). */
|
|
125
|
+
lockTimeoutMs?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function textOf(item: TimelineItem): string | null {
|
|
129
|
+
if (item.type === "user_message" || item.type === "assistant_message") {
|
|
130
|
+
return typeof item.text === "string" ? item.text : null;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The tail of a whole-conversation timeline that plausibly belongs to the turn
|
|
137
|
+
* that just ended: from the last `user_message` to the end.
|
|
138
|
+
*
|
|
139
|
+
* Used when there is no turn boundary to filter on: the hook payload carries
|
|
140
|
+
* none, and a refetch whose `turnId` is null has none either. A turn begins
|
|
141
|
+
* with a user message, so the slice is at most one turn.
|
|
142
|
+
*/
|
|
143
|
+
export function sliceLastTurn<T>(items: readonly T[], itemOf: (entry: T) => unknown = (entry) => entry): T[] {
|
|
144
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
145
|
+
const item = itemOf(items[index]!) as { type?: unknown } | null | undefined;
|
|
146
|
+
if (item?.type === "user_message") return items.slice(index);
|
|
147
|
+
}
|
|
148
|
+
return [...items];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `…/<name>/SKILL.md` → `<name>`. */
|
|
152
|
+
const SKILL_FILE = /([A-Za-z0-9._-]+)\/SKILL\.md\b/;
|
|
153
|
+
/** Commands that read a file's content; `ls`, `test -f` and `find` only look. */
|
|
154
|
+
const READS_FILE = /^(?:cat|sed|head|tail|less|more|bat|nl)\b/;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Skills an item loaded.
|
|
158
|
+
*
|
|
159
|
+
* Verified on Paseo 0.8 with Claude Code: a load is a `tool_call` named `Skill`
|
|
160
|
+
* whose `plain_text` detail carries the skill name as `label` ("Launching skill:
|
|
161
|
+
* feature-workflow"). Other providers read the skill's `SKILL.md`; a read of
|
|
162
|
+
* that file counts, a directory listing or an existence check does not (the
|
|
163
|
+
* Manager checks skills that way without loading them).
|
|
164
|
+
*/
|
|
165
|
+
export function skillsFromItem(item: TimelineItem): string[] {
|
|
166
|
+
if (item.type !== "tool_call") return [];
|
|
167
|
+
const call = item as { name?: unknown; detail?: { type?: string; label?: unknown; filePath?: unknown; command?: unknown } };
|
|
168
|
+
const detail = call.detail;
|
|
169
|
+
if (call.name === "Skill") {
|
|
170
|
+
const label = detail?.label;
|
|
171
|
+
return typeof label === "string" && label.trim() !== "" ? [label.trim()] : [];
|
|
172
|
+
}
|
|
173
|
+
if (detail?.type === "read" && typeof detail.filePath === "string") {
|
|
174
|
+
const found = SKILL_FILE.exec(detail.filePath);
|
|
175
|
+
return found === null ? [] : [found[1]!];
|
|
176
|
+
}
|
|
177
|
+
if (detail?.type === "shell" && typeof detail.command === "string") {
|
|
178
|
+
const out: string[] = [];
|
|
179
|
+
for (const segment of detail.command.split(/&&|\|\||[;\n|]/)) {
|
|
180
|
+
const trimmed = segment.trim();
|
|
181
|
+
if (!READS_FILE.test(trimmed)) continue;
|
|
182
|
+
const found = SKILL_FILE.exec(trimmed);
|
|
183
|
+
if (found !== null) out.push(found[1]!);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Evidence a turn leaves behind: skills loaded, commands run, files written,
|
|
192
|
+
* sub-agents started.
|
|
193
|
+
*
|
|
194
|
+
* This is the raw material for "did this request create beads?" (REQ-044b) and
|
|
195
|
+
* the workflow table (REQ-045), so it records what was *observed*, never a
|
|
196
|
+
* conclusion drawn from it.
|
|
197
|
+
*/
|
|
198
|
+
export function evidenceFromItem(item: TimelineItem, agentId: string, at: string): Evidence[] {
|
|
199
|
+
if (item.type !== "tool_call") return [];
|
|
200
|
+
const out: Evidence[] = skillsFromItem(item).map((skill) => ({ kind: "skill", detail: skill, agentId, at }));
|
|
201
|
+
const detail = (item as { detail?: Record<string, unknown> }).detail;
|
|
202
|
+
const text = (key: string): string => {
|
|
203
|
+
const value = detail?.[key];
|
|
204
|
+
return typeof value === "string" ? value.trim() : "";
|
|
205
|
+
};
|
|
206
|
+
switch (detail?.["type"]) {
|
|
207
|
+
case "shell":
|
|
208
|
+
if (text("command") !== "") out.push({ kind: "shell", detail: text("command"), agentId, at });
|
|
209
|
+
break;
|
|
210
|
+
case "edit":
|
|
211
|
+
case "write":
|
|
212
|
+
if (text("filePath") !== "") out.push({ kind: "file", detail: text("filePath"), agentId, at });
|
|
213
|
+
break;
|
|
214
|
+
case "sub_agent": {
|
|
215
|
+
const label = [text("subAgentType"), text("description")].filter((part) => part !== "").join(" — ");
|
|
216
|
+
out.push({ kind: "agent", detail: label === "" ? "sub_agent" : label, agentId, at });
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface RefetchEntry {
|
|
224
|
+
item: unknown;
|
|
225
|
+
turnId?: string;
|
|
226
|
+
timestamp?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const nonEmpty = (value: unknown): string | null => (typeof value === "string" && value.trim() !== "" ? value : null);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* What the agent ran on this turn, from the snapshot `timeline.refetch` returns
|
|
233
|
+
* (delta 20260918 §4.2). The provider's running values (`runtimeInfo`) come
|
|
234
|
+
* first and the snapshot's configuration only fills in what they leave empty —
|
|
235
|
+
* a provider-reported `null` never hides a real fallback. `null` when there is
|
|
236
|
+
* no snapshot. Never throws: a malformed snapshot costs the fields, not the turn.
|
|
237
|
+
*
|
|
238
|
+
* `provider` (delta 20260921 §4.2.7, F7) is the snapshot's own `provider`,
|
|
239
|
+
* reduced to the provider id the way every other paseo-bm reader does
|
|
240
|
+
* (`bm-worker/<model>` → `bm-worker`), so a later price lookup can hand it to
|
|
241
|
+
* `providers.listModels` as is. `null` when the snapshot names none.
|
|
242
|
+
*/
|
|
243
|
+
export function runtimeOf(snapshot: unknown): TraceRuntime | null {
|
|
244
|
+
try {
|
|
245
|
+
if (snapshot === null || typeof snapshot !== "object") return null;
|
|
246
|
+
const agent = snapshot as Record<string, unknown>;
|
|
247
|
+
const info = (agent["runtimeInfo"] !== null && typeof agent["runtimeInfo"] === "object" ? agent["runtimeInfo"] : {}) as Record<string, unknown>;
|
|
248
|
+
return {
|
|
249
|
+
model: nonEmpty(info["model"]) ?? nonEmpty(agent["model"]),
|
|
250
|
+
thinkingOptionId:
|
|
251
|
+
nonEmpty(info["thinkingOptionId"]) ?? nonEmpty(agent["effectiveThinkingOptionId"]) ?? nonEmpty(agent["thinkingOptionId"]),
|
|
252
|
+
modeId: nonEmpty(info["modeId"]) ?? nonEmpty(agent["currentModeId"]),
|
|
253
|
+
provider: nonEmpty(providerId(agent["provider"])),
|
|
254
|
+
};
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Reads `timestamp`s back for one turn with a single `refetch` call (assumption A-2). */
|
|
261
|
+
export async function timestampsForTurn(
|
|
262
|
+
deps: CollectorDeps,
|
|
263
|
+
agentId: string,
|
|
264
|
+
turnId: string | null,
|
|
265
|
+
): Promise<{ entries: RefetchEntry[]; usage: Usage | null; requestIdLabel: string | null; runtime: TraceRuntime | null }> {
|
|
266
|
+
if (deps.paseo === undefined) return { entries: [], usage: null, requestIdLabel: null, runtime: null };
|
|
267
|
+
let payload: unknown;
|
|
268
|
+
try {
|
|
269
|
+
payload = await deps.paseo.agents.ref(agentId).timeline.refetch({
|
|
270
|
+
direction: "tail",
|
|
271
|
+
limit: REFETCH_LIMIT,
|
|
272
|
+
});
|
|
273
|
+
} catch {
|
|
274
|
+
return { entries: [], usage: null, requestIdLabel: null, runtime: null };
|
|
275
|
+
}
|
|
276
|
+
const asRecord = payload as { entries?: unknown; agent?: unknown } | null;
|
|
277
|
+
const rawEntries = Array.isArray(asRecord?.entries) ? (asRecord?.entries as RefetchEntry[]) : [];
|
|
278
|
+
// A turn id selects the turn exactly. Without one, the entries are cut down
|
|
279
|
+
// the same way the hook payload is — everything from the last user message on.
|
|
280
|
+
//
|
|
281
|
+
// WP-214 acceptance, defect 10: returning `rawEntries` here recorded the
|
|
282
|
+
// ENTIRE conversation in one record. On the acceptance workspace that record
|
|
283
|
+
// held 13 inbound messages and 10 reports, and because its first message was
|
|
284
|
+
// F-1's request it folded into F-1 and multiplied that request's tokens.
|
|
285
|
+
const entries =
|
|
286
|
+
turnId === null
|
|
287
|
+
? sliceLastTurn(rawEntries, (entry) => entry.item)
|
|
288
|
+
: rawEntries.filter((entry) => entry.turnId === turnId);
|
|
289
|
+
const snapshot = asRecord?.agent as
|
|
290
|
+
| { lastUsage?: Record<string, unknown>; model?: unknown; labels?: Record<string, unknown> }
|
|
291
|
+
| null;
|
|
292
|
+
// The agent's own `bm.requestId` label, set when it was created. A Worker's
|
|
293
|
+
// later turns rarely repeat the id in their text, so without this its records
|
|
294
|
+
// carry no request id and are lost from the trace once the agent is deleted
|
|
295
|
+
// (WP-214, D-8's delete leg: 14 of 14 F-1 Worker records had none).
|
|
296
|
+
const label = snapshot?.labels?.["bm.requestId"];
|
|
297
|
+
const requestIdLabel = typeof label === "string" && label !== "" ? label : null;
|
|
298
|
+
const lastUsage = snapshot?.lastUsage;
|
|
299
|
+
const usage: Usage | null =
|
|
300
|
+
lastUsage === undefined || lastUsage === null
|
|
301
|
+
? null
|
|
302
|
+
: {
|
|
303
|
+
inputTokens: Number(lastUsage["inputTokens"] ?? 0) || 0,
|
|
304
|
+
cachedInputTokens: Number(lastUsage["cachedInputTokens"] ?? 0) || 0,
|
|
305
|
+
outputTokens: Number(lastUsage["outputTokens"] ?? 0) || 0,
|
|
306
|
+
// `lastUsage.totalCostUsd` is the agent's running SESSION total, not
|
|
307
|
+
// the cost of this turn: across the twelve Manager turns of the
|
|
308
|
+
// WP-214 acceptance run it rose monotonically (0.3956 → 0.4492 → …
|
|
309
|
+
// → 2.2712) while the token counts beside it went up and down per
|
|
310
|
+
// turn. Summing it over a request's turns would multiply the bill,
|
|
311
|
+
// so a per-turn record never claims a provider cost and the request
|
|
312
|
+
// cost is estimated from the per-turn tokens instead (defect 11).
|
|
313
|
+
costUsd: null,
|
|
314
|
+
costBasis: "unavailable",
|
|
315
|
+
model: typeof snapshot?.model === "string" ? (snapshot.model as string) : null,
|
|
316
|
+
pricesUpdatedAt: null,
|
|
317
|
+
};
|
|
318
|
+
return { entries, usage, requestIdLabel, runtime: runtimeOf(asRecord?.agent) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** In-memory start marks, keyed by agent and turn. Lost on reload, which is fine. */
|
|
322
|
+
const startMarks = new Map<string, string>();
|
|
323
|
+
|
|
324
|
+
function markKey(agentId: string, turnId: string | null): string {
|
|
325
|
+
return `${agentId}::${turnId ?? ""}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Records a turn's start time. */
|
|
329
|
+
export function noteTurnStart(event: TurnStartedEvent, now: () => Date = () => new Date()): void {
|
|
330
|
+
// Only paseo-bm's agents are collected, fallback aliases included (delta 20260921 §4.4.1).
|
|
331
|
+
if (roleOfProvider(event.agent.provider) === null) return;
|
|
332
|
+
startMarks.set(markKey(event.agent.id, event.turnId), now().toISOString());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Test helper: forget every start mark. */
|
|
336
|
+
export function clearStartMarks(): void {
|
|
337
|
+
startMarks.clear();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `Continue req-…` at the very start of a relay prompt: the only form
|
|
342
|
+
* `roles/manager.md` uses to resume a Worker (delta 20260917 §5.3). Anchored so
|
|
343
|
+
* a prompt that merely mentions another request is never read as resuming it.
|
|
344
|
+
* Markdown around the keyword or the id (`**Continue**`, a backticked id) is
|
|
345
|
+
* tolerated: models add it, and the WP-214 run lost exact links to exactly that.
|
|
346
|
+
*/
|
|
347
|
+
const RELAY_REQUEST_ID = /^\s*[*_`]*Continue[*_`]*\s+[*_`]*(req-\d{8}T\d{6}Z)\b/;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The request a `send_agent_prompt` tool call resumes, or null. The tool name
|
|
351
|
+
* may carry an MCP prefix (`mcp__paseo__send_agent_prompt`), so only the part
|
|
352
|
+
* after the last `__` is compared.
|
|
353
|
+
*/
|
|
354
|
+
export function relayRequestIdOf(item: TimelineItem): string | null {
|
|
355
|
+
if (item.type !== "tool_call") return null;
|
|
356
|
+
const call = item as { name?: unknown; detail?: { input?: unknown } };
|
|
357
|
+
if (typeof call.name !== "string" || call.name.split("__").pop() !== "send_agent_prompt") return null;
|
|
358
|
+
const input = call.detail?.input;
|
|
359
|
+
const prompt = input !== null && typeof input === "object" ? (input as { prompt?: unknown }).prompt : undefined;
|
|
360
|
+
return typeof prompt === "string" ? (RELAY_REQUEST_ID.exec(prompt)?.[1] ?? null) : null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Builds the record for one ended turn. Pure apart from the timestamp lookup,
|
|
365
|
+
* so the assembly is testable without a store or a daemon.
|
|
366
|
+
*/
|
|
367
|
+
export async function buildRecord(
|
|
368
|
+
event: TurnEndedEvent,
|
|
369
|
+
deps: CollectorDeps,
|
|
370
|
+
): Promise<{ record: TraceRecord; workspaceName: string | null } | null> {
|
|
371
|
+
const role = roleOfProvider(event.agent.provider);
|
|
372
|
+
if (role === null) return null;
|
|
373
|
+
if (event.agent.workspaceId === null) return null;
|
|
374
|
+
|
|
375
|
+
const now = deps.now ?? (() => new Date());
|
|
376
|
+
const endedAt = now().toISOString();
|
|
377
|
+
const env = deps.env ?? process.env;
|
|
378
|
+
|
|
379
|
+
const items = Array.isArray(event.timeline) ? event.timeline : [];
|
|
380
|
+
const { entries, usage, requestIdLabel, runtime } = await timestampsForTurn(deps, event.agent.id, event.turnId);
|
|
381
|
+
|
|
382
|
+
// Preferred source: the refetch entries for this turn, which carry both the
|
|
383
|
+
// item and its timestamp. Fallback: the hook payload, which is the whole
|
|
384
|
+
// conversation, so it is cut down to the last user message onwards — a turn
|
|
385
|
+
// starts with one, and recording the entire history on every turn would
|
|
386
|
+
// duplicate it in every record.
|
|
387
|
+
const timed: TimedItem[] =
|
|
388
|
+
entries.length > 0
|
|
389
|
+
? entries
|
|
390
|
+
.filter((entry) => entry.item !== undefined && entry.item !== null)
|
|
391
|
+
.map((entry) => ({
|
|
392
|
+
item: entry.item as TimelineItem,
|
|
393
|
+
at: typeof entry.timestamp === "string" ? entry.timestamp : endedAt,
|
|
394
|
+
}))
|
|
395
|
+
: sliceLastTurn(items).map((item) => ({ item, at: endedAt }));
|
|
396
|
+
|
|
397
|
+
const sent: TraceMessage[] = [];
|
|
398
|
+
const received: TraceMessage[] = [];
|
|
399
|
+
const reports: ParsedReport[] = [];
|
|
400
|
+
const reviews: ParsedReview[] = [];
|
|
401
|
+
const evidence: Evidence[] = [];
|
|
402
|
+
let relayRequestId: string | null = null;
|
|
403
|
+
|
|
404
|
+
for (const { item, at } of timed) {
|
|
405
|
+
evidence.push(...evidenceFromItem(item, event.agent.id, at));
|
|
406
|
+
if (role === "manager" && relayRequestId === null) relayRequestId = relayRequestIdOf(item);
|
|
407
|
+
const text = textOf(item);
|
|
408
|
+
if (text === null) continue;
|
|
409
|
+
// `/bm-worker-new` prefixes the user's request with a flag line so the
|
|
410
|
+
// Manager knows not to fold it into whatever is already running. The flag is
|
|
411
|
+
// the plugin's, the words after it are the user's, so the flag comes off
|
|
412
|
+
// here and the message stays theirs (delta 20260917f §4.1).
|
|
413
|
+
const safe = stripNewRequestMarker(redactText(text, env));
|
|
414
|
+
const message: TraceMessage = { agentId: event.agent.id, at, text: safe, truncated: false };
|
|
415
|
+
if (item.type === "user_message") {
|
|
416
|
+
// Typed in Paseo's app → `clientMessageId`; sent by an agent → none.
|
|
417
|
+
// Verified on the owner's Manager: 15/15 typed vs 43/43 agent reports.
|
|
418
|
+
// The plugin's own notices carry a clientMessageId too (the SDK adds one),
|
|
419
|
+
// so they are recognised by their text instead (review b2).
|
|
420
|
+
message.origin =
|
|
421
|
+
typeof (item as { clientMessageId?: unknown }).clientMessageId === "string" && !isPluginNotice(safe)
|
|
422
|
+
? "user"
|
|
423
|
+
: "agent";
|
|
424
|
+
sent.push(message);
|
|
425
|
+
} else {
|
|
426
|
+
received.push(message);
|
|
427
|
+
}
|
|
428
|
+
reports.push(...parseReports(safe, { agentId: event.agent.id, at }));
|
|
429
|
+
reviews.push(...parseReviews(safe, { agentId: event.agent.id, at }));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const requestIdFromReports = reports.find((report) => report.requestId !== null)?.requestId ?? null;
|
|
433
|
+
// The same tolerant pattern the reconstruction uses: real prompts write the
|
|
434
|
+
// id as "- `requestId`: `req-…`" (WP-214 acceptance finding).
|
|
435
|
+
const requestIdFromPrompt = requestIdFromText(sent.map((message) => message.text).join("\n"));
|
|
436
|
+
|
|
437
|
+
const record: TraceRecord = {
|
|
438
|
+
v: TRACE_STORE_SCHEMA_VERSION,
|
|
439
|
+
kind: "turn",
|
|
440
|
+
at: endedAt,
|
|
441
|
+
workspaceId: event.agent.workspaceId,
|
|
442
|
+
agentId: event.agent.id,
|
|
443
|
+
role,
|
|
444
|
+
turnId: event.turnId,
|
|
445
|
+
// Last resort: the Manager's own `Continue <requestId>.` relay. A user's
|
|
446
|
+
// answer otherwise shows as its own request row until the next report.
|
|
447
|
+
requestId: requestIdLabel ?? requestIdFromReports ?? requestIdFromPrompt ?? relayRequestId,
|
|
448
|
+
parentAgentId: event.agent.parentAgentId,
|
|
449
|
+
agentCreatedAt: null,
|
|
450
|
+
startedAt: startMarks.get(markKey(event.agent.id, event.turnId)) ?? null,
|
|
451
|
+
endedAt,
|
|
452
|
+
outcome: event.outcome.kind,
|
|
453
|
+
sent,
|
|
454
|
+
received,
|
|
455
|
+
reports,
|
|
456
|
+
reviews,
|
|
457
|
+
evidence,
|
|
458
|
+
usage,
|
|
459
|
+
runtime,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const cwd = typeof event.agent.cwd === "string" && event.agent.cwd !== "" ? event.agent.cwd : null;
|
|
463
|
+
return { record, workspaceName: cwd === null ? null : basename(cwd) };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Collects one ended turn: build, append, refresh the workspace label.
|
|
468
|
+
*
|
|
469
|
+
* Every failure is contained here. A lock timeout is expected under load and
|
|
470
|
+
* only costs one trace; anything else is logged with its message so a support
|
|
471
|
+
* question has something to go on.
|
|
472
|
+
*/
|
|
473
|
+
export async function collectTurnEnded(event: TurnEndedEvent, deps: CollectorDeps): Promise<boolean> {
|
|
474
|
+
const log = deps.log ?? ((message: string) => console.warn(message));
|
|
475
|
+
try {
|
|
476
|
+
if (deps.location === null) return false;
|
|
477
|
+
const built = await buildRecord(event, deps);
|
|
478
|
+
if (built === null) return false;
|
|
479
|
+
|
|
480
|
+
await appendRecord(deps.location, built.record, { timeoutMs: deps.lockTimeoutMs });
|
|
481
|
+
startMarks.delete(markKey(event.agent.id, event.turnId));
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
writeWorkspaceMeta(deps.location, built.record.workspaceId, {
|
|
485
|
+
lastKnownName: built.workspaceName,
|
|
486
|
+
lastKnownDirectory: typeof event.agent.cwd === "string" ? event.agent.cwd : null,
|
|
487
|
+
lastSeenAt: built.record.at,
|
|
488
|
+
});
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// Losing the label only costs recognisability of an orphaned workspace.
|
|
491
|
+
log(`[paseo-bm] could not update trace workspace metadata: ${messageOf(error)}`);
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (error instanceof TraceStoreLockTimeout) {
|
|
496
|
+
log(`[paseo-bm] dropped one trace record: ${error.message}`);
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
log(`[paseo-bm] could not record a trace for agent ${event.agent.id}: ${messageOf(error)}`);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function messageOf(error: unknown): string {
|
|
505
|
+
return error instanceof Error ? error.message : String(error);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The part of the server context this needs; `on` is absent on older hosts. */
|
|
509
|
+
export type CollectorHost = Partial<Pick<PluginServerContext, "on" | "before">>;
|
|
510
|
+
|
|
511
|
+
export type LocationResolver = (paseo: unknown) => Promise<TraceStoreLocation | null>;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolves the trace store once and remembers it.
|
|
515
|
+
*
|
|
516
|
+
* `contribute()` has no `paseo`, only the hook context does, so the location
|
|
517
|
+
* cannot be resolved at registration time. A successful answer is cached
|
|
518
|
+
* forever (the install home does not move while the daemon runs); a failure is
|
|
519
|
+
* retried on the next turn, which costs one `config.get()`.
|
|
520
|
+
*/
|
|
521
|
+
export function createLocationResolver(
|
|
522
|
+
resolve: (paseo: unknown) => Promise<TraceStoreLocation | null>,
|
|
523
|
+
): LocationResolver {
|
|
524
|
+
let cached: TraceStoreLocation | null = null;
|
|
525
|
+
return async (paseo: unknown) => {
|
|
526
|
+
if (cached !== null) return cached;
|
|
527
|
+
cached = await resolve(paseo);
|
|
528
|
+
return cached;
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Default resolver: WP-202's install-home lookup, with real `fs` and `os`. */
|
|
533
|
+
export async function resolveLocationFromPaseo(paseo: unknown): Promise<TraceStoreLocation | null> {
|
|
534
|
+
const resolution = await resolveInstallHome({
|
|
535
|
+
paseo: paseo as Parameters<typeof resolveInstallHome>[0]["paseo"],
|
|
536
|
+
fs: { readFileSync: (path, encoding) => readFileSync(path, encoding) },
|
|
537
|
+
homedir,
|
|
538
|
+
});
|
|
539
|
+
return resolution.home === null ? null : { tracesDir: resolution.tracesDir };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface RegisterCollectorOptions {
|
|
543
|
+
/** Overridden in tests; production uses the install-home resolver. */
|
|
544
|
+
resolveLocation?: LocationResolver;
|
|
545
|
+
now?: () => Date;
|
|
546
|
+
env?: NodeJS.ProcessEnv;
|
|
547
|
+
log?: (message: string) => void;
|
|
548
|
+
lockTimeoutMs?: number;
|
|
549
|
+
/**
|
|
550
|
+
* Runs after a turn's record has been written, so a reader sees that turn.
|
|
551
|
+
* The review budget check hangs here rather than on its own
|
|
552
|
+
* `on("agent.turn_ended")`: two handlers of one event give no ordering
|
|
553
|
+
* guarantee, and a count read before the append is one call short.
|
|
554
|
+
* Its failures are contained like the collector's own.
|
|
555
|
+
*/
|
|
556
|
+
onRecorded?: (event: TurnEndedEvent, input: { location: TraceStoreLocation; paseo: unknown }) => unknown;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Registers both hooks and returns their remover. On a host without `on` it
|
|
561
|
+
* logs one line and returns a no-op, exactly like `registerRoleHook` does for a
|
|
562
|
+
* missing `before`.
|
|
563
|
+
*/
|
|
564
|
+
export function registerCollector(
|
|
565
|
+
host: CollectorHost,
|
|
566
|
+
options: RegisterCollectorOptions = {},
|
|
567
|
+
): () => void {
|
|
568
|
+
const log = options.log ?? ((message: string) => console.warn(message));
|
|
569
|
+
if (typeof host.on !== "function") {
|
|
570
|
+
// Same convention as `registerStopPropagation`: on a host with no
|
|
571
|
+
// lifecycle hooks at all, the role hook's line is the informative one, so
|
|
572
|
+
// this stays quiet instead of turning one problem into three log lines.
|
|
573
|
+
if (typeof host.before === "function") {
|
|
574
|
+
log(
|
|
575
|
+
"[paseo-bm] this Paseo host has no on(\"agent.turn_ended\") hook; the Dashboard will have no trace history.",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return () => {};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const resolveLocation =
|
|
582
|
+
options.resolveLocation ?? createLocationResolver(resolveLocationFromPaseo);
|
|
583
|
+
let warnedDisabled = false;
|
|
584
|
+
|
|
585
|
+
const removeStarted = host.on("agent.turn_started", (event) => {
|
|
586
|
+
try {
|
|
587
|
+
noteTurnStart(event, options.now);
|
|
588
|
+
} catch {
|
|
589
|
+
// A missing start mark only costs one duration.
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const removeEnded = host.on("agent.turn_ended", async (event, context) => {
|
|
594
|
+
try {
|
|
595
|
+
const location = await resolveLocation((context as { paseo?: unknown } | undefined)?.paseo);
|
|
596
|
+
if (location === null) {
|
|
597
|
+
if (!warnedDisabled) {
|
|
598
|
+
warnedDisabled = true;
|
|
599
|
+
log("[paseo-bm] trace store unavailable; the Dashboard will have no trace history.");
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const paseo = (context as { paseo?: CollectorPaseo } | undefined)?.paseo;
|
|
604
|
+
const recorded = await collectTurnEnded(event, {
|
|
605
|
+
location,
|
|
606
|
+
paseo,
|
|
607
|
+
now: options.now,
|
|
608
|
+
env: options.env,
|
|
609
|
+
log,
|
|
610
|
+
lockTimeoutMs: options.lockTimeoutMs,
|
|
611
|
+
});
|
|
612
|
+
if (recorded && options.onRecorded !== undefined) {
|
|
613
|
+
try {
|
|
614
|
+
await options.onRecorded(event, { location, paseo });
|
|
615
|
+
} catch (error) {
|
|
616
|
+
log(`[paseo-bm] after-record step failed: ${messageOf(error)}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} catch (error) {
|
|
620
|
+
// Last line of defence: this hook runs inside a real agent turn.
|
|
621
|
+
log(`[paseo-bm] trace collection failed: ${messageOf(error)}`);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
return () => {
|
|
626
|
+
if (typeof removeStarted === "function") removeStarted();
|
|
627
|
+
if (typeof removeEnded === "function") removeEnded();
|
|
628
|
+
};
|
|
629
|
+
}
|