@linimin/pi-letscook 0.1.45 → 0.1.46

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.
@@ -0,0 +1,455 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import { promises as fsp } from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { DynamicBorder, parseFrontmatter } from "@mariozechner/pi-coding-agent";
7
+ import { Container, Text } from "@mariozechner/pi-tui";
8
+ import {
9
+ buildContextProposalAnalystPromptFromEntries,
10
+ parseContextProposalAnalystOutput,
11
+ type ContextProposal,
12
+ type RecentDiscussionEntry,
13
+ } from "./proposal";
14
+ import { contextProposalAnalystProgressLines } from "./prompt-surfaces";
15
+ import {
16
+ applyLiveRoleEvent,
17
+ buildInlineRunningLines,
18
+ cloneLiveRoleActivity,
19
+ createLiveRoleActivity,
20
+ formatInlineRunningText,
21
+ nowMs,
22
+ pushRecentActivity,
23
+ refreshCompletionStatus,
24
+ type RoleMessage,
25
+ } from "./status-surface";
26
+ import { completionRootKey, findCompletionRoot, findRepoRoot, loadCompletionDataForReminder } from "./state-store";
27
+ import { parseReportFields, transcribeRoleOutput, type TranscriptionResult } from "./transcription";
28
+ import type { AgentDefinition, CompletionRole, JsonRecord, LiveRoleActivity } from "./types";
29
+
30
+ export type RunCompletionRoleParams = {
31
+ root: string;
32
+ role: CompletionRole;
33
+ task?: string;
34
+ signal?: AbortSignal;
35
+ systemPromptPreamble: string[];
36
+ evaluationContextLines?: string[];
37
+ onUpdate?: (activity: LiveRoleActivity) => void;
38
+ onConsoleMessage?: (level: "info" | "warning", text: string) => void;
39
+ createLiveRoleActivity: (role: string) => LiveRoleActivity;
40
+ cloneLiveRoleActivity: (activity: LiveRoleActivity, overrides?: Partial<LiveRoleActivity>) => LiveRoleActivity;
41
+ applyLiveRoleEvent: (activity: LiveRoleActivity, event: JsonRecord, messages: Array<{ role: string; content: Array<{ type: string; text?: string }> }>) => boolean;
42
+ nowMs: () => number;
43
+ heartbeatMs: number;
44
+ };
45
+
46
+ export type RunCompletionRoleResult = {
47
+ role: CompletionRole;
48
+ ok: boolean;
49
+ exitCode: number;
50
+ output: string;
51
+ stderr?: string;
52
+ reportFields: Record<string, string>;
53
+ transcription?: TranscriptionResult;
54
+ activity: LiveRoleActivity;
55
+ };
56
+
57
+ export type AnalyzeContextProposalWithAgentParams = {
58
+ ctx: { cwd: string; hasUI: boolean; ui: any; model?: any };
59
+ projectName: string;
60
+ recentEntries: RecentDiscussionEntry[];
61
+ liveRoleActivityByRoot: Map<string, LiveRoleActivity>;
62
+ completionStatusKey: string;
63
+ safeUiCall: (action: () => void) => void;
64
+ getCtxCwd: (ctx: { cwd: string }) => string;
65
+ getCtxHasUI: (ctx: { hasUI: boolean }) => boolean;
66
+ getCtxUi: <T extends { ui: any }>(ctx: T) => any | undefined;
67
+ };
68
+
69
+ const AGENT_HOME = path.join(os.homedir(), ".pi", "agent");
70
+ const EXTENSION_DIR = typeof __dirname === "string" ? __dirname : process.cwd();
71
+ const PACKAGE_ROOT_CANDIDATE = path.resolve(EXTENSION_DIR, "..", "..");
72
+ const PACKAGE_ROOT = fs.existsSync(path.join(PACKAGE_ROOT_CANDIDATE, "package.json")) ? PACKAGE_ROOT_CANDIDATE : undefined;
73
+ const PACKAGE_AGENTS_DIR = PACKAGE_ROOT ? path.join(PACKAGE_ROOT, "agents") : undefined;
74
+ const CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT = [
75
+ "You analyze recent /cook startup discussion and return a strict JSON object.",
76
+ "Do not emit markdown, code fences, or commentary.",
77
+ "Return exactly one JSON object with keys: mission, scope, constraints, acceptance, critique, risks, task_type, evaluation_profile, confidence, possible_noise.",
78
+ "mission must be a concise implementation mission anchor sentence.",
79
+ "scope must contain only work items that directly support the mission.",
80
+ "constraints must contain guardrails or non-goals explicitly stated or strongly implied by the discussion.",
81
+ "acceptance must contain verifiable outcomes explicitly stated or strongly implied by the discussion.",
82
+ "critique must contain operator-facing cautions, concerns, or reminders that should be shown separately from mission and scope later.",
83
+ "risks must contain concrete failure modes or regressions that the later workflow should keep in view.",
84
+ "task_type and evaluation_profile should be candidate routing hints only; reuse the existing completion vocabulary when it clearly fits instead of inventing new schema names.",
85
+ "possible_noise should list discussion points that look stale, weakly related, or unsafe to promote into scope.",
86
+ "When discussion is insufficient, prefer empty arrays and a low confidence value over invention.",
87
+ ].join(" ");
88
+ const STARTUP_ANALYST_ROLE = "cook-proposal-analyst";
89
+ const ANALYST_HEARTBEAT_MS = 5_000;
90
+
91
+ class StartupAnalystOverlay extends Container {
92
+ private readonly border: DynamicBorder;
93
+ private readonly title: Text;
94
+ private readonly body: Text;
95
+ private readonly footer: Text;
96
+ private lines: string[] = [];
97
+ onAbort?: () => void;
98
+
99
+ constructor(private readonly theme: any) {
100
+ super();
101
+ this.border = new DynamicBorder((s: string) => this.theme.fg("accent", s));
102
+ this.title = new Text("", 1, 0);
103
+ this.body = new Text("", 1, 1);
104
+ this.footer = new Text("", 1, 0);
105
+ this.addChild(this.border);
106
+ this.addChild(this.title);
107
+ this.addChild(this.body);
108
+ this.addChild(this.footer);
109
+ this.updateDisplay();
110
+ }
111
+
112
+ setLines(lines: string[]): void {
113
+ this.lines = [...lines];
114
+ this.updateDisplay();
115
+ this.invalidate();
116
+ }
117
+
118
+ private updateDisplay(): void {
119
+ this.title.setText(this.theme.fg("accent", this.theme.bold("/cook proposal analyst")));
120
+ this.body.setText(formatInlineRunningText(this.theme, this.lines, { primaryAssistant: true }));
121
+ this.footer.setText(this.theme.fg("muted", "Esc/Ctrl+C cancel • This analysis runs before /cook writes canonical workflow state"));
122
+ }
123
+
124
+ override handleInput(data: string): void {
125
+ if (data === "\u001b" || data === "\u0003") {
126
+ this.onAbort?.();
127
+ return;
128
+ }
129
+ }
130
+
131
+ override invalidate(): void {
132
+ super.invalidate();
133
+ this.updateDisplay();
134
+ }
135
+ }
136
+
137
+ function asString(value: unknown): string | undefined {
138
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
139
+ }
140
+
141
+ function isRecord(value: unknown): value is JsonRecord {
142
+ return typeof value === "object" && value !== null && !Array.isArray(value);
143
+ }
144
+
145
+ function walkUpForDir(startCwd: string, segments: string[]): string | undefined {
146
+ let current = path.resolve(startCwd);
147
+ while (true) {
148
+ const candidate = path.join(current, ...segments);
149
+ if (fs.existsSync(candidate)) return candidate;
150
+ const parent = path.dirname(current);
151
+ if (parent === current) return undefined;
152
+ current = parent;
153
+ }
154
+ }
155
+
156
+ function contextProposalAnalystModelArg(model: unknown): string | undefined {
157
+ if (!isRecord(model)) return undefined;
158
+ const provider = asString(model.provider);
159
+ const id = asString(model.id);
160
+ return provider && id ? `${provider}/${id}` : undefined;
161
+ }
162
+
163
+ async function runContextProposalAnalystSubprocess(params: AnalyzeContextProposalWithAgentParams): Promise<string | undefined> {
164
+ const { ctx, projectName, recentEntries } = params;
165
+ const modelArg = contextProposalAnalystModelArg(ctx.model);
166
+ if (!modelArg) return undefined;
167
+ const cwd = params.getCtxCwd(ctx);
168
+ const runCwd = findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? cwd;
169
+ const rootKey = completionRootKey(undefined, cwd);
170
+ const prompt = buildContextProposalAnalystPromptFromEntries(projectName, recentEntries);
171
+ const systemPromptTemp = await writeTempFile(runCwd, "pi-cook-proposal-analyst-", CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT);
172
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", systemPromptTemp.filePath, "--model", modelArg, prompt];
173
+ const invocation = getPiInvocation(args);
174
+ const liveActivity = createLiveRoleActivity(STARTUP_ANALYST_ROLE);
175
+ liveActivity.progress = "Analyzing recent discussion";
176
+ liveActivity.currentAction = "Reading recent discussion and preparing a startup proposal";
177
+ liveActivity.assistantSummary = liveActivity.progress;
178
+ liveActivity.recentActivity = pushRecentActivity(liveActivity.recentActivity, `assistant: ${liveActivity.progress}`);
179
+ const messages: RoleMessage[] = [];
180
+ let overlay: StartupAnalystOverlay | undefined;
181
+ let finishOverlay: ((value: string | undefined) => void) | undefined;
182
+ let overlaySettled = false;
183
+ const settleOverlay = (value: string | undefined) => {
184
+ if (overlaySettled) return;
185
+ overlaySettled = true;
186
+ finishOverlay?.(value);
187
+ };
188
+ const updateActivity = (fresh = false) => {
189
+ if (fresh) liveActivity.updatedAt = nowMs();
190
+ params.liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(liveActivity, { status: "running" }));
191
+ void refreshCompletionStatus({
192
+ ctx,
193
+ liveRoleActivityByRoot: params.liveRoleActivityByRoot,
194
+ completionStatusKey: params.completionStatusKey,
195
+ safeUiCall: params.safeUiCall,
196
+ getCtxCwd: params.getCtxCwd,
197
+ getCtxHasUI: params.getCtxHasUI,
198
+ getCtxUi: params.getCtxUi,
199
+ });
200
+ overlay?.setLines(contextProposalAnalystProgressLines(liveActivity, buildInlineRunningLines));
201
+ };
202
+ const heartbeat = setInterval(() => updateActivity(false), ANALYST_HEARTBEAT_MS);
203
+ const run = async (): Promise<string | undefined> => {
204
+ try {
205
+ updateActivity(true);
206
+ const output = await new Promise<string | undefined>((resolve) => {
207
+ const proc = spawn(invocation.command, invocation.args, {
208
+ cwd: runCwd,
209
+ env: process.env,
210
+ stdio: ["ignore", "pipe", "pipe"],
211
+ shell: false,
212
+ });
213
+ let settled = false;
214
+ const resolveOnce = (value: string | undefined) => {
215
+ if (settled) return;
216
+ settled = true;
217
+ resolve(value);
218
+ };
219
+ const abort = () => {
220
+ proc.kill("SIGTERM");
221
+ resolveOnce(undefined);
222
+ };
223
+ const handleSigint = () => abort();
224
+ let buffer = "";
225
+ const processLine = (line: string) => {
226
+ if (!line.trim()) return;
227
+ try {
228
+ const event = JSON.parse(line) as JsonRecord;
229
+ if (applyLiveRoleEvent(liveActivity, event, messages)) updateActivity(true);
230
+ } catch {
231
+ // ignore malformed lines
232
+ }
233
+ };
234
+ proc.stdout.on("data", (chunk) => {
235
+ buffer += chunk.toString();
236
+ const lines = buffer.split("\n");
237
+ buffer = lines.pop() ?? "";
238
+ for (const line of lines) processLine(line);
239
+ });
240
+ proc.stderr.on("data", (_chunk) => {
241
+ // ignore analyst stderr unless the subprocess exits without assistant output
242
+ });
243
+ proc.on("close", (code) => {
244
+ process.off("SIGINT", handleSigint);
245
+ if (buffer.trim()) processLine(buffer);
246
+ resolveOnce(code === 0 ? liveActivity.lastAssistantText?.trim() || undefined : undefined);
247
+ });
248
+ proc.on("error", () => {
249
+ process.off("SIGINT", handleSigint);
250
+ resolveOnce(undefined);
251
+ });
252
+ process.once("SIGINT", handleSigint);
253
+ if (overlay) {
254
+ overlay.onAbort = () => {
255
+ process.off("SIGINT", handleSigint);
256
+ abort();
257
+ };
258
+ }
259
+ });
260
+ params.liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(liveActivity, { status: output ? "ok" : "error" }));
261
+ await refreshCompletionStatus({
262
+ ctx,
263
+ liveRoleActivityByRoot: params.liveRoleActivityByRoot,
264
+ completionStatusKey: params.completionStatusKey,
265
+ safeUiCall: params.safeUiCall,
266
+ getCtxCwd: params.getCtxCwd,
267
+ getCtxHasUI: params.getCtxHasUI,
268
+ getCtxUi: params.getCtxUi,
269
+ });
270
+ return output;
271
+ } finally {
272
+ clearInterval(heartbeat);
273
+ setTimeout(() => {
274
+ const current = params.liveRoleActivityByRoot.get(rootKey);
275
+ if (current && current.role === STARTUP_ANALYST_ROLE && current.status !== "running") {
276
+ params.liveRoleActivityByRoot.delete(rootKey);
277
+ void refreshCompletionStatus({
278
+ ctx,
279
+ liveRoleActivityByRoot: params.liveRoleActivityByRoot,
280
+ completionStatusKey: params.completionStatusKey,
281
+ safeUiCall: params.safeUiCall,
282
+ getCtxCwd: params.getCtxCwd,
283
+ getCtxHasUI: params.getCtxHasUI,
284
+ getCtxUi: params.getCtxUi,
285
+ });
286
+ }
287
+ }, 10_000);
288
+ await fsp.rm(systemPromptTemp.dir, { recursive: true, force: true });
289
+ }
290
+ };
291
+ if (params.getCtxHasUI(ctx)) {
292
+ const ui = params.getCtxUi(ctx);
293
+ if (ui) {
294
+ return await ui.custom<string | undefined>((_tui, theme, _kb, done) => {
295
+ finishOverlay = done;
296
+ overlay = new StartupAnalystOverlay(theme);
297
+ overlay.setLines(contextProposalAnalystProgressLines(liveActivity, buildInlineRunningLines));
298
+ run().then(settleOverlay).catch(() => settleOverlay(undefined));
299
+ return overlay;
300
+ });
301
+ }
302
+ }
303
+ return await run();
304
+ }
305
+
306
+ export async function analyzeContextProposalWithAgent(params: AnalyzeContextProposalWithAgentParams): Promise<ContextProposal | undefined> {
307
+ if (process.env.PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST === "1") return undefined;
308
+ const testOutput = asString(process.env.PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT);
309
+ if (testOutput) return parseContextProposalAnalystOutput(testOutput, params.projectName);
310
+ if (params.recentEntries.length === 0) return undefined;
311
+ try {
312
+ const raw = await runContextProposalAnalystSubprocess(params);
313
+ if (!raw) return undefined;
314
+ return parseContextProposalAnalystOutput(raw, params.projectName);
315
+ } catch (error) {
316
+ console.warn("[completion] context proposal analyst failed", error);
317
+ return undefined;
318
+ }
319
+ }
320
+
321
+ export async function loadAgentDefinition(cwd: string, role: CompletionRole): Promise<AgentDefinition> {
322
+ const projectAgent = walkUpForDir(cwd, [".pi", "agents", `${role}.md`]);
323
+ const packageAgent = PACKAGE_AGENTS_DIR ? path.join(PACKAGE_AGENTS_DIR, `${role}.md`) : undefined;
324
+ const candidates = [projectAgent, packageAgent, path.join(AGENT_HOME, "agents", `${role}.md`)].filter(
325
+ (candidate): candidate is string => Boolean(candidate),
326
+ );
327
+ for (const candidate of candidates) {
328
+ if (!fs.existsSync(candidate)) continue;
329
+ const raw = await fsp.readFile(candidate, "utf8");
330
+ const { frontmatter, body } = parseFrontmatter<Record<string, string>>(raw);
331
+ return {
332
+ name: frontmatter.name ?? role,
333
+ description: frontmatter.description,
334
+ tools: frontmatter.tools?.split(",").map((tool) => tool.trim()).filter(Boolean),
335
+ model: frontmatter.model,
336
+ systemPrompt: body.trim(),
337
+ filePath: candidate,
338
+ };
339
+ }
340
+ throw new Error(`Missing completion agent definition for ${role}`);
341
+ }
342
+
343
+ export async function writeTempFile(root: string, prefix: string, content: string): Promise<{ dir: string; filePath: string }> {
344
+ const agentTmpRoot = path.join(root, ".agent", "tmp");
345
+ try {
346
+ await fsp.mkdir(agentTmpRoot, { recursive: true });
347
+ const dir = await fsp.mkdtemp(path.join(agentTmpRoot, prefix));
348
+ const filePath = path.join(dir, "prompt.md");
349
+ await fsp.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 });
350
+ return { dir, filePath };
351
+ } catch {
352
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
353
+ const filePath = path.join(dir, "prompt.md");
354
+ await fsp.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 });
355
+ return { dir, filePath };
356
+ }
357
+ }
358
+
359
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
360
+ const currentScript = process.argv[1];
361
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
362
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
363
+ return { command: process.execPath, args: [currentScript, ...args] };
364
+ }
365
+ const execName = path.basename(process.execPath).toLowerCase();
366
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
367
+ if (!isGenericRuntime) return { command: process.execPath, args };
368
+ return { command: "pi", args };
369
+ }
370
+
371
+ export async function runCompletionRole(params: RunCompletionRoleParams): Promise<RunCompletionRoleResult> {
372
+ const agent = await loadAgentDefinition(params.root, params.role);
373
+ await loadCompletionDataForReminder(params.root);
374
+ const systemPromptTemp = await writeTempFile(params.root, "pi-completion-role-", agent.systemPrompt);
375
+ const taskLines = [...params.systemPromptPreamble];
376
+ if (params.evaluationContextLines?.length) taskLines.push("", ...params.evaluationContextLines);
377
+ if (params.task?.trim()) taskLines.push("", "Supplemental task context:", params.task.trim());
378
+ const prompt = taskLines.join("\n");
379
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", systemPromptTemp.filePath];
380
+ if (agent.model) args.push("--model", agent.model);
381
+ if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
382
+ args.push(prompt);
383
+
384
+ const invocation = getPiInvocation(args);
385
+ let stderr = "";
386
+ const messages: Array<{ role: string; content: Array<{ type: string; text?: string }> }> = [];
387
+ const liveActivity = params.createLiveRoleActivity(params.role);
388
+ params.onUpdate?.(liveActivity);
389
+ const heartbeat = setInterval(() => params.onUpdate?.(liveActivity), params.heartbeatMs);
390
+
391
+ try {
392
+ const exitCode = await new Promise<number>((resolve) => {
393
+ const proc = spawn(invocation.command, invocation.args, {
394
+ cwd: params.root,
395
+ env: { ...process.env, PI_COMPLETION_ROLE: params.role },
396
+ stdio: ["ignore", "pipe", "pipe"],
397
+ shell: false,
398
+ });
399
+ let buffer = "";
400
+
401
+ const processLine = (line: string) => {
402
+ if (!line.trim()) return;
403
+ try {
404
+ const event = JSON.parse(line) as JsonRecord;
405
+ if (params.applyLiveRoleEvent(liveActivity, event, messages)) params.onUpdate?.(liveActivity);
406
+ } catch {
407
+ // ignore malformed lines
408
+ }
409
+ };
410
+
411
+ proc.stdout.on("data", (chunk) => {
412
+ buffer += chunk.toString();
413
+ const lines = buffer.split("\n");
414
+ buffer = lines.pop() ?? "";
415
+ for (const line of lines) processLine(line);
416
+ });
417
+
418
+ proc.stderr.on("data", (chunk) => {
419
+ stderr += chunk.toString();
420
+ });
421
+
422
+ proc.on("close", (code) => {
423
+ if (buffer.trim()) processLine(buffer);
424
+ resolve(code ?? 0);
425
+ });
426
+
427
+ proc.on("error", () => resolve(1));
428
+
429
+ if (params.signal) {
430
+ const abort = () => proc.kill("SIGTERM");
431
+ if (params.signal.aborted) abort();
432
+ else params.signal.addEventListener("abort", abort, { once: true });
433
+ }
434
+ });
435
+
436
+ const output = liveActivity.lastAssistantText || stderr.trim() || `${params.role} finished with no text output.`;
437
+ const reportFields = parseReportFields(output);
438
+ const transcription = exitCode === 0 ? await transcribeRoleOutput(params.role, params.root, output, reportFields) : undefined;
439
+ if (transcription?.appended.length) params.onConsoleMessage?.("info", `Completion transcription appended: ${transcription.appended.join(", ")}`);
440
+ if (transcription?.errors.length) params.onConsoleMessage?.("warning", `Completion transcription warning: ${transcription.errors.join(" | ")}`);
441
+ return {
442
+ role: params.role,
443
+ ok: exitCode === 0,
444
+ exitCode,
445
+ output,
446
+ stderr: stderr.trim(),
447
+ reportFields,
448
+ transcription,
449
+ activity: params.cloneLiveRoleActivity(liveActivity, { status: exitCode === 0 ? "ok" : "error" }),
450
+ };
451
+ } finally {
452
+ clearInterval(heartbeat);
453
+ await fsp.rm(systemPromptTemp.dir, { recursive: true, force: true });
454
+ }
455
+ }