@hank-warren/pi-loop 0.4.1 → 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 +33 -0
- package/README.md +170 -32
- package/package.json +4 -2
- package/src/ack.ts +67 -0
- package/src/command.ts +92 -33
- package/src/complete-tool.ts +122 -15
- package/src/decide.ts +92 -51
- package/src/errors.ts +131 -0
- package/src/index.ts +117 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/ledger.ts +230 -0
- package/src/loop.ts +844 -129
- package/src/manager.ts +67 -46
- package/src/markers.ts +24 -3
- package/src/messages.ts +167 -35
- package/src/objective.ts +34 -9
- package/src/render.ts +28 -9
- package/src/safety.ts +98 -0
- package/src/schedule/command.ts +255 -0
- package/src/schedule/cron.ts +182 -0
- package/src/schedule/manager.ts +129 -0
- package/src/schedule/model.ts +237 -0
- package/src/schedule/runner.ts +351 -0
- package/src/schedule/store.ts +183 -0
- package/src/settings.ts +62 -5
- package/src/start-tool.ts +145 -0
- package/src/state.ts +96 -97
- package/src/wait-tool.ts +114 -0
- package/src/wait.ts +95 -0
package/src/index.ts
CHANGED
|
@@ -1,34 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-loop: Claude-Code-/loop-inspired
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* pi-loop: Claude-Code-/loop-inspired long-running work for Pi. A loop
|
|
3
|
+
* carries its own objective and completion criteria, is paced by the session
|
|
4
|
+
* settling, keeps a durable ledger, compacts itself, and ends through
|
|
5
|
+
* `loop_complete`, a cap, its expiry, or the user. It depends on no other
|
|
6
|
+
* extension; the only sibling state it reads is pi-plan-mode's, fail-open, so
|
|
7
|
+
* a loop never injects into a planning conversation.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
|
+
import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
|
|
14
|
+
import { registerLoopStartTool } from "./start-tool.js";
|
|
15
|
+
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
13
16
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
14
17
|
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
15
18
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
16
19
|
import { registerLoopMessageRendering } from "./render.js";
|
|
20
|
+
import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
|
|
21
|
+
import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
|
|
22
|
+
import { describeSchedule } from "./schedule/model.js";
|
|
23
|
+
import { Scheduler } from "./schedule/runner.js";
|
|
17
24
|
|
|
18
25
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
19
26
|
const controller = new LoopController(pi, options);
|
|
27
|
+
const scheduler = new Scheduler(pi, {
|
|
28
|
+
...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
|
|
29
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
30
|
+
});
|
|
20
31
|
// Registered unconditionally and never toggled with loop state: tools are
|
|
21
32
|
// part of the cached request prefix, so mutating the tool set mid-session
|
|
22
|
-
// would invalidate the whole conversation cache. It refuses when no
|
|
23
|
-
//
|
|
33
|
+
// would invalidate the whole conversation cache. It refuses when no loop is
|
|
34
|
+
// active.
|
|
24
35
|
registerLoopCompleteTool(pi, controller);
|
|
36
|
+
// Registered on the same terms and for the same reason: the tool set is
|
|
37
|
+
// part of the cached prefix, so it never changes with loop state.
|
|
38
|
+
registerLoopWaitTool(pi, controller);
|
|
39
|
+
// Inline invocation: an `input` handler arms a one-turn system-prompt hint
|
|
40
|
+
// for a mid-prompt `/loop` token, `before_agent_start` appends it, and
|
|
41
|
+
// loop_start is the model-invoked start it points at — refused on any turn
|
|
42
|
+
// the hint did not arm. The user's message is never transformed.
|
|
43
|
+
const invocation = new InlineInvocationState();
|
|
44
|
+
registerInlineInvocation(pi, controller, invocation);
|
|
45
|
+
registerLoopStartTool(pi, controller, invocation);
|
|
25
46
|
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
26
47
|
// stored message and model context are untouched).
|
|
27
48
|
registerLoopMessageRendering(pi);
|
|
28
49
|
|
|
29
50
|
pi.registerCommand("loop", {
|
|
30
51
|
description:
|
|
31
|
-
"
|
|
52
|
+
"Work an objective across many turns, waking the session if it goes quiet: /loop [--max N] [--compact-at 60%] [--expires 3d] <interval> [objective]",
|
|
32
53
|
getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
|
|
33
54
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
34
55
|
const command = parseLoopCommand(args);
|
|
@@ -72,30 +93,111 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
72
93
|
return;
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
|
-
controller.startLoop(ctx, command);
|
|
96
|
+
const result = controller.startLoop(ctx, command);
|
|
97
|
+
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
76
98
|
return;
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
101
|
},
|
|
80
102
|
});
|
|
81
103
|
|
|
104
|
+
// The scheduler is user-typed only, exactly like /loop: the model gets no
|
|
105
|
+
// scheduling tools, because a model that can schedule its own future turns
|
|
106
|
+
// can schedule its way around every limit the loop imposes.
|
|
107
|
+
pi.registerCommand("schedule", {
|
|
108
|
+
description:
|
|
109
|
+
'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
|
|
110
|
+
getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
|
|
111
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
112
|
+
const command = parseScheduleCommand(args, { cwd: ctx.cwd });
|
|
113
|
+
switch (command.kind) {
|
|
114
|
+
case "show":
|
|
115
|
+
await showScheduleManager(scheduler, ctx);
|
|
116
|
+
return;
|
|
117
|
+
case "list":
|
|
118
|
+
ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
|
|
119
|
+
return;
|
|
120
|
+
case "error":
|
|
121
|
+
ctx.ui.notify(command.message, "error");
|
|
122
|
+
return;
|
|
123
|
+
case "create": {
|
|
124
|
+
const { task, warning } = scheduler.create(command);
|
|
125
|
+
if (warning) {
|
|
126
|
+
ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
|
|
127
|
+
}
|
|
128
|
+
if (command.clampedFrom !== undefined) {
|
|
129
|
+
ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
|
|
130
|
+
}
|
|
131
|
+
ctx.ui.notify(
|
|
132
|
+
[
|
|
133
|
+
`Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
|
|
134
|
+
task.task.kind === "run"
|
|
135
|
+
? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
|
|
136
|
+
: "Injects a prompt into this session at an idle boundary; it dies with the session.",
|
|
137
|
+
`Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
|
|
138
|
+
].join("\n"),
|
|
139
|
+
"info",
|
|
140
|
+
);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
default: {
|
|
144
|
+
const task = scheduler.find(command.id);
|
|
145
|
+
if (!task) {
|
|
146
|
+
ctx.ui.notify(
|
|
147
|
+
`No scheduled task matches ${command.id}. Run /schedule list to see them.`,
|
|
148
|
+
"error",
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (command.kind === "status") {
|
|
153
|
+
ctx.ui.notify(describeTask(task).join("\n"), "info");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (command.kind === "pause" || command.kind === "resume") {
|
|
157
|
+
const status = command.kind === "pause" ? "paused" : "active";
|
|
158
|
+
scheduler.update({ ...task, status });
|
|
159
|
+
ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (command.kind === "run") {
|
|
163
|
+
scheduler.fireNow(task);
|
|
164
|
+
ctx.ui.notify(`Running "${task.name}" now.`, "info");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
scheduler.remove(task.id);
|
|
168
|
+
ctx.ui.notify(`Deleted "${task.name}".`, "info");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
82
174
|
pi.on("session_start", async (_event, ctx) => {
|
|
83
175
|
controller.onSessionStart(ctx);
|
|
176
|
+
scheduler.onSessionStart(ctx);
|
|
84
177
|
});
|
|
85
178
|
pi.on("session_shutdown", async () => {
|
|
86
179
|
controller.onSessionShutdown();
|
|
180
|
+
scheduler.onSessionShutdown();
|
|
181
|
+
});
|
|
182
|
+
// The pacemaker: agent_end records the intent to continue, agent_settled
|
|
183
|
+
// delivers it once Pi will accept a message.
|
|
184
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
185
|
+
controller.onAgentStart(ctx);
|
|
186
|
+
});
|
|
187
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
188
|
+
controller.onAgentEnd(ctx, event.messages ?? []);
|
|
87
189
|
});
|
|
88
190
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
89
191
|
controller.onAgentSettled(ctx);
|
|
192
|
+
scheduler.onAgentSettled(ctx);
|
|
90
193
|
});
|
|
91
|
-
// A
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// nothing here — pi-goal already owns that turn's append.
|
|
194
|
+
// A loop carries its own objective and injects it as a byte-stable system
|
|
195
|
+
// append, which is what lets the poke and continuation messages stay
|
|
196
|
+
// pointer-sized.
|
|
95
197
|
pi.on("before_agent_start", (event) => {
|
|
96
198
|
const loop = controller.state;
|
|
97
199
|
if (!loop || loop.status !== "active") return;
|
|
98
|
-
const objectivePrompt = buildLoopObjectivePrompt(loop);
|
|
200
|
+
const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
|
|
99
201
|
if (objectivePrompt === undefined) return;
|
|
100
202
|
return { systemPrompt: `${event.systemPrompt}\n\n${objectivePrompt}` };
|
|
101
203
|
});
|
|
@@ -0,0 +1,159 @@
|
|
|
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 inline occurrences of a `/cmd` token (and `cmd:` prefix lines)
|
|
7
|
+
* so the caller can react — here, by appending a reminder that tells the model
|
|
8
|
+
* to invoke the corresponding tool. It never rewrites or re-sends the user's
|
|
9
|
+
* message.
|
|
10
|
+
*
|
|
11
|
+
* Ported from @hank-warren/pi-goal, which proved the mechanism before pi-loop
|
|
12
|
+
* absorbed it; the helper was always generic over the command name.
|
|
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) or
|
|
31
|
+
* inside a single/double-quoted span are ignored. Code regions are
|
|
32
|
+
* approximated by the CommonMark backtick-run rule: a run of N backticks
|
|
33
|
+
* opens a region closed by the next run of 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 ignored = ignoredRegions(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(ignored, 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
|
+
/**
|
|
65
|
+
* True when the message inline-invokes the command: a mid-message `/cmd`
|
|
66
|
+
* token with a remainder, or a `cmd:` prefix at the start of any line — both
|
|
67
|
+
* outside backtick code and quoted spans.
|
|
68
|
+
*/
|
|
69
|
+
export function detectsInlineInvocation(text: string, commandName: string): boolean {
|
|
70
|
+
if (extractInlineCommand(text, commandName) !== undefined) return true;
|
|
71
|
+
const ignored = ignoredRegions(text);
|
|
72
|
+
const prefixPattern = new RegExp(`^[ \\t]*${escapeRegExpText(commandName)}:[ \\t]+\\S`, "gim");
|
|
73
|
+
for (
|
|
74
|
+
let match = prefixPattern.exec(text);
|
|
75
|
+
match;
|
|
76
|
+
match = prefixPattern.exec(text)
|
|
77
|
+
) {
|
|
78
|
+
if (!insideRegion(ignored, match.index)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Region = readonly [start: number, end: number];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Backtick code regions plus single/double-quoted spans. Quoting a command is
|
|
87
|
+
* how people discuss one (`use "/loop 10m ship it" to start`), so a quoted
|
|
88
|
+
* token is a mention, not an invocation.
|
|
89
|
+
*/
|
|
90
|
+
function ignoredRegions(text: string): Region[] {
|
|
91
|
+
return [...codeRegions(text), ...quoteRegions(text)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function codeRegions(text: string): Region[] {
|
|
95
|
+
const regions: Region[] = [];
|
|
96
|
+
const runs: Array<{ index: number; length: number }> = [];
|
|
97
|
+
const runPattern = /`+/g;
|
|
98
|
+
for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
|
|
99
|
+
runs.push({ index: match.index, length: match[0].length });
|
|
100
|
+
}
|
|
101
|
+
for (let open = 0; open < runs.length; open += 1) {
|
|
102
|
+
const opener = runs[open];
|
|
103
|
+
if (opener === undefined) continue;
|
|
104
|
+
for (let close = open + 1; close < runs.length; close += 1) {
|
|
105
|
+
const closer = runs[close];
|
|
106
|
+
if (closer === undefined || closer.length !== opener.length) continue;
|
|
107
|
+
regions.push([opener.index, closer.index + closer.length]);
|
|
108
|
+
open = close;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return regions;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Single- and double-quoted spans, matched conservatively so an apostrophe
|
|
117
|
+
* inside a word (`don't`) never opens one: an opening quote follows nothing,
|
|
118
|
+
* whitespace, or an opening bracket and precedes a non-space; its closing
|
|
119
|
+
* quote is on the same line, follows a non-space, and precedes end-of-line,
|
|
120
|
+
* whitespace, or closing punctuation.
|
|
121
|
+
*/
|
|
122
|
+
function quoteRegions(text: string): Region[] {
|
|
123
|
+
const regions: Region[] = [];
|
|
124
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
125
|
+
const quote = text[index];
|
|
126
|
+
if (quote !== '"' && quote !== "'") continue;
|
|
127
|
+
const before = text[index - 1];
|
|
128
|
+
const after = text[index + 1];
|
|
129
|
+
if (before !== undefined && !/[\s([{<]/.test(before)) continue;
|
|
130
|
+
if (after === undefined || /\s/.test(after)) continue;
|
|
131
|
+
const close = closingQuoteIndex(text, index, quote);
|
|
132
|
+
if (close === undefined) continue;
|
|
133
|
+
regions.push([index, close + 1]);
|
|
134
|
+
index = close;
|
|
135
|
+
}
|
|
136
|
+
return regions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function closingQuoteIndex(text: string, openIndex: number, quote: string): number | undefined {
|
|
140
|
+
for (let index = openIndex + 1; index < text.length; index += 1) {
|
|
141
|
+
const char = text[index];
|
|
142
|
+
if (char === "\n") return undefined;
|
|
143
|
+
if (char !== quote) continue;
|
|
144
|
+
const before = text[index - 1];
|
|
145
|
+
const after = text[index + 1];
|
|
146
|
+
if (before === undefined || /\s/.test(before)) continue;
|
|
147
|
+
if (after !== undefined && !/[\s.,;:!?)\]}>]/.test(after)) continue;
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function insideRegion(regions: readonly Region[], index: number): boolean {
|
|
154
|
+
return regions.some(([start, end]) => index >= start && index < end);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function escapeRegExpText(value: string) {
|
|
158
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
159
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline `/loop` invocation, tool-mediated.
|
|
3
|
+
*
|
|
4
|
+
* Pi only dispatches `/loop` when it starts the message. When the user writes
|
|
5
|
+
* `quick check /loop 10m get CI green` or a `loop:` prefixed line mid-prompt,
|
|
6
|
+
* this module appends a one-turn reminder to the system prompt so the model
|
|
7
|
+
* reliably calls the `loop_start` tool with the objective. The user's message
|
|
8
|
+
* itself is never touched — no cutting, splitting, re-sending, or visible
|
|
9
|
+
* annotation — so there are no delivery races, no message loss, and no
|
|
10
|
+
* transcript noise: guidance is injected at agent-start time, not by
|
|
11
|
+
* rewriting input.
|
|
12
|
+
*
|
|
13
|
+
* Two hooks cooperate because neither alone is safe:
|
|
14
|
+
* - `input` carries a `source` and so can tell user-typed text from
|
|
15
|
+
* extension-sent prompts, but any transform it returns rewrites the stored,
|
|
16
|
+
* visible user message. It only records the armed text here.
|
|
17
|
+
* - `before_agent_start` can extend the system prompt, but fires for
|
|
18
|
+
* extension-sent prompts too, and pi-loop's own kickoff and continuation
|
|
19
|
+
* prompts contain phrases like "the active /loop objective" that the
|
|
20
|
+
* detector would match. It injects only when the starting prompt *is* the
|
|
21
|
+
* armed user message, and disarms on every start, matched or not.
|
|
22
|
+
*
|
|
23
|
+
* Two deliberate limits keep the armed window one turn wide:
|
|
24
|
+
* - Streaming-typed input (`streamingBehavior` set, i.e. steered or queued)
|
|
25
|
+
* never arms. Pi returns from `prompt()` before `before_agent_start` for
|
|
26
|
+
* those, so an armed flag would survive to a later, unrelated turn — the
|
|
27
|
+
* window through which pi-loop's own continuation prompts could be matched.
|
|
28
|
+
* - The hint is a per-turn `systemPrompt` append, not a stored message, so
|
|
29
|
+
* "call loop_start now" cannot linger in the conversation and fire on a
|
|
30
|
+
* later turn.
|
|
31
|
+
*
|
|
32
|
+
* The armed flag is also the `loop_start` tool's gate: unlike pi-goal, which
|
|
33
|
+
* relied on prompt guidelines alone, a loop is self-continuing, so this
|
|
34
|
+
* extension *enforces* that the tool only runs on a turn the user explicitly
|
|
35
|
+
* invoked. See `InlineInvocationState.invokedThisTurn`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import { detectsInlineInvocation } from "./inline-command.js";
|
|
40
|
+
import type { LoopController } from "./loop.js";
|
|
41
|
+
|
|
42
|
+
export const INLINE_LOOP_COMMAND = "loop";
|
|
43
|
+
|
|
44
|
+
export const INLINE_LOOP_HINT =
|
|
45
|
+
"<system-reminder>The user's message this turn inline-invoked a loop (/loop or loop:). Call the loop_start tool now with the objective text that follows the token in that message, then begin working toward it. Do not answer the objective as prose without starting the loop. If the message is discussing, quoting, or documenting the /loop command rather than invoking it, do not call loop_start. This reminder applies only to the user's message this turn.</system-reminder>";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The armed state, shared between the hooks and the `loop_start` tool.
|
|
49
|
+
*
|
|
50
|
+
* `invokedThisTurn` is the hard gate: set when `before_agent_start` matched
|
|
51
|
+
* the armed user message, cleared at `agent_end` and at every session
|
|
52
|
+
* boundary. `loop_start` refuses whenever it is false, so no amount of prompt
|
|
53
|
+
* drift, transcript replay, or model initiative can start a self-continuing
|
|
54
|
+
* loop the user did not ask for.
|
|
55
|
+
*/
|
|
56
|
+
export class InlineInvocationState {
|
|
57
|
+
invokedThisTurn = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerInlineInvocation(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
controller: LoopController,
|
|
63
|
+
state: InlineInvocationState,
|
|
64
|
+
) {
|
|
65
|
+
let armedText: string | undefined;
|
|
66
|
+
|
|
67
|
+
pi.on("input", (event) => {
|
|
68
|
+
// Extension-sourced messages (loop kickoffs, continuations, pokes, other
|
|
69
|
+
// extensions' injections) never arm the hint.
|
|
70
|
+
if (event.source === "extension") return;
|
|
71
|
+
// Steered or queued input returns from prompt() without ever reaching
|
|
72
|
+
// before_agent_start, so arming it would leave stale text armed.
|
|
73
|
+
if (event.streamingBehavior !== undefined) return;
|
|
74
|
+
if (!controller.settings.inlineInvocation) return;
|
|
75
|
+
if (!detectsInlineInvocation(event.text, INLINE_LOOP_COMMAND)) return;
|
|
76
|
+
armedText = event.text;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on("before_agent_start", (event) => {
|
|
80
|
+
const armed = armedText;
|
|
81
|
+
armedText = undefined;
|
|
82
|
+
// Every start closes the previous turn's window, so a turn that ends
|
|
83
|
+
// without an agent_end still cannot leave the tool unlocked.
|
|
84
|
+
state.invokedThisTurn = false;
|
|
85
|
+
if (armed === undefined) return;
|
|
86
|
+
if (!controller.settings.inlineInvocation) return;
|
|
87
|
+
if (!promptCarriesArmedMessage(event.prompt, armed)) return;
|
|
88
|
+
state.invokedThisTurn = true;
|
|
89
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${INLINE_LOOP_HINT}` };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const disarm = () => {
|
|
93
|
+
armedText = undefined;
|
|
94
|
+
state.invokedThisTurn = false;
|
|
95
|
+
};
|
|
96
|
+
pi.on("agent_end", disarm);
|
|
97
|
+
pi.on("session_start", disarm);
|
|
98
|
+
pi.on("session_shutdown", disarm);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True when the starting prompt is the armed user message. Pi may wrap the
|
|
103
|
+
* text with expanded prefixes or suffixes, so the armed text has to be the
|
|
104
|
+
* whole prompt or one of its ends — mirroring how upstream recognises its own
|
|
105
|
+
* owned prompts at a terminal boundary.
|
|
106
|
+
*/
|
|
107
|
+
function promptCarriesArmedMessage(prompt: string, armed: string) {
|
|
108
|
+
return prompt === armed || prompt.startsWith(armed) || prompt.endsWith(armed);
|
|
109
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop ledger: `~/.pi/agent/loop/<loop-id>/`.
|
|
3
|
+
*
|
|
4
|
+
* A multi-day loop cannot keep its state in the conversation — compaction is
|
|
5
|
+
* lossy by construction, and every summary of a summary drifts further from
|
|
6
|
+
* what actually happened. So the conversation stays the working memory and
|
|
7
|
+
* two files on disk become the record:
|
|
8
|
+
*
|
|
9
|
+
* - `criteria.json` — the completion criteria, written by the extension. JSON
|
|
10
|
+
* deliberately, not Markdown: models rewrite prose they are asked to
|
|
11
|
+
* maintain far more readily than they rewrite a structured file, and the
|
|
12
|
+
* only edit this file may receive is flipping `passes`.
|
|
13
|
+
* - `PROGRESS.md` — the agent-maintained ledger, created here with a fixed
|
|
14
|
+
* schema so "update the ledger" means the same thing on every turn.
|
|
15
|
+
*
|
|
16
|
+
* Keyed by **loop id**, not session id: session ids are not stably exposed to
|
|
17
|
+
* extensions, and one session can run several loops in sequence.
|
|
18
|
+
*
|
|
19
|
+
* Every operation here is best-effort. A read-only home directory, a full
|
|
20
|
+
* disk, or a file the user hand-edited into invalid JSON must degrade the
|
|
21
|
+
* loop to "no ledger", never break it: the ledger is an anchor for the model,
|
|
22
|
+
* not a dependency of the engine.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
|
|
29
|
+
export const LEDGER_DIR_NAME = "loop";
|
|
30
|
+
export const CRITERIA_FILE = "criteria.json";
|
|
31
|
+
export const PROGRESS_FILE = "PROGRESS.md";
|
|
32
|
+
|
|
33
|
+
/** Cap on derived criteria: an objective is a paragraph, not a backlog. */
|
|
34
|
+
const MAX_CRITERIA = 12;
|
|
35
|
+
const MAX_DESCRIPTION_LENGTH = 500;
|
|
36
|
+
|
|
37
|
+
export interface LoopCriterion {
|
|
38
|
+
id: string;
|
|
39
|
+
description: string;
|
|
40
|
+
/**
|
|
41
|
+
* How the criterion is verified. Empty means "audit against authoritative
|
|
42
|
+
* current state"; the extension writes this field and the model may not
|
|
43
|
+
* change it.
|
|
44
|
+
*/
|
|
45
|
+
check: string;
|
|
46
|
+
passes: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
|
|
50
|
+
return join(agentDir, LEDGER_DIR_NAME, loopId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split an objective into checkable criteria.
|
|
55
|
+
*
|
|
56
|
+
* Deterministic and dumb on purpose: bullets first (a user who wrote a list
|
|
57
|
+
* meant a list), otherwise sentences. An objective with no separable parts
|
|
58
|
+
* yields the single implicit criterion, so `criteria.json` is never empty and
|
|
59
|
+
* `loop_complete` always has something concrete to answer for.
|
|
60
|
+
*/
|
|
61
|
+
export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
62
|
+
const trimmed = objective.trim();
|
|
63
|
+
if (!trimmed) return [implicitCriterion(objective)];
|
|
64
|
+
const bullets = trimmed
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((line) => line.trim())
|
|
67
|
+
.filter((line) => /^([-*+]|\d+[.)])\s+/.test(line))
|
|
68
|
+
.map((line) => line.replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
|
|
71
|
+
if (parts.length < 2) return [implicitCriterion(trimmed)];
|
|
72
|
+
return parts.slice(0, MAX_CRITERIA).map((description, index) => ({
|
|
73
|
+
id: `c${index + 1}`,
|
|
74
|
+
description: truncate(description),
|
|
75
|
+
check: "",
|
|
76
|
+
passes: false,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function implicitCriterion(objective: string): LoopCriterion {
|
|
81
|
+
return {
|
|
82
|
+
id: "c1",
|
|
83
|
+
description: truncate(objective.trim()) || "objective met as stated",
|
|
84
|
+
check: "",
|
|
85
|
+
passes: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Sentence split that survives how objectives are actually typed: mostly
|
|
91
|
+
* lowercase, occasionally with an abbreviation in the middle. Splitting on
|
|
92
|
+
* any letter after a full stop would turn "e.g. run the tests" into two
|
|
93
|
+
* criteria, so a fragment following a known abbreviation is merged back.
|
|
94
|
+
*/
|
|
95
|
+
const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|no|fig|dr|mr|ms|mrs|st)\.$/iu;
|
|
96
|
+
|
|
97
|
+
function splitSentences(text: string): string[] {
|
|
98
|
+
const parts = text
|
|
99
|
+
.split(/(?<=[.!?])\s+(?=[\p{L}\d])/u)
|
|
100
|
+
.map((sentence) => sentence.trim().replace(/\s+/gu, " "))
|
|
101
|
+
.filter((sentence) => sentence.length > 2);
|
|
102
|
+
const merged: string[] = [];
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const previous = merged.at(-1);
|
|
105
|
+
if (previous !== undefined && ABBREVIATION.test(previous)) {
|
|
106
|
+
merged[merged.length - 1] = `${previous} ${part}`;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
merged.push(part);
|
|
110
|
+
}
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function truncate(value: string): string {
|
|
115
|
+
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
116
|
+
return collapsed.length <= MAX_DESCRIPTION_LENGTH
|
|
117
|
+
? collapsed
|
|
118
|
+
: `${collapsed.slice(0, MAX_DESCRIPTION_LENGTH - 1)}…`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface LedgerPaths {
|
|
122
|
+
dir: string;
|
|
123
|
+
criteria: string;
|
|
124
|
+
progress: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function ledgerPaths(loopId: string, agentDir?: string): LedgerPaths {
|
|
128
|
+
const dir = loopLedgerDir(loopId, agentDir);
|
|
129
|
+
return { dir, criteria: join(dir, CRITERIA_FILE), progress: join(dir, PROGRESS_FILE) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create the ledger for a loop. Returns the failure reason, or undefined on
|
|
134
|
+
* success — the caller warns once and carries on either way.
|
|
135
|
+
*
|
|
136
|
+
* `criteria.json` is authoritative and overwritten on start (a new loop has
|
|
137
|
+
* new criteria). `PROGRESS.md` is only ever created, never overwritten: it is
|
|
138
|
+
* the agent's file, and a session restart must not erase days of ledger.
|
|
139
|
+
*/
|
|
140
|
+
export function createLedger(
|
|
141
|
+
paths: LedgerPaths,
|
|
142
|
+
objective: string,
|
|
143
|
+
criteria: LoopCriterion[],
|
|
144
|
+
): string | undefined {
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
147
|
+
writeFileSync(paths.criteria, `${JSON.stringify(criteria, null, 2)}\n`, "utf8");
|
|
148
|
+
try {
|
|
149
|
+
writeFileSync(paths.progress, progressTemplate(objective), { encoding: "utf8", flag: "wx" });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// EEXIST is the normal case on restore: keep the existing ledger.
|
|
152
|
+
if (!isNodeError(error) || error.code !== "EEXIST") throw error;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return formatError(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function progressTemplate(objective: string): string {
|
|
161
|
+
return [
|
|
162
|
+
"# Loop progress ledger",
|
|
163
|
+
"",
|
|
164
|
+
`Objective: ${objective.replace(/\s+/gu, " ").trim()}`,
|
|
165
|
+
"",
|
|
166
|
+
"Maintained by the agent. Keep these four sections; replace their contents.",
|
|
167
|
+
"",
|
|
168
|
+
"## Current status",
|
|
169
|
+
"",
|
|
170
|
+
"Not started.",
|
|
171
|
+
"",
|
|
172
|
+
"## Completed",
|
|
173
|
+
"",
|
|
174
|
+
"- (nothing yet)",
|
|
175
|
+
"",
|
|
176
|
+
"## Failed approaches and why",
|
|
177
|
+
"",
|
|
178
|
+
"- (nothing yet)",
|
|
179
|
+
"",
|
|
180
|
+
"## Next actions",
|
|
181
|
+
"",
|
|
182
|
+
"- (nothing yet)",
|
|
183
|
+
"",
|
|
184
|
+
].join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read the criteria back, fail-open: undefined when absent or unreadable. */
|
|
188
|
+
export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
|
|
189
|
+
let contents: string;
|
|
190
|
+
try {
|
|
191
|
+
contents = readFileSync(paths.criteria, "utf8");
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const parsed: unknown = JSON.parse(contents);
|
|
197
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
198
|
+
const criteria: LoopCriterion[] = [];
|
|
199
|
+
for (const value of parsed) {
|
|
200
|
+
const criterion = normalizeCriterion(value);
|
|
201
|
+
if (!criterion) return undefined;
|
|
202
|
+
criteria.push(criterion);
|
|
203
|
+
}
|
|
204
|
+
return criteria.length > 0 ? criteria : undefined;
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeCriterion(value: unknown): LoopCriterion | undefined {
|
|
211
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
212
|
+
const record = value as Record<string, unknown>;
|
|
213
|
+
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
214
|
+
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
215
|
+
if (!id || !description) return undefined;
|
|
216
|
+
return {
|
|
217
|
+
id,
|
|
218
|
+
description,
|
|
219
|
+
check: typeof record.check === "string" ? record.check : "",
|
|
220
|
+
passes: record.passes === true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
225
|
+
return error instanceof Error && "code" in error;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatError(error: unknown): string {
|
|
229
|
+
return error instanceof Error ? error.message : String(error);
|
|
230
|
+
}
|