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,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `BM-FORMAT`: the sender of a block that breaks its template is told, and
|
|
3
|
+
* asked to send the whole corrected block again (delta 20260918g §4.7,
|
|
4
|
+
* REQ-061 f, owner decisions Q2 a and Q8 a).
|
|
5
|
+
*
|
|
6
|
+
* Only timeline shapes verified on a real daemon are read: a `user_message`
|
|
7
|
+
* with no `clientMessageId` is another agent's message, and an
|
|
8
|
+
* `assistant_message` is the agent's own. The raw shape of a
|
|
9
|
+
* `send_agent_prompt` tool call is not verified, so a block is checked where it
|
|
10
|
+
* ARRIVES, at the turn end of the side that received it:
|
|
11
|
+
*
|
|
12
|
+
* | Block | Checked at the turn end of | Sender (told) |
|
|
13
|
+
* |----------------------------|----------------------------|-----------------------------------|
|
|
14
|
+
* | BM-REPORT (+ BM-QUESTIONS) | the Manager | the one Worker with that request |
|
|
15
|
+
* | BM-ANSWERS | the Worker | its parent, when it is a Manager |
|
|
16
|
+
* | BM-REVIEW | the Reviewer (its own) | the Reviewer itself |
|
|
17
|
+
*
|
|
18
|
+
* Rules this module must never lose:
|
|
19
|
+
* - **Never into a running turn.** `send()` on a running agent replaces its
|
|
20
|
+
* turn (review-budget.ts); a sender that is running or initializing is left
|
|
21
|
+
* for a later turn end. An archived or closed sender is never sent to:
|
|
22
|
+
* `send()` would un-archive it (ADR-005).
|
|
23
|
+
* - **Only the latest block.** Each turn end of the checking side replaces the
|
|
24
|
+
* pending notice with the verdict on the newest block it received (a valid
|
|
25
|
+
* one clears it). At the SENDER's own turn end the checking side may not
|
|
26
|
+
* have seen the sender's newest block yet, so there the notice goes only if
|
|
27
|
+
* the checking side has received nothing since the block was checked (its
|
|
28
|
+
* `lastUserMessageAt` still equals the mark); otherwise it waits for the
|
|
29
|
+
* checking side's next turn end, which decides (design §4.7 errata).
|
|
30
|
+
* - **A later chance always comes for reports and reviews.** A pending notice is
|
|
31
|
+
* looked at again at the next turn end of (1) the checking side, (2) the
|
|
32
|
+
* receiving side — the Manager for reports, the Worker for answers, the
|
|
33
|
+
* Reviewer's parent Worker for reviews — and (3) the sender itself. The
|
|
34
|
+
* Manager is woken after every Worker turn and a Worker when its Reviewer
|
|
35
|
+
* finishes (both `notifyOnFinish` by default). Answers have no such
|
|
36
|
+
* guarantee: that notice may never go, and the card chip still shows.
|
|
37
|
+
* - **Bounded.** One notice per distinct block, at most `MAX_NOTICES` per
|
|
38
|
+
* sender, request and kind. State lives in memory; a reload starts afresh
|
|
39
|
+
* and never re-reads old blocks.
|
|
40
|
+
*
|
|
41
|
+
* Nothing here throws into an agent's turn end: a failure costs one log line.
|
|
42
|
+
*/
|
|
43
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
44
|
+
import { checkBlocks, issueText, type BlockKind, type CheckedBlock } from "../shared/bm-format";
|
|
45
|
+
import { roleOfProvider, type BmRole } from "./agent-role";
|
|
46
|
+
import { sliceLastTurn } from "./collector";
|
|
47
|
+
import { bmAgentsOf, requireLocation, type DashboardPaseo } from "./dashboard-rpc";
|
|
48
|
+
import { FORMAT_NOTICE_MARKER, isPluginNotice } from "./notices";
|
|
49
|
+
import { readRecords } from "./trace-store";
|
|
50
|
+
import { requestIdOfAgent, type AgentFacts } from "./traces";
|
|
51
|
+
|
|
52
|
+
/** Notices per sender, request and kind (owner decision Q8 a). */
|
|
53
|
+
export const MAX_NOTICES = 2;
|
|
54
|
+
/** Re-reads of a sender that still reads `running` at its own turn end, and the pause between them. */
|
|
55
|
+
export const OWN_TURN_REREADS = 5;
|
|
56
|
+
export const OWN_TURN_REREAD_MS = 1000;
|
|
57
|
+
|
|
58
|
+
export interface FormatAgentSnapshot {
|
|
59
|
+
status?: string | null;
|
|
60
|
+
archivedAt?: string | null;
|
|
61
|
+
lastUserMessageAt?: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The SDK slice this module uses; `PaseoApi` is structurally close enough to be cast. */
|
|
65
|
+
export interface FormatPaseo extends Omit<DashboardPaseo, "agents"> {
|
|
66
|
+
agents: Omit<DashboardPaseo["agents"], "ref"> & {
|
|
67
|
+
ref(agentId: string): {
|
|
68
|
+
refresh(): Promise<{ agent?: FormatAgentSnapshot | null } | null>;
|
|
69
|
+
send(text: string): Promise<void>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What the hook hands over; the timeline is the whole conversation. */
|
|
75
|
+
export interface FormatTurnEvent {
|
|
76
|
+
agent: { id: string; workspaceId: string | null; parentAgentId: string | null; provider: string };
|
|
77
|
+
turnId?: string | null;
|
|
78
|
+
timeline: readonly unknown[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Pending {
|
|
82
|
+
key: string;
|
|
83
|
+
kind: BlockKind;
|
|
84
|
+
requestId: string | null;
|
|
85
|
+
senderId: string;
|
|
86
|
+
senderRole: BmRole;
|
|
87
|
+
/** The side that checked the block: its turn end, and its `lastUserMessageAt` mark. */
|
|
88
|
+
checkerId: string;
|
|
89
|
+
mark: string | null;
|
|
90
|
+
/** The receiving side's agent (Manager / Worker / the Reviewer's parent Worker). */
|
|
91
|
+
receiverId: string | null;
|
|
92
|
+
issues: string[];
|
|
93
|
+
hash: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface FormatState {
|
|
97
|
+
pending: Map<string, Pending>;
|
|
98
|
+
/** Notices sent per `sender|requestId|kind`. */
|
|
99
|
+
sent: Map<string, number>;
|
|
100
|
+
/** Hashes of blocks already notified. */
|
|
101
|
+
notifiedBlocks: Set<string>;
|
|
102
|
+
/** Keys being sent right now, so two turn ends never send one notice twice. */
|
|
103
|
+
sending: Set<string>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createFormatState(): FormatState {
|
|
107
|
+
return { pending: new Map(), sent: new Map(), notifiedBlocks: new Set(), sending: new Set() };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FormatDeps {
|
|
111
|
+
paseo: FormatPaseo;
|
|
112
|
+
state: FormatState;
|
|
113
|
+
log?: (message: string) => void;
|
|
114
|
+
/** Pause between own-turn re-reads; tests pass an instant one. */
|
|
115
|
+
sleep?: (ms: number) => Promise<void>;
|
|
116
|
+
homedir?: () => string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type FormatOutcome = "ignored" | "checked";
|
|
120
|
+
|
|
121
|
+
/** The notice, word for word from design §4.7. */
|
|
122
|
+
export function formatNotice(kind: BlockKind, requestId: string | null, issues: readonly string[]): string {
|
|
123
|
+
const last =
|
|
124
|
+
kind === "BM-REVIEW"
|
|
125
|
+
? "Answer with the whole corrected BM-REVIEW block as your final message; do not review again."
|
|
126
|
+
: "Send the whole corrected block again, to the same agent as before, in one message. Change nothing else and do not redo any work; then carry on exactly where you were.";
|
|
127
|
+
return [
|
|
128
|
+
`${FORMAT_NOTICE_MARKER} requestId: ${requestId ?? "unknown"}`,
|
|
129
|
+
`Your last ${kind} broke the template:`,
|
|
130
|
+
...issues.map((issue) => `- ${issue}`),
|
|
131
|
+
last,
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** djb2, enough to tell two blocks apart. */
|
|
136
|
+
function hashOf(text: string): string {
|
|
137
|
+
let hash = 5381;
|
|
138
|
+
for (let index = 0; index < text.length; index += 1) hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0;
|
|
139
|
+
return (hash >>> 0).toString(36);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function describeError(error: unknown): string {
|
|
143
|
+
return error instanceof Error ? error.message : String(error);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface TimelineText {
|
|
147
|
+
type: string;
|
|
148
|
+
text: string;
|
|
149
|
+
clientMessageId?: unknown;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function textItem(item: unknown): TimelineText | null {
|
|
153
|
+
if (item === null || typeof item !== "object") return null;
|
|
154
|
+
const { type, text } = item as { type?: unknown; text?: unknown };
|
|
155
|
+
if ((type !== "user_message" && type !== "assistant_message") || typeof text !== "string") return null;
|
|
156
|
+
return item as TimelineText;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The blocks of one message grouped as the sender sent them: a report with its questions is one unit. */
|
|
160
|
+
function unitsOf(blocks: readonly CheckedBlock[]): Array<{ kind: BlockKind; requestId: string | null; issues: string[]; text: string }> {
|
|
161
|
+
const units: Array<{ kind: BlockKind; requestId: string | null; issues: string[]; text: string }> = [];
|
|
162
|
+
for (const block of blocks) {
|
|
163
|
+
const previous = units.at(-1);
|
|
164
|
+
if (block.kind === "BM-QUESTIONS" && previous?.kind === "BM-REPORT") {
|
|
165
|
+
previous.issues.push(...block.issues.map(issueText));
|
|
166
|
+
previous.text += `\n\n${block.text}`;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
units.push({ kind: block.kind, requestId: block.requestId, issues: block.issues.map(issueText), text: block.text });
|
|
170
|
+
}
|
|
171
|
+
return units;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function snapshotOf(paseo: FormatPaseo, agentId: string): Promise<FormatAgentSnapshot | null> {
|
|
175
|
+
const result = await paseo.agents.ref(agentId).refresh();
|
|
176
|
+
return result?.agent ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `bmAgentsOf` reads only `agents.list`; hand it exactly that. */
|
|
180
|
+
function directoryOf(paseo: FormatPaseo): DashboardPaseo {
|
|
181
|
+
return { agents: { list: (options) => paseo.agents.list(options) }, workspaces: paseo.workspaces, config: paseo.config };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The Worker of the workspace that owns `requestId`, when exactly one does. */
|
|
185
|
+
async function workerOf(deps: FormatDeps, workspaceId: string, requestId: string | null): Promise<AgentFacts | null> {
|
|
186
|
+
if (requestId === null) return null;
|
|
187
|
+
const directory = directoryOf(deps.paseo);
|
|
188
|
+
const workers = (await bmAgentsOf(directory)).filter((entry) => entry.workspaceId === workspaceId && entry.facts.role === "worker");
|
|
189
|
+
let records: Parameters<typeof requestIdOfAgent>[1] = [];
|
|
190
|
+
try {
|
|
191
|
+
records = readRecords(await requireLocation(directory, { homedir: deps.homedir }), workspaceId).records;
|
|
192
|
+
} catch {
|
|
193
|
+
// Without a trace store only the labels are known.
|
|
194
|
+
}
|
|
195
|
+
const matches = workers.filter(({ facts }) => (facts.requestIdLabel ?? requestIdOfAgent(facts, records).requestId) === requestId);
|
|
196
|
+
return matches.length === 1 ? matches[0]!.facts : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function factsOfAgent(deps: FormatDeps, agentId: string | null): Promise<AgentFacts | null> {
|
|
200
|
+
if (agentId === null) return null;
|
|
201
|
+
return (await bmAgentsOf(directoryOf(deps.paseo))).find((entry) => entry.facts.id === agentId)?.facts ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Records the latest block of each sender/request/kind seen in this turn. */
|
|
205
|
+
async function detect(event: FormatTurnEvent, role: BmRole, deps: FormatDeps, log: (message: string) => void): Promise<void> {
|
|
206
|
+
const items = sliceLastTurn(Array.isArray(event.timeline) ? event.timeline : [])
|
|
207
|
+
.map(textItem)
|
|
208
|
+
.filter((item): item is TimelineText => item !== null);
|
|
209
|
+
const wanted: BlockKind = role === "manager" ? "BM-REPORT" : role === "worker" ? "BM-ANSWERS" : "BM-REVIEW";
|
|
210
|
+
// Received from another agent (no clientMessageId, not the plugin's own notice) — or, for a Reviewer, its own words.
|
|
211
|
+
const sources = items.filter((item) =>
|
|
212
|
+
role === "reviewer"
|
|
213
|
+
? item.type === "assistant_message"
|
|
214
|
+
: item.type === "user_message" && typeof item.clientMessageId !== "string" && !isPluginNotice(item.text),
|
|
215
|
+
);
|
|
216
|
+
const latest = new Map<string, { kind: BlockKind; requestId: string | null; issues: string[]; text: string }>();
|
|
217
|
+
for (const item of sources) {
|
|
218
|
+
for (const unit of unitsOf(checkBlocks(item.text))) {
|
|
219
|
+
if (unit.kind !== wanted) continue;
|
|
220
|
+
latest.set(`${unit.kind}|${unit.requestId ?? "?"}`, unit);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (latest.size === 0) return;
|
|
224
|
+
|
|
225
|
+
let mark: string | null = null;
|
|
226
|
+
let markRead = false;
|
|
227
|
+
for (const unit of latest.values()) {
|
|
228
|
+
let sender: { id: string; role: BmRole } | null;
|
|
229
|
+
let receiverId: string | null;
|
|
230
|
+
if (role === "manager") {
|
|
231
|
+
const worker = event.agent.workspaceId === null ? null : await workerOf(deps, event.agent.workspaceId, unit.requestId);
|
|
232
|
+
sender = worker === null ? null : { id: worker.id, role: "worker" };
|
|
233
|
+
receiverId = event.agent.id;
|
|
234
|
+
} else if (role === "worker") {
|
|
235
|
+
const parent = await factsOfAgent(deps, event.agent.parentAgentId);
|
|
236
|
+
sender = parent?.role === "manager" ? { id: parent.id, role: "manager" } : null;
|
|
237
|
+
receiverId = event.agent.id;
|
|
238
|
+
} else {
|
|
239
|
+
sender = { id: event.agent.id, role: "reviewer" };
|
|
240
|
+
receiverId = event.agent.parentAgentId;
|
|
241
|
+
}
|
|
242
|
+
if (sender === null) {
|
|
243
|
+
if (unit.issues.length > 0) log(`[paseo-bm] a ${unit.kind} for ${unit.requestId ?? "an unknown request"} breaks the template, but its sender is not known; no BM-FORMAT sent.`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const key = `${sender.id}|${unit.requestId ?? "?"}|${unit.kind}`;
|
|
247
|
+
if (unit.issues.length === 0) {
|
|
248
|
+
deps.state.pending.delete(key);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const hash = hashOf(unit.text);
|
|
252
|
+
if (deps.state.notifiedBlocks.has(hash)) continue;
|
|
253
|
+
if (!markRead) {
|
|
254
|
+
mark = (await snapshotOf(deps.paseo, event.agent.id))?.lastUserMessageAt ?? null;
|
|
255
|
+
markRead = true;
|
|
256
|
+
}
|
|
257
|
+
deps.state.pending.set(key, {
|
|
258
|
+
key,
|
|
259
|
+
kind: unit.kind,
|
|
260
|
+
requestId: unit.requestId,
|
|
261
|
+
senderId: sender.id,
|
|
262
|
+
senderRole: sender.role,
|
|
263
|
+
checkerId: event.agent.id,
|
|
264
|
+
mark,
|
|
265
|
+
receiverId,
|
|
266
|
+
issues: unit.issues,
|
|
267
|
+
hash,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Tries every pending notice this turn end is a chance for. */
|
|
273
|
+
async function flush(endedId: string, deps: FormatDeps, log: (message: string) => void): Promise<void> {
|
|
274
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
275
|
+
const chances = [...deps.state.pending.values()].filter(
|
|
276
|
+
(entry) => entry.checkerId === endedId || entry.receiverId === endedId || entry.senderId === endedId,
|
|
277
|
+
);
|
|
278
|
+
for (const entry of chances) {
|
|
279
|
+
if (deps.state.sending.has(entry.key)) continue;
|
|
280
|
+
deps.state.sending.add(entry.key);
|
|
281
|
+
try {
|
|
282
|
+
const counted = `${entry.senderId}|${entry.requestId ?? "?"}|${entry.kind}`;
|
|
283
|
+
if ((deps.state.sent.get(counted) ?? 0) >= MAX_NOTICES) {
|
|
284
|
+
deps.state.pending.delete(entry.key);
|
|
285
|
+
log(`[paseo-bm] ${entry.kind} from ${entry.senderId} still breaks the template after ${MAX_NOTICES} notices; not asking again.`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const atSenderOnly = endedId === entry.senderId && endedId !== entry.checkerId && endedId !== entry.receiverId;
|
|
289
|
+
if (atSenderOnly) {
|
|
290
|
+
// The sender may have just sent a newer block the checking side has not
|
|
291
|
+
// read yet: wait for the checking side's next turn end to decide.
|
|
292
|
+
const checker = await snapshotOf(deps.paseo, entry.checkerId);
|
|
293
|
+
if ((checker?.lastUserMessageAt ?? null) !== entry.mark) continue;
|
|
294
|
+
}
|
|
295
|
+
let sender = await snapshotOf(deps.paseo, entry.senderId);
|
|
296
|
+
if (entry.senderId === endedId) {
|
|
297
|
+
for (let reread = 0; reread < OWN_TURN_REREADS && isBusy(sender); reread += 1) {
|
|
298
|
+
await sleep(OWN_TURN_REREAD_MS);
|
|
299
|
+
sender = await snapshotOf(deps.paseo, entry.senderId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (sender === null || sender.archivedAt != null || sender.status === "closed") {
|
|
303
|
+
deps.state.pending.delete(entry.key);
|
|
304
|
+
log(`[paseo-bm] ${entry.senderId} is archived, closed or gone; the BM-FORMAT notice was not sent.`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (isBusy(sender)) continue;
|
|
308
|
+
await deps.paseo.agents.ref(entry.senderId).send(formatNotice(entry.kind, entry.requestId, entry.issues));
|
|
309
|
+
deps.state.pending.delete(entry.key);
|
|
310
|
+
deps.state.sent.set(counted, (deps.state.sent.get(counted) ?? 0) + 1);
|
|
311
|
+
deps.state.notifiedBlocks.add(entry.hash);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
log(`[paseo-bm] could not send BM-FORMAT to ${entry.senderId}: ${describeError(error)}; trying again at a later turn end.`);
|
|
314
|
+
} finally {
|
|
315
|
+
deps.state.sending.delete(entry.key);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isBusy(snapshot: FormatAgentSnapshot | null): boolean {
|
|
321
|
+
return snapshot?.status === "running" || snapshot?.status === "initializing";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Handler body of `on("agent.turn_ended")`. Never throws. */
|
|
325
|
+
export async function checkTurnFormat(event: FormatTurnEvent, deps: FormatDeps): Promise<FormatOutcome> {
|
|
326
|
+
const log = deps.log ?? ((message: string) => console.warn(message));
|
|
327
|
+
try {
|
|
328
|
+
const role = roleOfProvider(event?.agent?.provider);
|
|
329
|
+
if (role === null) return "ignored";
|
|
330
|
+
try {
|
|
331
|
+
await detect(event, role, deps, log);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
log(`[paseo-bm] checking the BM blocks of ${event.agent.id} failed: ${describeError(error)}`);
|
|
334
|
+
}
|
|
335
|
+
await flush(event.agent.id, deps, log);
|
|
336
|
+
return "checked";
|
|
337
|
+
} catch (error) {
|
|
338
|
+
log(`[paseo-bm] the BM-FORMAT check failed: ${describeError(error)}`);
|
|
339
|
+
return "ignored";
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export type FormatHost = Partial<Pick<PluginServerContext, "on">>;
|
|
344
|
+
|
|
345
|
+
/** Registers the check on `agent.turn_ended`; a no-op on a host without `on`. */
|
|
346
|
+
export function registerFormatCheck(host: FormatHost, state: FormatState = createFormatState()): () => void {
|
|
347
|
+
if (typeof host.on !== "function") return () => {};
|
|
348
|
+
const remove = host.on("agent.turn_ended", async (event, context) => {
|
|
349
|
+
await checkTurnFormat(event as unknown as FormatTurnEvent, { paseo: context.paseo as unknown as FormatPaseo, state });
|
|
350
|
+
});
|
|
351
|
+
return typeof remove === "function" ? remove : () => {};
|
|
352
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the trace store lives (WP-202, Dashboard Design §3.1).
|
|
3
|
+
*
|
|
4
|
+
* The server bundle cannot work this out by itself: Paseo 0.8 compiles
|
|
5
|
+
* `index.server.ts` into a CommonJS bundle and forks a worker with no cwd, and
|
|
6
|
+
* the module-URL global is empty there (errata bm-dnc), which is why the payload
|
|
7
|
+
* guard in test/plugin-bundle-cjs.test.ts forbids naming it at all. The authority is therefore
|
|
8
|
+
* the path Paseo itself recorded when the plugin was installed:
|
|
9
|
+
* `config.plugins["paseo-bm"] = { source: "directory", path }`, where `path` is
|
|
10
|
+
* `<install home>/plugin/<version>`. Two levels up is the install home, so a
|
|
11
|
+
* user who installed with `--home` or `PASEO_BM_HOME` is handled with no extra
|
|
12
|
+
* configuration.
|
|
13
|
+
*
|
|
14
|
+
* Read-only by construction: this module reads `install.json` to confirm it
|
|
15
|
+
* found the right directory and never writes anything. When it cannot be sure,
|
|
16
|
+
* it returns a reason instead of throwing — tracing then stays off and the rest
|
|
17
|
+
* of the Dashboard (bead statistics) keeps working.
|
|
18
|
+
*
|
|
19
|
+
* SDK facts this relies on (checked against @getpaseo/protocol 0.8.0
|
|
20
|
+
* `MutableDaemonConfigSchema`, not guessed):
|
|
21
|
+
* - `config.plugins` is `Record<string, { source: "directory"; path: string; enabled?: boolean }>`.
|
|
22
|
+
*/
|
|
23
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Plugin id the installer registers with Paseo (design §3.2 `paseo.pluginId`). */
|
|
26
|
+
export const PLUGIN_ID = "paseo-bm";
|
|
27
|
+
|
|
28
|
+
/** Directory of the trace store inside the install home (design §3.2). */
|
|
29
|
+
export const TRACES_DIR_NAME = "traces";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Directory inside the install home for UI state that is not trace data
|
|
33
|
+
* (`answer-marks.json`). The pin order an earlier version kept here is no
|
|
34
|
+
* longer read, and is left alone (delta 20260918f §4.5).
|
|
35
|
+
*/
|
|
36
|
+
export const UI_DIR_NAME = "ui";
|
|
37
|
+
|
|
38
|
+
/** Highest `install.json` schema version this code understands (design §3.2). */
|
|
39
|
+
export const SUPPORTED_RECORD_SCHEMA_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
/** Minimal SDK view this module needs. `PaseoApi` is structurally assignable to it. */
|
|
42
|
+
export interface InstallHomePaseo {
|
|
43
|
+
config: {
|
|
44
|
+
get(): Promise<{
|
|
45
|
+
config: {
|
|
46
|
+
plugins?: Record<string, unknown>;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The bits of `node:fs` this module needs, injectable so tests need no real home. */
|
|
53
|
+
export interface InstallHomeFs {
|
|
54
|
+
readFileSync(path: string, encoding: "utf8"): string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ResolveInstallHomeDeps {
|
|
58
|
+
paseo: InstallHomePaseo;
|
|
59
|
+
fs: InstallHomeFs;
|
|
60
|
+
/** `os.homedir()`; injected so the fallback is testable. */
|
|
61
|
+
homedir: () => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Either the install home, or the readable reason tracing is off.
|
|
66
|
+
*
|
|
67
|
+
* `source` records how it was found, which is what a support question ("why is
|
|
68
|
+
* my history empty?") actually needs.
|
|
69
|
+
*/
|
|
70
|
+
export type InstallHomeResolution =
|
|
71
|
+
| { home: string; tracesDir: string; source: "plugin-path" | "default-home" }
|
|
72
|
+
| { home: null; tracesDir: null; reason: string };
|
|
73
|
+
|
|
74
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
75
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
76
|
+
? (value as Record<string, unknown>)
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `<install home>/plugin/<version>` → `<install home>`, or null when the shape is wrong. */
|
|
81
|
+
export function installHomeFromPluginPath(pluginPath: string): string | null {
|
|
82
|
+
if (typeof pluginPath !== "string" || pluginPath.trim() === "" || !isAbsolute(pluginPath)) return null;
|
|
83
|
+
const versionDir = resolve(pluginPath);
|
|
84
|
+
const pluginDir = dirname(versionDir);
|
|
85
|
+
const home = dirname(pluginDir);
|
|
86
|
+
// Guard against a path so short that dirname() starts returning the same root.
|
|
87
|
+
if (home === pluginDir || pluginDir === versionDir) return null;
|
|
88
|
+
return home;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when `<home>/install.json` is readable, is JSON, and carries a
|
|
93
|
+
* `schemaVersion` this build understands.
|
|
94
|
+
*
|
|
95
|
+
* A newer profile means a newer paseo-bm wrote it; this build must not assume
|
|
96
|
+
* it understands that directory, so it reports "not confirmed" rather than
|
|
97
|
+
* using it (same posture as `E_RECORD_SCHEMA_TOO_NEW` in the CLI).
|
|
98
|
+
*/
|
|
99
|
+
export function confirmInstallHome(
|
|
100
|
+
home: string,
|
|
101
|
+
fs: InstallHomeFs,
|
|
102
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
103
|
+
const recordPath = join(home, "install.json");
|
|
104
|
+
let raw: string;
|
|
105
|
+
try {
|
|
106
|
+
raw = fs.readFileSync(recordPath, "utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, reason: `no readable install.json at ${recordPath}` };
|
|
109
|
+
}
|
|
110
|
+
let parsed: unknown;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(raw);
|
|
113
|
+
} catch {
|
|
114
|
+
return { ok: false, reason: `install.json at ${recordPath} is not valid JSON` };
|
|
115
|
+
}
|
|
116
|
+
const version = asRecord(parsed)?.["schemaVersion"];
|
|
117
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
|
118
|
+
return { ok: false, reason: `install.json at ${recordPath} has no usable schemaVersion` };
|
|
119
|
+
}
|
|
120
|
+
if (version > SUPPORTED_RECORD_SCHEMA_VERSION) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: `install.json at ${recordPath} has schemaVersion ${version}, newer than this plugin understands (${SUPPORTED_RECORD_SCHEMA_VERSION})`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolves the install home in the three steps of design §3.1, and never throws.
|
|
131
|
+
*
|
|
132
|
+
* 1. the directory Paseo registered for this plugin, two levels up;
|
|
133
|
+
* 2. confirmed by a readable `install.json`;
|
|
134
|
+
* 3. otherwise `~/.paseo-bm`, confirmed the same way.
|
|
135
|
+
*
|
|
136
|
+
* Anything else returns `{ home: null, reason }` so the caller can disable
|
|
137
|
+
* tracing with a message a person can act on.
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveInstallHome(
|
|
140
|
+
deps: ResolveInstallHomeDeps,
|
|
141
|
+
): Promise<InstallHomeResolution> {
|
|
142
|
+
const disabled = (reason: string): InstallHomeResolution => ({ home: null, tracesDir: null, reason });
|
|
143
|
+
|
|
144
|
+
let plugins: Record<string, unknown> | undefined;
|
|
145
|
+
try {
|
|
146
|
+
const { config } = await deps.paseo.config.get();
|
|
147
|
+
plugins = asRecord(config?.plugins);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
150
|
+
return disabled(`could not read the Paseo configuration: ${message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const entry = asRecord(plugins?.[PLUGIN_ID]);
|
|
154
|
+
const registeredPath = typeof entry?.["path"] === "string" ? (entry["path"] as string) : null;
|
|
155
|
+
|
|
156
|
+
if (registeredPath !== null) {
|
|
157
|
+
const home = installHomeFromPluginPath(registeredPath);
|
|
158
|
+
if (home === null) {
|
|
159
|
+
return disabled(`the registered plugin path is not a usable directory: ${registeredPath}`);
|
|
160
|
+
}
|
|
161
|
+
const confirmed = confirmInstallHome(home, deps.fs);
|
|
162
|
+
if (confirmed.ok) {
|
|
163
|
+
return { home, tracesDir: join(home, TRACES_DIR_NAME), source: "plugin-path" };
|
|
164
|
+
}
|
|
165
|
+
// Fall through to the default home: a directory plugin can be registered
|
|
166
|
+
// from somewhere that is not an install home at all (a developer checkout),
|
|
167
|
+
// and the default home may still hold a real installation.
|
|
168
|
+
const fallback = join(deps.homedir(), ".paseo-bm");
|
|
169
|
+
if (fallback !== home) {
|
|
170
|
+
const fallbackConfirmed = confirmInstallHome(fallback, deps.fs);
|
|
171
|
+
if (fallbackConfirmed.ok) {
|
|
172
|
+
return { home: fallback, tracesDir: join(fallback, TRACES_DIR_NAME), source: "default-home" };
|
|
173
|
+
}
|
|
174
|
+
return disabled(`${confirmed.reason}; ${fallbackConfirmed.reason}`);
|
|
175
|
+
}
|
|
176
|
+
return disabled(confirmed.reason);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const fallback = join(deps.homedir(), ".paseo-bm");
|
|
180
|
+
const fallbackConfirmed = confirmInstallHome(fallback, deps.fs);
|
|
181
|
+
if (fallbackConfirmed.ok) {
|
|
182
|
+
return { home: fallback, tracesDir: join(fallback, TRACES_DIR_NAME), source: "default-home" };
|
|
183
|
+
}
|
|
184
|
+
return disabled(
|
|
185
|
+
`Paseo has no directory plugin registered as "${PLUGIN_ID}"; ${fallbackConfirmed.reason}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills and user messages read live from an agent's timeline, when a trace
|
|
3
|
+
* is opened.
|
|
4
|
+
*
|
|
5
|
+
* The collector records both from the turn it was installed on. Everything an
|
|
6
|
+
* agent did before that is still in Paseo's own timeline for as long as the
|
|
7
|
+
* agent exists, so opening a trace fills the gap from there instead of making
|
|
8
|
+
* the user wait for new turns.
|
|
9
|
+
*
|
|
10
|
+
* Only Workers and Reviewers are read: each belongs to one request, so its
|
|
11
|
+
* whole timeline is that request's. A Manager serves every request of the
|
|
12
|
+
* workspace, and nothing in its timeline says which request a message was
|
|
13
|
+
* about.
|
|
14
|
+
*
|
|
15
|
+
* Verified on Paseo 0.8: `timeline.refetch({ direction: "tail" })` returns the
|
|
16
|
+
* newest page and `startCursor`; `direction: "before"` with that cursor pages
|
|
17
|
+
* back. Real agents needed 2–5 pages of 200 and under 200 ms.
|
|
18
|
+
*/
|
|
19
|
+
import { redactText, skillsFromItem } from "./collector";
|
|
20
|
+
import { isPluginNotice } from "./notices";
|
|
21
|
+
import { MAX_MESSAGE_CHARS, TRUNCATION_MARKER } from "./trace-store";
|
|
22
|
+
import type { TraceMessage } from "../shared/contracts";
|
|
23
|
+
import { byAt, uniqueBy } from "../shared/order";
|
|
24
|
+
|
|
25
|
+
/** A long-lived agent is read at most this far back. */
|
|
26
|
+
export const LIVE_PAGE_LIMIT = 200;
|
|
27
|
+
export const LIVE_MAX_PAGES = 25;
|
|
28
|
+
|
|
29
|
+
export interface LiveTimelinePaseo {
|
|
30
|
+
agents: {
|
|
31
|
+
ref?(agentId: string): {
|
|
32
|
+
timeline: { refetch(options: Record<string, unknown>): Promise<unknown> };
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LiveExtras {
|
|
38
|
+
skills: Array<{ agentId: string; skill: string; at: string | null }>;
|
|
39
|
+
userMessages: TraceMessage[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Page {
|
|
43
|
+
entries?: Array<{ item?: unknown; timestamp?: unknown }>;
|
|
44
|
+
hasOlder?: unknown;
|
|
45
|
+
startCursor?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capped(text: string): { text: string; truncated: boolean } {
|
|
49
|
+
if (text.length <= MAX_MESSAGE_CHARS) return { text, truncated: false };
|
|
50
|
+
return { text: `${text.slice(0, MAX_MESSAGE_CHARS - TRUNCATION_MARKER.length)}${TRUNCATION_MARKER}`, truncated: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reads an agent's timeline newest page first, back at most `pages` pages,
|
|
55
|
+
* handing each page's entries (oldest first, as Paseo returns them) to
|
|
56
|
+
* `visit`. Returns how many entries were read. Never throws: an agent that
|
|
57
|
+
* cannot be read simply yields nothing.
|
|
58
|
+
*/
|
|
59
|
+
export async function readTimelinePages(
|
|
60
|
+
paseo: LiveTimelinePaseo,
|
|
61
|
+
agentId: string,
|
|
62
|
+
options: { pages: number; limit: number },
|
|
63
|
+
visit: (entries: NonNullable<Page["entries"]>) => void,
|
|
64
|
+
): Promise<number> {
|
|
65
|
+
const ref = paseo.agents.ref?.(agentId);
|
|
66
|
+
if (ref === undefined) return 0;
|
|
67
|
+
let cursor: unknown;
|
|
68
|
+
let read = 0;
|
|
69
|
+
for (let page = 0; page < options.pages; page += 1) {
|
|
70
|
+
let payload: Page;
|
|
71
|
+
try {
|
|
72
|
+
payload = (await ref.timeline.refetch(
|
|
73
|
+
cursor === undefined
|
|
74
|
+
? { direction: "tail", limit: options.limit }
|
|
75
|
+
: { direction: "before", cursor, limit: options.limit },
|
|
76
|
+
)) as Page;
|
|
77
|
+
} catch {
|
|
78
|
+
return read;
|
|
79
|
+
}
|
|
80
|
+
const entries = payload.entries ?? [];
|
|
81
|
+
read += entries.length;
|
|
82
|
+
visit(entries);
|
|
83
|
+
if (payload.hasOlder !== true || payload.startCursor === undefined || payload.startCursor === null) return read;
|
|
84
|
+
cursor = payload.startCursor;
|
|
85
|
+
}
|
|
86
|
+
return read;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Skills and typed user messages of one agent's timeline, back to the start (bounded). */
|
|
90
|
+
async function readAgent(paseo: LiveTimelinePaseo, agentId: string, out: LiveExtras, env: NodeJS.ProcessEnv): Promise<void> {
|
|
91
|
+
await readTimelinePages(paseo, agentId, { pages: LIVE_MAX_PAGES, limit: LIVE_PAGE_LIMIT }, (entries) => {
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const item = entry.item as { type?: unknown; text?: unknown; clientMessageId?: unknown } | undefined;
|
|
94
|
+
if (item === undefined || item === null) continue;
|
|
95
|
+
const at = typeof entry.timestamp === "string" ? entry.timestamp : null;
|
|
96
|
+
for (const skill of skillsFromItem(item as never)) out.skills.push({ agentId, skill, at });
|
|
97
|
+
if (
|
|
98
|
+
item.type === "user_message" &&
|
|
99
|
+
typeof item.clientMessageId === "string" &&
|
|
100
|
+
typeof item.text === "string" &&
|
|
101
|
+
!isPluginNotice(item.text)
|
|
102
|
+
) {
|
|
103
|
+
const { text, truncated } = capped(redactText(item.text, env));
|
|
104
|
+
out.userMessages.push({ agentId, at: at ?? "", text, truncated, origin: "user" });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function readLiveExtras(
|
|
111
|
+
paseo: LiveTimelinePaseo,
|
|
112
|
+
agentIds: readonly string[],
|
|
113
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
114
|
+
): Promise<LiveExtras> {
|
|
115
|
+
const out: LiveExtras = { skills: [], userMessages: [] };
|
|
116
|
+
await Promise.all(agentIds.map((agentId) => readAgent(paseo, agentId, out, env)));
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Recorded and live entries together, once each, oldest first. */
|
|
121
|
+
export function mergeExtras(recorded: LiveExtras, live: LiveExtras): LiveExtras {
|
|
122
|
+
return {
|
|
123
|
+
skills: uniqueBy([...recorded.skills, ...live.skills].sort(byAt), (entry) => `${entry.agentId}|${entry.skill}`),
|
|
124
|
+
userMessages: uniqueBy(
|
|
125
|
+
[...recorded.userMessages, ...live.userMessages].sort(byAt),
|
|
126
|
+
(message) => `${message.agentId}|${message.text}`,
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
}
|