mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-25 14:00:15 +08:00
Co-Authored-By: PI/OpenAI <288921227+claudypoo@users.noreply.github.com>
201 lines
10 KiB
TypeScript
201 lines
10 KiB
TypeScript
// Adapted from wassname/pi-intercom-supervisor's VCC Markdown view; raw history stays in the saved session.
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
import { compile } from "@sting8k/pi-vcc/src/core/summarize.js";
|
|
|
|
export const MAX_WORKER_VIEW_BYTES = 8_000;
|
|
const RECALL = /\n*-*\n*Use `vcc_recall`[\s\S]*$/;
|
|
|
|
export interface WorkerProcess {
|
|
pid: number;
|
|
ppid: number;
|
|
command: string;
|
|
args: string;
|
|
}
|
|
|
|
export interface WorkerViewRuntime {
|
|
connected?: boolean;
|
|
status?: string;
|
|
model?: string;
|
|
contextPct?: number;
|
|
pid?: number;
|
|
processes?: WorkerProcess[];
|
|
processError?: string;
|
|
}
|
|
|
|
export interface WorkerViewCursor {
|
|
sessionFile: string;
|
|
boundary: string;
|
|
through: string;
|
|
turns: number;
|
|
progressKey: string;
|
|
stale: number;
|
|
}
|
|
|
|
export function viewClip(text: string, bytes: number): string {
|
|
if (Buffer.byteLength(text) <= bytes) return text;
|
|
return Buffer.from(text).subarray(0, bytes - 32).toString("utf8") + "\n[truncated; see saved session]";
|
|
}
|
|
|
|
function messageRows(entries: SessionEntry[]) {
|
|
const boundary = entries.map(entry => entry.type).lastIndexOf("compaction");
|
|
const boundaryId = boundary < 0 ? "root" : entries[boundary].id;
|
|
const rows = entries.slice(boundary + 1).flatMap(entry => {
|
|
if (entry.type === "message") return [{ id: entry.id, timestamp: entry.timestamp, message: entry.message }];
|
|
if (entry.type === "custom_message" && !entry.customType.startsWith("pi-goals-")) {
|
|
return [{ id: entry.id, timestamp: entry.timestamp, message: { role: "user" as const, content: entry.content, timestamp: Date.parse(entry.timestamp) } }];
|
|
}
|
|
return [];
|
|
});
|
|
return { boundary: boundaryId ?? `compaction-${boundary}`, rows };
|
|
}
|
|
|
|
function cleanCompile(messages: unknown[]): string {
|
|
return compile({ messages }).replace(RECALL, "").trim();
|
|
}
|
|
|
|
function section(summary: string, name: string): string {
|
|
const start = summary.indexOf(`[${name}]`);
|
|
if (start < 0) return "";
|
|
const tail = summary.slice(start);
|
|
const next = tail.slice(1).search(/\n\[[^\]]+\]/);
|
|
const separator = tail.indexOf("\n\n---\n\n");
|
|
const ends = [next < 0 ? -1 : next + 1, separator].filter(index => index > 0);
|
|
return tail.slice(0, ends.length ? Math.min(...ends) : undefined).trim();
|
|
}
|
|
|
|
function compactSummary(summary: string): string {
|
|
if (!summary) return "No new saved turns since the last look.";
|
|
if (Buffer.byteLength(summary) <= 5_500) return summary;
|
|
const head = Buffer.from(summary).subarray(0, 1_800).toString("utf8");
|
|
const tail = Buffer.from(summary).subarray(-3_500).toString("utf8");
|
|
return `${head}\n\n[part of this single saved turn omitted; inspect the saved session for exact content]\n\n${tail}`;
|
|
}
|
|
|
|
function age(timestamp: string | undefined, now = Date.now()): string {
|
|
if (!timestamp) return "unknown";
|
|
const seconds = Math.max(0, Math.round((now - Date.parse(timestamp)) / 1000));
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.round(seconds / 60);
|
|
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
}
|
|
|
|
function callsAndResults(rows: ReturnType<typeof messageRows>["rows"]) {
|
|
const calls = new Map<string, { name: string; index: number }>();
|
|
const results = new Set<string>();
|
|
for (const [index, row] of rows.entries()) {
|
|
const message = row.message;
|
|
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
for (const block of message.content) if (block.type === "toolCall") calls.set(block.id, { name: block.name, index });
|
|
}
|
|
if (message.role === "toolResult") results.add(message.toolCallId);
|
|
}
|
|
return { calls, results };
|
|
}
|
|
|
|
function outstandingCalls(rows: ReturnType<typeof messageRows>["rows"]) {
|
|
const { calls, results } = callsAndResults(rows);
|
|
return [...calls].flatMap(([id, call]) => results.has(id) ? [] : [{ id, name: call.name }]);
|
|
}
|
|
|
|
const CONTROL_RESULTS = new Set(["process", "subagent", "bg_wait", "schedule_task", "manage_scheduled_task"]);
|
|
function newResultSummaries(rows: ReturnType<typeof messageRows>["rows"], since: number, through: number): string[] {
|
|
const { calls } = callsAndResults(rows);
|
|
return rows.slice(since, through).flatMap(row => {
|
|
const message = row.message;
|
|
if (message.role !== "toolResult") return [];
|
|
const call = calls.get(message.toolCallId), name = call?.name ?? message.toolName;
|
|
if (!message.isError && call?.index !== undefined && call.index >= since && !CONTROL_RESULTS.has(name)) return [];
|
|
const raw = typeof message.content === "string" ? message.content : message.content.filter(block => block.type === "text").map(block => block.text).join(" ");
|
|
const text = raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
const outcome = message.isError ? "failed" : "returned";
|
|
return [`${name} ${outcome}${text ? `: ${text.slice(0, 220)}` : ""}`];
|
|
});
|
|
}
|
|
|
|
export function descendantProcesses(rootPid: number): WorkerProcess[] {
|
|
if (!Number.isSafeInteger(rootPid) || rootPid <= 0 || !["darwin", "linux"].includes(process.platform)) return [];
|
|
const stdout = execFileSync("ps", ["-eo", "pid=,ppid=,comm=,args="], { encoding: "utf8" });
|
|
const processes = stdout.trim().split("\n").flatMap(line => {
|
|
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
const ownSnapshot = match?.[3] === "ps" && match[4].includes("-eo pid=,ppid=,comm=,args=");
|
|
return match && !ownSnapshot ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3], args: match[4] }] : [];
|
|
});
|
|
const descendants: WorkerProcess[] = [];
|
|
const parents = new Set([rootPid]);
|
|
for (;;) {
|
|
const found = processes.filter(item => parents.has(item.ppid) && !parents.has(item.pid));
|
|
if (!found.length) break;
|
|
for (const item of found) { descendants.push(item); parents.add(item.pid); }
|
|
}
|
|
return descendants;
|
|
}
|
|
|
|
export function buildWorkerView(
|
|
entries: SessionEntry[],
|
|
sessionFile: string,
|
|
task: string,
|
|
runtime: WorkerViewRuntime = {},
|
|
previous?: WorkerViewCursor,
|
|
diagnostic = false,
|
|
): { text: string; cursor: WorkerViewCursor } {
|
|
const current = messageRows(entries), rows = current.rows;
|
|
const sameHistory = previous?.sessionFile === sessionFile && previous.boundary === current.boundary;
|
|
const anchor = sameHistory && previous.through ? rows.findIndex(row => row.id === previous.through) : -1;
|
|
const since = anchor >= 0 ? anchor + 1 : 0;
|
|
const fresh = rows.slice(since);
|
|
const pending = outstandingCalls(rows);
|
|
const pendingNames = pending.length ? `${pending.slice(0, 8).map(call => call.name).join(", ")}${pending.length > 8 ? ` (+${pending.length - 8} more)` : ""}` : "none";
|
|
const processes = runtime.processes;
|
|
const piChildren = processes?.filter(item => item.command === "pi" || /(?:^|\/)(?:pi|pi-coding-agent)(?:[\s/]|$)/.test(item.args)) ?? [];
|
|
const lastTimestamp = rows.at(-1)?.timestamp;
|
|
const status = runtime.connected === false ? "disconnected" : (runtime.status || (runtime.connected ? "connected" : "saved history only")).slice(0, 120);
|
|
const modelName = runtime.model?.slice(0, 160);
|
|
const model = modelName ? `${modelName}${runtime.contextPct === undefined ? "" : `, ${runtime.contextPct}% context used`}` : "unknown";
|
|
const background = runtime.processError ? `process snapshot unavailable: ${viewClip(runtime.processError, 300)}`
|
|
: processes === undefined ? "process snapshot not available"
|
|
: `${processes.length} child OS process${processes.length === 1 ? "" : "es"}; ${piChildren.length} probable child Pi process${piChildren.length === 1 ? "" : "es"}`;
|
|
let consumed = fresh;
|
|
let text = "";
|
|
let progressKey = previous?.progressKey ?? createHash("sha256").update("").digest("hex");
|
|
let stale = 0;
|
|
for (;;) {
|
|
const through = since + consumed.length;
|
|
const compiled = cleanCompile(consumed.map(row => row.message));
|
|
const summary = compactSummary(compiled);
|
|
const results = newResultSummaries(rows, since, through);
|
|
const visibleSummary = cleanCompile(rows.slice(0, through).map(row => row.message));
|
|
const progress = [section(visibleSummary, "Files And Changes"), section(visibleSummary, "Commits")].filter(Boolean).join("\n\n");
|
|
progressKey = createHash("sha256").update(progress).digest("hex");
|
|
stale = consumed.length && sameHistory && previous?.progressKey === progressKey ? previous.stale + 1 : 0;
|
|
const remaining = fresh.length - consumed.length;
|
|
const lines = [
|
|
"## Worker view",
|
|
`Task: ${task.replace(/\s+/g, " ").trim().slice(0, 400) || "unknown"}`,
|
|
`Status: ${status}; last saved activity ${age(lastTimestamp)} ago`,
|
|
`Model: ${model}`,
|
|
`Background: ${background}; unanswered tool calls: ${pendingNames}`,
|
|
...(stale ? [`Progress: no new file or commit for ${stale} view${stale === 1 ? "" : "s"} with new turns`] : []),
|
|
"",
|
|
"### VCC summary of new turns",
|
|
summary,
|
|
...(results.length ? ["", "### New result summaries", ...results.map(result => `- ${result}`)] : []),
|
|
...(remaining ? ["", `${remaining} newer saved turn${remaining === 1 ? "" : "s"} remain; call worker_view again.`] : []),
|
|
];
|
|
if (diagnostic) {
|
|
lines.push("", "### Diagnostics", `Saved session: ${sessionFile}`, `Summary through entry: ${consumed.at(-1)?.id ?? previous?.through ?? "none"}`, `Latest saved entry: ${entries.at(-1)?.id ?? "unknown"}`,
|
|
`Unanswered calls: ${pending.length ? `${pending.slice(0, 8).map(call => `${call.name} (${call.id})`).join(", ")}${pending.length > 8 ? ` (+${pending.length - 8} more)` : ""}` : "none"}`,
|
|
`Child processes: ${processes?.length ? processes.slice(0, 8).map(item => `${item.pid} ${item.command} ${viewClip(item.args, 120)}`).join("; ") : runtime.processError || "none observed"}`,
|
|
"Detached queues and jobs are not inferred from the process tree; check their native owner when the saved turns name one.");
|
|
}
|
|
text = lines.join("\n");
|
|
if (consumed.length <= 1 || Buffer.byteLength(compiled) <= 5_500 && Buffer.byteLength(text) <= MAX_WORKER_VIEW_BYTES) break;
|
|
consumed = fresh.slice(0, Math.max(1, Math.floor(consumed.length / 2)));
|
|
}
|
|
text = viewClip(text, MAX_WORKER_VIEW_BYTES);
|
|
const through = consumed.at(-1)?.id ?? (sameHistory ? previous?.through : "") ?? "";
|
|
return { text, cursor: { sessionFile, boundary: current.boundary, through, turns: since + consumed.length, progressKey, stale } };
|
|
}
|