@computesdk/pi-loops 1.0.3 → 1.0.5
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/extensions/loop.ts +69 -35
- package/package.json +1 -1
package/extensions/loop.ts
CHANGED
|
@@ -121,6 +121,10 @@ export type ToolDef<P = any> = {
|
|
|
121
121
|
description: string;
|
|
122
122
|
parameters: any;
|
|
123
123
|
outputSchema?: any;
|
|
124
|
+
/** One line for the default system prompt's "Available tools" section: without it Pi omits the tool there. */
|
|
125
|
+
promptSnippet?: string;
|
|
126
|
+
/** Rules for the Guidelines section, shown only while this tool is active. Workflow belongs here, not in the snippet. */
|
|
127
|
+
promptGuidelines?: string[];
|
|
124
128
|
/** `model-only` for the tools that ask the user or orchestrate: never callable from scripts. */
|
|
125
129
|
exposure?: "direct" | "model-only" | "codemode" | "deferred" | "hidden";
|
|
126
130
|
/** "sequential" for tools that share in-memory state, so one message's calls cannot interleave. */
|
|
@@ -131,6 +135,8 @@ export type ToolDef<P = any> = {
|
|
|
131
135
|
renderCall?: (args: any, theme: any) => any;
|
|
132
136
|
renderResult?: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) => any;
|
|
133
137
|
};
|
|
138
|
+
/** Resolve a model spec against the registry captured while the ctx was alive. */
|
|
139
|
+
export type ModelPicker = (spec?: string | null) => Model;
|
|
134
140
|
/** A candidate for the judge: a checked-out directory at a commit. */
|
|
135
141
|
export type Candidate = { label: string; dir: string; commit: string; fresh?: boolean };
|
|
136
142
|
/** One round of a run. */
|
|
@@ -364,16 +370,16 @@ export async function stopAll() { shuttingDown = true; const all = [...active];
|
|
|
364
370
|
* *extension* adds (rather than one built into Pi) is unknown inside it and its credential cannot be resolved. Say that
|
|
365
371
|
* instead of pi's "No API key found", which points at /login and sends people the wrong way.
|
|
366
372
|
*/
|
|
367
|
-
function explainAuth(e: unknown, role: string, spec: string | null,
|
|
373
|
+
function explainAuth(e: unknown, role: string, spec: string | null, registry?: ModelRegistryLike): unknown {
|
|
368
374
|
const provider = errText(e).match(/No API key found for ([\w.-]+)/)?.[1]?.replace(/\.+$/, ""); // pi ends the sentence with a period
|
|
369
375
|
if (!provider) return e;
|
|
370
376
|
// the host can see a model on that provider (so this session is missing something the host has), not a bare missing login
|
|
371
|
-
const hostKnows = (
|
|
377
|
+
const hostKnows = (registry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
|
|
372
378
|
if (!hostKnows) return e;
|
|
373
379
|
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 });
|
|
374
380
|
}
|
|
375
381
|
|
|
376
|
-
async function oneShot({ role, model, content, cwd,
|
|
382
|
+
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 }) {
|
|
377
383
|
const agentDir = getAgentDir();
|
|
378
384
|
const spec = model ?? settings.models[role] ?? null;
|
|
379
385
|
const agent = loadAgent(role, { cwd, agentDir });
|
|
@@ -386,15 +392,15 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
|
|
|
386
392
|
// "no extensions" would also drop the *providers* extensions add (anything not built into Pi), and an unregistered
|
|
387
393
|
// provider has no credentials. Copy the host's registrations over: config only, no extension code runs, still no tools.
|
|
388
394
|
const known = new Set(services.modelRuntime.getRegisteredProviderIds());
|
|
389
|
-
for (const id of
|
|
395
|
+
for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
|
|
390
396
|
if (known.has(id)) continue;
|
|
391
|
-
const config =
|
|
397
|
+
const config = registry?.getRegisteredProviderConfig?.(id);
|
|
392
398
|
if (config) { services.modelRuntime.registerProvider(id, config as any); continue; }
|
|
393
|
-
const native =
|
|
399
|
+
const native = registry?.getRegisteredNativeProvider?.(id); // a provider built with createProvider, not config
|
|
394
400
|
if (native) services.modelRuntime.registerNativeProvider(native as any);
|
|
395
401
|
}
|
|
396
402
|
// creating the session resolves the credential too, so it belongs inside this try: that is where an unusable provider shows up
|
|
397
|
-
const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model:
|
|
403
|
+
const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pick(spec ?? agent.model), noTools: "all" });
|
|
398
404
|
session = track(created);
|
|
399
405
|
const { text, images } = flattenParts(content);
|
|
400
406
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -407,7 +413,7 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
|
|
|
407
413
|
if (last?.stopReason === "error") throw new Error(last.errorMessage || `${role} errored`);
|
|
408
414
|
}
|
|
409
415
|
return parseJson(session.getLastAssistantText());
|
|
410
|
-
} catch (e) { throw explainAuth(e, role, spec,
|
|
416
|
+
} catch (e) { throw explainAuth(e, role, spec, registry); }
|
|
411
417
|
finally {
|
|
412
418
|
signal?.removeEventListener("abort", onAbort);
|
|
413
419
|
if (session) {
|
|
@@ -419,14 +425,14 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
|
|
|
419
425
|
}
|
|
420
426
|
|
|
421
427
|
/* ---------- an attempt: forked session + worktree + style agent ---------- */
|
|
422
|
-
async function runAttempt({ state, style, n, dir, cwd,
|
|
428
|
+
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> {
|
|
423
429
|
const agentDir = getAgentDir();
|
|
424
430
|
const agent = loadAgent(style, { cwd, agentDir });
|
|
425
431
|
const fork = state.best.session ?? src;
|
|
426
432
|
const sessionManager = fork && fs.existsSync(fork) ? SessionManager.forkFrom(fork, dir, sessionDir) : SessionManager.create(dir, sessionDir);
|
|
427
433
|
const loader = new DefaultResourceLoader({ cwd: dir, agentDir, noExtensions: true, noPromptTemplates: true, noThemes: true, appendSystemPrompt: [agent.body] });
|
|
428
434
|
await loader.reload();
|
|
429
|
-
const picked =
|
|
435
|
+
const picked = pick(model ?? settings.models[style] ?? agent.model);
|
|
430
436
|
const { session: created } = await createAgentSession({ cwd: dir, agentDir, model: picked, tools: agent.tools?.length ? agent.tools : undefined, resourceLoader: loader, sessionManager });
|
|
431
437
|
const session = track(created);
|
|
432
438
|
live?.attempts.set(`R${n}-${style}`, session);
|
|
@@ -525,6 +531,13 @@ export const LoopResult = Type.Object({
|
|
|
525
531
|
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()]) }),
|
|
526
532
|
rounds: Type.Array(RoundOut),
|
|
527
533
|
});
|
|
534
|
+
/** `loop_status`: what a script polling a run gets. */
|
|
535
|
+
export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String() });
|
|
536
|
+
/** `loop_init`: what was written to .pi/loops.json. */
|
|
537
|
+
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())) });
|
|
538
|
+
/** `loop_apply`: what landed in the checkout, and what was deliberately left out. */
|
|
539
|
+
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()) });
|
|
540
|
+
|
|
528
541
|
export function structuredResult(state: Pick<State, "id" | "status" | "kind" | "goal" | "baseCommit" | "metric" | "best">, rounds: Round[]) {
|
|
529
542
|
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
530
543
|
return {
|
|
@@ -823,12 +836,20 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
823
836
|
});
|
|
824
837
|
// state is module-scoped, so a session that starts after one has shut down must not inherit its latch
|
|
825
838
|
pi.on?.("session_start", async () => { shuttingDown = false; RUNS.clear(); });
|
|
826
|
-
pi.on?.("session_shutdown", async () => {
|
|
839
|
+
pi.on?.("session_shutdown", async () => {
|
|
840
|
+
shuttingDown = true;
|
|
841
|
+
const runs = [...RUNS.values()];
|
|
842
|
+
for (const r of runs) r.ctrl.abort();
|
|
843
|
+
// disposing the session invalidates the ctx, so the runs must be finished before this returns, not just aborted
|
|
844
|
+
await Promise.allSettled(runs.map((r) => r.promise));
|
|
845
|
+
await stopAll();
|
|
846
|
+
});
|
|
827
847
|
async function run(params: LoopParamsIn, signal: AbortSignal | undefined, onUpdate: ((update: any) => void) | undefined, ctx: Ctx, live: any) {
|
|
828
848
|
const cwd = ctx.cwd;
|
|
849
|
+
const hasUI = ctx.hasUI ?? false; // a getter on the ctx proxy: stale after replacement, so read it once
|
|
829
850
|
let settings = loopSettings(pi, cwd);
|
|
830
851
|
const chosen = Object.keys(settings.models).some((k) => k !== "pool");
|
|
831
|
-
if (!params.from && !chosen &&
|
|
852
|
+
if (!params.from && !chosen && hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
|
|
832
853
|
const r = await pickModels(ctx, { reason: "First loop here" });
|
|
833
854
|
if (r && "error" in r) throw new Error(r.error);
|
|
834
855
|
if (r && "models" in r) settings = { ...settings, models: { ...settings.models, ...r.models } };
|
|
@@ -863,7 +884,7 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
863
884
|
if (SELF_SHOT.test(state.build ?? "")) findChrome();
|
|
864
885
|
// a visual round is only as good as the eyes on it: refuse a judge (or panel member) that cannot see the screenshots
|
|
865
886
|
if (judgedOnScreenshots(state)) {
|
|
866
|
-
const asModel = (spec: string | null | undefined) => { try { return
|
|
887
|
+
const asModel = (spec: string | null | undefined) => { try { return pick(spec); } catch { return null; } }; // an unavailable model reports itself later
|
|
867
888
|
const panelOn = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1;
|
|
868
889
|
const problem = visionProblem([
|
|
869
890
|
{ role: "judge", key: "judge", spec: models.judge, model: asModel(models.judge) },
|
|
@@ -879,21 +900,27 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
879
900
|
const runDir = path.join(cwd, STATE_DIR, state.id);
|
|
880
901
|
// every nested model call in this round — attempts, judge samples, council — adds to one meter, reported as the result's `usage`
|
|
881
902
|
const meter = newMeter();
|
|
882
|
-
//
|
|
883
|
-
//
|
|
903
|
+
// A TUI call returns as soon as the round starts, and `ctx` is a getter proxy that throws once the session is
|
|
904
|
+
// replaced (`runner.assertActive()`), so the background run holds only values read while it is still alive: the
|
|
905
|
+
// model registry object itself, the session paths, and the status sink. `bindUI` rebinds the new session's UI,
|
|
906
|
+
// so the old one is not touched after a switch.
|
|
884
907
|
const sessionDir = ctx.sessionManager?.getSessionDir?.();
|
|
885
908
|
const sessionFile = ctx.sessionManager?.getSessionFile?.() ?? null;
|
|
886
|
-
const
|
|
909
|
+
const pickCtx: PickCtx = { cwd, model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui };
|
|
910
|
+
const pick: ModelPicker = (spec) => pickModel(pickCtx, spec);
|
|
911
|
+
const statusSink = ctx.ui;
|
|
912
|
+
let alive = true;
|
|
913
|
+
const setStatus = (text?: string) => { if (alive) { try { statusSink.setStatus?.("loops", text); } catch {} } };
|
|
887
914
|
const details: Details = { run: state.id, kind: state.kind, round: null, judge: null, attempts: [], phase: "starting" };
|
|
888
915
|
if (live) { live.id = state.id; live.state = state; live.details = details; live.cwd = cwd; live.started?.(); }
|
|
889
916
|
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) }); };
|
|
890
|
-
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,
|
|
891
|
-
const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd,
|
|
917
|
+
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 });
|
|
918
|
+
const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd, pick, registry: pickCtx.modelRegistry, settings, meter, signal });
|
|
892
919
|
// the judge panel: every council model (the attempts' models) scores every candidate, labels blind; the judge model keeps vetoes and the head-to-head
|
|
893
920
|
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;
|
|
894
921
|
state.models = { ...Object.fromEntries(STYLES.map((st) => [st, models[st]])), judge: models.judge, council: models.council, spread: models.spread };
|
|
895
922
|
const needsYou = async (title: string, options: string[], fallback: string): Promise<string> => {
|
|
896
|
-
if (
|
|
923
|
+
if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
|
|
897
924
|
return fallback;
|
|
898
925
|
};
|
|
899
926
|
|
|
@@ -925,7 +952,7 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
925
952
|
try { git(cwd, "worktree", "remove", "-f", dir); } catch {}
|
|
926
953
|
git(cwd, "worktree", "add", "-q", "-f", "-B", branch, dir, round.from ?? state.baseCommit);
|
|
927
954
|
const slot = details.attempts.find((a) => a.style === style)!;
|
|
928
|
-
const r = await runAttempt({ state, style, n, dir, cwd,
|
|
955
|
+
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(); } });
|
|
929
956
|
Object.assign(slot, { status: r.status, tool: null, text: r.summary || r.error || "" }); push();
|
|
930
957
|
return { ...r, branch, dir: path.relative(cwd, dir) };
|
|
931
958
|
}));
|
|
@@ -1008,7 +1035,7 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1008
1035
|
else judge.why = `${judge.why} (your pick ${live.pick} was not a passing attempt)`;
|
|
1009
1036
|
live.pick = null;
|
|
1010
1037
|
}
|
|
1011
|
-
if (
|
|
1038
|
+
if (hasUI && !live && takeable.length) {
|
|
1012
1039
|
for (;;) {
|
|
1013
1040
|
const others = takeable.filter((a) => a.label !== judge.pick);
|
|
1014
1041
|
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`)];
|
|
@@ -1053,7 +1080,12 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1053
1080
|
}
|
|
1054
1081
|
|
|
1055
1082
|
pi.registerTool?.({
|
|
1056
|
-
name: "loop", exposure: "model-only", executionMode: "sequential",
|
|
1083
|
+
name: "loop", exposure: "model-only", executionMode: "sequential",
|
|
1084
|
+
promptSnippet: "run hill-climbing rounds: three forked attempts in git worktrees, a judge keeps the best",
|
|
1085
|
+
promptGuidelines: [
|
|
1086
|
+
"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`.",
|
|
1087
|
+
"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.",
|
|
1088
|
+
], annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1057
1089
|
label: "loop",
|
|
1058
1090
|
outputSchema: LoopResult,
|
|
1059
1091
|
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.",
|
|
@@ -1073,12 +1105,13 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1073
1105
|
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.");
|
|
1074
1106
|
}
|
|
1075
1107
|
if (!ctx.hasUI || params.wait) return run(params, signal, onUpdate, ctx, null);
|
|
1108
|
+
const ui = ctx.ui; // the object, held by value: the ctx getter throws once the session is replaced
|
|
1076
1109
|
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() };
|
|
1077
1110
|
const started = new Promise((resolve) => { live.started = resolve; });
|
|
1078
1111
|
live.promise = run(params, live.ctrl.signal, (u) => { live.last = u; }, ctx, live).then(
|
|
1079
1112
|
(r) => { try { pi.sendUserMessage?.(r.content, { deliverAs: "followUp" }); } catch {} },
|
|
1080
1113
|
(e) => { live.error = e; if (live.id) { try { pi.sendUserMessage?.(`Loop ${live.id} failed: ${e.message}`, { deliverAs: "followUp" }); } catch {} } }
|
|
1081
|
-
).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try {
|
|
1114
|
+
).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ui.setStatus?.("loops", undefined); } catch {} });
|
|
1082
1115
|
await started;
|
|
1083
1116
|
if (!live.id) throw live.error ?? new Error("the loop did not start");
|
|
1084
1117
|
RUNS.set(live.id, live);
|
|
@@ -1098,28 +1131,28 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1098
1131
|
return new Text([head, ...lines].join("\n") + body, 0, 0);
|
|
1099
1132
|
},
|
|
1100
1133
|
});
|
|
1101
|
-
const said = (t: string) => ({ content: [{ type: "text", text: t }] });
|
|
1134
|
+
const said = (t: string, details: unknown = undefined) => ({ content: [{ type: "text", text: t }], details });
|
|
1102
1135
|
const idParam = Type.Optional(Type.String({ description: "Run id; default: the round started most recently" }));
|
|
1103
|
-
pi.registerTool?.({ name: "loop_init", exposure: "model-only", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_init", description: "Project setup for loops, written once to .pi/loops.json: serve (start the app on $PORT), check, bench + metric, protect. In the TUI it shows them and asks Save / Change / Discuss.", parameters: InitParams,
|
|
1136
|
+
pi.registerTool?.({ name: "loop_init", exposure: "model-only", executionMode: "sequential", promptSnippet: "set this repo up for loops (serve, check, bench, protect)", outputSchema: InitResult, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_init", description: "Project setup for loops, written once to .pi/loops.json: serve (start the app on $PORT), check, bench + metric, protect. In the TUI it shows them and asks Save / Change / Discuss.", parameters: InitParams,
|
|
1104
1137
|
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
1105
1138
|
const fields = Object.fromEntries(SETUP_KEYS.filter((k) => p[k] != null && (!Array.isArray(p[k]) || p[k].length)).map((k) => [k, p[k]]));
|
|
1106
1139
|
if (ctx.hasUI) {
|
|
1107
1140
|
const choice = await ctx.ui.select(`Project setup for loops (.pi/loops.json)\n\n${setupText(fields)}\n`, ["Save", "Change…", "Discuss"]);
|
|
1108
|
-
if (choice?.startsWith("Change")) { const note = (await ctx.ui.input?.("What should change?", "e.g. serve is npm start -- -p $PORT; no check"))?.trim(); return { content: [{ type: "text", text: note ? `Not saved. Change requested: ${note}\nRevise and call loop_init again.` : "Not saved. Revise with me in chat, then call loop_init again." }], details: { phase: "change", note } }; }
|
|
1141
|
+
if (choice?.startsWith("Change")) { const note = (await ctx.ui.input?.("What should change?", "e.g. serve is npm start -- -p $PORT; no check"))?.trim(); return { content: [{ type: "text", text: note ? `Not saved. Change requested: ${note}\nRevise and call loop_init again.` : "Not saved. Revise with me in chat, then call loop_init again." }], details: { phase: "change", note }, structuredContent: { phase: "change", ...(note ? { note } : {}) } }; }
|
|
1109
1142
|
if (choice !== "Save") return { content: [{ type: "text", text: "Not saved. Discuss the setup with me in chat, then call loop_init again." }], details: { phase: "discuss" } };
|
|
1110
1143
|
}
|
|
1111
1144
|
await saveConfigQueued(ctx.cwd, { ...Object.fromEntries(SETUP_KEYS.map((k) => [k, null])), ...fields });
|
|
1112
|
-
return { content: [{ type: "text", text: `Saved ${path.relative(ctx.cwd, configFiles(ctx.cwd).project)}:\n${setupText(fields)}` }], details: { phase: "saved", setup: fields } };
|
|
1145
|
+
return { content: [{ type: "text", text: `Saved ${path.relative(ctx.cwd, configFiles(ctx.cwd).project)}:\n${setupText(fields)}` }], details: { phase: "saved", setup: fields }, structuredContent: { phase: "saved", ...fields } };
|
|
1113
1146
|
} });
|
|
1114
|
-
pi.registerTool?.({ name: "loop_status",
|
|
1115
|
-
async execute(_id, p) { const r = liveRun(p.id); return { content: r.last?.content ?? [{ type: "text", text: `${r.id} starting` }], details: r.details }; } });
|
|
1116
|
-
pi.registerTool?.({ name: "loop_talk", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
1147
|
+
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 }),
|
|
1148
|
+
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") } }; } });
|
|
1149
|
+
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 }),
|
|
1117
1150
|
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}`); } });
|
|
1118
|
-
pi.registerTool?.({ name: "loop_rule", 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 }),
|
|
1151
|
+
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 }),
|
|
1119
1152
|
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"})`); } });
|
|
1120
|
-
pi.registerTool?.({ name: "loop_pick", 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 }),
|
|
1153
|
+
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 }),
|
|
1121
1154
|
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`); } });
|
|
1122
|
-
pi.registerTool?.({ name: "loop_apply", 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 }),
|
|
1155
|
+
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 }),
|
|
1123
1156
|
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
1124
1157
|
const mine = !p.id || p.id === "last" ? sessionRuns(ctx).find((id) => fs.existsSync(stateFile(ctx.cwd, id))) : null;
|
|
1125
1158
|
const state = loadState(ctx.cwd, mine ?? p.id);
|
|
@@ -1129,8 +1162,9 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1129
1162
|
const lines = [r.commit ? `Applied ${state.best.label} (${state.best.commit.slice(0, 7)}) as ${r.commit.slice(0, 7)}: ${r.applied.join(", ")}` : r.already ? `${state.best.label} is already in the checkout.` : "Nothing applied — the winner changed no tracked file."];
|
|
1130
1163
|
if (r.files.added.length) lines.push(`Not applied — files it added: ${r.files.added.join(", ")} (git checkout ${state.best.commit.slice(0, 7)} -- <file> to take one)`);
|
|
1131
1164
|
if (r.files.removed.length) lines.push(`Not applied — files it deleted: ${r.files.removed.join(", ")}`);
|
|
1132
|
-
return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files }
|
|
1165
|
+
return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files },
|
|
1166
|
+
structuredContent: { run: state.id, applied: r.applied, commit: r.commit ?? null, added: r.files.added, removed: r.files.removed } };
|
|
1133
1167
|
} });
|
|
1134
|
-
pi.registerTool?.({ name: "loop_stop", 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 }),
|
|
1168
|
+
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 }),
|
|
1135
1169
|
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}" }).`); } });
|
|
1136
1170
|
}
|
package/package.json
CHANGED