@hank-warren/pi-loop 0.4.0 → 0.5.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 +21 -0
- package/README.md +155 -19
- package/package.json +4 -2
- package/src/ack.ts +67 -0
- package/src/command.ts +92 -33
- package/src/complete-tool.ts +120 -13
- package/src/decide.ts +111 -22
- package/src/errors.ts +131 -0
- package/src/index.ts +95 -2
- package/src/ledger.ts +230 -0
- package/src/loop.ts +842 -40
- package/src/manager.ts +55 -29
- package/src/markers.ts +24 -3
- package/src/messages.ts +164 -8
- package/src/objective.ts +29 -2
- package/src/render.ts +25 -2
- package/src/safety.ts +98 -0
- package/src/schedule/command.ts +255 -0
- package/src/schedule/cron.ts +182 -0
- package/src/schedule/manager.ts +129 -0
- package/src/schedule/model.ts +237 -0
- package/src/schedule/runner.ts +351 -0
- package/src/schedule/store.ts +183 -0
- package/src/settings.ts +27 -1
- package/src/state.ts +80 -1
- package/src/wait-tool.ts +114 -0
- package/src/wait.ts +95 -0
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop ledger: `~/.pi/agent/loop/<loop-id>/`.
|
|
3
|
+
*
|
|
4
|
+
* A multi-day loop cannot keep its state in the conversation — compaction is
|
|
5
|
+
* lossy by construction, and every summary of a summary drifts further from
|
|
6
|
+
* what actually happened. So the conversation stays the working memory and
|
|
7
|
+
* two files on disk become the record:
|
|
8
|
+
*
|
|
9
|
+
* - `criteria.json` — the completion criteria, written by the extension. JSON
|
|
10
|
+
* deliberately, not Markdown: models rewrite prose they are asked to
|
|
11
|
+
* maintain far more readily than they rewrite a structured file, and the
|
|
12
|
+
* only edit this file may receive is flipping `passes`.
|
|
13
|
+
* - `PROGRESS.md` — the agent-maintained ledger, created here with a fixed
|
|
14
|
+
* schema so "update the ledger" means the same thing on every turn.
|
|
15
|
+
*
|
|
16
|
+
* Keyed by **loop id**, not session id: session ids are not stably exposed to
|
|
17
|
+
* extensions, and one session can run several loops in sequence.
|
|
18
|
+
*
|
|
19
|
+
* Every operation here is best-effort. A read-only home directory, a full
|
|
20
|
+
* disk, or a file the user hand-edited into invalid JSON must degrade the
|
|
21
|
+
* loop to "no ledger", never break it: the ledger is an anchor for the model,
|
|
22
|
+
* not a dependency of the engine.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
|
|
29
|
+
export const LEDGER_DIR_NAME = "loop";
|
|
30
|
+
export const CRITERIA_FILE = "criteria.json";
|
|
31
|
+
export const PROGRESS_FILE = "PROGRESS.md";
|
|
32
|
+
|
|
33
|
+
/** Cap on derived criteria: an objective is a paragraph, not a backlog. */
|
|
34
|
+
const MAX_CRITERIA = 12;
|
|
35
|
+
const MAX_DESCRIPTION_LENGTH = 500;
|
|
36
|
+
|
|
37
|
+
export interface LoopCriterion {
|
|
38
|
+
id: string;
|
|
39
|
+
description: string;
|
|
40
|
+
/**
|
|
41
|
+
* How the criterion is verified. Empty means "audit against authoritative
|
|
42
|
+
* current state"; the extension writes this field and the model may not
|
|
43
|
+
* change it.
|
|
44
|
+
*/
|
|
45
|
+
check: string;
|
|
46
|
+
passes: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
|
|
50
|
+
return join(agentDir, LEDGER_DIR_NAME, loopId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split an objective into checkable criteria.
|
|
55
|
+
*
|
|
56
|
+
* Deterministic and dumb on purpose: bullets first (a user who wrote a list
|
|
57
|
+
* meant a list), otherwise sentences. An objective with no separable parts
|
|
58
|
+
* yields the single implicit criterion, so `criteria.json` is never empty and
|
|
59
|
+
* `loop_complete` always has something concrete to answer for.
|
|
60
|
+
*/
|
|
61
|
+
export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
62
|
+
const trimmed = objective.trim();
|
|
63
|
+
if (!trimmed) return [implicitCriterion(objective)];
|
|
64
|
+
const bullets = trimmed
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((line) => line.trim())
|
|
67
|
+
.filter((line) => /^([-*+]|\d+[.)])\s+/.test(line))
|
|
68
|
+
.map((line) => line.replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
|
|
71
|
+
if (parts.length < 2) return [implicitCriterion(trimmed)];
|
|
72
|
+
return parts.slice(0, MAX_CRITERIA).map((description, index) => ({
|
|
73
|
+
id: `c${index + 1}`,
|
|
74
|
+
description: truncate(description),
|
|
75
|
+
check: "",
|
|
76
|
+
passes: false,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function implicitCriterion(objective: string): LoopCriterion {
|
|
81
|
+
return {
|
|
82
|
+
id: "c1",
|
|
83
|
+
description: truncate(objective.trim()) || "objective met as stated",
|
|
84
|
+
check: "",
|
|
85
|
+
passes: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Sentence split that survives how objectives are actually typed: mostly
|
|
91
|
+
* lowercase, occasionally with an abbreviation in the middle. Splitting on
|
|
92
|
+
* any letter after a full stop would turn "e.g. run the tests" into two
|
|
93
|
+
* criteria, so a fragment following a known abbreviation is merged back.
|
|
94
|
+
*/
|
|
95
|
+
const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|no|fig|dr|mr|ms|mrs|st)\.$/iu;
|
|
96
|
+
|
|
97
|
+
function splitSentences(text: string): string[] {
|
|
98
|
+
const parts = text
|
|
99
|
+
.split(/(?<=[.!?])\s+(?=[\p{L}\d])/u)
|
|
100
|
+
.map((sentence) => sentence.trim().replace(/\s+/gu, " "))
|
|
101
|
+
.filter((sentence) => sentence.length > 2);
|
|
102
|
+
const merged: string[] = [];
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const previous = merged.at(-1);
|
|
105
|
+
if (previous !== undefined && ABBREVIATION.test(previous)) {
|
|
106
|
+
merged[merged.length - 1] = `${previous} ${part}`;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
merged.push(part);
|
|
110
|
+
}
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function truncate(value: string): string {
|
|
115
|
+
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
116
|
+
return collapsed.length <= MAX_DESCRIPTION_LENGTH
|
|
117
|
+
? collapsed
|
|
118
|
+
: `${collapsed.slice(0, MAX_DESCRIPTION_LENGTH - 1)}…`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface LedgerPaths {
|
|
122
|
+
dir: string;
|
|
123
|
+
criteria: string;
|
|
124
|
+
progress: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function ledgerPaths(loopId: string, agentDir?: string): LedgerPaths {
|
|
128
|
+
const dir = loopLedgerDir(loopId, agentDir);
|
|
129
|
+
return { dir, criteria: join(dir, CRITERIA_FILE), progress: join(dir, PROGRESS_FILE) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create the ledger for a loop. Returns the failure reason, or undefined on
|
|
134
|
+
* success — the caller warns once and carries on either way.
|
|
135
|
+
*
|
|
136
|
+
* `criteria.json` is authoritative and overwritten on start (a new loop has
|
|
137
|
+
* new criteria). `PROGRESS.md` is only ever created, never overwritten: it is
|
|
138
|
+
* the agent's file, and a session restart must not erase days of ledger.
|
|
139
|
+
*/
|
|
140
|
+
export function createLedger(
|
|
141
|
+
paths: LedgerPaths,
|
|
142
|
+
objective: string,
|
|
143
|
+
criteria: LoopCriterion[],
|
|
144
|
+
): string | undefined {
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
147
|
+
writeFileSync(paths.criteria, `${JSON.stringify(criteria, null, 2)}\n`, "utf8");
|
|
148
|
+
try {
|
|
149
|
+
writeFileSync(paths.progress, progressTemplate(objective), { encoding: "utf8", flag: "wx" });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// EEXIST is the normal case on restore: keep the existing ledger.
|
|
152
|
+
if (!isNodeError(error) || error.code !== "EEXIST") throw error;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return formatError(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function progressTemplate(objective: string): string {
|
|
161
|
+
return [
|
|
162
|
+
"# Loop progress ledger",
|
|
163
|
+
"",
|
|
164
|
+
`Objective: ${objective.replace(/\s+/gu, " ").trim()}`,
|
|
165
|
+
"",
|
|
166
|
+
"Maintained by the agent. Keep these four sections; replace their contents.",
|
|
167
|
+
"",
|
|
168
|
+
"## Current status",
|
|
169
|
+
"",
|
|
170
|
+
"Not started.",
|
|
171
|
+
"",
|
|
172
|
+
"## Completed",
|
|
173
|
+
"",
|
|
174
|
+
"- (nothing yet)",
|
|
175
|
+
"",
|
|
176
|
+
"## Failed approaches and why",
|
|
177
|
+
"",
|
|
178
|
+
"- (nothing yet)",
|
|
179
|
+
"",
|
|
180
|
+
"## Next actions",
|
|
181
|
+
"",
|
|
182
|
+
"- (nothing yet)",
|
|
183
|
+
"",
|
|
184
|
+
].join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read the criteria back, fail-open: undefined when absent or unreadable. */
|
|
188
|
+
export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
|
|
189
|
+
let contents: string;
|
|
190
|
+
try {
|
|
191
|
+
contents = readFileSync(paths.criteria, "utf8");
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const parsed: unknown = JSON.parse(contents);
|
|
197
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
198
|
+
const criteria: LoopCriterion[] = [];
|
|
199
|
+
for (const value of parsed) {
|
|
200
|
+
const criterion = normalizeCriterion(value);
|
|
201
|
+
if (!criterion) return undefined;
|
|
202
|
+
criteria.push(criterion);
|
|
203
|
+
}
|
|
204
|
+
return criteria.length > 0 ? criteria : undefined;
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeCriterion(value: unknown): LoopCriterion | undefined {
|
|
211
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
212
|
+
const record = value as Record<string, unknown>;
|
|
213
|
+
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
214
|
+
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
215
|
+
if (!id || !description) return undefined;
|
|
216
|
+
return {
|
|
217
|
+
id,
|
|
218
|
+
description,
|
|
219
|
+
check: typeof record.check === "string" ? record.check : "",
|
|
220
|
+
passes: record.passes === true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
225
|
+
return error instanceof Error && "code" in error;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatError(error: unknown): string {
|
|
229
|
+
return error instanceof Error ? error.message : String(error);
|
|
230
|
+
}
|