@hank-warren/pi-loop 0.5.0 → 0.6.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 +18 -0
- package/README.md +37 -35
- package/package.json +1 -1
- package/src/complete-tool.ts +2 -2
- package/src/decide.ts +20 -68
- package/src/index.ts +24 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/loop.ts +103 -184
- package/src/manager.ts +12 -17
- package/src/messages.ts +12 -36
- package/src/objective.ts +5 -7
- package/src/render.ts +3 -7
- package/src/settings.ts +35 -4
- package/src/start-tool.ts +145 -0
- package/src/state.ts +16 -96
- package/src/wait-tool.ts +2 -2
package/src/manager.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { formatDuration, parseInterval } from "./interval.js";
|
|
9
9
|
import type { LoopController } from "./loop.js";
|
|
10
|
-
import { readGoalSnapshot } from "./state.js";
|
|
11
10
|
import {
|
|
12
11
|
DEFAULT_LOOP_SETTINGS,
|
|
13
12
|
type LoopSettings,
|
|
@@ -79,39 +78,35 @@ async function startFromMenu(
|
|
|
79
78
|
ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
|
|
80
79
|
return;
|
|
81
80
|
}
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// required — asking for it here is what replaces the old dead-end refusal.
|
|
85
|
-
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
86
|
-
const goalBound = goal?.status === "active";
|
|
81
|
+
// The loop owns its objective, so the text is required — asking for it here
|
|
82
|
+
// is what replaces the old dead-end refusal.
|
|
87
83
|
const promptText = await ctx.ui.input(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: "Objective, including how the loop knows it is done",
|
|
91
|
-
goalBound ? undefined : "e.g. get CI green on main, verified by a passing run",
|
|
84
|
+
"Objective, including how the loop knows it is done",
|
|
85
|
+
"e.g. get CI green on main, verified by a passing run",
|
|
92
86
|
);
|
|
93
87
|
if (promptText === undefined) return;
|
|
94
88
|
const prompt = promptText.trim();
|
|
95
|
-
if (!
|
|
96
|
-
ctx.ui.notify(
|
|
97
|
-
"A loop with no active goal needs its own objective, so no loop was started.",
|
|
98
|
-
"warning",
|
|
99
|
-
);
|
|
89
|
+
if (!prompt) {
|
|
90
|
+
ctx.ui.notify("A loop needs an objective, so no loop was started.", "warning");
|
|
100
91
|
return;
|
|
101
92
|
}
|
|
102
|
-
controller.startLoop(ctx, {
|
|
93
|
+
const result = controller.startLoop(ctx, {
|
|
103
94
|
kind: "start",
|
|
104
95
|
requestedMs: interval.requestedMs,
|
|
105
96
|
intervalMs: interval.effectiveMs,
|
|
106
97
|
clamped: interval.clamped,
|
|
107
98
|
...(prompt ? { prompt } : {}),
|
|
108
99
|
});
|
|
100
|
+
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
109
101
|
}
|
|
110
102
|
|
|
111
103
|
async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
|
|
112
104
|
const loop = controller.state;
|
|
113
105
|
if (!loop || loop.status === "stopped") return;
|
|
114
|
-
const next = await ctx.ui.input(
|
|
106
|
+
const next = await ctx.ui.input(
|
|
107
|
+
"Loop focus (optional, restated on every loop message)",
|
|
108
|
+
loop.prompt ?? "",
|
|
109
|
+
);
|
|
115
110
|
if (next === undefined) return;
|
|
116
111
|
const prompt = next.trim();
|
|
117
112
|
if (prompt) controller.state = { ...loop, prompt };
|
package/src/messages.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { LOOP_OK_TOKEN } from "./ack.js";
|
|
|
10
10
|
import { formatDuration } from "./interval.js";
|
|
11
11
|
import { CRITERIA_FILE, type LedgerPaths, PROGRESS_FILE } from "./ledger.js";
|
|
12
12
|
import { appendContinuationMarker, appendPokeMarker } from "./markers.js";
|
|
13
|
-
import type {
|
|
13
|
+
import type { LoopState } from "./state.js";
|
|
14
14
|
|
|
15
15
|
/** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
|
|
16
16
|
export type ContinuationKind = "kickoff" | "continue" | "reanchor";
|
|
@@ -21,30 +21,10 @@ function formatIteration(loop: LoopState): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* The
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* tokens in the conversation on every wake (see README: cross-extension
|
|
29
|
-
* assumption). Only the dynamic per-wake state (iteration, reason) belongs in
|
|
30
|
-
* this tail message.
|
|
31
|
-
*/
|
|
32
|
-
export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-waiting"): string {
|
|
33
|
-
const lines = [
|
|
34
|
-
`Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
35
|
-
reason === "goal-waiting"
|
|
36
|
-
? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
|
|
37
|
-
: "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.",
|
|
38
|
-
];
|
|
39
|
-
if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
|
|
40
|
-
return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The standalone poke. Slim for the same reason the goal-bound one is: this
|
|
45
|
-
* loop's own objective injection puts the objective and loop-mode rules in
|
|
46
|
-
* the system prompt of every turn, so restating them here would store a
|
|
47
|
-
* duplicate copy on every wake.
|
|
24
|
+
* The poke. Deliberately slim: the loop's own objective injection puts the
|
|
25
|
+
* objective and loop-mode rules in the system prompt of every turn, so
|
|
26
|
+
* restating them here would store a duplicate copy on every wake. Only the
|
|
27
|
+
* dynamic per-wake state (iteration, reason) belongs in this tail message.
|
|
48
28
|
*/
|
|
49
29
|
export function buildObjectivePoke(
|
|
50
30
|
loop: LoopState,
|
|
@@ -81,8 +61,8 @@ function addCancelledWaitHint(lines: string[], loop: LoopState): void {
|
|
|
81
61
|
}
|
|
82
62
|
|
|
83
63
|
/**
|
|
84
|
-
* The settle-driven continuation: the message that actually paces a
|
|
85
|
-
*
|
|
64
|
+
* The settle-driven continuation: the message that actually paces a loop.
|
|
65
|
+
* Pointer-sized for the same reason the pokes are — it
|
|
86
66
|
* only ever fires while the loop is active, so the byte-stable system append
|
|
87
67
|
* carrying the objective and loop-mode rules is guaranteed present on that
|
|
88
68
|
* turn.
|
|
@@ -207,20 +187,16 @@ export function extractNextActions(summary: string, maxLength = 240): string | u
|
|
|
207
187
|
*/
|
|
208
188
|
export function buildCompactionInstructions(
|
|
209
189
|
loop: LoopState,
|
|
210
|
-
/** The goal only when it is still active; a finished goal is not the objective. */
|
|
211
|
-
goal: GoalSnapshot | undefined,
|
|
212
190
|
override: string | null,
|
|
213
191
|
/** The loop's ledger, when it has one. */
|
|
214
192
|
ledger?: LedgerPaths,
|
|
215
193
|
): string {
|
|
216
194
|
if (override) return override;
|
|
217
|
-
const objective =
|
|
218
|
-
? `The session is working toward this
|
|
219
|
-
: loop.
|
|
220
|
-
? `The session is
|
|
221
|
-
: loop.
|
|
222
|
-
? `The session is running a recurring loop focused on: ${loop.prompt}`
|
|
223
|
-
: "The session is running a recurring loop.";
|
|
195
|
+
const objective = loop.objective
|
|
196
|
+
? `The session is working toward this loop objective: ${loop.objective}`
|
|
197
|
+
: loop.prompt
|
|
198
|
+
? `The session is running a recurring loop focused on: ${loop.prompt}`
|
|
199
|
+
: "The session is running a recurring loop.";
|
|
224
200
|
return [
|
|
225
201
|
`${objective}`,
|
|
226
202
|
"This summary must let that work continue seamlessly. Preserve verbatim:",
|
package/src/objective.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The loop's objective injection.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* discipline, because the alternative (restating the objective in every poke)
|
|
9
|
-
* is exactly the per-wake duplication that discipline exists to remove.
|
|
4
|
+
* The loop carries its own objective to the model on every active turn, which
|
|
5
|
+
* is what lets the pokes and continuations stay pointer-sized. The
|
|
6
|
+
* alternative — restating the objective in every poke — is exactly the
|
|
7
|
+
* per-wake duplication the cache-stability discipline below exists to remove.
|
|
10
8
|
*
|
|
11
9
|
* Cache-stability contract: this append lands inside the provider's cached
|
|
12
10
|
* system block (Anthropic caches tools -> system -> messages as one prefix),
|
package/src/render.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* text plus a provenance marker comment. This transformer collapses each into
|
|
6
6
|
* a one-line themed chip in the transcript. Display-only by Pi contract: the
|
|
7
7
|
* stored message and model context are untouched, and pokes keep being
|
|
8
|
-
* delivered through sendUserMessage so
|
|
9
|
-
* (which appends the
|
|
8
|
+
* delivered through sendUserMessage so the loop's own before_agent_start hook
|
|
9
|
+
* (which appends the objective) still fires for every poke turn.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -29,11 +29,7 @@ export function compactPokeMessage(markdown: string) {
|
|
|
29
29
|
if (!extractPokeMarker(markdown)) return undefined;
|
|
30
30
|
const head = POKE_HEAD_PATTERN.exec(markdown);
|
|
31
31
|
if (!head) return undefined;
|
|
32
|
-
const reason = markdown.includes("
|
|
33
|
-
? "waiting"
|
|
34
|
-
: markdown.includes("completion criteria are not met")
|
|
35
|
-
? "objective"
|
|
36
|
-
: "stalled";
|
|
32
|
+
const reason = markdown.includes("wait you asked for has elapsed") ? "wait elapsed" : "stalled";
|
|
37
33
|
const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
|
|
38
34
|
return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
|
|
39
35
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global pi-loop settings: `~/.pi/agent/pi-loop.json`. Follows the sibling
|
|
3
|
-
* convention (pi-
|
|
3
|
+
* convention (pi-plan-mode): an absent file means defaults and is
|
|
4
4
|
* never created implicitly, saves are atomic and preserve unknown fields, and
|
|
5
5
|
* an invalid file warns and falls back to defaults without being overwritten.
|
|
6
6
|
*/
|
|
@@ -37,6 +37,19 @@ export interface LoopSettings {
|
|
|
37
37
|
noProgressTurns: number | null;
|
|
38
38
|
/** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
|
|
39
39
|
maxLoopDuration: string;
|
|
40
|
+
/**
|
|
41
|
+
* Detect an inline `/loop` token or a `loop:` prefixed line mid-prompt and
|
|
42
|
+
* point the model at the `loop_start` tool. Pi only dispatches `/loop` from
|
|
43
|
+
* position 0, so without this a mid-prompt invocation is silently prose.
|
|
44
|
+
*/
|
|
45
|
+
inlineInvocation: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Fallback heartbeat used by an inline invocation that names no interval.
|
|
48
|
+
* In a settle-paced loop the interval is only a fallback — the settle
|
|
49
|
+
* boundary is the pacemaker — so this value is far less consequential than
|
|
50
|
+
* it looks; it is still clamped to MIN_INTERVAL_MS.
|
|
51
|
+
*/
|
|
52
|
+
defaultInterval: string;
|
|
40
53
|
compaction: LoopCompactionSettings;
|
|
41
54
|
}
|
|
42
55
|
|
|
@@ -45,6 +58,8 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
|
45
58
|
automaticTurns: 25,
|
|
46
59
|
noProgressTurns: 3,
|
|
47
60
|
maxLoopDuration: "7d",
|
|
61
|
+
inlineInvocation: true,
|
|
62
|
+
defaultInterval: "10m",
|
|
48
63
|
compaction: {
|
|
49
64
|
enabled: true,
|
|
50
65
|
threshold: 0.7,
|
|
@@ -80,12 +95,24 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
80
95
|
return undefined;
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
|
|
99
|
+
? record.inlineInvocation
|
|
100
|
+
: DEFAULT_LOOP_SETTINGS.inlineInvocation;
|
|
101
|
+
if (typeof inlineInvocation !== "boolean") return undefined;
|
|
102
|
+
|
|
103
|
+
const defaultInterval = Object.hasOwn(record, "defaultInterval")
|
|
104
|
+
? record.defaultInterval
|
|
105
|
+
: DEFAULT_LOOP_SETTINGS.defaultInterval;
|
|
106
|
+
if (typeof defaultInterval !== "string" || parseDuration(defaultInterval) === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
83
110
|
const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
|
|
84
111
|
if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
|
|
85
112
|
const compactionRecord = ownRecord(compactionValue) ?? {};
|
|
86
|
-
// `postCompactContinuation` was removed in favour of
|
|
87
|
-
//
|
|
88
|
-
//
|
|
113
|
+
// `postCompactContinuation` was removed in favour of the loop's own
|
|
114
|
+
// re-anchor; a file still carrying it is preserved as an unknown field and
|
|
115
|
+
// ignored, never rejected.
|
|
89
116
|
const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
|
|
90
117
|
const threshold = Object.hasOwn(compactionRecord, "threshold")
|
|
91
118
|
? compactionRecord.threshold
|
|
@@ -111,6 +138,8 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
111
138
|
automaticTurns,
|
|
112
139
|
noProgressTurns,
|
|
113
140
|
maxLoopDuration,
|
|
141
|
+
inlineInvocation,
|
|
142
|
+
defaultInterval,
|
|
114
143
|
compaction: { enabled, threshold, instructions },
|
|
115
144
|
};
|
|
116
145
|
}
|
|
@@ -199,6 +228,8 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
|
|
|
199
228
|
automaticTurns: normalized.automaticTurns,
|
|
200
229
|
noProgressTurns: normalized.noProgressTurns,
|
|
201
230
|
maxLoopDuration: normalized.maxLoopDuration,
|
|
231
|
+
inlineInvocation: normalized.inlineInvocation,
|
|
232
|
+
defaultInterval: normalized.defaultInterval,
|
|
202
233
|
compaction: { ...compaction, ...normalized.compaction },
|
|
203
234
|
},
|
|
204
235
|
null,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop_start`: the model-invoked way into a loop.
|
|
3
|
+
*
|
|
4
|
+
* Pi only dispatches `/loop` when it starts the message, so a mid-prompt
|
|
5
|
+
* `quick check /loop 10m get CI green` arrives as ordinary prose. The
|
|
6
|
+
* inline-invocation hooks append a one-turn reminder to the system prompt for
|
|
7
|
+
* exactly that message, and this tool is what the reminder points at. It
|
|
8
|
+
* reuses `LoopController.startLoop`, the same path the `/loop` command takes,
|
|
9
|
+
* so replacement rules, the loop_complete availability guard, the ledger, the
|
|
10
|
+
* kickoff anchor, and persistence all behave identically.
|
|
11
|
+
*
|
|
12
|
+
* The hard armed-gate is the deliberate divergence from pi-goal's equivalent
|
|
13
|
+
* tool, which relied on prompt guidelines alone. A loop is *self-continuing*:
|
|
14
|
+
* a spurious start does not produce one unwanted answer, it produces turns
|
|
15
|
+
* until a cap. So the tool refuses outright unless the inline hint armed for
|
|
16
|
+
* the turn that is calling it.
|
|
17
|
+
*
|
|
18
|
+
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
19
|
+
* of the cached request prefix, so it never changes with loop state.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
25
|
+
import { formatDuration, parseDuration, parseInterval } from "./interval.js";
|
|
26
|
+
import type { InlineInvocationState } from "./inline-invocation.js";
|
|
27
|
+
import type { LoopController } from "./loop.js";
|
|
28
|
+
|
|
29
|
+
export const LOOP_START_TOOL = "loop_start";
|
|
30
|
+
|
|
31
|
+
/** Long enough for a real objective, short enough to reject a pasted file. */
|
|
32
|
+
const MAX_OBJECTIVE_LENGTH = 4_000;
|
|
33
|
+
|
|
34
|
+
export function registerLoopStartTool(
|
|
35
|
+
pi: ExtensionAPI,
|
|
36
|
+
controller: LoopController,
|
|
37
|
+
invocation: InlineInvocationState,
|
|
38
|
+
) {
|
|
39
|
+
pi.registerTool(
|
|
40
|
+
defineTool({
|
|
41
|
+
name: LOOP_START_TOOL,
|
|
42
|
+
label: "Loop Start",
|
|
43
|
+
description:
|
|
44
|
+
"Start a /loop for an objective the user explicitly invoked with an inline /loop or loop: token in their message. Only for explicit invocations: never start a loop from general conversation, your own initiative, or an instruction that merely sounds loop-like. The objective is the text following the token.",
|
|
45
|
+
promptSnippet:
|
|
46
|
+
"Start a /loop when the user's message contains an explicit inline /loop or loop: invocation",
|
|
47
|
+
promptGuidelines: [
|
|
48
|
+
"Call loop_start only when the user's message contains an explicit `/loop <objective>` or `loop: <objective>` token. Never start a loop without that token, no matter how loop-like the request sounds; the tool refuses when the turn carries no inline invocation.",
|
|
49
|
+
"If the user is discussing, quoting, or documenting the /loop command rather than invoking it — asking how it works, pasting a transcript, or editing text that mentions it — do not call loop_start.",
|
|
50
|
+
"Pass the objective text that follows the token, without the token itself. A leading interval (`10m`, `2h`) and flags like `--max 5` or `--expires 3d` become the interval, max, and expires parameters, not part of the objective.",
|
|
51
|
+
"Call loop_start before doing any of the objective's work, then continue working toward it in the same turn.",
|
|
52
|
+
"Never call loop_complete in the same turn as loop_start: the starting turn has not done the work, and completion needs cited evidence per criterion.",
|
|
53
|
+
],
|
|
54
|
+
parameters: Type.Object({
|
|
55
|
+
objective: Type.String({
|
|
56
|
+
minLength: 1,
|
|
57
|
+
maxLength: MAX_OBJECTIVE_LENGTH,
|
|
58
|
+
description:
|
|
59
|
+
"The loop objective, including how the loop knows it is done: the user's text following the /loop or loop: token, verbatim, without the token, the interval, or flags.",
|
|
60
|
+
}),
|
|
61
|
+
interval: Type.Optional(
|
|
62
|
+
Type.String({
|
|
63
|
+
description:
|
|
64
|
+
"Fallback wake interval from the invocation, e.g. '10m' or '2h'. Omit when the user named none.",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
max: Type.Optional(
|
|
68
|
+
Type.Integer({
|
|
69
|
+
minimum: 1,
|
|
70
|
+
description: "Wake cap from a --max flag in the invocation.",
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
expires: Type.Optional(
|
|
74
|
+
Type.String({
|
|
75
|
+
description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
}),
|
|
79
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
80
|
+
// The gate. Everything below is ordinary validation; this is the
|
|
81
|
+
// one check that makes a self-continuing tool safe to expose.
|
|
82
|
+
if (!invocation.invokedThisTurn) {
|
|
83
|
+
return refusal(
|
|
84
|
+
`${LOOP_START_TOOL} is only available on a turn whose user message contains an explicit inline /loop or loop: invocation. This turn has none, so no loop was started. If the user wants one, they can type /loop <interval> <objective>.`,
|
|
85
|
+
{},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const objective = params.objective.trim();
|
|
89
|
+
if (!objective) {
|
|
90
|
+
return refusal("Loop not started: the objective is empty.", {});
|
|
91
|
+
}
|
|
92
|
+
const existing = controller.state;
|
|
93
|
+
if (existing && existing.status !== "stopped") {
|
|
94
|
+
return refusal(
|
|
95
|
+
`Loop not started: a loop already exists in this session (${existing.status}). The user can replace it with /loop, or stop it with /loop stop.`,
|
|
96
|
+
{ existingLoopId: existing.id },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const intervalToken = params.interval?.trim() || controller.settings.defaultInterval;
|
|
100
|
+
const interval = parseInterval(intervalToken);
|
|
101
|
+
if (!interval) {
|
|
102
|
+
return refusal(
|
|
103
|
+
`Loop not started: invalid interval ${intervalToken}. Use <number><unit> with unit s, m, h, or d, e.g. 10m.`,
|
|
104
|
+
{ objective },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
let expiresInMs: number | undefined;
|
|
108
|
+
if (params.expires !== undefined) {
|
|
109
|
+
expiresInMs = parseDuration(params.expires.trim());
|
|
110
|
+
if (expiresInMs === undefined) {
|
|
111
|
+
return refusal(
|
|
112
|
+
`Loop not started: invalid expiry ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
|
|
113
|
+
{ objective },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const result = controller.startLoop(ctx, {
|
|
118
|
+
kind: "start",
|
|
119
|
+
requestedMs: interval.requestedMs,
|
|
120
|
+
intervalMs: interval.effectiveMs,
|
|
121
|
+
clamped: interval.clamped,
|
|
122
|
+
...(params.max === undefined ? {} : { maxIterations: params.max }),
|
|
123
|
+
...(expiresInMs === undefined ? {} : { expiresInMs }),
|
|
124
|
+
prompt: objective,
|
|
125
|
+
});
|
|
126
|
+
if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
|
|
127
|
+
const loop = result.loop;
|
|
128
|
+
return {
|
|
129
|
+
content: toolContent(
|
|
130
|
+
`Loop started (loop_id ${loop.id}): ${objective}. Fallback wake every ${formatDuration(loop.intervalMs)}. Keep working the objective this turn; the loop continues at every idle boundary until you call ${LOOP_COMPLETE_TOOL} with this loop_id and cited evidence for every criterion, a cap is reached, or the user stops it. Do not call ${LOOP_COMPLETE_TOOL} in this turn.`,
|
|
131
|
+
),
|
|
132
|
+
details: { loopId: loop.id, objective, intervalMs: loop.intervalMs },
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function refusal(text: string, details: Record<string, unknown>) {
|
|
140
|
+
return { content: toolContent(text), details, isError: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toolContent(text: string) {
|
|
144
|
+
return [{ type: "text" as const, text }];
|
|
145
|
+
}
|
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,13 +17,16 @@ 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;
|
|
@@ -172,17 +173,11 @@ interface SessionEntryLike {
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
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) {
|
|
176
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
182
177
|
const entry = entries[index] as SessionEntryLike | undefined;
|
|
183
|
-
if (entry?.type === "custom" && entry.customType === customType)
|
|
178
|
+
if (entry?.type === "custom" && entry.customType === customType) return entry.data;
|
|
184
179
|
}
|
|
185
|
-
return
|
|
180
|
+
return undefined;
|
|
186
181
|
}
|
|
187
182
|
|
|
188
183
|
/** Restore the persisted loop state from a session branch, fail-open. */
|
|
@@ -193,77 +188,6 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
|
|
|
193
188
|
return normalizeLoopState(record.loop);
|
|
194
189
|
}
|
|
195
190
|
|
|
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
191
|
/** Read pi-plan-mode's persisted state, fail-open: absent or malformed = not planning. */
|
|
268
192
|
export function readPlanModeEnabled(entries: unknown[]): boolean {
|
|
269
193
|
const data = ownRecord(lastCustomEntryData(entries, PLAN_MODE_STATE_ENTRY_TYPE));
|
|
@@ -288,10 +212,6 @@ function isPositiveSafeInteger(value: unknown): value is number {
|
|
|
288
212
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
289
213
|
}
|
|
290
214
|
|
|
291
|
-
function isNonNegativeNumber(value: unknown): value is number {
|
|
292
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
215
|
function isTimestamp(value: unknown): value is number {
|
|
296
216
|
return (
|
|
297
217
|
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,
|