@hank-warren/pi-loop 0.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Builders for every loop-injected message: pokes, the proactive compaction
3
+ * instructions, and the post-compaction continuation. Pure string functions so
4
+ * tests can pin their contracts — most importantly that the continuation never
5
+ * embeds a runnable /loop command (research: Claude Code compaction summaries
6
+ * re-executed scheduling commands, issue #50554).
7
+ */
8
+
9
+ import { formatDuration } from "./interval.js";
10
+ import { appendContinuationMarker, appendPokeMarker } from "./markers.js";
11
+ import type { GoalSnapshot, LoopState } from "./state.js";
12
+
13
+ function formatIteration(loop: LoopState): string {
14
+ const cap = loop.maxIterations === null ? "unlimited" : `${loop.maxIterations}`;
15
+ return `${loop.iteration + 1}/${cap}`;
16
+ }
17
+
18
+ /** The recurring-prompt poke: the stored prompt with a short scheduled preamble. */
19
+ export function buildPromptPoke(loop: LoopState, preambleOverride: string | null): string {
20
+ const preamble =
21
+ preambleOverride ??
22
+ `Scheduled loop iteration ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}). Continue the recurring task below; if its work is exhausted, say so instead of inventing new work.`;
23
+ const prompt = loop.prompt ?? "";
24
+ return appendPokeMarker(`${preamble}\n\n${prompt}`.trim(), loop.id, loop.iteration + 1);
25
+ }
26
+
27
+ /** The goal-bound poke: restate the goal and wake goal_wait if applicable. */
28
+ export function buildGoalPoke(
29
+ loop: LoopState,
30
+ goal: GoalSnapshot,
31
+ reason: "goal-stalled" | "goal-waiting",
32
+ ): string {
33
+ const lines = [
34
+ `Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
35
+ reason === "goal-waiting"
36
+ ? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
37
+ : "The session went idle but the active goal is not complete. Continue working toward it.",
38
+ "",
39
+ `Active goal: ${goal.text}`,
40
+ ];
41
+ if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
42
+ lines.push("", "Use the goal tools (goal_complete, goal_blocked, goal_wait) when their conditions are met.");
43
+ return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
44
+ }
45
+
46
+ /**
47
+ * Instructions for the loop-owned proactive compaction. Encodes the
48
+ * research-backed preservation list, including cumulative carry-forward of
49
+ * prior summaries so detail does not decay geometrically across compactions.
50
+ */
51
+ export function buildCompactionInstructions(
52
+ loop: LoopState,
53
+ goal: GoalSnapshot | undefined,
54
+ override: string | null,
55
+ ): string {
56
+ if (override) return override;
57
+ const objective = goal
58
+ ? `The session is working toward this goal: ${goal.text}`
59
+ : loop.prompt
60
+ ? `The session is running a recurring task: ${loop.prompt}`
61
+ : "The session is running a recurring loop.";
62
+ return [
63
+ `${objective}`,
64
+ "This summary must let that work continue seamlessly. Preserve verbatim:",
65
+ "- the current objective and its acceptance criteria",
66
+ "- decisions made and their rationale, including rejected approaches and dead-ends (they must not be retried)",
67
+ "- exact files modified and what remains to be done",
68
+ "- exact commands run, their results, and any unresolved errors",
69
+ "- the next 1-3 concrete actions",
70
+ "- any prior compaction summary's still-relevant content, carried forward cumulatively",
71
+ "Discard raw tool output, file contents that live on disk, and duplicate exploration.",
72
+ ].join("\n");
73
+ }
74
+
75
+ /**
76
+ * The post-compaction continuation message. Restates the loop and goal state
77
+ * (including pi-goal's thresholds) so work resumes coherently. Deliberately
78
+ * references /loop only as inert prose-free metadata: no line of this message
79
+ * is a dispatchable command.
80
+ */
81
+ export function buildPostCompactContinuation(
82
+ loop: LoopState,
83
+ goal: GoalSnapshot | undefined,
84
+ ): string {
85
+ const lines = [
86
+ "Context was just compacted. Loop status, restored from outside the context window:",
87
+ `- loop iteration: ${loop.iteration}${loop.maxIterations === null ? "" : ` of ${loop.maxIterations}`}, waking every ${formatDuration(loop.intervalMs)}`,
88
+ ];
89
+ if (loop.prompt) lines.push(`- recurring task: ${loop.prompt}`);
90
+ if (goal) {
91
+ lines.push(`- active goal (status ${goal.status}): ${goal.text}`);
92
+ const thresholds: string[] = [];
93
+ if (goal.iteration !== undefined) thresholds.push(`goal iteration ${goal.iteration}`);
94
+ if (goal.automaticModelTurns !== undefined) {
95
+ thresholds.push(`${goal.automaticModelTurns} automatic turns used`);
96
+ }
97
+ if (goal.tokensUsed !== undefined) {
98
+ thresholds.push(
99
+ `${goal.tokensUsed} tokens used${goal.tokenBudget !== undefined ? ` of a ${goal.tokenBudget} budget` : ""}`,
100
+ );
101
+ }
102
+ if (thresholds.length > 0) lines.push(`- goal accounting: ${thresholds.join(", ")}`);
103
+ }
104
+ lines.push(
105
+ "Re-read any plan, progress, or state files the work relies on before continuing, then resume from the next concrete action.",
106
+ );
107
+ return appendContinuationMarker(lines.join("\n"), loop.id);
108
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Global pi-loop settings: `~/.pi/agent/pi-loop.json`. Follows the sibling
3
+ * convention (pi-goal, pi-plan-mode): an absent file means defaults and is
4
+ * never created implicitly, saves are atomic and preserve unknown fields, and
5
+ * an invalid file warns and falls back to defaults without being overwritten.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+ import { parseDuration } from "./interval.js";
13
+
14
+ export const LOOP_SETTINGS_FILE = "pi-loop.json";
15
+
16
+ export interface LoopCompactionSettings {
17
+ enabled: boolean;
18
+ /** Fraction of the context window that triggers a proactive compact. */
19
+ threshold: number;
20
+ postCompactContinuation: boolean;
21
+ /** Override for the built-in compaction instruction template. */
22
+ instructions: string | null;
23
+ }
24
+
25
+ export interface LoopSettings {
26
+ /** Delivered-poke cap; null means unlimited (explicit opt-in). */
27
+ maxIterations: number | null;
28
+ /** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
29
+ maxLoopDuration: string;
30
+ compaction: LoopCompactionSettings;
31
+ /** Override for the built-in poke preamble template. */
32
+ pokePreamble: string | null;
33
+ /** Recognize /loop mid-prompt. */
34
+ inlineInvocation: boolean;
35
+ }
36
+
37
+ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
38
+ maxIterations: 25,
39
+ maxLoopDuration: "7d",
40
+ compaction: {
41
+ enabled: true,
42
+ threshold: 0.7,
43
+ postCompactContinuation: true,
44
+ instructions: null,
45
+ },
46
+ pokePreamble: null,
47
+ inlineInvocation: true,
48
+ };
49
+
50
+ export type LoopSettingsLoadResult =
51
+ | { kind: "missing"; settings: LoopSettings }
52
+ | { kind: "invalid"; reason: string; settings: LoopSettings }
53
+ | { kind: "loaded"; settings: LoopSettings };
54
+
55
+ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined {
56
+ const record = ownRecord(value);
57
+ if (!record) return undefined;
58
+
59
+ const maxIterations = normalizeCap(record.maxIterations, DEFAULT_LOOP_SETTINGS.maxIterations);
60
+ if (maxIterations === false) return undefined;
61
+
62
+ const maxLoopDuration = Object.hasOwn(record, "maxLoopDuration")
63
+ ? record.maxLoopDuration
64
+ : DEFAULT_LOOP_SETTINGS.maxLoopDuration;
65
+ if (typeof maxLoopDuration !== "string" || parseDuration(maxLoopDuration) === undefined) {
66
+ return undefined;
67
+ }
68
+
69
+ const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
70
+ if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
71
+ const compactionRecord = ownRecord(compactionValue) ?? {};
72
+ const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
73
+ const postCompactContinuation = readBoolean(
74
+ compactionRecord,
75
+ "postCompactContinuation",
76
+ DEFAULT_LOOP_SETTINGS.compaction.postCompactContinuation,
77
+ );
78
+ const threshold = Object.hasOwn(compactionRecord, "threshold")
79
+ ? compactionRecord.threshold
80
+ : DEFAULT_LOOP_SETTINGS.compaction.threshold;
81
+ const instructions = readNullableString(
82
+ compactionRecord,
83
+ "instructions",
84
+ DEFAULT_LOOP_SETTINGS.compaction.instructions,
85
+ );
86
+ if (
87
+ typeof enabled !== "boolean" ||
88
+ typeof postCompactContinuation !== "boolean" ||
89
+ instructions === false ||
90
+ typeof threshold !== "number" ||
91
+ !Number.isFinite(threshold) ||
92
+ threshold <= 0 ||
93
+ threshold >= 1
94
+ ) {
95
+ return undefined;
96
+ }
97
+
98
+ const pokePreamble = readNullableString(
99
+ record,
100
+ "pokePreamble",
101
+ DEFAULT_LOOP_SETTINGS.pokePreamble,
102
+ );
103
+ if (pokePreamble === false) return undefined;
104
+
105
+ const inlineInvocation = readBoolean(
106
+ record,
107
+ "inlineInvocation",
108
+ DEFAULT_LOOP_SETTINGS.inlineInvocation,
109
+ );
110
+ if (typeof inlineInvocation !== "boolean") return undefined;
111
+
112
+ return {
113
+ maxIterations,
114
+ maxLoopDuration,
115
+ compaction: { enabled, threshold, postCompactContinuation, instructions },
116
+ pokePreamble,
117
+ inlineInvocation,
118
+ };
119
+ }
120
+
121
+ /** Returns the cap, or false when invalid. */
122
+ function normalizeCap(value: unknown, fallback: number | null): number | null | false {
123
+ if (value === undefined) return fallback;
124
+ if (value === null) return null;
125
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : false;
126
+ }
127
+
128
+ function readBoolean(record: Record<string, unknown>, key: string, fallback: boolean): unknown {
129
+ return Object.hasOwn(record, key) ? record[key] : fallback;
130
+ }
131
+
132
+ /** Returns the string, null, the fallback, or false when invalid. */
133
+ function readNullableString(
134
+ record: Record<string, unknown>,
135
+ key: string,
136
+ fallback: string | null,
137
+ ): string | null | false {
138
+ if (!Object.hasOwn(record, key)) return fallback;
139
+ const value = record[key];
140
+ if (value === null) return null;
141
+ return typeof value === "string" && value.trim().length > 0 ? value : false;
142
+ }
143
+
144
+ export function loopSettingsPath(): string {
145
+ return join(getAgentDir(), LOOP_SETTINGS_FILE);
146
+ }
147
+
148
+ export function readLoopSettings(settingsPath = loopSettingsPath()): LoopSettingsLoadResult {
149
+ let contents: string;
150
+ try {
151
+ contents = readFileSync(settingsPath, "utf8");
152
+ } catch (error) {
153
+ if (isNodeError(error) && error.code === "ENOENT") {
154
+ return { kind: "missing", settings: structuredClone(DEFAULT_LOOP_SETTINGS) };
155
+ }
156
+ return {
157
+ kind: "invalid",
158
+ reason: `${settingsPath}: ${formatError(error)}`,
159
+ settings: structuredClone(DEFAULT_LOOP_SETTINGS),
160
+ };
161
+ }
162
+ try {
163
+ const settings = normalizeLoopSettings(JSON.parse(contents) as unknown);
164
+ return settings
165
+ ? { kind: "loaded", settings }
166
+ : {
167
+ kind: "invalid",
168
+ reason: `${settingsPath}: invalid settings shape`,
169
+ settings: structuredClone(DEFAULT_LOOP_SETTINGS),
170
+ };
171
+ } catch (error) {
172
+ return {
173
+ kind: "invalid",
174
+ reason: `${settingsPath}: ${formatError(error)}`,
175
+ settings: structuredClone(DEFAULT_LOOP_SETTINGS),
176
+ };
177
+ }
178
+ }
179
+
180
+ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSettingsPath()): void {
181
+ const normalized = normalizeLoopSettings(settings);
182
+ if (!normalized) throw new Error("Refusing to save invalid pi-loop settings.");
183
+
184
+ let raw: Record<string, unknown> = {};
185
+ try {
186
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown;
187
+ if (!normalizeLoopSettings(parsed)) {
188
+ throw new Error(`${settingsPath}: invalid settings shape`);
189
+ }
190
+ raw = ownRecord(parsed) ?? {};
191
+ } catch (error) {
192
+ if (!isNodeError(error) || error.code !== "ENOENT") {
193
+ throw new Error(`Cannot save over invalid settings file: ${formatError(error)}`);
194
+ }
195
+ }
196
+
197
+ const compaction = ownRecord(raw.compaction) ?? {};
198
+ const document = `${JSON.stringify(
199
+ {
200
+ ...raw,
201
+ maxIterations: normalized.maxIterations,
202
+ maxLoopDuration: normalized.maxLoopDuration,
203
+ compaction: { ...compaction, ...normalized.compaction },
204
+ pokePreamble: normalized.pokePreamble,
205
+ inlineInvocation: normalized.inlineInvocation,
206
+ },
207
+ null,
208
+ 2,
209
+ )}\n`;
210
+ const temporaryPath = join(
211
+ dirname(settingsPath),
212
+ `.${basename(settingsPath)}.${randomUUID()}.tmp`,
213
+ );
214
+ try {
215
+ mkdirSync(dirname(settingsPath), { recursive: true });
216
+ writeFileSync(temporaryPath, document, { encoding: "utf8", flag: "wx" });
217
+ renameSync(temporaryPath, settingsPath);
218
+ } finally {
219
+ try {
220
+ rmSync(temporaryPath, { force: true });
221
+ } catch {
222
+ // Best-effort cleanup must not replace the save result.
223
+ }
224
+ }
225
+ }
226
+
227
+ function ownRecord(value: unknown): Record<string, unknown> | undefined {
228
+ return value && typeof value === "object" && !Array.isArray(value)
229
+ ? (value as Record<string, unknown>)
230
+ : undefined;
231
+ }
232
+
233
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
234
+ return error instanceof Error && "code" in error;
235
+ }
236
+
237
+ function formatError(error: unknown) {
238
+ return error instanceof Error ? error.message : String(error);
239
+ }
package/src/state.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Loop state persisted as `loop-state` custom session entries, plus read-only
3
+ * fail-open readers for the sibling extensions' entries: pi-goal's
4
+ * `goal-state` and pi-plan-mode's `plan-mode-state`. The coupling is
5
+ * deliberately loose — no package dependency, no RPC; an absent or
6
+ * unrecognizable entry degrades pi-loop to standalone behavior, never crashes
7
+ * it.
8
+ */
9
+
10
+ export const LOOP_STATE_ENTRY_TYPE = "loop-state";
11
+ export const GOAL_STATE_ENTRY_TYPE = "goal-state";
12
+ export const PLAN_MODE_STATE_ENTRY_TYPE = "plan-mode-state";
13
+
14
+ export const LOOP_STATUSES = ["active", "paused", "stopped"] as const;
15
+ export type LoopStatus = (typeof LOOP_STATUSES)[number];
16
+
17
+ export interface LoopState {
18
+ id: string;
19
+ status: LoopStatus;
20
+ /** The recurring prompt; undefined for a goal-bound loop started bare. */
21
+ prompt?: string;
22
+ intervalMs: number;
23
+ /** Delivered-poke cap; null means unlimited. */
24
+ maxIterations: number | null;
25
+ /** Proactive-compaction threshold fraction, or null when disabled per loop. */
26
+ compactAt: number | null;
27
+ /** Delivered pokes so far. */
28
+ iteration: number;
29
+ startedAt: number;
30
+ expiresAt: number;
31
+ lastWakeAt?: number;
32
+ }
33
+
34
+ const MAX_PROMPT_LENGTH = 100_000;
35
+ const MAX_TIMESTAMP = 8_640_000_000_000_000;
36
+
37
+ export function normalizeLoopState(value: unknown): LoopState | undefined {
38
+ const record = ownRecord(value);
39
+ if (!record) return undefined;
40
+ const id = typeof record.id === "string" ? record.id.trim() : "";
41
+ if (!id || id.length > 200 || /[\s:>]/.test(id)) return undefined;
42
+ const status = record.status;
43
+ if (!LOOP_STATUSES.includes(status as LoopStatus)) return undefined;
44
+ let prompt: string | undefined;
45
+ if (record.prompt !== undefined) {
46
+ if (typeof record.prompt !== "string") return undefined;
47
+ prompt = record.prompt.trim();
48
+ if (!prompt || prompt.length > MAX_PROMPT_LENGTH) return undefined;
49
+ }
50
+ const intervalMs = record.intervalMs;
51
+ if (!isPositiveSafeInteger(intervalMs)) return undefined;
52
+ const maxIterations = record.maxIterations;
53
+ if (maxIterations !== null && !isPositiveSafeInteger(maxIterations)) return undefined;
54
+ const compactAt = record.compactAt;
55
+ if (
56
+ compactAt !== null &&
57
+ (typeof compactAt !== "number" || !Number.isFinite(compactAt) || compactAt <= 0 || compactAt >= 1)
58
+ ) {
59
+ return undefined;
60
+ }
61
+ const iteration = record.iteration;
62
+ if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
63
+ return undefined;
64
+ }
65
+ if (!isTimestamp(record.startedAt) || !isTimestamp(record.expiresAt)) return undefined;
66
+ if (record.lastWakeAt !== undefined && !isTimestamp(record.lastWakeAt)) return undefined;
67
+ return {
68
+ id,
69
+ status: status as LoopStatus,
70
+ ...(prompt === undefined ? {} : { prompt }),
71
+ intervalMs,
72
+ maxIterations: maxIterations as number | null,
73
+ compactAt: compactAt as number | null,
74
+ iteration,
75
+ startedAt: record.startedAt as number,
76
+ expiresAt: record.expiresAt as number,
77
+ ...(record.lastWakeAt === undefined ? {} : { lastWakeAt: record.lastWakeAt as number }),
78
+ };
79
+ }
80
+
81
+ // --- session-branch entry readers ---
82
+
83
+ interface SessionEntryLike {
84
+ type?: string;
85
+ customType?: string;
86
+ data?: unknown;
87
+ }
88
+
89
+ function lastCustomEntryData(entries: unknown[], customType: string): unknown {
90
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
91
+ const entry = entries[index] as SessionEntryLike | undefined;
92
+ if (entry?.type === "custom" && entry.customType === customType) return entry.data;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /** Restore the persisted loop state from a session branch, fail-open. */
98
+ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
99
+ const data = lastCustomEntryData(entries, LOOP_STATE_ENTRY_TYPE);
100
+ const record = ownRecord(data);
101
+ if (!record) return undefined;
102
+ return normalizeLoopState(record.loop);
103
+ }
104
+
105
+ export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
106
+
107
+ export interface GoalSnapshot {
108
+ status: string;
109
+ text: string;
110
+ /** Present while the goal is in a goal_wait external-event wait. */
111
+ waiting: boolean;
112
+ iteration?: number;
113
+ tokensUsed?: number;
114
+ tokenBudget?: number;
115
+ automaticModelTurns?: number;
116
+ }
117
+
118
+ /**
119
+ * Read pi-goal's persisted goal, fail-open: undefined when absent or when the
120
+ * entry shape is not recognizably a goal. Only fields pi-loop consumes are
121
+ * extracted; unknown statuses are preserved verbatim so the caller can treat
122
+ * anything outside its known sets conservatively.
123
+ */
124
+ export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
125
+ const data = ownRecord(lastCustomEntryData(entries, GOAL_STATE_ENTRY_TYPE));
126
+ if (!data) return undefined;
127
+ const goal = ownRecord(data.goal);
128
+ if (!goal) return undefined;
129
+ const status = typeof goal.status === "string" ? goal.status : undefined;
130
+ const text = typeof goal.text === "string" ? goal.text.trim() : "";
131
+ if (!status || !text) return undefined;
132
+ return {
133
+ status,
134
+ text,
135
+ waiting: ownRecord(goal.waiting) !== undefined,
136
+ ...(isNonNegativeNumber(goal.iteration) ? { iteration: goal.iteration } : {}),
137
+ ...(isNonNegativeNumber(goal.tokensUsed) ? { tokensUsed: goal.tokensUsed } : {}),
138
+ ...(isNonNegativeNumber(goal.tokenBudget) ? { tokenBudget: goal.tokenBudget } : {}),
139
+ ...(isNonNegativeNumber(goal.automaticModelTurns)
140
+ ? { automaticModelTurns: goal.automaticModelTurns }
141
+ : {}),
142
+ };
143
+ }
144
+
145
+ /** Read pi-plan-mode's persisted state, fail-open: absent or malformed = not planning. */
146
+ export function readPlanModeEnabled(entries: unknown[]): boolean {
147
+ const data = ownRecord(lastCustomEntryData(entries, PLAN_MODE_STATE_ENTRY_TYPE));
148
+ return data?.enabled === true;
149
+ }
150
+
151
+ function ownRecord(value: unknown): Record<string, unknown> | undefined {
152
+ return value && typeof value === "object" && !Array.isArray(value)
153
+ ? (value as Record<string, unknown>)
154
+ : undefined;
155
+ }
156
+
157
+ function isPositiveSafeInteger(value: unknown): value is number {
158
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
159
+ }
160
+
161
+ function isNonNegativeNumber(value: unknown): value is number {
162
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
163
+ }
164
+
165
+ function isTimestamp(value: unknown): value is number {
166
+ return (
167
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIMESTAMP
168
+ );
169
+ }