@alphacoder-v0/pi-loops 0.17.6 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +13 -6
- package/README.zh-CN.md +19 -6
- package/docs/agents/issue-tracker.md +34 -0
- package/docs/cli.md +22 -2
- package/docs/configuration.md +1 -1
- package/docs/design.md +63 -6
- package/docs/loops.md +25 -17
- package/docs/recipes.md +239 -0
- package/docs/triggers.md +1 -1
- package/docs/troubleshooting.md +3 -2
- package/docs/web-ui-parity.md +9 -3
- package/package.json +2 -1
- package/recipes/_tracker/issue-tracker-github.md +31 -0
- package/recipes/_tracker/issue-tracker-local.md +38 -0
- package/recipes/_tracker-setup.md +12 -0
- package/recipes/autoresearch/README.md +27 -0
- package/recipes/autoresearch/RESEARCH.template.md +37 -0
- package/recipes/autoresearch/example/RESEARCH.md +31 -0
- package/recipes/autoresearch/example/eval.py +60 -0
- package/recipes/autoresearch/example/eval.sh +3 -0
- package/recipes/autoresearch/example/solution.py +20 -0
- package/recipes/autoresearch/experiment.md +82 -0
- package/recipes/autoresearch/recipe.toml +15 -0
- package/recipes/changelog-draft/recipe.toml +9 -0
- package/recipes/changelog-draft/release.md +35 -0
- package/recipes/issue-loop/README.md +16 -0
- package/recipes/issue-loop/implement.md +135 -0
- package/recipes/issue-loop/labels.sh +21 -0
- package/recipes/issue-loop/recipe.toml +18 -0
- package/recipes/issue-loop/triage.md +147 -0
- package/skills/pi-loops/SKILL.md +3 -1
- package/src/archive.ts +5 -5
- package/src/args.ts +1 -8
- package/src/cli.ts +83 -6
- package/src/job-edit.ts +0 -3
- package/src/job-health.ts +6 -7
- package/src/job-owner.ts +29 -0
- package/src/pi-loops.ts +339 -25
- package/src/protocol.ts +2 -3
- package/src/recipe.ts +651 -0
- package/src/scheduler.ts +13 -13
- package/src/session-head.ts +98 -0
- package/src/store.ts +9 -17
- package/src/tools.ts +17 -19
- package/src/trigger-runtime.ts +3 -5
- package/src/triggers.ts +3 -4
- package/src/web.mjs +40 -17
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
|
|
|
20
20
|
import { liveHost, stopHost } from "./host-control.ts";
|
|
21
21
|
import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
|
|
22
22
|
import { JobStore, defaultLoopsDir } from "./store.ts";
|
|
23
|
+
import { automationBadge, previewText, readSessionHead } from "./session-head.ts";
|
|
24
|
+
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
|
|
23
25
|
import { TriggerStore } from "./triggers.ts";
|
|
24
26
|
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
25
27
|
import { stamp } from "./schedule.ts";
|
|
@@ -97,13 +99,17 @@ export const CLI_USAGE = [
|
|
|
97
99
|
" Imported automation stays disabled unless --activate-triggers=on (ask: prompt on a terminal).",
|
|
98
100
|
"",
|
|
99
101
|
"pi-loops sessions [--all] [--limit <n>]",
|
|
100
|
-
" List
|
|
102
|
+
" List sessions, newest first: short id, when it started, its automation, what was first said in it.",
|
|
101
103
|
"",
|
|
102
104
|
"pi-loops inspect <file>",
|
|
103
105
|
" Show what an archive contains without writing anything.",
|
|
104
106
|
"",
|
|
105
107
|
"pi-loops host status | abort <run-id|trace-id> | stop",
|
|
106
108
|
" Look in on the background host that keeps the clock while no pi is open, or interrupt it.",
|
|
109
|
+
"",
|
|
110
|
+
"pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]",
|
|
111
|
+
" The packaged recipes; what one installs; copy its playbooks into a project (.agents/skills/<name>/).",
|
|
112
|
+
" The setup script and the jobs need a pi open in the project: /recipe add <name> finds the copies and finishes.",
|
|
107
113
|
].join("\n");
|
|
108
114
|
|
|
109
115
|
/**
|
|
@@ -123,6 +129,10 @@ interface SessionFile {
|
|
|
123
129
|
file: string;
|
|
124
130
|
cwd: string;
|
|
125
131
|
mtimeMs: number;
|
|
132
|
+
/** The header's timestamp. */
|
|
133
|
+
startedAt?: string;
|
|
134
|
+
/** `/name`, or the first thing the person said — what tells two sessions apart. */
|
|
135
|
+
preview?: string;
|
|
126
136
|
}
|
|
127
137
|
|
|
128
138
|
/** pi keeps `<agentDir>/sessions/<encoded cwd>/<timestamp>_<uuid>.jsonl`; the header names the cwd. */
|
|
@@ -146,9 +156,9 @@ export function listSessions(sessionsRoot: string): SessionFile[] {
|
|
|
146
156
|
if (!name.endsWith(".jsonl")) continue;
|
|
147
157
|
const file = path.join(dir, name);
|
|
148
158
|
try {
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
out.push({ id:
|
|
159
|
+
const head = readSessionHead(file);
|
|
160
|
+
if (!head) continue;
|
|
161
|
+
out.push({ id: head.id, file, cwd: head.cwd, mtimeMs: fs.statSync(file).mtimeMs, startedAt: head.startedAt, preview: head.name ?? head.first });
|
|
152
162
|
} catch {
|
|
153
163
|
/* not a readable session */
|
|
154
164
|
}
|
|
@@ -239,6 +249,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
239
249
|
if (command === "web" || command === "tui") return launch(argv, out);
|
|
240
250
|
if (command === "upgrade") return upgrade(flags.has("check"), out);
|
|
241
251
|
if (command === "install-launcher") return installLauncher(str("dir"), out);
|
|
252
|
+
if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
|
|
242
253
|
|
|
243
254
|
if (command === "sessions") {
|
|
244
255
|
// `pickSession` refuses an unknown id, and there was no way to discover one.
|
|
@@ -248,7 +259,14 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
248
259
|
out(flags.has("all") ? "no sessions recorded" : `no sessions recorded for ${cwd} (use --all)`);
|
|
249
260
|
return 1;
|
|
250
261
|
}
|
|
251
|
-
|
|
262
|
+
// One line a person can tell sessions apart by: short id, when it started, the automation the
|
|
263
|
+
// session has, what was first said in it. With --all the cwd sits after the id.
|
|
264
|
+
const jobs = new JobStore(loopsDir).load();
|
|
265
|
+
const rules = new TriggerStore(loopsDir).load();
|
|
266
|
+
for (const s of here.slice(0, Number(str("limit") ?? 20))) {
|
|
267
|
+
const badge = automationBadge(s.id, jobs, rules);
|
|
268
|
+
out(`${s.id.slice(0, 16)} ${flags.has("all") ? `${s.cwd} ` : ""}${(s.startedAt ?? stamp(s.mtimeMs)).slice(0, 16)}${badge ? ` [${badge}]` : ""} ${s.preview ? previewText(s.preview) : ""}`.trimEnd());
|
|
269
|
+
}
|
|
252
270
|
return 0;
|
|
253
271
|
}
|
|
254
272
|
|
|
@@ -375,7 +393,66 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
375
393
|
return asked ? 2 : 0;
|
|
376
394
|
}
|
|
377
395
|
|
|
378
|
-
const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "web", "upgrade", "install-launcher", "help"]);
|
|
396
|
+
const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "web", "upgrade", "install-launcher", "help"]);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* `pi-loops recipe`: the deterministic part of `/recipe`, for a shell with no pi open. `add` copies
|
|
400
|
+
* the playbooks, lists the directory in `.git/info/exclude` and writes the record — and stops there:
|
|
401
|
+
* the setup script is shown to a person before it runs and the jobs are created after a
|
|
402
|
+
* confirmation, both of which need the pi that `/recipe add` runs in. It finds the copies in place.
|
|
403
|
+
*/
|
|
404
|
+
async function recipeCommand(positional: string[], opts: { level?: string; overwrite: boolean }, cwd: string, loopsDir: string, out: (line: string) => void): Promise<number> {
|
|
405
|
+
const sub = positional[0] ?? "list";
|
|
406
|
+
if (sub === "list") {
|
|
407
|
+
const packaged = listRecipes();
|
|
408
|
+
const installed = new Map(installedRecipes(cwd).map((r) => [r.recipe, r]));
|
|
409
|
+
const jobs = new JobStore(loopsDir).load().filter((j) => j.recipe && j.cwd === cwd);
|
|
410
|
+
for (const r of packaged) {
|
|
411
|
+
const rec = installed.get(r.manifest.name);
|
|
412
|
+
const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
|
|
413
|
+
out(`${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`);
|
|
414
|
+
}
|
|
415
|
+
for (const rec of installed.values()) if (!packaged.some((r) => r.manifest.name === rec.recipe)) out(`${rec.recipe.padEnd(17)} installed from ${rec.source} (${rec.level})`);
|
|
416
|
+
if (!packaged.length && !installed.size) out("no recipes packaged, none installed here");
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
if (sub === "show" || sub === "add") {
|
|
420
|
+
const ref = positional[1];
|
|
421
|
+
if (!ref) throw new Error(`recipe ${sub} needs a recipe name or a directory`);
|
|
422
|
+
const resolved = resolveRecipeRef(ref, { cwd });
|
|
423
|
+
const recipe = loadRecipe(resolved.dir);
|
|
424
|
+
const m = recipe.manifest;
|
|
425
|
+
const rel = path.join(INSTALL_ROOT, m.name);
|
|
426
|
+
if (sub === "show") {
|
|
427
|
+
out(`${m.name} (${recipe.dir})`);
|
|
428
|
+
out(` ${m.summary}`);
|
|
429
|
+
out(` levels: ${m.levels.join(", ")} tracker: ${m.needsTracker ? "needs docs/agents/issue-tracker.md" : "not needed"}${m.setup ? ` setup: ${m.setup}` : ""}`);
|
|
430
|
+
out(` files → ${rel}/: ${playbookFiles(m).join(", ")}`);
|
|
431
|
+
for (const j of m.jobs) out(` /cron add ${addLineFor(j, rel)}`);
|
|
432
|
+
const record = readRecord(installDirFor(cwd, m.name));
|
|
433
|
+
if (record) out(` installed in ${cwd}: ${record.level}, ${record.installedAt}, pi-loops ${record.version}`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
let level = m.levels[0];
|
|
437
|
+
if (opts.level) {
|
|
438
|
+
if (!m.levels.includes(opts.level as AutonomyLevel)) throw new Error(`${m.name} supports ${m.levels.join(", ")}, not "${opts.level}" (levels are ${AUTONOMY_LEVELS.join(", ")})`);
|
|
439
|
+
level = opts.level as AutonomyLevel;
|
|
440
|
+
}
|
|
441
|
+
const plan = planInstall(recipe, cwd, level);
|
|
442
|
+
const record = installFiles(recipe, cwd, level, { overwrite: opts.overwrite, source: resolved.source });
|
|
443
|
+
const excluded = ensureExcluded(cwd, rel);
|
|
444
|
+
if (m.needsTracker && fs.existsSync(path.join(cwd, TRACKER_FILE))) ensureExcluded(cwd, TRACKER_FILE, "file");
|
|
445
|
+
out(`copied ${record.files.length} file(s) → ${plan.targetDir}/ (level ${level}${opts.level ? "" : ", the lowest; --level to choose"})`);
|
|
446
|
+
for (const f of plan.changed) out(` ${f}: ${opts.overwrite ? "overwritten" : "kept as edited (--overwrite replaces it)"}`);
|
|
447
|
+
out(excluded === "no-git" ? "not a git repository: nothing to exclude" : `.git/info/exclude: ${excluded === "added" ? "added" : "already listed"}`);
|
|
448
|
+
if (m.needsTracker && !fs.existsSync(path.join(cwd, TRACKER_FILE))) out(`${m.name} reads docs/agents/issue-tracker.md and this project has none: /recipe add ${m.name} in pi writes it with you first`);
|
|
449
|
+
out(`no jobs created: open pi in ${cwd} and run /recipe add ${m.name} — it shows ${m.setup ? `${m.setup} and ` : ""}these lines, then creates them:`);
|
|
450
|
+
for (const line of plan.addLines) out(` /cron add ${line}`);
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
out(`unknown recipe command ${JSON.stringify(sub)}: pi-loops recipe list | show <name|path> | add <name|path>`);
|
|
454
|
+
return 2;
|
|
455
|
+
}
|
|
379
456
|
|
|
380
457
|
/** `v1.2.3` → comparable parts; anything else is not a release and is ignored. */
|
|
381
458
|
function semver(tag: string): [number, number, number] | undefined {
|
package/src/job-edit.ts
CHANGED
|
@@ -20,7 +20,6 @@ export interface JobEdit {
|
|
|
20
20
|
timeoutMs?: number | null;
|
|
21
21
|
name?: string | null;
|
|
22
22
|
/** "here" pins the job to this machine; null lets any machine run it. */
|
|
23
|
-
host?: string | null;
|
|
24
23
|
prompt?: string;
|
|
25
24
|
schedule?: Schedule;
|
|
26
25
|
}
|
|
@@ -38,7 +37,6 @@ export interface AppliedJobEdit {
|
|
|
38
37
|
|
|
39
38
|
export interface JobEditContext {
|
|
40
39
|
now: number;
|
|
41
|
-
hostName: string;
|
|
42
40
|
maxPromptBytes: number;
|
|
43
41
|
/** Called with the requested name and the other jobs; throws if it is not usable as a reference. */
|
|
44
42
|
checkName: (name: string, others: LoopJob[]) => void;
|
|
@@ -66,7 +64,6 @@ export function applyJobEdit(job: LoopJob, edit: JobEdit, ctx: JobEditContext):
|
|
|
66
64
|
if (edit.thinking !== undefined) patch.thinking = edit.thinking ?? undefined;
|
|
67
65
|
if (edit.timeoutMs !== undefined) patch.timeoutMs = edit.timeoutMs ?? undefined;
|
|
68
66
|
if (edit.name !== undefined) patch.name = edit.name ?? undefined;
|
|
69
|
-
if (edit.host !== undefined) patch.host = edit.host === "here" ? ctx.hostName : undefined;
|
|
70
67
|
if (edit.prompt !== undefined) {
|
|
71
68
|
patch.prompt = edit.prompt;
|
|
72
69
|
// The old wording is otherwise unrecoverable, and it is what ties "this loop started
|
package/src/job-health.ts
CHANGED
|
@@ -10,18 +10,17 @@ import { FAILURE_BACKOFF_AFTER } from "./scheduler.ts";
|
|
|
10
10
|
import type { LoopJob } from "./store.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Jobs failing
|
|
13
|
+
* Jobs failing, worst first. The threshold is the scheduler's backoff threshold, which
|
|
14
14
|
* is the honest place to draw the line: below it a failure is a bad night, at it the scheduler has
|
|
15
|
-
* already started widening the gap.
|
|
16
|
-
* report, not ours.
|
|
15
|
+
* already started widening the gap.
|
|
17
16
|
*/
|
|
18
|
-
export function failingJobs(jobs: LoopJob[]
|
|
19
|
-
return jobs.filter((j) => j.enabled && (
|
|
17
|
+
export function failingJobs(jobs: LoopJob[]): LoopJob[] {
|
|
18
|
+
return jobs.filter((j) => j.enabled && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/** `2 job(s) failing (check-issues ×7)` — one clause, the worst one named, whatever the count. */
|
|
23
|
-
export function failingSummary(jobs: LoopJob[]
|
|
24
|
-
const failing = failingJobs(jobs
|
|
22
|
+
export function failingSummary(jobs: LoopJob[]): string | undefined {
|
|
23
|
+
const failing = failingJobs(jobs);
|
|
25
24
|
const worst = failing[0];
|
|
26
25
|
if (!worst) return undefined;
|
|
27
26
|
const label = worst.name ?? worst.id;
|
package/src/job-owner.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a job runs, as one question the scheduler, the lists and `/cron run` all ask the same way.
|
|
3
|
+
*
|
|
4
|
+
* A stateful loop belongs to the machine: whichever process holds the clock runs it. A plain job
|
|
5
|
+
* belongs to the session that created it — its result is a message in that conversation, so it
|
|
6
|
+
* fires only in the process holding that session. Another session sees it, and sees that it is
|
|
7
|
+
* asleep; it is not broken, and nothing here promises it a next run it will not get.
|
|
8
|
+
*/
|
|
9
|
+
import type { LoopJob } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
/** Would the process holding `sessionId` dispatch this job? (For a loop, only if it is also the leader.) */
|
|
12
|
+
export function runsIn(job: Pick<LoopJob, "stateful" | "sessionId">, sessionId: string | undefined): boolean {
|
|
13
|
+
if (job.stateful) return true;
|
|
14
|
+
return !!sessionId && job.sessionId === sessionId;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The short form of the session a plain job belongs to. */
|
|
18
|
+
export function ownerSession(job: Pick<LoopJob, "stateful" | "sessionId">): string | undefined {
|
|
19
|
+
return job.stateful ? undefined : (job.sessionId ?? "?").slice(0, 8);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The line a list shows for a plain job that will not run here — worded as what to do, because
|
|
24
|
+
* "not open here" was read as an error. Undefined when the job runs here.
|
|
25
|
+
*/
|
|
26
|
+
export function asleepNote(job: Pick<LoopJob, "stateful" | "sessionId">, sessionId: string | undefined): string | undefined {
|
|
27
|
+
if (runsIn(job, sessionId)) return undefined;
|
|
28
|
+
return `session ${ownerSession(job)} — resume it to run`;
|
|
29
|
+
}
|