@wassname2/pi-supervise 0.0.1

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/src/view.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The worker view: what the supervisor judges from.
3
+ *
4
+ * Built from ctx.sessionManager.getBranch(), which already follows the live leaf path, so a fork
5
+ * or a rewind cannot leave dead entries in here. There is no disk read and no cross-branch merge.
6
+ *
7
+ * The body is pi-vcc's compiler, the same algorithmic compactor the worker can run, called here on
8
+ * the live messages with the worker's last compaction summary as previousSummary. So the view is
9
+ * "compaction summary, merged with everything since". We add what a compactor has no reason to
10
+ * track: unanswered tool calls and whether anything changed since the last review.
11
+ */
12
+ import { compile } from "@sting8k/pi-vcc/src/core/summarize.ts";
13
+ import { normalize } from "@sting8k/pi-vcc/src/core/normalize.ts";
14
+ import { extractFiles } from "@sting8k/pi-vcc/src/extract/files.ts";
15
+ import { extractCommits } from "@sting8k/pi-vcc/src/extract/commits.ts";
16
+
17
+ const SUPERVISOR_PREFIX = "[supervisor] ";
18
+
19
+ /** Entry shapes we read. Only the fields this file touches, taken from real session jsonl. */
20
+ export interface Block {
21
+ type: string;
22
+ id?: string;
23
+ text?: string;
24
+ /** Set on `type: "thinking"` blocks. Empty when the provider redacted the reasoning. */
25
+ thinking?: string;
26
+ name?: string;
27
+ arguments?: Record<string, unknown>;
28
+ }
29
+ export interface AgentMsg {
30
+ role: "user" | "assistant" | "toolResult" | string;
31
+ content?: string | Block[];
32
+ toolName?: string;
33
+ toolCallId?: string;
34
+ isError?: boolean;
35
+ }
36
+ export interface Entry {
37
+ type: string;
38
+ message?: AgentMsg;
39
+ /** ISO, written on every entry by the session manager (core/session-manager.d.ts:21). */
40
+ timestamp?: string;
41
+ /** Written by whichever compactor the worker runs. VCC's summary lands here too. */
42
+ summary?: string;
43
+ tokensBefore?: number;
44
+ }
45
+
46
+ /**
47
+ * Milliseconds since the worker last put a message in its session.
48
+ *
49
+ * The clock a stuck worker shows on, and the only one that reads the same for both ways of being
50
+ * stuck: sitting at the prompt, and inside one command that never returns. A supervisor directive
51
+ * does not reset it. Time since the last look measures the supervisor instead, and understates a
52
+ * worker that stopped hours before.
53
+ */
54
+ export function sinceLastTurn(entries: Entry[], now = Date.now()): number {
55
+ const last = [...entries].reverse().find((e) =>
56
+ e.type === "message" && e.timestamp && !(e.message?.role === "user" && textOf(e.message).startsWith(SUPERVISOR_PREFIX))
57
+ );
58
+ return last ? now - Date.parse(last.timestamp!) : 0;
59
+ }
60
+
61
+ /** A duration a supervisor can read at a glance: 2h27m, 45m, 30s. */
62
+ export function age(ms: number): string {
63
+ const s = Math.max(0, Math.round(ms / 1000));
64
+ if (s < 60) return `${s}s`;
65
+ const m = Math.round(s / 60);
66
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
67
+ }
68
+
69
+ /** Extension channel payloads cap at 16 KiB, so the view must stay under it. */
70
+ export const MAX_VIEW_BYTES = 15000;
71
+ const GOAL_PREVIEW_CHARS = 160;
72
+
73
+ /** A long goal remains identifiable in every view without replaying its whole rubric. */
74
+ export function goalPreview(goal: string): string {
75
+ if (!goal.includes("\n")) return goal || "not set";
76
+ const firstLine = goal.split("\n").find((line) => line.trim())?.trim() || "not set";
77
+ return `${firstLine.slice(0, GOAL_PREVIEW_CHARS)} [...]`;
78
+ }
79
+
80
+ function blocks(msg: AgentMsg): Block[] {
81
+ return Array.isArray(msg.content) ? msg.content : [];
82
+ }
83
+
84
+ function textOf(msg: AgentMsg): string {
85
+ if (typeof msg.content === "string") return msg.content;
86
+ return blocks(msg)
87
+ .filter((b) => b.type === "text")
88
+ .map((b) => b.text ?? "")
89
+ .join("\n");
90
+ }
91
+
92
+ /**
93
+ * Tool calls with no matching result on this branch. A settled worker with an unanswered
94
+ * subagent call still has delegated work running, and "done" then means nothing.
95
+ */
96
+ export function outstandingWork(entries: Entry[]): string[] {
97
+ const called = new Map<string, string>();
98
+ const answered = new Set<string>();
99
+ for (const entry of entries) {
100
+ const msg = entry.message;
101
+ if (!msg) continue;
102
+ for (const b of blocks(msg)) {
103
+ if (b.type === "toolCall" && b.id) called.set(b.id, b.name ?? "?");
104
+ }
105
+ if (msg.role === "toolResult" && msg.toolCallId) answered.add(msg.toolCallId);
106
+ }
107
+ return [...called].filter(([id]) => !answered.has(id)).map(([, name]) => name);
108
+ }
109
+
110
+ /** The summary written by whichever compactor the worker runs. Empty when it has not compacted. */
111
+ export function compactionSummary(entries: Entry[]): string {
112
+ let summary = "";
113
+ for (const entry of entries) {
114
+ if (entry.type === "compaction" && entry.summary) summary = entry.summary;
115
+ }
116
+ return summary;
117
+ }
118
+
119
+ /**
120
+ * What the worker has changed: the files it wrote and the commits it made.
121
+ *
122
+ * Two reviews with the same key mean the last instruction produced neither. That is evidence for
123
+ * the supervisor, not a rule: re-editing one file while a test still fails looks the same, and is
124
+ * sometimes the right thing to be doing.
125
+ *
126
+ * Read from pi-vcc's extractor rather than from its rendered section, which caps the list at ten
127
+ * paths and would freeze this key on any run long enough to matter.
128
+ */
129
+ export function progressKey(entries: Entry[]): string {
130
+ const blocks = normalize(messagesSince(entries) as any);
131
+ const files = extractFiles(blocks);
132
+ const commits = extractCommits(blocks).map((c) => c.hash ?? c.message);
133
+ return [[...files.modified].sort(), [...files.created].sort(), commits].map((p) => p.join(",")).join("||");
134
+ }
135
+
136
+ /**
137
+ * Messages after the worker's last compaction.
138
+ *
139
+ * getBranch keeps the entries a compaction replaced, so handing every message to compile alongside
140
+ * the summary would send the supervisor both copies and spend the byte budget twice. Supervisor
141
+ * directives already live in the supervisor transcript, so exclude their worker-session echo.
142
+ */
143
+ function messagesSince(entries: Entry[]): AgentMsg[] {
144
+ const lastCompaction = entries.map((e) => e.type).lastIndexOf("compaction");
145
+ return entries
146
+ .slice(lastCompaction + 1)
147
+ .filter((e) => e.type === "message" && e.message)
148
+ .map((e) => e.message!)
149
+ .filter((message) => message.role !== "user" || !textOf(message).startsWith(SUPERVISOR_PREFIX));
150
+ }
151
+
152
+ /** What the caller records after a view goes out, and hands back as `since` on the next one. */
153
+ export function turnsSince(entries: Entry[]): number {
154
+ return messagesSince(entries).length;
155
+ }
156
+
157
+ /** Reasoning blocks kept, newest first, and the tail kept from each. A block ends on a decision. */
158
+ const THINKING_BLOCKS = 2;
159
+ const THINKING_CHARS = 400;
160
+
161
+ /**
162
+ * Keep the last few reasoning blocks by rewriting them as text, and let pi-vcc drop the rest.
163
+ *
164
+ * normalize() keeps only text and toolCall blocks from an assistant message, so reasoning never
165
+ * reaches the supervisor although you see it on screen. Rewriting in place leaves each thought
166
+ * next to the tool call it produced, which is the order you read a session in. A separate section
167
+ * at the top of the view would divorce the thought from what it did.
168
+ *
169
+ * Only the last two, because one worker session here held 161 reasoning blocks and all of them
170
+ * would make the view a second transcript. Everything older needs no work: pi-vcc drops it.
171
+ */
172
+ function keepRecentThinking(msgs: AgentMsg[]): AgentMsg[] {
173
+ const keep = new Set<string>();
174
+ outer: for (let i = msgs.length - 1; i >= 0; i--) {
175
+ const content = msgs[i].content;
176
+ if (!Array.isArray(content)) continue;
177
+ for (let j = content.length - 1; j >= 0; j--) {
178
+ if (content[j].type !== "thinking" || !content[j].thinking) continue;
179
+ keep.add(`${i}:${j}`);
180
+ if (keep.size === THINKING_BLOCKS) break outer;
181
+ }
182
+ }
183
+ if (!keep.size) return msgs;
184
+ return msgs.map((msg, i) =>
185
+ Array.isArray(msg.content)
186
+ ? {
187
+ ...msg,
188
+ content: msg.content.map((b, j) =>
189
+ keep.has(`${i}:${j}`) ? { type: "text", text: `(thinking) ${b.thinking!.slice(-THINKING_CHARS)}` } : b
190
+ ),
191
+ }
192
+ : msg
193
+ );
194
+ }
195
+
196
+ const VCC_SEPARATOR = "\n\n---\n\n";
197
+ /** pi-vcc's section names, in the order formatSummary writes them (its format.ts). */
198
+ const VCC_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
199
+
200
+ /**
201
+ * pi-vcc's compiled summary, split into its header sections and its brief transcript.
202
+ *
203
+ * compile() writes `sections + "\n\n---\n\n" + brief`, and drops either part when it is empty, so
204
+ * all four combinations are possible. Get this wrong and the header block lands in the transcript,
205
+ * where the byte cut eats the newest turns instead of the oldest.
206
+ */
207
+ function vccSections(fresh: AgentMsg[]): { headers: string; brief: string } {
208
+ // No previousSummary: compile's merge reads the fresh brief with briefOf, which finds nothing
209
+ // when the fresh messages produced no header sections, and the newest turns vanish. The
210
+ // compaction summary goes into the view above this instead, which loses nothing.
211
+ //
212
+ // compile appends a note telling the reader to call vcc_recall, which the supervisor does not
213
+ // have. Matched on the tool name because wrapLongLines rewraps the note before we see it.
214
+ const compiled = compile({ messages: keepRecentThinking(fresh) as any })
215
+ .replace(/\n*-*\n*Use `vcc_recall`[\s\S]*$/, "")
216
+ .trim();
217
+ if (!VCC_HEADERS.some((h) => compiled.startsWith(`[${h}]`))) return { headers: "", brief: compiled };
218
+ const at = compiled.indexOf(VCC_SEPARATOR);
219
+ if (at < 0) return { headers: compiled, brief: "" };
220
+ return { headers: compiled.slice(0, at), brief: compiled.slice(at + VCC_SEPARATOR.length) };
221
+ }
222
+
223
+ export interface ViewInput {
224
+ goal: string;
225
+ status: string;
226
+ entries: Entry[];
227
+ /**
228
+ * Turns the supervisor has already been sent, from turnsSince() after the last view.
229
+ *
230
+ * The supervisor is a real session and keeps every view it has read, so re-sending the whole
231
+ * transcript every time is a second copy of what it already has. This is a person glancing at a
232
+ * screen: they read the new lines, not the scrollback. Past the compaction or a rewind this no
233
+ * longer lines up, and the view says so and sends everything after the compaction.
234
+ */
235
+ since?: number;
236
+ /** Reviews in a row where progressKey did not change. 0 means something changed this time. */
237
+ stale?: number;
238
+ /** Child pi processes still running. A settled worker with one of these is still spending. */
239
+ subagents?: number[];
240
+ /**
241
+ * The worker's model and how full its context is, from the intercom presence record.
242
+ *
243
+ * A supervisor steering a small fast model should give smaller steps than one steering a frontier
244
+ * model, and a worker near the top of its context is about to compact and lose detail.
245
+ */
246
+ model?: string;
247
+ }
248
+
249
+ /** Render the view, and cut it to MAX_VIEW_BYTES so the broker cannot reject it. */
250
+ export function buildView({ goal, status, entries, since = 0, stale = 0, subagents = [], model = "" }: ViewInput): string {
251
+ const messages = entries.filter((e) => e.type === "message" && e.message);
252
+ const pending = outstandingWork(messages);
253
+ const workerMessages = messagesSince(entries);
254
+ const total = workerMessages.length;
255
+ // A compaction or a rewind leaves the mark past the end. Restart from the compaction and say so,
256
+ // otherwise the supervisor silently reads a slice of the wrong history.
257
+ const restarted = since > total;
258
+ const from = restarted ? 0 : since;
259
+ const fresh = workerMessages.slice(from);
260
+ const { headers, brief } = vccSections(fresh);
261
+ const earlier = compactionSummary(entries);
262
+
263
+ const head = [
264
+ // Short goals are the criterion on every review. A multi-line research rubric is reinserted
265
+ // into the supervisor context at its own cadence, so this view carries only its locator.
266
+ `<goal>`,
267
+ goalPreview(goal),
268
+ `</goal>`,
269
+ ``,
270
+ `# Worker`,
271
+ ...(model ? [`model: ${model}`] : []),
272
+ `status: ${status}`,
273
+ `turns: ${workerMessages.length}`,
274
+ `tool calls with no result: ${pending.length ? pending.join(", ") : "none"}`,
275
+ `child pi processes still running: ${subagents.length ? subagents.join(", ") : "none"}`,
276
+ ...(stale > 0 ? [`no new file or commit for ${stale} reviews in a row`] : []),
277
+ ``,
278
+ // Sent when this view starts at the compaction boundary, which is the first view and every
279
+ // view after the worker compacts. In between the supervisor already has it.
280
+ ...(from === 0 && earlier
281
+ ? [
282
+ restarted ? `# The worker compacted, so this view restarts. Everything before it:` : `# Earlier work, from the worker's own compaction summary`,
283
+ earlier.slice(0, 6000),
284
+ ``,
285
+ ]
286
+ : []),
287
+ ...(headers ? [`# Files, commits and context, from the new turns only`, headers, ``] : []),
288
+ from > 0 ? `# New turns since your last look (${total - from} of ${total})` : `# Turns so far`,
289
+ ].join("\n");
290
+
291
+ // Oldest brief lines go first, because the newest turns are what the next instruction rests on.
292
+ let lines = brief.split("\n");
293
+ let view = `${head}\n${lines.join("\n")}\n`;
294
+ while (Buffer.byteLength(view, "utf-8") > MAX_VIEW_BYTES && lines.length > 1) {
295
+ lines = lines.slice(1);
296
+ view = `${head}\n[earlier turns cut to fit the channel]\n${lines.join("\n")}\n`;
297
+ }
298
+ if (Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES) return view;
299
+ // The head alone can overflow, on a long goal. The broker drops anything over
300
+ // 16 KiB and never tells the extension, so the supervisor would go blind. Cut, and say so.
301
+ return `${Buffer.from(view, "utf-8").subarray(0, MAX_VIEW_BYTES - 40).toString("utf-8")}\n[view cut here to fit the channel]\n`;
302
+ }