@signalridge/pi-goal 0.49.7

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,142 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { completeGoalArguments, parseCommand } from "./command.js";
3
+ import type { GoalCommandController } from "./commands.js";
4
+ import { notifyTerminal, safeTerminalText } from "./errors.js";
5
+ import type { GoalRuntime } from "./runtime.js";
6
+
7
+ type GoalManagerModule = Pick<typeof import("./menu.js"), "showGoalManager">;
8
+ type GoalSettingsModule = Pick<typeof import("./settings-ui.js"), "showGoalSettings">;
9
+
10
+ export interface GoalCommandRegistrationOptions {
11
+ settingsPath?: string;
12
+ loadGoalManager?: () => Promise<GoalManagerModule>;
13
+ loadGoalSettings?: () => Promise<GoalSettingsModule>;
14
+ }
15
+
16
+ export function registerGoalCommand(
17
+ pi: ExtensionAPI,
18
+ runtime: GoalRuntime,
19
+ commands: GoalCommandController,
20
+ options: GoalCommandRegistrationOptions = {},
21
+ ) {
22
+ const loadGoalManager = cachedModuleLoader(options.loadGoalManager ?? (() => import("./menu.js")));
23
+ const loadGoalSettings = cachedModuleLoader(options.loadGoalSettings ?? (() => import("./settings-ui.js")));
24
+
25
+ pi.registerCommand("goal", {
26
+ description: "Run a goal to completion: /goal [--tokens 100k] <goal_to_complete>",
27
+ getArgumentCompletions: (prefix) =>
28
+ completeGoalArguments(prefix, {
29
+ experimentalGoals: runtime.settings.experimental.goals,
30
+ }),
31
+ handler: async (args, ctx) => {
32
+ const result = parseCommand(args, {
33
+ experimentalGoals: runtime.settings.experimental.goals,
34
+ });
35
+ if (typeof result === "string") {
36
+ reportCommandError(result, ctx);
37
+ return;
38
+ }
39
+ if (result.kind === "show" && args.trim() === "") {
40
+ const menuIsCurrent = captureMenuOwnership(runtime);
41
+ let managerModule: GoalManagerModule;
42
+ try {
43
+ managerModule = await loadGoalManager();
44
+ } catch (error) {
45
+ if (!menuIsCurrent()) return;
46
+ throw error;
47
+ }
48
+ if (!menuIsCurrent()) return;
49
+ const { showGoalManager } = managerModule;
50
+ await showGoalManager(runtime, commands, ctx, async (menuCtx, target) => {
51
+ const settingsAreCurrent = captureMenuOwnership(runtime);
52
+ let settingsModule: GoalSettingsModule;
53
+ try {
54
+ settingsModule = await loadGoalSettings();
55
+ } catch (error) {
56
+ if (!settingsAreCurrent()) return;
57
+ throw error;
58
+ }
59
+ if (!settingsAreCurrent()) return;
60
+ const { showGoalSettings } = settingsModule;
61
+ await showGoalSettings(runtime, menuCtx, {
62
+ settingsPath: options.settingsPath,
63
+ initialScreen: target,
64
+ onQueueUnfrozen: async (settingsCtx) => {
65
+ await commands.resumeQueueAfterUnfreeze(settingsCtx);
66
+ },
67
+ });
68
+ });
69
+ return;
70
+ }
71
+ if (runtime.queueFrozen) {
72
+ if (result.kind === "show") commands.showGoal(ctx);
73
+ else if (result.kind === "clear") commands.clearGoal(ctx);
74
+ else commands.notifyFrozenQueue(ctx);
75
+ return;
76
+ }
77
+ if (runtime.pendingQueueAction && result.kind !== "show" && result.kind !== "clear") {
78
+ notifyTerminal(ctx.ui, "A queued goal change is waiting for Pi to settle. Retry after it finishes.", "warning");
79
+ return;
80
+ }
81
+
82
+ switch (result.kind) {
83
+ case "show":
84
+ commands.showGoal(ctx);
85
+ return;
86
+ case "pause":
87
+ commands.pauseGoal(ctx);
88
+ return;
89
+ case "resume":
90
+ await commands.resumeGoal(ctx);
91
+ return;
92
+ case "clear":
93
+ commands.clearGoal(ctx);
94
+ return;
95
+ case "edit":
96
+ await commands.editGoal(result.objective ?? "", result.tokenBudget, ctx);
97
+ return;
98
+ case "add":
99
+ await commands.addGoal(result.objective ?? "", result.tokenBudget, ctx);
100
+ return;
101
+ case "prioritize":
102
+ await commands.prioritizeGoal(result.objective ?? "", result.tokenBudget, ctx);
103
+ return;
104
+ case "drop-last":
105
+ commands.dropLastGoal(ctx);
106
+ return;
107
+ case "skip":
108
+ await commands.skipGoal(ctx);
109
+ return;
110
+ case "start":
111
+ await commands.startGoal(result.objective ?? "", result.tokenBudget, ctx);
112
+ return;
113
+ }
114
+ },
115
+ });
116
+ }
117
+
118
+ function reportCommandError(message: string, ctx: ExtensionCommandContext) {
119
+ const safeMessage = safeTerminalText(message);
120
+ if (ctx.mode === "print" || ctx.mode === "json") throw new Error(safeMessage);
121
+ notifyTerminal(ctx.ui, safeMessage, "warning");
122
+ }
123
+
124
+ function captureMenuOwnership(runtime: GoalRuntime): () => boolean {
125
+ const generation = runtime.menuGeneration;
126
+ const controller = runtime.menuController;
127
+ return () =>
128
+ runtime.menuGeneration === generation && runtime.menuController === controller && !controller.signal.aborted;
129
+ }
130
+
131
+ function cachedModuleLoader<Module>(load: () => Promise<Module>): () => Promise<Module> {
132
+ let pending: Promise<Module> | undefined;
133
+ return () => {
134
+ if (!pending) {
135
+ pending = load().catch((error) => {
136
+ pending = undefined;
137
+ throw error;
138
+ });
139
+ }
140
+ return pending;
141
+ };
142
+ }
package/src/command.ts ADDED
@@ -0,0 +1,170 @@
1
+ const MAX_OBJECTIVE_LENGTH = 4_000;
2
+
3
+ export interface GoalCommandFeatures {
4
+ experimentalGoals?: boolean;
5
+ }
6
+
7
+ export interface CommandResult {
8
+ kind: "start" | "pause" | "resume" | "clear" | "show" | "edit" | "add" | "prioritize" | "drop-last" | "skip";
9
+ objective?: string;
10
+ tokenBudget?: number;
11
+ }
12
+
13
+ export interface GoalArgumentCompletion {
14
+ value: string;
15
+ label: string;
16
+ description?: string;
17
+ }
18
+
19
+ const TOKEN_BUDGET_COMPLETION: GoalArgumentCompletion = {
20
+ value: "--tokens ",
21
+ label: "--tokens",
22
+ description: "Set a token budget before the goal",
23
+ };
24
+ const GOAL_ARGUMENT_COMPLETIONS: readonly GoalArgumentCompletion[] = [
25
+ { value: "pause", label: "pause", description: "Pause the active goal" },
26
+ { value: "resume", label: "resume", description: "Resume a stopped or budget-limited goal" },
27
+ { value: "clear", label: "clear", description: "Clear the current goal" },
28
+ { value: "edit", label: "edit", description: "Edit the current goal objective" },
29
+ { value: "status", label: "status", description: "Show the current goal" },
30
+ TOKEN_BUDGET_COMPLETION,
31
+ ];
32
+ const QUEUE_ARGUMENT_COMPLETIONS: readonly GoalArgumentCompletion[] = [
33
+ { value: "add", label: "add", description: "Add a goal to the end of the queue" },
34
+ {
35
+ value: "prioritize",
36
+ label: "prioritize",
37
+ description: "Prioritize a new goal at the front of the queue",
38
+ },
39
+ { value: "drop-last", label: "drop-last", description: "Remove the last goal" },
40
+ { value: "skip", label: "skip", description: "Skip the current goal" },
41
+ ];
42
+
43
+ export function completeGoalArguments(
44
+ argumentPrefix: string,
45
+ features: GoalCommandFeatures = {},
46
+ ): GoalArgumentCompletion[] | null {
47
+ const prefix = argumentPrefix.trimStart();
48
+ const completions = features.experimentalGoals
49
+ ? [...GOAL_ARGUMENT_COMPLETIONS.slice(0, -1), ...QUEUE_ARGUMENT_COMPLETIONS, TOKEN_BUDGET_COMPLETION]
50
+ : [...GOAL_ARGUMENT_COMPLETIONS];
51
+ if (prefix === "") return completions;
52
+
53
+ const objectiveOption = features.experimentalGoals
54
+ ? /^(edit|add|prioritize)\s+(\S*)$/.exec(prefix)
55
+ : /^edit\s+(\S*)$/.exec(prefix);
56
+ if (objectiveOption) {
57
+ const command = features.experimentalGoals ? (objectiveOption[1] ?? "edit") : "edit";
58
+ const optionPrefix = features.experimentalGoals ? (objectiveOption[2] ?? "") : (objectiveOption[1] ?? "");
59
+ return optionPrefix === "" || "--tokens".startsWith(optionPrefix)
60
+ ? [
61
+ {
62
+ value: `${command} --tokens `,
63
+ label: "--tokens",
64
+ description:
65
+ command === "edit"
66
+ ? "Set a token budget before the updated goal"
67
+ : "Set a token budget before the queued goal",
68
+ },
69
+ ]
70
+ : null;
71
+ }
72
+ if (/\s/.test(prefix)) return null;
73
+ const matches = completions.filter((item) => item.value.startsWith(prefix) || item.label.startsWith(prefix));
74
+ return matches.length > 0 ? matches : null;
75
+ }
76
+
77
+ export function parseCommand(args: string, features: GoalCommandFeatures = {}): CommandResult | string {
78
+ const tokens = tokenize(args.trim());
79
+ if (tokens.length === 0) return { kind: "show" };
80
+ const [first, ...rest] = tokens;
81
+ if (first === "pause") return rest.length === 0 ? { kind: "pause" } : "Usage: /goal pause";
82
+ if (first === "resume") return rest.length === 0 ? { kind: "resume" } : "Usage: /goal resume";
83
+ if (first === "clear" || first === "stop") return rest.length === 0 ? { kind: "clear" } : "Usage: /goal clear";
84
+ if (first === "status") return rest.length === 0 ? { kind: "show" } : "Usage: /goal status";
85
+ if (first === "edit") return parseObjective("edit", rest);
86
+
87
+ if (features.experimentalGoals) {
88
+ if (first === "drop-last" || first === "pop") {
89
+ return rest.length === 0 ? { kind: "drop-last" } : "Usage: /goal drop-last";
90
+ }
91
+ if (first === "skip" || first === "shift") {
92
+ return rest.length === 0 ? { kind: "skip" } : "Usage: /goal skip";
93
+ }
94
+ if (first === "add" || first === "push") return parseObjective("add", rest);
95
+ if (first === "prioritize" || first === "unshift") {
96
+ return parseObjective("prioritize", rest);
97
+ }
98
+ }
99
+
100
+ return parseObjective("start", tokens);
101
+ }
102
+
103
+ function parseObjective(kind: "start" | "edit" | "add" | "prioritize", tokens: string[]): CommandResult | string {
104
+ let tokenBudget: number | undefined;
105
+ const objectiveTokens = [...tokens];
106
+ if (objectiveTokens[0] === "--tokens") {
107
+ const rawBudget = objectiveTokens[1];
108
+ if (!rawBudget) {
109
+ return kind === "start"
110
+ ? "Usage: /goal --tokens 100k <goal_to_complete>"
111
+ : `Usage: /goal ${kind} --tokens 100k <goal_to_complete>`;
112
+ }
113
+ const parsedBudget = parseTokenBudget(rawBudget);
114
+ if (parsedBudget === undefined) return `Invalid token budget: ${rawBudget}`;
115
+ tokenBudget = parsedBudget;
116
+ objectiveTokens.splice(0, 2);
117
+ }
118
+ if (objectiveTokens.length === 0) {
119
+ if (kind === "start") return "Usage: /goal <goal_to_complete>";
120
+ return `Usage: /goal ${kind} <goal_to_complete>`;
121
+ }
122
+ return { kind, objective: objectiveTokens.join(" "), tokenBudget };
123
+ }
124
+
125
+ function tokenize(input: string): string[] {
126
+ const tokens: string[] = [];
127
+ let current = "";
128
+ let quote: '"' | "'" | undefined;
129
+ for (const char of input) {
130
+ if (quote) {
131
+ if (char === quote) quote = undefined;
132
+ else current += char;
133
+ continue;
134
+ }
135
+ if (char === '"' || char === "'") {
136
+ quote = char;
137
+ continue;
138
+ }
139
+ if (/\s/.test(char)) {
140
+ if (current) tokens.push(current);
141
+ current = "";
142
+ continue;
143
+ }
144
+ current += char;
145
+ }
146
+ if (current) tokens.push(current);
147
+ return tokens;
148
+ }
149
+
150
+ export function parseTokenBudget(value: string): number | undefined {
151
+ const match = /^(\d+(?:\.\d+)?)([km])?$/iu.exec(value.trim());
152
+ if (!match) return undefined;
153
+ const amount = Number(match[1]);
154
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
155
+ const multiplier = match[2]?.toLowerCase() === "m" ? 1_000_000 : match[2]?.toLowerCase() === "k" ? 1_000 : 1;
156
+ return normalizeTokenBudget(Math.floor(amount * multiplier));
157
+ }
158
+
159
+ export function validateObjective(objective: string): string | undefined {
160
+ const trimmed = objective.trim();
161
+ if (!trimmed) return "Usage: /goal <goal_to_complete>";
162
+ if (trimmed.length > MAX_OBJECTIVE_LENGTH) {
163
+ return `Goal objective is too long (${trimmed.length}/${MAX_OBJECTIVE_LENGTH} characters). Put long instructions in a file and reference it from /goal instead.`;
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ function normalizeTokenBudget(value: unknown) {
169
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
170
+ }