@hank-warren/pi-loop 0.9.0 → 1.1.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 +26 -0
- package/README.md +35 -32
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +64 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/index.ts +119 -87
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +2 -1
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +120 -25
- package/src/manager.ts +118 -104
- package/src/messages.ts +1 -1
- package/src/objective.ts +25 -0
- package/src/planning.ts +67 -23
- package/src/presentation.ts +55 -18
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +35 -14
- package/src/settings.ts +27 -22
- package/src/state.ts +33 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/propose-tool.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* `loop_propose`: put a drafted loop up for the user's approval.
|
|
3
3
|
*
|
|
4
4
|
* It starts nothing. That separation is the point — the model drafts, the user
|
|
5
|
-
* approves, and the approval is what
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* approves, and the approval is what starts the loop. A loop is
|
|
6
|
+
* self-continuing and must never begin on model initiative, so this is the
|
|
7
|
+
* only way a model can put one in front of a user: a card showing the
|
|
8
|
+
* objective, the criteria, the ground rules, the cadence and the caps, with
|
|
9
|
+
* the start reserved to the human reading it.
|
|
10
10
|
*
|
|
11
11
|
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
12
12
|
* of the cached request prefix, so it never changes with loop state.
|
|
@@ -16,23 +16,27 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
18
18
|
import type { LoopController } from "./loop.js";
|
|
19
|
-
import {
|
|
19
|
+
import { MAX_GROUND_RULE_LENGTH, MAX_GROUND_RULES } from "./planning.js";
|
|
20
20
|
|
|
21
21
|
export const LOOP_PROPOSE_TOOL = "loop_propose";
|
|
22
22
|
|
|
23
|
-
export function registerLoopProposeTool(
|
|
23
|
+
export function registerLoopProposeTool(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
controller: LoopController,
|
|
26
|
+
onProposed?: () => void,
|
|
27
|
+
) {
|
|
24
28
|
pi.registerTool(
|
|
25
29
|
defineTool({
|
|
26
30
|
name: LOOP_PROPOSE_TOOL,
|
|
27
31
|
label: "Loop Propose",
|
|
28
32
|
description:
|
|
29
|
-
"Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce. Starts nothing: the user approves, edits, or cancels.",
|
|
33
|
+
"Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce, plus any ground rules. Starts nothing: the user approves, edits, or cancels.",
|
|
30
34
|
promptSnippet: "Propose a drafted loop objective for approval",
|
|
31
35
|
promptGuidelines: [
|
|
32
36
|
"Call loop_propose only while loop planning is open, and only once the objective reads as an acceptance test: one requirement per bullet, each naming the check that proves it.",
|
|
33
37
|
"Pass the objective you and the user agreed on, not a tidier version of it. The criteria are derived from this text and frozen when the loop starts.",
|
|
34
|
-
"
|
|
35
|
-
"loop_propose starts nothing, so
|
|
38
|
+
"Pass ground_rules for the hard constraints the loop must never violate while unattended. They bound how the work may be done and never gate completion, so they belong there rather than in the objective.",
|
|
39
|
+
"loop_propose starts nothing, so no rule against starting loops on your own applies to it. While planning is open, a conversational request for a loop is the signal to draft one and propose it — never to refuse and ask the user to run a command instead.",
|
|
36
40
|
],
|
|
37
41
|
parameters: Type.Object({
|
|
38
42
|
objective: Type.String({
|
|
@@ -58,6 +62,13 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
58
62
|
description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
|
|
59
63
|
}),
|
|
60
64
|
),
|
|
65
|
+
ground_rules: Type.Optional(
|
|
66
|
+
Type.Array(Type.String({ minLength: 1, maxLength: MAX_GROUND_RULE_LENGTH }), {
|
|
67
|
+
maxItems: MAX_GROUND_RULES,
|
|
68
|
+
description:
|
|
69
|
+
"Hard constraints the loop must never violate, one per entry (e.g. 'never touch production', 'never force-push', 'never edit a test to make it pass'). Constraints, not criteria: they never gate completion.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
61
72
|
}),
|
|
62
73
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
63
74
|
if (!controller.planning.active) {
|
|
@@ -67,14 +78,22 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
67
78
|
}
|
|
68
79
|
if (controller.state && controller.state.status !== "stopped") {
|
|
69
80
|
return failure(
|
|
70
|
-
"A loop is already running in this session. Stop it
|
|
81
|
+
"A loop is already running in this session. Stop it from the /loop menu before planning another.",
|
|
71
82
|
);
|
|
72
83
|
}
|
|
73
84
|
const objective = params.objective.trim();
|
|
74
85
|
if (!objective) return failure("A proposal needs an objective.");
|
|
75
86
|
|
|
76
|
-
const overrides: {
|
|
77
|
-
|
|
87
|
+
const overrides: {
|
|
88
|
+
intervalMs?: number;
|
|
89
|
+
maxTurns?: number | null;
|
|
90
|
+
expiresInMs?: number;
|
|
91
|
+
groundRules?: string[];
|
|
92
|
+
} = {};
|
|
93
|
+
// Bounded and trimmed in buildProposal, so an over-long or empty entry
|
|
94
|
+
// is normalized rather than refused: a rejected proposal costs the whole
|
|
95
|
+
// draft, and the card is where the user reviews them anyway.
|
|
96
|
+
if (params.ground_rules !== undefined) overrides.groundRules = params.ground_rules;
|
|
78
97
|
if (params.interval !== undefined) {
|
|
79
98
|
const parsed = parseDuration(params.interval);
|
|
80
99
|
if (parsed === undefined) {
|
|
@@ -101,6 +120,7 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
const proposal = controller.propose(objective, overrides);
|
|
123
|
+
onProposed?.();
|
|
104
124
|
// The card goes to the transcript as a framed block, not back through
|
|
105
125
|
// this tool result. Returning it here too would render the same
|
|
106
126
|
// artifact twice, once framed and once as a wall of markdown, and
|
|
@@ -111,13 +131,14 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
111
131
|
content: [
|
|
112
132
|
{
|
|
113
133
|
type: "text" as const,
|
|
114
|
-
text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}, waking every ${formatDuration(proposal.intervalMs)}, cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns}, expires in ${formatDuration(proposal.expiresInMs)}. The user starts it from /loop; nothing is running yet.`,
|
|
134
|
+
text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}${proposal.groundRules ? `, ${proposal.groundRules.length} ground rule${proposal.groundRules.length === 1 ? "" : "s"}` : ""}, waking every ${formatDuration(proposal.intervalMs)}, cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns}, expires in ${formatDuration(proposal.expiresInMs)}. The user starts it from /loop; nothing is running yet.`,
|
|
115
135
|
},
|
|
116
136
|
],
|
|
117
137
|
details: {
|
|
118
138
|
criteria: proposal.criteria.length,
|
|
119
139
|
intervalMs: proposal.intervalMs,
|
|
120
140
|
maxTurns: proposal.maxTurns,
|
|
141
|
+
groundRules: proposal.groundRules?.length ?? 0,
|
|
121
142
|
},
|
|
122
143
|
};
|
|
123
144
|
},
|
package/src/settings.ts
CHANGED
|
@@ -29,12 +29,28 @@ export interface LoopCompactionSettings {
|
|
|
29
29
|
*/
|
|
30
30
|
const LEGACY_CAP_KEYS = ["maxIterations", "automaticTurns"] as const;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Settings that no longer exist. They are tolerated on read (an unknown field
|
|
34
|
+
* is ignored, never a reason to reject the file) and dropped on the next save,
|
|
35
|
+
* so a settings file written by an older version keeps working and quietly
|
|
36
|
+
* stops advertising a switch that controls nothing.
|
|
37
|
+
*
|
|
38
|
+
* `inlineInvocation` toggled mid-prompt `/loop` detection, which was removed
|
|
39
|
+
* along with the `loop_start` tool it pointed at.
|
|
40
|
+
*/
|
|
41
|
+
const REMOVED_KEYS = ["inlineInvocation"] as const;
|
|
42
|
+
|
|
32
43
|
export interface LoopSettings {
|
|
33
44
|
/**
|
|
34
45
|
* Cap on the turns the loop itself causes (settle continuations plus
|
|
35
|
-
* fallback pokes); null means unlimited
|
|
36
|
-
* there is: one wake can yield many turns, so counting turns is
|
|
37
|
-
* actually bounds a loop.
|
|
46
|
+
* fallback pokes); null means unlimited, and unlimited is the default. The
|
|
47
|
+
* only cap there is: one wake can yield many turns, so counting turns is
|
|
48
|
+
* what actually bounds a loop.
|
|
49
|
+
*
|
|
50
|
+
* A turn budget is a proxy for cost, not for progress, and a loop that hits
|
|
51
|
+
* one stops in the middle of the work with nothing decided. The real bounds
|
|
52
|
+
* are the expiry and the no-progress breaker, which stop a loop for reasons
|
|
53
|
+
* a user can act on. Set a number here to opt back into a budget.
|
|
38
54
|
*/
|
|
39
55
|
maxTurns: number | null;
|
|
40
56
|
/**
|
|
@@ -45,26 +61,19 @@ export interface LoopSettings {
|
|
|
45
61
|
/** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
|
|
46
62
|
maxLoopDuration: string;
|
|
47
63
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
inlineInvocation: boolean;
|
|
53
|
-
/**
|
|
54
|
-
* Fallback heartbeat used by an inline invocation that names no interval.
|
|
55
|
-
* In a settle-paced loop the interval is only a fallback — the settle
|
|
56
|
-
* boundary is the pacemaker — so this value is far less consequential than
|
|
57
|
-
* it looks; it is still clamped to MIN_INTERVAL_MS.
|
|
64
|
+
* Fallback heartbeat used by a proposal that names no interval. In a
|
|
65
|
+
* settle-paced loop the interval is only a fallback — the settle boundary is
|
|
66
|
+
* the pacemaker — so this value is far less consequential than it looks; it
|
|
67
|
+
* is still clamped to MIN_INTERVAL_MS.
|
|
58
68
|
*/
|
|
59
69
|
defaultInterval: string;
|
|
60
70
|
compaction: LoopCompactionSettings;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
64
|
-
maxTurns:
|
|
74
|
+
maxTurns: null,
|
|
65
75
|
noProgressTurns: 3,
|
|
66
76
|
maxLoopDuration: "7d",
|
|
67
|
-
inlineInvocation: true,
|
|
68
77
|
defaultInterval: "10m",
|
|
69
78
|
compaction: {
|
|
70
79
|
enabled: true,
|
|
@@ -98,11 +107,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
98
107
|
return undefined;
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
|
|
102
|
-
? record.inlineInvocation
|
|
103
|
-
: DEFAULT_LOOP_SETTINGS.inlineInvocation;
|
|
104
|
-
if (typeof inlineInvocation !== "boolean") return undefined;
|
|
105
|
-
|
|
106
110
|
const defaultInterval = Object.hasOwn(record, "defaultInterval")
|
|
107
111
|
? record.defaultInterval
|
|
108
112
|
: DEFAULT_LOOP_SETTINGS.defaultInterval;
|
|
@@ -140,7 +144,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
140
144
|
maxTurns,
|
|
141
145
|
noProgressTurns,
|
|
142
146
|
maxLoopDuration,
|
|
143
|
-
inlineInvocation,
|
|
144
147
|
defaultInterval,
|
|
145
148
|
compaction: { enabled, threshold, instructions },
|
|
146
149
|
};
|
|
@@ -253,13 +256,15 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
|
|
|
253
256
|
// unknown: leaving them next to a cap that supersedes them would show the
|
|
254
257
|
// user two numbers where only one applies.
|
|
255
258
|
for (const key of LEGACY_CAP_KEYS) delete raw[key];
|
|
259
|
+
// Removed settings are dropped rather than preserved: keeping a switch that
|
|
260
|
+
// controls nothing is worse than losing it.
|
|
261
|
+
for (const key of REMOVED_KEYS) delete raw[key];
|
|
256
262
|
const document = `${JSON.stringify(
|
|
257
263
|
{
|
|
258
264
|
...raw,
|
|
259
265
|
maxTurns: normalized.maxTurns,
|
|
260
266
|
noProgressTurns: normalized.noProgressTurns,
|
|
261
267
|
maxLoopDuration: normalized.maxLoopDuration,
|
|
262
|
-
inlineInvocation: normalized.inlineInvocation,
|
|
263
268
|
defaultInterval: normalized.defaultInterval,
|
|
264
269
|
compaction: { ...compaction, ...normalized.compaction },
|
|
265
270
|
},
|
package/src/state.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface LoopState {
|
|
|
29
29
|
* before 0.6.0 may predate it — every loop started now has one.
|
|
30
30
|
*/
|
|
31
31
|
objective?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Hard constraints, approved with the objective and injected alongside it on
|
|
34
|
+
* every active turn. Optional: a loop started before ground rules existed,
|
|
35
|
+
* or approved without any, simply has none.
|
|
36
|
+
*/
|
|
37
|
+
groundRules?: string[];
|
|
32
38
|
intervalMs: number;
|
|
33
39
|
/**
|
|
34
40
|
* Cap on the turns this loop causes (continuations plus pokes); null means
|
|
@@ -57,6 +63,8 @@ export interface LoopState {
|
|
|
57
63
|
lastFingerprint?: string;
|
|
58
64
|
/** Why a paused loop paused, for the widget and status after a restore. */
|
|
59
65
|
pauseCause?: string;
|
|
66
|
+
/** Durable reason recorded when the loop enters its terminal stopped state. */
|
|
67
|
+
terminalReason?: string;
|
|
60
68
|
/**
|
|
61
69
|
* Set once the expiry's final wake has been delivered. The loop is still
|
|
62
70
|
* active for exactly that one turn, so the objective append is present
|
|
@@ -100,6 +108,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
100
108
|
objective = record.objective.trim();
|
|
101
109
|
if (!objective || objective.length > MAX_PROMPT_LENGTH) return undefined;
|
|
102
110
|
}
|
|
111
|
+
const groundRules = normalizeGroundRuleList(record.groundRules);
|
|
112
|
+
if (groundRules === false) return undefined;
|
|
103
113
|
const intervalMs = record.intervalMs;
|
|
104
114
|
if (!isPositiveSafeInteger(intervalMs)) return undefined;
|
|
105
115
|
const maxTurns = readTurnCap(record);
|
|
@@ -136,6 +146,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
136
146
|
if (cancelledWaitReason === false) return undefined;
|
|
137
147
|
const pauseCause = optionalText(record.pauseCause);
|
|
138
148
|
if (pauseCause === false) return undefined;
|
|
149
|
+
const terminalReason = optionalText(record.terminalReason);
|
|
150
|
+
if (terminalReason === false) return undefined;
|
|
139
151
|
const toolFreeRepeatCount = record.toolFreeRepeatCount;
|
|
140
152
|
if (
|
|
141
153
|
toolFreeRepeatCount !== undefined &&
|
|
@@ -154,6 +166,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
154
166
|
status: status as LoopStatus,
|
|
155
167
|
...(prompt === undefined ? {} : { prompt }),
|
|
156
168
|
...(objective === undefined ? {} : { objective }),
|
|
169
|
+
...(groundRules === undefined ? {} : { groundRules }),
|
|
157
170
|
intervalMs,
|
|
158
171
|
maxTurns,
|
|
159
172
|
compactAt: compactAt as number | null,
|
|
@@ -167,6 +180,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
167
180
|
...(toolFreeRepeatCount === undefined ? {} : { toolFreeRepeatCount }),
|
|
168
181
|
...(lastFingerprint === undefined ? {} : { lastFingerprint }),
|
|
169
182
|
...(pauseCause === undefined ? {} : { pauseCause }),
|
|
183
|
+
...(terminalReason === undefined ? {} : { terminalReason }),
|
|
170
184
|
...(record.expiring === true ? { expiring: true as const } : {}),
|
|
171
185
|
...(record.handoff === true ? { handoff: true as const } : {}),
|
|
172
186
|
};
|
|
@@ -235,6 +249,25 @@ function ownRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
235
249
|
: undefined;
|
|
236
250
|
}
|
|
237
251
|
|
|
252
|
+
/**
|
|
253
|
+
* A present-but-optional ground-rule list: the list, undefined when absent,
|
|
254
|
+
* false when invalid. Empty survives as undefined so an approved loop with no
|
|
255
|
+
* rules and a restored one are the same state.
|
|
256
|
+
*/
|
|
257
|
+
function normalizeGroundRuleList(value: unknown): string[] | undefined | false {
|
|
258
|
+
if (value === undefined) return undefined;
|
|
259
|
+
if (!Array.isArray(value)) return false;
|
|
260
|
+
const rules: string[] = [];
|
|
261
|
+
for (const entry of value) {
|
|
262
|
+
if (typeof entry !== "string") return false;
|
|
263
|
+
const trimmed = entry.trim();
|
|
264
|
+
if (!trimmed) continue;
|
|
265
|
+
if (trimmed.length > MAX_PROMPT_LENGTH) return false;
|
|
266
|
+
rules.push(trimmed);
|
|
267
|
+
}
|
|
268
|
+
return rules.length > 0 ? rules : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
238
271
|
/** A present-but-optional string: the value, undefined when absent, false when invalid. */
|
|
239
272
|
function optionalText(value: unknown): string | undefined | false {
|
|
240
273
|
if (value === undefined) return undefined;
|
package/src/wait-tool.ts
CHANGED
|
@@ -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 /loop with an objective is active, so there is nothing to wait on.
|
|
62
|
+
"No /loop with an objective is active, so there is nothing to wait on. Run /loop to plan and approve one.",
|
|
63
63
|
),
|
|
64
64
|
details: {},
|
|
65
65
|
isError: true,
|
package/src/widget.ts
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* order is the whole reason to glance at the line: paused and blocked and
|
|
22
22
|
* expiring come before the ordinary running line.
|
|
23
23
|
*
|
|
24
|
+
* The glyph vocabulary is shared with pi-plan-mode by convention, not by
|
|
25
|
+
* import — `◆` planning or ready, `▶` implementing, `⟳` running, `⏸` paused,
|
|
26
|
+
* `⏳` waiting, `⚠` attention. Six characters are not worth a package; a user
|
|
27
|
+
* reading a footer is worth the consistency.
|
|
28
|
+
*
|
|
24
29
|
* Presentation only: every entry point tolerates a host without setWidget
|
|
25
30
|
* (test fixtures, print mode) and swallows render-side failures, because a
|
|
26
31
|
* widget must never interrupt loop state transitions.
|
|
@@ -122,9 +127,8 @@ export function widgetTone(view: LoopWidgetView): Tone {
|
|
|
122
127
|
|
|
123
128
|
export function loopWidgetLine(view: LoopWidgetView): string {
|
|
124
129
|
if (view.kind === "planning") {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
: `◆ loop planning · ${view.proposedCriteria} criteria proposed · approve to start`;
|
|
130
|
+
if (view.proposedCriteria === undefined) return "◆ loop · drafting objective";
|
|
131
|
+
return `◆ loop · ${view.proposedCriteria} ${view.proposedCriteria === 1 ? "criterion" : "criteria"} proposed · approve to start`;
|
|
128
132
|
}
|
|
129
133
|
const loop = view.loop;
|
|
130
134
|
|
package/src/inline-command.ts
DELETED
|
@@ -1,159 +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 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
|
-
}
|
package/src/inline-invocation.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
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
|
-
}
|