pi-prompt-template-model 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 +30 -0
- package/README.md +29 -21
- package/args.ts +42 -38
- package/chain-parser.ts +23 -10
- package/index.ts +259 -75
- package/package.json +1 -1
- package/prompt-execution.ts +27 -1
- package/prompt-loader.ts +59 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-03-19
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Clarified the README chain examples so the optional ` -- ` shared-args separator is clearly distinct from loop flags like `--loop`, `--fresh`, and `--no-converge`.
|
|
10
|
+
- Clarified in the README that chain frontmatter declarations support per-step `--loop N` inside the `chain:` value.
|
|
11
|
+
- Argument substitution now accepts `@$` as an alias for `$@` for compatibility with commonly-typed placeholder variants.
|
|
12
|
+
- Skill injection now uses a next-turn context message from `before_agent_start` instead of mutating the turn system prompt.
|
|
13
|
+
- Non-chain templates can now omit `model` and inherit the current session model, so inline `<if-model ...>` rendering and skill injection still work without explicit model frontmatter.
|
|
14
|
+
- Chain steps without `model` now inherit a fixed chain-start model snapshot, so model-less chain steps behave as if that model were declared in frontmatter while remaining deterministic across step switches.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Chain step execution now avoids implicit previous-step model bleed for model-less templates by resolving them against the chain-start model snapshot instead of whichever model was active after the prior step.
|
|
19
|
+
- Model-less prompt loading now skips plain templates that do not use extension features, preventing command collisions with other extension commands like `/review` and `/handover`.
|
|
20
|
+
- Model-less prompt loading now also ignores no-op/invalid-only extension metadata (for example `restore`-only or invalid loop flags), so ineffective frontmatter does not unnecessarily claim command names.
|
|
21
|
+
- Model-less prompt loading now recognizes invalid conditional closers like `</else>` as extension-relevant markup, so those templates stay in this extension path and surface proper conditional-parse warnings instead of silently bypassing extension handling.
|
|
22
|
+
- Model-less prompt execution now tracks runtime model changes (`model_select` + internal switches/restores) and uses that tracked model instead of potentially stale command-context snapshots.
|
|
23
|
+
- Prompt commands now fail fast when a configured `skill` file is missing or unreadable, instead of silently sending the prompt without skill context.
|
|
24
|
+
- Skill resolution now returns a typed success/error outcome that callers handle explicitly, rather than emitting notifications from inside the resolver and returning sentinel `null` values.
|
|
25
|
+
- Session start/switch now clear any queued skill context message so stale pending skill payloads cannot leak across session boundaries.
|
|
26
|
+
- Session start/switch now also clear pending single-command restore state (`previousModel`/`previousThinking`) so restore writes cannot leak into a different session.
|
|
27
|
+
- Skill frontmatter resolution now checks registered skill commands first (`pi.getCommands()` skill entries), accepts both `<name>` and `skill:<name>` values, searches additional standard pi skill locations (`.agents/skills` in project ancestors and `~/.agents/skills`), supports direct `<skill>.md` files alongside `SKILL.md` directories, and rejects traversal-like skill names for path fallback.
|
|
28
|
+
- `extractLoopCount()` now strips repeated unquoted `--loop` tokens once looping is active, preventing stray loop flags from leaking into prompt arguments.
|
|
29
|
+
- Chain frontmatter step parsing now strips repeated per-step `--loop` tokens once a valid per-step loop is resolved, and keeps the first valid value (including mixed invalid/valid numeric sequences like `--loop 1000 --loop 2`).
|
|
30
|
+
- Loop-mode restore now tracks runtime model/thinking state per iteration instead of relying on command-context model snapshots, so model restoration remains correct even when command context values are stale.
|
|
31
|
+
- Chain execution now restores model/thinking state in a `finally` path, so restore still runs after unexpected runtime errors during a chain step and chain cleanup state is still reset even when restore itself fails.
|
|
32
|
+
- Loop and chain executions no longer report `Loop finished`/`Loop converged` when runtime errors abort execution mid-loop.
|
|
33
|
+
- Loop and chain error propagation now preserves thrown falsy values (for example `throw 0`) instead of treating them as success, preventing swallowed errors and false completion notifications.
|
|
34
|
+
|
|
5
35
|
## [0.5.0] - 2026-03-17
|
|
6
36
|
|
|
7
37
|
### Added
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
│ /debug-python ──► Extension detects model + skill │
|
|
16
16
|
│ │ │
|
|
17
17
|
│ ▼ │
|
|
18
|
-
│ Switches to Sonnet ──►
|
|
18
|
+
│ Switches to Sonnet ──► Queues tmux skill context for next turn │
|
|
19
19
|
│ │ │
|
|
20
20
|
│ ▼ │
|
|
21
21
|
│ Agent responds with Sonnet + tmux expertise │
|
|
@@ -49,7 +49,7 @@ Restart pi to load the extension.
|
|
|
49
49
|
|
|
50
50
|
## Quick Start
|
|
51
51
|
|
|
52
|
-
Add `model` and optionally `skill` to any prompt template:
|
|
52
|
+
Add `model` (or omit it to inherit the current session model) and optionally `skill` to any prompt template:
|
|
53
53
|
|
|
54
54
|
```markdown
|
|
55
55
|
---
|
|
@@ -80,15 +80,15 @@ skill: surf
|
|
|
80
80
|
$@
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Here `skill: surf` loads `~/.pi/agent/skills/surf/SKILL.md` and injects its content
|
|
83
|
+
Here `skill: surf` loads `~/.pi/agent/skills/surf/SKILL.md` and injects its content as a context message on the next turn before the agent handles your task. No decision-making, no read tool, just immediate expertise. It's a forcing function for when you know exactly what workflow the agent needs.
|
|
84
84
|
|
|
85
85
|
## Frontmatter Fields
|
|
86
86
|
|
|
87
87
|
| Field | Required | Default | Description |
|
|
88
88
|
|-------|----------|---------|-------------|
|
|
89
|
-
| `model` |
|
|
89
|
+
| `model` | No | `current` | Target model(s). If omitted on a non-chain template, execution inherits the current session model. Ignored when `chain` is set. |
|
|
90
90
|
| `chain` | Conditional | - | Chain declaration (`step -> step --loop 2`) for orchestration templates; body is ignored |
|
|
91
|
-
| `skill` | No | - | Skill name to inject
|
|
91
|
+
| `skill` | No | - | Skill name to inject as next-turn context message |
|
|
92
92
|
| `thinking` | No | - | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
|
|
93
93
|
| `description` | No | - | Shown in autocomplete |
|
|
94
94
|
| `restore` | No | `true` | Restore previous model and thinking level after response |
|
|
@@ -153,7 +153,7 @@ Do a deeper pass and call out subtle risks.
|
|
|
153
153
|
</if-model>
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
Conditionals are evaluated against the model that actually runs the command
|
|
156
|
+
Conditionals are evaluated against the model that actually runs the command. For fallback prompts, that means after candidate resolution; for prompts without `model`, that means the current session model. The same template can render differently depending on which model is active.
|
|
157
157
|
|
|
158
158
|
Supported matches inside `is="..."`:
|
|
159
159
|
|
|
@@ -183,6 +183,7 @@ Prompt bodies support argument placeholders that expand to command arguments:
|
|
|
183
183
|
|-------------|-------------|
|
|
184
184
|
| `$1`, `$2`, ... | Positional argument (1-indexed) |
|
|
185
185
|
| `$@` | All arguments joined with spaces |
|
|
186
|
+
| `@$` | Alias for `$@` |
|
|
186
187
|
| `$ARGUMENTS` | Same as `$@` |
|
|
187
188
|
| `${@:N}` | All arguments from position N onward |
|
|
188
189
|
| `${@:N:L}` | L arguments starting from position N |
|
|
@@ -203,17 +204,22 @@ Running `/analyze src/main.ts performance edge cases error handling` expands to:
|
|
|
203
204
|
|
|
204
205
|
## Skill Resolution
|
|
205
206
|
|
|
206
|
-
The `skill` field
|
|
207
|
+
The `skill` field accepts either a bare skill name or a slash-command style name:
|
|
207
208
|
|
|
208
209
|
```yaml
|
|
209
210
|
skill: tmux
|
|
211
|
+
# also valid
|
|
212
|
+
skill: skill:tmux
|
|
210
213
|
```
|
|
211
214
|
|
|
212
|
-
|
|
213
|
-
1.
|
|
214
|
-
2.
|
|
215
|
+
Resolution order:
|
|
216
|
+
1. Registered skill commands from `pi.getCommands()` (`source: "skill"`), matched by `skill:name` or `name`
|
|
217
|
+
2. `<cwd>/.pi/skills/<name>/SKILL.md` or `<cwd>/.pi/skills/<name>.md`
|
|
218
|
+
3. `.agents/skills` in `cwd` and ancestor directories (up to git repo root)
|
|
219
|
+
4. `~/.pi/agent/skills/<name>/SKILL.md` or `~/.pi/agent/skills/<name>.md`
|
|
220
|
+
5. `~/.agents/skills/<name>/SKILL.md` or `~/.agents/skills/<name>.md`
|
|
215
221
|
|
|
216
|
-
|
|
222
|
+
If the configured skill file is missing or unreadable, the command fails fast and does not send the prompt body to the model.
|
|
217
223
|
|
|
218
224
|
## Subdirectories
|
|
219
225
|
|
|
@@ -310,13 +316,13 @@ Switched to Haiku. How can I help?
|
|
|
310
316
|
|
|
311
317
|
## Chaining Templates
|
|
312
318
|
|
|
313
|
-
The `/chain-prompts` command runs multiple templates sequentially. Each step switches to its own model, renders
|
|
319
|
+
The `/chain-prompts` command runs multiple templates sequentially. Each step switches to its own model (or, if the step has no `model`, to the chain-start model snapshot), renders inline model conditionals against that resolved step model, injects its own skill context message, and conversation context carries forward between steps.
|
|
314
320
|
|
|
315
321
|
```
|
|
316
322
|
/chain-prompts analyze-code -> fix-plan -> summarize -- src/main.ts
|
|
317
323
|
```
|
|
318
324
|
|
|
319
|
-
This runs `analyze-code` first, then `fix-plan` (which sees the analysis in conversation context), then `summarize`. The
|
|
325
|
+
This runs `analyze-code` first, then `fix-plan` (which sees the analysis in conversation context), then `summarize`. The ` -- src/main.ts` part is optional. The literal ` -- ` separator means "shared args start here": everything after it is passed to each step as `$@`, unless that step already has its own inline args.
|
|
320
326
|
|
|
321
327
|
Each step can also receive its own args, overriding the shared args for that step:
|
|
322
328
|
|
|
@@ -348,7 +354,7 @@ chain: double-check --loop 2 -> deslop --loop 2
|
|
|
348
354
|
ignored — chain templates don't use the body
|
|
349
355
|
```
|
|
350
356
|
|
|
351
|
-
This registers `/review-then-clean` as a command that runs `double-check` twice, then `deslop` twice. Each step references a separate prompt template with
|
|
357
|
+
This registers `/review-then-clean` as a command that runs `double-check` twice, then `deslop` twice. Each step references a separate prompt template. Steps with `model` use their configured model; steps without `model` inherit the chain-start model snapshot (the model active when the chain command began), so behavior stays deterministic even if earlier steps switch models.
|
|
352
358
|
|
|
353
359
|
Per-step `--loop N` repeats that step N times before moving to the next. Per-step convergence applies: if a step makes no file changes on an iteration, its inner loop stops early (unless the step's template has `converge: false`).
|
|
354
360
|
|
|
@@ -440,14 +446,15 @@ Review the codebase and improve code quality. $@
|
|
|
440
446
|
Chains support the same looping forms:
|
|
441
447
|
|
|
442
448
|
```
|
|
449
|
+
/chain-prompts analyze -> fix --loop 3
|
|
450
|
+
/chain-prompts analyze -> fix --loop=3
|
|
451
|
+
/chain-prompts analyze -> fix --loop
|
|
452
|
+
/chain-prompts analyze -> fix --loop 3 --fresh
|
|
453
|
+
/chain-prompts analyze -> fix --loop 3 --no-converge
|
|
443
454
|
/chain-prompts analyze -> fix --loop 3 -- src/main.ts
|
|
444
|
-
/chain-prompts analyze -> fix --loop=3 -- src/main.ts
|
|
445
|
-
/chain-prompts analyze -> fix --loop -- src/main.ts
|
|
446
|
-
/chain-prompts analyze -> fix --loop 3 --fresh -- src/main.ts
|
|
447
|
-
/chain-prompts analyze -> fix --loop 3 --no-converge -- src/main.ts
|
|
448
455
|
```
|
|
449
456
|
|
|
450
|
-
This runs the full chain (analyze → fix) three times. Convergence detection applies across all steps in each iteration — if no step made file changes, the loop stops. Each iteration re-reads prompts from disk, so template edits take effect between iterations. The status bar shows `loop 2/3` during execution.
|
|
457
|
+
This runs the full chain (analyze → fix) three times. The final example adds optional shared args: ` -- src/main.ts` means "pass `src/main.ts` to any step that doesn't already have its own args." If you don't need shared args, leave that part out entirely. Convergence detection applies across all steps in each iteration — if no step made file changes, the loop stops. Each iteration re-reads prompts from disk, so template edits take effect between iterations. The status bar shows `loop 2/3` during execution. Chain frontmatter declarations also support per-step `--loop N` inside the `chain:` value (for example `chain: double-check --loop 3 -> simplify -> deslop`).
|
|
451
458
|
|
|
452
459
|
## Agent Tool
|
|
453
460
|
|
|
@@ -499,11 +506,12 @@ These commands work in print mode too:
|
|
|
499
506
|
pi -p "/debug-python my code crashes on line 42"
|
|
500
507
|
```
|
|
501
508
|
|
|
502
|
-
The model switches, skill
|
|
509
|
+
The model switches, a skill context message is injected, the agent responds, and output prints to stdout. Useful for scripting or piping to other tools.
|
|
503
510
|
|
|
504
511
|
## Limitations
|
|
505
512
|
|
|
506
513
|
- Prompt files are reloaded on session start and whenever an extension-owned prompt command runs. If you add a brand-new prompt file while already inside a session, run another extension-owned command such as `/chain-prompts`, start a new session, or reload pi so the new slash command is registered.
|
|
507
514
|
- Model restore state is in-memory. Closing pi mid-response loses restore state.
|
|
508
|
-
-
|
|
515
|
+
- Model-less templates are only managed by this extension when they use extension features (for example `skill`, `thinking`, loop flags, or inline `<if-model ...>`). Plain prompt templates without extension features stay with pi's default prompt loader to avoid command conflicts.
|
|
516
|
+
- In chains, model-less steps inherit the chain-start model snapshot, not the immediately previous step model. This is intentional for deterministic behavior.
|
|
509
517
|
- The `run-prompt` tool must be explicitly enabled with `/prompt-tool on` before the agent can use it.
|
package/args.ts
CHANGED
|
@@ -17,6 +17,7 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
|
|
|
17
17
|
let fresh = false;
|
|
18
18
|
let noConverge = false;
|
|
19
19
|
const tokensToRemove: Array<{ start: number; end: number }> = [];
|
|
20
|
+
const loopTokenRanges: Array<{ start: number; end: number }> = [];
|
|
20
21
|
|
|
21
22
|
let i = 0;
|
|
22
23
|
while (i < argsString.length) {
|
|
@@ -39,45 +40,46 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
|
|
|
39
40
|
while (i < argsString.length && !/\s/.test(argsString[i])) i++;
|
|
40
41
|
const token = argsString.slice(tokenStart, i);
|
|
41
42
|
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
tokensToRemove.push({ start: tokenStart, end: i });
|
|
51
|
-
}
|
|
43
|
+
if (token.startsWith("--loop=")) {
|
|
44
|
+
loopTokenRanges.push({ start: tokenStart, end: i });
|
|
45
|
+
const value = token.slice("--loop=".length);
|
|
46
|
+
if (/^\d+$/.test(value)) {
|
|
47
|
+
const parsed = parseInt(value, 10);
|
|
48
|
+
if (parsed >= 1 && parsed <= 999 && !loopFound) {
|
|
49
|
+
loopFound = true;
|
|
50
|
+
loopCount = parsed;
|
|
52
51
|
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
} else {
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (token === "--loop") {
|
|
57
|
+
let lookahead = i;
|
|
58
|
+
while (lookahead < argsString.length && /\s/.test(argsString[lookahead])) lookahead++;
|
|
59
|
+
|
|
60
|
+
if (lookahead < argsString.length && argsString[lookahead] !== '"' && argsString[lookahead] !== "'") {
|
|
61
|
+
const nextTokenStart = lookahead;
|
|
62
|
+
while (lookahead < argsString.length && !/\s/.test(argsString[lookahead])) lookahead++;
|
|
63
|
+
const nextToken = argsString.slice(nextTokenStart, lookahead);
|
|
64
|
+
|
|
65
|
+
if (/^\d+$/.test(nextToken)) {
|
|
66
|
+
loopTokenRanges.push({ start: tokenStart, end: i }, { start: nextTokenStart, end: lookahead });
|
|
67
|
+
const parsed = parseInt(nextToken, 10);
|
|
68
|
+
if (parsed >= 1 && parsed <= 999 && !loopFound) {
|
|
71
69
|
loopFound = true;
|
|
72
|
-
loopCount =
|
|
73
|
-
tokensToRemove.push({ start: tokenStart, end: i });
|
|
70
|
+
loopCount = parsed;
|
|
74
71
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
loopCount = null;
|
|
78
|
-
tokensToRemove.push({ start: tokenStart, end: i });
|
|
72
|
+
i = lookahead;
|
|
73
|
+
continue;
|
|
79
74
|
}
|
|
80
75
|
}
|
|
76
|
+
|
|
77
|
+
loopTokenRanges.push({ start: tokenStart, end: i });
|
|
78
|
+
if (!loopFound) {
|
|
79
|
+
loopFound = true;
|
|
80
|
+
loopCount = null;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
if (token === "--fresh") {
|
|
@@ -91,15 +93,16 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
|
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
if (
|
|
96
|
+
if (!loopFound) return null;
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
const allRanges = [...tokensToRemove, ...loopTokenRanges];
|
|
99
|
+
allRanges.sort((a, b) => b.start - a.start);
|
|
97
100
|
let cleaned = argsString;
|
|
98
|
-
for (const { start, end } of
|
|
101
|
+
for (const { start, end } of allRanges) {
|
|
99
102
|
cleaned = cleaned.slice(0, start) + cleaned.slice(end);
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
const converge =
|
|
105
|
+
const converge = loopCount === null ? true : !noConverge;
|
|
103
106
|
return { args: cleaned.trim(), loopCount, fresh, converge };
|
|
104
107
|
}
|
|
105
108
|
|
|
@@ -227,6 +230,7 @@ export function substituteArgs(content: string, args: string[]): string {
|
|
|
227
230
|
const allArgs = args.join(" ");
|
|
228
231
|
result = result.replace(/\$ARGUMENTS/g, allArgs);
|
|
229
232
|
result = result.replace(/\$@/g, allArgs);
|
|
233
|
+
result = result.replace(/@\$/g, allArgs);
|
|
230
234
|
|
|
231
235
|
return result;
|
|
232
236
|
}
|
package/chain-parser.ts
CHANGED
|
@@ -76,19 +76,20 @@ function scanSegmentTokens(segment: string): SegmentToken[] {
|
|
|
76
76
|
|
|
77
77
|
function extractStepLoopCount(segment: string): { cleanedSegment: string; loopCount?: number } {
|
|
78
78
|
const tokens = scanSegmentTokens(segment);
|
|
79
|
+
const loopTokenRanges: Array<{ start: number; end: number }> = [];
|
|
80
|
+
let loopCount: number | undefined;
|
|
79
81
|
|
|
80
82
|
for (let i = 1; i < tokens.length; i++) {
|
|
81
83
|
const token = tokens[i];
|
|
82
84
|
if (token.quoted) continue;
|
|
83
85
|
|
|
84
86
|
if (token.value.startsWith("--loop=")) {
|
|
87
|
+
loopTokenRanges.push({ start: token.start, end: token.end });
|
|
85
88
|
const value = token.value.slice("--loop=".length);
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return { cleanedSegment, loopCount: parsed };
|
|
91
|
-
}
|
|
89
|
+
if (!/^\d+$/.test(value)) continue;
|
|
90
|
+
const parsed = parseInt(value, 10);
|
|
91
|
+
if (parsed >= 1 && parsed <= 999 && loopCount === undefined) {
|
|
92
|
+
loopCount = parsed;
|
|
92
93
|
}
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
@@ -96,16 +97,28 @@ function extractStepLoopCount(segment: string): { cleanedSegment: string; loopCo
|
|
|
96
97
|
if (token.value === "--loop" && i + 1 < tokens.length) {
|
|
97
98
|
const next = tokens[i + 1];
|
|
98
99
|
if (!next.quoted && /^\d+$/.test(next.value)) {
|
|
100
|
+
loopTokenRanges.push({ start: token.start, end: token.end }, { start: next.start, end: next.end });
|
|
99
101
|
const parsed = parseInt(next.value, 10);
|
|
100
|
-
if (parsed >= 1 && parsed <= 999) {
|
|
101
|
-
|
|
102
|
-
return { cleanedSegment, loopCount: parsed };
|
|
102
|
+
if (parsed >= 1 && parsed <= 999 && loopCount === undefined) {
|
|
103
|
+
loopCount = parsed;
|
|
103
104
|
}
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
104
107
|
}
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
if (loopCount === undefined || loopTokenRanges.length === 0) {
|
|
112
|
+
return { cleanedSegment: segment };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
loopTokenRanges.sort((a, b) => b.start - a.start);
|
|
116
|
+
let cleanedSegment = segment;
|
|
117
|
+
for (const { start, end } of loopTokenRanges) {
|
|
118
|
+
cleanedSegment = `${cleanedSegment.slice(0, start)}${cleanedSegment.slice(end)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { cleanedSegment: cleanedSegment.trim(), loopCount };
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
export function parseChainSteps(args: string): ParsedChainSteps {
|
package/index.ts
CHANGED
|
@@ -22,11 +22,29 @@ interface FreshCollapse {
|
|
|
22
22
|
totalIterations: number | null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface PendingSkillMessage {
|
|
26
|
+
customType: "skill-loaded";
|
|
27
|
+
content: string;
|
|
28
|
+
display: true;
|
|
29
|
+
details: SkillLoadedDetails;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type SkillMessageResolution =
|
|
33
|
+
| { kind: "none" }
|
|
34
|
+
| { kind: "ready"; message: PendingSkillMessage }
|
|
35
|
+
| { kind: "error"; error: string };
|
|
36
|
+
|
|
37
|
+
interface ExecutionErrorState {
|
|
38
|
+
hasError: boolean;
|
|
39
|
+
error: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
export default function promptModelExtension(pi: ExtensionAPI) {
|
|
26
43
|
let prompts = new Map<string, PromptWithModel>();
|
|
27
44
|
let previousModel: Model<any> | undefined;
|
|
28
45
|
let previousThinking: ThinkingLevel | undefined;
|
|
29
|
-
let
|
|
46
|
+
let pendingSkillMessage: PendingSkillMessage | undefined;
|
|
47
|
+
let runtimeModel: Model<any> | undefined;
|
|
30
48
|
let chainActive = false;
|
|
31
49
|
let loopState: LoopState | null = null;
|
|
32
50
|
let freshCollapse: FreshCollapse | null = null;
|
|
@@ -49,6 +67,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
49
67
|
return a.provider === b.provider && a.id === b.id;
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
function getCurrentModel(ctx: Pick<ExtensionContext, "model">): Model<any> | undefined {
|
|
71
|
+
return runtimeModel ?? ctx.model;
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
pi.registerMessageRenderer<SkillLoadedDetails>("skill-loaded", renderSkillLoaded);
|
|
53
75
|
|
|
54
76
|
function registerPromptCommand(name: string) {
|
|
@@ -76,6 +98,74 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
76
98
|
lastDiagnostics = fingerprint;
|
|
77
99
|
}
|
|
78
100
|
|
|
101
|
+
function consumePendingSkillMessage() {
|
|
102
|
+
if (!pendingSkillMessage) return undefined;
|
|
103
|
+
const message = pendingSkillMessage;
|
|
104
|
+
pendingSkillMessage = undefined;
|
|
105
|
+
return message;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeSkillName(skillName: string): string {
|
|
109
|
+
return skillName.startsWith("skill:") ? skillName.slice("skill:".length) : skillName;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isPathResolvableSkillName(skillName: string): boolean {
|
|
113
|
+
if (skillName === "." || skillName === "..") return false;
|
|
114
|
+
if (skillName.includes("/")) return false;
|
|
115
|
+
if (skillName.includes("\\")) return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveRegisteredSkillPath(skillName: string): string | undefined {
|
|
120
|
+
const normalizedSkillName = normalizeSkillName(skillName);
|
|
121
|
+
if (!normalizedSkillName) return undefined;
|
|
122
|
+
const candidates = new Set([normalizedSkillName, `skill:${normalizedSkillName}`]);
|
|
123
|
+
|
|
124
|
+
for (const command of pi.getCommands()) {
|
|
125
|
+
if (command.source !== "skill") continue;
|
|
126
|
+
if (!command.path) continue;
|
|
127
|
+
if (!candidates.has(command.name)) continue;
|
|
128
|
+
return command.path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveSkillMessage(skillName: string | undefined, cwd: string): SkillMessageResolution {
|
|
135
|
+
if (!skillName) {
|
|
136
|
+
return { kind: "none" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const normalizedSkillName = normalizeSkillName(skillName);
|
|
140
|
+
if (!normalizedSkillName) {
|
|
141
|
+
return { kind: "error", error: `Skill "${skillName}" not found` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const skillPath =
|
|
145
|
+
resolveRegisteredSkillPath(skillName) ?? (isPathResolvableSkillName(normalizedSkillName) ? resolveSkillPath(normalizedSkillName, cwd) : undefined);
|
|
146
|
+
if (!skillPath) {
|
|
147
|
+
return { kind: "error", error: `Skill "${skillName}" not found` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const skillContent = readSkillContent(skillPath);
|
|
152
|
+
return {
|
|
153
|
+
kind: "ready",
|
|
154
|
+
message: {
|
|
155
|
+
customType: "skill-loaded",
|
|
156
|
+
content: `<skill name="${normalizedSkillName}">\n${skillContent}\n</skill>`,
|
|
157
|
+
display: true,
|
|
158
|
+
details: { skillName: normalizedSkillName, skillContent, skillPath },
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return {
|
|
163
|
+
kind: "error",
|
|
164
|
+
error: `Failed to read skill "${skillName}": ${error instanceof Error ? error.message : String(error)}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
79
169
|
async function waitForTurnStart(ctx: ExtensionContext) {
|
|
80
170
|
while (ctx.isIdle()) {
|
|
81
171
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
@@ -90,19 +180,19 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
90
180
|
currentThinking?: ThinkingLevel,
|
|
91
181
|
) {
|
|
92
182
|
const restoredParts: string[] = [];
|
|
93
|
-
const shouldRestoreModel = originalModel !== undefined && !sameModel(originalModel, currentModel);
|
|
94
183
|
const shouldRestoreThinking =
|
|
95
184
|
originalThinking !== undefined && (currentThinking === undefined || currentThinking !== originalThinking);
|
|
96
185
|
|
|
97
|
-
if (
|
|
186
|
+
if (originalModel && !sameModel(originalModel, currentModel)) {
|
|
98
187
|
const restoredModel = await pi.setModel(originalModel);
|
|
99
188
|
if (restoredModel) {
|
|
189
|
+
runtimeModel = originalModel;
|
|
100
190
|
restoredParts.push(originalModel.id);
|
|
101
191
|
} else {
|
|
102
192
|
notify(ctx, `Failed to restore model ${originalModel.provider}/${originalModel.id}`, "error");
|
|
103
193
|
}
|
|
104
194
|
}
|
|
105
|
-
if (shouldRestoreThinking
|
|
195
|
+
if (shouldRestoreThinking) {
|
|
106
196
|
restoredParts.push(`thinking:${originalThinking}`);
|
|
107
197
|
pi.setThinkingLevel(originalThinking);
|
|
108
198
|
}
|
|
@@ -111,6 +201,63 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
111
201
|
}
|
|
112
202
|
}
|
|
113
203
|
|
|
204
|
+
async function restoreAfterExecution(
|
|
205
|
+
ctx: ExtensionContext,
|
|
206
|
+
shouldRestore: boolean,
|
|
207
|
+
originalModel: Model<any> | undefined,
|
|
208
|
+
originalThinking: ThinkingLevel | undefined,
|
|
209
|
+
currentModel: Model<any> | undefined,
|
|
210
|
+
currentThinking: ThinkingLevel | undefined,
|
|
211
|
+
errorState: ExecutionErrorState,
|
|
212
|
+
phase: "loop" | "chain",
|
|
213
|
+
): Promise<ExecutionErrorState> {
|
|
214
|
+
if (!shouldRestore) return errorState;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await restoreSessionState(ctx, originalModel, originalThinking, currentModel, currentThinking);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (errorState.hasError) {
|
|
220
|
+
notify(
|
|
221
|
+
ctx,
|
|
222
|
+
`Failed to restore session state after ${phase} error: ${error instanceof Error ? error.message : String(error)}`,
|
|
223
|
+
"error",
|
|
224
|
+
);
|
|
225
|
+
return errorState;
|
|
226
|
+
}
|
|
227
|
+
return { hasError: true, error };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return errorState;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function notifyLoopCompletion(
|
|
234
|
+
ctx: ExtensionContext,
|
|
235
|
+
completedIterations: number,
|
|
236
|
+
totalIterations: number | null,
|
|
237
|
+
effectiveMax: number,
|
|
238
|
+
converged: boolean,
|
|
239
|
+
requireMultipleIterations: boolean,
|
|
240
|
+
) {
|
|
241
|
+
if (converged) {
|
|
242
|
+
const convergedLabel = totalIterations !== null ? `${completedIterations}/${totalIterations}` : `${completedIterations}`;
|
|
243
|
+
notify(ctx, `Loop converged at ${convergedLabel} (no changes)`, "info");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (completedIterations === 0) return;
|
|
248
|
+
if (requireMultipleIterations && effectiveMax <= 1) return;
|
|
249
|
+
|
|
250
|
+
if (totalIterations !== null) {
|
|
251
|
+
notify(ctx, `Loop finished: ${completedIterations}/${totalIterations} iterations`, "info");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (completedIterations === effectiveMax) {
|
|
255
|
+
notify(ctx, `Loop finished: ${completedIterations} iterations (cap reached)`, "info");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
notify(ctx, `Loop finished: ${completedIterations} iterations`, "info");
|
|
259
|
+
}
|
|
260
|
+
|
|
114
261
|
function updateLoopStatus(ctx: ExtensionContext) {
|
|
115
262
|
if (!ctx.hasUI) return;
|
|
116
263
|
if (loopState) {
|
|
@@ -152,8 +299,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
152
299
|
return;
|
|
153
300
|
}
|
|
154
301
|
|
|
155
|
-
const savedModel = ctx
|
|
302
|
+
const savedModel = getCurrentModel(ctx);
|
|
156
303
|
const savedThinking = pi.getThinkingLevel();
|
|
304
|
+
let currentModel = savedModel;
|
|
305
|
+
let currentThinking = savedThinking;
|
|
157
306
|
const shouldRestore = initialPrompt.restore;
|
|
158
307
|
const useFresh = freshFlag || initialPrompt.fresh === true;
|
|
159
308
|
const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
|
|
@@ -166,6 +315,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
166
315
|
updateLoopStatus(ctx);
|
|
167
316
|
let completedIterations = 0;
|
|
168
317
|
let converged = false;
|
|
318
|
+
let loopErrorState: ExecutionErrorState = { hasError: false, error: undefined };
|
|
169
319
|
|
|
170
320
|
try {
|
|
171
321
|
for (let i = 0; i < effectiveMax; i++) {
|
|
@@ -181,7 +331,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
181
331
|
break;
|
|
182
332
|
}
|
|
183
333
|
|
|
184
|
-
const prepared = await preparePromptExecution(prompt, parseCommandArgs(cleanedArgs),
|
|
334
|
+
const prepared = await preparePromptExecution(prompt, parseCommandArgs(cleanedArgs), currentModel, ctx.modelRegistry);
|
|
185
335
|
if (!prepared) {
|
|
186
336
|
notify(ctx, `No available model from: ${prompt.models.join(", ")}`, "error");
|
|
187
337
|
break;
|
|
@@ -195,19 +345,29 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
195
345
|
notify(ctx, prepared.warning, "warning");
|
|
196
346
|
}
|
|
197
347
|
|
|
348
|
+
const skillResolution = resolveSkillMessage(prompt.skill, ctx.cwd);
|
|
349
|
+
if (skillResolution.kind === "error") {
|
|
350
|
+
notify(ctx, skillResolution.error, "error");
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
198
354
|
if (!prepared.selectedModel.alreadyActive) {
|
|
199
355
|
const switched = await pi.setModel(prepared.selectedModel.model);
|
|
200
356
|
if (!switched) {
|
|
201
357
|
notify(ctx, `Failed to switch to model ${prepared.selectedModel.model.provider}/${prepared.selectedModel.model.id}`, "error");
|
|
202
358
|
break;
|
|
203
359
|
}
|
|
360
|
+
runtimeModel = prepared.selectedModel.model;
|
|
204
361
|
}
|
|
362
|
+
currentModel = prepared.selectedModel.model;
|
|
363
|
+
currentThinking = pi.getThinkingLevel();
|
|
205
364
|
|
|
206
365
|
if (prompt.thinking) {
|
|
207
366
|
pi.setThinkingLevel(prompt.thinking);
|
|
367
|
+
currentThinking = pi.getThinkingLevel();
|
|
208
368
|
}
|
|
209
369
|
|
|
210
|
-
|
|
370
|
+
pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
|
|
211
371
|
const iterationStartId = ctx.sessionManager.getLeafId();
|
|
212
372
|
|
|
213
373
|
pi.sendUserMessage(prepared.content);
|
|
@@ -230,29 +390,33 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
230
390
|
}
|
|
231
391
|
}
|
|
232
392
|
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
loopErrorState = { hasError: true, error };
|
|
233
395
|
} finally {
|
|
396
|
+
loopErrorState = await restoreAfterExecution(
|
|
397
|
+
ctx,
|
|
398
|
+
shouldRestore,
|
|
399
|
+
savedModel,
|
|
400
|
+
savedThinking,
|
|
401
|
+
currentModel,
|
|
402
|
+
currentThinking,
|
|
403
|
+
loopErrorState,
|
|
404
|
+
"loop",
|
|
405
|
+
);
|
|
406
|
+
|
|
234
407
|
loopState = null;
|
|
235
|
-
|
|
408
|
+
pendingSkillMessage = undefined;
|
|
236
409
|
freshCollapse = null;
|
|
237
410
|
accumulatedSummaries = [];
|
|
238
411
|
updateLoopStatus(ctx);
|
|
239
412
|
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
notify(ctx, `Loop converged at ${convergedLabel} (no changes)`, "info");
|
|
243
|
-
} else if (completedIterations > 0) {
|
|
244
|
-
if (totalIterations !== null) {
|
|
245
|
-
notify(ctx, `Loop finished: ${completedIterations}/${totalIterations} iterations`, "info");
|
|
246
|
-
} else if (completedIterations === effectiveMax) {
|
|
247
|
-
notify(ctx, `Loop finished: ${completedIterations} iterations (cap reached)`, "info");
|
|
248
|
-
} else {
|
|
249
|
-
notify(ctx, `Loop finished: ${completedIterations} iterations`, "info");
|
|
250
|
-
}
|
|
413
|
+
if (!loopErrorState.hasError) {
|
|
414
|
+
notifyLoopCompletion(ctx, completedIterations, totalIterations, effectiveMax, converged, false);
|
|
251
415
|
}
|
|
416
|
+
}
|
|
252
417
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
418
|
+
if (loopErrorState.hasError) {
|
|
419
|
+
throw loopErrorState.error;
|
|
256
420
|
}
|
|
257
421
|
}
|
|
258
422
|
|
|
@@ -273,7 +437,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
273
437
|
}
|
|
274
438
|
|
|
275
439
|
for (const step of steps) {
|
|
276
|
-
|
|
440
|
+
const stepPrompt = prompts.get(step.name);
|
|
441
|
+
if (!stepPrompt) continue;
|
|
442
|
+
if (stepPrompt.chain) {
|
|
277
443
|
notify(ctx, `Step "${step.name}" is a chain template. Chain nesting is not supported.`, "error");
|
|
278
444
|
return false;
|
|
279
445
|
}
|
|
@@ -284,12 +450,13 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
284
450
|
|
|
285
451
|
if (!validateChainSteps()) return;
|
|
286
452
|
|
|
287
|
-
const originalModel = ctx
|
|
453
|
+
const originalModel = getCurrentModel(ctx);
|
|
454
|
+
const chainInheritedModel = getCurrentModel(ctx);
|
|
288
455
|
const originalThinking = pi.getThinkingLevel();
|
|
289
456
|
let currentModel = originalModel;
|
|
290
457
|
let currentThinking = originalThinking;
|
|
291
458
|
chainActive = true;
|
|
292
|
-
|
|
459
|
+
pendingSkillMessage = undefined;
|
|
293
460
|
const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
|
|
294
461
|
const isUnlimited = totalIterations === null;
|
|
295
462
|
const useConverge = isUnlimited ? true : converge;
|
|
@@ -298,6 +465,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
298
465
|
const chainStepNames = steps.map((step) => step.name).join(" -> ");
|
|
299
466
|
let completedIterations = 0;
|
|
300
467
|
let converged = false;
|
|
468
|
+
let chainErrorState: ExecutionErrorState = { hasError: false, error: undefined };
|
|
301
469
|
if (effectiveMax > 1) {
|
|
302
470
|
loopState = { currentIteration: 1, totalIterations };
|
|
303
471
|
accumulatedSummaries = [];
|
|
@@ -347,7 +515,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
347
515
|
const iterSuffix = stepLoop > 1 ? ` (iter ${stepIteration + 1}/${stepLoop})` : "";
|
|
348
516
|
notify(ctx, `${loopPrefix}Step ${stepNumber}/${templates.length}: ${template.name}${iterSuffix} ${buildPromptCommandDescription(template)}`, "info");
|
|
349
517
|
|
|
350
|
-
const prepared = await preparePromptExecution(template, effectiveArgs, currentModel, ctx.modelRegistry
|
|
518
|
+
const prepared = await preparePromptExecution(template, effectiveArgs, currentModel, ctx.modelRegistry, {
|
|
519
|
+
inheritedModel: chainInheritedModel,
|
|
520
|
+
});
|
|
351
521
|
if (!prepared) {
|
|
352
522
|
notify(
|
|
353
523
|
ctx,
|
|
@@ -367,6 +537,13 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
367
537
|
notify(ctx, prepared.warning, "warning");
|
|
368
538
|
}
|
|
369
539
|
|
|
540
|
+
const skillResolution = resolveSkillMessage(template.skill, ctx.cwd);
|
|
541
|
+
if (skillResolution.kind === "error") {
|
|
542
|
+
notify(ctx, `Step ${stepNumber}/${templates.length} failed: ${skillResolution.error}`, "error");
|
|
543
|
+
aborted = true;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
|
|
370
547
|
if (!prepared.selectedModel.alreadyActive) {
|
|
371
548
|
const switched = await pi.setModel(prepared.selectedModel.model);
|
|
372
549
|
if (!switched) {
|
|
@@ -378,6 +555,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
378
555
|
aborted = true;
|
|
379
556
|
break;
|
|
380
557
|
}
|
|
558
|
+
runtimeModel = prepared.selectedModel.model;
|
|
381
559
|
}
|
|
382
560
|
|
|
383
561
|
currentModel = prepared.selectedModel.model;
|
|
@@ -386,7 +564,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
386
564
|
pi.setThinkingLevel(template.thinking);
|
|
387
565
|
currentThinking = pi.getThinkingLevel();
|
|
388
566
|
}
|
|
389
|
-
|
|
567
|
+
pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
|
|
390
568
|
|
|
391
569
|
const stepIterationStartId = ctx.sessionManager.getLeafId();
|
|
392
570
|
pi.sendUserMessage(prepared.content);
|
|
@@ -426,30 +604,35 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
426
604
|
}
|
|
427
605
|
}
|
|
428
606
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
chainErrorState = { hasError: true, error };
|
|
432
609
|
} finally {
|
|
433
|
-
|
|
610
|
+
chainErrorState = await restoreAfterExecution(
|
|
611
|
+
ctx,
|
|
612
|
+
shouldRestore,
|
|
613
|
+
originalModel,
|
|
614
|
+
originalThinking,
|
|
615
|
+
currentModel,
|
|
616
|
+
currentThinking,
|
|
617
|
+
chainErrorState,
|
|
618
|
+
"chain",
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
pendingSkillMessage = undefined;
|
|
434
622
|
chainActive = false;
|
|
435
623
|
loopState = null;
|
|
436
624
|
freshCollapse = null;
|
|
437
625
|
accumulatedSummaries = [];
|
|
438
626
|
updateLoopStatus(ctx);
|
|
439
627
|
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
notify(ctx, `Loop converged at ${convergedLabel} (no changes)`, "info");
|
|
443
|
-
} else if (effectiveMax > 1 && completedIterations > 0) {
|
|
444
|
-
if (totalIterations !== null) {
|
|
445
|
-
notify(ctx, `Loop finished: ${completedIterations}/${totalIterations} iterations`, "info");
|
|
446
|
-
} else if (completedIterations === effectiveMax) {
|
|
447
|
-
notify(ctx, `Loop finished: ${completedIterations} iterations (cap reached)`, "info");
|
|
448
|
-
} else {
|
|
449
|
-
notify(ctx, `Loop finished: ${completedIterations} iterations`, "info");
|
|
450
|
-
}
|
|
628
|
+
if (!chainErrorState.hasError) {
|
|
629
|
+
notifyLoopCompletion(ctx, completedIterations, totalIterations, effectiveMax, converged, true);
|
|
451
630
|
}
|
|
452
631
|
}
|
|
632
|
+
|
|
633
|
+
if (chainErrorState.hasError) {
|
|
634
|
+
throw chainErrorState.error;
|
|
635
|
+
}
|
|
453
636
|
}
|
|
454
637
|
|
|
455
638
|
async function runPromptCommand(name: string, args: string, ctx: ExtensionCommandContext) {
|
|
@@ -514,7 +697,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
514
697
|
return;
|
|
515
698
|
}
|
|
516
699
|
|
|
517
|
-
const savedModel = ctx
|
|
700
|
+
const savedModel = getCurrentModel(ctx);
|
|
518
701
|
const savedThinking = pi.getThinkingLevel();
|
|
519
702
|
const prepared = await preparePromptExecution(prompt, parseCommandArgs(args), savedModel, ctx.modelRegistry);
|
|
520
703
|
if (!prepared) {
|
|
@@ -530,12 +713,19 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
530
713
|
notify(ctx, prepared.warning, "warning");
|
|
531
714
|
}
|
|
532
715
|
|
|
716
|
+
const skillResolution = resolveSkillMessage(prompt.skill, ctx.cwd);
|
|
717
|
+
if (skillResolution.kind === "error") {
|
|
718
|
+
notify(ctx, skillResolution.error, "error");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
533
722
|
if (!prepared.selectedModel.alreadyActive) {
|
|
534
723
|
const switched = await pi.setModel(prepared.selectedModel.model);
|
|
535
724
|
if (!switched) {
|
|
536
725
|
notify(ctx, `Failed to switch to model ${prepared.selectedModel.model.provider}/${prepared.selectedModel.model.id}`, "error");
|
|
537
726
|
return;
|
|
538
727
|
}
|
|
728
|
+
runtimeModel = prepared.selectedModel.model;
|
|
539
729
|
}
|
|
540
730
|
|
|
541
731
|
if (prompt.restore && !prepared.selectedModel.alreadyActive) {
|
|
@@ -548,26 +738,36 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
548
738
|
}
|
|
549
739
|
pi.setThinkingLevel(prompt.thinking);
|
|
550
740
|
}
|
|
551
|
-
|
|
741
|
+
pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
|
|
552
742
|
|
|
553
743
|
pi.sendUserMessage(prepared.content);
|
|
554
744
|
await waitForTurnStart(ctx);
|
|
555
745
|
await ctx.waitForIdle();
|
|
556
746
|
}
|
|
557
747
|
|
|
558
|
-
|
|
748
|
+
function resetSessionScopedState(ctx: ExtensionContext) {
|
|
559
749
|
storedCommandCtx = null;
|
|
750
|
+
pendingSkillMessage = undefined;
|
|
751
|
+
previousModel = undefined;
|
|
752
|
+
previousThinking = undefined;
|
|
753
|
+
runtimeModel = ctx.model;
|
|
560
754
|
toolManager.clearQueue();
|
|
561
755
|
refreshPrompts(ctx.cwd, ctx);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
759
|
+
resetSessionScopedState(ctx);
|
|
562
760
|
});
|
|
563
761
|
|
|
564
762
|
pi.on("session_switch", async (_event, ctx) => {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
763
|
+
resetSessionScopedState(ctx);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
pi.on("model_select", async (event) => {
|
|
767
|
+
runtimeModel = event.model;
|
|
568
768
|
});
|
|
569
769
|
|
|
570
|
-
pi.on("before_agent_start", async (event
|
|
770
|
+
pi.on("before_agent_start", async (event) => {
|
|
571
771
|
let systemPrompt = event.systemPrompt;
|
|
572
772
|
|
|
573
773
|
if (toolManager.isEnabled() && !loopState && !chainActive) {
|
|
@@ -586,38 +786,22 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
586
786
|
systemPrompt += `\n\nYou are on ${iterText} of the same prompt. Previous iterations and their results are visible in the conversation above. Build on that work — focus on what remains to improve.`;
|
|
587
787
|
}
|
|
588
788
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const skillPath = resolveSkillPath(skillName, cwd);
|
|
594
|
-
if (skillPath) {
|
|
595
|
-
try {
|
|
596
|
-
const skillContent = readSkillContent(skillPath);
|
|
597
|
-
pi.sendMessage<SkillLoadedDetails>({
|
|
598
|
-
customType: "skill-loaded",
|
|
599
|
-
content: `Loaded skill: ${skillName}`,
|
|
600
|
-
display: true,
|
|
601
|
-
details: { skillName, skillContent, skillPath },
|
|
602
|
-
});
|
|
603
|
-
systemPrompt += `\n\n<skill name="${skillName}">\n${skillContent}\n</skill>`;
|
|
604
|
-
} catch (error) {
|
|
605
|
-
notify(ctx, `Failed to read skill "${skillName}": ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
606
|
-
}
|
|
607
|
-
} else {
|
|
608
|
-
notify(ctx, `Skill "${skillName}" not found`, "error");
|
|
609
|
-
}
|
|
610
|
-
}
|
|
789
|
+
const skillMessage = consumePendingSkillMessage();
|
|
790
|
+
const hasSystemPromptOverride = systemPrompt !== event.systemPrompt;
|
|
791
|
+
if (!hasSystemPromptOverride && !skillMessage) return;
|
|
611
792
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
793
|
+
return {
|
|
794
|
+
...(hasSystemPromptOverride ? { systemPrompt } : {}),
|
|
795
|
+
...(skillMessage ? { message: skillMessage } : {}),
|
|
796
|
+
};
|
|
615
797
|
});
|
|
616
798
|
|
|
617
799
|
pi.on("agent_end", async (_event, ctx) => {
|
|
618
800
|
if (chainActive) return;
|
|
619
801
|
if (loopState) return;
|
|
620
802
|
|
|
803
|
+
runtimeModel = ctx.model;
|
|
804
|
+
|
|
621
805
|
const restoreModel = previousModel;
|
|
622
806
|
const restoreThinking = previousThinking;
|
|
623
807
|
previousModel = undefined;
|
|
@@ -625,7 +809,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
|
|
|
625
809
|
|
|
626
810
|
const restoreFn = async () => {
|
|
627
811
|
if (restoreModel || restoreThinking !== undefined) {
|
|
628
|
-
await restoreSessionState(ctx, restoreModel, restoreThinking, ctx
|
|
812
|
+
await restoreSessionState(ctx, restoreModel, restoreThinking, getCurrentModel(ctx), pi.getThinkingLevel());
|
|
629
813
|
}
|
|
630
814
|
};
|
|
631
815
|
const processed = await toolManager.processQueue(ctx, restoreFn);
|
package/package.json
CHANGED
package/prompt-execution.ts
CHANGED
|
@@ -16,14 +16,40 @@ export interface EmptyPromptAbort {
|
|
|
16
16
|
warning?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
interface PromptExecutionOptions {
|
|
20
|
+
inheritedModel?: Model<any>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameModel(a: Model<any> | undefined, b: Model<any> | undefined): boolean {
|
|
24
|
+
if (!a || !b) return a === b;
|
|
25
|
+
return a.provider === b.provider && a.id === b.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
export async function preparePromptExecution(
|
|
20
29
|
prompt: Pick<PromptWithModel, "name" | "content" | "models">,
|
|
21
30
|
args: string[],
|
|
22
31
|
currentModel: Model<any> | undefined,
|
|
23
32
|
modelRegistry: Pick<ModelRegistry, "find" | "getAll" | "getAvailable" | "getApiKey" | "isUsingOAuth">,
|
|
33
|
+
options?: PromptExecutionOptions,
|
|
24
34
|
): Promise<PreparedPromptExecution | EmptyPromptAbort | undefined> {
|
|
25
|
-
const selectedModel =
|
|
35
|
+
const selectedModel =
|
|
36
|
+
prompt.models.length === 0
|
|
37
|
+
? (() => {
|
|
38
|
+
const hasInheritedModel = options !== undefined && Object.hasOwn(options, "inheritedModel");
|
|
39
|
+
const inheritedModel = hasInheritedModel ? options.inheritedModel : currentModel;
|
|
40
|
+
if (!inheritedModel) {
|
|
41
|
+
return {
|
|
42
|
+
message: `Prompt \`${prompt.name}\` has no \`model\` configured and there is no active session model to inherit.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
model: inheritedModel,
|
|
47
|
+
alreadyActive: sameModel(currentModel, inheritedModel),
|
|
48
|
+
};
|
|
49
|
+
})()
|
|
50
|
+
: await selectModelCandidate(prompt.models, currentModel, modelRegistry);
|
|
26
51
|
if (!selectedModel) return undefined;
|
|
52
|
+
if ("message" in selectedModel) return selectedModel;
|
|
27
53
|
|
|
28
54
|
const rendered = renderTemplateConditionals(prompt.content, getResolvedModelRef(selectedModel.model), prompt.name);
|
|
29
55
|
const content = substituteArgs(rendered.content, args);
|
package/prompt-loader.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
5
5
|
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
|
|
@@ -436,8 +436,10 @@ function loadPromptsWithModelFromDir(
|
|
|
436
436
|
if (!frontmatter) continue;
|
|
437
437
|
const { body } = parsed;
|
|
438
438
|
const chain = normalizeChain(frontmatter.chain, fullPath, source, diagnostics);
|
|
439
|
-
const
|
|
440
|
-
|
|
439
|
+
const hasModelField = Object.hasOwn(frontmatter, "model");
|
|
440
|
+
const parsedModels = chain ? [] : normalizeModelSpecs(frontmatter.model, fullPath, source, diagnostics);
|
|
441
|
+
if (!chain && hasModelField && !parsedModels) continue;
|
|
442
|
+
const models = chain ? [] : (parsedModels ?? []);
|
|
441
443
|
|
|
442
444
|
const name = entry.name.slice(0, -3);
|
|
443
445
|
if (RESERVED_COMMAND_NAMES.has(name)) {
|
|
@@ -459,6 +461,17 @@ function loadPromptsWithModelFromDir(
|
|
|
459
461
|
const fresh = normalizeFresh(frontmatter.fresh, fullPath, source, diagnostics);
|
|
460
462
|
const loop = normalizeLoop(frontmatter.loop, fullPath, source, diagnostics);
|
|
461
463
|
const converge = normalizeConverge(frontmatter.converge, fullPath, source, diagnostics);
|
|
464
|
+
const hasModelConditionalDirectives = /<if-model(?:\s|>)|<else(?:\s|>)|<\/if-model\s*>|<\/else(?:\s|>)/.test(body);
|
|
465
|
+
const hasExtensionSpecificConfig =
|
|
466
|
+
skill !== undefined ||
|
|
467
|
+
thinking !== undefined ||
|
|
468
|
+
fresh === true ||
|
|
469
|
+
loop !== undefined ||
|
|
470
|
+
converge === false ||
|
|
471
|
+
hasModelConditionalDirectives;
|
|
472
|
+
if (!chain && !hasModelField && !hasExtensionSpecificConfig) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
462
475
|
|
|
463
476
|
prompts.push({
|
|
464
477
|
name,
|
|
@@ -550,7 +563,7 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
|
|
|
550
563
|
const details = `[chain: ${prompt.chain}] ${sourceLabel}`;
|
|
551
564
|
return prompt.description ? `${prompt.description} ${details}` : details;
|
|
552
565
|
}
|
|
553
|
-
const modelLabel = prompt.models.map((model) => model.split("/").pop() || model).join("|");
|
|
566
|
+
const modelLabel = prompt.models.length > 0 ? prompt.models.map((model) => model.split("/").pop() || model).join("|") : "current";
|
|
554
567
|
const skillLabel = prompt.skill ? ` +${prompt.skill}` : "";
|
|
555
568
|
const thinkingLabel = prompt.thinking ? ` ${prompt.thinking}` : "";
|
|
556
569
|
const loopLabel = prompt.loop ? ` loop:${prompt.loop}` : "";
|
|
@@ -558,16 +571,53 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
|
|
|
558
571
|
return prompt.description ? `${prompt.description} ${details}` : details;
|
|
559
572
|
}
|
|
560
573
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
574
|
+
function getSkillCandidates(baseDir: string, skillName: string): string[] {
|
|
575
|
+
return [join(baseDir, skillName, "SKILL.md"), join(baseDir, `${skillName}.md`)];
|
|
576
|
+
}
|
|
564
577
|
|
|
565
|
-
|
|
566
|
-
|
|
578
|
+
function* walkAncestors(startDir: string, stopDir?: string): Generator<string> {
|
|
579
|
+
let current = startDir;
|
|
580
|
+
while (true) {
|
|
581
|
+
yield current;
|
|
582
|
+
if (stopDir && current === stopDir) return;
|
|
583
|
+
const parent = dirname(current);
|
|
584
|
+
if (parent === current) return;
|
|
585
|
+
current = parent;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
567
588
|
|
|
589
|
+
function findRepoRoot(startDir: string): string | undefined {
|
|
590
|
+
for (const dir of walkAncestors(startDir)) {
|
|
591
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
592
|
+
}
|
|
568
593
|
return undefined;
|
|
569
594
|
}
|
|
570
595
|
|
|
596
|
+
function findFirstExisting(paths: string[]): string | undefined {
|
|
597
|
+
for (const path of paths) {
|
|
598
|
+
if (existsSync(path)) return path;
|
|
599
|
+
}
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function resolveSkillPath(skillName: string, cwd: string): string | undefined {
|
|
604
|
+
const projectDir = resolve(cwd);
|
|
605
|
+
|
|
606
|
+
const projectPiSkill = findFirstExisting(getSkillCandidates(resolve(projectDir, ".pi", "skills"), skillName));
|
|
607
|
+
if (projectPiSkill) return projectPiSkill;
|
|
608
|
+
|
|
609
|
+
const repoRoot = findRepoRoot(projectDir);
|
|
610
|
+
for (const dir of walkAncestors(projectDir, repoRoot)) {
|
|
611
|
+
const projectAgentsSkill = findFirstExisting(getSkillCandidates(join(dir, ".agents", "skills"), skillName));
|
|
612
|
+
if (projectAgentsSkill) return projectAgentsSkill;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const globalPiSkill = findFirstExisting(getSkillCandidates(join(homedir(), ".pi", "agent", "skills"), skillName));
|
|
616
|
+
if (globalPiSkill) return globalPiSkill;
|
|
617
|
+
|
|
618
|
+
return findFirstExisting(getSkillCandidates(join(homedir(), ".agents", "skills"), skillName));
|
|
619
|
+
}
|
|
620
|
+
|
|
571
621
|
export function readSkillContent(skillPath: string): string {
|
|
572
622
|
const raw = readFileSync(skillPath, "utf-8");
|
|
573
623
|
return parseFrontmatter(raw).body;
|