@linimin/pi-letscook 0.1.50 → 0.1.51
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/CHANGELOG.md +11 -0
- package/README.md +80 -52
- package/extensions/completion/driver.ts +151 -131
- package/extensions/completion/index.ts +8 -3
- package/extensions/completion/input-routing.ts +350 -0
- package/extensions/completion/prompt-surfaces.ts +99 -1
- package/extensions/completion/proposal.ts +1 -1
- package/extensions/completion/role-runner.ts +285 -2
- package/extensions/completion/types.ts +42 -0
- package/package.json +1 -1
- package/scripts/cook-trigger-routing-test.sh +314 -0
- package/scripts/release-check.sh +12 -5
|
@@ -7,11 +7,17 @@ import { DynamicBorder, parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
|
7
7
|
import { Container, Text } from "@mariozechner/pi-tui";
|
|
8
8
|
import {
|
|
9
9
|
buildContextProposalAnalystPromptFromEntries,
|
|
10
|
+
extractJsonObjectFromText,
|
|
10
11
|
parseContextProposalAnalystOutput,
|
|
12
|
+
serializeRecentDiscussionEntries,
|
|
11
13
|
type ContextProposal,
|
|
12
14
|
type RecentDiscussionEntry,
|
|
13
15
|
} from "./proposal";
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
buildCookTriggerClassifierPrompt,
|
|
18
|
+
contextProposalAnalystProgressLines,
|
|
19
|
+
maybeWriteCookTriggerClassifierSnapshot,
|
|
20
|
+
} from "./prompt-surfaces";
|
|
15
21
|
import {
|
|
16
22
|
applyLiveRoleEvent,
|
|
17
23
|
buildInlineRunningLines,
|
|
@@ -25,7 +31,13 @@ import {
|
|
|
25
31
|
} from "./status-surface";
|
|
26
32
|
import { completionRootKey, findCompletionRoot, findRepoRoot, loadCompletionDataForReminder } from "./state-store";
|
|
27
33
|
import { parseReportFields, transcribeRoleOutput, type TranscriptionResult } from "./transcription";
|
|
28
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
AgentDefinition,
|
|
36
|
+
CompletionRole,
|
|
37
|
+
CookTriggerClassification,
|
|
38
|
+
JsonRecord,
|
|
39
|
+
LiveRoleActivity,
|
|
40
|
+
} from "./types";
|
|
29
41
|
|
|
30
42
|
export type RunCompletionRoleParams = {
|
|
31
43
|
root: string;
|
|
@@ -67,6 +79,21 @@ export type AnalyzeContextProposalWithAgentParams = {
|
|
|
67
79
|
getCtxUi: <T extends { ui: any }>(ctx: T) => any | undefined;
|
|
68
80
|
};
|
|
69
81
|
|
|
82
|
+
export type ClassifyCookTriggerIntentWithAgentParams = {
|
|
83
|
+
ctx: { cwd: string; hasUI: boolean; ui: any; model?: any };
|
|
84
|
+
projectName: string;
|
|
85
|
+
inputText: string;
|
|
86
|
+
recentEntries: RecentDiscussionEntry[];
|
|
87
|
+
workflowContextLines?: string[];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type CookTriggerClassifierResult = {
|
|
91
|
+
status: "classified" | "timeout" | "invalid_output" | "error";
|
|
92
|
+
classification?: CookTriggerClassification;
|
|
93
|
+
rawOutput?: string;
|
|
94
|
+
errorMessage?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
70
97
|
const AGENT_HOME = path.join(os.homedir(), ".pi", "agent");
|
|
71
98
|
const EXTENSION_DIR = typeof __dirname === "string" ? __dirname : process.cwd();
|
|
72
99
|
const PACKAGE_ROOT_CANDIDATE = path.resolve(EXTENSION_DIR, "..", "..");
|
|
@@ -93,6 +120,22 @@ const CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT = [
|
|
|
93
120
|
].join(" ");
|
|
94
121
|
const STARTUP_ANALYST_ROLE = "cook-proposal-analyst";
|
|
95
122
|
const ANALYST_HEARTBEAT_MS = 5_000;
|
|
123
|
+
const COOK_TRIGGER_CLASSIFIER_SYSTEM_PROMPT = [
|
|
124
|
+
"You classify whether the latest user input should hand control to the canonical /cook workflow before the primary agent starts implementation work.",
|
|
125
|
+
"Do not emit markdown, code fences, or commentary.",
|
|
126
|
+
"Return exactly one JSON object with keys: intent, confidence, reason, evidence, riskFlags, focusHint.",
|
|
127
|
+
"intent must be exactly one of route_to_cook, normal_prompt, or unclear.",
|
|
128
|
+
"Use route_to_cook only when the latest input is handing control from discussion into workflow execution or explicitly asking to let /cook take over.",
|
|
129
|
+
"Use normal_prompt for ordinary questions, explanations, or direct requests that should stay with the primary agent.",
|
|
130
|
+
"Use unclear for ambiguous approvals, acknowledgements, or mixed signals where false-positive routing risk is material.",
|
|
131
|
+
"confidence must be a number from 0 to 1.",
|
|
132
|
+
"reason must be a single concise sentence.",
|
|
133
|
+
"evidence must be an array of short grounded strings.",
|
|
134
|
+
"riskFlags must be an array of short machine-readable strings such as ambiguous-approval, possible-normal-agent-request, or active-workflow-refocus-risk.",
|
|
135
|
+
"focusHint is optional, must stay short, and must never rewrite the workflow mission or invent scope.",
|
|
136
|
+
"Short acknowledgements like 好, 可以, ok, sure, or 那就這樣 should usually be unclear unless the surrounding context makes the handoff explicit.",
|
|
137
|
+
].join(" ");
|
|
138
|
+
const COOK_TRIGGER_CLASSIFIER_TIMEOUT_MS = 10_000;
|
|
96
139
|
|
|
97
140
|
class StartupAnalystOverlay extends Container {
|
|
98
141
|
private readonly border: DynamicBorder;
|
|
@@ -324,6 +367,246 @@ export async function analyzeContextProposalWithAgent(params: AnalyzeContextProp
|
|
|
324
367
|
}
|
|
325
368
|
}
|
|
326
369
|
|
|
370
|
+
function uniqueStrings(items: string[]): string[] {
|
|
371
|
+
const seen = new Set<string>();
|
|
372
|
+
const result: string[] = [];
|
|
373
|
+
for (const item of items) {
|
|
374
|
+
const normalized = item.trim();
|
|
375
|
+
if (!normalized) continue;
|
|
376
|
+
const key = normalized.toLowerCase();
|
|
377
|
+
if (seen.has(key)) continue;
|
|
378
|
+
seen.add(key);
|
|
379
|
+
result.push(normalized);
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function localAsStringArray(value: unknown): string[] {
|
|
385
|
+
return Array.isArray(value)
|
|
386
|
+
? uniqueStrings(value.filter((item): item is string => typeof item === "string" && item.trim().length > 0))
|
|
387
|
+
: [];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function confidenceFromUnknown(value: unknown): number {
|
|
391
|
+
const parsed =
|
|
392
|
+
typeof value === "number"
|
|
393
|
+
? value
|
|
394
|
+
: typeof value === "string" && value.trim().length > 0
|
|
395
|
+
? Number.parseFloat(value)
|
|
396
|
+
: Number.NaN;
|
|
397
|
+
if (!Number.isFinite(parsed)) return 0;
|
|
398
|
+
return Math.min(1, Math.max(0, parsed));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseCookTriggerClassification(raw: string): CookTriggerClassification | undefined {
|
|
402
|
+
const jsonText = extractJsonObjectFromText(raw);
|
|
403
|
+
if (!jsonText) return undefined;
|
|
404
|
+
let parsed: unknown;
|
|
405
|
+
try {
|
|
406
|
+
parsed = JSON.parse(jsonText);
|
|
407
|
+
} catch {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
if (!isRecord(parsed)) return undefined;
|
|
411
|
+
const intent = asString(parsed.intent);
|
|
412
|
+
if (intent !== "route_to_cook" && intent !== "normal_prompt" && intent !== "unclear") return undefined;
|
|
413
|
+
const evidence = localAsStringArray(parsed.evidence);
|
|
414
|
+
const riskFlags = localAsStringArray(parsed.riskFlags ?? parsed.risk_flags);
|
|
415
|
+
const reason = asString(parsed.reason) ?? asString(parsed.rationale) ?? evidence[0] ?? `Classifier returned ${intent}.`;
|
|
416
|
+
const focusHint = asString(parsed.focusHint ?? parsed.focus_hint);
|
|
417
|
+
return {
|
|
418
|
+
intent,
|
|
419
|
+
confidence: confidenceFromUnknown(parsed.confidence),
|
|
420
|
+
reason,
|
|
421
|
+
focusHint,
|
|
422
|
+
evidence: evidence.length > 0 ? evidence : [reason],
|
|
423
|
+
riskFlags,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function triggerClassifierFailureModeFromEnv(): "timeout" | "error" | "invalid_output" | undefined {
|
|
428
|
+
const raw = asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_FAILURE)?.toLowerCase();
|
|
429
|
+
return raw === "timeout" || raw === "error" || raw === "invalid_output" ? raw : undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function triggerClassifierSnapshotPath(): string | undefined {
|
|
433
|
+
return asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function runCookTriggerClassifierSubprocess(
|
|
437
|
+
params: ClassifyCookTriggerIntentWithAgentParams & { prompt: string },
|
|
438
|
+
): Promise<CookTriggerClassifierResult> {
|
|
439
|
+
const cwd = params.ctx.cwd;
|
|
440
|
+
const runCwd = findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? cwd;
|
|
441
|
+
const modelArg = contextProposalAnalystModelArg(params.ctx.model);
|
|
442
|
+
const systemPromptTemp = await writeTempFile(runCwd, "pi-cook-trigger-classifier-", COOK_TRIGGER_CLASSIFIER_SYSTEM_PROMPT);
|
|
443
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", systemPromptTemp.filePath];
|
|
444
|
+
if (modelArg) args.push("--model", modelArg);
|
|
445
|
+
args.push(params.prompt);
|
|
446
|
+
const invocation = getPiInvocation(args);
|
|
447
|
+
const liveActivity = createLiveRoleActivity("cook-trigger-classifier");
|
|
448
|
+
const messages: RoleMessage[] = [];
|
|
449
|
+
let stderr = "";
|
|
450
|
+
let timedOut = false;
|
|
451
|
+
try {
|
|
452
|
+
const rawOutput = await new Promise<string | undefined>((resolve) => {
|
|
453
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
454
|
+
cwd: runCwd,
|
|
455
|
+
env: process.env,
|
|
456
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
457
|
+
shell: false,
|
|
458
|
+
});
|
|
459
|
+
let settled = false;
|
|
460
|
+
let buffer = "";
|
|
461
|
+
const resolveOnce = (value: string | undefined) => {
|
|
462
|
+
if (settled) return;
|
|
463
|
+
settled = true;
|
|
464
|
+
resolve(value);
|
|
465
|
+
};
|
|
466
|
+
const timeoutHandle = setTimeout(() => {
|
|
467
|
+
timedOut = true;
|
|
468
|
+
proc.kill("SIGTERM");
|
|
469
|
+
resolveOnce(undefined);
|
|
470
|
+
}, COOK_TRIGGER_CLASSIFIER_TIMEOUT_MS);
|
|
471
|
+
const processLine = (line: string) => {
|
|
472
|
+
if (!line.trim()) return;
|
|
473
|
+
try {
|
|
474
|
+
const event = JSON.parse(line) as JsonRecord;
|
|
475
|
+
applyLiveRoleEvent(liveActivity, event, messages);
|
|
476
|
+
} catch {
|
|
477
|
+
// ignore malformed lines from the subprocess event stream
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
proc.stdout.on("data", (chunk) => {
|
|
481
|
+
buffer += chunk.toString();
|
|
482
|
+
const lines = buffer.split("\n");
|
|
483
|
+
buffer = lines.pop() ?? "";
|
|
484
|
+
for (const line of lines) processLine(line);
|
|
485
|
+
});
|
|
486
|
+
proc.stderr.on("data", (chunk) => {
|
|
487
|
+
stderr += chunk.toString();
|
|
488
|
+
});
|
|
489
|
+
proc.on("close", (code) => {
|
|
490
|
+
clearTimeout(timeoutHandle);
|
|
491
|
+
if (buffer.trim()) processLine(buffer);
|
|
492
|
+
if (timedOut) return;
|
|
493
|
+
resolveOnce(code === 0 ? liveActivity.lastAssistantText?.trim() || undefined : undefined);
|
|
494
|
+
});
|
|
495
|
+
proc.on("error", () => {
|
|
496
|
+
clearTimeout(timeoutHandle);
|
|
497
|
+
resolveOnce(undefined);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
if (!rawOutput) {
|
|
501
|
+
if (timedOut) {
|
|
502
|
+
return {
|
|
503
|
+
status: "timeout",
|
|
504
|
+
errorMessage: `Trigger classifier timed out after ${COOK_TRIGGER_CLASSIFIER_TIMEOUT_MS}ms.`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return { status: "error", errorMessage: stderr.trim() || "Trigger classifier produced no assistant output." };
|
|
508
|
+
}
|
|
509
|
+
const classification = parseCookTriggerClassification(rawOutput);
|
|
510
|
+
if (!classification) {
|
|
511
|
+
return {
|
|
512
|
+
status: "invalid_output",
|
|
513
|
+
rawOutput,
|
|
514
|
+
errorMessage: "Trigger classifier returned invalid JSON output.",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
return { status: "classified", classification, rawOutput };
|
|
518
|
+
} finally {
|
|
519
|
+
await fsp.rm(systemPromptTemp.dir, { recursive: true, force: true });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function classifyCookTriggerIntentWithAgent(
|
|
524
|
+
params: ClassifyCookTriggerIntentWithAgentParams,
|
|
525
|
+
): Promise<CookTriggerClassifierResult> {
|
|
526
|
+
const recentDiscussion = serializeRecentDiscussionEntries(params.recentEntries);
|
|
527
|
+
const prompt = buildCookTriggerClassifierPrompt({
|
|
528
|
+
projectName: params.projectName,
|
|
529
|
+
inputText: params.inputText,
|
|
530
|
+
recentDiscussion,
|
|
531
|
+
workflowContextLines: params.workflowContextLines,
|
|
532
|
+
});
|
|
533
|
+
const snapshotPath = triggerClassifierSnapshotPath();
|
|
534
|
+
const testFailureMode = triggerClassifierFailureModeFromEnv();
|
|
535
|
+
if (testFailureMode) {
|
|
536
|
+
const result: CookTriggerClassifierResult = {
|
|
537
|
+
status: testFailureMode,
|
|
538
|
+
errorMessage: `Forced trigger classifier ${testFailureMode} for deterministic tests.`,
|
|
539
|
+
};
|
|
540
|
+
maybeWriteCookTriggerClassifierSnapshot(
|
|
541
|
+
{
|
|
542
|
+
projectName: params.projectName,
|
|
543
|
+
inputText: params.inputText,
|
|
544
|
+
recentEntries: params.recentEntries,
|
|
545
|
+
workflowContextLines: params.workflowContextLines ?? [],
|
|
546
|
+
prompt,
|
|
547
|
+
result,
|
|
548
|
+
},
|
|
549
|
+
snapshotPath,
|
|
550
|
+
);
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
const testOutput = asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT);
|
|
554
|
+
if (testOutput) {
|
|
555
|
+
const classification = parseCookTriggerClassification(testOutput);
|
|
556
|
+
const result: CookTriggerClassifierResult = classification
|
|
557
|
+
? { status: "classified", classification, rawOutput: testOutput }
|
|
558
|
+
: {
|
|
559
|
+
status: "invalid_output",
|
|
560
|
+
rawOutput: testOutput,
|
|
561
|
+
errorMessage: "Trigger classifier test override did not match the required JSON schema.",
|
|
562
|
+
};
|
|
563
|
+
maybeWriteCookTriggerClassifierSnapshot(
|
|
564
|
+
{
|
|
565
|
+
projectName: params.projectName,
|
|
566
|
+
inputText: params.inputText,
|
|
567
|
+
recentEntries: params.recentEntries,
|
|
568
|
+
workflowContextLines: params.workflowContextLines ?? [],
|
|
569
|
+
prompt,
|
|
570
|
+
result,
|
|
571
|
+
},
|
|
572
|
+
snapshotPath,
|
|
573
|
+
);
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const result = await runCookTriggerClassifierSubprocess({ ...params, prompt });
|
|
578
|
+
maybeWriteCookTriggerClassifierSnapshot(
|
|
579
|
+
{
|
|
580
|
+
projectName: params.projectName,
|
|
581
|
+
inputText: params.inputText,
|
|
582
|
+
recentEntries: params.recentEntries,
|
|
583
|
+
workflowContextLines: params.workflowContextLines ?? [],
|
|
584
|
+
prompt,
|
|
585
|
+
result,
|
|
586
|
+
},
|
|
587
|
+
snapshotPath,
|
|
588
|
+
);
|
|
589
|
+
return result;
|
|
590
|
+
} catch (error) {
|
|
591
|
+
const result: CookTriggerClassifierResult = {
|
|
592
|
+
status: "error",
|
|
593
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
594
|
+
};
|
|
595
|
+
maybeWriteCookTriggerClassifierSnapshot(
|
|
596
|
+
{
|
|
597
|
+
projectName: params.projectName,
|
|
598
|
+
inputText: params.inputText,
|
|
599
|
+
recentEntries: params.recentEntries,
|
|
600
|
+
workflowContextLines: params.workflowContextLines ?? [],
|
|
601
|
+
prompt,
|
|
602
|
+
result,
|
|
603
|
+
},
|
|
604
|
+
snapshotPath,
|
|
605
|
+
);
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
327
610
|
export async function loadAgentDefinition(cwd: string, role: CompletionRole): Promise<AgentDefinition> {
|
|
328
611
|
const projectAgent = walkUpForDir(cwd, [".pi", "agents", `${role}.md`]);
|
|
329
612
|
const packageAgent = PACKAGE_AGENTS_DIR ? path.join(PACKAGE_AGENTS_DIR, `${role}.md`) : undefined;
|
|
@@ -85,3 +85,45 @@ export type CompletionStatusSurface = {
|
|
|
85
85
|
liveStateDeltas?: string[];
|
|
86
86
|
liveDetailsLines?: string[];
|
|
87
87
|
};
|
|
88
|
+
|
|
89
|
+
export type NaturalLanguageCookTriggerMode = "off" | "assist" | "auto";
|
|
90
|
+
export type CookTriggerIntent = "route_to_cook" | "normal_prompt" | "unclear";
|
|
91
|
+
|
|
92
|
+
export type CookTriggerClassification = {
|
|
93
|
+
intent: CookTriggerIntent;
|
|
94
|
+
confidence: number;
|
|
95
|
+
reason: string;
|
|
96
|
+
focusHint?: string;
|
|
97
|
+
evidence: string[];
|
|
98
|
+
riskFlags: string[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type CookTriggerConfirmationAction = "start_cook" | "keep_chatting" | "cancel";
|
|
102
|
+
|
|
103
|
+
export type CookTriggerConfirmationActionItem = {
|
|
104
|
+
id: CookTriggerConfirmationAction;
|
|
105
|
+
label: string;
|
|
106
|
+
description: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type CookTriggerConfirmationLayout = {
|
|
110
|
+
title: string;
|
|
111
|
+
intro: string;
|
|
112
|
+
evidenceHeading?: string;
|
|
113
|
+
evidenceBody?: string;
|
|
114
|
+
riskHeading?: string;
|
|
115
|
+
riskBody?: string;
|
|
116
|
+
focusHintHeading?: string;
|
|
117
|
+
focusHintBody?: string;
|
|
118
|
+
actionsHeading: string;
|
|
119
|
+
actions: CookTriggerConfirmationActionItem[];
|
|
120
|
+
footer: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type CookTriggerDecision = {
|
|
124
|
+
mode: NaturalLanguageCookTriggerMode;
|
|
125
|
+
action: "continue" | "handled" | "routed_to_cook";
|
|
126
|
+
reason: string;
|
|
127
|
+
classification?: CookTriggerClassification;
|
|
128
|
+
bypassReason?: string;
|
|
129
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linimin/pi-letscook",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.51",
|
|
4
4
|
"description": "Pi package for long-running completion workflows with canonical .agent state, role-based subagents, continuity, and verification helpers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|