@alphacoder-v0/pi-loops 0.22.8 → 0.24.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 +664 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A finding as a person reads it: the one place the stored line of `inbox.jsonl` is split into what
|
|
3
|
+
* the terminal's `/inbox`, `pi-loops inbox list` and the page show. Parsed at read time and never
|
|
4
|
+
* written back, so the stored shape (CONTRACT.md 2.2) does not change.
|
|
5
|
+
*
|
|
6
|
+
* A checkpoint is `<the decision> · waits: <what waits on it> · if not: <what happens if nobody
|
|
7
|
+
* acts>` (docs/recipes.md); a card shows the three parts apart, under the page's words "Waiting on"
|
|
8
|
+
* and "If you don't". News is one finding with a headline. What a claim says to the agent and what a
|
|
9
|
+
* dismiss tells the loop are decided here too, so every surface says the same thing.
|
|
10
|
+
*/
|
|
11
|
+
import { deadLetterParts } from "./engine.ts";
|
|
12
|
+
import { loopLabel } from "./job-lines.ts";
|
|
13
|
+
import type { InboxEntry, InboxStatus } from "./inbox.ts";
|
|
14
|
+
import { INBOX_TEXT_MAX_CHARS, capChars } from "./protocol.ts";
|
|
15
|
+
import { redact } from "./redact.ts";
|
|
16
|
+
|
|
17
|
+
export interface InboxCard {
|
|
18
|
+
/** `inb-…`: the only thing a button sends. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** `decision` exactly when the stored kind is `checkpoint`; never read again off the text. */
|
|
21
|
+
kind: "decision" | "news";
|
|
22
|
+
status: InboxStatus;
|
|
23
|
+
/** A decision: the text before `· waits:`. News: the first sentence. Never empty. */
|
|
24
|
+
headline: string;
|
|
25
|
+
/** News only: what follows the first sentence. */
|
|
26
|
+
body?: string;
|
|
27
|
+
/** A decision: between `· waits:` and `· if not:`. */
|
|
28
|
+
waits?: string;
|
|
29
|
+
/** A decision: after `· if not:`; undefined when the loop left the clause out. */
|
|
30
|
+
ifNot?: string;
|
|
31
|
+
/** The checker kept it (✓). */
|
|
32
|
+
verified: boolean;
|
|
33
|
+
verifiedReason?: string;
|
|
34
|
+
/** `rule`: a trigger's result. `undelivered`: a prompt that never reached its conversation (a dead letter). */
|
|
35
|
+
origin: "loop" | "rule" | "undelivered";
|
|
36
|
+
/** The loop that filed it, with its name when the loop still exists, and its label (src/job-lines.ts loopLabel). */
|
|
37
|
+
loop?: { id: string; name?: string; label: string };
|
|
38
|
+
/** A rule check's result: the first rule it matched, with its condition while the rule exists. */
|
|
39
|
+
rule?: { id: string; condition?: string };
|
|
40
|
+
source: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
runId: string;
|
|
44
|
+
/** The conversation that created the loop: where a claim runs (D3). */
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
/** The finding names its run's transcript. The file is not looked at. */
|
|
47
|
+
hasTranscript: boolean;
|
|
48
|
+
/** `sessionId` is the conversation the card is shown in. */
|
|
49
|
+
fromThisConversation: boolean;
|
|
50
|
+
/** A claim from the page is queued for it and has not marked it claimed yet: Yes is not offered again. */
|
|
51
|
+
claiming: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CardContext {
|
|
55
|
+
/** The conversation the card is shown in. */
|
|
56
|
+
here?: string;
|
|
57
|
+
/** A loop's name from jobs.json, or undefined when the loop is gone or has none. */
|
|
58
|
+
loopName(jobId: string): string | undefined;
|
|
59
|
+
/** Whether a page claim row for this finding is queued. */
|
|
60
|
+
claiming?(id: string): boolean;
|
|
61
|
+
/** A rule's condition from triggers.json, or undefined when the rule is gone. */
|
|
62
|
+
ruleCondition?(ruleId: string): string | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A rule's condition on a card's origin line, clipped so the line stays one line. */
|
|
66
|
+
const RULE_CONDITION_CHARS = 80;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A finding's text as a screen may show it: a terminal's escape sequences (colour, clear, title),
|
|
70
|
+
* other control characters, the bidi marks and overrides that reorder a line, and the characters no
|
|
71
|
+
* surface draws are taken out: the zero-width space and invisible operators (U+200B, U+2060-2064,
|
|
72
|
+
* U+FEFF), the soft hyphen, U+034F, the Hangul and Mongolian fillers, the tags U+E0000-E007F and the
|
|
73
|
+
* variation selectors U+FE00-FE0D, which can carry bytes no one sees. What ordinary text needs stays:
|
|
74
|
+
* the joiners U+200C and U+200D (emoji sequences, Persian, Devanagari), U+FE0E and U+FE0F (how an
|
|
75
|
+
* emoji is drawn) and the ideographic variation selectors U+E0100-E01EF. So `/inbox` cannot repaint the terminal it prints in, and every
|
|
76
|
+
* surface, and the claim's prompt, carries the same words.
|
|
77
|
+
*/
|
|
78
|
+
export function screenText(text: string): string {
|
|
79
|
+
return text
|
|
80
|
+
// Only a terminated string: an introducer with no BEL or ST would otherwise take the rest of
|
|
81
|
+
// the finding with it, and the card would hide what a claim then sends.
|
|
82
|
+
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
|
|
83
|
+
.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
|
|
84
|
+
.replace(/\u001b[@-_]?/g, "")
|
|
85
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u034f\u061c\u115f\u1160\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\u3164\ufe00-\ufe0d\ufeff\uffa0\u{e0000}-\u{e007f}]/gu, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The mark `findingKind` reads a checkpoint by (CONTRACT.md 2.6), and the clause after it. */
|
|
89
|
+
const WAITS = /(^|\s)·\s*waits:/i;
|
|
90
|
+
const IF_NOT = /\s·\s*if not:/i;
|
|
91
|
+
/** How far into a news finding its first sentence may end and still be a headline. */
|
|
92
|
+
const HEADLINE_MAX_CHARS = 200;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The parts of a finding's text. A decision splits at the first `· waits:` and then at the first
|
|
96
|
+
* `· if not:`; a decision with no `· waits:` left (cut by the 500-character cap, or written by hand)
|
|
97
|
+
* keeps its whole text as the headline, and so does one whose headline would be empty. News splits
|
|
98
|
+
* after its first sentence, when that ends within 200 characters.
|
|
99
|
+
*/
|
|
100
|
+
export function splitFinding(text: string, kind: InboxEntry["kind"]): Pick<InboxCard, "headline" | "body" | "waits" | "ifNot"> {
|
|
101
|
+
if (kind === "checkpoint") {
|
|
102
|
+
const at = WAITS.exec(text);
|
|
103
|
+
if (!at) return { headline: text };
|
|
104
|
+
const headline = text.slice(0, at.index).trim();
|
|
105
|
+
if (!headline) return { headline: text };
|
|
106
|
+
const rest = text.slice(at.index + at[0].length);
|
|
107
|
+
const cut = IF_NOT.exec(rest);
|
|
108
|
+
const waits = (cut ? rest.slice(0, cut.index) : rest).trim();
|
|
109
|
+
const ifNot = cut ? rest.slice(cut.index + cut[0].length).trim() : "";
|
|
110
|
+
return { headline, ...(waits ? { waits } : {}), ...(ifNot ? { ifNot } : {}) };
|
|
111
|
+
}
|
|
112
|
+
const end = /[.!?](?=\s)|[。!?]/.exec(text);
|
|
113
|
+
if (!end || end.index >= HEADLINE_MAX_CHARS) return { headline: text };
|
|
114
|
+
const headline = text.slice(0, end.index + 1).trim();
|
|
115
|
+
const body = text.slice(end.index + 1).trim();
|
|
116
|
+
return body ? { headline, body } : { headline };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `matched <rule-id>` a rule check starts what it says with (src/triggers.ts asks for the ids),
|
|
121
|
+
* with more ids after it and the punctuation that ends it. It names the rule, which the card's
|
|
122
|
+
* origin line does already.
|
|
123
|
+
*/
|
|
124
|
+
const MATCHED_TOKEN = /^\s*`?matched`?\s+`?dyn-[0-9a-f]{32}`?(?:\s*(?:,|and)\s*`?dyn-[0-9a-f]{32}`?)*\s*[.:;,!—–-]*\s*/i;
|
|
125
|
+
|
|
126
|
+
/** A rule result's text as its card reads it: without the leading `matched <rule-id>`, unless that is all it says. */
|
|
127
|
+
export function ruleResultText(text: string): string {
|
|
128
|
+
const rest = text.replace(MATCHED_TOKEN, "");
|
|
129
|
+
return rest || text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One finding as a card, or undefined for a line with no id or no text: `readFinding` turns a
|
|
134
|
+
* line `{}` into an entry with both empty, and a button that sends an empty id names nothing.
|
|
135
|
+
* The text is redacted and capped again, since a line another tool wrote is not bounded.
|
|
136
|
+
*/
|
|
137
|
+
export function inboxCard(entry: InboxEntry, ctx: CardContext): InboxCard | undefined {
|
|
138
|
+
if (!entry.id || !entry.text) return undefined;
|
|
139
|
+
const text = capChars(redact(screenText(entry.text)).replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS);
|
|
140
|
+
const origin = entry.source.startsWith("trigger:") ? "rule" : deadLetterParts(entry.text) ? "undelivered" : "loop";
|
|
141
|
+
const loopId = origin === "loop" ? entry.job_id : undefined;
|
|
142
|
+
const loopName = loopId ? ctx.loopName(loopId) : undefined;
|
|
143
|
+
// A rule check files its result with the first matched rule's id (src/trigger-runtime.ts); a push
|
|
144
|
+
// with no rule files its source label there, which names no rule.
|
|
145
|
+
const ruleId = origin === "rule" && entry.job_id?.startsWith("dyn-") ? entry.job_id : undefined;
|
|
146
|
+
const condition = ruleId ? ctx.ruleCondition?.(ruleId) : undefined;
|
|
147
|
+
return {
|
|
148
|
+
id: entry.id,
|
|
149
|
+
kind: entry.kind === "checkpoint" ? "decision" : "news",
|
|
150
|
+
status: entry.status,
|
|
151
|
+
...splitFinding(origin === "rule" ? ruleResultText(text) : text, entry.kind),
|
|
152
|
+
verified: entry.verified === true,
|
|
153
|
+
...(entry.verified_reason ? { verifiedReason: redact(screenText(entry.verified_reason)) } : {}),
|
|
154
|
+
origin,
|
|
155
|
+
...(loopId ? { loop: loopName === undefined ? { id: loopId, label: loopLabel({ id: loopId }) } : { id: loopId, name: loopName, label: loopLabel({ id: loopId, name: loopName }) } } : {}),
|
|
156
|
+
...(ruleId ? { rule: condition ? { id: ruleId, condition: capChars(redact(screenText(condition)).replace(/\s+/g, " "), RULE_CONDITION_CHARS - 1) } : { id: ruleId } } : {}),
|
|
157
|
+
source: entry.source,
|
|
158
|
+
cwd: entry.cwd,
|
|
159
|
+
createdAt: entry.created_at,
|
|
160
|
+
runId: entry.run_id,
|
|
161
|
+
...(entry.session_id ? { sessionId: entry.session_id } : {}),
|
|
162
|
+
hasTranscript: origin !== "rule" && !!entry.transcript,
|
|
163
|
+
fromThisConversation: !!entry.session_id && entry.session_id === ctx.here,
|
|
164
|
+
claiming: entry.status === "new" && (ctx.claiming?.(entry.id) ?? false),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The cards of `entries`, decisions first and each group in the order given (`Inbox.listNew`'s); lines with no id or text are left out. */
|
|
169
|
+
export function cardsOf(entries: readonly InboxEntry[], ctx: CardContext): InboxCard[] {
|
|
170
|
+
const cards = entries.flatMap((e) => inboxCard(e, ctx) ?? []);
|
|
171
|
+
return [...cards.filter((c) => c.kind === "decision"), ...cards.filter((c) => c.kind === "news")];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** How far a card's continuation lines are indented under its first line. */
|
|
175
|
+
const CLAUSE_INDENT = " ";
|
|
176
|
+
|
|
177
|
+
/** What filed a card, as every surface but `inbox --json` names it: a loop's label, else the stored source. */
|
|
178
|
+
export function cardOrigin(card: Pick<InboxCard, "loop" | "source">): string {
|
|
179
|
+
return card.loop ? card.loop.label : card.source;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A card as terminal lines: `head`, the marks, the headline (news: its whole text) and `meta` on
|
|
184
|
+
* the first line; a decision's clauses on indented lines under it, under the page's words. A clause
|
|
185
|
+
* the loop left out prints nothing: two packaged playbooks leave `if not` out on purpose.
|
|
186
|
+
*/
|
|
187
|
+
export function cardLines(card: InboxCard, head: string, meta: string): string[] {
|
|
188
|
+
const mark = `${card.kind === "decision" ? "⚑ " : ""}${card.verified ? "✓ " : ""}`;
|
|
189
|
+
const title = card.body === undefined ? card.headline : `${card.headline}${/[。!?]$/.test(card.headline) ? "" : " "}${card.body}`;
|
|
190
|
+
const lines = [`${head}${mark}${title}${meta}`];
|
|
191
|
+
if (card.waits !== undefined) lines.push(`${CLAUSE_INDENT}waiting on: ${card.waits}`);
|
|
192
|
+
if (card.ifNot !== undefined) lines.push(`${CLAUSE_INDENT}if you don't: ${card.ifNot}`);
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A card as one line, for a shell: `pi-loops inbox list` prints one line per finding (docs/cli.md),
|
|
198
|
+
* so a script that reads the ids with `awk '{print $1}'` keeps working. The clauses follow the
|
|
199
|
+
* headline on the same line, under the page's words.
|
|
200
|
+
*/
|
|
201
|
+
export function cardLine(card: InboxCard, head: string, meta: string): string {
|
|
202
|
+
const [first, ...clauses] = cardLines(card, "", "");
|
|
203
|
+
return `${head}${first}${clauses.map((c) => ` · ${c.trim()}`).join("")}${meta}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The reasons a page offers for a dismiss, beside a typed one. Each goes to the loop as said. */
|
|
207
|
+
export const DISMISS_CHIPS = ["Not a bug", "Already fixed", "Duplicate", "Too noisy", "Not now"] as const;
|
|
208
|
+
|
|
209
|
+
/** Whom a dismiss tells: the loop's next run, nobody (and why), or nobody because no reason was given. */
|
|
210
|
+
export type DismissOutcome = { told: "loop"; loop: string } | { told: "nobody"; why: "loop-gone" | "rule" } | { told: "silent" };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whom a dismiss with `reason` tells. A loop's finding carries its job id; a trigger's carries the
|
|
214
|
+
* rule's label there, and a rule keeps no notes and has no next run to tell. `loop` is the job
|
|
215
|
+
* that filed it, when it still exists.
|
|
216
|
+
*/
|
|
217
|
+
export function dismissOutcome(entry: Pick<InboxEntry, "job_id">, loop: { id: string; name?: string } | undefined, reason?: string): DismissOutcome {
|
|
218
|
+
if (!reason?.trim()) return { told: "silent" };
|
|
219
|
+
if (loop) return { told: "loop", loop: loopLabel(loop) };
|
|
220
|
+
return { told: "nobody", why: entry.job_id?.startsWith("cron-") ? "loop-gone" : "rule" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What the terminal adds after `dismissed: <finding>`. */
|
|
224
|
+
export function dismissTail(outcome: DismissOutcome): string {
|
|
225
|
+
if (outcome.told === "loop") return ` — ${outcome.loop} is told why on its next run`;
|
|
226
|
+
if (outcome.told === "nobody") return outcome.why === "loop-gone" ? " — the loop that reported it is gone, so nobody is told" : " — a trigger reported it, and a trigger keeps no notes, so nobody is told";
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What a card says once it is dismissed from the page. */
|
|
231
|
+
export function dismissLine(outcome: DismissOutcome): string {
|
|
232
|
+
if (outcome.told === "loop") return `Dismissed. Your reason goes to ${outcome.loop}'s next run.`;
|
|
233
|
+
if (outcome.told === "nobody") return outcome.why === "loop-gone" ? "Dismissed. The loop that reported it is gone, so nobody is told." : "Dismissed. A rule reported it and keeps no notes, so nobody is told.";
|
|
234
|
+
return "Dismissed.";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The turn a claim runs: the finding, where it came from, and what a claim of a checkpoint means. */
|
|
238
|
+
export function claimPrompt(e: InboxEntry): string {
|
|
239
|
+
// The words the card showed, redacted and capped as it does, so the turn is asked about what the
|
|
240
|
+
// person read and said yes to, and a secret masked on the card is not sent to the model; the
|
|
241
|
+
// source and the cwd are screened too, since the turn's message is drawn in a terminal when one
|
|
242
|
+
// holds the conversation.
|
|
243
|
+
const text = capChars(redact(screenText(e.text)).replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS);
|
|
244
|
+
const source = screenText(e.source).replace(/\s+/g, " ").trim();
|
|
245
|
+
const cwd = screenText(e.cwd).replace(/\s+/g, " ").trim();
|
|
246
|
+
const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verified_reason ? `: ${redact(screenText(e.verified_reason))}` : ""}.)` : "";
|
|
247
|
+
// A checkpoint is a decision the loop prepared and left to a person; the claim is that
|
|
248
|
+
// person's yes, so the turn carries the decision out rather than re-investigating it.
|
|
249
|
+
const checkpoint = e.kind === "checkpoint" ? "\nThis finding is a checkpoint: claiming it is the person's approval of the decision it recommends — carry that decision out, and stop where the finding says a person acts." : "";
|
|
250
|
+
// A prompt that never reached the chat: claiming it is asking for it now, not investigating why it was late.
|
|
251
|
+
const late = deadLetterParts(text);
|
|
252
|
+
if (late) return `This prompt (${source}, for ${cwd}) could not be delivered earlier (${late.why}). Do what it asks now:\n${late.prompt}`;
|
|
253
|
+
return `A recurring loop (${source}, running in ${cwd}) reported this finding — investigate and address it:\n${text}${verified}${checkpoint}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Where a claim from the page runs, as the page server finds the conversation that created the
|
|
258
|
+
* loop: open in the page (rung, so it runs as the person's turn), a terminal that answers what is
|
|
259
|
+
* queued, a terminal that does not (`pi -p`, a window behind the file), pi-loops in the middle of a
|
|
260
|
+
* scheduled turn there, nobody (the page starts it), no free page slot (or pi could not be started),
|
|
261
|
+
* pi stopping too often there to be started again on its own, a pi there without pi-loops (nothing
|
|
262
|
+
* would drain the row), or a conversation whose file is gone.
|
|
263
|
+
*/
|
|
264
|
+
export type ClaimDelivery = "page" | "terminal" | "terminal-quiet" | "background" | "started" | "full" | "stopped" | "no-pi-loops" | "gone";
|
|
265
|
+
|
|
266
|
+
/** What a card says once its claim is queued; `title` names the conversation it runs in. */
|
|
267
|
+
export function claimLine(delivery: ClaimDelivery, title?: string): string {
|
|
268
|
+
const where = title ? `"${title}"` : "its conversation";
|
|
269
|
+
if (delivery === "page") return `Running in ${where}.`;
|
|
270
|
+
if (delivery === "started") return `Starting ${where}…`;
|
|
271
|
+
if (delivery === "terminal") return "Queued for your terminal.";
|
|
272
|
+
if (delivery === "terminal-quiet") return "Queued; the terminal that has this conversation is not taking messages (/resume there).";
|
|
273
|
+
if (delivery === "background") return "Queued; runs after pi-loops finishes its scheduled run there.";
|
|
274
|
+
if (delivery === "full") return "Queued; it opens when another conversation in the page closes.";
|
|
275
|
+
if (delivery === "no-pi-loops") return "Queued; the pi of that conversation has no pi-loops, so nothing runs it here. Open it in a terminal with pi-loops.";
|
|
276
|
+
if (delivery === "stopped") return "Queued; pi stopped too often in that conversation to be started again on its own.";
|
|
277
|
+
return "Queued; its conversation is gone.";
|
|
278
|
+
}
|
package/src/inbox.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* the morning. Findings are one-liners with a new → claimed/dismissed lifecycle.
|
|
5
5
|
*
|
|
6
6
|
* Appends are line-atomic; status rewrites are serialized through a file lock.
|
|
7
|
-
* Corrupt lines are skipped on read, never deleted.
|
|
7
|
+
* Corrupt lines are skipped on read and written back verbatim by every rewrite, never deleted.
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
@@ -15,6 +15,9 @@ import { stamp } from "./schedule.ts";
|
|
|
15
15
|
|
|
16
16
|
export type InboxStatus = "new" | "claimed" | "dismissed";
|
|
17
17
|
|
|
18
|
+
/** What `Inbox.decide` did: changed the finding, found none with that id, or found it already decided (and by whom, for a claim). */
|
|
19
|
+
export type Decision = { ok: InboxEntry } | { missing: true } | { already: InboxStatus; by?: string };
|
|
20
|
+
|
|
18
21
|
/** Past this the inbox drops its oldest triaged entries (docs/configuration.md). */
|
|
19
22
|
export const INBOX_ROTATE_BYTES = 1_000_000;
|
|
20
23
|
|
|
@@ -49,18 +52,23 @@ export interface InboxEntry {
|
|
|
49
52
|
*/
|
|
50
53
|
dismiss_reason: string | null;
|
|
51
54
|
job_id?: string;
|
|
52
|
-
/**
|
|
55
|
+
/** The session that created the loop that filed this finding. */
|
|
53
56
|
session_id?: string;
|
|
54
57
|
claimed_by?: string;
|
|
55
58
|
/** Checker's reason when it kept a finding, if it gave one. */
|
|
56
59
|
verified_reason?: string;
|
|
57
60
|
/** When it was dismissed — what decides whether the loop's next run is shown the reason. */
|
|
58
61
|
dismissed_at?: string;
|
|
62
|
+
/** The transcript of the run that filed this finding (RunRecord.sessionFile). */
|
|
63
|
+
transcript?: string;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/** The fields a finding is, in their order — CONTRACT.md §2.2 and docs/downstream.md §3. */
|
|
62
67
|
export const FINDING_FIELDS = ["id", "created_at", "status", "kind", "source", "run_id", "cwd", "text", "verified", "dismiss_reason"] as const;
|
|
63
68
|
|
|
69
|
+
/** What a writer gives for a new finding; the inbox fills in the id, the time and the status. */
|
|
70
|
+
export type NewFinding = Omit<InboxEntry, "id" | "created_at" | "status" | "kind" | "verified" | "dismiss_reason"> & Partial<Pick<InboxEntry, "kind" | "verified">>;
|
|
71
|
+
|
|
64
72
|
/** Reasons longer than a finding are a paragraph, and the prompt they go into is capped. */
|
|
65
73
|
const DISMISS_REASON_MAX_CHARS = INBOX_TEXT_MAX_CHARS;
|
|
66
74
|
|
|
@@ -73,9 +81,45 @@ export class Inbox {
|
|
|
73
81
|
this.lockPath = path.join(dir, "inbox.lock");
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
async append(entry:
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
async append(entry: NewFinding): Promise<InboxEntry> {
|
|
85
|
+
const full = this.build(entry);
|
|
86
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
87
|
+
// Under the same lock as the triage rewrites: findings are appended by every pi window, the
|
|
88
|
+
// headless host and up to `max_concurrent_runs` loop runs, while `/inbox dismiss|clear`
|
|
89
|
+
// rewrites the whole file — an append landing mid-rewrite is a finding nobody ever sees.
|
|
90
|
+
// The lock is awaited, never spun on: a leftover lock directory from a killed process would
|
|
91
|
+
// otherwise block this process's event loop for the whole stale window.
|
|
92
|
+
await withFileLock(this.lockPath, () => this.write(full));
|
|
93
|
+
return full;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Append `entry` unless a finding with the key's `run_id` and `source` is in the inbox already, and
|
|
98
|
+
* return the finding that is there either way. A key that has `session_id`, even undefined, names
|
|
99
|
+
* one creating conversation's group and matches only its finding; a key without it matches any.
|
|
100
|
+
* The look and the append are one step under the inbox lock. A rule check files its result this
|
|
101
|
+
* way, and the promotion's inbox fallback and the dead letter of its row then find it: one result,
|
|
102
|
+
* one finding, whichever writer comes first. With `unlessRepeat`, the latest new finding of the
|
|
103
|
+
* same `job_id`, `cwd` and source with the same text is the one that is there.
|
|
104
|
+
*/
|
|
105
|
+
async appendOnce(entry: NewFinding, key: { run_id: string; source: string } | { run_id: string; source: string; session_id: string | undefined }, opts: { unlessRepeat?: boolean } = {}): Promise<InboxEntry> {
|
|
106
|
+
const full = this.build(entry);
|
|
107
|
+
const source = capChars(key.source, 80);
|
|
108
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
109
|
+
return withFileLock(this.lockPath, () => {
|
|
110
|
+
const all = this.list();
|
|
111
|
+
const there = all.find((e) => e.run_id === key.run_id && e.source === source && (!("session_id" in key) || e.session_id === key.session_id));
|
|
112
|
+
if (there) return there;
|
|
113
|
+
const latest = opts.unlessRepeat ? all.filter((e) => e.status === "new" && e.job_id === full.job_id && e.cwd === full.cwd && e.source === full.source).at(-1) : undefined;
|
|
114
|
+
if (latest && latest.text === full.text) return latest;
|
|
115
|
+
this.write(full);
|
|
116
|
+
return full;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The line a new finding is: built in `FINDING_FIELDS` order, then pi-loops' own. */
|
|
121
|
+
private build(entry: NewFinding): InboxEntry {
|
|
122
|
+
return {
|
|
79
123
|
id: newId("inb"), // inb-<32 hex>, the shape every other id in pi-loops has
|
|
80
124
|
created_at: stamp(),
|
|
81
125
|
status: "new",
|
|
@@ -87,22 +131,18 @@ export class Inbox {
|
|
|
87
131
|
verified: entry.verified ?? null,
|
|
88
132
|
dismiss_reason: null,
|
|
89
133
|
job_id: entry.job_id,
|
|
90
|
-
session_id: entry.session_id,
|
|
91
134
|
claimed_by: entry.claimed_by,
|
|
92
135
|
verified_reason: entry.verified_reason,
|
|
93
136
|
dismissed_at: entry.dismissed_at,
|
|
137
|
+
...(entry.session_id === undefined ? {} : { session_id: entry.session_id }),
|
|
138
|
+
...(entry.transcript === undefined ? {} : { transcript: entry.transcript }),
|
|
94
139
|
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
await withFileLock(this.lockPath, () => {
|
|
102
|
-
fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, "utf8");
|
|
103
|
-
this.rotate();
|
|
104
|
-
});
|
|
105
|
-
return full;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Caller holds the lock. */
|
|
143
|
+
private write(full: InboxEntry): void {
|
|
144
|
+
fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, "utf8");
|
|
145
|
+
this.rotate();
|
|
106
146
|
}
|
|
107
147
|
|
|
108
148
|
/**
|
|
@@ -114,14 +154,14 @@ export class Inbox {
|
|
|
114
154
|
private rotate(): void {
|
|
115
155
|
try {
|
|
116
156
|
if (fs.statSync(this.file).size < INBOX_ROTATE_BYTES) return;
|
|
117
|
-
const
|
|
118
|
-
const triaged =
|
|
157
|
+
const lines = this.lines();
|
|
158
|
+
const triaged = lines.flatMap(({ entry }) => (entry && entry.status !== "new" ? [entry] : []));
|
|
119
159
|
const drop = Math.floor(triaged.length / 2);
|
|
120
160
|
// Nothing triaged to drop means an inbox full of unread findings: rewriting it would
|
|
121
161
|
// change nothing and would cost a full read+write on every append from here on.
|
|
122
162
|
if (drop === 0) return;
|
|
123
|
-
const dropped = new Set(triaged.slice(0, drop)
|
|
124
|
-
this.rewrite(
|
|
163
|
+
const dropped = new Set(triaged.slice(0, drop));
|
|
164
|
+
this.rewrite(lines.filter(({ entry }) => !entry || !dropped.has(entry)));
|
|
125
165
|
} catch {
|
|
126
166
|
/* best effort: a rotation that fails must never lose the append that just succeeded */
|
|
127
167
|
}
|
|
@@ -129,6 +169,14 @@ export class Inbox {
|
|
|
129
169
|
|
|
130
170
|
/** All entries, oldest first. Unparseable lines are skipped. */
|
|
131
171
|
list(): InboxEntry[] {
|
|
172
|
+
return this.lines().flatMap(({ entry }) => (entry ? [entry] : []));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every non-blank line of the file, in order, with the entry it reads as; `entry` is missing for a
|
|
177
|
+
* line that is not a finding (torn, not JSON). A rewrite writes such a line back as it found it.
|
|
178
|
+
*/
|
|
179
|
+
private lines(): Array<{ raw: string; entry?: InboxEntry }> {
|
|
132
180
|
let text: string;
|
|
133
181
|
try {
|
|
134
182
|
text = fs.readFileSync(this.file, "utf8");
|
|
@@ -136,15 +184,16 @@ export class Inbox {
|
|
|
136
184
|
if (err?.code === "ENOENT") return [];
|
|
137
185
|
throw err;
|
|
138
186
|
}
|
|
139
|
-
const out: InboxEntry
|
|
140
|
-
for (const
|
|
141
|
-
if (!
|
|
187
|
+
const out: Array<{ raw: string; entry?: InboxEntry }> = [];
|
|
188
|
+
for (const raw of text.split("\n")) {
|
|
189
|
+
if (!raw.trim()) continue;
|
|
190
|
+
let entry: InboxEntry | undefined;
|
|
142
191
|
try {
|
|
143
|
-
|
|
144
|
-
if (e && typeof e.id === "string" && typeof e.text === "string") out.push(e);
|
|
192
|
+
entry = readFinding(JSON.parse(raw));
|
|
145
193
|
} catch {
|
|
146
|
-
/*
|
|
194
|
+
/* a corrupt line: kept as raw text, read as nothing */
|
|
147
195
|
}
|
|
196
|
+
out.push(entry ? { raw, entry } : { raw });
|
|
148
197
|
}
|
|
149
198
|
return out;
|
|
150
199
|
}
|
|
@@ -155,10 +204,20 @@ export class Inbox {
|
|
|
155
204
|
* resolve numbers against it, so the order is decided once, here.
|
|
156
205
|
*/
|
|
157
206
|
listNew(): InboxEntry[] {
|
|
158
|
-
|
|
207
|
+
// A finding with no id or no text names nothing a person could read or act on: not listed, not counted.
|
|
208
|
+
const fresh = this.list().filter((e) => e.status === "new" && e.id !== "" && e.text !== "");
|
|
159
209
|
return [...fresh.filter((e) => e.kind === "checkpoint"), ...fresh.filter((e) => e.kind !== "checkpoint")];
|
|
160
210
|
}
|
|
161
211
|
|
|
212
|
+
newFor(sessionId: string, since?: string): InboxEntry[] {
|
|
213
|
+
const after = since === undefined ? Number.NEGATIVE_INFINITY : Date.parse(since);
|
|
214
|
+
return this.list().filter((e) => e.status === "new" && e.session_id === sessionId && Date.parse(e.created_at) > after);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
pinnedTranscripts(jobId: string): Set<string> {
|
|
218
|
+
return new Set(this.list().filter((e) => e.status === "new" && e.job_id === jobId && e.transcript).map((e) => path.resolve(e.transcript!)));
|
|
219
|
+
}
|
|
220
|
+
|
|
162
221
|
/** Count of new entries; 0 on any error (badge path must never throw). */
|
|
163
222
|
newCount(): number {
|
|
164
223
|
try {
|
|
@@ -177,38 +236,52 @@ export class Inbox {
|
|
|
177
236
|
}
|
|
178
237
|
}
|
|
179
238
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Claim or dismiss one finding, only while it is `new` (CONTRACT.md 2.3: each succeeds once).
|
|
241
|
+
* The check and the write are one critical section under the inbox lock, against the file as it
|
|
242
|
+
* is then: two pages, a page and a terminal, or two `pi-loops inbox claim` processes that all
|
|
243
|
+
* listed the finding as new get one `ok` between them. `by` is who claimed it; `reason` is only
|
|
244
|
+
* read for a dismiss — what the person said, kept for the loop's next run.
|
|
245
|
+
*
|
|
246
|
+
* `before` runs inside the same critical section, after the check and before the write: a claim
|
|
247
|
+
* queues its hand-off there, so only the winner ever queues one, and a crash between the two leaves
|
|
248
|
+
* the finding new with its hand-off queued (which the page's sweep finishes). Queued before the
|
|
249
|
+
* check, a loser's row could be run by its holder at once, or be the row the winner collapsed onto
|
|
250
|
+
* and then taken back. A `before` that throws leaves the finding as it was.
|
|
251
|
+
*/
|
|
252
|
+
async decide(id: string, to: "claimed" | "dismissed", opts: { by?: string; reason?: string; before?: () => void } = {}): Promise<Decision> {
|
|
253
|
+
return withFileLock(this.lockPath, (): Decision => {
|
|
254
|
+
const lines = this.lines();
|
|
255
|
+
const entry = lines.find((l) => l.entry?.id === id)?.entry;
|
|
256
|
+
if (!entry) return { missing: true };
|
|
257
|
+
if (entry.status !== "new") return entry.claimed_by === undefined ? { already: entry.status } : { already: entry.status, by: entry.claimed_by };
|
|
258
|
+
opts.before?.();
|
|
259
|
+
entry.status = to;
|
|
260
|
+
if (opts.by) entry.claimed_by = opts.by;
|
|
261
|
+
if (to === "dismissed") {
|
|
189
262
|
entry.dismissed_at = stamp();
|
|
190
|
-
const why = reason?.replace(/\s+/g, " ").trim();
|
|
263
|
+
const why = opts.reason?.replace(/\s+/g, " ").trim();
|
|
191
264
|
if (why) entry.dismiss_reason = capChars(why, DISMISS_REASON_MAX_CHARS);
|
|
192
265
|
}
|
|
193
|
-
this.rewrite(
|
|
194
|
-
return entry;
|
|
266
|
+
this.rewrite(lines);
|
|
267
|
+
return { ok: entry };
|
|
195
268
|
});
|
|
196
269
|
}
|
|
197
270
|
|
|
198
271
|
/** `match` limits it to what the caller listed — `/inbox clear` must not dismiss findings it did not show. */
|
|
199
272
|
async dismissAllNew(match?: (entry: InboxEntry) => boolean): Promise<number> {
|
|
200
273
|
return withFileLock(this.lockPath, () => {
|
|
201
|
-
const
|
|
274
|
+
const lines = this.lines();
|
|
202
275
|
const at = stamp();
|
|
203
276
|
let changed = 0;
|
|
204
|
-
for (const e of
|
|
205
|
-
if (e.status === "new" && (!match || match(e))) {
|
|
277
|
+
for (const { entry: e } of lines) {
|
|
278
|
+
if (e && e.status === "new" && (!match || match(e))) {
|
|
206
279
|
e.status = "dismissed";
|
|
207
280
|
e.dismissed_at = at;
|
|
208
281
|
changed++;
|
|
209
282
|
}
|
|
210
283
|
}
|
|
211
|
-
if (changed) this.rewrite(
|
|
284
|
+
if (changed) this.rewrite(lines);
|
|
212
285
|
return changed;
|
|
213
286
|
});
|
|
214
287
|
}
|
|
@@ -225,8 +298,9 @@ export class Inbox {
|
|
|
225
298
|
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
299
|
}
|
|
227
300
|
|
|
228
|
-
|
|
229
|
-
|
|
301
|
+
/** Write `lines` back, each finding in this version's shape and every line it could not read as it was. Caller holds the lock. */
|
|
302
|
+
private rewrite(lines: Array<{ raw: string; entry?: InboxEntry }>): void {
|
|
303
|
+
writeFileAtomic(this.file, lines.map(({ raw, entry }) => (entry ? JSON.stringify(entry) : raw)).join("\n") + (lines.length ? "\n" : ""));
|
|
230
304
|
}
|
|
231
305
|
}
|
|
232
306
|
|
|
@@ -238,27 +312,30 @@ export class Inbox {
|
|
|
238
312
|
*/
|
|
239
313
|
function readFinding(raw: any): InboxEntry | undefined {
|
|
240
314
|
if (!raw || typeof raw !== "object") return undefined;
|
|
241
|
-
|
|
242
|
-
|
|
315
|
+
// A line anything may have written: a field of the wrong type reads as missing, so every reader
|
|
316
|
+
// of a finding can call a string method on a string field.
|
|
317
|
+
const str = (...keys: string[]): string | undefined => {
|
|
318
|
+
for (const k of keys) if (typeof raw[k] === "string") return raw[k];
|
|
243
319
|
return undefined;
|
|
244
320
|
};
|
|
245
|
-
const status =
|
|
321
|
+
const status = str("status");
|
|
246
322
|
return {
|
|
247
|
-
id:
|
|
248
|
-
created_at:
|
|
323
|
+
id: str("id") ?? "",
|
|
324
|
+
created_at: str("created_at", "createdAt") ?? "",
|
|
249
325
|
status: status === "claimed" || status === "dismissed" ? status : "new",
|
|
250
326
|
kind: raw.kind === "checkpoint" ? "checkpoint" : "news",
|
|
251
|
-
source:
|
|
252
|
-
run_id:
|
|
253
|
-
cwd:
|
|
254
|
-
text:
|
|
255
|
-
verified:
|
|
256
|
-
dismiss_reason:
|
|
257
|
-
job_id:
|
|
258
|
-
session_id:
|
|
259
|
-
claimed_by:
|
|
260
|
-
verified_reason:
|
|
261
|
-
dismissed_at:
|
|
327
|
+
source: str("source") ?? "",
|
|
328
|
+
run_id: str("run_id", "trace_id", "runId") ?? "",
|
|
329
|
+
cwd: str("cwd") ?? "",
|
|
330
|
+
text: str("text") ?? "",
|
|
331
|
+
verified: typeof raw.verified === "boolean" ? raw.verified : null,
|
|
332
|
+
dismiss_reason: str("dismiss_reason") ?? null,
|
|
333
|
+
job_id: str("job_id", "jobId"),
|
|
334
|
+
session_id: str("session_id", "sessionId"),
|
|
335
|
+
claimed_by: str("claimed_by", "claimedBy"),
|
|
336
|
+
verified_reason: str("verified_reason", "verifiedReason"),
|
|
337
|
+
dismissed_at: str("dismissed_at"),
|
|
338
|
+
transcript: str("transcript"),
|
|
262
339
|
};
|
|
263
340
|
}
|
|
264
341
|
|
package/src/job-edit.ts
CHANGED
|
@@ -19,7 +19,8 @@ export interface JobEdit {
|
|
|
19
19
|
thinking?: string | null;
|
|
20
20
|
timeoutMs?: number | null;
|
|
21
21
|
name?: string | null;
|
|
22
|
-
/**
|
|
22
|
+
/** The tools the job's turns run with; null clears the list (a plain job's turns then run uncapped, as its 0.22 turns did). */
|
|
23
|
+
tools?: string[] | null;
|
|
23
24
|
prompt?: string;
|
|
24
25
|
schedule?: Schedule;
|
|
25
26
|
}
|
|
@@ -64,6 +65,11 @@ export function applyJobEdit(job: LoopJob, edit: JobEdit, ctx: JobEditContext):
|
|
|
64
65
|
if (edit.thinking !== undefined) patch.thinking = edit.thinking ?? undefined;
|
|
65
66
|
if (edit.timeoutMs !== undefined) patch.timeoutMs = edit.timeoutMs ?? undefined;
|
|
66
67
|
if (edit.name !== undefined) patch.name = edit.name ?? undefined;
|
|
68
|
+
if (edit.tools !== undefined) {
|
|
69
|
+
patch.tools = edit.tools ?? undefined;
|
|
70
|
+
// The old list is what the job's turns were capped to, and the edit is what uncapped them.
|
|
71
|
+
changed.push(`tools changed from ${job.tools ? job.tools.join(",") : "(none)"} to ${edit.tools ? edit.tools.join(",") : "(none)"}`);
|
|
72
|
+
}
|
|
67
73
|
if (edit.prompt !== undefined) {
|
|
68
74
|
patch.prompt = edit.prompt;
|
|
69
75
|
// The old wording is otherwise unrecoverable, and it is what ties "this loop started
|