@hank-warren/pi-loop 0.1.0 → 0.2.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 +17 -0
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/decide.ts +21 -21
- package/src/index.ts +5 -6
- package/src/loop.ts +13 -15
- package/src/manager.ts +13 -18
- package/src/messages.ts +2 -11
- package/src/settings.ts +0 -24
- package/src/state.ts +27 -6
- package/src/inline-command.ts +0 -89
- package/src/inline-invocation.ts +0 -39
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @hank-warren/pi-loop
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2b22273: Stop the loop with "goal completed" when pi-goal clears its state entry after completion. pi-goal persists the finished goal (status complete) and then writes a clear (goal: null), so by the loop's next tick the last goal-state entry was the clear and the loop paused as goal-missing instead of stopping as goal-complete. readGoalSnapshot now reads a completed goal through its completion clear; a clear over any non-complete goal (user /goal clear mid-flight) still pauses the loop.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 4e16c70: Redesign inline invocation to be tool-mediated, fixing message loss, "Agent is already processing a prompt" errors, and uninterruptible turns caused by the input-splitting approach (extension-sent messages are never dispatched as commands, so the re-sent `/goal` reached the model as plain text and the user's prose was dropped).
|
|
14
|
+
|
|
15
|
+
pi-goal: new `goal_start` tool reuses the `/goal` command's exact activation path; mid-prompt `/goal <objective>` and line-leading `goal: <objective>` invocations now append a one-line reminder to the otherwise-untouched message so the model calls the tool — nothing is cut, split, or re-sent. The `inlineInvocation` setting gates the reminder.
|
|
16
|
+
|
|
17
|
+
pi-loop: loops now require an active pi-goal goal to operate — start refuses without one, a cleared goal pauses the loop, completion still stops it. `/loop` is user-typed only: the inline `/loop` input handler and the `pokePreamble`/`inlineInvocation` settings are removed, and the loop prompt is now an optional focus added to goal pokes.
|
package/README.md
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# pi-loop — interval wakeups for the Pi coding agent
|
|
2
2
|
|
|
3
|
-
Inspired by Claude Code's `/loop`, adapted to Pi:
|
|
3
|
+
Inspired by Claude Code's `/loop`, adapted to Pi: wake the session on an interval to keep an active [pi-goal](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal) goal moving, and keep long loops coherent across context compaction.
|
|
4
4
|
|
|
5
|
-
pi-loop is a **pacemaker, not an evaluator**: it owns *when* the session wakes; pi-goal owns *whether the work is done*.
|
|
5
|
+
pi-loop is a **pacemaker, not an evaluator**: it owns *when* the session wakes; pi-goal owns *whether the work is done*. **Loops require an active goal to operate** — the goal evaluator is the stop criterion, so a loop without a goal has nothing to decide when it is done. Coupling is read-only, fail-open reads of pi-goal's `goal-state` and pi-plan-mode's `plan-mode-state` session entries.
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
/
|
|
11
|
-
/loop 30m
|
|
12
|
-
/loop
|
|
10
|
+
/goal get CI green # loops need an active goal first
|
|
11
|
+
/loop 30m # poke the goal every 30 minutes if the session stalls
|
|
12
|
+
/loop 10m recheck the pipeline # optional focus text added to every poke
|
|
13
|
+
/loop # manager TUI (status, pause/resume, edit, settings, stop)
|
|
13
14
|
/loop status | pause | resume | stop | settings
|
|
14
|
-
/loop --max 20 --compact-at 60% 10m
|
|
15
|
+
/loop --max 20 --compact-at 60% 10m # per-loop overrides
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
- **Intervals** are `<number><unit>` with unit `s`/`m`/`h`/`d`, parsed by the extension (never the model), minimum 1 minute (smaller values clamp, and the effective value is echoed).
|
|
18
|
-
- `/loop`
|
|
19
|
+
- `/loop` is deliberately **user-typed only** — the model never starts or stops loops. (Inline `/goal` invocation is the [pi-goal fork](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-goal)'s `goal_start` tool.)
|
|
19
20
|
|
|
20
21
|
## What a wakeup does
|
|
21
22
|
|
|
@@ -24,9 +25,9 @@ Each tick evaluates, in order:
|
|
|
24
25
|
1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
|
|
25
26
|
2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
|
|
26
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.
|
|
27
|
-
4. **Goal state
|
|
28
|
+
4. **Goal state**: a missing goal (cleared mid-loop) **pauses** the loop; completion **stops** 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).
|
|
28
29
|
5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
|
|
29
|
-
6. **Poke**:
|
|
30
|
+
6. **Poke**: a goal wake message restating the goal (plus the loop focus, when set). Every loop-injected message carries a provenance marker (`<!-- pi-loop-poke:<id>:<n> -->`) so wakeups are distinguishable from user prompts and stale wakes are dropped.
|
|
30
31
|
|
|
31
32
|
The footer widget shows `loop 5m · 3/25 · next 14:32`; `/loop status` shows the full card including the last tick's decision and reason.
|
|
32
33
|
|
|
@@ -51,13 +52,11 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
|
|
|
51
52
|
"threshold": 0.7,
|
|
52
53
|
"postCompactContinuation": true,
|
|
53
54
|
"instructions": null
|
|
54
|
-
}
|
|
55
|
-
"pokePreamble": null,
|
|
56
|
-
"inlineInvocation": true
|
|
55
|
+
}
|
|
57
56
|
}
|
|
58
57
|
```
|
|
59
58
|
|
|
60
|
-
`maxIterations: null` means unlimited. `compaction.instructions`
|
|
59
|
+
`maxIterations: null` means unlimited. `compaction.instructions` overrides the built-in template.
|
|
61
60
|
|
|
62
61
|
## Install
|
|
63
62
|
|
|
@@ -65,7 +64,7 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
|
|
|
65
64
|
pi install npm:@hank-warren/pi-loop
|
|
66
65
|
```
|
|
67
66
|
|
|
68
|
-
|
|
67
|
+
Requires a goal extension for its session entries: `npm:@hank-warren/pi-goal` (recommended) or upstream `@narumitw/pi-goal`.
|
|
69
68
|
|
|
70
69
|
## License
|
|
71
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.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": [
|
package/src/decide.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* decides what a wakeup does, so the full decision matrix is unit-testable
|
|
4
4
|
* without timers or a Pi runtime.
|
|
5
5
|
*
|
|
6
|
-
* Precedence
|
|
7
|
-
* goal
|
|
6
|
+
* Precedence: loop liveness → expiry → plan mode → busy → goal presence and
|
|
7
|
+
* state (loops require an active pi-goal goal to operate) → iteration cap →
|
|
8
|
+
* poke.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { GOAL_SAFETY_STATUSES, type GoalSnapshot, type LoopState } from "./state.js";
|
|
@@ -25,7 +26,8 @@ export type TickDecision =
|
|
|
25
26
|
| { action: "skip"; reason: "plan-mode-active" | "agent-busy" | "compaction-in-flight" }
|
|
26
27
|
| { action: "stop"; reason: "goal-complete" | "max-iterations" }
|
|
27
28
|
| { action: "pause"; reason: "goal-safety"; cause: string }
|
|
28
|
-
| { action: "
|
|
29
|
+
| { action: "pause"; reason: "goal-missing" }
|
|
30
|
+
| { action: "poke"; reason: "goal-stalled" | "goal-waiting" };
|
|
29
31
|
|
|
30
32
|
export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
|
|
31
33
|
if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
|
|
@@ -35,28 +37,26 @@ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision
|
|
|
35
37
|
if (env.busy) return { action: "skip", reason: "agent-busy" };
|
|
36
38
|
|
|
37
39
|
const goal = env.goal;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
// Loops require a goal to operate: the goal evaluator owns the stop
|
|
41
|
+
// criterion, so a loop with no goal has nothing to decide when it is done.
|
|
42
|
+
if (!goal) return { action: "pause", reason: "goal-missing" };
|
|
43
|
+
if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
|
|
44
|
+
if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
|
|
45
|
+
return { action: "pause", reason: "goal-safety", cause: goal.status };
|
|
46
|
+
}
|
|
47
|
+
if (goal.status !== "active") {
|
|
48
|
+
// Unknown status from a newer pi-goal: treat like a safety state
|
|
49
|
+
// rather than poking past a guard we do not understand.
|
|
50
|
+
return { action: "pause", reason: "goal-safety", cause: goal.status };
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
|
|
51
54
|
return { action: "stop", reason: "max-iterations" };
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
|
|
60
|
-
}
|
|
61
|
-
return { action: "poke", reason: "recurring-prompt" };
|
|
57
|
+
// An idle session with an active goal is exactly the stall/wait case:
|
|
58
|
+
// pi-goal continues on its own at every idle boundary, so idleness at
|
|
59
|
+
// tick time means its continuation was lost, or the goal is waiting on
|
|
60
|
+
// an external event — which this wakeup is.
|
|
61
|
+
return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
|
|
62
62
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-loop: Claude-Code-/loop-inspired pacemaker for Pi
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* pi-loop: Claude-Code-/loop-inspired pacemaker for Pi. A loop wakes the
|
|
3
|
+
* session on an interval to keep an active pi-goal goal moving (stall rescue
|
|
4
|
+
* and goal_wait wakes) with loop-aware compaction. Loops require an active
|
|
5
|
+
* goal: the loop owns *when* the session wakes; @narumitw/pi-goal (or the
|
|
5
6
|
* @hank-warren/pi-goal fork) owns *whether the work is done*, read through
|
|
6
7
|
* its `goal-state` session entries only.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
11
|
-
import { registerInlineInvocation } from "./inline-invocation.js";
|
|
12
12
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
13
13
|
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
14
14
|
|
|
@@ -17,7 +17,7 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
17
17
|
|
|
18
18
|
pi.registerCommand("loop", {
|
|
19
19
|
description:
|
|
20
|
-
"Wake the session on an interval: /loop [--max N] [--compact-at 60%] <interval> [
|
|
20
|
+
"Wake the session on an interval to keep the active /goal moving: /loop [--max N] [--compact-at 60%] <interval> [focus]",
|
|
21
21
|
getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
|
|
22
22
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
23
23
|
const command = parseLoopCommand(args);
|
|
@@ -80,5 +80,4 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
80
80
|
pi.on("session_compact", async (_event, ctx) => {
|
|
81
81
|
controller.onSessionCompact(ctx);
|
|
82
82
|
});
|
|
83
|
-
registerInlineInvocation(pi, controller);
|
|
84
83
|
}
|
package/src/loop.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* - Pokes deliver only at a fully idle boundary; a tick that lands while the
|
|
9
9
|
* agent is busy coalesces into a single pending wake delivered at the next
|
|
10
10
|
* agent_settled. Missed ticks never stack.
|
|
11
|
-
* - pi-goal
|
|
12
|
-
*
|
|
11
|
+
* - Loops require an active pi-goal goal to operate: pi-goal owns "whether
|
|
12
|
+
* the work is done". Its safety states pause the loop, its completion stops
|
|
13
|
+
* it, a missing goal pauses the loop, and its thresholds ride along in the
|
|
13
14
|
* post-compaction continuation. Coupling is read-only session entries.
|
|
14
15
|
* - The loop's proactive compaction is the normal compaction path; Pi's
|
|
15
16
|
* reserve-token auto-compaction is the fault handler.
|
|
@@ -28,7 +29,6 @@ import {
|
|
|
28
29
|
buildCompactionInstructions,
|
|
29
30
|
buildGoalPoke,
|
|
30
31
|
buildPostCompactContinuation,
|
|
31
|
-
buildPromptPoke,
|
|
32
32
|
} from "./messages.js";
|
|
33
33
|
import {
|
|
34
34
|
DEFAULT_LOOP_SETTINGS,
|
|
@@ -201,7 +201,9 @@ export class LoopController {
|
|
|
201
201
|
case "pause":
|
|
202
202
|
this.transition(
|
|
203
203
|
"paused",
|
|
204
|
-
|
|
204
|
+
decision.reason === "goal-missing"
|
|
205
|
+
? "loops require an active goal; start one with /goal <objective>, then /loop resume"
|
|
206
|
+
: `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
|
|
205
207
|
);
|
|
206
208
|
return;
|
|
207
209
|
case "poke":
|
|
@@ -213,14 +215,11 @@ export class LoopController {
|
|
|
213
215
|
private deliverPoke(
|
|
214
216
|
ctx: ExtensionContext,
|
|
215
217
|
env: TickEnvironment,
|
|
216
|
-
reason: "
|
|
218
|
+
reason: "goal-stalled" | "goal-waiting",
|
|
217
219
|
): void {
|
|
218
220
|
const loop = this.state;
|
|
219
|
-
if (!loop) return;
|
|
220
|
-
const message =
|
|
221
|
-
reason === "recurring-prompt" || !env.goal
|
|
222
|
-
? buildPromptPoke(loop, this.settings.pokePreamble)
|
|
223
|
-
: buildGoalPoke(loop, env.goal, reason === "goal-waiting" ? "goal-waiting" : "goal-stalled");
|
|
221
|
+
if (!loop || !env.goal) return;
|
|
222
|
+
const message = buildGoalPoke(loop, env.goal, reason);
|
|
224
223
|
this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
|
|
225
224
|
this.persist();
|
|
226
225
|
this.pi.sendUserMessage(message);
|
|
@@ -315,7 +314,7 @@ export class LoopController {
|
|
|
315
314
|
`Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
|
|
316
315
|
`Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
|
|
317
316
|
];
|
|
318
|
-
if (loop.prompt) lines.push(`
|
|
317
|
+
if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
|
|
319
318
|
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
320
319
|
if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
|
|
321
320
|
if (this.nextWakeAt && loop.status === "active") {
|
|
@@ -335,9 +334,9 @@ export class LoopController {
|
|
|
335
334
|
this.sessionCtx = ctx;
|
|
336
335
|
const now = this.now();
|
|
337
336
|
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
338
|
-
if (
|
|
337
|
+
if (goal?.status !== "active") {
|
|
339
338
|
ctx.ui.notify(
|
|
340
|
-
"
|
|
339
|
+
"Loops require an active goal to operate. Start one first: /goal <objective>, then /loop <interval> [focus].",
|
|
341
340
|
"error",
|
|
342
341
|
);
|
|
343
342
|
return;
|
|
@@ -369,9 +368,8 @@ export class LoopController {
|
|
|
369
368
|
const clampNote = start.clamped
|
|
370
369
|
? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
|
|
371
370
|
: "";
|
|
372
|
-
const target = start.prompt ? "the loop prompt" : "the active goal";
|
|
373
371
|
ctx.ui.notify(
|
|
374
|
-
`Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking ${
|
|
372
|
+
`Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking the active goal${start.prompt ? " with the loop focus" : ""}. Stop with /loop stop.`,
|
|
375
373
|
"info",
|
|
376
374
|
);
|
|
377
375
|
}
|
package/src/manager.ts
CHANGED
|
@@ -27,8 +27,8 @@ export async function showLoopManager(
|
|
|
27
27
|
for (;;) {
|
|
28
28
|
const loop = controller.state;
|
|
29
29
|
const options: string[] = ["Status"];
|
|
30
|
-
if (loop?.status === "active") options.push("Pause", "Edit
|
|
31
|
-
else if (loop?.status === "paused") options.push("Resume", "Edit
|
|
30
|
+
if (loop?.status === "active") options.push("Pause", "Edit focus", "Edit interval", "Stop");
|
|
31
|
+
else if (loop?.status === "paused") options.push("Resume", "Edit focus", "Edit interval", "Stop");
|
|
32
32
|
else options.push("Start a loop…");
|
|
33
33
|
options.push("Settings");
|
|
34
34
|
const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
|
|
@@ -49,7 +49,7 @@ export async function showLoopManager(
|
|
|
49
49
|
case "Start a loop…":
|
|
50
50
|
await startFromMenu(controller, ctx);
|
|
51
51
|
break;
|
|
52
|
-
case "Edit
|
|
52
|
+
case "Edit focus":
|
|
53
53
|
await editPrompt(controller, ctx);
|
|
54
54
|
break;
|
|
55
55
|
case "Edit interval":
|
|
@@ -80,11 +80,14 @@ async function startFromMenu(
|
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
if (goal?.status !== "active") {
|
|
84
|
+
ctx.ui.notify(
|
|
85
|
+
"Loops require an active goal to operate. Start one first: /goal <objective>.",
|
|
86
|
+
"error",
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const promptText = await ctx.ui.input("Loop focus (optional, added to every goal poke)");
|
|
88
91
|
if (promptText === undefined) return;
|
|
89
92
|
const prompt = promptText.trim();
|
|
90
93
|
controller.startLoop(ctx, {
|
|
@@ -99,21 +102,16 @@ async function startFromMenu(
|
|
|
99
102
|
async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
|
|
100
103
|
const loop = controller.state;
|
|
101
104
|
if (!loop || loop.status === "stopped") return;
|
|
102
|
-
const next = await ctx.ui.input("Loop
|
|
105
|
+
const next = await ctx.ui.input("Loop focus (optional, added to every goal poke)", loop.prompt ?? "");
|
|
103
106
|
if (next === undefined) return;
|
|
104
107
|
const prompt = next.trim();
|
|
105
|
-
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
106
|
-
if (!prompt && goal?.status !== "active") {
|
|
107
|
-
ctx.ui.notify("A prompt is required unless a pi-goal goal is active.", "error");
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
108
|
if (prompt) controller.state = { ...loop, prompt };
|
|
111
109
|
else {
|
|
112
110
|
const { prompt: _dropped, ...rest } = loop;
|
|
113
111
|
controller.state = rest;
|
|
114
112
|
}
|
|
115
113
|
controller.persist();
|
|
116
|
-
ctx.ui.notify("Loop
|
|
114
|
+
ctx.ui.notify("Loop focus updated.", "info");
|
|
117
115
|
}
|
|
118
116
|
|
|
119
117
|
async function editInterval(
|
|
@@ -156,7 +154,6 @@ export async function showLoopSettings(
|
|
|
156
154
|
`Max loop duration: ${s.maxLoopDuration}`,
|
|
157
155
|
`Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
|
|
158
156
|
`Post-compact continuation: ${s.compaction.postCompactContinuation ? "On" : "Off"}`,
|
|
159
|
-
`Inline /loop: ${s.inlineInvocation ? "On" : "Off"}`,
|
|
160
157
|
];
|
|
161
158
|
const choice = await ctx.ui.select("Pi Loop Settings", items);
|
|
162
159
|
if (choice === undefined) return;
|
|
@@ -205,8 +202,6 @@ export async function showLoopSettings(
|
|
|
205
202
|
}
|
|
206
203
|
} else if (index === 3) {
|
|
207
204
|
next.compaction.postCompactContinuation = !s.compaction.postCompactContinuation;
|
|
208
|
-
} else if (index === 4) {
|
|
209
|
-
next.inlineInvocation = !s.inlineInvocation;
|
|
210
205
|
} else {
|
|
211
206
|
continue;
|
|
212
207
|
}
|
package/src/messages.ts
CHANGED
|
@@ -15,15 +15,6 @@ function formatIteration(loop: LoopState): string {
|
|
|
15
15
|
return `${loop.iteration + 1}/${cap}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** The recurring-prompt poke: the stored prompt with a short scheduled preamble. */
|
|
19
|
-
export function buildPromptPoke(loop: LoopState, preambleOverride: string | null): string {
|
|
20
|
-
const preamble =
|
|
21
|
-
preambleOverride ??
|
|
22
|
-
`Scheduled loop iteration ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}). Continue the recurring task below; if its work is exhausted, say so instead of inventing new work.`;
|
|
23
|
-
const prompt = loop.prompt ?? "";
|
|
24
|
-
return appendPokeMarker(`${preamble}\n\n${prompt}`.trim(), loop.id, loop.iteration + 1);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
18
|
/** The goal-bound poke: restate the goal and wake goal_wait if applicable. */
|
|
28
19
|
export function buildGoalPoke(
|
|
29
20
|
loop: LoopState,
|
|
@@ -57,7 +48,7 @@ export function buildCompactionInstructions(
|
|
|
57
48
|
const objective = goal
|
|
58
49
|
? `The session is working toward this goal: ${goal.text}`
|
|
59
50
|
: loop.prompt
|
|
60
|
-
? `The session is running a recurring
|
|
51
|
+
? `The session is running a recurring loop focused on: ${loop.prompt}`
|
|
61
52
|
: "The session is running a recurring loop.";
|
|
62
53
|
return [
|
|
63
54
|
`${objective}`,
|
|
@@ -86,7 +77,7 @@ export function buildPostCompactContinuation(
|
|
|
86
77
|
"Context was just compacted. Loop status, restored from outside the context window:",
|
|
87
78
|
`- loop iteration: ${loop.iteration}${loop.maxIterations === null ? "" : ` of ${loop.maxIterations}`}, waking every ${formatDuration(loop.intervalMs)}`,
|
|
88
79
|
];
|
|
89
|
-
if (loop.prompt) lines.push(`-
|
|
80
|
+
if (loop.prompt) lines.push(`- loop focus: ${loop.prompt}`);
|
|
90
81
|
if (goal) {
|
|
91
82
|
lines.push(`- active goal (status ${goal.status}): ${goal.text}`);
|
|
92
83
|
const thresholds: string[] = [];
|
package/src/settings.ts
CHANGED
|
@@ -28,10 +28,6 @@ export interface LoopSettings {
|
|
|
28
28
|
/** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
|
|
29
29
|
maxLoopDuration: string;
|
|
30
30
|
compaction: LoopCompactionSettings;
|
|
31
|
-
/** Override for the built-in poke preamble template. */
|
|
32
|
-
pokePreamble: string | null;
|
|
33
|
-
/** Recognize /loop mid-prompt. */
|
|
34
|
-
inlineInvocation: boolean;
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
@@ -43,8 +39,6 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
|
43
39
|
postCompactContinuation: true,
|
|
44
40
|
instructions: null,
|
|
45
41
|
},
|
|
46
|
-
pokePreamble: null,
|
|
47
|
-
inlineInvocation: true,
|
|
48
42
|
};
|
|
49
43
|
|
|
50
44
|
export type LoopSettingsLoadResult =
|
|
@@ -95,26 +89,10 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
95
89
|
return undefined;
|
|
96
90
|
}
|
|
97
91
|
|
|
98
|
-
const pokePreamble = readNullableString(
|
|
99
|
-
record,
|
|
100
|
-
"pokePreamble",
|
|
101
|
-
DEFAULT_LOOP_SETTINGS.pokePreamble,
|
|
102
|
-
);
|
|
103
|
-
if (pokePreamble === false) return undefined;
|
|
104
|
-
|
|
105
|
-
const inlineInvocation = readBoolean(
|
|
106
|
-
record,
|
|
107
|
-
"inlineInvocation",
|
|
108
|
-
DEFAULT_LOOP_SETTINGS.inlineInvocation,
|
|
109
|
-
);
|
|
110
|
-
if (typeof inlineInvocation !== "boolean") return undefined;
|
|
111
|
-
|
|
112
92
|
return {
|
|
113
93
|
maxIterations,
|
|
114
94
|
maxLoopDuration,
|
|
115
95
|
compaction: { enabled, threshold, postCompactContinuation, instructions },
|
|
116
|
-
pokePreamble,
|
|
117
|
-
inlineInvocation,
|
|
118
96
|
};
|
|
119
97
|
}
|
|
120
98
|
|
|
@@ -201,8 +179,6 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
|
|
|
201
179
|
maxIterations: normalized.maxIterations,
|
|
202
180
|
maxLoopDuration: normalized.maxLoopDuration,
|
|
203
181
|
compaction: { ...compaction, ...normalized.compaction },
|
|
204
|
-
pokePreamble: normalized.pokePreamble,
|
|
205
|
-
inlineInvocation: normalized.inlineInvocation,
|
|
206
182
|
},
|
|
207
183
|
null,
|
|
208
184
|
2,
|
package/src/state.ts
CHANGED
|
@@ -87,11 +87,17 @@ interface SessionEntryLike {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function lastCustomEntryData(entries: unknown[], customType: string): unknown {
|
|
90
|
-
|
|
90
|
+
return lastCustomEntryDatas(entries, customType, 1)[0];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Newest-first data of the last `limit` custom entries of `customType`. */
|
|
94
|
+
function lastCustomEntryDatas(entries: unknown[], customType: string, limit: number): unknown[] {
|
|
95
|
+
const datas: unknown[] = [];
|
|
96
|
+
for (let index = entries.length - 1; index >= 0 && datas.length < limit; index -= 1) {
|
|
91
97
|
const entry = entries[index] as SessionEntryLike | undefined;
|
|
92
|
-
if (entry?.type === "custom" && entry.customType === customType)
|
|
98
|
+
if (entry?.type === "custom" && entry.customType === customType) datas.push(entry.data);
|
|
93
99
|
}
|
|
94
|
-
return
|
|
100
|
+
return datas;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/** Restore the persisted loop state from a session branch, fail-open. */
|
|
@@ -120,11 +126,26 @@ export interface GoalSnapshot {
|
|
|
120
126
|
* entry shape is not recognizably a goal. Only fields pi-loop consumes are
|
|
121
127
|
* extracted; unknown statuses are preserved verbatim so the caller can treat
|
|
122
128
|
* anything outside its known sets conservatively.
|
|
129
|
+
*
|
|
130
|
+
* Completion race: pi-goal persists the finished goal (status "complete") and
|
|
131
|
+
* then clears the entry (goal: null), so by the loop's next tick the last
|
|
132
|
+
* entry is the clear. A clear whose immediately preceding entry is a complete
|
|
133
|
+
* goal therefore reports that complete goal — the loop must stop with
|
|
134
|
+
* "goal completed", not pause as goal-missing. A clear over any other status
|
|
135
|
+
* (user /goal clear mid-flight) still reads as no goal.
|
|
123
136
|
*/
|
|
124
137
|
export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
138
|
+
const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, 2);
|
|
139
|
+
const newest = ownRecord(datas[0]);
|
|
140
|
+
if (!newest) return undefined;
|
|
141
|
+
const goal = parseGoalSnapshot(newest.goal);
|
|
142
|
+
if (goal) return goal;
|
|
143
|
+
const cleared = parseGoalSnapshot(ownRecord(datas[1])?.goal);
|
|
144
|
+
return cleared?.status === "complete" ? cleared : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
|
|
148
|
+
const goal = ownRecord(value);
|
|
128
149
|
if (!goal) return undefined;
|
|
129
150
|
const status = typeof goal.status === "string" ? goal.status : undefined;
|
|
130
151
|
const text = typeof goal.text === "string" ? goal.text.trim() : "";
|
package/src/inline-command.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline slash-command detection.
|
|
3
|
-
*
|
|
4
|
-
* Pi dispatches extension commands only when a message *starts* with the
|
|
5
|
-
* command token; a command mentioned mid-prompt arrives as plain text. This
|
|
6
|
-
* helper finds the first inline occurrence of a command token so the caller
|
|
7
|
-
* can dispatch it and deliver the surrounding prose separately.
|
|
8
|
-
*
|
|
9
|
-
* This module is deliberately package-agnostic: every package-specific detail
|
|
10
|
-
* is a parameter so the file can be duplicated byte-for-byte into sibling
|
|
11
|
-
* packages (see DUPLICATED_SOURCES in scripts/validate.py once a second copy
|
|
12
|
-
* exists). Edit one copy, then copy it over the other.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export interface InlineCommandSplit {
|
|
16
|
-
/** Text before the command token, trailing whitespace removed. */
|
|
17
|
-
prose: string;
|
|
18
|
-
/** Everything after the command token to end of message, trimmed. */
|
|
19
|
-
commandArgs: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Find the first inline `/<commandName>` occurrence in `text`.
|
|
24
|
-
*
|
|
25
|
-
* Detection rules:
|
|
26
|
-
* - The token must be preceded by start-of-line or whitespace and followed by
|
|
27
|
-
* whitespace plus a non-empty remainder. A bare trailing `/cmd` mention or a
|
|
28
|
-
* path-like `foo/cmd` never matches.
|
|
29
|
-
* - A token at position 0 is ignored: that is Pi's native dispatch position.
|
|
30
|
-
* - Occurrences inside backtick code (inline spans and fenced blocks) are
|
|
31
|
-
* ignored. Code regions are approximated by the CommonMark backtick-run
|
|
32
|
-
* rule: a run of N backticks opens a region closed by the next run of
|
|
33
|
-
* exactly N backticks.
|
|
34
|
-
*
|
|
35
|
-
* Returns undefined when no qualifying occurrence exists.
|
|
36
|
-
*/
|
|
37
|
-
export function extractInlineCommand(
|
|
38
|
-
text: string,
|
|
39
|
-
commandName: string,
|
|
40
|
-
): InlineCommandSplit | undefined {
|
|
41
|
-
const token = `/${commandName}`;
|
|
42
|
-
const code = codeRegions(text);
|
|
43
|
-
let searchFrom = 0;
|
|
44
|
-
while (searchFrom < text.length) {
|
|
45
|
-
const index = text.indexOf(token, searchFrom);
|
|
46
|
-
if (index === -1) return undefined;
|
|
47
|
-
searchFrom = index + 1;
|
|
48
|
-
if (index === 0) continue;
|
|
49
|
-
const before = text[index - 1];
|
|
50
|
-
if (before !== undefined && !/\s/.test(before)) continue;
|
|
51
|
-
const afterToken = text[index + token.length];
|
|
52
|
-
if (afterToken === undefined || !/\s/.test(afterToken)) continue;
|
|
53
|
-
if (insideRegion(code, index)) continue;
|
|
54
|
-
const commandArgs = text.slice(index + token.length).trim();
|
|
55
|
-
if (!commandArgs) continue;
|
|
56
|
-
return {
|
|
57
|
-
prose: text.slice(0, index).trimEnd(),
|
|
58
|
-
commandArgs,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
type Region = readonly [start: number, end: number];
|
|
65
|
-
|
|
66
|
-
function codeRegions(text: string): Region[] {
|
|
67
|
-
const regions: Region[] = [];
|
|
68
|
-
const runs: Array<{ index: number; length: number }> = [];
|
|
69
|
-
const runPattern = /`+/g;
|
|
70
|
-
for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
|
|
71
|
-
runs.push({ index: match.index, length: match[0].length });
|
|
72
|
-
}
|
|
73
|
-
for (let open = 0; open < runs.length; open += 1) {
|
|
74
|
-
const opener = runs[open];
|
|
75
|
-
if (opener === undefined) continue;
|
|
76
|
-
for (let close = open + 1; close < runs.length; close += 1) {
|
|
77
|
-
const closer = runs[close];
|
|
78
|
-
if (closer === undefined || closer.length !== opener.length) continue;
|
|
79
|
-
regions.push([opener.index, closer.index + closer.length]);
|
|
80
|
-
open = close;
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return regions;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function insideRegion(regions: readonly Region[], index: number): boolean {
|
|
88
|
-
return regions.some(([start, end]) => index >= start && index < end);
|
|
89
|
-
}
|
package/src/inline-invocation.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline /loop invocation: recognize `/loop <args>` mid-prompt through Pi's
|
|
3
|
-
* input pipeline, dispatch it command-first, and deliver surrounding prose as
|
|
4
|
-
* a follow-up. Same design as the pi-goal fork's inline /goal; the detection
|
|
5
|
-
* helper (inline-command.ts) is duplicated byte-for-byte from
|
|
6
|
-
* packages/pi-goal and registered in DUPLICATED_SOURCES.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { extractInlineCommand } from "./inline-command.js";
|
|
11
|
-
import type { LoopController } from "./loop.js";
|
|
12
|
-
|
|
13
|
-
export const INLINE_LOOP_COMMAND = "loop";
|
|
14
|
-
|
|
15
|
-
export function registerInlineInvocation(pi: ExtensionAPI, controller: LoopController) {
|
|
16
|
-
pi.on("input", (event) => {
|
|
17
|
-
// Messages sent by extensions (including our own re-dispatch below) are
|
|
18
|
-
// never rewritten; leading "/loop" is consumed by Pi's command dispatch
|
|
19
|
-
// before this event fires, so recursion is impossible either way.
|
|
20
|
-
if (event.source === "extension") return;
|
|
21
|
-
if (!controller.settings.inlineInvocation) return;
|
|
22
|
-
// A message with attached images is passed through untouched: splitting
|
|
23
|
-
// it would detach the images from the text they belong to.
|
|
24
|
-
if (event.images && event.images.length > 0) return;
|
|
25
|
-
const split = extractInlineCommand(event.text, INLINE_LOOP_COMMAND);
|
|
26
|
-
if (!split) return;
|
|
27
|
-
// While the agent streams, Pi requires an explicit delivery mode; reuse
|
|
28
|
-
// the mode the original message would have used.
|
|
29
|
-
const deliverAs = event.streamingBehavior;
|
|
30
|
-
pi.sendUserMessage(
|
|
31
|
-
`/${INLINE_LOOP_COMMAND} ${split.commandArgs}`,
|
|
32
|
-
deliverAs ? { deliverAs } : undefined,
|
|
33
|
-
);
|
|
34
|
-
if (split.prose) {
|
|
35
|
-
pi.sendUserMessage(split.prose, { deliverAs: "followUp" });
|
|
36
|
-
}
|
|
37
|
-
return { action: "handled" as const };
|
|
38
|
-
});
|
|
39
|
-
}
|