@hank-warren/pi-loop 0.3.0 → 0.3.1
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 +47 -0
- package/README.md +6 -2
- package/package.json +3 -2
- package/src/index.ts +4 -0
- package/src/loop.ts +7 -1
- package/src/messages.ts +11 -10
- package/src/render.ts +33 -0
- package/src/widget.ts +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @hank-warren/pi-loop
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 910f77a: stop invalidating the prompt cache, and stop re-sending the goal block every turn
|
|
8
|
+
|
|
9
|
+
Goal mode has to keep the objective and its rules in front of the model on every
|
|
10
|
+
turn. It did that twice: a full block appended to the system prompt with the
|
|
11
|
+
running budget counter embedded in it, and a near-complete copy re-sent as a
|
|
12
|
+
stored user message on every automatic continuation. pi-loop restated the
|
|
13
|
+
objective a third time in every poke.
|
|
14
|
+
|
|
15
|
+
Anthropic caches `tools → system → messages` as one prefix, so the moving budget
|
|
16
|
+
counter invalidated the system _and_ conversation cache on every turn of a
|
|
17
|
+
budgeted goal. Verified against the installed pi-ai Anthropic adapter
|
|
18
|
+
(`buildParams` places `cache_control` on the system block and the last user
|
|
19
|
+
message, and the `before_agent_start` append lands inside that cached block).
|
|
20
|
+
|
|
21
|
+
**Static per goal.** The system append is now byte-identical across turns of the
|
|
22
|
+
same goal and states only the budget _total_, so it changes only when the goal
|
|
23
|
+
does — start, edit, clear — which is one accepted cache rewrite per boundary.
|
|
24
|
+
Pinned as the cache-stability contract: same goal, different tokens used,
|
|
25
|
+
iteration, and elapsed time produce identical output.
|
|
26
|
+
|
|
27
|
+
**Dynamic per wake.** Kickoff, continuation, and poke messages shrink to
|
|
28
|
+
pointer-sized triggers carrying the continuation number, budget usage, and wake
|
|
29
|
+
reason, and point at the system prompt for the rest. Measured at ~4 chars/token:
|
|
30
|
+
kickoff 805 → 53, continuation 840 → 54, poke 86 → 64, system append 807 → 820
|
|
31
|
+
(+13 once per goal, the price of stability). Stored conversation tokens for a
|
|
32
|
+
25-iteration goal: 20,965 → 1,349. The resume, edit, and waiting-resume prompts
|
|
33
|
+
keep the full block — they are rare, user-initiated, and already a cache
|
|
34
|
+
boundary.
|
|
35
|
+
|
|
36
|
+
**Legibility.** Those messages now collapse to one-line transcript chips
|
|
37
|
+
(`⟳ goal continuation #4 · budget 12k/100k`, `⏰ loop wake 4/25 · stalled`) via
|
|
38
|
+
markdown transformers, which are display-only by Pi's contract: the stored
|
|
39
|
+
message and the model's context are untouched. New goal and loop widgets above
|
|
40
|
+
the editor carry the counters that left the system prompt — objective, budget
|
|
41
|
+
fraction, iteration, automatic turns for the goal; interval, iteration/cap, next
|
|
42
|
+
wake, and focus for the loop.
|
|
43
|
+
|
|
44
|
+
**Cross-extension assumption.** pi-loop's poke no longer restates the objective,
|
|
45
|
+
because loops require an active goal and pokes are ordinary user messages that
|
|
46
|
+
go through `before_agent_start`, so every poke turn already carries pi-goal's
|
|
47
|
+
system append. Documented in both READMEs; if pokes are ever delivered by a path
|
|
48
|
+
that bypasses that hook, the poke must carry the objective again.
|
|
49
|
+
|
|
3
50
|
## 0.3.0
|
|
4
51
|
|
|
5
52
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -27,11 +27,15 @@ Each tick evaluates, in order:
|
|
|
27
27
|
3. **Agent busy?** Never interrupt: coalesce into a single pending wake delivered at the next fully-settled idle boundary. N missed ticks collapse into one poke.
|
|
28
28
|
4. **Goal state**: a missing goal (cleared mid-loop) **pauses** the loop; completion **stops** it — including through the clear that follows it, since pi-goal persists the finished goal and *then* clears the entry, so the newest entry at completion is a clear and the loop reads back past it; a safety pause (`paused`/`blocked`/`usage_limited`/`budget_limited`, or any unknown status) **pauses** it — pi-loop never pokes past pi-goal's circuit breakers. An `active` or `goal_wait`-waiting goal in an idle session is exactly the stall this extension exists for, so it pokes toward the goal (a tick is the external wake `goal_wait` arranges).
|
|
29
29
|
5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
|
|
30
|
-
6. **Poke**: a goal wake message
|
|
30
|
+
6. **Poke**: a goal wake message — the wake header, why it fired (stalled or the external wake for a waiting goal), and the loop focus when set. Every poke carries a marker (`<!-- pi-loop-poke:<id>:<n> -->`) so a wakeup is identifiable as loop-injected rather than user-typed. The marker is **provenance only** — pi-loop coalesces wakes in its own state and never reads the marker back to drop a delivery.
|
|
31
|
+
|
|
32
|
+
**A poke does not restate the objective**, and this is a deliberate cross-extension assumption. Loops require an active goal, and pi-goal appends the objective, `goal_id`, and goal-mode rules to the system prompt of *every* goal turn — including poke turns, because pokes are delivered as ordinary user messages that go through `before_agent_start`. Restating the goal in the poke would store a duplicate copy in the conversation on every wake for no benefit. The token-lean contract is pinned in `test/messages.test.ts`; the matching cache-stability contract lives in [pi-goal](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-goal#fork-feature-cache-safe-token-lean-injections). If pokes are ever delivered by a path that bypasses `before_agent_start` (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly), this assumption breaks and the poke must carry the objective again.
|
|
33
|
+
|
|
34
|
+
In the transcript, a poke renders as a one-line chip (`⏰ loop wake 4/25 · stalled`) via a markdown transformer. That hook is display-only by Pi's contract — the stored message and the model's context are untouched.
|
|
31
35
|
|
|
32
36
|
Expiry, completion, and pi-goal's safety states are also evaluated whenever the session settles, so a loop stops as soon as its goal does rather than at the next scheduled tick. Only the timer pokes.
|
|
33
37
|
|
|
34
|
-
The footer
|
|
38
|
+
The footer status shows `loop 5m · 3/25 · next 14:32`, and a widget above the editor shows the same state with the loop focus beneath it; `/loop status` shows the full card including the last tick's decision and reason.
|
|
35
39
|
|
|
36
40
|
## Loop-aware compaction
|
|
37
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Interval wakeups for Pi: recurring prompt re-runs, stall rescue toward an active pi-goal goal, and loop-aware compaction that survives long sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"CHANGELOG.md"
|
|
39
39
|
],
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-tui": "*"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,9 +11,13 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
13
13
|
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
14
|
+
import { registerLoopMessageRendering } from "./render.js";
|
|
14
15
|
|
|
15
16
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
16
17
|
const controller = new LoopController(pi, options);
|
|
18
|
+
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
19
|
+
// stored message and model context are untouched).
|
|
20
|
+
registerLoopMessageRendering(pi);
|
|
17
21
|
|
|
18
22
|
pi.registerCommand("loop", {
|
|
19
23
|
description:
|
package/src/loop.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
readPlanModeEnabled,
|
|
44
44
|
restoreLoopState,
|
|
45
45
|
} from "./state.js";
|
|
46
|
+
import { clearLoopWidget, updateLoopWidget } from "./widget.js";
|
|
46
47
|
|
|
47
48
|
export const LOOP_STATUS_KEY = "loop";
|
|
48
49
|
|
|
@@ -100,6 +101,7 @@ export class LoopController {
|
|
|
100
101
|
onSessionShutdown(): void {
|
|
101
102
|
this.clearTimer();
|
|
102
103
|
this.wakePending = false;
|
|
104
|
+
if (this.sessionCtx) clearLoopWidget(this.sessionCtx.ui);
|
|
103
105
|
this.sessionCtx = undefined;
|
|
104
106
|
}
|
|
105
107
|
|
|
@@ -233,7 +235,7 @@ export class LoopController {
|
|
|
233
235
|
const loop = this.state;
|
|
234
236
|
if (!loop || !env.goal) return;
|
|
235
237
|
try {
|
|
236
|
-
this.pi.sendUserMessage(buildGoalPoke(loop,
|
|
238
|
+
this.pi.sendUserMessage(buildGoalPoke(loop, reason));
|
|
237
239
|
} catch (error) {
|
|
238
240
|
this.sessionCtx?.ui.notify(
|
|
239
241
|
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
@@ -319,6 +321,10 @@ export class LoopController {
|
|
|
319
321
|
const ui = this.sessionCtx?.ui;
|
|
320
322
|
if (!ui) return;
|
|
321
323
|
const loop = this.state;
|
|
324
|
+
updateLoopWidget(
|
|
325
|
+
ui,
|
|
326
|
+
loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
|
|
327
|
+
);
|
|
322
328
|
if (!loop || loop.status === "stopped") {
|
|
323
329
|
ui.setStatus(LOOP_STATUS_KEY, undefined);
|
|
324
330
|
return;
|
package/src/messages.ts
CHANGED
|
@@ -15,22 +15,23 @@ function formatIteration(loop: LoopState): string {
|
|
|
15
15
|
return `${loop.iteration + 1}/${cap}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The goal-bound poke: wake the session and point at the goal without
|
|
20
|
+
* restating it. Loops require an active pi-goal goal, so every poke turn
|
|
21
|
+
* already carries the objective, goal_id, and goal-mode rules through
|
|
22
|
+
* pi-goal's system prompt append; restating them here would store duplicate
|
|
23
|
+
* tokens in the conversation on every wake (see README: cross-extension
|
|
24
|
+
* assumption). Only the dynamic per-wake state (iteration, reason) belongs in
|
|
25
|
+
* this tail message.
|
|
26
|
+
*/
|
|
27
|
+
export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-waiting"): string {
|
|
24
28
|
const lines = [
|
|
25
29
|
`Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
26
30
|
reason === "goal-waiting"
|
|
27
31
|
? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
|
|
28
|
-
: "The session went idle but the active goal is not complete. Continue working toward it.",
|
|
29
|
-
"",
|
|
30
|
-
`Active goal: ${goal.text}`,
|
|
32
|
+
: "The session went idle but the active goal is not complete. Continue working toward it — the objective and goal-mode rules are in the system prompt.",
|
|
31
33
|
];
|
|
32
34
|
if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
|
|
33
|
-
lines.push("", "Use the goal tools (goal_complete, goal_blocked, goal_wait) when their conditions are met.");
|
|
34
35
|
return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
|
|
35
36
|
}
|
|
36
37
|
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only compaction of loop-injected pokes.
|
|
3
|
+
*
|
|
4
|
+
* Pokes are already token-lean, but they still render as multi-line prompt
|
|
5
|
+
* text plus a provenance marker comment. This transformer collapses each into
|
|
6
|
+
* a one-line themed chip in the transcript. Display-only by Pi contract: the
|
|
7
|
+
* stored message and model context are untouched, and pokes keep being
|
|
8
|
+
* delivered through sendUserMessage so pi-goal's before_agent_start hook
|
|
9
|
+
* (which appends the goal system prompt) still fires for every poke turn.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { extractPokeMarker } from "./markers.js";
|
|
14
|
+
|
|
15
|
+
const POKE_HEAD_PATTERN = /^Scheduled loop wakeup (\S+) \(every ([^)]+)\)\./u;
|
|
16
|
+
const POKE_FOCUS_PATTERN = /^Loop focus: (.+)$/mu;
|
|
17
|
+
|
|
18
|
+
export function registerLoopMessageRendering(pi: ExtensionAPI) {
|
|
19
|
+
pi.registerMarkdownTransformer((markdown, { messageType }) => {
|
|
20
|
+
if (messageType !== "user") return markdown;
|
|
21
|
+
return compactPokeMessage(markdown) ?? markdown;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Exported for tests: the poke chip, or undefined when not ours. */
|
|
26
|
+
export function compactPokeMessage(markdown: string) {
|
|
27
|
+
if (!extractPokeMarker(markdown)) return undefined;
|
|
28
|
+
const head = POKE_HEAD_PATTERN.exec(markdown);
|
|
29
|
+
if (!head) return undefined;
|
|
30
|
+
const reason = markdown.includes("external wake") ? "waiting" : "stalled";
|
|
31
|
+
const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
|
|
32
|
+
return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
|
|
33
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop widget: a compact themed line above the editor mirroring the
|
|
3
|
+
* footer status (interval · iteration/cap · next wake), with the loop focus
|
|
4
|
+
* dimmed below it when set.
|
|
5
|
+
*
|
|
6
|
+
* Presentation only: every entry point tolerates a host without setWidget
|
|
7
|
+
* (test fixtures, print mode) and swallows render-side failures, because a
|
|
8
|
+
* widget must never interrupt loop state transitions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import { formatClock, formatDuration } from "./interval.js";
|
|
13
|
+
import type { LoopState } from "./state.js";
|
|
14
|
+
|
|
15
|
+
export const LOOP_WIDGET_KEY = "loop";
|
|
16
|
+
|
|
17
|
+
interface WidgetTheme {
|
|
18
|
+
bold?: (text: string) => string;
|
|
19
|
+
fg?: (color: string, text: string) => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type WidgetHost = { setWidget?: unknown };
|
|
23
|
+
|
|
24
|
+
export interface LoopWidgetView {
|
|
25
|
+
loop: LoopState;
|
|
26
|
+
/** A wake is held for the next idle boundary. */
|
|
27
|
+
wakePending: boolean;
|
|
28
|
+
/** Epoch ms of the next scheduled tick, when armed. */
|
|
29
|
+
nextWakeAt: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
|
|
33
|
+
const setWidget = resolveSetWidget(ui);
|
|
34
|
+
if (!setWidget) return;
|
|
35
|
+
try {
|
|
36
|
+
if (!view || view.loop.status === "stopped") {
|
|
37
|
+
setWidget(LOOP_WIDGET_KEY, undefined);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
|
|
41
|
+
const bold = theme.bold ?? identity;
|
|
42
|
+
const dim = (text: string) => theme.fg?.("dim", text) ?? text;
|
|
43
|
+
const focus = view.loop.prompt ? `\n${dim(` focus: ${view.loop.prompt}`)}` : "";
|
|
44
|
+
return new Text(`${bold(loopWidgetLine(view))}${focus}`);
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
// Presentation only; a widget failure must never break a loop transition.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearLoopWidget(ui: WidgetHost) {
|
|
52
|
+
const setWidget = resolveSetWidget(ui);
|
|
53
|
+
if (!setWidget) return;
|
|
54
|
+
try {
|
|
55
|
+
setWidget(LOOP_WIDGET_KEY, undefined);
|
|
56
|
+
} catch {
|
|
57
|
+
// Presentation only.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loopWidgetLine(view: LoopWidgetView) {
|
|
62
|
+
const loop = view.loop;
|
|
63
|
+
if (loop.status === "paused") return "⏸ loop paused";
|
|
64
|
+
const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
|
|
65
|
+
const next = view.wakePending
|
|
66
|
+
? "next on idle"
|
|
67
|
+
: view.nextWakeAt !== undefined
|
|
68
|
+
? `next ${formatClock(view.nextWakeAt)}`
|
|
69
|
+
: "next unscheduled";
|
|
70
|
+
return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function identity(text: string) {
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveSetWidget(ui: WidgetHost) {
|
|
78
|
+
return typeof ui.setWidget === "function"
|
|
79
|
+
? (ui.setWidget as (key: string, content: unknown) => void).bind(ui)
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|