@alphacoder-v0/pi-loops 0.22.7 → 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 +681 -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 -41
- 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 +598 -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
package/src/seen.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { withFileLockSync, writeFileAtomic } from "./lock.ts";
|
|
4
|
+
import { Inbox, type InboxEntry } from "./inbox.ts";
|
|
5
|
+
import { stamp } from "./schedule.ts";
|
|
6
|
+
import { JobStore, type RunRecord } from "./store.ts";
|
|
7
|
+
import { realpathish, sameProject } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* When `/inbox` was last looked at, per project: the terminal's `/inbox`, and the page's Mark all
|
|
11
|
+
* seen. `"*"` is `/inbox --all`, which looks at every project. `sessions` holds the per-conversation
|
|
12
|
+
* cursors a v1 file had: read when folding, never written to, and pruned by the sweep.
|
|
13
|
+
*/
|
|
14
|
+
export interface SeenFile {
|
|
15
|
+
v: 2;
|
|
16
|
+
projects: Record<string, string>;
|
|
17
|
+
sessions: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The key `/inbox --all` marks: it looked at every project. */
|
|
21
|
+
export const ALL_PROJECTS = "*";
|
|
22
|
+
/** With no cursor (nobody looked yet, or an upgrade from a version without one), what is counted. */
|
|
23
|
+
export const FIRST_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
/** A cursor this far ahead of the clock is a clock that moved, not a look: it counts as none. */
|
|
25
|
+
export const SKEW_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const fileIn = (dir: string): string => path.join(dir, "seen.json");
|
|
28
|
+
const lockIn = (dir: string): string => path.join(dir, "seen.lock");
|
|
29
|
+
const empty = (): SeenFile => ({ v: 2, projects: {}, sessions: {} });
|
|
30
|
+
|
|
31
|
+
function stringsOf(value: unknown): Record<string, string> {
|
|
32
|
+
const out: Record<string, string> = {};
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
|
34
|
+
for (const [k, at] of Object.entries(value)) if (typeof at === "string" && Number.isFinite(Date.parse(at))) out[k] = at;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The file as v2: a v1 file's per-conversation cursors become `sessions`; anything else is empty. */
|
|
39
|
+
export function readSeen(dir: string): SeenFile {
|
|
40
|
+
let value: any;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(fs.readFileSync(fileIn(dir), "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return empty(); // not written yet, or torn: the first window, never a flood
|
|
45
|
+
}
|
|
46
|
+
if (!value || typeof value !== "object") return empty();
|
|
47
|
+
if (value.v === 1) return { v: 2, projects: {}, sessions: stringsOf(value.seen) };
|
|
48
|
+
if (value.v === 2) return { v: 2, projects: stringsOf(value.projects), sessions: stringsOf(value.sessions) };
|
|
49
|
+
return empty();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const write = (dir: string, seen: SeenFile) => writeFileAtomic(fileIn(dir), `${JSON.stringify(seen, null, 2)}\n`);
|
|
53
|
+
const later = (a: string | undefined, b: string) => (a !== undefined && Date.parse(a) >= Date.parse(b) ? a : b);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record a look at `cwd`'s project (or every project, `"*"`) at `at`. A cursor only moves forward,
|
|
57
|
+
* so two writers end in the same state whatever their order; `at` is never later than `now`, and a
|
|
58
|
+
* stored cursor in the future (a clock that moved) is replaced. Returns what is stored.
|
|
59
|
+
*/
|
|
60
|
+
export function markProjectSeen(dir: string, cwd: string, at: string, now = Date.now()): string {
|
|
61
|
+
const key = cwd === ALL_PROJECTS ? cwd : realpathish(cwd) || cwd;
|
|
62
|
+
const capped = Date.parse(at) > now ? stamp(now) : at;
|
|
63
|
+
return withFileLockSync(lockIn(dir), () => {
|
|
64
|
+
const seen = readSeen(dir);
|
|
65
|
+
const prev = seen.projects[key];
|
|
66
|
+
const stored = later(prev !== undefined && Date.parse(prev) <= now + SKEW_MS ? prev : undefined, capped);
|
|
67
|
+
if (stored !== prev) {
|
|
68
|
+
seen.projects[key] = stored;
|
|
69
|
+
write(dir, seen);
|
|
70
|
+
}
|
|
71
|
+
return stored;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The sweep: forget v1 cursors of conversations nothing refers to any more, and project cursors
|
|
77
|
+
* whose directory is gone. Returns how many were forgotten.
|
|
78
|
+
*/
|
|
79
|
+
export function pruneSeen(dir: string, keepSession: (sessionId: string) => boolean, exists: (p: string) => boolean = fs.existsSync): number {
|
|
80
|
+
return withFileLockSync(lockIn(dir), () => {
|
|
81
|
+
const seen = readSeen(dir);
|
|
82
|
+
const sessions = Object.keys(seen.sessions).filter((id) => !keepSession(id));
|
|
83
|
+
const projects = Object.keys(seen.projects).filter((k) => k !== ALL_PROJECTS && !exists(k));
|
|
84
|
+
if (!sessions.length && !projects.length) return 0;
|
|
85
|
+
for (const id of sessions) delete seen.sessions[id];
|
|
86
|
+
for (const k of projects) delete seen.projects[k];
|
|
87
|
+
write(dir, seen);
|
|
88
|
+
return sessions.length + projects.length;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where "since" starts for a project; `looked: false` is the first window. */
|
|
93
|
+
export interface Cursor {
|
|
94
|
+
sinceMs: number;
|
|
95
|
+
looked: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The latest look at `cwd`'s project: every project key `inProject` matches (a project's
|
|
100
|
+
* subdirectory is a key of its own), `"*"`, and the v1 cursor of every conversation whose records
|
|
101
|
+
* are in the project. A cursor in the future is ignored; none at all is the first window.
|
|
102
|
+
*/
|
|
103
|
+
export function cursorFor(seen: SeenFile, cwd: string, ctx: { inProject: (entryCwd: string, cwd: string) => boolean; sessionsHere: ReadonlySet<string>; now: number }): Cursor {
|
|
104
|
+
const times: string[] = [];
|
|
105
|
+
for (const [k, at] of Object.entries(seen.projects)) if (k === ALL_PROJECTS || ctx.inProject(k, cwd)) times.push(at);
|
|
106
|
+
for (const [id, at] of Object.entries(seen.sessions)) if (ctx.sessionsHere.has(id)) times.push(at);
|
|
107
|
+
const valid = times.map((t) => Date.parse(t)).filter((ms) => Number.isFinite(ms) && ms <= ctx.now + SKEW_MS);
|
|
108
|
+
// A loop, not Math.max(...valid): a hand-made file with many keys would overflow the stack.
|
|
109
|
+
const latest = valid.reduce((a, b) => (b > a ? b : a), Number.NEGATIVE_INFINITY);
|
|
110
|
+
return valid.length ? { sinceMs: latest, looked: true } : { sinceMs: ctx.now - FIRST_WINDOW_MS, looked: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What happened in one project while nobody looked: the page's away card and the terminal's
|
|
115
|
+
* session-start line both say this (docs/loops.md).
|
|
116
|
+
*/
|
|
117
|
+
export interface AwaySummary {
|
|
118
|
+
cwd: string;
|
|
119
|
+
/** The cursor as an ISO time; null for the first window ("In the last day"). */
|
|
120
|
+
since: string | null;
|
|
121
|
+
/** When this was counted, the end of its window: what Mark all seen sends back. */
|
|
122
|
+
asOf: string;
|
|
123
|
+
runs: number;
|
|
124
|
+
failedRuns: number;
|
|
125
|
+
/** New findings, a rule check's once; `decisions` of them are checkpoints. */
|
|
126
|
+
findings: number;
|
|
127
|
+
decisions: number;
|
|
128
|
+
fromThisConversation: number;
|
|
129
|
+
costUsd: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface AwayInput {
|
|
133
|
+
seen: SeenFile;
|
|
134
|
+
runs: readonly RunRecord[];
|
|
135
|
+
findings: readonly InboxEntry[];
|
|
136
|
+
cwd: string;
|
|
137
|
+
sessionId?: string;
|
|
138
|
+
now: number;
|
|
139
|
+
inProject: (entryCwd: string, cwd: string) => boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether a path is in `cwd`'s project, asked once per distinct path: matching resolves paths on disk. */
|
|
143
|
+
function matcher(cwd: string, inProject: (entryCwd: string, cwd: string) => boolean): (entryCwd: unknown) => boolean {
|
|
144
|
+
const known = new Map<string, boolean>();
|
|
145
|
+
return (entryCwd) => {
|
|
146
|
+
if (typeof entryCwd !== "string") return false;
|
|
147
|
+
let yes = known.get(entryCwd);
|
|
148
|
+
if (yes === undefined) known.set(entryCwd, (yes = inProject(entryCwd, cwd)));
|
|
149
|
+
return yes;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The one function behind the away card and the session-start line. Pure: the caller reads the files. */
|
|
154
|
+
export function awayFor(input: AwayInput): AwaySummary {
|
|
155
|
+
const here = matcher(input.cwd, input.inProject);
|
|
156
|
+
// A finding with no cwd is in every project, as `/inbox` lists it (inbox.ts belongsToProject).
|
|
157
|
+
const findingHere = (f: InboxEntry) => !f.cwd || here(f.cwd);
|
|
158
|
+
// Lines another process wrote: one that is not an object (`null`, a number) is no record.
|
|
159
|
+
const runs = input.runs.filter((r) => !!r && typeof r === "object");
|
|
160
|
+
const findings = input.findings.filter((f) => !!f && typeof f === "object");
|
|
161
|
+
// The v1 fold: a conversation's old cursor counts for the project its records are in.
|
|
162
|
+
const sessionsHere = new Set<string>();
|
|
163
|
+
if (Object.keys(input.seen.sessions).length) {
|
|
164
|
+
for (const r of runs) if (typeof r.forSession === "string" && here(r.cwd)) sessionsHere.add(r.forSession);
|
|
165
|
+
for (const f of findings) if (typeof f.session_id === "string" && f.cwd && here(f.cwd)) sessionsHere.add(f.session_id);
|
|
166
|
+
}
|
|
167
|
+
const cursor = cursorFor(input.seen, input.cwd, { inProject: input.inProject, sessionsHere, now: input.now });
|
|
168
|
+
// The window is (cursor, now]: Mark all seen sends `now` back as asOf, and the page refuses a time
|
|
169
|
+
// ahead of its clock, so a record stamped a little ahead is counted once its time has come.
|
|
170
|
+
const after = (at: unknown) => {
|
|
171
|
+
const ms = typeof at === "string" ? Date.parse(at) : NaN;
|
|
172
|
+
return ms > cursor.sinceMs && ms <= input.now;
|
|
173
|
+
};
|
|
174
|
+
let runCount = 0;
|
|
175
|
+
let failedRuns = 0;
|
|
176
|
+
let costUsd = 0;
|
|
177
|
+
for (const r of runs) {
|
|
178
|
+
if (!r.stateful || !here(r.cwd) || !after(r.finishedAt)) continue;
|
|
179
|
+
runCount++;
|
|
180
|
+
if (!r.ok && !r.aborted) failedRuns++;
|
|
181
|
+
costUsd += (typeof r.usage?.cost === "number" ? r.usage.cost : 0) + (typeof r.checker?.cost === "number" ? r.checker.cost : 0);
|
|
182
|
+
}
|
|
183
|
+
// A rule check files one finding per conversation it was for (src/trigger-runtime.ts): one result.
|
|
184
|
+
const groups = new Map<string, InboxEntry[]>();
|
|
185
|
+
for (const f of findings) {
|
|
186
|
+
if (f.status !== "new" || !findingHere(f) || !after(f.created_at)) continue;
|
|
187
|
+
const key = typeof f.source === "string" && f.source.startsWith("trigger:") && f.run_id ? `rule:${f.run_id}` : `id:${f.id}`;
|
|
188
|
+
groups.set(key, [...(groups.get(key) ?? []), f]);
|
|
189
|
+
}
|
|
190
|
+
const results = [...groups.values()];
|
|
191
|
+
return {
|
|
192
|
+
cwd: input.cwd,
|
|
193
|
+
since: cursor.looked ? stamp(cursor.sinceMs) : null,
|
|
194
|
+
asOf: stamp(input.now),
|
|
195
|
+
runs: runCount,
|
|
196
|
+
failedRuns,
|
|
197
|
+
findings: results.length,
|
|
198
|
+
decisions: results.filter((g) => g.some((f) => f.kind === "checkpoint")).length,
|
|
199
|
+
fromThisConversation: input.sessionId ? results.filter((g) => g.some((f) => f.session_id === input.sessionId)).length : 0,
|
|
200
|
+
costUsd: Math.round(costUsd * 100) / 100,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The summary from the loops directory's files: the whole run log (halved past 1 MB, so it is
|
|
206
|
+
* bounded), the inbox and `seen.json`. What the terminal's session-start line and the page's
|
|
207
|
+
* `GET /inbox` both call, so the two cannot count differently.
|
|
208
|
+
*/
|
|
209
|
+
export function awayIn(dir: string, cwd: string, sessionId?: string, now = Date.now()): AwaySummary {
|
|
210
|
+
return awayFor({ seen: readSeen(dir), runs: new JobStore(dir).allRuns(), findings: new Inbox(dir).list(), cwd, sessionId, now, inProject: sameProject });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
214
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
215
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* When `sinceMs` was, said from `nowMs`, in local time: "09:14", "yesterday 23:10", "Mon 23:10",
|
|
219
|
+
* "Oct 28 23:10", "Oct 28 2025 23:10". Days are calendar dates, not 24-hour steps, so a night with
|
|
220
|
+
* a clock change is still one day. The page has a copy of this (src/web.mjs `sinceWords`).
|
|
221
|
+
*/
|
|
222
|
+
export function sinceWords(sinceMs: number, nowMs: number): string {
|
|
223
|
+
const a = new Date(sinceMs);
|
|
224
|
+
const b = new Date(nowMs);
|
|
225
|
+
const day = (d: Date) => Math.round(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000);
|
|
226
|
+
const days = day(b) - day(a);
|
|
227
|
+
const time = `${pad(a.getHours())}:${pad(a.getMinutes())}`;
|
|
228
|
+
if (days <= 0) return time;
|
|
229
|
+
if (days === 1) return `yesterday ${time}`;
|
|
230
|
+
if (days < 7) return `${DAYS[a.getDay()]} ${time}`;
|
|
231
|
+
const date = `${MONTHS[a.getMonth()]} ${a.getDate()}`;
|
|
232
|
+
return a.getFullYear() === b.getFullYear() ? `${date} ${time}` : `${date} ${a.getFullYear()} ${time}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
236
|
+
|
|
237
|
+
/** How many findings came from this conversation, worth saying only when that is not all of them. */
|
|
238
|
+
const fromHere = (s: AwaySummary): number => (s.fromThisConversation !== s.findings ? s.fromThisConversation : 0);
|
|
239
|
+
|
|
240
|
+
/** Nothing to say: no run and no finding since the cursor. */
|
|
241
|
+
export const awayEmpty = (s: AwaySummary): boolean => s.runs === 0 && s.findings === 0;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The away card's sentence (the page draws it from its own copy, part by part, and
|
|
245
|
+
* test/web-page.test.ts holds the two to one sentence); runs are the
|
|
246
|
+
* activity strip's to count, so a card is about findings:
|
|
247
|
+
* "Since yesterday 23:10 · 3 findings, 1 from this conversation · 1 needs you · $0.41".
|
|
248
|
+
*/
|
|
249
|
+
export function awaySentence(s: AwaySummary): string | undefined {
|
|
250
|
+
if (!s.findings) return undefined;
|
|
251
|
+
const parts = [s.since === null ? "In the last day" : `Since ${sinceWords(Date.parse(s.since), Date.parse(s.asOf))}`];
|
|
252
|
+
if (s.findings) parts.push(`${plural(s.findings, "finding")}${fromHere(s) ? `, ${fromHere(s)} from this conversation` : ""}`);
|
|
253
|
+
if (s.decisions) parts.push(`${s.decisions} need${s.decisions === 1 ? "s" : ""} you`);
|
|
254
|
+
if (s.costUsd > 0) parts.push(`$${s.costUsd.toFixed(2)}`);
|
|
255
|
+
return parts.join(" · ");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The terminal's session-start line, from the same summary:
|
|
260
|
+
* "[inbox] since you last looked (yesterday 23:10): 41 loop runs (3 failed), 3 new findings (1 from this conversation) — /inbox".
|
|
261
|
+
*/
|
|
262
|
+
export function awayLine(s: AwaySummary): string | undefined {
|
|
263
|
+
if (awayEmpty(s)) return undefined;
|
|
264
|
+
const when = s.since === null ? "in the last day" : sinceWords(Date.parse(s.since), Date.parse(s.asOf));
|
|
265
|
+
const parts: string[] = [];
|
|
266
|
+
if (s.runs) parts.push(`${plural(s.runs, "loop run")}${s.failedRuns ? ` (${s.failedRuns} failed)` : ""}`);
|
|
267
|
+
if (s.findings) parts.push(`${plural(s.findings, "new finding")}${fromHere(s) ? ` (${fromHere(s)} from this conversation)` : ""}`);
|
|
268
|
+
return `[inbox] since you last looked (${when}): ${parts.join(", ")} — /inbox`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The Background view's column for one conversation: its project's counts. */
|
|
272
|
+
export interface SinceYouLooked {
|
|
273
|
+
runs: number;
|
|
274
|
+
failed: number;
|
|
275
|
+
findings: number;
|
|
276
|
+
mine: number;
|
|
277
|
+
}
|
|
278
|
+
export const sinceCounts = (s: AwaySummary): SinceYouLooked => ({ runs: s.runs, failed: s.failedRuns, findings: s.findings, mine: s.fromThisConversation });
|
package/src/session-head.ts
CHANGED
|
@@ -84,6 +84,27 @@ export function previewText(text: string, max = 80): string {
|
|
|
84
84
|
return chars.length > max ? `${chars.slice(0, max).join("")}…` : flat;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** What tells a conversation apart from another started the same minute: its name, else the first thing said. */
|
|
88
|
+
export function sessionTitle(file: string): string | undefined {
|
|
89
|
+
const head = readSessionHead(file);
|
|
90
|
+
const title = head?.name ?? head?.first;
|
|
91
|
+
return title ? previewText(title, 40) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A conversation's short form on every surface: its id's first 13 characters (`01a1c183-7467`).
|
|
96
|
+
* The 12 hex digits hold a uuidv7's whole millisecond stamp, so two collide only when they start in
|
|
97
|
+
* the same millisecond; the first 8 collided about every 65 s. A prefix, so `--session` takes it.
|
|
98
|
+
*/
|
|
99
|
+
export function shortSessionId(sessionId: string): string {
|
|
100
|
+
return sessionId.slice(0, 13);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A conversation in a listing: its short form, and its title when one is known. */
|
|
104
|
+
export function conversationLabel(sessionId: string, title?: string): string {
|
|
105
|
+
return title ? `${shortSessionId(sessionId)} "${title}"` : shortSessionId(sessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
/**
|
|
88
109
|
* The automation badge of a session listing: the plain jobs bound to this session (`sessionId`)
|
|
89
110
|
* and the jobs and rules it created. `2 cron, 1 trigger`; `automation off` when everything
|
package/src/share.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* taken back, so the summary comes first and the confirmation after it.
|
|
10
10
|
*/
|
|
11
11
|
import { redact } from "./redact.ts";
|
|
12
|
+
import { plural } from "./job-lines.ts";
|
|
12
13
|
import { stamp } from "./schedule.ts";
|
|
13
14
|
|
|
14
15
|
export interface ShareMessage {
|
|
@@ -49,6 +50,15 @@ function contentText(content: unknown): string {
|
|
|
49
50
|
return "";
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The messages a share carries: pi's own message entries of the context, and nothing an extension
|
|
55
|
+
* wrote beside them. A run card (`pi-loops:view`), a promoted rule result (`custom_message`) and the
|
|
56
|
+
* trigger audit (`custom`) are pi-loops' records of the conversation, not the conversation.
|
|
57
|
+
*/
|
|
58
|
+
export function shareableMessages(entries: ReadonlyArray<{ type?: string; message?: ShareMessage }>): ShareMessage[] {
|
|
59
|
+
return entries.flatMap((e) => (e.type === "message" && e.message ? [e.message] : []));
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
/**
|
|
53
63
|
* Render the messages of a session as Markdown. Pure: the caller decides where it goes, which is
|
|
54
64
|
* what makes the redaction counting testable without a session, a model or a network.
|
|
@@ -113,8 +123,8 @@ export function renderShare(messages: ShareMessage[], meta: { model?: string; se
|
|
|
113
123
|
*/
|
|
114
124
|
export function shareSummary(rendered: RenderedShare, opts: { public: boolean }): string[] {
|
|
115
125
|
return [
|
|
116
|
-
` ${rendered.messages
|
|
117
|
-
` ${rendered.redactions
|
|
126
|
+
` ${plural(rendered.messages, "message")}, ${plural(rendered.toolResults, "tool result")}, ${(rendered.bytes / 1024).toFixed(1)} KB`,
|
|
127
|
+
` ${plural(rendered.redactions, "secret")} masked — the redactor knows common shapes, not every shape`,
|
|
118
128
|
opts.public ? " visibility: PUBLIC — anyone can find it" : " visibility: secret gist — unlisted, but anyone with the link can read it",
|
|
119
129
|
" it contains every file the agent read and every command it ran in this session",
|
|
120
130
|
];
|
package/src/snapshot.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const SNAPSHOT_MIN_GAP_MS = 60_000;
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* What counts as a change worth an entry: which servers are connected and what they exposed, who
|
|
14
|
-
*
|
|
14
|
+
* the host runs, the tools, the hooks, how many jobs and rules there are. Deliberately not the
|
|
15
15
|
* counters — a server pushing every ten seconds moves `queued` constantly, and writing the session
|
|
16
16
|
* file four times a minute to record that is not observability, it is noise. The counters are still
|
|
17
17
|
* in the entry; they just do not trigger one, and `/cron snapshot` forces a fresh entry whenever a
|
|
@@ -19,7 +19,8 @@ export const SNAPSHOT_MIN_GAP_MS = 60_000;
|
|
|
19
19
|
*/
|
|
20
20
|
export function snapshotFingerprint(data: any): string {
|
|
21
21
|
return JSON.stringify({
|
|
22
|
-
scheduler: { running: data.scheduler?.running,
|
|
22
|
+
scheduler: { running: data.scheduler?.running, host: data.scheduler?.host?.running },
|
|
23
|
+
paused: data.paused,
|
|
23
24
|
counts: data.counts,
|
|
24
25
|
mcp: (data.mcp ?? []).map((m: any) => ({ name: m.name, state: m.state, tools: m.tools, attention: m.attention, lastError: m.lastError })),
|
|
25
26
|
mcpConfigError: data.mcpConfigError,
|
package/src/store.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* state/<job-id>.md loop state — the "state spine", human-readable
|
|
8
8
|
* inbox.jsonl the triage inbox (see inbox.ts)
|
|
9
9
|
* runs.jsonl run log, bounded
|
|
10
|
-
* scheduler.json
|
|
10
|
+
* scheduler.json the host's fence for pi-loops 0.22 windows, during an upgrade only
|
|
11
|
+
* leases/, requests/ which process holds a conversation, and what is queued for it (lease.ts, requests.ts)
|
|
11
12
|
* *.lock mkdir locks
|
|
12
13
|
*/
|
|
13
14
|
import * as fs from "node:fs";
|
|
@@ -41,18 +42,29 @@ export interface LoopJob {
|
|
|
41
42
|
model?: string;
|
|
42
43
|
thinking?: string;
|
|
43
44
|
tools?: string[];
|
|
45
|
+
/**
|
|
46
|
+
* The built-in tools (read, bash, edit, …) of the chat it was made in, when no `tools` were given:
|
|
47
|
+
* it runs in the background, which has no `--tools` of its own, and must not get more than that chat.
|
|
48
|
+
*/
|
|
49
|
+
builtinTools?: string[];
|
|
44
50
|
enabled: boolean;
|
|
45
51
|
/** Maker/checker: a second sub-agent reviews findings before they enter the inbox. */
|
|
46
52
|
verify?: boolean;
|
|
47
53
|
/** "provider/model-id" for the checker; undefined → same as the maker. */
|
|
48
54
|
checkerModel?: string;
|
|
49
|
-
/** Fire once
|
|
55
|
+
/** Fire once when its scheduler starts if a tick was missed while it was not running (default true). */
|
|
50
56
|
catchUp: boolean;
|
|
51
57
|
timeoutMs?: number;
|
|
52
58
|
/** The recipe that created this job (`/recipe add`), so `/recipe list|remove` can find it. */
|
|
53
59
|
recipe?: string;
|
|
54
60
|
createdAt: string;
|
|
55
61
|
createdBy?: { sessionId?: string; cwd: string };
|
|
62
|
+
/**
|
|
63
|
+
* `"project"`: the loop belongs to its project, not a conversation (made where no saved
|
|
64
|
+
* conversation was, recipe.ts installScope), and has no `createdBy`. A job with neither is from
|
|
65
|
+
* before `createdBy` existed.
|
|
66
|
+
*/
|
|
67
|
+
owner?: "project";
|
|
56
68
|
/** non-stateful jobs only: the session that receives the message. */
|
|
57
69
|
sessionId?: string;
|
|
58
70
|
lastDueAt?: string;
|
|
@@ -64,6 +76,19 @@ export interface LoopJob {
|
|
|
64
76
|
skippedOverlap: number;
|
|
65
77
|
/** Failures in a row. Cleared by a success; drives the backoff in the scheduler. */
|
|
66
78
|
consecutiveFailures?: number;
|
|
79
|
+
/**
|
|
80
|
+
* A slot handed back unrun: the host's turn in a closed conversation was taken back by a window,
|
|
81
|
+
* or stopped by the host itself (a stop, an upgrade, the budget), or its process died before a
|
|
82
|
+
* record was written. The next dispatch runs it at once instead of calling it missed — the
|
|
83
|
+
* catch-up rule is for slots nobody tried, and this one was tried. Cleared by the next claim.
|
|
84
|
+
*/
|
|
85
|
+
owedSlot?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Runs in a row that started and never finished: the process running them died mid-run. Three
|
|
88
|
+
* park the job (disabled, with why) rather than letting one that takes its process down with it
|
|
89
|
+
* do so on every start. Cleared by a run that finishes, and by enabling the job again.
|
|
90
|
+
*/
|
|
91
|
+
interruptions?: number;
|
|
67
92
|
/**
|
|
68
93
|
* When this job's `cwd` was first found missing, if it still is.
|
|
69
94
|
*
|
|
@@ -75,6 +100,16 @@ export interface LoopJob {
|
|
|
75
100
|
* next write, and the only consequence is that the grace period starts again.
|
|
76
101
|
*/
|
|
77
102
|
cwdMissingSince?: string;
|
|
103
|
+
/**
|
|
104
|
+
* The page's suggestion for this loop that a person answered with Keep (src/job-tune.ts): the
|
|
105
|
+
* same one is not offered again. Its key names the schedule, so a schedule change makes a new one.
|
|
106
|
+
*/
|
|
107
|
+
tuneKept?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Who asked for a run by hand: the conversation the request came from. */
|
|
111
|
+
export interface RunRequester {
|
|
112
|
+
sessionId: string;
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
export interface RunRecord {
|
|
@@ -105,6 +140,27 @@ export interface RunRecord {
|
|
|
105
140
|
sessionFile?: string;
|
|
106
141
|
/** Present when the job has verify=true and the maker reported findings. */
|
|
107
142
|
checker?: CheckerRecord;
|
|
143
|
+
/** The session that created this run's job (job.createdBy.sessionId), so its results can be counted for that conversation. */
|
|
144
|
+
forSession?: string;
|
|
145
|
+
/**
|
|
146
|
+
* Which run of its loop this was: the job's `runCount + 1` when the record was written. A stopped
|
|
147
|
+
* run does not raise `runCount`, so the run after it has the same ordinal. Absent on a record
|
|
148
|
+
* written before 0.24, and on a plain job's turn. The run log is halved as it grows, so counting
|
|
149
|
+
* records cannot tell run 3 from run 300.
|
|
150
|
+
*/
|
|
151
|
+
ordinal?: number;
|
|
152
|
+
/**
|
|
153
|
+
* The conversation a person asked for this run from (`/cron run`, the page's run button). Its card
|
|
154
|
+
* goes there, whatever the run's number. Absent on a scheduled run and on a record written before
|
|
155
|
+
* 0.24.0 had it.
|
|
156
|
+
*/
|
|
157
|
+
requestedBy?: RunRequester;
|
|
158
|
+
/** The run was stopped (a person, a quit, a restart), not failed: it gets no card and is no failure. */
|
|
159
|
+
aborted?: true;
|
|
160
|
+
/** Set beside `aborted` when a person stopped it (the page's Stop, `pi-loops abort`, `/triggers abort`), which `aborted` alone cannot tell from a quit. */
|
|
161
|
+
stoppedBy?: "person";
|
|
162
|
+
/** Where that person stopped it: the browser page, or a terminal (`pi-loops abort`, `/triggers abort`). */
|
|
163
|
+
stoppedFrom?: "page" | "cli";
|
|
108
164
|
}
|
|
109
165
|
|
|
110
166
|
export interface CheckerRecord {
|
|
@@ -124,10 +180,20 @@ export interface CheckerRecord {
|
|
|
124
180
|
|
|
125
181
|
/** Transcripts kept per loop; older ones are deleted when a run completes. */
|
|
126
182
|
export const SESSIONS_KEPT_PER_JOB = 20;
|
|
183
|
+
export const PINNED_TRANSCRIPTS_MAX = 100;
|
|
127
184
|
|
|
128
185
|
/** Past this the run log is halved (docs/configuration.md). */
|
|
129
186
|
export const RUNS_ROTATE_BYTES = 1_000_000;
|
|
130
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The loops directory pi-loops uses when nobody moved it: `loops` under pi's agent directory, even
|
|
190
|
+
* when `PI_LOOPS_DIR` points elsewhere. Only that one gets the host's fixed page port and a login
|
|
191
|
+
* unit; a moved directory is a test run or a second setup.
|
|
192
|
+
*/
|
|
193
|
+
export function homeLoopsDir(agentDir: string): string {
|
|
194
|
+
return path.join(agentDir, "loops");
|
|
195
|
+
}
|
|
196
|
+
|
|
131
197
|
export function defaultLoopsDir(agentDir?: string): string {
|
|
132
198
|
if (process.env.PI_LOOPS_DIR) return process.env.PI_LOOPS_DIR;
|
|
133
199
|
const base = agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
@@ -192,7 +258,7 @@ function sessionIdOf(file: string): string | undefined {
|
|
|
192
258
|
try {
|
|
193
259
|
// A regular file, checked before it is opened. Anything else in this directory is not a
|
|
194
260
|
// session — and a fifo named like one never returns from `openSync`, which would hang the
|
|
195
|
-
// scan this is called from, on the
|
|
261
|
+
// scan this is called from, on the scheduler's tick, for ever.
|
|
196
262
|
if (!fs.statSync(file).isFile()) return undefined;
|
|
197
263
|
const fd = fs.openSync(file, "r");
|
|
198
264
|
try {
|
|
@@ -261,16 +327,18 @@ export class JobStore {
|
|
|
261
327
|
return dir;
|
|
262
328
|
}
|
|
263
329
|
|
|
264
|
-
/** Keep
|
|
265
|
-
pruneSessions(jobId: string, keep = SESSIONS_KEPT_PER_JOB): void {
|
|
330
|
+
/** Keep the newest `keep` transcripts and up to `PINNED_TRANSCRIPTS_MAX` pinned files. Best effort. */
|
|
331
|
+
pruneSessions(jobId: string, keep = SESSIONS_KEPT_PER_JOB, pinned?: ReadonlySet<string>): void {
|
|
266
332
|
const dir = path.join(this.sessionsDir, jobId);
|
|
267
333
|
try {
|
|
268
334
|
const files = fs
|
|
269
335
|
.readdirSync(dir)
|
|
270
336
|
.filter((f) => f.endsWith(".jsonl"))
|
|
271
|
-
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
337
|
+
.map((f) => ({ file: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
272
338
|
.sort((a, b) => b.mtime - a.mtime);
|
|
273
|
-
|
|
339
|
+
const protectedFiles = new Set(files.filter(({ file }) => pinned?.has(path.resolve(file))).slice(0, PINNED_TRANSCRIPTS_MAX).map(({ file }) => file));
|
|
340
|
+
const newest = new Set(files.slice(0, keep).map(({ file }) => file));
|
|
341
|
+
for (const { file } of files) if (!protectedFiles.has(file) && !newest.has(file)) fs.rmSync(file, { force: true });
|
|
274
342
|
} catch {
|
|
275
343
|
/* nothing to prune */
|
|
276
344
|
}
|
|
@@ -296,7 +364,12 @@ export class JobStore {
|
|
|
296
364
|
// An existing but empty file is damage (a torn write, an external truncation), not "no jobs".
|
|
297
365
|
// Returning [] here would let the next tick's write erase every job with no error anywhere.
|
|
298
366
|
if (!text.trim()) throw new Error(`${this.jobsFile} is empty; restore it from ${this.jobsFile}.bak or delete it to start over`);
|
|
299
|
-
|
|
367
|
+
let parsed: JobsFile;
|
|
368
|
+
try {
|
|
369
|
+
parsed = JSON.parse(text) as JobsFile;
|
|
370
|
+
} catch (err: any) {
|
|
371
|
+
throw new Error(`${this.jobsFile}: ${err?.message ?? err}`);
|
|
372
|
+
}
|
|
300
373
|
// A file stamped by a newer pi-loops may carry fields this build does not know and would
|
|
301
374
|
// drop on its next write. Refuse it instead: two versions sharing a $HOME must not silently
|
|
302
375
|
// downgrade each other's jobs.
|