pi-prompt-template-model 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.1] - 2026-04-26
6
+
7
+ ### Fixed
8
+ - `boomerang: true` prompt collapses now signal rewind to keep current files automatically, avoiding the restore-options prompt during collapse.
9
+
10
+ ## [0.9.0] - 2026-04-25
11
+
12
+ ### Added
13
+ - Added `boomerang: true` prompt frontmatter so non-chain templates, including looped templates, can run through prompt-template-model and then collapse their execution context back to the pre-run branch.
14
+
15
+ ### Fixed
16
+ - Migrated extension tool schemas from `@sinclair/typebox` to `typebox` 1.x so packaged installs follow Pi's current extension runtime contract.
17
+
18
+ ### Changed
19
+ - Added `typebox` as a runtime dependency for packaged installs.
20
+
5
21
  ## [0.8.2] - 2026-04-21
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -73,6 +73,7 @@ All fields are optional. Templates that don't use any extension features (no `mo
73
73
  | `rotate` | `false` | When `true` and looping, cycle through models in the `model` list instead of using fallback semantics. Thinking levels can also be comma-separated to pair with each model. |
74
74
  | `fresh` | `false` | When looping, collapse the conversation between iterations to a brief summary instead of carrying the full context forward. Saves tokens on long loops. |
75
75
  | `converge` | `true` | When looping, stop early if an iteration makes no file changes. Set `false` to always run every iteration. |
76
+ | `boomerang` | `false` | After a non-chain prompt finishes, collapse its execution context back to the branch point with a brief summary. Works with loops, including `fresh` loop summaries. Useful for review prompts like `/double-check`. |
76
77
  | `worktree` | `false` | When `true`, parallel delegated work runs in separate git worktrees. Valid on chain templates with `parallel()` steps, on delegated prompts with `parallel: N`, and on compare templates via `bestOfN.worktree`. |
77
78
 
78
79
  ### Delegation
@@ -291,7 +292,7 @@ This repo ships one example compare prompt under `examples/`:
291
292
  - `examples/best-of-n.md` installs as `/best-of-n`, runs in the current repo, and shows mixed workers, mixed reviewers, and an optional final apply phase.
292
293
  - Smoke test: `/best-of-n smoke test`.
293
294
 
294
- Install them manually from this repo checkout (or from the installed package directory):
295
+ Install it manually from this repo checkout (or from the installed package directory):
295
296
 
296
297
  ```bash
297
298
  PTM_DIR=/path/to/pi-prompt-template-model
package/index.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  type SubagentOverride,
17
17
  } from "./args.js";
18
18
  import { parseChainSteps, parseChainDeclaration, type ChainStep, type ChainStepOrParallel, type ParallelChainStep } from "./chain-parser.js";
19
- import { generateChainStepSummary, generateIterationSummary, didIterationMakeChanges, getIterationEntries, wasIterationAborted } from "./loop-utils.js";
19
+ import { generateBoomerangSummary, generateChainStepSummary, generateIterationSummary, didIterationMakeChanges, getIterationEntries, wasIterationAborted } from "./loop-utils.js";
20
20
  import { selectModelCandidate } from "./model-selection.js";
21
21
  import { notify, summarizePromptDiagnostics, diagnosticsFingerprint } from "./notifications.js";
22
22
  import { preparePromptExecution, renderPromptForResolvedModel } from "./prompt-execution.js";
@@ -56,6 +56,12 @@ interface FreshCollapse {
56
56
  totalIterations: number | null;
57
57
  }
58
58
 
59
+ interface BoomerangCollapse {
60
+ targetId: string;
61
+ task: string;
62
+ previousSummaries: string[];
63
+ }
64
+
59
65
  interface PendingSkillMessage {
60
66
  customType: "skill-loaded";
61
67
  content: string;
@@ -108,6 +114,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
108
114
  let chainActive = false;
109
115
  let loopState: LoopState | null = null;
110
116
  let freshCollapse: FreshCollapse | null = null;
117
+ let boomerangCollapse: BoomerangCollapse | null = null;
111
118
  let accumulatedSummaries: string[] = [];
112
119
  let lastDiagnostics = "";
113
120
  let storedCommandCtx: ExtensionCommandContext | null = null;
@@ -918,6 +925,28 @@ export default function promptModelExtension(pi: ExtensionAPI) {
918
925
  }
919
926
  }
920
927
 
928
+ async function collapseBoomerangPrompt(
929
+ ctx: ExtensionContext,
930
+ name: string,
931
+ targetId: string | null,
932
+ previousSummaries: string[] = [],
933
+ ) {
934
+ if (!targetId) {
935
+ notify(ctx, `Cannot boomerang prompt \`${name}\`: no session entry to return to.`, "warning");
936
+ return;
937
+ }
938
+
939
+ boomerangCollapse = { targetId, task: name, previousSummaries };
940
+ try {
941
+ (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress = true;
942
+ const result = await ctx.navigateTree(targetId, { summarize: true });
943
+ if (result.cancelled) notify(ctx, `Boomerang cancelled for prompt \`${name}\``, "warning");
944
+ } finally {
945
+ (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress = false;
946
+ boomerangCollapse = null;
947
+ }
948
+ }
949
+
921
950
  async function runPromptLoop(
922
951
  name: string,
923
952
  cleanedArgs: string,
@@ -942,10 +971,11 @@ export default function promptModelExtension(pi: ExtensionAPI) {
942
971
  let currentThinking = savedThinking;
943
972
  const shouldRestore = initialPrompt.restore;
944
973
  const useFresh = freshFlag || initialPrompt.fresh === true;
974
+ const shouldBoomerang = initialPrompt.boomerang === true;
945
975
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
946
976
  const isUnlimited = totalIterations === null;
947
977
  const useConverge = converge && initialPrompt.converge !== false;
948
- const anchorId = useFresh ? ctx.sessionManager.getLeafId() : null;
978
+ const anchorId = useFresh || shouldBoomerang ? ctx.sessionManager.getLeafId() : null;
949
979
 
950
980
  loopState = { currentIteration: 1, totalIterations };
951
981
  accumulatedSummaries = [];
@@ -955,6 +985,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
955
985
  let loopErrorState: ExecutionErrorState = { hasError: false, error: undefined };
956
986
  let lastDelegatedText: string | undefined;
957
987
  let loopAborted = false;
988
+ let boomerangPreviousSummaries: string[] = [];
958
989
 
959
990
  try {
960
991
  for (let i = 0; i < effectiveMax; i++) {
@@ -1024,7 +1055,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1024
1055
  break;
1025
1056
  }
1026
1057
 
1027
- if (anchorId && i < effectiveMax - 1) {
1058
+ if (useFresh && anchorId && i < effectiveMax - 1) {
1028
1059
  freshCollapse = { targetId: anchorId, task: name, iteration: i + 1, totalIterations };
1029
1060
  const result = await ctx.navigateTree(anchorId, { summarize: true });
1030
1061
  freshCollapse = null;
@@ -1049,9 +1080,11 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1049
1080
  "loop",
1050
1081
  );
1051
1082
 
1083
+ boomerangPreviousSummaries = accumulatedSummaries;
1052
1084
  loopState = null;
1053
1085
  pendingSkillMessage = undefined;
1054
1086
  freshCollapse = null;
1087
+ boomerangCollapse = null;
1055
1088
  accumulatedSummaries = [];
1056
1089
  updateLoopStatus(ctx);
1057
1090
 
@@ -1069,6 +1102,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1069
1102
  await ctx.waitForIdle();
1070
1103
  }
1071
1104
 
1105
+ if (!loopErrorState.hasError && !loopAborted && shouldBoomerang) {
1106
+ await collapseBoomerangPrompt(ctx, name, anchorId, boomerangPreviousSummaries);
1107
+ }
1108
+
1072
1109
  if (loopErrorState.hasError) {
1073
1110
  throw loopErrorState.error;
1074
1111
  }
@@ -1402,6 +1439,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1402
1439
  chainActive = false;
1403
1440
  loopState = null;
1404
1441
  freshCollapse = null;
1442
+ boomerangCollapse = null;
1405
1443
  accumulatedSummaries = [];
1406
1444
  updateLoopStatus(ctx);
1407
1445
  if (ctx.hasUI) {
@@ -1549,6 +1587,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1549
1587
  };
1550
1588
  const savedModel = getCurrentModel(ctx);
1551
1589
  const savedThinking = pi.getThinkingLevel();
1590
+ const boomerangTargetId = effectivePrompt.boomerang ? ctx.sessionManager.getLeafId() : null;
1552
1591
  const stepResult = await executePromptStep(
1553
1592
  effectivePrompt,
1554
1593
  parseCommandArgs(argsWithoutSubagent),
@@ -1573,6 +1612,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1573
1612
  previousThinking = savedThinking;
1574
1613
  }
1575
1614
  }
1615
+
1616
+ if (effectivePrompt.boomerang) {
1617
+ await collapseBoomerangPrompt(ctx, name, boomerangTargetId);
1618
+ }
1576
1619
  }
1577
1620
 
1578
1621
  function resetSessionScopedState(ctx: ExtensionContext) {
@@ -1581,6 +1624,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1581
1624
  previousModel = undefined;
1582
1625
  previousThinking = undefined;
1583
1626
  runtimeModel = ctx.model;
1627
+ boomerangCollapse = null;
1584
1628
  toolManager.clearQueue();
1585
1629
  refreshPrompts(ctx.cwd, ctx);
1586
1630
  }
@@ -1644,6 +1688,15 @@ export default function promptModelExtension(pi: ExtensionAPI) {
1644
1688
  });
1645
1689
 
1646
1690
  pi.on("session_before_tree", async (event) => {
1691
+ if (boomerangCollapse && event.preparation.targetId === boomerangCollapse.targetId) {
1692
+ const summary = generateBoomerangSummary(event.preparation.entriesToSummarize, boomerangCollapse.task);
1693
+ return {
1694
+ summary: {
1695
+ summary: [...boomerangCollapse.previousSummaries, summary].join("\n\n---\n\n"),
1696
+ },
1697
+ };
1698
+ }
1699
+
1647
1700
  if (!freshCollapse) return;
1648
1701
  if (event.preparation.targetId !== freshCollapse.targetId) return;
1649
1702
 
package/loop-utils.ts CHANGED
@@ -88,7 +88,7 @@ function collectSummaryData(entries: SessionEntry[]): CollectedSummaryData {
88
88
  };
89
89
  }
90
90
 
91
- function formatSummary(header: string, entries: SessionEntry[]): string {
91
+ function formatSummary(header: string, entries: SessionEntry[], preserveOutcome = false): string {
92
92
  const { filesRead, filesWritten, commandCount, lastAssistantText } = collectSummaryData(entries);
93
93
 
94
94
  let summary = header;
@@ -102,9 +102,11 @@ function formatSummary(header: string, entries: SessionEntry[]): string {
102
102
  }
103
103
 
104
104
  if (lastAssistantText) {
105
- const cleaned = lastAssistantText.replace(/\n+/g, " ").trim();
106
- const truncated = cleaned.slice(0, 500);
107
- summary += `\nOutcome: ${truncated}${cleaned.length > 500 ? "..." : ""}`;
105
+ const cleaned = preserveOutcome
106
+ ? lastAssistantText.replace(/\r\n?/g, "\n").trim()
107
+ : lastAssistantText.replace(/\n+/g, " ").trim();
108
+ const outcome = preserveOutcome || cleaned.length <= 500 ? cleaned : `${cleaned.slice(0, 500)}...`;
109
+ summary += `\nOutcome: ${outcome}`;
108
110
  }
109
111
 
110
112
  return summary;
@@ -117,6 +119,10 @@ export function generateIterationSummary(entries: SessionEntry[], task: string,
117
119
  return formatSummary(header, entries);
118
120
  }
119
121
 
122
+ export function generateBoomerangSummary(entries: SessionEntry[], task: string): string {
123
+ return formatSummary(`[Boomerang]\nTask: "${task}"`, entries, true);
124
+ }
125
+
120
126
  export function generateChainStepSummary(entries: SessionEntry[], stepLabel: string, stepNumber: number): string {
121
127
  return formatSummary(`Step ${stepNumber} — ${stepLabel}:`, entries);
122
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-prompt-template-model",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Prompt template model selector extension for pi coding agent",
6
6
  "author": "Nico Bailon",
@@ -49,6 +49,9 @@
49
49
  "scripts": {
50
50
  "test": "tsx --test test/**/*.test.ts"
51
51
  },
52
+ "dependencies": {
53
+ "typebox": "^1.1.24"
54
+ },
52
55
  "devDependencies": {
53
56
  "@mariozechner/pi-agent-core": "^0.65.0",
54
57
  "@mariozechner/pi-ai": "^0.65.0",
package/prompt-loader.ts CHANGED
@@ -74,6 +74,7 @@ export interface PromptWithModel {
74
74
  fresh?: boolean;
75
75
  loop?: number | null;
76
76
  converge?: boolean;
77
+ boomerang?: boolean;
77
78
  parallel?: number;
78
79
  worktree?: boolean;
79
80
  deterministic?: DeterministicStep;
@@ -303,6 +304,31 @@ function normalizeRotate(
303
304
  return false;
304
305
  }
305
306
 
307
+ function normalizeBoomerang(
308
+ value: unknown,
309
+ filePath: string,
310
+ source: PromptSource,
311
+ diagnostics: PromptLoaderDiagnostic[],
312
+ ): boolean {
313
+ if (value === undefined) return false;
314
+ if (typeof value === "boolean") return value;
315
+ if (typeof value === "string") {
316
+ const normalized = value.trim().toLowerCase();
317
+ if (normalized === "true") return true;
318
+ if (normalized === "false") return false;
319
+ }
320
+
321
+ diagnostics.push(
322
+ createDiagnostic(
323
+ "invalid-boomerang",
324
+ filePath,
325
+ source,
326
+ `Using default boomerang=false for ${filePath}: frontmatter field "boomerang" must be true or false.`,
327
+ ),
328
+ );
329
+ return false;
330
+ }
331
+
306
332
  function normalizeLoop(
307
333
  value: unknown,
308
334
  filePath: string,
@@ -1643,6 +1669,18 @@ function loadPromptsWithModelFromDir(
1643
1669
  const fresh = normalizeFresh(frontmatter.fresh, fullPath, source, diagnostics);
1644
1670
  const loop = normalizeLoop(frontmatter.loop, fullPath, source, diagnostics);
1645
1671
  const converge = normalizeConverge(frontmatter.converge, fullPath, source, diagnostics);
1672
+ let boomerang = normalizeBoomerang(frontmatter.boomerang, fullPath, source, diagnostics);
1673
+ if (chain && boomerang) {
1674
+ diagnostics.push(
1675
+ createDiagnostic(
1676
+ "invalid-boomerang-chain",
1677
+ fullPath,
1678
+ source,
1679
+ `Ignoring boomerang in ${fullPath}: frontmatter fields "chain" and "boomerang" cannot be combined.`,
1680
+ ),
1681
+ );
1682
+ boomerang = false;
1683
+ }
1646
1684
  if (loop !== undefined && deterministic !== undefined) {
1647
1685
  diagnostics.push(
1648
1686
  createDiagnostic(
@@ -1695,6 +1733,7 @@ function loadPromptsWithModelFromDir(
1695
1733
  fresh === true ||
1696
1734
  loop !== undefined ||
1697
1735
  converge === false ||
1736
+ boomerang === true ||
1698
1737
  safeParallel !== undefined ||
1699
1738
  deterministic !== undefined ||
1700
1739
  hasLineup ||
@@ -1721,6 +1760,7 @@ function loadPromptsWithModelFromDir(
1721
1760
  fresh: fresh || undefined,
1722
1761
  loop: loop !== undefined ? loop : undefined,
1723
1762
  converge: converge === false ? false : undefined,
1763
+ boomerang: boomerang || undefined,
1724
1764
  parallel: safeParallel,
1725
1765
  worktree: safeWorktree,
1726
1766
  deterministic,
@@ -1821,6 +1861,7 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
1821
1861
  const thinkingValue = prompt.thinkingLevels ? prompt.thinkingLevels.join(",") : prompt.thinking;
1822
1862
  const thinkingLabel = thinkingValue ? ` ${thinkingValue}` : "";
1823
1863
  const loopLabel = prompt.loop !== undefined ? ` loop:${prompt.loop === null ? "unlimited" : prompt.loop}` : "";
1864
+ const boomerangLabel = prompt.boomerang ? " boomerang" : "";
1824
1865
  const subagentLabel = prompt.subagent ? ` subagent:${prompt.subagent === true ? "delegate" : prompt.subagent}` : "";
1825
1866
  const parallelLabel = prompt.parallel !== undefined ? ` parallel:${prompt.parallel}` : "";
1826
1867
  const deterministicLabel = prompt.deterministic ? ` deterministic-step:${prompt.deterministic.handoff}` : "";
@@ -1831,7 +1872,7 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
1831
1872
  const inheritContextLabel = prompt.inheritContext ? " fork" : "";
1832
1873
  const worktreeLabel = prompt.worktree ? " worktree" : "";
1833
1874
  const details =
1834
- `[${modelLabel}${rotateLabel}${thinkingLabel}${skillLabel}${loopLabel}${subagentLabel}${parallelLabel}${deterministicLabel}${workersLabel}${reviewersLabel}${finalApplierLabel}${cwdLabel}${inheritContextLabel}${worktreeLabel}] ${sourceLabel}`;
1875
+ `[${modelLabel}${rotateLabel}${thinkingLabel}${skillLabel}${loopLabel}${boomerangLabel}${subagentLabel}${parallelLabel}${deterministicLabel}${workersLabel}${reviewersLabel}${finalApplierLabel}${cwdLabel}${inheritContextLabel}${worktreeLabel}] ${sourceLabel}`;
1835
1876
  return prompt.description ? `${prompt.description} ${details}` : details;
1836
1877
  }
1837
1878
 
package/tool-manager.ts CHANGED
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
- import { Type } from "@sinclair/typebox";
5
+ import { Type } from "typebox";
6
6
  import { notify } from "./notifications.js";
7
7
 
8
8
  export interface ToolManagerDeps {