@hank-warren/pi-loop 0.5.0 → 0.7.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 +29 -0
- package/README.md +60 -49
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +112 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +56 -15
- package/src/decide.ts +30 -81
- package/src/index.ts +24 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/ledger.ts +17 -4
- package/src/loop.ts +124 -200
- package/src/manager.ts +18 -28
- package/src/messages.ts +21 -38
- package/src/objective.ts +5 -7
- package/src/render.ts +3 -7
- package/src/settings.ts +83 -21
- package/src/start-tool.ts +199 -0
- package/src/state.ts +56 -116
- package/src/wait-tool.ts +2 -2
- package/src/widget.ts +5 -3
package/src/state.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loop state persisted as `loop-state` custom session entries, plus
|
|
3
|
-
* fail-open
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* it.
|
|
2
|
+
* Loop state persisted as `loop-state` custom session entries, plus one
|
|
3
|
+
* read-only fail-open reader for a sibling extension's entries:
|
|
4
|
+
* pi-plan-mode's `plan-mode-state`. The coupling is deliberately loose — no
|
|
5
|
+
* package dependency, no RPC; an absent or unrecognizable entry degrades
|
|
6
|
+
* pi-loop to "not planning", never crashes it.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
export const LOOP_STATE_ENTRY_TYPE = "loop-state";
|
|
11
|
-
export const GOAL_STATE_ENTRY_TYPE = "goal-state";
|
|
12
10
|
export const PLAN_MODE_STATE_ENTRY_TYPE = "plan-mode-state";
|
|
13
11
|
|
|
14
12
|
import { type LoopWait, normalizeLoopWait } from "./wait.js";
|
|
@@ -19,29 +17,30 @@ export type LoopStatus = (typeof LOOP_STATUSES)[number];
|
|
|
19
17
|
export interface LoopState {
|
|
20
18
|
id: string;
|
|
21
19
|
status: LoopStatus;
|
|
22
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* An optional recurring focus, restated on every loop message. Also the
|
|
22
|
+
* only field a loop persisted before 0.6.0 may carry instead of an
|
|
23
|
+
* objective; the restore shim adopts it as one.
|
|
24
|
+
*/
|
|
23
25
|
prompt?: string;
|
|
24
26
|
/**
|
|
25
|
-
* The loop's
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* active pi-goal goal owns it, exactly as before).
|
|
27
|
+
* The loop's objective and completion criteria: what it works on, and what
|
|
28
|
+
* `loop_complete` answers for. Optional only because a loop persisted
|
|
29
|
+
* before 0.6.0 may predate it — every loop started now has one.
|
|
29
30
|
*/
|
|
30
31
|
objective?: string;
|
|
31
32
|
intervalMs: number;
|
|
32
|
-
/** Delivered-poke cap; null means unlimited. */
|
|
33
|
-
maxIterations: number | null;
|
|
34
|
-
/** Proactive-compaction threshold fraction, or null when disabled per loop. */
|
|
35
|
-
compactAt: number | null;
|
|
36
33
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
34
|
+
* Cap on the turns this loop causes (continuations plus pokes); null means
|
|
35
|
+
* unlimited. The only cap: a settle-driven continuation chain runs without
|
|
36
|
+
* any wake at all, so a wake cap bounded nothing this one does not.
|
|
40
37
|
*/
|
|
41
|
-
|
|
42
|
-
/**
|
|
38
|
+
maxTurns: number | null;
|
|
39
|
+
/** Proactive-compaction threshold fraction, or null when disabled per loop. */
|
|
40
|
+
compactAt: number | null;
|
|
41
|
+
/** Delivered wakes so far (fallback pokes only); uncapped, and displayed. */
|
|
43
42
|
iteration: number;
|
|
44
|
-
/** Loop-caused turns so far (continuations + pokes). */
|
|
43
|
+
/** Loop-caused turns so far (continuations + pokes): what `maxTurns` caps. */
|
|
45
44
|
automaticTurns: number;
|
|
46
45
|
startedAt: number;
|
|
47
46
|
expiresAt: number;
|
|
@@ -90,8 +89,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
90
89
|
}
|
|
91
90
|
const intervalMs = record.intervalMs;
|
|
92
91
|
if (!isPositiveSafeInteger(intervalMs)) return undefined;
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
92
|
+
const maxTurns = readTurnCap(record);
|
|
93
|
+
if (maxTurns === false) return undefined;
|
|
95
94
|
const compactAt = record.compactAt;
|
|
96
95
|
if (
|
|
97
96
|
compactAt !== null &&
|
|
@@ -99,16 +98,12 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
99
98
|
) {
|
|
100
99
|
return undefined;
|
|
101
100
|
}
|
|
102
|
-
const maxAutomaticTurns = Object.hasOwn(record, "maxAutomaticTurns")
|
|
103
|
-
? record.maxAutomaticTurns
|
|
104
|
-
: null;
|
|
105
|
-
if (maxAutomaticTurns !== null && !isPositiveSafeInteger(maxAutomaticTurns)) return undefined;
|
|
106
101
|
const iteration = record.iteration;
|
|
107
102
|
if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
|
|
108
103
|
return undefined;
|
|
109
104
|
}
|
|
110
|
-
//
|
|
111
|
-
// absent counter restores as zero rather than rejecting the whole state.
|
|
105
|
+
// A loop persisted before the turn counter existed carries no automaticTurns;
|
|
106
|
+
// an absent counter restores as zero rather than rejecting the whole state.
|
|
112
107
|
const automaticTurns = Object.hasOwn(record, "automaticTurns") ? record.automaticTurns : 0;
|
|
113
108
|
if (
|
|
114
109
|
typeof automaticTurns !== "number" ||
|
|
@@ -146,8 +141,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
146
141
|
...(prompt === undefined ? {} : { prompt }),
|
|
147
142
|
...(objective === undefined ? {} : { objective }),
|
|
148
143
|
intervalMs,
|
|
149
|
-
|
|
150
|
-
maxAutomaticTurns: maxAutomaticTurns as number | null,
|
|
144
|
+
maxTurns,
|
|
151
145
|
compactAt: compactAt as number | null,
|
|
152
146
|
iteration,
|
|
153
147
|
automaticTurns,
|
|
@@ -163,6 +157,33 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
163
157
|
};
|
|
164
158
|
}
|
|
165
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The turn cap, adopting the caps a loop persisted by an older version
|
|
162
|
+
* carries: `maxAutomaticTurns` (turns) and `maxIterations` (wakes). An
|
|
163
|
+
* in-flight loop restored mid-upgrade keeps the tighter of them rather than
|
|
164
|
+
* having its bound widened or being dropped as unparsable; its wake *counter*
|
|
165
|
+
* is kept for display but no longer caps anything. Returns the cap, or false
|
|
166
|
+
* when a present value is invalid.
|
|
167
|
+
*/
|
|
168
|
+
function readTurnCap(record: Record<string, unknown>): number | null | false {
|
|
169
|
+
if (Object.hasOwn(record, "maxTurns")) {
|
|
170
|
+
const value = record.maxTurns;
|
|
171
|
+
if (value === null) return null;
|
|
172
|
+
return isPositiveSafeInteger(value) ? value : false;
|
|
173
|
+
}
|
|
174
|
+
let adopted: number | null | undefined;
|
|
175
|
+
for (const key of ["maxAutomaticTurns", "maxIterations"]) {
|
|
176
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
177
|
+
const value = record[key];
|
|
178
|
+
if (value !== null && !isPositiveSafeInteger(value)) return false;
|
|
179
|
+
const cap = value as number | null;
|
|
180
|
+
// null is unlimited, so it only wins when every legacy cap is unlimited.
|
|
181
|
+
if (adopted === undefined || adopted === null) adopted = cap;
|
|
182
|
+
else if (cap !== null) adopted = Math.min(adopted, cap);
|
|
183
|
+
}
|
|
184
|
+
return adopted === undefined ? null : adopted;
|
|
185
|
+
}
|
|
186
|
+
|
|
166
187
|
// --- session-branch entry readers ---
|
|
167
188
|
|
|
168
189
|
interface SessionEntryLike {
|
|
@@ -172,17 +193,11 @@ interface SessionEntryLike {
|
|
|
172
193
|
}
|
|
173
194
|
|
|
174
195
|
function lastCustomEntryData(entries: unknown[], customType: string): unknown {
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Newest-first data of the last `limit` custom entries of `customType`. */
|
|
179
|
-
function lastCustomEntryDatas(entries: unknown[], customType: string, limit: number): unknown[] {
|
|
180
|
-
const datas: unknown[] = [];
|
|
181
|
-
for (let index = entries.length - 1; index >= 0 && datas.length < limit; index -= 1) {
|
|
196
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
182
197
|
const entry = entries[index] as SessionEntryLike | undefined;
|
|
183
|
-
if (entry?.type === "custom" && entry.customType === customType)
|
|
198
|
+
if (entry?.type === "custom" && entry.customType === customType) return entry.data;
|
|
184
199
|
}
|
|
185
|
-
return
|
|
200
|
+
return undefined;
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
/** Restore the persisted loop state from a session branch, fail-open. */
|
|
@@ -193,77 +208,6 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
|
|
|
193
208
|
return normalizeLoopState(record.loop);
|
|
194
209
|
}
|
|
195
210
|
|
|
196
|
-
/**
|
|
197
|
-
* A standalone loop owns its own completion criteria; a goal-bound loop
|
|
198
|
-
* delegates that to pi-goal. Presence of `objective` is the discriminator.
|
|
199
|
-
*/
|
|
200
|
-
export function isStandaloneLoop(loop: LoopState): boolean {
|
|
201
|
-
return loop.objective !== undefined;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
|
|
205
|
-
|
|
206
|
-
/** How many `goal-state` entries a clear may be scanned back through. */
|
|
207
|
-
const GOAL_CLEAR_SCAN_LIMIT = 8;
|
|
208
|
-
|
|
209
|
-
export interface GoalSnapshot {
|
|
210
|
-
status: string;
|
|
211
|
-
text: string;
|
|
212
|
-
/** Present while the goal is in a goal_wait external-event wait. */
|
|
213
|
-
waiting: boolean;
|
|
214
|
-
iteration?: number;
|
|
215
|
-
tokensUsed?: number;
|
|
216
|
-
tokenBudget?: number;
|
|
217
|
-
automaticModelTurns?: number;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Read pi-goal's persisted goal, fail-open: undefined when absent or when the
|
|
222
|
-
* entry shape is not recognizably a goal. Only fields pi-loop consumes are
|
|
223
|
-
* extracted; unknown statuses are preserved verbatim so the caller can treat
|
|
224
|
-
* anything outside its known sets conservatively.
|
|
225
|
-
*
|
|
226
|
-
* Completion race: pi-goal persists the finished goal (status "complete") and
|
|
227
|
-
* then clears the entry (goal: null), so by the loop's next tick the last
|
|
228
|
-
* entry is the clear. When the newest entry is a clear (or unreadable), scan
|
|
229
|
-
* back over the consecutive run of clears for the goal they cleared: a
|
|
230
|
-
* complete goal is reported, so the loop stops with "goal completed" instead
|
|
231
|
-
* of pausing as goal-missing. A clear over any other status (user /goal clear
|
|
232
|
-
* mid-flight) still reads as no goal. The scan is bounded so a long history of
|
|
233
|
-
* clears cannot make the read walk the branch.
|
|
234
|
-
*/
|
|
235
|
-
export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
|
|
236
|
-
const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, GOAL_CLEAR_SCAN_LIMIT);
|
|
237
|
-
const newest = parseGoalSnapshot(ownRecord(datas[0])?.goal);
|
|
238
|
-
if (newest) return newest;
|
|
239
|
-
for (let index = 1; index < datas.length; index += 1) {
|
|
240
|
-
const cleared = parseGoalSnapshot(ownRecord(datas[index])?.goal);
|
|
241
|
-
// Another clear or an unreadable entry: keep scanning back.
|
|
242
|
-
if (!cleared) continue;
|
|
243
|
-
return cleared.status === "complete" ? cleared : undefined;
|
|
244
|
-
}
|
|
245
|
-
return undefined;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
|
|
249
|
-
const goal = ownRecord(value);
|
|
250
|
-
if (!goal) return undefined;
|
|
251
|
-
const status = typeof goal.status === "string" ? goal.status : undefined;
|
|
252
|
-
const text = typeof goal.text === "string" ? goal.text.trim() : "";
|
|
253
|
-
if (!status || !text) return undefined;
|
|
254
|
-
return {
|
|
255
|
-
status,
|
|
256
|
-
text,
|
|
257
|
-
waiting: ownRecord(goal.waiting) !== undefined,
|
|
258
|
-
...(isNonNegativeNumber(goal.iteration) ? { iteration: goal.iteration } : {}),
|
|
259
|
-
...(isNonNegativeNumber(goal.tokensUsed) ? { tokensUsed: goal.tokensUsed } : {}),
|
|
260
|
-
...(isNonNegativeNumber(goal.tokenBudget) ? { tokenBudget: goal.tokenBudget } : {}),
|
|
261
|
-
...(isNonNegativeNumber(goal.automaticModelTurns)
|
|
262
|
-
? { automaticModelTurns: goal.automaticModelTurns }
|
|
263
|
-
: {}),
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
211
|
/** Read pi-plan-mode's persisted state, fail-open: absent or malformed = not planning. */
|
|
268
212
|
export function readPlanModeEnabled(entries: unknown[]): boolean {
|
|
269
213
|
const data = ownRecord(lastCustomEntryData(entries, PLAN_MODE_STATE_ENTRY_TYPE));
|
|
@@ -288,10 +232,6 @@ function isPositiveSafeInteger(value: unknown): value is number {
|
|
|
288
232
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
289
233
|
}
|
|
290
234
|
|
|
291
|
-
function isNonNegativeNumber(value: unknown): value is number {
|
|
292
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
235
|
function isTimestamp(value: unknown): value is number {
|
|
296
236
|
return (
|
|
297
237
|
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIMESTAMP
|
package/src/wait-tool.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Registered unconditionally, like `loop_complete`, and for the same reason:
|
|
11
11
|
* tools are part of the cached request prefix, so adding or removing one
|
|
12
12
|
* mid-session invalidates the whole conversation cache. It refuses when no
|
|
13
|
-
*
|
|
13
|
+
* loop is active.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -59,7 +59,7 @@ export function registerLoopWaitTool(pi: ExtensionAPI, controller: LoopControlle
|
|
|
59
59
|
if (!loop || loop.objective === undefined) {
|
|
60
60
|
return {
|
|
61
61
|
content: toolContent(
|
|
62
|
-
"No
|
|
62
|
+
"No /loop with an objective is active, so there is nothing to wait on. Start one with /loop <interval> <objective>.",
|
|
63
63
|
),
|
|
64
64
|
details: {},
|
|
65
65
|
isError: true,
|
package/src/widget.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The loop widget: a compact themed line above the editor mirroring the
|
|
3
|
-
* footer status (interval ·
|
|
3
|
+
* footer status (interval · loop turns/cap · next wake), with the loop focus
|
|
4
4
|
* dimmed below it when set.
|
|
5
5
|
*
|
|
6
6
|
* Presentation only: every entry point tolerates a host without setWidget
|
|
@@ -61,13 +61,15 @@ export function clearLoopWidget(ui: WidgetHost) {
|
|
|
61
61
|
export function loopWidgetLine(view: LoopWidgetView) {
|
|
62
62
|
const loop = view.loop;
|
|
63
63
|
if (loop.status === "paused") return "⏸ loop paused";
|
|
64
|
-
const cap = loop.
|
|
64
|
+
const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
|
|
65
65
|
const next = view.wakePending
|
|
66
66
|
? "next on idle"
|
|
67
67
|
: view.nextWakeAt !== undefined
|
|
68
68
|
? `next ${formatClock(view.nextWakeAt)}`
|
|
69
69
|
: "next unscheduled";
|
|
70
|
-
|
|
70
|
+
// The turn counter, not the wake counter: the cap counts turns, so a
|
|
71
|
+
// progress line against that cap has to count the same thing.
|
|
72
|
+
return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function identity(text: string) {
|