@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +13 -6
  3. package/README.zh-CN.md +19 -6
  4. package/docs/agents/issue-tracker.md +34 -0
  5. package/docs/cli.md +22 -2
  6. package/docs/configuration.md +1 -1
  7. package/docs/design.md +63 -6
  8. package/docs/loops.md +25 -17
  9. package/docs/recipes.md +239 -0
  10. package/docs/triggers.md +1 -1
  11. package/docs/troubleshooting.md +3 -2
  12. package/docs/web-ui-parity.md +9 -3
  13. package/package.json +2 -1
  14. package/recipes/_tracker/issue-tracker-github.md +31 -0
  15. package/recipes/_tracker/issue-tracker-local.md +38 -0
  16. package/recipes/_tracker-setup.md +12 -0
  17. package/recipes/autoresearch/README.md +27 -0
  18. package/recipes/autoresearch/RESEARCH.template.md +37 -0
  19. package/recipes/autoresearch/example/RESEARCH.md +31 -0
  20. package/recipes/autoresearch/example/eval.py +60 -0
  21. package/recipes/autoresearch/example/eval.sh +3 -0
  22. package/recipes/autoresearch/example/solution.py +20 -0
  23. package/recipes/autoresearch/experiment.md +82 -0
  24. package/recipes/autoresearch/recipe.toml +15 -0
  25. package/recipes/changelog-draft/recipe.toml +9 -0
  26. package/recipes/changelog-draft/release.md +35 -0
  27. package/recipes/issue-loop/README.md +16 -0
  28. package/recipes/issue-loop/implement.md +135 -0
  29. package/recipes/issue-loop/labels.sh +21 -0
  30. package/recipes/issue-loop/recipe.toml +18 -0
  31. package/recipes/issue-loop/triage.md +147 -0
  32. package/skills/pi-loops/SKILL.md +3 -1
  33. package/src/archive.ts +5 -5
  34. package/src/args.ts +1 -8
  35. package/src/cli.ts +83 -6
  36. package/src/job-edit.ts +0 -3
  37. package/src/job-health.ts +6 -7
  38. package/src/job-owner.ts +29 -0
  39. package/src/pi-loops.ts +339 -25
  40. package/src/protocol.ts +2 -3
  41. package/src/recipe.ts +651 -0
  42. package/src/scheduler.ts +13 -13
  43. package/src/session-head.ts +98 -0
  44. package/src/store.ts +9 -17
  45. package/src/tools.ts +17 -19
  46. package/src/trigger-runtime.ts +3 -5
  47. package/src/triggers.ts +3 -4
  48. 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 session ids you can export, newest first.",
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 header = JSON.parse(fs.readFileSync(file, "utf8").split("\n", 1)[0] ?? "{}");
150
- if (typeof header?.id !== "string") continue;
151
- out.push({ id: header.id, file, cwd: typeof header.cwd === "string" ? header.cwd : "", mtimeMs: fs.statSync(file).mtimeMs });
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
- for (const s of here.slice(0, Number(str("limit") ?? 20))) out(`${s.id} ${stamp(s.mtimeMs)} ${s.cwd}`);
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 on `hostName`, worst first. The threshold is the scheduler's backoff threshold, which
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. A job pinned to another host (a shared $HOME) is that host's to
16
- * report, not ours.
15
+ * already started widening the gap.
17
16
  */
18
- export function failingJobs(jobs: LoopJob[], hostName: string): LoopJob[] {
19
- return jobs.filter((j) => j.enabled && (!j.host || j.host === hostName) && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
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[], hostName: string): string | undefined {
24
- const failing = failingJobs(jobs, hostName);
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;
@@ -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
+ }