@computesdk/pi-loops 1.0.5 → 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 +169 -27
- 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
|
};
|
|
@@ -384,6 +416,7 @@ async function oneShot({ role, model, content, cwd, pick, registry, settings, me
|
|
|
384
416
|
const spec = model ?? settings.models[role] ?? null;
|
|
385
417
|
const agent = loadAgent(role, { cwd, agentDir });
|
|
386
418
|
let session: AgentSession | null = null;
|
|
419
|
+
let pickedModel: Model | null = null; // named in the finally, so the spend can be attributed to it
|
|
387
420
|
const onAbort = () => session?.abort().catch(() => {});
|
|
388
421
|
try {
|
|
389
422
|
// what a one-shot wants: no extensions, no skills, no tools — just the role's system prompt and one answer
|
|
@@ -400,7 +433,8 @@ async function oneShot({ role, model, content, cwd, pick, registry, settings, me
|
|
|
400
433
|
if (native) services.modelRuntime.registerNativeProvider(native as any);
|
|
401
434
|
}
|
|
402
435
|
// creating the session resolves the credential too, so it belongs inside this try: that is where an unusable provider shows up
|
|
403
|
-
|
|
436
|
+
pickedModel = pick(spec ?? agent.model);
|
|
437
|
+
const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pickedModel, noTools: "all" });
|
|
404
438
|
session = track(created);
|
|
405
439
|
const { text, images } = flattenParts(content);
|
|
406
440
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -418,7 +452,7 @@ async function oneShot({ role, model, content, cwd, pick, registry, settings, me
|
|
|
418
452
|
signal?.removeEventListener("abort", onAbort);
|
|
419
453
|
if (session) {
|
|
420
454
|
// a one-shot is a whole session with real spend: hand it to the round's meter before the session goes away
|
|
421
|
-
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);
|
|
422
456
|
release(session);
|
|
423
457
|
}
|
|
424
458
|
}
|
|
@@ -444,7 +478,7 @@ async function runAttempt({ state, style, n, dir, cwd, pick, settings, model, me
|
|
|
444
478
|
else if (e.type === "message_end" && e.message?.role === "assistant") {
|
|
445
479
|
p.tokens += e.message.usage?.totalTokens || 0;
|
|
446
480
|
p.cost += typeof e.message.usage?.cost === "number" ? e.message.usage.cost : e.message.usage?.cost?.total || 0;
|
|
447
|
-
addUsage(meter, e.message.usage);
|
|
481
|
+
addUsage(meter, e.message.usage, modelSpec(picked));
|
|
448
482
|
const t = (e.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join("").trim();
|
|
449
483
|
if (t) p.text = t.slice(-200);
|
|
450
484
|
onProgress(p);
|
|
@@ -522,14 +556,31 @@ async function agentJSON({ role, model, prompt, ok, cwd, ctx, settings, signal,
|
|
|
522
556
|
}
|
|
523
557
|
|
|
524
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
|
+
};
|
|
525
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")])) });
|
|
526
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()]),
|
|
527
|
-
|
|
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()) })) });
|
|
528
576
|
export const LoopResult = Type.Object({
|
|
529
577
|
run: Type.String(), status: Type.String(), kind: Type.String(), goal: Type.String(), baseCommit: Type.String(),
|
|
530
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()]) }),
|
|
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()]) }),
|
|
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())),
|
|
532
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()) }))),
|
|
533
584
|
});
|
|
534
585
|
/** `loop_status`: what a script polling a run gets. */
|
|
535
586
|
export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String() });
|
|
@@ -537,15 +588,37 @@ export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String
|
|
|
537
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())) });
|
|
538
589
|
/** `loop_apply`: what landed in the checkout, and what was deliberately left out. */
|
|
539
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;
|
|
540
596
|
|
|
541
|
-
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[]) {
|
|
542
598
|
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
543
599
|
return {
|
|
544
|
-
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 ?? "",
|
|
545
601
|
metric: { key: state.metric?.key ?? null, unit: state.metric?.unit ?? null, better: state.metric?.better ?? null },
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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(),
|
|
549
622
|
};
|
|
550
623
|
}
|
|
551
624
|
export const LoopParams = Type.Object({
|
|
@@ -787,18 +860,19 @@ export function runsText(cwd: string): string {
|
|
|
787
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");
|
|
788
861
|
}
|
|
789
862
|
|
|
790
|
-
export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs"];
|
|
863
|
+
export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs", "questions", "answer"];
|
|
791
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. */
|
|
792
865
|
export function isSubcommand(args?: string | null): boolean {
|
|
793
866
|
const w = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
794
867
|
if (!w.length) return true;
|
|
795
868
|
const sub = w[0].toLowerCase();
|
|
796
869
|
if (!SUBCOMMANDS.includes(sub)) return false;
|
|
797
|
-
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;
|
|
798
871
|
if (sub === "open") return w.length <= 3;
|
|
872
|
+
if (sub === "answer") return w.length >= 2 && w.length <= 8;
|
|
799
873
|
return w.length <= 2;
|
|
800
874
|
}
|
|
801
|
-
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]";
|
|
802
876
|
export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void> {
|
|
803
877
|
const [first, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
804
878
|
const sub = first?.toLowerCase();
|
|
@@ -822,6 +896,34 @@ export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void
|
|
|
822
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; }
|
|
823
897
|
case "init": await pi.sendUserMessage?.(initPrompt()); return;
|
|
824
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
|
+
}
|
|
825
927
|
default: ctx.ui.notify(LOOPS_USAGE, "info");
|
|
826
928
|
}
|
|
827
929
|
}
|
|
@@ -904,8 +1006,9 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
904
1006
|
// replaced (`runner.assertActive()`), so the background run holds only values read while it is still alive: the
|
|
905
1007
|
// model registry object itself, the session paths, and the status sink. `bindUI` rebinds the new session's UI,
|
|
906
1008
|
// so the old one is not touched after a switch.
|
|
907
|
-
const
|
|
908
|
-
const
|
|
1009
|
+
const sessionManager = ctx.sessionManager;
|
|
1010
|
+
const sessionDir = sessionManager?.getSessionDir?.();
|
|
1011
|
+
const sessionFile = sessionManager?.getSessionFile?.() ?? null;
|
|
909
1012
|
const pickCtx: PickCtx = { cwd, model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui };
|
|
910
1013
|
const pick: ModelPicker = (spec) => pickModel(pickCtx, spec);
|
|
911
1014
|
const statusSink = ctx.ui;
|
|
@@ -923,6 +1026,17 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
923
1026
|
if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
|
|
924
1027
|
return fallback;
|
|
925
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
|
+
};
|
|
926
1040
|
|
|
927
1041
|
if (!state.rubric && settings.council !== false && state.kind === "bench") {
|
|
928
1042
|
push("council writes the rubric");
|
|
@@ -1064,6 +1178,14 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1064
1178
|
}
|
|
1065
1179
|
}
|
|
1066
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
|
+
}
|
|
1067
1189
|
done.push(round);
|
|
1068
1190
|
push(`round ${n}: kept ${judge.pick}`);
|
|
1069
1191
|
}
|
|
@@ -1071,12 +1193,19 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1071
1193
|
state.status = aborted ? "aborted" : "done";
|
|
1072
1194
|
saveState(cwd, state);
|
|
1073
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
|
+
}
|
|
1074
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);
|
|
1075
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}`);
|
|
1076
1205
|
const spend = meterText(meter);
|
|
1077
1206
|
const content: any[] = [{ type: "text", text: spend ? `${text}\nSpent ${spend} on this run's models.` : text }];
|
|
1078
1207
|
if (lastSheet) { try { content.push({ type: "image", data: fs.readFileSync(path.join(cwd, lastSheet)).toString("base64"), mimeType: "image/png" }); } catch {} }
|
|
1079
|
-
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 })) } };
|
|
1080
1209
|
}
|
|
1081
1210
|
|
|
1082
1211
|
pi.registerTool?.({
|
|
@@ -1165,6 +1294,19 @@ export default function loopsExtension(pi: Pi): void {
|
|
|
1165
1294
|
return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files },
|
|
1166
1295
|
structuredContent: { run: state.id, applied: r.applied, commit: r.commit ?? null, added: r.files.added, removed: r.files.removed } };
|
|
1167
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
|
+
} });
|
|
1168
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 }),
|
|
1169
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}" }).`); } });
|
|
1170
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": "*",
|