@alphacoder-v0/pi-loops 0.20.0 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/CONTRACT.md +139 -0
  3. package/README.md +11 -7
  4. package/README.zh-CN.md +6 -4
  5. package/docs/cli.md +19 -1
  6. package/docs/downstream.md +60 -0
  7. package/docs/goal.md +4 -2
  8. package/docs/loops.md +55 -9
  9. package/docs/recipes.md +94 -18
  10. package/docs/session-archive.md +8 -3
  11. package/docs/web-ui-parity.md +11 -1
  12. package/package.json +5 -2
  13. package/recipes/_tracker/issue-tracker-local.md +3 -1
  14. package/recipes/autoresearch/experiment.md +30 -6
  15. package/recipes/autoresearch/recipe.toml +2 -0
  16. package/recipes/changelog-draft/recipe.toml +4 -0
  17. package/recipes/changelog-draft/release.md +8 -3
  18. package/recipes/ci-sweeper/recipe.toml +4 -0
  19. package/recipes/ci-sweeper/sweep.md +2 -1
  20. package/recipes/daily-digest/digest.md +5 -4
  21. package/recipes/daily-digest/recipe.toml +2 -0
  22. package/recipes/deps-sweeper/README.md +11 -0
  23. package/recipes/deps-sweeper/audit.md +99 -0
  24. package/recipes/deps-sweeper/recipe.toml +17 -0
  25. package/recipes/ecosystem/recipe.toml +3 -0
  26. package/recipes/ecosystem/scan.md +1 -1
  27. package/recipes/issue-loop/implement.md +17 -4
  28. package/recipes/issue-loop/labels.sh +18 -0
  29. package/recipes/issue-loop/recipe.toml +2 -0
  30. package/recipes/issue-loop/triage.md +16 -2
  31. package/recipes/pr-watch/recipe.toml +3 -0
  32. package/skills/pi-loops/SKILL.md +3 -2
  33. package/src/archive.ts +97 -2
  34. package/src/cli.ts +103 -16
  35. package/src/goal.ts +11 -0
  36. package/src/hooks.ts +25 -1
  37. package/src/host-runtime.ts +4 -8
  38. package/src/inbox.ts +118 -55
  39. package/src/job-signal.ts +78 -0
  40. package/src/pi-loops.ts +177 -109
  41. package/src/presence.ts +5 -0
  42. package/src/protocol.ts +45 -2
  43. package/src/recipe.ts +158 -2
  44. package/src/scheduler.ts +6 -2
  45. package/src/store.ts +8 -3
  46. package/src/tools.ts +17 -11
  47. package/src/triggers.ts +8 -3
  48. package/src/web.mjs +73 -7
package/src/inbox.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { withFileLock, writeFileAtomic } from "./lock.ts";
12
- import { INBOX_TEXT_MAX_CHARS, capChars } from "./protocol.ts";
12
+ import { INBOX_TEXT_MAX_CHARS, type FindingKind, capChars } from "./protocol.ts";
13
13
  import { newId } from "./store.ts";
14
14
  import { stamp } from "./schedule.ts";
15
15
 
@@ -18,25 +18,52 @@ export type InboxStatus = "new" | "claimed" | "dismissed";
18
18
  /** Past this the inbox drops its oldest triaged entries (docs/configuration.md). */
19
19
  export const INBOX_ROTATE_BYTES = 1_000_000;
20
20
 
21
+ /**
22
+ * A finding, in memory and on disk: the ten fields of the contract first, in their order, then
23
+ * pi-loops' own, which no interface names and which a line may be missing. This is the one shape —
24
+ * a line of `inbox.jsonl` is `JSON.stringify` of this, and `pi-loops inbox --json` is the first ten
25
+ * fields of it.
26
+ */
21
27
  export interface InboxEntry {
22
28
  id: string;
23
- createdAt: string;
29
+ /** RFC 3339, carrying this machine's UTC offset (`stamp()`). */
30
+ created_at: string;
31
+ status: InboxStatus;
32
+ /**
33
+ * `checkpoint` when the finding asks a person to decide (`protocol.findingKind`, read off the
34
+ * text when the run's findings are appended), `news` otherwise. Checkpoints are listed first,
35
+ * counted apart.
36
+ */
37
+ kind: FindingKind;
24
38
  /** Bounded origin label, e.g. "cron:check-issues" or "cron:cron-1a2b3c4d". */
25
39
  source: string;
26
- text: string;
27
- runId: string;
28
- jobId: string;
40
+ run_id: string;
29
41
  cwd: string;
42
+ text: string;
43
+ /** true = passed the checker; false = checker dropped it (never appended); null = no checker / unreviewed. */
44
+ verified: boolean | null;
45
+ /**
46
+ * Why a person dismissed it, when they said; null when they did not. A bare dismiss is silent; a
47
+ * reason goes back to the loop that reported the finding, in its next run's prompt
48
+ * (`Inbox.feedbackFor`).
49
+ */
50
+ dismiss_reason: string | null;
51
+ job_id?: string;
30
52
  /** Session that owned the loop when it reported; loops are machine-global here. */
31
- sessionId?: string;
32
- status: InboxStatus;
33
- claimedBy?: string;
34
- /** true = passed the checker; false = checker dropped it (never appended); undefined = no checker / unreviewed. */
35
- verified?: boolean;
53
+ session_id?: string;
54
+ claimed_by?: string;
36
55
  /** Checker's reason when it kept a finding, if it gave one. */
37
- verifiedReason?: string;
56
+ verified_reason?: string;
57
+ /** When it was dismissed — what decides whether the loop's next run is shown the reason. */
58
+ dismissed_at?: string;
38
59
  }
39
60
 
61
+ /** The fields a finding is, in their order — CONTRACT.md §2.2 and docs/downstream.md §3. */
62
+ export const FINDING_FIELDS = ["id", "created_at", "status", "kind", "source", "run_id", "cwd", "text", "verified", "dismiss_reason"] as const;
63
+
64
+ /** Reasons longer than a finding are a paragraph, and the prompt they go into is capped. */
65
+ const DISMISS_REASON_MAX_CHARS = INBOX_TEXT_MAX_CHARS;
66
+
40
67
  export class Inbox {
41
68
  readonly file: string;
42
69
  private readonly lockPath: string;
@@ -46,14 +73,24 @@ export class Inbox {
46
73
  this.lockPath = path.join(dir, "inbox.lock");
47
74
  }
48
75
 
49
- async append(entry: Omit<InboxEntry, "id" | "createdAt" | "status">): Promise<InboxEntry> {
76
+ async append(entry: Omit<InboxEntry, "id" | "created_at" | "status" | "kind" | "verified" | "dismiss_reason"> & Partial<Pick<InboxEntry, "kind" | "verified">>): Promise<InboxEntry> {
77
+ // Built in `FINDING_FIELDS` order, then pi-loops' own: the line is this object as it stands.
50
78
  const full: InboxEntry = {
51
79
  id: newId("inb"), // inb-<32 hex>, the shape every other id in pi-loops has
52
- createdAt: stamp(),
53
- ...entry,
80
+ created_at: stamp(),
81
+ status: "new",
82
+ kind: entry.kind ?? "news",
54
83
  source: capChars(entry.source, 80),
84
+ run_id: entry.run_id,
85
+ cwd: entry.cwd,
55
86
  text: capChars(entry.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS),
56
- status: "new",
87
+ verified: entry.verified ?? null,
88
+ dismiss_reason: null,
89
+ job_id: entry.job_id,
90
+ session_id: entry.session_id,
91
+ claimed_by: entry.claimed_by,
92
+ verified_reason: entry.verified_reason,
93
+ dismissed_at: entry.dismissed_at,
57
94
  };
58
95
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
59
96
  // Under the same lock as the triage rewrites: findings are appended by every pi window, the
@@ -62,7 +99,7 @@ export class Inbox {
62
99
  // The lock is awaited, never spun on: a leftover lock directory from a killed process would
63
100
  // otherwise block this process's event loop for the whole stale window.
64
101
  await withFileLock(this.lockPath, () => {
65
- fs.appendFileSync(this.file, `${JSON.stringify(toDisk(full))}\n`, "utf8");
102
+ fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, "utf8");
66
103
  this.rotate();
67
104
  });
68
105
  return full;
@@ -103,7 +140,7 @@ export class Inbox {
103
140
  for (const line of text.split("\n")) {
104
141
  if (!line.trim()) continue;
105
142
  try {
106
- const e = fromDisk(JSON.parse(line));
143
+ const e = readFinding(JSON.parse(line));
107
144
  if (e && typeof e.id === "string" && typeof e.text === "string") out.push(e);
108
145
  } catch {
109
146
  /* skip corrupt line */
@@ -112,8 +149,14 @@ export class Inbox {
112
149
  return out;
113
150
  }
114
151
 
152
+ /**
153
+ * What is waiting, in triage order: the checkpoints first — a decision a person owes — then the
154
+ * news, each group in the order it arrived. `/inbox` numbers this list and `claim`/`dismiss`
155
+ * resolve numbers against it, so the order is decided once, here.
156
+ */
115
157
  listNew(): InboxEntry[] {
116
- return this.list().filter((e) => e.status === "new");
158
+ const fresh = this.list().filter((e) => e.status === "new");
159
+ return [...fresh.filter((e) => e.kind === "checkpoint"), ...fresh.filter((e) => e.kind !== "checkpoint")];
117
160
  }
118
161
 
119
162
  /** Count of new entries; 0 on any error (badge path must never throw). */
@@ -125,13 +168,28 @@ export class Inbox {
125
168
  }
126
169
  }
127
170
 
128
- async setStatus(id: string, status: InboxStatus, claimedBy?: string): Promise<InboxEntry | undefined> {
171
+ /** How many of the new entries are checkpoints; 0 on any error, like `newCount`. */
172
+ decisionCount(): number {
173
+ try {
174
+ return this.listNew().filter((e) => e.kind === "checkpoint").length;
175
+ } catch {
176
+ return 0;
177
+ }
178
+ }
179
+
180
+ /** `reason` is only read for a dismiss: what the person said, kept for the loop's next run. */
181
+ async setStatus(id: string, status: InboxStatus, claimedBy?: string, reason?: string): Promise<InboxEntry | undefined> {
129
182
  return withFileLock(this.lockPath, () => {
130
183
  const entries = this.list();
131
184
  const entry = entries.find((e) => e.id === id);
132
185
  if (!entry) return undefined;
133
186
  entry.status = status;
134
- if (claimedBy) entry.claimedBy = claimedBy;
187
+ if (claimedBy) entry.claimed_by = claimedBy;
188
+ if (status === "dismissed") {
189
+ entry.dismissed_at = stamp();
190
+ const why = reason?.replace(/\s+/g, " ").trim();
191
+ if (why) entry.dismiss_reason = capChars(why, DISMISS_REASON_MAX_CHARS);
192
+ }
135
193
  this.rewrite(entries);
136
194
  return entry;
137
195
  });
@@ -141,10 +199,12 @@ export class Inbox {
141
199
  async dismissAllNew(match?: (entry: InboxEntry) => boolean): Promise<number> {
142
200
  return withFileLock(this.lockPath, () => {
143
201
  const entries = this.list();
202
+ const at = stamp();
144
203
  let changed = 0;
145
204
  for (const e of entries) {
146
205
  if (e.status === "new" && (!match || match(e))) {
147
206
  e.status = "dismissed";
207
+ e.dismissed_at = at;
148
208
  changed++;
149
209
  }
150
210
  }
@@ -153,35 +213,30 @@ export class Inbox {
153
213
  });
154
214
  }
155
215
 
216
+ /**
217
+ * What a person told one loop by dismissing its findings with a reason, since `since` (the start
218
+ * of the loop's previous run: everything before it was already shown to that run). Oldest first.
219
+ * A dismiss without a reason is not feedback — "not interesting" is not something the next run can
220
+ * act on, and the person did not ask it to — so it is not here. A reason dismissed before pi-loops
221
+ * stamped `dismissed_at` has no time to compare and is left out too.
222
+ */
223
+ feedbackFor(jobId: string, since?: string): InboxEntry[] {
224
+ const after = since ? Date.parse(since) : Number.NEGATIVE_INFINITY;
225
+ return this.list().filter((e) => e.job_id === jobId && e.status === "dismissed" && !!e.dismiss_reason && !!e.dismissed_at && Date.parse(e.dismissed_at) > after);
226
+ }
227
+
156
228
  private rewrite(entries: InboxEntry[]): void {
157
- writeFileAtomic(this.file, entries.map((e) => JSON.stringify(toDisk(e))).join("\n") + (entries.length ? "\n" : ""));
229
+ writeFileAtomic(this.file, entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""));
158
230
  }
159
231
  }
160
232
 
161
233
  /**
162
- * On disk the record shape is `{id, created_at, source, text, trace_id, session_id,
163
- * status}` — plus pi-loops' extras (`job_id`, `cwd`, `claimed_by`, `verified`, `verified_reason`),
164
- * so a reader of that shape can consume it. Lines written by pi-loops ≤ 0.1.2 (camelCase)
165
- * are still understood.
234
+ * A line as an entry. For a line this version wrote it is the identity; the two older shapes it
235
+ * still reads are pi-loops ≤ 0.1.2 (camelCase throughout) and the shape written until 0.21.0
236
+ * (`trace_id` for the run id, absent optional keys, no `kind` on news). A line in either is
237
+ * rewritten in this shape the next time its status changes.
166
238
  */
167
- function toDisk(e: InboxEntry): Record<string, unknown> {
168
- return {
169
- id: e.id,
170
- created_at: e.createdAt,
171
- source: e.source,
172
- text: e.text,
173
- trace_id: e.runId,
174
- session_id: e.sessionId ?? null,
175
- status: e.status,
176
- job_id: e.jobId,
177
- cwd: e.cwd,
178
- ...(e.claimedBy !== undefined ? { claimed_by: e.claimedBy } : {}),
179
- ...(e.verified !== undefined ? { verified: e.verified } : {}),
180
- ...(e.verifiedReason !== undefined ? { verified_reason: e.verifiedReason } : {}),
181
- };
182
- }
183
-
184
- function fromDisk(raw: any): InboxEntry | undefined {
239
+ function readFinding(raw: any): InboxEntry | undefined {
185
240
  if (!raw || typeof raw !== "object") return undefined;
186
241
  const pick = <T>(...keys: string[]): T | undefined => {
187
242
  for (const k of keys) if (raw[k] !== undefined && raw[k] !== null) return raw[k] as T;
@@ -190,17 +245,20 @@ function fromDisk(raw: any): InboxEntry | undefined {
190
245
  const status = pick<string>("status");
191
246
  return {
192
247
  id: String(raw.id ?? ""),
193
- createdAt: pick<string>("created_at", "createdAt") ?? "",
248
+ created_at: pick<string>("created_at", "createdAt") ?? "",
249
+ status: status === "claimed" || status === "dismissed" ? status : "new",
250
+ kind: raw.kind === "checkpoint" ? "checkpoint" : "news",
194
251
  source: pick<string>("source") ?? "",
195
- text: String(raw.text ?? ""),
196
- runId: pick<string>("trace_id", "runId") ?? "",
197
- jobId: pick<string>("job_id", "jobId") ?? "",
252
+ run_id: pick<string>("run_id", "trace_id", "runId") ?? "",
198
253
  cwd: pick<string>("cwd") ?? "",
199
- sessionId: pick<string>("session_id", "sessionId"),
200
- status: status === "claimed" || status === "dismissed" ? status : "new",
201
- claimedBy: pick<string>("claimed_by", "claimedBy"),
202
- verified: pick<boolean>("verified"),
203
- verifiedReason: pick<string>("verified_reason", "verifiedReason"),
254
+ text: String(raw.text ?? ""),
255
+ verified: pick<boolean>("verified") ?? null,
256
+ dismiss_reason: pick<string>("dismiss_reason") ?? null,
257
+ job_id: pick<string>("job_id", "jobId"),
258
+ session_id: pick<string>("session_id", "sessionId"),
259
+ claimed_by: pick<string>("claimed_by", "claimedBy"),
260
+ verified_reason: pick<string>("verified_reason", "verifiedReason"),
261
+ dismissed_at: pick<string>("dismissed_at"),
204
262
  };
205
263
  }
206
264
 
@@ -222,11 +280,16 @@ export function belongsToProject(entry: InboxEntry, cwd: string, sameProject: (a
222
280
  return !entry.cwd || sameProject(entry.cwd, cwd);
223
281
  }
224
282
 
225
- /** Resolve "<n>" (1-based in the new list) or an id / unique id prefix. */
226
- export function resolveInboxRef(entries: InboxEntry[], ref: string): InboxEntry | undefined {
283
+ /**
284
+ * Resolve an id or a unique id prefix — and, with `ordinals: true`, "<n>", the 1-based position in
285
+ * `entries`. A number is a position in a list the caller printed, so only a caller that printed one
286
+ * may ask for it (`/inbox`, which numbers what it shows); everywhere else a finding is named by its
287
+ * id, and an all-digit ref resolves to nothing, as an unknown id does.
288
+ */
289
+ export function resolveInboxRef(entries: InboxEntry[], ref: string, opts?: { ordinals?: boolean }): InboxEntry | undefined {
227
290
  const trimmed = ref.trim();
228
291
  if (!trimmed) return undefined;
229
- if (/^\d+$/.test(trimmed)) return entries[Number(trimmed) - 1];
292
+ if (/^\d+$/.test(trimmed)) return opts?.ordinals ? entries[Number(trimmed) - 1] : undefined;
230
293
  const matches = entries.filter((e) => e.id === trimmed || e.id.startsWith(trimmed));
231
294
  return matches.length === 1 ? matches[0] : undefined;
232
295
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What a loop's runs came to: findings filed, and what a person did with them.
3
+ *
4
+ * `/cron cost` has always said what a loop spends; nothing said whether anyone wanted what it
5
+ * bought. A loop whose every finding is dismissed is noise at whatever price, and a loop that has
6
+ * found nothing for a month is either watching a quiet thing or watching the wrong one. Both are
7
+ * questions about the run log and the inbox together, answerable without a scheduler or a clock —
8
+ * the shape of `job-health.ts`, for the other half of "is this loop worth keeping".
9
+ */
10
+ import type { InboxEntry } from "./inbox.ts";
11
+ import type { RunRecord } from "./store.ts";
12
+
13
+ export interface LoopSignal {
14
+ /** Runs that finished in the window (failed ones included: they cost, and they are what `quiet` is measured against). */
15
+ runs: number;
16
+ /** Findings that reached the inbox in the window, from the run log. */
17
+ findings: number;
18
+ /** Of the findings the inbox still holds from this window: claimed, dismissed, and dismissed with a reason. */
19
+ claimed: number;
20
+ dismissed: number;
21
+ dismissedWithReason: number;
22
+ /** Consecutive finished runs, newest backwards over the whole log, that found nothing. Not bounded by the window. */
23
+ quiet: number;
24
+ }
25
+
26
+ /** How long the `/cron` listing looks back. Long enough for a weekly loop to have a few runs in it. */
27
+ export const SIGNAL_WINDOW_MS = 30 * 86_400_000;
28
+ /** From here `/cron` marks a loop `quiet ×N`: three empty runs is where a loop stops looking merely between events. */
29
+ export const QUIET_MARK_AFTER = 3;
30
+
31
+ /**
32
+ * `runs` is the run log, any order; `inbox` the whole inbox. Both are filtered here so a caller can
33
+ * hand over what it already loaded. `sinceMs` bounds the counts; `quiet` is counted from the newest
34
+ * run backwards regardless of it, because a streak is a streak whenever it started.
35
+ */
36
+ export function loopSignal(jobId: string, runs: RunRecord[], inbox: InboxEntry[], sinceMs: number): LoopSignal {
37
+ const own = runs.filter((r) => r.jobId === jobId);
38
+ const at = (iso: string) => Date.parse(iso);
39
+ const inWindow = own.filter((r) => {
40
+ const t = at(r.finishedAt || r.startedAt);
41
+ return Number.isFinite(t) && t >= sinceMs;
42
+ });
43
+ const findings = inWindow.reduce((n, r) => n + (r.findings ?? 0), 0);
44
+ let claimed = 0;
45
+ let dismissed = 0;
46
+ let dismissedWithReason = 0;
47
+ for (const e of inbox) {
48
+ if (e.job_id !== jobId) continue;
49
+ const t = at(e.created_at);
50
+ if (!Number.isFinite(t) || t < sinceMs) continue;
51
+ if (e.status === "claimed") claimed++;
52
+ else if (e.status === "dismissed") {
53
+ dismissed++;
54
+ if (e.dismiss_reason) dismissedWithReason++;
55
+ }
56
+ }
57
+ // The log is appended in finishing order, so the tail is the newest; sort anyway in case a
58
+ // caller merged two sources. A run that failed found nothing, and counts toward the streak:
59
+ // a loop that has been erroring for a week is quiet in the sense that matters here too, and
60
+ // `job-health.ts` already says why.
61
+ const ordered = [...own].sort((a, b) => at(a.finishedAt || a.startedAt) - at(b.finishedAt || b.startedAt));
62
+ let quiet = 0;
63
+ for (let i = ordered.length - 1; i >= 0 && (ordered[i].findings ?? 0) === 0; i--) quiet++;
64
+ return { runs: inWindow.length, findings, claimed, dismissed, dismissedWithReason, quiet };
65
+ }
66
+
67
+ /**
68
+ * `9 findings · 2 claimed · 7 dismissed` — the counts a person reads a loop's worth off. Nothing
69
+ * when no finding was filed: a loop that found nothing has nothing to be judged by yet, and
70
+ * `quiet ×N` says that part.
71
+ */
72
+ export function signalSummary(s: LoopSignal): string | undefined {
73
+ if (!s.findings) return undefined;
74
+ const parts = [`${s.findings} finding${s.findings === 1 ? "" : "s"}`];
75
+ if (s.claimed) parts.push(`${s.claimed} claimed`);
76
+ if (s.dismissed) parts.push(`${s.dismissed} dismissed${s.dismissedWithReason ? ` (${s.dismissedWithReason} with a reason)` : ""}`);
77
+ return parts.join(" · ");
78
+ }