@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/manager.ts
CHANGED
|
@@ -159,7 +159,9 @@ export async function showLoopSettings(
|
|
|
159
159
|
for (;;) {
|
|
160
160
|
const s = controller.settings;
|
|
161
161
|
const items = [
|
|
162
|
-
`Max
|
|
162
|
+
`Max wakes: ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
|
|
163
|
+
`Max automatic turns: ${s.automaticTurns === null ? "Unlimited" : s.automaticTurns}`,
|
|
164
|
+
`No-progress breaker: ${s.noProgressTurns === null ? "Off" : `after ${s.noProgressTurns} repeats`}`,
|
|
163
165
|
`Max loop duration: ${s.maxLoopDuration}`,
|
|
164
166
|
`Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
|
|
165
167
|
];
|
|
@@ -170,34 +172,23 @@ export async function showLoopSettings(
|
|
|
170
172
|
if (index === 0) {
|
|
171
173
|
// Unlimited is a first-class choice, not a magic word typed into a free
|
|
172
174
|
// text box: it is only reachable by discovery otherwise.
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
`Max iterations · currently ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
|
|
177
|
-
[SET_NUMBER, UNLIMITED],
|
|
178
|
-
);
|
|
179
|
-
if (capChoice === undefined) continue;
|
|
180
|
-
if (capChoice === UNLIMITED) next.maxIterations = null;
|
|
181
|
-
else {
|
|
182
|
-
const value = await ctx.ui.input(
|
|
183
|
-
"Max iterations (positive whole number)",
|
|
184
|
-
s.maxIterations === null ? "25" : `${s.maxIterations}`,
|
|
185
|
-
);
|
|
186
|
-
if (value === undefined) continue;
|
|
187
|
-
const trimmed = value.trim();
|
|
188
|
-
// Keep honouring the typed word so muscle memory and the /loop --max
|
|
189
|
-
// vocabulary still work.
|
|
190
|
-
if (trimmed === "unlimited") next.maxIterations = null;
|
|
191
|
-
else {
|
|
192
|
-
const parsed = Number(trimmed);
|
|
193
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
194
|
-
ctx.ui.notify(`Invalid value: ${value}.`, "error");
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
next.maxIterations = parsed;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
175
|
+
const cap = await editCap(ctx, "Max wakes", "no wake cap", s.maxIterations);
|
|
176
|
+
if (cap === undefined) continue;
|
|
177
|
+
next.maxIterations = cap === "unlimited" ? null : cap;
|
|
200
178
|
} else if (index === 1) {
|
|
179
|
+
const cap = await editCap(ctx, "Max automatic turns", "no turn cap", s.automaticTurns);
|
|
180
|
+
if (cap === undefined) continue;
|
|
181
|
+
next.automaticTurns = cap === "unlimited" ? null : cap;
|
|
182
|
+
} else if (index === 2) {
|
|
183
|
+
const cap = await editCap(
|
|
184
|
+
ctx,
|
|
185
|
+
"No-progress breaker",
|
|
186
|
+
"never pause for repeated answers",
|
|
187
|
+
s.noProgressTurns,
|
|
188
|
+
);
|
|
189
|
+
if (cap === undefined) continue;
|
|
190
|
+
next.noProgressTurns = cap === "unlimited" ? null : cap;
|
|
191
|
+
} else if (index === 3) {
|
|
201
192
|
const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
|
|
202
193
|
if (value === undefined) continue;
|
|
203
194
|
if (parseDuration(value.trim()) === undefined) {
|
|
@@ -205,7 +196,7 @@ export async function showLoopSettings(
|
|
|
205
196
|
continue;
|
|
206
197
|
}
|
|
207
198
|
next.maxLoopDuration = value.trim();
|
|
208
|
-
} else if (index ===
|
|
199
|
+
} else if (index === 4) {
|
|
209
200
|
if (s.compaction.enabled) next.compaction.enabled = false;
|
|
210
201
|
else {
|
|
211
202
|
const value = await ctx.ui.input(
|
|
@@ -229,6 +220,41 @@ export async function showLoopSettings(
|
|
|
229
220
|
}
|
|
230
221
|
}
|
|
231
222
|
|
|
223
|
+
/**
|
|
224
|
+
* One cap editor for both counters. Unlimited is a first-class choice, not a
|
|
225
|
+
* magic word typed into a free text box: it is only reachable by discovery
|
|
226
|
+
* otherwise. The typed word still works, so the /loop --max vocabulary and
|
|
227
|
+
* muscle memory keep working.
|
|
228
|
+
*/
|
|
229
|
+
async function editCap(
|
|
230
|
+
ctx: ExtensionCommandContext,
|
|
231
|
+
label: string,
|
|
232
|
+
unlimitedNote: string,
|
|
233
|
+
current: number | null,
|
|
234
|
+
): Promise<number | "unlimited" | undefined> {
|
|
235
|
+
const SET_NUMBER = "Set a number…";
|
|
236
|
+
const UNLIMITED = `Unlimited (${unlimitedNote})`;
|
|
237
|
+
const choice = await ctx.ui.select(
|
|
238
|
+
`${label} · currently ${current === null ? "Unlimited" : current}`,
|
|
239
|
+
[SET_NUMBER, UNLIMITED],
|
|
240
|
+
);
|
|
241
|
+
if (choice === undefined) return undefined;
|
|
242
|
+
if (choice === UNLIMITED) return "unlimited";
|
|
243
|
+
const value = await ctx.ui.input(
|
|
244
|
+
`${label} (positive whole number)`,
|
|
245
|
+
current === null ? "25" : `${current}`,
|
|
246
|
+
);
|
|
247
|
+
if (value === undefined) return undefined;
|
|
248
|
+
const trimmed = value.trim();
|
|
249
|
+
if (trimmed === "unlimited") return "unlimited";
|
|
250
|
+
const parsed = Number(trimmed);
|
|
251
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
252
|
+
ctx.ui.notify(`Invalid value: ${value}.`, "error");
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
return parsed;
|
|
256
|
+
}
|
|
257
|
+
|
|
232
258
|
function applySettings(
|
|
233
259
|
controller: LoopController,
|
|
234
260
|
ctx: ExtensionCommandContext,
|
package/src/markers.ts
CHANGED
|
@@ -11,15 +11,28 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const POKE_MARKER_PREFIX = "pi-loop-poke:";
|
|
14
|
+
const CONTINUATION_MARKER_PREFIX = "pi-loop-continuation:";
|
|
14
15
|
|
|
15
|
-
const POKE_MARKER_PATTERN =
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const POKE_MARKER_PATTERN = markerPattern(POKE_MARKER_PREFIX);
|
|
17
|
+
const CONTINUATION_MARKER_PATTERN = markerPattern(CONTINUATION_MARKER_PREFIX);
|
|
18
|
+
|
|
19
|
+
function markerPattern(prefix: string) {
|
|
20
|
+
return new RegExp(`<!--\\s*${escapeRegExpText(prefix)}([^\\s:>]+):(\\d+)\\s*-->`);
|
|
21
|
+
}
|
|
18
22
|
|
|
19
23
|
export function appendPokeMarker(prompt: string, loopId: string, iteration: number): string {
|
|
20
24
|
return `${prompt}\n\n<!-- ${POKE_MARKER_PREFIX}${loopId}:${iteration} -->`;
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Settle-driven continuations carry their own marker so the transcript, the
|
|
29
|
+
* model, and sibling extensions can tell a continuation apart from a
|
|
30
|
+
* fallback wake — they mean different things about why the loop is talking.
|
|
31
|
+
*/
|
|
32
|
+
export function appendContinuationMarker(prompt: string, loopId: string, turn: number): string {
|
|
33
|
+
return `${prompt}\n\n<!-- ${CONTINUATION_MARKER_PREFIX}${loopId}:${turn} -->`;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export function extractPokeMarker(
|
|
24
37
|
prompt: string,
|
|
25
38
|
): { loopId: string; iteration: number } | undefined {
|
|
@@ -28,6 +41,14 @@ export function extractPokeMarker(
|
|
|
28
41
|
return { loopId: match[1], iteration: Number(match[2]) };
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
export function extractContinuationMarker(
|
|
45
|
+
prompt: string,
|
|
46
|
+
): { loopId: string; turn: number } | undefined {
|
|
47
|
+
const match = CONTINUATION_MARKER_PATTERN.exec(prompt);
|
|
48
|
+
if (!match || match[1] === undefined || match[2] === undefined) return undefined;
|
|
49
|
+
return { loopId: match[1], turn: Number(match[2]) };
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
function escapeRegExpText(value: string) {
|
|
32
53
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
54
|
}
|
package/src/messages.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* issue #50554).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { LOOP_OK_TOKEN } from "./ack.js";
|
|
9
10
|
import { formatDuration } from "./interval.js";
|
|
10
|
-
import {
|
|
11
|
+
import { CRITERIA_FILE, type LedgerPaths, PROGRESS_FILE } from "./ledger.js";
|
|
12
|
+
import { appendContinuationMarker, appendPokeMarker } from "./markers.js";
|
|
11
13
|
import type { GoalSnapshot, LoopState } from "./state.js";
|
|
12
14
|
|
|
15
|
+
/** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
|
|
16
|
+
export type ContinuationKind = "kickoff" | "continue" | "reanchor";
|
|
17
|
+
|
|
13
18
|
function formatIteration(loop: LoopState): string {
|
|
14
19
|
const cap = loop.maxIterations === null ? "unlimited" : `${loop.maxIterations}`;
|
|
15
20
|
return `${loop.iteration + 1}/${cap}`;
|
|
@@ -41,25 +46,172 @@ export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-wa
|
|
|
41
46
|
* the system prompt of every turn, so restating them here would store a
|
|
42
47
|
* duplicate copy on every wake.
|
|
43
48
|
*/
|
|
44
|
-
export function buildObjectivePoke(
|
|
49
|
+
export function buildObjectivePoke(
|
|
50
|
+
loop: LoopState,
|
|
51
|
+
reason: "objective-stalled" | "wait-elapsed" = "objective-stalled",
|
|
52
|
+
): string {
|
|
45
53
|
const lines = [
|
|
46
54
|
`Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
47
|
-
|
|
55
|
+
reason === "wait-elapsed"
|
|
56
|
+
? "The wait you asked for has elapsed. Re-check the external state it depended on and continue — the objective and loop-mode rules are in the system prompt."
|
|
57
|
+
: "The session went idle but the loop objective's completion criteria are not met. Continue working it — the objective and loop-mode rules are in the system prompt.",
|
|
48
58
|
];
|
|
59
|
+
// The no-op acknowledgement: a wake with nothing to do should cost a token,
|
|
60
|
+
// not a paragraph, and it gives the engine a deterministic "that wake was
|
|
61
|
+
// wasted" signal to back the heartbeat off with.
|
|
62
|
+
lines.push(`If nothing needs attention, reply ${LOOP_OK_TOKEN} and stop.`);
|
|
63
|
+
if (reason === "wait-elapsed" && loop.waiting) {
|
|
64
|
+
lines.push("", `Elapsed wait: ${loop.waiting.reason}`);
|
|
65
|
+
}
|
|
66
|
+
addCancelledWaitHint(lines, loop);
|
|
49
67
|
if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
|
|
50
68
|
return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
72
|
+
* A wait cancelled by something other than its own deadline — a user message,
|
|
73
|
+
* or another wake that arrived first — still knows something the next turn
|
|
74
|
+
* needs: what the loop thought it was waiting for. There is no cancel tool to
|
|
75
|
+
* report it, so the hint rides along once on the next message and is then
|
|
76
|
+
* dropped.
|
|
77
|
+
*/
|
|
78
|
+
function addCancelledWaitHint(lines: string[], loop: LoopState): void {
|
|
79
|
+
if (!loop.cancelledWaitReason) return;
|
|
80
|
+
lines.push("", `Previous wait (cancelled): ${loop.cancelledWaitReason}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The settle-driven continuation: the message that actually paces a
|
|
85
|
+
* standalone loop now. Pointer-sized for the same reason the pokes are — it
|
|
86
|
+
* only ever fires while the loop is active, so the byte-stable system append
|
|
87
|
+
* carrying the objective and loop-mode rules is guaranteed present on that
|
|
88
|
+
* turn.
|
|
89
|
+
*
|
|
90
|
+
* `kind` distinguishes the very first dispatch (the immediate kickoff turn a
|
|
91
|
+
* `/loop` start fires before any interval elapses) from the ordinary
|
|
92
|
+
* continuation, because the first one is not a "continue" at all.
|
|
93
|
+
*/
|
|
94
|
+
export function buildContinuation(
|
|
95
|
+
loop: LoopState,
|
|
96
|
+
kind: ContinuationKind,
|
|
97
|
+
/** Next actions lifted out of the compaction summary, for a re-anchor. */
|
|
98
|
+
nextActions?: string,
|
|
99
|
+
): string {
|
|
100
|
+
const lines =
|
|
101
|
+
kind === "kickoff"
|
|
102
|
+
? [
|
|
103
|
+
"Loop started. Begin working the loop objective in the system prompt now, from the authoritative current state.",
|
|
104
|
+
]
|
|
105
|
+
: kind === "reanchor"
|
|
106
|
+
? [
|
|
107
|
+
`The conversation was compacted mid-loop. Re-read ${PROGRESS_FILE} and ${CRITERIA_FILE} in the loop ledger before acting; the objective is in the system prompt. Continue from the authoritative current state, not from the summary.`,
|
|
108
|
+
...(nextActions ? ["", `Carried next actions: ${nextActions}`] : []),
|
|
109
|
+
]
|
|
110
|
+
: [
|
|
111
|
+
`Automatic loop continuation #${loop.automaticTurns + 1} — the objective's completion criteria are not met. Continue working it from the authoritative current state; the objective and loop-mode rules are in the system prompt.`,
|
|
112
|
+
];
|
|
113
|
+
addCancelledWaitHint(lines, loop);
|
|
114
|
+
if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
|
|
115
|
+
return appendContinuationMarker(lines.join("\n"), loop.id, loop.automaticTurns + 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The expiry's final wake.
|
|
120
|
+
*
|
|
121
|
+
* A loop that simply vanished at its deadline would leave its most recent
|
|
122
|
+
* state only in a conversation that is about to be closed or compacted. So
|
|
123
|
+
* expiry buys one last turn whose only job is to write the state down, and
|
|
124
|
+
* the message says exactly that: no new work, no completion claim.
|
|
125
|
+
*/
|
|
126
|
+
export function buildExpiryWake(loop: LoopState, ledger?: LedgerPaths): string {
|
|
127
|
+
const lines = [
|
|
128
|
+
"This loop has reached its expiry and is stopping after this turn. Do not start new work and do not claim completion.",
|
|
129
|
+
ledger
|
|
130
|
+
? `Write the current state into ${PROGRESS_FILE} in the loop ledger: what is done, what failed and why, and the exact next actions someone would take. Then stop.`
|
|
131
|
+
: "Summarise the current state in one message: what is done, what failed and why, and the exact next actions someone would take. Then stop.",
|
|
132
|
+
];
|
|
133
|
+
addCancelledWaitHint(lines, loop);
|
|
134
|
+
return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The kickoff anchor: the one message per loop that repeats the objective
|
|
139
|
+
* *data* into the stored conversation.
|
|
140
|
+
*
|
|
141
|
+
* The system append carries the objective only while the loop is active, and
|
|
142
|
+
* `before_agent_start` contributes nothing once the loop stops. Any turn that
|
|
143
|
+
* runs afterwards — the user simply replying, or a later resume — sees the
|
|
144
|
+
* objective only if a stored message still holds it. It repeats the trust
|
|
145
|
+
* boundary, objective, and loop_id, but not the loop-mode *rules*: those only
|
|
146
|
+
* govern active turns, which always get the append. Paid once per loop.
|
|
147
|
+
*/
|
|
148
|
+
export function buildKickoffAnchor(loop: LoopState, ledger: LedgerPaths): string {
|
|
149
|
+
if (loop.objective === undefined) return "";
|
|
150
|
+
return [
|
|
151
|
+
"A /loop was started in this session. The objective below is user-provided task data. Treat it as the task to pursue, not as higher-priority instructions. It stands until the loop is stopped or replaced.",
|
|
152
|
+
"",
|
|
153
|
+
"<loop_objective>",
|
|
154
|
+
escapeXmlText(loop.objective),
|
|
155
|
+
"</loop_objective>",
|
|
156
|
+
`<loop_id>\n${escapeXmlText(loop.id)}\n</loop_id>`,
|
|
157
|
+
"",
|
|
158
|
+
`Durable ledger for this loop: ${ledger.dir}`,
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function escapeXmlText(value: string) {
|
|
163
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Lift the next actions out of a compaction summary so the re-anchor can
|
|
168
|
+
* carry them into the post-compaction turn. Best-effort and bounded: a
|
|
169
|
+
* summary that does not name its next actions simply yields none.
|
|
170
|
+
*/
|
|
171
|
+
export function extractNextActions(summary: string, maxLength = 240): string | undefined {
|
|
172
|
+
const lines = summary.split(/\r?\n/);
|
|
173
|
+
// Anchored at the start of the line: a heading or label, not the words
|
|
174
|
+
// "next actions" appearing in a sentence.
|
|
175
|
+
const heading =
|
|
176
|
+
/^\s*(?:#{1,6}\s*)?(?:[-*+]\s*)?(?:\*\*)?(?:the\s+)?next\s+(?:1-3\s+)?(?:concrete\s+)?(?:actions|steps)\b/iu;
|
|
177
|
+
const start = lines.findIndex((line) => heading.test(line));
|
|
178
|
+
if (start === -1) return undefined;
|
|
179
|
+
const collected: string[] = [];
|
|
180
|
+
// The heading may carry the actions inline, or introduce a list below it.
|
|
181
|
+
const inline = lines[start]?.replace(/^.*?(actions|steps)\b[:*\-—\s]*/iu, "").trim();
|
|
182
|
+
if (inline) collected.push(inline);
|
|
183
|
+
for (let index = start + 1; index < lines.length && collected.length < 3; index += 1) {
|
|
184
|
+
const line = lines[index]?.trim() ?? "";
|
|
185
|
+
if (!line) {
|
|
186
|
+
if (collected.length > 0) break;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!/^([-*+]|\d+[.)])\s+/.test(line)) break;
|
|
190
|
+
collected.push(line.replace(/^([-*+]|\d+[.)])\s+/, "").trim());
|
|
191
|
+
}
|
|
192
|
+
if (collected.length === 0) return undefined;
|
|
193
|
+
const joined = collected.join("; ").replace(/\s+/gu, " ").trim();
|
|
194
|
+
return joined.length <= maxLength ? joined : `${joined.slice(0, maxLength - 1)}…`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Instructions for the loop-owned proactive compaction.
|
|
199
|
+
*
|
|
200
|
+
* Deliberately *not* cumulative any more. Carrying every prior summary
|
|
201
|
+
* forward makes each compaction a summary of summaries: the text grows while
|
|
202
|
+
* the information in it decays, and the model starts trusting the narrative
|
|
203
|
+
* over the world. The ledger on disk is the record now, so the summary's job
|
|
204
|
+
* is to hand over the live working state and point at the ledger — and the
|
|
205
|
+
* one thing that must never be lost, because it is nowhere else, is which
|
|
206
|
+
* approaches were already tried and *why they failed*.
|
|
57
207
|
*/
|
|
58
208
|
export function buildCompactionInstructions(
|
|
59
209
|
loop: LoopState,
|
|
60
210
|
/** The goal only when it is still active; a finished goal is not the objective. */
|
|
61
211
|
goal: GoalSnapshot | undefined,
|
|
62
212
|
override: string | null,
|
|
213
|
+
/** The loop's ledger, when it has one. */
|
|
214
|
+
ledger?: LedgerPaths,
|
|
63
215
|
): string {
|
|
64
216
|
if (override) return override;
|
|
65
217
|
const objective = goal
|
|
@@ -73,11 +225,15 @@ export function buildCompactionInstructions(
|
|
|
73
225
|
`${objective}`,
|
|
74
226
|
"This summary must let that work continue seamlessly. Preserve verbatim:",
|
|
75
227
|
"- the current objective and its acceptance criteria",
|
|
76
|
-
"-
|
|
228
|
+
"- every approach already tried that failed, and the reason it failed (this is the one thing no file records; it must not be retried)",
|
|
229
|
+
"- decisions made and their rationale",
|
|
77
230
|
"- exact files modified and what remains to be done",
|
|
78
231
|
"- exact commands run, their results, and any unresolved errors",
|
|
79
232
|
"- the next 1-3 concrete actions",
|
|
80
|
-
|
|
233
|
+
...(ledger
|
|
234
|
+
? [`The loop keeps a durable ledger at ${ledger.dir}; the next turn re-reads it.`]
|
|
235
|
+
: []),
|
|
236
|
+
"Re-derive the current status from that ledger and from authoritative state (the worktree, git, command output) rather than from any previous summary. Do not carry previous compaction summaries forward wholesale: restate only what is still true.",
|
|
81
237
|
"Discard raw tool output, file contents that live on disk, and duplicate exploration.",
|
|
82
238
|
].join("\n");
|
|
83
239
|
}
|
package/src/objective.ts
CHANGED
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
* edit, stop.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { CRITERIA_FILE, type LedgerPaths, PROGRESS_FILE } from "./ledger.js";
|
|
20
21
|
import type { LoopState } from "./state.js";
|
|
21
22
|
|
|
22
|
-
export function buildLoopObjectivePrompt(
|
|
23
|
+
export function buildLoopObjectivePrompt(
|
|
24
|
+
loop: LoopState,
|
|
25
|
+
/** The loop's ledger; omitted when it could not be created. */
|
|
26
|
+
ledger?: LedgerPaths,
|
|
27
|
+
): string | undefined {
|
|
23
28
|
if (loop.objective === undefined) return undefined;
|
|
24
29
|
const focus = loop.prompt ? `\n\nRecurring focus for every wake:\n${escapeXmlText(loop.prompt)}` : "";
|
|
25
30
|
return [
|
|
@@ -36,14 +41,36 @@ export function buildLoopObjectivePrompt(loop: LoopState): string | undefined {
|
|
|
36
41
|
"- A scheduled wake means the session went idle with this objective unfinished. Continue working it from the authoritative current state.",
|
|
37
42
|
"- Treat the current worktree, command output, tests, and runtime behavior as authoritative. Previous conversation and summaries are context, not proof.",
|
|
38
43
|
"- Do not stop at analysis, a plan, or suggested next steps; do the work.",
|
|
39
|
-
"-
|
|
44
|
+
"- Before completion, treat completion as unproven and audit requirement by requirement. For every criterion, artifact, command, test, and deliverable, inspect authoritative evidence and match verification scope to requirement scope.",
|
|
45
|
+
"- Weak, indirect, missing, or merely consistent evidence is not enough; gather stronger evidence and keep working.",
|
|
46
|
+
"- Effort exhaustion is not completion. Running long, running out of ideas, or approaching a cap is never a reason to call loop_complete.",
|
|
47
|
+
"- Call loop_complete with this exact loop_id only when every completion criterion is proven, passing one cited piece of evidence per criterion id. It stops the wakeups; it does not assert that unrelated work is finished.",
|
|
40
48
|
"- If the criteria are not met, keep working and expect another wake.",
|
|
49
|
+
...(ledger ? ledgerRules(ledger) : []),
|
|
41
50
|
`${focus}`,
|
|
42
51
|
]
|
|
43
52
|
.join("\n")
|
|
44
53
|
.trimEnd();
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The ledger contract. Stable per loop (the path is derived from the loop
|
|
58
|
+
* id), so it keeps the append byte-identical across turns.
|
|
59
|
+
*
|
|
60
|
+
* `criteria.json` is deliberately narrow: the model may flip `passes` and
|
|
61
|
+
* nothing else. A model allowed to rewrite its own acceptance criteria will
|
|
62
|
+
* eventually rewrite them into something it has already achieved.
|
|
63
|
+
*/
|
|
64
|
+
function ledgerRules(ledger: LedgerPaths): string[] {
|
|
65
|
+
return [
|
|
66
|
+
"",
|
|
67
|
+
`Loop ledger (durable state for this loop, at ${ledger.dir}):`,
|
|
68
|
+
`- ${PROGRESS_FILE} is yours to maintain. Update it as you work, keeping its four sections: current status, completed, failed approaches and why, next actions. Record failures and their reasons — nothing else remembers them once the conversation is compacted.`,
|
|
69
|
+
`- ${CRITERIA_FILE} holds this loop's completion criteria. You may change only the \`passes\` field of an entry, and only when authoritative evidence proves that criterion, citing the evidence in ${PROGRESS_FILE}. Never edit an id, description, or check, and never add or remove entries.`,
|
|
70
|
+
"- After a compaction, re-read both files before acting. They are the record; a summary is not.",
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
function escapeXmlText(value: string) {
|
|
48
75
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
49
76
|
}
|
package/src/render.ts
CHANGED
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
13
|
+
import { parseLoopOkAck } from "./ack.js";
|
|
14
|
+
import { extractContinuationMarker, extractPokeMarker } from "./markers.js";
|
|
14
15
|
|
|
15
16
|
const POKE_HEAD_PATTERN = /^Scheduled loop wakeup (\S+) \(every ([^)]+)\)\./u;
|
|
16
17
|
const POKE_FOCUS_PATTERN = /^Loop focus: (.+)$/mu;
|
|
17
18
|
|
|
18
19
|
export function registerLoopMessageRendering(pi: ExtensionAPI) {
|
|
19
20
|
pi.registerMarkdownTransformer((markdown, { messageType }) => {
|
|
21
|
+
if (messageType === "assistant") return compactAckMessage(markdown) ?? markdown;
|
|
20
22
|
if (messageType !== "user") return markdown;
|
|
21
|
-
return compactPokeMessage(markdown) ?? markdown;
|
|
23
|
+
return compactPokeMessage(markdown) ?? compactContinuationMessage(markdown) ?? markdown;
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -35,3 +37,24 @@ export function compactPokeMessage(markdown: string) {
|
|
|
35
37
|
const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
|
|
36
38
|
return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
|
|
37
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Exported for tests: the acknowledgement chip, or undefined when the reply
|
|
43
|
+
* is an ordinary answer. Display only — the stored message keeps its bytes,
|
|
44
|
+
* because rewriting them would break the prompt cache the whole design is
|
|
45
|
+
* built around.
|
|
46
|
+
*/
|
|
47
|
+
export function compactAckMessage(markdown: string) {
|
|
48
|
+
const ack = parseLoopOkAck(markdown);
|
|
49
|
+
if (!ack) return undefined;
|
|
50
|
+
return `*✓ loop ok${ack.remainder ? ` · ${ack.remainder}` : ""}*`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Exported for tests: the continuation chip, or undefined when not ours. */
|
|
54
|
+
export function compactContinuationMessage(markdown: string) {
|
|
55
|
+
const marker = extractContinuationMarker(markdown);
|
|
56
|
+
if (!marker) return undefined;
|
|
57
|
+
const kind = markdown.startsWith("Loop started.") ? "kickoff" : "continue";
|
|
58
|
+
const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
|
|
59
|
+
return `*⟳ loop ${kind} #${marker.turn}${focus ? ` · ${focus}` : ""}*`;
|
|
60
|
+
}
|
package/src/safety.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The no-progress breaker.
|
|
3
|
+
*
|
|
4
|
+
* An autonomous loop's characteristic failure is not crashing, it is
|
|
5
|
+
* *restating*: the model produces the same paragraph of "here is what I would
|
|
6
|
+
* do next" turn after turn, calling no tools, while the loop dutifully wakes
|
|
7
|
+
* it again. Nothing in the caps catches that quickly enough — 25 identical
|
|
8
|
+
* turns is 25 turns of wasted tokens.
|
|
9
|
+
*
|
|
10
|
+
* So fingerprint the visible assistant text of every tool-free loop-caused
|
|
11
|
+
* turn and count consecutive repeats. Normalisation (NFKC, case, whitespace,
|
|
12
|
+
* control and format characters) exists because "the same answer" from a
|
|
13
|
+
* model is rarely byte-identical.
|
|
14
|
+
*
|
|
15
|
+
* A turn that called *any* tool is progress by definition and resets the
|
|
16
|
+
* counter. That includes a turn that called `loop_wait`: waiting for the
|
|
17
|
+
* world is a decision, not a stall, and counting it was the false positive
|
|
18
|
+
* that made this class of breaker infamous.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
export interface NoProgressState {
|
|
24
|
+
toolFreeRepeatCount: number;
|
|
25
|
+
lastFingerprint?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nextNoProgressState(
|
|
29
|
+
current: NoProgressState,
|
|
30
|
+
messages: readonly unknown[],
|
|
31
|
+
toolAttempted: boolean,
|
|
32
|
+
): NoProgressState {
|
|
33
|
+
if (toolAttempted) return { toolFreeRepeatCount: 0 };
|
|
34
|
+
const fingerprint = fingerprintVisibleAssistantOutput(messages);
|
|
35
|
+
return {
|
|
36
|
+
toolFreeRepeatCount:
|
|
37
|
+
fingerprint === current.lastFingerprint
|
|
38
|
+
? Math.min(Number.MAX_SAFE_INTEGER, current.toolFreeRepeatCount + 1)
|
|
39
|
+
: 1,
|
|
40
|
+
lastFingerprint: fingerprint,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasAssistantToolCall(messages: readonly unknown[]): boolean {
|
|
45
|
+
for (const message of messages) {
|
|
46
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (message.content.some((block) => isRecord(block) && block.type === "toolCall")) return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether the run called a specific tool, e.g. the loop's own `loop_wait`. */
|
|
55
|
+
export function calledTool(messages: readonly unknown[], toolName: string): boolean {
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const block of message.content) {
|
|
61
|
+
if (!isRecord(block) || block.type !== "toolCall") continue;
|
|
62
|
+
if (block.name === toolName || block.toolName === toolName) return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function fingerprintVisibleAssistantOutput(messages: readonly unknown[]): string {
|
|
69
|
+
return createHash("sha256")
|
|
70
|
+
.update(normalizeVisibleAssistantOutput(messages), "utf8")
|
|
71
|
+
.digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeVisibleAssistantOutput(messages: readonly unknown[]): string {
|
|
75
|
+
const text: string[] = [];
|
|
76
|
+
for (const message of messages) {
|
|
77
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const block of message.content) {
|
|
81
|
+
if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
|
|
82
|
+
text.push(block.text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const normalized = text
|
|
86
|
+
.join("\n")
|
|
87
|
+
.normalize("NFKC")
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/\s+/gu, " ")
|
|
90
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
91
|
+
.trim();
|
|
92
|
+
// Empty or punctuation-only output is not a distinguishable answer.
|
|
93
|
+
return normalized === "" || /^[\p{P}\s]+$/u.test(normalized) ? "" : normalized;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
97
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
98
|
+
}
|