@alphacoder-v0/pi-loops 0.19.3 → 0.22.0
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 +164 -0
- package/CONTRACT.md +139 -0
- package/README.md +12 -7
- package/README.zh-CN.md +6 -4
- package/docs/cli.md +19 -1
- package/docs/downstream.md +60 -0
- package/docs/goal.md +4 -2
- package/docs/loops.md +55 -9
- package/docs/recipes.md +140 -18
- package/docs/session-archive.md +8 -3
- package/docs/web-ui-parity.md +11 -1
- package/package.json +6 -2
- package/recipes/_tracker/issue-tracker-local.md +3 -1
- package/recipes/autoresearch/experiment.md +30 -6
- package/recipes/autoresearch/recipe.toml +2 -0
- package/recipes/changelog-draft/recipe.toml +6 -1
- package/recipes/changelog-draft/release.md +25 -5
- package/recipes/ci-sweeper/README.md +9 -0
- package/recipes/ci-sweeper/recipe.toml +16 -0
- package/recipes/ci-sweeper/sweep.md +71 -0
- package/recipes/daily-digest/README.md +10 -0
- package/recipes/daily-digest/digest.md +65 -0
- package/recipes/daily-digest/recipe.toml +14 -0
- package/recipes/deps-sweeper/README.md +11 -0
- package/recipes/deps-sweeper/audit.md +99 -0
- package/recipes/deps-sweeper/recipe.toml +17 -0
- package/recipes/ecosystem/README.md +9 -0
- package/recipes/ecosystem/recipe.toml +15 -0
- package/recipes/ecosystem/scan.md +59 -0
- package/recipes/issue-loop/implement.md +17 -4
- package/recipes/issue-loop/labels.sh +18 -0
- package/recipes/issue-loop/recipe.toml +2 -0
- package/recipes/issue-loop/triage.md +16 -2
- package/recipes/pr-watch/README.md +9 -0
- package/recipes/pr-watch/recipe.toml +15 -0
- package/recipes/pr-watch/watch.md +70 -0
- package/skills/pi-loops/SKILL.md +3 -2
- package/src/archive.ts +97 -2
- package/src/cli.ts +103 -16
- package/src/goal.ts +11 -0
- package/src/hooks.ts +25 -1
- package/src/host-runtime.ts +4 -8
- package/src/inbox.ts +118 -55
- package/src/job-signal.ts +78 -0
- package/src/pi-loops.ts +177 -109
- package/src/presence.ts +5 -0
- package/src/protocol.ts +45 -2
- package/src/recipe.ts +158 -2
- package/src/scheduler.ts +6 -2
- package/src/store.ts +8 -3
- package/src/tools.ts +17 -11
- package/src/triggers.ts +8 -3
- package/src/web.mjs +73 -7
package/src/recipe.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* to `parseAddArgs` — the same parser, the same refusals — so a recipe can never create a job a
|
|
10
10
|
* person could not have typed.
|
|
11
11
|
*/
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as os from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
@@ -42,6 +42,18 @@ export interface RecipeManifest {
|
|
|
42
42
|
summary: string;
|
|
43
43
|
needsTracker: boolean;
|
|
44
44
|
levels: AutonomyLevel[];
|
|
45
|
+
/**
|
|
46
|
+
* Where the recipe sits in `/recipe list`: a `starter` reads and files findings and is the
|
|
47
|
+
* one to install first; an `advanced` one writes somewhere — a worktree, a pull request, a
|
|
48
|
+
* tracker — and wants the playbooks read before the level question is answered.
|
|
49
|
+
*/
|
|
50
|
+
tier: RecipeTier;
|
|
51
|
+
/** One sentence each, for `/recipe show`: the situations this recipe is for. */
|
|
52
|
+
usefulWhen: string[];
|
|
53
|
+
/** What the project must have for a run to work, checked before the install (`PREFLIGHT_CHECKS`). */
|
|
54
|
+
needs: PreflightCheck[];
|
|
55
|
+
/** The same, for `propose` and `act` — the levels whose runs push, open pull requests, or write. */
|
|
56
|
+
needsPropose: PreflightCheck[];
|
|
45
57
|
/** A script run once per project, with confirmation, before the jobs exist. */
|
|
46
58
|
setup?: string;
|
|
47
59
|
/** Files copied beside the playbooks (templates a playbook tells the run to create from). */
|
|
@@ -69,6 +81,18 @@ export interface InstallRecord {
|
|
|
69
81
|
source: string;
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The checks a manifest may ask for before the install (`needs`, `needs_propose`). Each is one
|
|
86
|
+
* deterministic look at the project — a command on the PATH, a file, a remote — and none blocks:
|
|
87
|
+
* the point is that "the first sign of credentials that do not work should not be an empty inbox
|
|
88
|
+
* tomorrow morning" (README), so the confirmation says it tonight instead.
|
|
89
|
+
*/
|
|
90
|
+
export const PREFLIGHT_CHECKS = ["gh", "git-remote", "ci-workflows", "lockfile", "tracker-github"] as const;
|
|
91
|
+
export type PreflightCheck = (typeof PREFLIGHT_CHECKS)[number];
|
|
92
|
+
|
|
93
|
+
export const RECIPE_TIERS = ["starter", "advanced"] as const;
|
|
94
|
+
export type RecipeTier = (typeof RECIPE_TIERS)[number];
|
|
95
|
+
|
|
72
96
|
export const MANIFEST_FILE = "recipe.toml";
|
|
73
97
|
export const RECORD_FILE = ".recipe.json";
|
|
74
98
|
/** Where a recipe's files land in a project, relative to the project root. */
|
|
@@ -157,6 +181,19 @@ export function parseManifest(text: string, opts: { now?: number } = {}): Recipe
|
|
|
157
181
|
for (const f of files) if (!safeRelative(f)) throw new Error(`${where}: files entry "${f}" must be inside the recipe directory`);
|
|
158
182
|
const budget = doc.budget_hint_usd;
|
|
159
183
|
if (budget !== undefined && (typeof budget !== "number" || budget < 0)) throw new Error(`${where}: budget_hint_usd must be a non-negative number`);
|
|
184
|
+
const tierRaw = str(doc, "tier", where) ?? "advanced";
|
|
185
|
+
if (!(RECIPE_TIERS as readonly string[]).includes(tierRaw)) throw new Error(`${where}: tier must be ${RECIPE_TIERS.join(" or ")} (got "${tierRaw}")`);
|
|
186
|
+
const usefulWhen = strList(doc, "useful_when", where) ?? [];
|
|
187
|
+
const checks = (key: string): PreflightCheck[] => {
|
|
188
|
+
const out: PreflightCheck[] = [];
|
|
189
|
+
for (const c of strList(doc, key, where) ?? []) {
|
|
190
|
+
if (!(PREFLIGHT_CHECKS as readonly string[]).includes(c)) throw new Error(`${where}: ${key} entry "${c}" is not a check (checks are ${PREFLIGHT_CHECKS.join(", ")})`);
|
|
191
|
+
if (!out.includes(c as PreflightCheck)) out.push(c as PreflightCheck);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
};
|
|
195
|
+
const needs = checks("needs");
|
|
196
|
+
const needsPropose = checks("needs_propose");
|
|
160
197
|
const jobsRaw = doc.job;
|
|
161
198
|
if (!Array.isArray(jobsRaw) || jobsRaw.length === 0) throw new Error(`${where}: at least one [[job]] is required`);
|
|
162
199
|
const jobs: RecipeJob[] = [];
|
|
@@ -190,7 +227,7 @@ export function parseManifest(text: string, opts: { now?: number } = {}): Recipe
|
|
|
190
227
|
}
|
|
191
228
|
jobs.push(job);
|
|
192
229
|
}
|
|
193
|
-
return { name, summary, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, setup, files, budgetHintUsd: budget as number | undefined, jobs };
|
|
230
|
+
return { name, summary, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, tier: tierRaw as RecipeTier, usefulWhen, needs, needsPropose, setup, files, budgetHintUsd: budget as number | undefined, jobs };
|
|
194
231
|
}
|
|
195
232
|
|
|
196
233
|
/** Relative, inside the directory, and a single file: no `..`, no absolute path, no trailing slash. */
|
|
@@ -306,6 +343,125 @@ export function readLevelLine(text: string): AutonomyLevel | undefined {
|
|
|
306
343
|
return l && (AUTONOMY_LEVELS as readonly string[]).includes(l) ? (l as AutonomyLevel) : undefined;
|
|
307
344
|
}
|
|
308
345
|
|
|
346
|
+
/**
|
|
347
|
+
* What a playbook forbids, for `/recipe show` and the install confirmation: the non-empty lines
|
|
348
|
+
* under every `## Never…` heading (`## Never`, `## Never bump`), up to the next `## ` heading.
|
|
349
|
+
* The section is the packaged recipes' convention for the safety envelope of a run, and the one
|
|
350
|
+
* place a person can read it before the playbook is installed and followed unattended.
|
|
351
|
+
*/
|
|
352
|
+
export function neverSection(text: string): string[] {
|
|
353
|
+
const out: string[] = [];
|
|
354
|
+
let inside = false;
|
|
355
|
+
for (const line of text.split("\n")) {
|
|
356
|
+
if (/^## /.test(line)) {
|
|
357
|
+
inside = /^## Never\b/.test(line);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (inside && line.trim()) out.push(line.trimEnd());
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* ------------------------------------------------------------------------------- preflight */
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Which checks apply to an install of `manifest` at `level`: the declared ones, `needs_propose`
|
|
369
|
+
* above `report`, and — for a recipe that reads the tracker — `gh` when the tracker description
|
|
370
|
+
* uses it. That last rule is what keeps a local Markdown tracker out of it: its description has
|
|
371
|
+
* no `gh` in it, so nothing is asked for that the project was never going to use. Returned in
|
|
372
|
+
* `PREFLIGHT_CHECKS` order, each once.
|
|
373
|
+
*/
|
|
374
|
+
export function preflightChecks(manifest: RecipeManifest, level: AutonomyLevel, trackerText: string | undefined): PreflightCheck[] {
|
|
375
|
+
const wanted = new Set<PreflightCheck>(manifest.needs);
|
|
376
|
+
if (level !== "report") for (const c of manifest.needsPropose) wanted.add(c);
|
|
377
|
+
if (manifest.needsTracker && trackerText && /\bgh\b/.test(trackerText)) wanted.add("gh");
|
|
378
|
+
return PREFLIGHT_CHECKS.filter((c) => wanted.has(c));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface PreflightLine {
|
|
382
|
+
check: PreflightCheck;
|
|
383
|
+
/** true = there; false = not there; undefined = could not tell (a timeout, an unreadable file). */
|
|
384
|
+
ok: boolean | undefined;
|
|
385
|
+
/** What was found, and for a miss what it means for the runs. */
|
|
386
|
+
detail: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "Cargo.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "go.sum", "Gemfile.lock", "composer.lock"];
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Runs the checks against `project`. Never throws: a check that cannot run says so in its line.
|
|
393
|
+
* The commands run off the turn (`gh auth status` reaches the network), so a slow one holds the
|
|
394
|
+
* confirmation, not the whole window.
|
|
395
|
+
*/
|
|
396
|
+
export async function runPreflight(checks: PreflightCheck[], project: string, opts: { env?: NodeJS.ProcessEnv } = {}): Promise<PreflightLine[]> {
|
|
397
|
+
const env = opts.env ?? process.env;
|
|
398
|
+
const run = (file: string, args: string[]): Promise<{ code: number | "missing" | "timeout"; out: string }> =>
|
|
399
|
+
new Promise((resolve) => {
|
|
400
|
+
execFile(file, args, { cwd: project, env, encoding: "utf8", timeout: 10_000 }, (err: any, stdout, stderr) => {
|
|
401
|
+
if (!err) return resolve({ code: 0, out: String(stdout) });
|
|
402
|
+
if (err.code === "ENOENT") return resolve({ code: "missing", out: "" });
|
|
403
|
+
if (err.killed || err.code === "ETIMEDOUT") return resolve({ code: "timeout", out: "" });
|
|
404
|
+
resolve({ code: typeof err.code === "number" ? err.code : 1, out: `${stdout ?? ""}${stderr ?? ""}` });
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
const lines: PreflightLine[] = [];
|
|
408
|
+
for (const check of checks) {
|
|
409
|
+
switch (check) {
|
|
410
|
+
case "gh": {
|
|
411
|
+
const r = await run("gh", ["auth", "status"]);
|
|
412
|
+
if (r.code === 0) lines.push({ check, ok: true, detail: "gh: logged in" });
|
|
413
|
+
else if (r.code === "missing") lines.push({ check, ok: false, detail: "gh: not on the PATH — install the GitHub CLI and run `gh auth login`; runs that use it fail until then" });
|
|
414
|
+
else if (r.code === "timeout") lines.push({ check, ok: undefined, detail: "gh: could not check (`gh auth status` took more than 10s)" });
|
|
415
|
+
else lines.push({ check, ok: false, detail: "gh: not logged in — run `gh auth login`; runs that use it fail until then" });
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case "git-remote": {
|
|
419
|
+
const r = await run("git", ["remote", "get-url", "origin"]);
|
|
420
|
+
if (r.code === 0) lines.push({ check, ok: true, detail: `git remote: origin is ${r.out.trim().replace(/(:\/\/)[^/@\s]+@/, "$1")}` });
|
|
421
|
+
else if (r.code === "missing") lines.push({ check, ok: undefined, detail: "git remote: could not check (git is not on the PATH)" });
|
|
422
|
+
else if (r.code === "timeout") lines.push({ check, ok: undefined, detail: "git remote: could not check (git took more than 10s)" });
|
|
423
|
+
else lines.push({ check, ok: false, detail: "git remote: no origin — runs that fetch from it, push to it or open pull requests fail" });
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case "ci-workflows": {
|
|
427
|
+
let names: string[];
|
|
428
|
+
try {
|
|
429
|
+
names = fs.readdirSync(path.join(project, ".github", "workflows")).filter((n) => /\.ya?ml$/.test(n));
|
|
430
|
+
} catch {
|
|
431
|
+
names = [];
|
|
432
|
+
}
|
|
433
|
+
lines.push(names.length ? { check, ok: true, detail: `CI workflows: ${names.length} under .github/workflows` } : { check, ok: false, detail: "CI workflows: none under .github/workflows — runs that read the default branch's checks find no runs" });
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case "lockfile": {
|
|
437
|
+
const found = LOCKFILES.find((f) => fs.existsSync(path.join(project, f)));
|
|
438
|
+
lines.push(found ? { check, ok: true, detail: `lockfile: ${found}` } : { check, ok: false, detail: "lockfile: none found — the audit and outdated commands need one to name the package manager" });
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case "tracker-github": {
|
|
442
|
+
let text: string | undefined;
|
|
443
|
+
try {
|
|
444
|
+
text = fs.readFileSync(path.join(project, TRACKER_FILE), "utf8");
|
|
445
|
+
} catch {
|
|
446
|
+
text = undefined;
|
|
447
|
+
}
|
|
448
|
+
if (text === undefined) lines.push({ check, ok: false, detail: `tracker: ${TRACKER_FILE} is missing` });
|
|
449
|
+
else if (/\bgh\b/.test(text)) lines.push({ check, ok: true, detail: "tracker: GitHub" });
|
|
450
|
+
else lines.push({ check, ok: false, detail: "tracker: local Markdown, which has no pull requests — this recipe has nothing to watch" });
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return lines;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The lines the confirmation and `show` print: one per check, or one line when every check passed. */
|
|
459
|
+
export function renderPreflight(lines: PreflightLine[]): string[] {
|
|
460
|
+
if (!lines.length) return [];
|
|
461
|
+
if (lines.every((l) => l.ok)) return [" all checks pass"];
|
|
462
|
+
return lines.map((l) => ` ${l.ok ? "✓" : l.ok === false ? "✗" : "?"} ${l.detail}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
309
465
|
/* ------------------------------------------------------------------------------ installing */
|
|
310
466
|
|
|
311
467
|
export interface InstallPlan {
|
package/src/scheduler.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { PresenceRegistry, type PresenceEntry, type PresenceKind, type PresenceS
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { Inbox } from "./inbox.ts";
|
|
17
17
|
import { pidAlive, withFileLock, writeFileAtomic } from "./lock.ts";
|
|
18
|
-
import { composeCheckerPrompt, composeLoopPrompt, parseCheckerOutput, parseRunOutput, stripProtocolTags } from "./protocol.ts";
|
|
18
|
+
import { composeCheckerPrompt, composeLoopPrompt, findingKind, parseCheckerOutput, parseRunOutput, stripProtocolTags } from "./protocol.ts";
|
|
19
19
|
import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
|
|
20
20
|
import { previewRedacted, redact } from "./redact.ts";
|
|
21
21
|
import { type SubagentSlot, SubagentSlots } from "./slots.ts";
|
|
@@ -767,8 +767,12 @@ export class LoopScheduler {
|
|
|
767
767
|
/** The body of one run: everything between claiming the job and writing its record. */
|
|
768
768
|
private async runOnce(job: LoopJob, claimed: LoopJob, runId: string, startedAt: string, ctrl: AbortController, session: SessionSnapshot, now: number, catchingUp: boolean, prior: { dueAt?: string; firedAt?: string }): Promise<void> {
|
|
769
769
|
const previousState = this.store.readState(job.id);
|
|
770
|
+
// `job` is the pre-claim snapshot, so its `lastFiredAt` is when the previous run started —
|
|
771
|
+
// a reason given before that was already in front of that run.
|
|
772
|
+
const dismissed = this.inbox.feedbackFor(job.id, job.lastFiredAt).map((e) => ({ text: String(e.text), reason: String(e.dismiss_reason ?? "") }));
|
|
770
773
|
const prompt = composeLoopPrompt(job.prompt, previousState, {
|
|
771
774
|
name: job.name,
|
|
775
|
+
dismissed,
|
|
772
776
|
// Zoned, unlike everything shown on a screen: this one goes into a sub-agent's prompt,
|
|
773
777
|
// where a model is asked to reason about how long ago the last run was and has no
|
|
774
778
|
// surrounding context to tell it which clock this came off. It used to be a bare local
|
|
@@ -834,7 +838,7 @@ export class LoopScheduler {
|
|
|
834
838
|
const source = `cron:${job.name ?? job.id.slice(0, "cron-".length + 8)}`;
|
|
835
839
|
for (const f of reviewed) {
|
|
836
840
|
try {
|
|
837
|
-
await this.inbox.append({ source, text: f.text, runId,
|
|
841
|
+
await this.inbox.append({ source, text: f.text, run_id: runId, job_id: job.id, cwd: job.cwd, kind: findingKind(f.text), verified: f.verified, verified_reason: f.reason });
|
|
838
842
|
findings.push(f.text);
|
|
839
843
|
} catch (err: any) {
|
|
840
844
|
this.log(`loop ${job.id}: inbox append failed: ${err?.message ?? err}`);
|
package/src/store.ts
CHANGED
|
@@ -579,11 +579,16 @@ export class JobStore {
|
|
|
579
579
|
}
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
-
/**
|
|
583
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Resolve an id / unique id prefix / exact name — and, with `ordinals: true`, "<n>", the 1-based
|
|
584
|
+
* position in `jobs`. A number is a position in a list the caller printed, so only a caller that
|
|
585
|
+
* printed one may ask for it (`/cron`, which numbers what it shows); everywhere else, the
|
|
586
|
+
* model-facing tools included, an all-digit ref resolves to nothing.
|
|
587
|
+
*/
|
|
588
|
+
export function resolveJobRef(jobs: LoopJob[], ref: string, opts?: { ordinals?: boolean }): LoopJob | undefined {
|
|
584
589
|
const trimmed = ref.trim();
|
|
585
590
|
if (!trimmed) return undefined;
|
|
586
|
-
if (/^\d+$/.test(trimmed)) return jobs[Number(trimmed) - 1];
|
|
591
|
+
if (/^\d+$/.test(trimmed)) return opts?.ordinals ? jobs[Number(trimmed) - 1] : undefined;
|
|
587
592
|
const exact = jobs.find((j) => j.id === trimmed || j.name === trimmed);
|
|
588
593
|
if (exact) return exact;
|
|
589
594
|
const byPrefix = jobs.filter((j) => j.id.startsWith(trimmed));
|
package/src/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* definitions. One factory serves the interactive session (hop 0, registered with pi), every
|
|
5
5
|
* in-process sub-session (hop 1, passed as customTools) and the headless host (hop 1, no UI).
|
|
6
6
|
*/
|
|
7
|
+
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal } from "./job-signal.ts";
|
|
7
8
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import * as os from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
@@ -14,7 +15,7 @@ import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
|
14
15
|
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
15
16
|
import { asleepNote, ownerSession, runsIn } from "./job-owner.ts";
|
|
16
17
|
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
17
|
-
import { withinProject } from "./presence.ts";
|
|
18
|
+
import { sameProject, withinProject } from "./presence.ts";
|
|
18
19
|
import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
19
20
|
|
|
20
21
|
export interface ControlPlaneRequest {
|
|
@@ -77,16 +78,11 @@ function resolveJobCwd(sessionCwd: string, cwd: string | undefined): string {
|
|
|
77
78
|
return cwd ? path.resolve(sessionCwd || process.cwd(), cwd) : path.resolve(sessionCwd);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
|
|
81
|
-
function sameProject(a: string, b: string): boolean {
|
|
82
|
-
return withinProject(b, a) || withinProject(a, b);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
81
|
/** A rule by ref, preferring this project's; another project's needs its exact id. */
|
|
86
82
|
export function resolveRuleRefScoped(rules: DynamicTriggerRule[], ref: string, cwd: string): DynamicTriggerRule | undefined {
|
|
87
83
|
// Ordinals are what the user sees in `/triggers rules`; a model's list may be a different one,
|
|
88
|
-
// so the tools take an id, a unique prefix or a name — never a position.
|
|
89
|
-
|
|
84
|
+
// so the tools take an id, a unique prefix or a name — never a position. That is the resolver's
|
|
85
|
+
// default: no caller here printed a numbered list, so none of them asks for one.
|
|
90
86
|
const mine = resolveRuleRef(
|
|
91
87
|
rules.filter((r) => withinProject(cwd, r.cwd) || withinProject(r.cwd, cwd)),
|
|
92
88
|
ref,
|
|
@@ -98,7 +94,6 @@ export function resolveRuleRefScoped(rules: DynamicTriggerRule[], ref: string, c
|
|
|
98
94
|
|
|
99
95
|
/** A job by ref, preferring this project's; another project's needs its exact id. */
|
|
100
96
|
export function resolveJobRefScoped(jobs: LoopJob[], ref: string, cwd: string): LoopJob | undefined {
|
|
101
|
-
if (/^\d+$/.test(ref.trim())) return undefined;
|
|
102
97
|
const mine = resolveJobRef(
|
|
103
98
|
jobs.filter((j) => sameProject(j.cwd, cwd)),
|
|
104
99
|
ref,
|
|
@@ -182,10 +177,13 @@ function nextRunForTool(job: LoopJob, now: number, sessionId: string | undefined
|
|
|
182
177
|
}
|
|
183
178
|
|
|
184
179
|
/** Cron jobs, rendered for a tool result. */
|
|
185
|
-
function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">): string {
|
|
180
|
+
function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session" | "scheduler">): string {
|
|
186
181
|
if (!jobs.length) return "cron jobs: none";
|
|
187
182
|
const now = Date.now();
|
|
188
183
|
const lines = [`cron jobs: ${jobs.length}`];
|
|
184
|
+
// Read once for the listing, as `/cron` does; only loops have a signal to report.
|
|
185
|
+
const runs = jobs.some((j) => j.stateful) ? host.scheduler.store.allRuns() : [];
|
|
186
|
+
const inbox = jobs.some((j) => j.stateful) ? host.scheduler.inbox.list() : [];
|
|
189
187
|
for (const job of jobs) {
|
|
190
188
|
lines.push(`- ${job.id}${job.name ? ` "${job.name}"` : ""} [${job.enabled ? "enabled" : "disabled"}${job.stateful ? ", stateful" : ""}${job.verify ? ", verify" : ""}] schedule: ${formatSchedule(job.schedule)} action: ${previewRedacted(job.prompt, 120)}${job.cwd !== host.session().cwd ? ` cwd: ${job.cwd}` : ""}`);
|
|
191
189
|
const asleep = asleepNote(job, host.session().sessionId);
|
|
@@ -194,7 +192,15 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">)
|
|
|
194
192
|
if (next) lines.push(` next_run: ${stamp(next)}`);
|
|
195
193
|
if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
|
|
196
194
|
if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
|
|
195
|
+
if (job.consecutiveFailures) lines.push(` consecutive_failures: ${job.consecutiveFailures}`);
|
|
197
196
|
if (job.skippedOverlap) lines.push(` skipped_overlap_count: ${job.skippedOverlap}`);
|
|
197
|
+
if (job.stateful) {
|
|
198
|
+
// The same two facts `/cron` shows, so the model can say "this loop's findings are all
|
|
199
|
+
// dismissed" or "it has found nothing in 12 runs" when the user asks about their automation.
|
|
200
|
+
const s = loopSignal(job.id, runs, inbox, now - SIGNAL_WINDOW_MS);
|
|
201
|
+
if (s.findings) lines.push(` signal_30d: findings=${s.findings} claimed=${s.claimed} dismissed=${s.dismissed}`);
|
|
202
|
+
if (s.quiet >= QUIET_MARK_AFTER) lines.push(` quiet_streak: ${s.quiet} (consecutive runs with no finding)`);
|
|
203
|
+
}
|
|
198
204
|
}
|
|
199
205
|
return lines.join("\n");
|
|
200
206
|
}
|
|
@@ -379,7 +385,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
379
385
|
const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
|
|
380
386
|
const nowMs = Date.now();
|
|
381
387
|
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs, host.session().sessionId);
|
|
382
|
-
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), owner_session: ownerSession(j), asleep: !runsIn(j, host.session().sessionId) || undefined, created_at: j.createdAt })) } };
|
|
388
|
+
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, consecutive_failures: j.consecutiveFailures, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), owner_session: ownerSession(j), asleep: !runsIn(j, host.session().sessionId) || undefined, created_at: j.createdAt })) } };
|
|
383
389
|
},
|
|
384
390
|
});
|
|
385
391
|
|
package/src/triggers.ts
CHANGED
|
@@ -443,11 +443,16 @@ export function auditCronFinish(store: TriggerStore, job: { id: string; cwd: str
|
|
|
443
443
|
});
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
-
/**
|
|
447
|
-
|
|
446
|
+
/**
|
|
447
|
+
* Resolve an id or a unique id prefix — and, with `ordinals: true`, "<n>", the 1-based position in
|
|
448
|
+
* `rules`. A number is a position in a list the caller printed, so only a caller that printed one
|
|
449
|
+
* may ask for it (`/triggers`, which numbers what it shows); everywhere else, the model-facing
|
|
450
|
+
* tools included, an all-digit ref resolves to nothing.
|
|
451
|
+
*/
|
|
452
|
+
export function resolveRuleRef(rules: DynamicTriggerRule[], ref: string, opts?: { ordinals?: boolean }): DynamicTriggerRule | undefined {
|
|
448
453
|
const t = ref.trim();
|
|
449
454
|
if (!t) return undefined;
|
|
450
|
-
if (/^\d+$/.test(t)) return rules[Number(t) - 1];
|
|
455
|
+
if (/^\d+$/.test(t)) return opts?.ordinals ? rules[Number(t) - 1] : undefined;
|
|
451
456
|
const exact = rules.find((r) => r.id === t);
|
|
452
457
|
if (exact) return exact;
|
|
453
458
|
const hits = rules.filter((r) => r.id.startsWith(t));
|
package/src/web.mjs
CHANGED
|
@@ -512,11 +512,15 @@ function automation(cwd) {
|
|
|
512
512
|
const elsewhere = { jobs: allJobs.length - jobs.length, rules: allRules.length - rules.length };
|
|
513
513
|
|
|
514
514
|
let inboxNew = 0;
|
|
515
|
+
let inboxDecisions = 0;
|
|
515
516
|
try {
|
|
516
517
|
for (const line of fs.readFileSync(path.join(LOOPS_DIR, "inbox.jsonl"), "utf8").split("\n")) {
|
|
517
518
|
if (!line.trim()) continue;
|
|
518
519
|
try {
|
|
519
|
-
|
|
520
|
+
const e = JSON.parse(line);
|
|
521
|
+
if ((e.status ?? "new") !== "new") continue;
|
|
522
|
+
inboxNew++;
|
|
523
|
+
if (e.kind === "checkpoint") inboxDecisions++;
|
|
520
524
|
} catch {
|
|
521
525
|
/* skip a torn line */
|
|
522
526
|
}
|
|
@@ -527,7 +531,7 @@ function automation(cwd) {
|
|
|
527
531
|
|
|
528
532
|
// MCP servers and hooks are deliberately not counted from the config files here: what matters
|
|
529
533
|
// is which ones actually connected and what they exposed, and that is in the runtime snapshot.
|
|
530
|
-
return { installed: true, dir: LOOPS_DIR, jobs, rules, inboxNew, elsewhere };
|
|
534
|
+
return { installed: true, dir: LOOPS_DIR, jobs, rules, inboxNew, inboxDecisions, elsewhere };
|
|
531
535
|
}
|
|
532
536
|
|
|
533
537
|
/* ------------------------------------------------------------------ what a prompt needs first */
|
|
@@ -1359,14 +1363,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1359
1363
|
if (!text && !(images ?? []).length) return void json(res, { success: false, error: "empty prompt" }, 400);
|
|
1360
1364
|
const guard = commandRefusal(text ?? "");
|
|
1361
1365
|
if (guard) return void json(res, guard, 400);
|
|
1362
|
-
// Submitting while a turn runs queues instead of racing it, as the TUI does.
|
|
1363
|
-
|
|
1366
|
+
// Submitting while a turn runs queues instead of racing it, as the TUI does. `prompt` is the
|
|
1367
|
+
// one rpc command that carries both halves of that: pi queues it while streaming, and an
|
|
1368
|
+
// extension command — /inbox, /cron, /triggers, /goal — runs immediately, which is what the
|
|
1369
|
+
// terminal does. The `follow_up` and `steer` commands refuse anything naming one of them with
|
|
1370
|
+
// pi's "cannot be queued", which told the person to call prompt(). It is asked for
|
|
1371
|
+
// unconditionally because the page's busy flag is its own guess: a send that lands while pi
|
|
1372
|
+
// is streaming but before the page has noticed is queued rather than refused, and while pi
|
|
1373
|
+
// is idle the option is not read at all.
|
|
1374
|
+
const streamingBehavior = mode === "steer" ? "steer" : "followUp";
|
|
1364
1375
|
const message = expandMentions(text ?? "", sessionCwd);
|
|
1365
1376
|
// pi answers a prompt command when the command has *finished*, and a slash command may
|
|
1366
1377
|
// sit in a dialog for as long as a person takes to read what it shows (a setup script,
|
|
1367
1378
|
// say). Sixty seconds reported that as "timed out" while the command went on to succeed.
|
|
1368
1379
|
const patience = message.trimStart().startsWith("/") ? 15 * 60_000 : undefined;
|
|
1369
|
-
return void json(res, await rpc({ type, message, ...(images?.length ? { images } : {}) }, patience));
|
|
1380
|
+
return void json(res, await rpc({ type: "prompt", message, streamingBehavior, ...(images?.length ? { images } : {}) }, patience));
|
|
1370
1381
|
}
|
|
1371
1382
|
if (url.pathname === "/model" && req.method === "POST") {
|
|
1372
1383
|
// Recorded below, once pi has accepted it.
|
|
@@ -3425,7 +3436,7 @@ function renderSidebar(s) {
|
|
|
3425
3436
|
if (!a.installed) { box.innerHTML = '<div class="notice">pi-loops not found in ' + safeText(a.dir || "") + "</div>"; }
|
|
3426
3437
|
else {
|
|
3427
3438
|
let html = "";
|
|
3428
|
-
html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
|
|
3439
|
+
html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new" + (a.inboxDecisions ? " (" + num(a.inboxDecisions) + " decision" + (a.inboxDecisions === 1 ? "" : "s") + ")" : "") + " · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
|
|
3429
3440
|
for (const j of a.jobs) {
|
|
3430
3441
|
html += '<div class="card' + (j.enabled ? "" : " off") + '"><div class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
|
|
3431
3442
|
'<span class="m">' + safeText(j.schedule) + (j.stateful ? " · loop" : "") + "</span>" +
|
|
@@ -3793,7 +3804,7 @@ $("composer").onsubmit = async (e) => {
|
|
|
3793
3804
|
const input = $("input");
|
|
3794
3805
|
let text = input.value.trim();
|
|
3795
3806
|
if (!text && !images.length) return;
|
|
3796
|
-
input.value = ""; hidePop();
|
|
3807
|
+
input.value = ""; hidePop(); saveDraft(); autoGrow();
|
|
3797
3808
|
if (text) { sentPrompts.push(text); if (sentPrompts.length > 200) sentPrompts.shift(); }
|
|
3798
3809
|
promptIdx = -1;
|
|
3799
3810
|
// Typed rather than clicked, which is how these get used once they are a habit. They run here
|
|
@@ -3853,6 +3864,8 @@ $("undo").onclick = async () => {
|
|
|
3853
3864
|
if (r.data?.cancelled) return row("notice", "", "undo was cancelled by an extension");
|
|
3854
3865
|
// pi hands back the forked message; put it where it came from.
|
|
3855
3866
|
$("input").value = r.data?.text ?? "";
|
|
3867
|
+
saveDraft();
|
|
3868
|
+
autoGrow();
|
|
3856
3869
|
// The branch moved, so the conversation is taken again — through resync, which is the one path
|
|
3857
3870
|
// that also resets what the page is counting against and drains what arrived meanwhile. This used
|
|
3858
3871
|
// to be a second copy of it, and the copy was missing all three of those.
|
|
@@ -4152,6 +4165,51 @@ function remember(key, value) {
|
|
|
4152
4165
|
}
|
|
4153
4166
|
}
|
|
4154
4167
|
|
|
4168
|
+
/**
|
|
4169
|
+
* What you have typed and not sent is the only copy of it, and this page reloads for its own
|
|
4170
|
+
* reasons: the header button, the banner that says the page is stale, a phone that discards a
|
|
4171
|
+
* backgrounded tab. None of them should cost a prompt. sessionStorage rather than localStorage,
|
|
4172
|
+
* because two tabs of this address are open at once by design and one tab's typing must not land in
|
|
4173
|
+
* the other's box. As with the theme, the store may refuse — that costs the draft, never the page.
|
|
4174
|
+
*/
|
|
4175
|
+
const DRAFT_KEY = "pi-web-draft";
|
|
4176
|
+
function savedDraft() {
|
|
4177
|
+
try {
|
|
4178
|
+
return sessionStorage.getItem(DRAFT_KEY) ?? "";
|
|
4179
|
+
} catch (_) {
|
|
4180
|
+
return "";
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
/**
|
|
4184
|
+
* The text in the box, kept as it changes. Only the text: an attached image is one gesture to redo.
|
|
4185
|
+
* Recalling a sent prompt with the up-arrow deliberately does not come through here — it is a look at
|
|
4186
|
+
* something that already exists elsewhere, and storing it would drop the one copy of what does not.
|
|
4187
|
+
*/
|
|
4188
|
+
function saveDraft() {
|
|
4189
|
+
try {
|
|
4190
|
+
const text = $("input").value;
|
|
4191
|
+
if (text) sessionStorage.setItem(DRAFT_KEY, text);
|
|
4192
|
+
else sessionStorage.removeItem(DRAFT_KEY);
|
|
4193
|
+
} catch (_) {
|
|
4194
|
+
// Nothing to do and nothing worth saying: the draft simply does not outlive a reload.
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
/**
|
|
4199
|
+
* The box grows with what is in it, up to the ceiling the stylesheet already puts on it. The order
|
|
4200
|
+
* matters: scrollHeight is measured against the height the box has now, so it is set back to auto
|
|
4201
|
+
* first — assigned straight back, the box can only ever get taller, and a shorter prompt would sit in
|
|
4202
|
+
* a slot as tall as the last one. The cap stays in CSS (max-height:40vh) rather than being said
|
|
4203
|
+
* twice, so past it the box scrolls exactly as it did before.
|
|
4204
|
+
*/
|
|
4205
|
+
function autoGrow() {
|
|
4206
|
+
const el = $("input");
|
|
4207
|
+
el.style.height = "auto";
|
|
4208
|
+
// scrollHeight leaves out the border, and these boxes are border-box: without it added back, the
|
|
4209
|
+
// content overflows by exactly the border and a box that fits its text shows a scrollbar anyway.
|
|
4210
|
+
el.style.height = el.scrollHeight + (el.offsetHeight - el.clientHeight) + "px";
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4155
4213
|
const THEMES = ["system", "light", "dark"];
|
|
4156
4214
|
function applyTheme(theme) {
|
|
4157
4215
|
const root = document.documentElement;
|
|
@@ -4269,6 +4327,8 @@ function accept(i) {
|
|
|
4269
4327
|
el.value = el.value.slice(0, from) + it.value + (it.value.endsWith("/") ? "" : " ") + el.value.slice(el.selectionStart);
|
|
4270
4328
|
el.focus();
|
|
4271
4329
|
hidePop();
|
|
4330
|
+
saveDraft();
|
|
4331
|
+
autoGrow();
|
|
4272
4332
|
}
|
|
4273
4333
|
/** What has been sent from this composer, for the up-arrow. Not window.history, which it shadowed. */
|
|
4274
4334
|
const sentPrompts = [];
|
|
@@ -4303,12 +4363,14 @@ $("input").onkeydown = (e) => {
|
|
|
4303
4363
|
if (promptIdx === -1) promptDraft = el.value;
|
|
4304
4364
|
promptIdx = Math.min(promptIdx + 1, sentPrompts.length - 1);
|
|
4305
4365
|
el.value = sentPrompts[sentPrompts.length - 1 - promptIdx];
|
|
4366
|
+
autoGrow();
|
|
4306
4367
|
e.preventDefault();
|
|
4307
4368
|
return;
|
|
4308
4369
|
}
|
|
4309
4370
|
if (e.key === "ArrowDown" && atEnd && promptIdx >= 0) {
|
|
4310
4371
|
promptIdx -= 1;
|
|
4311
4372
|
el.value = promptIdx === -1 ? promptDraft : sentPrompts[sentPrompts.length - 1 - promptIdx];
|
|
4373
|
+
autoGrow();
|
|
4312
4374
|
e.preventDefault();
|
|
4313
4375
|
return;
|
|
4314
4376
|
}
|
|
@@ -4334,6 +4396,8 @@ $("input").onkeydown = (e) => {
|
|
|
4334
4396
|
};
|
|
4335
4397
|
$("input").oninput = () => {
|
|
4336
4398
|
const el = $("input");
|
|
4399
|
+
saveDraft();
|
|
4400
|
+
autoGrow();
|
|
4337
4401
|
const line = el.value.slice(0, el.selectionStart).split("\n").pop();
|
|
4338
4402
|
if (/(^\/[\w-]*$)|(@[^\s]*$)/.test(line)) updatePop(); else hidePop();
|
|
4339
4403
|
};
|
|
@@ -4472,6 +4536,8 @@ function applyEvent(ev) {
|
|
|
4472
4536
|
|
|
4473
4537
|
/* ---------------- start ---------------- */
|
|
4474
4538
|
(async () => {
|
|
4539
|
+
$("input").value = savedDraft();
|
|
4540
|
+
autoGrow();
|
|
4475
4541
|
setAllWork(allOpen);
|
|
4476
4542
|
await refresh();
|
|
4477
4543
|
const hist = await api("/history");
|