@brettinternet/pi-loop 0.1.3 → 0.1.4
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/README.md +9 -1
- package/index.ts +83 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-loop
|
|
2
2
|
|
|
3
|
-
Run a prompt repeatedly, with a fresh session for every iteration.
|
|
3
|
+
Run a prompt repeatedly, with a fresh session for every iteration. Each new session uses the model selected in the preceding session (including a custom non-default model); changing models mid-loop takes effect on the next iteration. If that model is unavailable, the loop pauses rather than falling back to the default.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pi install npm:@brettinternet/pi-loop
|
|
@@ -35,3 +35,11 @@ Chain commands in the prompt:
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Built-in interactive commands cannot be chained.
|
|
38
|
+
|
|
39
|
+
While a loop is active, every child command receives its stable run ID as `PI_LOOP_RUN_ID`:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
PI_LOOP_RUN_ID=3992f183-e054-4068-a13e-11281d1747e2
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The value is always the full UUID, remains unchanged across every iteration and replacement session in that loop, and differs between independent loops. When the loop ends or its session closes, pi-loop restores the previous value or removes the variable if it was previously unset.
|
package/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Type } from "typebox";
|
|
|
11
11
|
|
|
12
12
|
export const LOOP_STATE_ENTRY = "pi-loop-state-v1";
|
|
13
13
|
export const LOOP_WIDGET_KEY = "pi-loop";
|
|
14
|
+
export const LOOP_RUN_ID_ENV = "PI_LOOP_RUN_ID";
|
|
14
15
|
export const LOOP_USAGE =
|
|
15
16
|
"usage: /loop <positive-count> [--delay <duration>] <prompt> | /loop for <duration> --delay <duration> <prompt> | /loop <positive-count> | /loop <+|-><count> | /loop time <duration> | /loop delay <duration> | /loop prompt <text> | /loop append <text> | /loop status | /loop pause | /loop resume | /loop next | /loop end";
|
|
16
17
|
|
|
@@ -47,6 +48,7 @@ export interface LoopState {
|
|
|
47
48
|
pausedAt?: number;
|
|
48
49
|
ownerSessionId?: string;
|
|
49
50
|
ownerSessionFile?: string;
|
|
51
|
+
model?: { provider: string; id: string };
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export type ParsedLoopCommand =
|
|
@@ -64,12 +66,41 @@ export type ParsedLoopCommand =
|
|
|
64
66
|
| { kind: "next" }
|
|
65
67
|
| { kind: "end" }
|
|
66
68
|
| { kind: "continue"; runId: string; iteration: number }
|
|
67
|
-
| { kind: "pause"; runId: string; iteration: number }
|
|
69
|
+
| { kind: "pause"; runId: string; iteration: number }
|
|
70
|
+
| { kind: "restoreModel"; runId: string; iteration: number };
|
|
68
71
|
|
|
69
72
|
const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping"]);
|
|
70
73
|
const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping", "paused"]);
|
|
71
74
|
const TERMINAL_STATUSES = new Set<LoopStatus>(["completed", "stopped", "inactive"]);
|
|
72
75
|
|
|
76
|
+
const LOOP_ENVIRONMENT_STATE_KEY = "__piLoopRunEnvironmentV1";
|
|
77
|
+
type LoopEnvironmentState = { runId: string; originalValue: string | undefined };
|
|
78
|
+
|
|
79
|
+
function environmentState(): LoopEnvironmentState | undefined {
|
|
80
|
+
return (globalThis as Record<string, unknown>)[LOOP_ENVIRONMENT_STATE_KEY] as LoopEnvironmentState | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function exposeLoopRunId(runId: string): void {
|
|
84
|
+
const active = environmentState();
|
|
85
|
+
if (active) {
|
|
86
|
+
active.runId = runId;
|
|
87
|
+
} else {
|
|
88
|
+
(globalThis as Record<string, unknown>)[LOOP_ENVIRONMENT_STATE_KEY] = {
|
|
89
|
+
runId,
|
|
90
|
+
originalValue: process.env[LOOP_RUN_ID_ENV],
|
|
91
|
+
} satisfies LoopEnvironmentState;
|
|
92
|
+
}
|
|
93
|
+
process.env[LOOP_RUN_ID_ENV] = runId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function restoreLoopRunId(expectedRunId?: string): void {
|
|
97
|
+
const active = environmentState();
|
|
98
|
+
if (!active || (expectedRunId !== undefined && active.runId !== expectedRunId)) return;
|
|
99
|
+
if (active.originalValue === undefined) delete process.env[LOOP_RUN_ID_ENV];
|
|
100
|
+
else process.env[LOOP_RUN_ID_ENV] = active.originalValue;
|
|
101
|
+
delete (globalThis as Record<string, unknown>)[LOOP_ENVIRONMENT_STATE_KEY];
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
type ArgumentCompletion = { value: string; label: string; description?: string };
|
|
74
105
|
|
|
75
106
|
function completeArguments(
|
|
@@ -312,7 +343,7 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
312
343
|
// These commands are only emitted by the extension itself. Keeping them in
|
|
313
344
|
// the same dispatcher gives boundary transitions command-only session APIs
|
|
314
345
|
// while preventing user input from accidentally looking like one.
|
|
315
|
-
if (first === "__continue" || first === "__pause") {
|
|
346
|
+
if (first === "__continue" || first === "__pause" || first === "__restore_model") {
|
|
316
347
|
const fields = rest.split(/\s+/).filter(Boolean);
|
|
317
348
|
if (fields.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(fields[0])) {
|
|
318
349
|
throw new Error("invalid internal loop command");
|
|
@@ -323,7 +354,9 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
323
354
|
}
|
|
324
355
|
return first === "__continue"
|
|
325
356
|
? { kind: "continue", runId: fields[0], iteration }
|
|
326
|
-
:
|
|
357
|
+
: first === "__pause"
|
|
358
|
+
? { kind: "pause", runId: fields[0], iteration }
|
|
359
|
+
: { kind: "restoreModel", runId: fields[0], iteration };
|
|
327
360
|
}
|
|
328
361
|
|
|
329
362
|
if (/^[+\-]\d/.test(first)) {
|
|
@@ -411,6 +444,12 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
411
444
|
if (value.pausedAt !== undefined && !isNonNegativeInteger(value.pausedAt)) return undefined;
|
|
412
445
|
if (value.ownerSessionId !== undefined && typeof value.ownerSessionId !== "string") return undefined;
|
|
413
446
|
if (value.ownerSessionFile !== undefined && typeof value.ownerSessionFile !== "string") return undefined;
|
|
447
|
+
const model = value.model;
|
|
448
|
+
if (
|
|
449
|
+
model !== undefined &&
|
|
450
|
+
(!isRecord(model) || typeof model.provider !== "string" || !model.provider ||
|
|
451
|
+
typeof model.id !== "string" || !model.id)
|
|
452
|
+
) return undefined;
|
|
414
453
|
|
|
415
454
|
return {
|
|
416
455
|
version: 1,
|
|
@@ -430,6 +469,9 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
430
469
|
...(value.pausedAt !== undefined ? { pausedAt: value.pausedAt } : {}),
|
|
431
470
|
...(value.ownerSessionId ? { ownerSessionId: value.ownerSessionId } : {}),
|
|
432
471
|
...(value.ownerSessionFile ? { ownerSessionFile: value.ownerSessionFile } : {}),
|
|
472
|
+
...(isRecord(model) && typeof model.provider === "string" && typeof model.id === "string"
|
|
473
|
+
? { model: { provider: model.provider, id: model.id } }
|
|
474
|
+
: {}),
|
|
433
475
|
};
|
|
434
476
|
}
|
|
435
477
|
|
|
@@ -612,9 +654,19 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
612
654
|
return runState;
|
|
613
655
|
}
|
|
614
656
|
|
|
657
|
+
function syncLoopEnvironment(state: LoopState | undefined): void {
|
|
658
|
+
const runId = state?.runId;
|
|
659
|
+
if (state && statusIsVisible(state)) {
|
|
660
|
+
exposeLoopRunId(state.runId);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (!transitionInFlight) restoreLoopRunId(runId);
|
|
664
|
+
}
|
|
665
|
+
|
|
615
666
|
function persist(ctx: Pick<ExtensionAPI, "appendEntry">, state: LoopState): void {
|
|
616
667
|
ctx.appendEntry(LOOP_STATE_ENTRY, state);
|
|
617
668
|
runState = state;
|
|
669
|
+
syncLoopEnvironment(state);
|
|
618
670
|
reportHerdrBlocked(state);
|
|
619
671
|
}
|
|
620
672
|
|
|
@@ -885,6 +937,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
885
937
|
|
|
886
938
|
function transferState(state: LoopState, manager: SessionManager): LoopState {
|
|
887
939
|
const transferred = stateForSession({ ...state, status: "active" }, sessionIdentity(manager));
|
|
940
|
+
exposeLoopRunId(transferred.runId);
|
|
888
941
|
manager.appendCustomEntry(LOOP_STATE_ENTRY, transferred);
|
|
889
942
|
return transferred;
|
|
890
943
|
}
|
|
@@ -893,9 +946,17 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
893
946
|
replacement: ReplacementContext,
|
|
894
947
|
state: LoopState,
|
|
895
948
|
): Promise<void> {
|
|
896
|
-
// The new extension
|
|
897
|
-
//
|
|
898
|
-
|
|
949
|
+
// The replacement owns the new extension runtime. Restore its model before
|
|
950
|
+
// starting a turn, rather than using the invalidated previous runtime.
|
|
951
|
+
if (state.model) {
|
|
952
|
+
await replacement.sendUserMessage(
|
|
953
|
+
`/loop __restore_model ${state.runId} ${state.currentIteration}`,
|
|
954
|
+
{ expandPromptTemplates: true },
|
|
955
|
+
);
|
|
956
|
+
if (replacement.model?.provider !== state.model.provider || replacement.model.id !== state.model.id) {
|
|
957
|
+
throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
899
960
|
if (replacement.hasUI) showWidget(replacement, state);
|
|
900
961
|
if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
|
|
901
962
|
await replacement.sendUserMessage(
|
|
@@ -908,6 +969,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
908
969
|
}
|
|
909
970
|
|
|
910
971
|
async function replaceForIteration(ctx: ExtensionCommandContext, next: LoopState): Promise<void> {
|
|
972
|
+
exposeLoopRunId(next.runId);
|
|
911
973
|
clearContinuationWait();
|
|
912
974
|
clearRetryWait();
|
|
913
975
|
clearRecoveryTimer();
|
|
@@ -1068,6 +1130,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1068
1130
|
} = state;
|
|
1069
1131
|
const next: LoopState = {
|
|
1070
1132
|
...withoutPause,
|
|
1133
|
+
...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
|
|
1071
1134
|
currentIteration: state.currentIteration + 1,
|
|
1072
1135
|
remainingBudget: state.endsAt === undefined ? nextBudget - 1 : 0,
|
|
1073
1136
|
pendingRetune: null,
|
|
@@ -1119,6 +1182,16 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1119
1182
|
return;
|
|
1120
1183
|
}
|
|
1121
1184
|
|
|
1185
|
+
if (parsed.kind === "restoreModel") {
|
|
1186
|
+
const state = currentState(ctx);
|
|
1187
|
+
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !state.model) return;
|
|
1188
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
1189
|
+
if (!model || !(await pi.setModel(model))) {
|
|
1190
|
+
throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1122
1195
|
if (parsed.kind === "pause") {
|
|
1123
1196
|
const state = currentState(ctx);
|
|
1124
1197
|
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !statusIsActive(state)) return;
|
|
@@ -1402,6 +1475,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1402
1475
|
status: "active",
|
|
1403
1476
|
retryCount: 0,
|
|
1404
1477
|
phase: "running",
|
|
1478
|
+
...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
|
|
1405
1479
|
...(timed ? { endsAt: Date.now() + parsed.duration } : {}),
|
|
1406
1480
|
...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
|
|
1407
1481
|
...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
|
|
@@ -1454,6 +1528,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1454
1528
|
const loaded = latestStateFromContext(ctx);
|
|
1455
1529
|
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
1456
1530
|
runState = owned;
|
|
1531
|
+
syncLoopEnvironment(owned);
|
|
1457
1532
|
reportHerdrBlocked(owned);
|
|
1458
1533
|
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
1459
1534
|
else renderWidget(ctx, owned);
|
|
@@ -1533,6 +1608,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1533
1608
|
const loaded = latestStateFromContext(ctx);
|
|
1534
1609
|
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
1535
1610
|
runState = owned;
|
|
1611
|
+
syncLoopEnvironment(owned);
|
|
1536
1612
|
reportHerdrBlocked(owned);
|
|
1537
1613
|
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
1538
1614
|
else renderWidget(ctx, owned);
|
|
@@ -1553,6 +1629,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1553
1629
|
// Shutdown may already have detached the runtime's append action.
|
|
1554
1630
|
}
|
|
1555
1631
|
}
|
|
1632
|
+
if (event.reason !== "reload" && !transitionInFlight) restoreLoopRunId(loaded?.runId);
|
|
1556
1633
|
reportHerdrBlocked(undefined);
|
|
1557
1634
|
clearWidget(ctx);
|
|
1558
1635
|
currentSessionManagerRef = undefined;
|