@computesdk/pi-loops 1.0.4 → 1.0.6
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/README.md +2 -0
- package/extensions/loop.ts +216 -51
- package/lib/questions.ts +106 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,6 +74,8 @@ Screenshots come from `lib/shot.ts`: headless Chrome driven over the Chrome DevT
|
|
|
74
74
|
|
|
75
75
|
**The pick is yours.** After every judged round you get the round card: when the run has `shots` (either mode) a side-by-side **sheet** of CURRENT and the attempts with their numbers (`.pi/loops/<run>/r<n>.png`, also shown inline in terminals with image support), and a picker — keep the judge's pick, take another attempt instead, show an attempt's diff, or mark an attempt's session to open afterwards (`/loop-open R1-bold`, `/loop-open judge`). The result ends with the **climb**: one line per round, CURRENT, each attempt with its delta, what was kept.
|
|
76
76
|
|
|
77
|
+
**What a round spends.** Every nested model call — the attempts, each judge sample, the council — is counted: the result carries `usage`, and the card ends with `Spent 68.8k tok · $0.0000`. A round started from the TUI returns before it finishes, so there is no tool result to carry that; instead Loops writes one `type: "usage"` entry per model to the session (kind `loops`, note naming the run), which is the entry kind Pi sums into its token and cost totals and shows in the footer. So a background round lands in `/cost` and the footer like any other model call, and `/resume` of that session still adds up.
|
|
78
|
+
|
|
77
79
|
**Steering while it runs.** In the TUI the tool returns as soon as the round has started and the card arrives as a message when the judge is done, so the conversation stays open: `loop_status` (progress), `loop_talk("R1-bold", "…")` (a steering message into that attempt's session), `loop_rule("…")` (told to every running attempt, enforced by the judge from this round on, kept for later rounds), `loop_pick("wild")` (keep that attempt, or `CURRENT`, whatever the judge scores), `loop_stop()` (abort; worktrees stay, `loop({ from })` resumes). After the card, `loop_apply()` takes the run's best into the checkout — the winner's changed tracked files from its commit, as one commit; files it added or deleted are listed, not applied. Headless (`pi -p`) or with `wait: true` the tool blocks until the round is done, as before.
|
|
78
80
|
|
|
79
81
|
Before round 1 of a bench run a **council** (two proposals + a synthesis) writes the rubric the judge judges against; your `rules` become decisive criteria in it.
|
package/extensions/loop.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { better as betterOf, git, restoreProtected, sh, diffText, errText, type
|
|
|
14
14
|
import { addRule, fromRules, newRubric, parseJson, rubricText, writeRubric } from "../lib/rubric.ts";
|
|
15
15
|
import { sheet } from "../lib/sheet.ts";
|
|
16
16
|
import { findChrome } from "../lib/shot.ts";
|
|
17
|
+
import { appendAnswer, appendQuestion, findQuestion, newQuestion, pendingQuestions, questionsOf, questionsText, questionText, type Question, type QuestionOption } from "../lib/questions.ts";
|
|
17
18
|
|
|
18
19
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
/** Package root: `agents/`, `prompts/` and `lib/` sit one level up from `extensions/`. */
|
|
@@ -21,20 +22,47 @@ const ROOT = path.resolve(HERE, "..");
|
|
|
21
22
|
export const STYLES = ["safe", "bold", "wild"];
|
|
22
23
|
export const DEFAULTS = { attempts: 3, rounds: 1, timeoutMin: 15, tokenBudget: null, repeat: 2, minGain: 0.05, judgeSamples: 3, judgePairs: 2, judgePanel: true, council: true, models: {} };
|
|
23
24
|
const STATE_DIR = path.join(".pi", "loops");
|
|
24
|
-
/** Usage of a whole round
|
|
25
|
-
export type
|
|
26
|
-
export
|
|
25
|
+
/** Usage of a whole round, and of each model in it: a round's attempts, judge and council can be different models. */
|
|
26
|
+
export type UsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number } };
|
|
27
|
+
export type Meter = UsageTotals & { byModel: Record<string, UsageTotals> };
|
|
28
|
+
export const emptyTotals = (): UsageTotals => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
|
29
|
+
export const newMeter = (): Meter => ({ ...emptyTotals(), byModel: {} });
|
|
27
30
|
/** Add one message's `usage`. Providers report what they report: anything missing counts as zero, never as NaN. */
|
|
28
|
-
|
|
31
|
+
function addTo(t: UsageTotals, u: any) {
|
|
32
|
+
t.input += u.input || 0; t.output += u.output || 0; t.cacheRead += u.cacheRead || 0; t.cacheWrite += u.cacheWrite || 0;
|
|
33
|
+
t.totalTokens += u.totalTokens || 0;
|
|
34
|
+
t.cost.input += u.cost?.input || 0; t.cost.output += u.cost?.output || 0;
|
|
35
|
+
t.cost.cacheRead += u.cost?.cacheRead || 0; t.cost.cacheWrite += u.cost?.cacheWrite || 0;
|
|
36
|
+
t.cost.total += typeof u.cost === "number" ? u.cost : u.cost?.total || 0;
|
|
37
|
+
}
|
|
38
|
+
export function addUsage(m: Meter | undefined, u: any, model?: string | null) {
|
|
29
39
|
if (!m || !u) return;
|
|
30
|
-
m
|
|
31
|
-
m.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
addTo(m, u);
|
|
41
|
+
if (model) { m.byModel[model] ??= emptyTotals(); addTo(m.byModel[model], u); }
|
|
42
|
+
}
|
|
43
|
+
/** The `usage` a tool result carries: the totals, without the per-model split. */
|
|
44
|
+
export const meterUsage = (m: Meter): UsageTotals => { const { byModel: _byModel, ...totals } = m; return totals; };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A background round cannot report its spend on a tool result — the card arrives as a message — so it records
|
|
48
|
+
* usage entries on the session instead: Pi's totals and the footer scan entries of `type: "usage"`, and such an
|
|
49
|
+
* entry is model-attributed and never enters the LLM context. One entry per model, since each is billed separately.
|
|
50
|
+
*/
|
|
51
|
+
export function recordUsage(sm: SessionManagerLike | undefined, meter: Meter, note: string): string[] {
|
|
52
|
+
const recorded: string[] = [];
|
|
53
|
+
for (const [spec, u] of Object.entries(meter.byModel)) {
|
|
54
|
+
if (!u.totalTokens) continue;
|
|
55
|
+
const cut = spec.indexOf("/");
|
|
56
|
+
const provider = cut > 0 ? spec.slice(0, cut) : spec;
|
|
57
|
+
const model = cut > 0 ? spec.slice(cut + 1) : "unknown";
|
|
58
|
+
if (typeof sm?.appendUsage !== "function") break; // a session manager from an older Pi: nothing to record against
|
|
59
|
+
try { sm.appendUsage("loops", provider, model, u, note); recorded.push(spec); } catch {}
|
|
60
|
+
}
|
|
61
|
+
return recorded;
|
|
35
62
|
}
|
|
63
|
+
|
|
36
64
|
/** `3.2k tok · $0.0410`, for the card and the result. */
|
|
37
|
-
export const meterText = (m:
|
|
65
|
+
export const meterText = (m: UsageTotals | undefined) => m && m.totalTokens ? `${Math.round(m.totalTokens / 100) / 10}k tok · $${m.cost.total.toFixed(4)}` : "";
|
|
38
66
|
|
|
39
67
|
/** A `build` that is our own screenshot CLI (written by `routes`): it needs a browser, so check for one up front. */
|
|
40
68
|
const SELF_SHOT = /[\\/]shot\.ts/;
|
|
@@ -87,7 +115,9 @@ export type ModelRegistryLike = {
|
|
|
87
115
|
getRegisteredNativeProvider?: (provider: string) => unknown;
|
|
88
116
|
};
|
|
89
117
|
/** The session-manager methods Loops calls. */
|
|
90
|
-
export type SessionManagerLike = { getBranch?: () => any[]; getSessionDir?: () => string; getSessionFile?: () => string | undefined
|
|
118
|
+
export type SessionManagerLike = { getBranch?: () => any[]; getSessionDir?: () => string; getSessionFile?: () => string | undefined;
|
|
119
|
+
/** Record spend that came from a session other than this one (`type: "usage"`), which Pi adds to its totals. */
|
|
120
|
+
appendUsage?: (kind: string, provider: string, model: string, usage: any, note?: string) => unknown };
|
|
91
121
|
/** The subset of Pi's UI the extension calls. */
|
|
92
122
|
export type Ui = {
|
|
93
123
|
notify: (message: string, type?: string) => void;
|
|
@@ -101,6 +131,8 @@ export type PickCtx = { cwd: string; model?: ModelRef | null; modelRegistry?: Mo
|
|
|
101
131
|
/** The Pi command/tool context Loops uses (only these fields are touched). */
|
|
102
132
|
export type Ctx = PickCtx & {
|
|
103
133
|
hasUI?: boolean;
|
|
134
|
+
/** `tui` in the terminal, `rpc`/`json` in a programmatic session: only the TUI wants a notify nudge. */
|
|
135
|
+
mode?: string;
|
|
104
136
|
sessionManager?: SessionManagerLike;
|
|
105
137
|
switchSession?: (file: string) => Promise<void>;
|
|
106
138
|
};
|
|
@@ -123,6 +155,8 @@ export type ToolDef<P = any> = {
|
|
|
123
155
|
outputSchema?: any;
|
|
124
156
|
/** One line for the default system prompt's "Available tools" section: without it Pi omits the tool there. */
|
|
125
157
|
promptSnippet?: string;
|
|
158
|
+
/** Rules for the Guidelines section, shown only while this tool is active. Workflow belongs here, not in the snippet. */
|
|
159
|
+
promptGuidelines?: string[];
|
|
126
160
|
/** `model-only` for the tools that ask the user or orchestrate: never callable from scripts. */
|
|
127
161
|
exposure?: "direct" | "model-only" | "codemode" | "deferred" | "hidden";
|
|
128
162
|
/** "sequential" for tools that share in-memory state, so one message's calls cannot interleave. */
|
|
@@ -133,6 +167,8 @@ export type ToolDef<P = any> = {
|
|
|
133
167
|
renderCall?: (args: any, theme: any) => any;
|
|
134
168
|
renderResult?: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) => any;
|
|
135
169
|
};
|
|
170
|
+
/** Resolve a model spec against the registry captured while the ctx was alive. */
|
|
171
|
+
export type ModelPicker = (spec?: string | null) => Model;
|
|
136
172
|
/** A candidate for the judge: a checked-out directory at a commit. */
|
|
137
173
|
export type Candidate = { label: string; dir: string; commit: string; fresh?: boolean };
|
|
138
174
|
/** One round of a run. */
|
|
@@ -366,20 +402,21 @@ export async function stopAll() { shuttingDown = true; const all = [...active];
|
|
|
366
402
|
* *extension* adds (rather than one built into Pi) is unknown inside it and its credential cannot be resolved. Say that
|
|
367
403
|
* instead of pi's "No API key found", which points at /login and sends people the wrong way.
|
|
368
404
|
*/
|
|
369
|
-
function explainAuth(e: unknown, role: string, spec: string | null,
|
|
405
|
+
function explainAuth(e: unknown, role: string, spec: string | null, registry?: ModelRegistryLike): unknown {
|
|
370
406
|
const provider = errText(e).match(/No API key found for ([\w.-]+)/)?.[1]?.replace(/\.+$/, ""); // pi ends the sentence with a period
|
|
371
407
|
if (!provider) return e;
|
|
372
408
|
// the host can see a model on that provider (so this session is missing something the host has), not a bare missing login
|
|
373
|
-
const hostKnows = (
|
|
409
|
+
const hostKnows = (registry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
|
|
374
410
|
if (!hostKnows) return e;
|
|
375
411
|
return new Error(`the ${role} cannot run on ${spec ?? provider}: "${provider}" is a Pi extension's provider, and a one-shot ${role} session loads no extensions, so it has no credentials there — give the ${role} a provider built into Pi (attempts can still use ${provider}).`, { cause: e });
|
|
376
412
|
}
|
|
377
413
|
|
|
378
|
-
async function oneShot({ role, model, content, cwd,
|
|
414
|
+
async function oneShot({ role, model, content, cwd, pick, registry, settings, meter, signal }: { role: string; model?: string | null; content: any; cwd: string; pick: ModelPicker; registry?: ModelRegistryLike; settings: LoopSettings; meter?: Meter; signal?: AbortSignal }) {
|
|
379
415
|
const agentDir = getAgentDir();
|
|
380
416
|
const spec = model ?? settings.models[role] ?? null;
|
|
381
417
|
const agent = loadAgent(role, { cwd, agentDir });
|
|
382
418
|
let session: AgentSession | null = null;
|
|
419
|
+
let pickedModel: Model | null = null; // named in the finally, so the spend can be attributed to it
|
|
383
420
|
const onAbort = () => session?.abort().catch(() => {});
|
|
384
421
|
try {
|
|
385
422
|
// what a one-shot wants: no extensions, no skills, no tools — just the role's system prompt and one answer
|
|
@@ -388,15 +425,16 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
|
|
|
388
425
|
// "no extensions" would also drop the *providers* extensions add (anything not built into Pi), and an unregistered
|
|
389
426
|
// provider has no credentials. Copy the host's registrations over: config only, no extension code runs, still no tools.
|
|
390
427
|
const known = new Set(services.modelRuntime.getRegisteredProviderIds());
|
|
391
|
-
for (const id of
|
|
428
|
+
for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
|
|
392
429
|
if (known.has(id)) continue;
|
|
393
|
-
const config =
|
|
430
|
+
const config = registry?.getRegisteredProviderConfig?.(id);
|
|
394
431
|
if (config) { services.modelRuntime.registerProvider(id, config as any); continue; }
|
|
395
|
-
const native =
|
|
432
|
+
const native = registry?.getRegisteredNativeProvider?.(id); // a provider built with createProvider, not config
|
|
396
433
|
if (native) services.modelRuntime.registerNativeProvider(native as any);
|
|
397
434
|
}
|
|
398
435
|
// creating the session resolves the credential too, so it belongs inside this try: that is where an unusable provider shows up
|
|
399
|
-
|
|
436
|
+
pickedModel = pick(spec ?? agent.model);
|
|
437
|
+
const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pickedModel, noTools: "all" });
|
|
400
438
|
session = track(created);
|
|
401
439
|
const { text, images } = flattenParts(content);
|
|
402
440
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -409,26 +447,26 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
|
|
|
409
447
|
if (last?.stopReason === "error") throw new Error(last.errorMessage || `${role} errored`);
|
|
410
448
|
}
|
|
411
449
|
return parseJson(session.getLastAssistantText());
|
|
412
|
-
} catch (e) { throw explainAuth(e, role, spec,
|
|
450
|
+
} catch (e) { throw explainAuth(e, role, spec, registry); }
|
|
413
451
|
finally {
|
|
414
452
|
signal?.removeEventListener("abort", onAbort);
|
|
415
453
|
if (session) {
|
|
416
454
|
// a one-shot is a whole session with real spend: hand it to the round's meter before the session goes away
|
|
417
|
-
for (const m of session.messages) if (m.role === "assistant") addUsage(meter, (m as { usage?: unknown }).usage);
|
|
455
|
+
for (const m of session.messages) if (m.role === "assistant") addUsage(meter, (m as { usage?: unknown }).usage, pickedModel ? modelSpec(pickedModel) : null);
|
|
418
456
|
release(session);
|
|
419
457
|
}
|
|
420
458
|
}
|
|
421
459
|
}
|
|
422
460
|
|
|
423
461
|
/* ---------- an attempt: forked session + worktree + style agent ---------- */
|
|
424
|
-
async function runAttempt({ state, style, n, dir, cwd,
|
|
462
|
+
async function runAttempt({ state, style, n, dir, cwd, pick, settings, model, meter, sessionDir, src, signal, onProgress, live }: { state: State; style: string; n: number; dir: string; cwd: string; pick: ModelPicker; settings: LoopSettings; model?: string | null; meter?: Meter; sessionDir?: string; src?: string | null; signal?: AbortSignal; onProgress: (progress: any) => void; live?: any }): Promise<Attempt> {
|
|
425
463
|
const agentDir = getAgentDir();
|
|
426
464
|
const agent = loadAgent(style, { cwd, agentDir });
|
|
427
465
|
const fork = state.best.session ?? src;
|
|
428
466
|
const sessionManager = fork && fs.existsSync(fork) ? SessionManager.forkFrom(fork, dir, sessionDir) : SessionManager.create(dir, sessionDir);
|
|
429
467
|
const loader = new DefaultResourceLoader({ cwd: dir, agentDir, noExtensions: true, noPromptTemplates: true, noThemes: true, appendSystemPrompt: [agent.body] });
|
|
430
468
|
await loader.reload();
|
|
431
|
-
const picked =
|
|
469
|
+
const picked = pick(model ?? settings.models[style] ?? agent.model);
|
|
432
470
|
const { session: created } = await createAgentSession({ cwd: dir, agentDir, model: picked, tools: agent.tools?.length ? agent.tools : undefined, resourceLoader: loader, sessionManager });
|
|
433
471
|
const session = track(created);
|
|
434
472
|
live?.attempts.set(`R${n}-${style}`, session);
|
|
@@ -440,7 +478,7 @@ async function runAttempt({ state, style, n, dir, cwd, ctx, settings, model, met
|
|
|
440
478
|
else if (e.type === "message_end" && e.message?.role === "assistant") {
|
|
441
479
|
p.tokens += e.message.usage?.totalTokens || 0;
|
|
442
480
|
p.cost += typeof e.message.usage?.cost === "number" ? e.message.usage.cost : e.message.usage?.cost?.total || 0;
|
|
443
|
-
addUsage(meter, e.message.usage);
|
|
481
|
+
addUsage(meter, e.message.usage, modelSpec(picked));
|
|
444
482
|
const t = (e.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join("").trim();
|
|
445
483
|
if (t) p.text = t.slice(-200);
|
|
446
484
|
onProgress(p);
|
|
@@ -518,14 +556,31 @@ async function agentJSON({ role, model, prompt, ok, cwd, ctx, settings, signal,
|
|
|
518
556
|
}
|
|
519
557
|
|
|
520
558
|
/* ---------- the tool ---------- */
|
|
559
|
+
/** The `loop` result's artifact pointers: a frontend renders a run without guessing where its worktrees keep things. */
|
|
560
|
+
const Artifacts = {
|
|
561
|
+
/** Paths (relative to the repo) that each attempt's build writes — the same paths in every attempt worktree. */
|
|
562
|
+
shots: Type.Optional(Type.Array(Type.String())),
|
|
563
|
+
sheet: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
564
|
+
dir: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
565
|
+
files: Type.Optional(Type.Any()),
|
|
566
|
+
tokens: Type.Optional(Type.Number()),
|
|
567
|
+
cost: Type.Optional(Type.Number()),
|
|
568
|
+
judgeTokens: Type.Optional(Type.Number()),
|
|
569
|
+
judgeCost: Type.Optional(Type.Number()),
|
|
570
|
+
from: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
571
|
+
};
|
|
521
572
|
const Metric = Type.Object({ key: Type.Optional(Type.String({ description: "JSON key in the bench output; default ms" })), unit: Type.Optional(Type.String()), better: Type.Optional(Type.Union([Type.Literal("lower"), Type.Literal("higher")])) });
|
|
522
573
|
const RoundOut = Type.Object({ n: Type.Number(), pick: Type.Union([Type.String(), Type.Null()]), why: Type.Union([Type.String(), Type.Null()]), scores: Type.Record(Type.String(), Type.Union([Type.Number(), Type.Null()])), reasons: Type.Record(Type.String(), Type.String()), sheet: Type.Union([Type.String(), Type.Null()]),
|
|
523
|
-
|
|
574
|
+
from: Artifacts.from, judgeTokens: Artifacts.judgeTokens, judgeCost: Artifacts.judgeCost,
|
|
575
|
+
attempts: Type.Array(Type.Object({ label: Type.String(), style: Type.String(), status: Type.String(), model: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), session: Type.Union([Type.String(), Type.Null()]), summary: Type.String(), cost: Type.Number(), dir: Artifacts.dir, tokens: Artifacts.tokens, bench: Type.Optional(Type.Any()), review: Type.Optional(Type.Any()), vetoed: Type.Optional(Type.Boolean()), restored: Type.Optional(Type.Boolean()) })) });
|
|
524
576
|
export const LoopResult = Type.Object({
|
|
525
577
|
run: Type.String(), status: Type.String(), kind: Type.String(), goal: Type.String(), baseCommit: Type.String(),
|
|
526
578
|
metric: Type.Object({ key: Type.Union([Type.String(), Type.Null()]), unit: Type.Union([Type.String(), Type.Null()]), better: Type.Union([Type.String(), Type.Null()]) }),
|
|
527
|
-
best: Type.Object({ label: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), score: Type.Union([Type.Number(), Type.Null()]), branch: Type.Union([Type.String(), Type.Null()]) }),
|
|
579
|
+
best: Type.Object({ label: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), score: Type.Union([Type.Number(), Type.Null()]), branch: Type.Union([Type.String(), Type.Null()]), round: Type.Optional(Type.Union([Type.Number(), Type.Null()])), dir: Artifacts.dir, files: Artifacts.files }),
|
|
580
|
+
shots: Artifacts.shots,
|
|
581
|
+
rules: Type.Optional(Type.Array(Type.String())),
|
|
528
582
|
rounds: Type.Array(RoundOut),
|
|
583
|
+
questions: Type.Optional(Type.Array(Type.Object({ id: Type.String(), kind: Type.String(), title: Type.String(), options: Type.Array(Type.String()) }))),
|
|
529
584
|
});
|
|
530
585
|
/** `loop_status`: what a script polling a run gets. */
|
|
531
586
|
export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String() });
|
|
@@ -533,15 +588,37 @@ export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String
|
|
|
533
588
|
export const InitResult = Type.Object({ phase: Type.String(), note: Type.Optional(Type.String()), serve: Type.Optional(Type.String()), check: Type.Optional(Type.String()), bench: Type.Optional(Type.String()), protect: Type.Optional(Type.Array(Type.String())) });
|
|
534
589
|
/** `loop_apply`: what landed in the checkout, and what was deliberately left out. */
|
|
535
590
|
export const ApplyResult = Type.Object({ run: Type.String(), applied: Type.Array(Type.String()), commit: Type.Union([Type.String(), Type.Null()]), added: Type.Array(Type.String()), removed: Type.Array(Type.String()) });
|
|
591
|
+
/** `loop_ask`: the queued question, and where a frontend reads it from. */
|
|
592
|
+
export const AskResult = Type.Object({ id: Type.String(), question: Type.String(), options: Type.Array(Type.String()), pending: Type.Number() });
|
|
593
|
+
|
|
594
|
+
/** The `loop` result's artifact pointers: a frontend renders a run without guessing where its worktrees keep things. */
|
|
595
|
+
const ArtifactsOld = null;
|
|
536
596
|
|
|
537
|
-
export function structuredResult(state: Pick<State, "id" | "status" | "kind" | "goal" | "baseCommit" | "metric" | "best">, rounds: Round[]) {
|
|
597
|
+
export function structuredResult(state: Pick<State, "id" | "status" | "kind" | "goal" | "baseCommit" | "metric" | "best"> & Partial<State>, rounds: Round[]) {
|
|
538
598
|
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
539
599
|
return {
|
|
540
|
-
run: state.id, status: state.status, kind: state.kind, goal: state.goal, baseCommit: state.baseCommit,
|
|
600
|
+
run: state.id, status: state.status, kind: state.kind, goal: state.goal, baseCommit: state.baseCommit ?? "",
|
|
541
601
|
metric: { key: state.metric?.key ?? null, unit: state.metric?.unit ?? null, better: state.metric?.better ?? null },
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
602
|
+
shots: state.shots ?? [],
|
|
603
|
+
rules: state.rules ?? [],
|
|
604
|
+
best: { label: state.best?.label ?? null, commit: state.best?.commit ?? null, score: n(state.best?.score), branch: state.best?.branch ?? null, round: n(state.best?.round), dir: state.best?.dir ?? null, files: state.winner?.files ?? null },
|
|
605
|
+
rounds: rounds.map((r) => ({ n: r.n, from: r.from ?? null, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null, scores: Object.fromEntries(Object.entries(r.judge?.scores ?? {}).map(([k, v]) => [k, n(v)])), reasons: Object.fromEntries(Object.entries(r.judge?.reasons ?? {}).map(([k, v]) => [k, String(v)])), sheet: r.sheet ?? null, judgeTokens: n(r.judgeTokens), judgeCost: n(r.judgeCost),
|
|
606
|
+
attempts: r.attempts.map((a) => ({ label: a.label, style: a.style, status: a.status, model: a.model ?? null, commit: a.commit ?? null, session: a.session ?? null, summary: a.summary ?? "", cost: n(a.cost) ?? 0, tokens: n(a.tokens), dir: a.dir ?? null, ...(a.bench ? { bench: a.bench } : {}), ...(a.review ? { review: a.review } : {}), ...(a.vetoed ? { vetoed: true } : {}), ...(a.restored ? { restored: true } : {}) })) })),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** One judged round as a session entry: history that survives deleting `.pi/loops/<run>.json` and needs no query. */
|
|
611
|
+
export function roundEntry(state: State, r: Round) {
|
|
612
|
+
return {
|
|
613
|
+
run: state.id, n: r.n, status: r.status, kind: state.kind, goal: state.goal, metric: state.metric ?? null,
|
|
614
|
+
from: r.from ?? null, baseCommit: state.baseCommit ?? null, shots: state.shots ?? [], rules: state.rules ?? [],
|
|
615
|
+
pick: r.judge?.pick ?? null, why: r.judge?.why ?? null, human: !!r.judge?.human,
|
|
616
|
+
scores: r.judge?.scores ?? {}, reasons: r.judge?.reasons ?? {}, byModel: r.judge?.byModel ?? null,
|
|
617
|
+
sheet: r.sheet ?? null, judgeSession: r.judgeSession ?? null, judgeTokens: r.judgeTokens ?? null, judgeCost: r.judgeCost ?? null,
|
|
618
|
+
startedAt: r.startedAt ?? null, endedAt: r.endedAt ?? null,
|
|
619
|
+
best: { label: state.best?.label ?? null, commit: state.best?.commit ?? null, score: state.best?.score ?? null, round: state.best?.round ?? null, dir: state.best?.dir ?? null },
|
|
620
|
+
attempts: r.attempts.map((a) => ({ label: a.label, style: a.style, model: a.model ?? null, status: a.status, commit: a.commit ?? null, session: a.session ?? null, dir: a.dir ?? null, summary: a.summary ?? "", tokens: a.tokens ?? null, cost: a.cost ?? null, bench: a.bench ?? null, review: a.review ?? null })),
|
|
621
|
+
at: Date.now(),
|
|
545
622
|
};
|
|
546
623
|
}
|
|
547
624
|
export const LoopParams = Type.Object({
|
|
@@ -783,18 +860,19 @@ export function runsText(cwd: string): string {
|
|
|
783
860
|
return files.map((f) => { try { const st = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); return `${st.id} — ${st.status} — ${st.goal.slice(0, 60)}\n${climbText(st).split("\n").map((l) => " " + l).join("\n")}`; } catch { return f; } }).join("\n");
|
|
784
861
|
}
|
|
785
862
|
|
|
786
|
-
export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs"];
|
|
863
|
+
export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs", "questions", "answer"];
|
|
787
864
|
/** Bare, or a subcommand word with at most its argument(s) — anything longer is a goal ("rounds of golf should load faster"); bad values error in loopsCommand instead of starting a round. */
|
|
788
865
|
export function isSubcommand(args?: string | null): boolean {
|
|
789
866
|
const w = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
790
867
|
if (!w.length) return true;
|
|
791
868
|
const sub = w[0].toLowerCase();
|
|
792
869
|
if (!SUBCOMMANDS.includes(sub)) return false;
|
|
793
|
-
if (sub === "models" || sub === "runs" || sub === "init") return w.length === 1;
|
|
870
|
+
if (sub === "models" || sub === "runs" || sub === "init" || sub === "questions") return w.length === 1;
|
|
794
871
|
if (sub === "open") return w.length <= 3;
|
|
872
|
+
if (sub === "answer") return w.length >= 2 && w.length <= 8;
|
|
795
873
|
return w.length <= 2;
|
|
796
874
|
}
|
|
797
|
-
const LOOPS_USAGE = "/loop — card and picker · /loop <goal> — run a round · /loop init — project setup (serve, check, bench, protect) · /loop models · /loop rounds <n> · /loop budget <tokens, e.g. 800k|1.5m> · /loop timeout <min> · /loop open <R1-safe|judge> [run] · /loop runs";
|
|
875
|
+
const LOOPS_USAGE = "/loop — card and picker · /loop <goal> — run a round · /loop init — project setup (serve, check, bench, protect) · /loop models · /loop rounds <n> · /loop budget <tokens, e.g. 800k|1.5m> · /loop timeout <min> · /loop open <R1-safe|judge> [run] · /loop runs · /loop questions · /loop answer <id> [option]";
|
|
798
876
|
export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void> {
|
|
799
877
|
const [first, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
800
878
|
const sub = first?.toLowerCase();
|
|
@@ -818,6 +896,34 @@ export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void
|
|
|
818
896
|
case "open": { if (!rest[0]) { ctx.ui.notify("/loop open <R1-safe|judge> [run id]", "info"); return; } await openSession(ctx, rest[0], rest[1]); return; }
|
|
819
897
|
case "init": await pi.sendUserMessage?.(initPrompt()); return;
|
|
820
898
|
case "runs": ctx.ui.notify(runsText(ctx.cwd), "info"); return;
|
|
899
|
+
case "questions": {
|
|
900
|
+
const list = questionsOf(ctx);
|
|
901
|
+
ctx.ui.notify(questionsText(list.slice(0, 12)), "info");
|
|
902
|
+
const open = pendingQuestions(ctx);
|
|
903
|
+
if (open.length) {
|
|
904
|
+
const q = open[0];
|
|
905
|
+
const choice = await ctx.ui.select(questionText(q), q.options.map((o) => o.label));
|
|
906
|
+
if (choice) await loopsCommand(`answer ${q.id} ${choice}`, ctx, pi);
|
|
907
|
+
}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
case "answer": {
|
|
911
|
+
const open = questionsOf(ctx).filter((x) => !x.answer);
|
|
912
|
+
const q = findQuestion(open.map((x) => x.question), rest[0]);
|
|
913
|
+
if (!q) { ctx.ui.notify(`no open question ${rest[0] ?? ""} — /loop questions`, "error"); return; }
|
|
914
|
+
const want = rest.slice(1).join(" ").trim();
|
|
915
|
+
if (!want) { ctx.ui.notify(`which answer? ${q.options.map((o) => o.label).join(" · ")}`, "error"); return; }
|
|
916
|
+
const num = Number(want);
|
|
917
|
+
const choice = Number.isInteger(num) && num >= 1 && num <= q.options.length ? q.options[num - 1].label
|
|
918
|
+
: q.options.find((o) => o.label.toLowerCase() === want.toLowerCase())?.label
|
|
919
|
+
?? q.options.find((o) => o.label.toLowerCase().includes(want.toLowerCase()))?.label;
|
|
920
|
+
if (!choice) { ctx.ui.notify(`"${want}" is not one of: ${q.options.map((o) => o.label).join(" · ")}`, "error"); return; }
|
|
921
|
+
appendAnswer(pi, { id: q.id, choice, by: "human", answeredAt: Date.now() });
|
|
922
|
+
ctx.ui.notify(`answered ${q.id}: ${choice}`, "info");
|
|
923
|
+
// the answer has to reach the agent: it arrives as a user message, the same way any other steering does
|
|
924
|
+
await pi.sendUserMessage?.(`Answer to ${q.id}${q.run ? ` (${q.run}${q.round ? ` r${q.round}` : ""})` : ""} — ${q.title}\n${choice}\n\nIf this changes a run, act on it now with loop_pick / loop_rule / loop_stop / loop_apply / loop({ from }).`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
821
927
|
default: ctx.ui.notify(LOOPS_USAGE, "info");
|
|
822
928
|
}
|
|
823
929
|
}
|
|
@@ -832,12 +938,20 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
832
938
|
});
|
|
833
939
|
// state is module-scoped, so a session that starts after one has shut down must not inherit its latch
|
|
834
940
|
pi.on?.("session_start", async () => { shuttingDown = false; RUNS.clear(); });
|
|
835
|
-
pi.on?.("session_shutdown", async () => {
|
|
941
|
+
pi.on?.("session_shutdown", async () => {
|
|
942
|
+
shuttingDown = true;
|
|
943
|
+
const runs = [...RUNS.values()];
|
|
944
|
+
for (const r of runs) r.ctrl.abort();
|
|
945
|
+
// disposing the session invalidates the ctx, so the runs must be finished before this returns, not just aborted
|
|
946
|
+
await Promise.allSettled(runs.map((r) => r.promise));
|
|
947
|
+
await stopAll();
|
|
948
|
+
});
|
|
836
949
|
async function run(params: LoopParamsIn, signal: AbortSignal | undefined, onUpdate: ((update: any) => void) | undefined, ctx: Ctx, live: any) {
|
|
837
950
|
const cwd = ctx.cwd;
|
|
951
|
+
const hasUI = ctx.hasUI ?? false; // a getter on the ctx proxy: stale after replacement, so read it once
|
|
838
952
|
let settings = loopSettings(pi, cwd);
|
|
839
953
|
const chosen = Object.keys(settings.models).some((k) => k !== "pool");
|
|
840
|
-
if (!params.from && !chosen &&
|
|
954
|
+
if (!params.from && !chosen && hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
|
|
841
955
|
const r = await pickModels(ctx, { reason: "First loop here" });
|
|
842
956
|
if (r && "error" in r) throw new Error(r.error);
|
|
843
957
|
if (r && "models" in r) settings = { ...settings, models: { ...settings.models, ...r.models } };
|
|
@@ -872,7 +986,7 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
872
986
|
if (SELF_SHOT.test(state.build ?? "")) findChrome();
|
|
873
987
|
// a visual round is only as good as the eyes on it: refuse a judge (or panel member) that cannot see the screenshots
|
|
874
988
|
if (judgedOnScreenshots(state)) {
|
|
875
|
-
const asModel = (spec: string | null | undefined) => { try { return
|
|
989
|
+
const asModel = (spec: string | null | undefined) => { try { return pick(spec); } catch { return null; } }; // an unavailable model reports itself later
|
|
876
990
|
const panelOn = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1;
|
|
877
991
|
const problem = visionProblem([
|
|
878
992
|
{ role: "judge", key: "judge", spec: models.judge, model: asModel(models.judge) },
|
|
@@ -888,23 +1002,41 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
888
1002
|
const runDir = path.join(cwd, STATE_DIR, state.id);
|
|
889
1003
|
// every nested model call in this round — attempts, judge samples, council — adds to one meter, reported as the result's `usage`
|
|
890
1004
|
const meter = newMeter();
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
const
|
|
1005
|
+
// A TUI call returns as soon as the round starts, and `ctx` is a getter proxy that throws once the session is
|
|
1006
|
+
// replaced (`runner.assertActive()`), so the background run holds only values read while it is still alive: the
|
|
1007
|
+
// model registry object itself, the session paths, and the status sink. `bindUI` rebinds the new session's UI,
|
|
1008
|
+
// so the old one is not touched after a switch.
|
|
1009
|
+
const sessionManager = ctx.sessionManager;
|
|
1010
|
+
const sessionDir = sessionManager?.getSessionDir?.();
|
|
1011
|
+
const sessionFile = sessionManager?.getSessionFile?.() ?? null;
|
|
1012
|
+
const pickCtx: PickCtx = { cwd, model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui };
|
|
1013
|
+
const pick: ModelPicker = (spec) => pickModel(pickCtx, spec);
|
|
1014
|
+
const statusSink = ctx.ui;
|
|
1015
|
+
let alive = true;
|
|
1016
|
+
const setStatus = (text?: string) => { if (alive) { try { statusSink.setStatus?.("loops", text); } catch {} } };
|
|
896
1017
|
const details: Details = { run: state.id, kind: state.kind, round: null, judge: null, attempts: [], phase: "starting" };
|
|
897
1018
|
if (live) { live.id = state.id; live.state = state; live.details = details; live.cwd = cwd; live.started?.(); }
|
|
898
1019
|
const push = (phase?: string) => { details.phase = phase ?? details.phase; if (live) setStatus(`${state.id} r${details.round ?? "?"} · ${details.phase}`); onUpdate?.({ content: [{ type: "text", text: `${state.id} r${details.round ?? "?"} · ${details.phase}\n${details.attempts.map((a) => ` ${a.style.padEnd(5)} ${a.status.padEnd(8)} ${a.tool ? `[${a.tool}] ` : ""}${a.tools} tools, ${a.tokens} tok${a.text ? ` — ${a.text.split("\n").pop()?.slice(0, 80) ?? ""}` : ""}`).join("\n")}` }], details: structuredClone(details) }); };
|
|
899
|
-
const askModel = (role: string, i: number = 0) => (content: any) => oneShot({ role, model: role === "council" ? (models.council ?? [])[i % (models.council?.length || 1)] : models[role], content, cwd,
|
|
900
|
-
const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd,
|
|
1020
|
+
const askModel = (role: string, i: number = 0) => (content: any) => oneShot({ role, model: role === "council" ? (models.council ?? [])[i % (models.council?.length || 1)] : models[role], content, cwd, pick, registry: pickCtx.modelRegistry, settings, meter, signal });
|
|
1021
|
+
const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd, pick, registry: pickCtx.modelRegistry, settings, meter, signal });
|
|
901
1022
|
// the judge panel: every council model (the attempts' models) scores every candidate, labels blind; the judge model keeps vetoes and the head-to-head
|
|
902
1023
|
const panel = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1 ? (models.council ?? []).map((m) => ({ name: shortModel(m), ask: askAs("judge", m) })) : undefined;
|
|
903
1024
|
state.models = { ...Object.fromEntries(STYLES.map((st) => [st, models[st]])), judge: models.judge, council: models.council, spread: models.spread };
|
|
904
1025
|
const needsYou = async (title: string, options: string[], fallback: string): Promise<string> => {
|
|
905
|
-
if (
|
|
1026
|
+
if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
|
|
906
1027
|
return fallback;
|
|
907
1028
|
};
|
|
1029
|
+
// A queued question never blocks the round: it is an append-only session entry that the TUI, a script and a
|
|
1030
|
+
// frontend all read, and that stops being shown on its own if the round makes it moot.
|
|
1031
|
+
const askHuman = (fields: { kind: Question["kind"]; title: string; body?: string | null; options: QuestionOption[]; expiresInMin?: number }): Question | null => {
|
|
1032
|
+
try {
|
|
1033
|
+
const q = newQuestion({ run: state.id, round: details.round ?? null, kind: fields.kind, title: fields.title, body: fields.body ?? null, options: fields.options, expiresAt: Date.now() + (fields.expiresInMin ?? 360) * 60_000 });
|
|
1034
|
+
appendQuestion(pi, q);
|
|
1035
|
+
// the TUI has no queue to open: nudge once, and /loop questions shows the card
|
|
1036
|
+
try { if (ctx.mode === "tui") ctx.ui.notify(`Question for you (${q.kind})\n${questionText(q)}\n\n/loop questions · /loop answer ${q.id}`, "info"); } catch {}
|
|
1037
|
+
return q;
|
|
1038
|
+
} catch { return null; }
|
|
1039
|
+
};
|
|
908
1040
|
|
|
909
1041
|
if (!state.rubric && settings.council !== false && state.kind === "bench") {
|
|
910
1042
|
push("council writes the rubric");
|
|
@@ -934,7 +1066,7 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
934
1066
|
try { git(cwd, "worktree", "remove", "-f", dir); } catch {}
|
|
935
1067
|
git(cwd, "worktree", "add", "-q", "-f", "-B", branch, dir, round.from ?? state.baseCommit);
|
|
936
1068
|
const slot = details.attempts.find((a) => a.style === style)!;
|
|
937
|
-
const r = await runAttempt({ state, style, n, dir, cwd,
|
|
1069
|
+
const r = await runAttempt({ state, style, n, dir, cwd, pick, settings, model: models[style], meter, sessionDir, src: sessionFile, signal, live, onProgress: (p) => { Object.assign(slot, p); push(); } });
|
|
938
1070
|
Object.assign(slot, { status: r.status, tool: null, text: r.summary || r.error || "" }); push();
|
|
939
1071
|
return { ...r, branch, dir: path.relative(cwd, dir) };
|
|
940
1072
|
}));
|
|
@@ -1017,7 +1149,7 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1017
1149
|
else judge.why = `${judge.why} (your pick ${live.pick} was not a passing attempt)`;
|
|
1018
1150
|
live.pick = null;
|
|
1019
1151
|
}
|
|
1020
|
-
if (
|
|
1152
|
+
if (hasUI && !live && takeable.length) {
|
|
1021
1153
|
for (;;) {
|
|
1022
1154
|
const others = takeable.filter((a) => a.label !== judge.pick);
|
|
1023
1155
|
const opts = [`Keep ${judge.pick}${judge.pick === "CURRENT" ? " (nothing beat it)" : " (judge's pick)"}`, ...others.map((a) => `Take ${a.label} instead`), ...valid.map((a) => `Show ${a.label}'s diff`), ...valid.filter((a) => a.session).map((a) => `Open ${a.label}'s session after the run`)];
|
|
@@ -1046,6 +1178,14 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1046
1178
|
}
|
|
1047
1179
|
}
|
|
1048
1180
|
saveState(cwd, state);
|
|
1181
|
+
try { pi.appendEntry?.("loops-round", roundEntry(state, round)); } catch {}
|
|
1182
|
+
if (live) {
|
|
1183
|
+
const vetoed = results.filter((a) => a.review && a.review.ok === false);
|
|
1184
|
+
const bestTakeable = takeable.find((a) => a.label === judge.pick) ?? takeable[0];
|
|
1185
|
+
if (!takeable.length) askHuman({ kind: "stall", title: `Round ${n} of ${state.id} kept nothing`, body: String(judge.why ?? "").slice(0, 400), options: [{ label: "Keep climbing", suggested: true }, { label: "Stop the loop" }, { label: "Change the rules first" }] });
|
|
1186
|
+
else if (judge.pick === "CURRENT") askHuman({ kind: "close-call", title: `Round ${n}: nothing beat the current best — take ${bestTakeable.label} anyway?`, body: String(judge.why ?? "").slice(0, 400), options: [{ label: "Keep the current best", suggested: true }, { label: `Take ${bestTakeable.label} anyway` }] });
|
|
1187
|
+
else if (vetoed.length) askHuman({ kind: "veto", title: `Round ${n}: ${vetoed.map((a) => a.label).join(", ")} vetoed by the judge`, body: vetoed.map((a) => `${a.label}${a.summary ? ` — ${a.summary}` : ""}: ${a.review?.note ?? "rule break"}`).join("\n").slice(0, 400), options: [{ label: `Keep ${judge.pick}`, suggested: true }, ...vetoed.map((a) => ({ label: `Take ${a.label} anyway` }))] });
|
|
1188
|
+
}
|
|
1049
1189
|
done.push(round);
|
|
1050
1190
|
push(`round ${n}: kept ${judge.pick}`);
|
|
1051
1191
|
}
|
|
@@ -1053,17 +1193,28 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1053
1193
|
state.status = aborted ? "aborted" : "done";
|
|
1054
1194
|
saveState(cwd, state);
|
|
1055
1195
|
link();
|
|
1196
|
+
// The winner is a commit in a worktree, not in the checkout: the last question a run asks is whether to land it.
|
|
1197
|
+
if (live && state.status === "done" && state.winner?.files?.changed?.length) {
|
|
1198
|
+
const files: string[] = state.winner.files.changed;
|
|
1199
|
+
askHuman({ kind: "apply", expiresInMin: 24 * 60, title: `Apply ${state.best.label} to the checkout?`, body: `Replaces ${files.slice(0, 6).join(", ")}${files.length > 6 ? ` and ${files.length - 6} more` : ""} (tracked files only, one commit)${state.best.score != null ? ` — ${fmt(state.best.score, state.metric?.unit)} from the start` : ""}.`, options: [{ label: "Apply to the checkout", suggested: true }, { label: "Not now" }] });
|
|
1200
|
+
}
|
|
1056
1201
|
const text = (aborted ? `Aborted during round ${details.round}; attempts' worktrees are kept under ${path.relative(cwd, runDir)}/. Resume with loop({ from: "${state.id}" }).\n\n` : "") + resultText(state, done, cwd);
|
|
1057
1202
|
const lastSheet = [...done].reverse().find((r) => r.sheet)?.sheet;
|
|
1203
|
+
// a background round's spend has no tool result to ride on, so it goes into the session's own entries
|
|
1204
|
+
if (live) recordUsage(sessionManager, meter, `loop ${state.id}`);
|
|
1058
1205
|
const spend = meterText(meter);
|
|
1059
1206
|
const content: any[] = [{ type: "text", text: spend ? `${text}\nSpent ${spend} on this run's models.` : text }];
|
|
1060
1207
|
if (lastSheet) { try { content.push({ type: "image", data: fs.readFileSync(path.join(cwd, lastSheet)).toString("base64"), mimeType: "image/png" }); } catch {} }
|
|
1061
|
-
return { content, usage: meter.totalTokens ? meter : undefined, structuredContent: structuredResult(state, state.rounds), details: { ...structuredClone(details), phase: aborted ? "aborted" : "done", status: state.status, best: state.best, winner: state.winner, spent: spend || null, rounds: done.map((r) => ({ n: r.n, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null })) } };
|
|
1208
|
+
return { content, usage: meter.totalTokens ? meterUsage(meter) : undefined, structuredContent: structuredResult(state, state.rounds), details: { ...structuredClone(details), phase: aborted ? "aborted" : "done", status: state.status, best: state.best, winner: state.winner, spent: spend || null, rounds: done.map((r) => ({ n: r.n, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null })) } };
|
|
1062
1209
|
}
|
|
1063
1210
|
|
|
1064
1211
|
pi.registerTool?.({
|
|
1065
1212
|
name: "loop", exposure: "model-only", executionMode: "sequential",
|
|
1066
|
-
promptSnippet: "run hill-climbing rounds: three forked attempts in git worktrees, a judge keeps the best",
|
|
1213
|
+
promptSnippet: "run hill-climbing rounds: three forked attempts in git worktrees, a judge keeps the best",
|
|
1214
|
+
promptGuidelines: [
|
|
1215
|
+
"When the ask is comparative — make this faster, improve this, which of these is better — prefer `loop` over editing by hand: it forks attempts into their own worktrees and a judge keeps the best, and your checkout is untouched until `loop_apply`.",
|
|
1216
|
+
"Give a `loop` round a `bench` when a number can be measured, and `routes` when the goal is what a page looks like; without either it is judged on the diffs.",
|
|
1217
|
+
], annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1067
1218
|
label: "loop",
|
|
1068
1219
|
outputSchema: LoopResult,
|
|
1069
1220
|
description: "Run hill-climbing round(s) on the current git repo: three forked attempt sessions (safe/bold/wild agents) in their own worktrees, a judge keeps the best. Bench mode when `bench` is set (each attempt sets up and measures itself; a judge agent re-measures every candidate, reads the diffs and vetoes rule breaks); visual mode otherwise (`build` writes `shots`, a judge panel scores them). `from` continues a run from its last judged round.",
|
|
@@ -1083,12 +1234,13 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1083
1234
|
if (!choice?.startsWith("Approve")) return notRun("discuss", "Not run. Discuss the spec with me in chat (what is unclear, what should be different), then call loop again when we agree.");
|
|
1084
1235
|
}
|
|
1085
1236
|
if (!ctx.hasUI || params.wait) return run(params, signal, onUpdate, ctx, null);
|
|
1237
|
+
const ui = ctx.ui; // the object, held by value: the ctx getter throws once the session is replaced
|
|
1086
1238
|
const live: any = { id: null, state: null, details: null, cwd: ctx.cwd, attempts: new Map(), pick: null, last: null, error: null, ctrl: new AbortController(), startedAt: Date.now() };
|
|
1087
1239
|
const started = new Promise((resolve) => { live.started = resolve; });
|
|
1088
1240
|
live.promise = run(params, live.ctrl.signal, (u) => { live.last = u; }, ctx, live).then(
|
|
1089
1241
|
(r) => { try { pi.sendUserMessage?.(r.content, { deliverAs: "followUp" }); } catch {} },
|
|
1090
1242
|
(e) => { live.error = e; if (live.id) { try { pi.sendUserMessage?.(`Loop ${live.id} failed: ${e.message}`, { deliverAs: "followUp" }); } catch {} } }
|
|
1091
|
-
).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try {
|
|
1243
|
+
).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ui.setStatus?.("loops", undefined); } catch {} });
|
|
1092
1244
|
await started;
|
|
1093
1245
|
if (!live.id) throw live.error ?? new Error("the loop did not start");
|
|
1094
1246
|
RUNS.set(live.id, live);
|
|
@@ -1123,13 +1275,13 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1123
1275
|
} });
|
|
1124
1276
|
pi.registerTool?.({ name: "loop_status", promptSnippet: "progress of a loop round running in the background", outputSchema: StatusResult, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_status", description: "Progress of a loop round running in the background: phase, each attempt's status, tools and tokens.", parameters: Type.Object({ id: idParam }),
|
|
1125
1277
|
async execute(_id, p) { const r = liveRun(p.id); return { content: r.last?.content ?? [{ type: "text", text: `${r.id} starting` }], details: r.details, structuredContent: { run: r.id, phase: String(r.details?.phase ?? "starting") } }; } });
|
|
1126
|
-
pi.registerTool?.({ name: "loop_talk", promptSnippet: "tell a running attempt something", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
1278
|
+
pi.registerTool?.({ name: "loop_talk", promptSnippet: "tell a running attempt something", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, label: "loop_talk", description: "Tell a running attempt something (R1-bold, or just bold): delivered into its session as a steering message it acts on next.", parameters: Type.Object({ attempt: Type.String(), message: Type.String(), id: idParam }),
|
|
1127
1279
|
async execute(_id, p) { const r = liveRun(p.id); const key = findAttempt(r, p.attempt); if (!key) throw new Error(`${p.attempt} is not running (running: ${[...r.attempts.keys()].join(", ") || "none"})`); await r.attempts.get(key).steer(`Message from the person running this loop: ${p.message}`); return said(`told ${key}: ${p.message}`); } });
|
|
1128
1280
|
pi.registerTool?.({ name: "loop_rule", promptSnippet: "add a rule a running loop must follow", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_rule", description: "Add a rule to a running loop: every running attempt is told now, the judge enforces it from this round on, and it stays for later rounds.", parameters: Type.Object({ rule: Type.String(), id: idParam }),
|
|
1129
1281
|
async execute(_id, p) { const r = liveRun(p.id); if (!r.state.rules.includes(p.rule)) { r.state.rules.push(p.rule); if (r.state.rubric) addRule(r.state.rubric, p.rule); saveState(r.cwd, r.state); } await Promise.allSettled([...r.attempts.values()].map((s) => s.steer(`New rule for this loop, from the person running it: ${p.rule}`))); return said(`rule added: ${p.rule} (told ${r.attempts.size} running attempt${r.attempts.size === 1 ? "" : "s"})`); } });
|
|
1130
1282
|
pi.registerTool?.({ name: "loop_pick", promptSnippet: "keep a specific attempt as the winner yourself", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_pick", description: "Decide the running round yourself: when the judge is done, keep this attempt (R1-wild, or just wild; or CURRENT) as the best if its check passes, whatever the judge scored.", parameters: Type.Object({ attempt: Type.String(), id: idParam }),
|
|
1131
1283
|
async execute(_id, p) { const r = liveRun(p.id); r.pick = p.attempt; return said(`when round ${r.details?.round ?? "?"} of ${r.id} is judged, ${p.attempt} is kept`); } });
|
|
1132
|
-
pi.registerTool?.({ name: "loop_apply", promptSnippet: "take a run's best into the checkout (tracked files only)", outputSchema: ApplyResult, executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, label: "loop_apply", description: "Take a run's best into the checkout: checks out the winner's changed tracked files from its commit and commits them (files it added or deleted are listed, not applied). Default: this session's last run.", parameters: Type.Object({ id: idParam }),
|
|
1284
|
+
pi.registerTool?.({ name: "loop_apply", promptSnippet: "take a run's best into the checkout (tracked files only)", outputSchema: ApplyResult, executionMode: "sequential", promptGuidelines: ["`loop_apply` takes the winner's changed *tracked* files as one commit; files it added or deleted are listed, not applied — copy those yourself if the round needs them."], annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, label: "loop_apply", description: "Take a run's best into the checkout: checks out the winner's changed tracked files from its commit and commits them (files it added or deleted are listed, not applied). Default: this session's last run.", parameters: Type.Object({ id: idParam }),
|
|
1133
1285
|
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
1134
1286
|
const mine = !p.id || p.id === "last" ? sessionRuns(ctx).find((id) => fs.existsSync(stateFile(ctx.cwd, id))) : null;
|
|
1135
1287
|
const state = loadState(ctx.cwd, mine ?? p.id);
|
|
@@ -1142,6 +1294,19 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1142
1294
|
return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files },
|
|
1143
1295
|
structuredContent: { run: state.id, applied: r.applied, commit: r.commit ?? null, added: r.files.added, removed: r.files.removed } };
|
|
1144
1296
|
} });
|
|
1297
|
+
pi.registerTool?.({ name: "loop_ask", exposure: "model-only", executionMode: "sequential",
|
|
1298
|
+
promptSnippet: "ask the person a decision as tap-able options (queued, never blocking)",
|
|
1299
|
+
promptGuidelines: ["Use `loop_ask` instead of asking in prose when the decision is the person's (which direction, whether to keep a version, whether to stop, whether to apply a winner): 2-4 options of a few words each. It does not block you; the answer arrives as a user message starting `Answer to <id>`."],
|
|
1300
|
+
label: "loop_ask", outputSchema: AskResult, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1301
|
+
description: "Ask the person who owns this repo a decision, as a queued card with tap-able options. Nothing blocks: the question is a session entry that the TUI shows via /loop questions and a frontend shows as a card, and the answer arrives later as a user message. Round outcomes (a veto, a close call, nothing passing, a winner to apply) raise these on their own.",
|
|
1302
|
+
parameters: Type.Object({ question: Type.String({ description: "One line, phrased as the decision" }), options: Type.Array(Type.String(), { description: "2-4 short answers" }), body: Type.Optional(Type.String({ description: "A sentence or two of context" })), suggested: Type.Optional(Type.Number({ description: "1-based index of the option you recommend" })), expiresInMin: Type.Optional(Type.Number({ description: "Hours-ish window before it stops being shown; default a day" })), id: idParam }),
|
|
1303
|
+
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
1304
|
+
const options: QuestionOption[] = (p.options as string[]).map((s, i) => ({ label: s, ...(p.suggested === i + 1 ? { suggested: true } : {}) }));
|
|
1305
|
+
const q = newQuestion({ run: p.id && p.id !== "last" ? p.id : null, kind: "ask", title: p.question, body: p.body ?? null, options, expiresAt: Date.now() + (p.expiresInMin ?? 24 * 60) * 60_000 });
|
|
1306
|
+
appendQuestion(pi, q);
|
|
1307
|
+
try { if (ctx.mode === "tui") ctx.ui.notify(`Question for you\n${questionText(q)}\n\n/loop answer ${q.id}`, "info"); } catch {}
|
|
1308
|
+
return { content: [{ type: "text", text: `asked ${q.id}: ${q.title} — ${options.map((o) => o.label).join(" / ")}. The answer arrives as a user message.` }], details: { id: q.id }, structuredContent: { id: q.id, question: q.title, options: options.map((o) => o.label), pending: pendingQuestions(ctx).length } };
|
|
1309
|
+
} });
|
|
1145
1310
|
pi.registerTool?.({ name: "loop_stop", promptSnippet: "stop a running round; its worktrees stay", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, label: "loop_stop", description: "Stop a round running in the background: aborts its attempts and judge. What they committed stays in the worktrees; loop({ from }) resumes the run.", parameters: Type.Object({ id: idParam }),
|
|
1146
1311
|
async execute(_id, p) { const r = liveRun(p.id); r.ctrl.abort(); await r.promise.catch(() => {}); return said(`${r.id} stopped. Resume with loop({ from: "${r.id}" }).`); } });
|
|
1147
1312
|
}
|
package/lib/questions.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Queued questions: a loop asks, the person answers when they get to it.
|
|
2
|
+
//
|
|
3
|
+
// A question is two session entries, `loops-question` and `loops-answer`, so the TUI, a script and any frontend read
|
|
4
|
+
// the same append-only truth and nothing needs a side channel. Nothing here blocks: the loop keeps climbing while a
|
|
5
|
+
// question sits unanswered, and a question that the loop itself made moot (an attempt picked by hand, a new rule) is
|
|
6
|
+
// simply left to expire.
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export type QuestionOption = { label: string; suggested?: boolean };
|
|
10
|
+
|
|
11
|
+
/** What a loop needs from the person running it. */
|
|
12
|
+
export type Question = {
|
|
13
|
+
id: string;
|
|
14
|
+
/** The run that asked, when it came from one. */
|
|
15
|
+
run?: string | null;
|
|
16
|
+
round?: number | null;
|
|
17
|
+
/** `ask` is raised on purpose; the rest are raised by a round's outcome. */
|
|
18
|
+
kind: "ask" | "close-call" | "veto" | "stall" | "apply";
|
|
19
|
+
title: string;
|
|
20
|
+
body?: string | null;
|
|
21
|
+
options: QuestionOption[];
|
|
22
|
+
createdAt: number;
|
|
23
|
+
/** When it stops being worth showing; the UI hides it, nothing deletes it. */
|
|
24
|
+
expiresAt?: number | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Answer = { id: string; choice: string; by?: string; answeredAt: number };
|
|
28
|
+
export type QuestionWithAnswer = { question: Question; answer: Answer | null };
|
|
29
|
+
|
|
30
|
+
export const QUESTION_ENTRY = "loops-question";
|
|
31
|
+
export const ANSWER_ENTRY = "loops-answer";
|
|
32
|
+
|
|
33
|
+
/** The bits of Pi's extension API this needs, so tests can pass a stub. */
|
|
34
|
+
export type QuestionPi = { appendEntry?: (type: string, data?: unknown) => void };
|
|
35
|
+
export type QuestionCtx = { sessionManager?: { getBranch?: () => unknown[] } };
|
|
36
|
+
|
|
37
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
38
|
+
|
|
39
|
+
export function newQuestion(fields: Omit<Question, "id" | "createdAt"> & { id?: string; createdAt?: number }): Question {
|
|
40
|
+
return {
|
|
41
|
+
id: fields.id ?? `q-${Date.now().toString(36)}${randomBytes(2).toString("hex")}`,
|
|
42
|
+
run: fields.run ?? null,
|
|
43
|
+
round: fields.round ?? null,
|
|
44
|
+
kind: fields.kind,
|
|
45
|
+
title: fields.title,
|
|
46
|
+
body: fields.body ?? null,
|
|
47
|
+
options: fields.options,
|
|
48
|
+
createdAt: fields.createdAt ?? Date.now(),
|
|
49
|
+
expiresAt: fields.expiresAt ?? null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every question the session's branch holds, newest first, with its answer when one was given. */
|
|
54
|
+
export function questionsOf(ctx: QuestionCtx): QuestionWithAnswer[] {
|
|
55
|
+
let entries: any[] = [];
|
|
56
|
+
try { entries = (ctx.sessionManager?.getBranch?.() ?? []) as any[]; } catch { return []; }
|
|
57
|
+
const answers = new Map<string, Answer>();
|
|
58
|
+
const asked: Question[] = [];
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (e?.type !== "custom") continue;
|
|
61
|
+
if (e.customType === ANSWER_ENTRY && str(e.data?.id)) {
|
|
62
|
+
answers.set(String(e.data.id), { id: String(e.data.id), choice: String(e.data?.choice ?? ""), by: str(e.data?.by) ?? undefined, answeredAt: Number(e.data?.answeredAt ?? 0) });
|
|
63
|
+
} else if (e.customType === QUESTION_ENTRY && str(e.data?.id)) {
|
|
64
|
+
const d = e.data as any;
|
|
65
|
+
asked.push({ id: String(d.id), run: str(d.run), round: typeof d.round === "number" ? d.round : null, kind: d.kind ?? "ask", title: String(d.title ?? ""), body: str(d.body), options: Array.isArray(d.options) ? d.options.map((o: any) => (typeof o === "string" ? { label: o } : { label: String(o?.label ?? ""), suggested: !!o?.suggested })).filter((o: QuestionOption) => o.label) : [], createdAt: Number(d.createdAt ?? 0), expiresAt: typeof d.expiresAt === "number" ? d.expiresAt : null });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// answers are appended after their question, so they are folded in once every entry has been read
|
|
69
|
+
return asked.reverse().map((question) => ({ question, answer: answers.get(question.id) ?? null }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Unanswered questions that have not expired, newest first. */
|
|
73
|
+
export function pendingQuestions(ctx: QuestionCtx, now: number = Date.now(), run?: string | null): Question[] {
|
|
74
|
+
return questionsOf(ctx).filter((x) => !x.answer && !(x.question.expiresAt && x.question.expiresAt < now) && (!run || x.question.run === run)).map((x) => x.question);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function appendQuestion(pi: QuestionPi, q: Question): Question {
|
|
78
|
+
try { pi.appendEntry?.(QUESTION_ENTRY, q); } catch {}
|
|
79
|
+
return q;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function appendAnswer(pi: QuestionPi, answer: Answer): Answer {
|
|
83
|
+
try { pi.appendEntry?.(ANSWER_ENTRY, answer); } catch {}
|
|
84
|
+
return answer;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `q-abc123` as a whole, or the shortest unique tail a person would type (`abc123`, `q3`). */
|
|
88
|
+
export function findQuestion(list: Question[], wanted: unknown): Question | null {
|
|
89
|
+
const want = String(wanted ?? "").trim().toLowerCase();
|
|
90
|
+
if (!want) return null;
|
|
91
|
+
return list.find((q) => q.id.toLowerCase() === want) ?? list.find((q) => q.id.toLowerCase().endsWith(want.replace(/^q-?/, ""))) ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One question as the card the TUI and the UI both draw. `picked` marks the answer in the list. */
|
|
95
|
+
export function questionText(q: Question, picked?: string | null): string {
|
|
96
|
+
const opts = q.options.map((o, i) => ` ${picked && picked === o.label ? "✓" : String(i + 1) + "."} ${o.label}${o.suggested ? " (suggested)" : ""}`).join("\n");
|
|
97
|
+
return [q.kind === "ask" ? q.title : `${q.title}`, q.body ?? "", opts].filter(Boolean).join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function questionsText(list: QuestionWithAnswer[]): string {
|
|
101
|
+
if (!list.length) return "no questions — a loop raises one when it needs a decision from you";
|
|
102
|
+
return list.map(({ question: q, answer }) => {
|
|
103
|
+
const head = `${q.id}${q.run ? ` · ${q.run}${q.round ? ` r${q.round}` : ""}` : ""} · ${q.kind}${answer ? ` · answered: ${answer.choice}` : ""}`;
|
|
104
|
+
return `${head}\n${questionText(q, answer?.choice ?? null)}`;
|
|
105
|
+
}).join("\n\n");
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@computesdk/pi-loops",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Hill-climbing loops as Pi units: one `loop` tool, style agents, forked attempt sessions in git worktrees, a judge keeps the best.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
51
|
"check": "tsc --noEmit",
|
|
52
|
-
"test": "tsc --noEmit && node test/rubric.ts && node test/judge.ts && node test/measure.ts && node test/shot.ts && node test/loop.ts"
|
|
52
|
+
"test": "tsc --noEmit && node test/rubric.ts && node test/judge.ts && node test/measure.ts && node test/shot.ts && node test/questions.ts && node test/loop.ts"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@earendil-works/pi-coding-agent": "*",
|