pi-goals 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.
- package/.pi/extensions/goal/budget.ts +74 -0
- package/.pi/extensions/goal/command.ts +201 -0
- package/.pi/extensions/goal/constants.ts +39 -0
- package/.pi/extensions/goal/continuation.ts +122 -0
- package/.pi/extensions/goal/format.ts +153 -0
- package/.pi/extensions/goal/index.ts +19 -0
- package/.pi/extensions/goal/lifecycle.ts +366 -0
- package/.pi/extensions/goal/model-output.ts +76 -0
- package/.pi/extensions/goal/monitor-prompts.ts +161 -0
- package/.pi/extensions/goal/monitor-report.ts +93 -0
- package/.pi/extensions/goal/monitor-state.ts +77 -0
- package/.pi/extensions/goal/monitor.ts +191 -0
- package/.pi/extensions/goal/prompts.ts +98 -0
- package/.pi/extensions/goal/state.ts +141 -0
- package/.pi/extensions/goal/telemetry.ts +153 -0
- package/.pi/extensions/goal/templates.ts +228 -0
- package/.pi/extensions/goal/tools.ts +279 -0
- package/.pi/extensions/goal/types.ts +168 -0
- package/.pi/extensions/goal/ui.ts +41 -0
- package/.pi/extensions/goal/widget.ts +159 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/package.json +44 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { TELEMETRY_SCHEMA_VERSION } from "./constants";
|
|
2
|
+
import type {
|
|
3
|
+
BudgetHardStopReason,
|
|
4
|
+
BudgetLimitReason,
|
|
5
|
+
BudgetWarningReason,
|
|
6
|
+
ContinuationReason,
|
|
7
|
+
ContinuationSkipReason,
|
|
8
|
+
GoalTelemetrySnapshot,
|
|
9
|
+
SafetyPauseReason,
|
|
10
|
+
TurnAccountingSnapshot,
|
|
11
|
+
TurnOrigin,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
let nextTurnOrigin: TurnOrigin = "user";
|
|
15
|
+
|
|
16
|
+
export function setNextTurnOrigin(origin: TurnOrigin): void {
|
|
17
|
+
nextTurnOrigin = origin;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function consumeNextTurnOrigin(): TurnOrigin {
|
|
21
|
+
const origin = nextTurnOrigin;
|
|
22
|
+
nextTurnOrigin = "user";
|
|
23
|
+
return origin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createTelemetry(goalId: string, now = Date.now()): GoalTelemetrySnapshot {
|
|
27
|
+
return {
|
|
28
|
+
version: TELEMETRY_SCHEMA_VERSION,
|
|
29
|
+
goalId,
|
|
30
|
+
totalTurns: 0,
|
|
31
|
+
userTurns: 0,
|
|
32
|
+
autoTurns: 0,
|
|
33
|
+
consecutiveAutoTurns: 0,
|
|
34
|
+
consecutiveNoProgressTurns: 0,
|
|
35
|
+
updatedAt: now,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isTelemetry(value: unknown): value is GoalTelemetrySnapshot {
|
|
40
|
+
if (typeof value !== "object" || value === null) return false;
|
|
41
|
+
const v = value as Record<string, unknown>;
|
|
42
|
+
return v.version === TELEMETRY_SCHEMA_VERSION && typeof v.goalId === "string";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function noteContinuationScheduled(
|
|
46
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
47
|
+
reason: ContinuationReason,
|
|
48
|
+
now = Date.now(),
|
|
49
|
+
): GoalTelemetrySnapshot | null {
|
|
50
|
+
if (!telemetry) return null;
|
|
51
|
+
return { ...telemetry, lastContinuationReason: reason, lastSkipReason: undefined, updatedAt: now };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function noteContinuationSkipped(
|
|
55
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
56
|
+
reason: ContinuationSkipReason,
|
|
57
|
+
now = Date.now(),
|
|
58
|
+
): GoalTelemetrySnapshot | null {
|
|
59
|
+
if (!telemetry) return null;
|
|
60
|
+
return { ...telemetry, lastSkipReason: reason, updatedAt: now };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function noteBudgetWrapUpSent(
|
|
64
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
65
|
+
now = Date.now(),
|
|
66
|
+
): GoalTelemetrySnapshot | null {
|
|
67
|
+
if (!telemetry) return null;
|
|
68
|
+
return { ...telemetry, budgetWrapUpSent: true, updatedAt: now };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function noteBudgetLimit(
|
|
72
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
73
|
+
reason: BudgetLimitReason,
|
|
74
|
+
now = Date.now(),
|
|
75
|
+
): GoalTelemetrySnapshot | null {
|
|
76
|
+
if (!telemetry) return null;
|
|
77
|
+
return { ...telemetry, lastBudgetLimitReason: reason, updatedAt: now };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function noteBudgetWarning(
|
|
81
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
82
|
+
reason: BudgetWarningReason,
|
|
83
|
+
now = Date.now(),
|
|
84
|
+
): GoalTelemetrySnapshot | null {
|
|
85
|
+
if (!telemetry) return null;
|
|
86
|
+
return {
|
|
87
|
+
...telemetry,
|
|
88
|
+
lastBudgetWarningReason: reason,
|
|
89
|
+
tokenBudgetWarningSent: telemetry.tokenBudgetWarningSent || reason === "tokenWarning",
|
|
90
|
+
timeBudgetWarningSent: telemetry.timeBudgetWarningSent || reason === "timeWarning",
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function noteBudgetHardStop(
|
|
96
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
97
|
+
reason: BudgetHardStopReason,
|
|
98
|
+
now = Date.now(),
|
|
99
|
+
): GoalTelemetrySnapshot | null {
|
|
100
|
+
if (!telemetry) return null;
|
|
101
|
+
return { ...telemetry, lastBudgetHardStopReason: reason, updatedAt: now };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function noteSafetyPause(
|
|
105
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
106
|
+
reason: SafetyPauseReason,
|
|
107
|
+
now = Date.now(),
|
|
108
|
+
): GoalTelemetrySnapshot | null {
|
|
109
|
+
if (!telemetry) return null;
|
|
110
|
+
return { ...telemetry, lastSafetyPauseReason: reason, updatedAt: now };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resetSafetyCounters(
|
|
114
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
115
|
+
now = Date.now(),
|
|
116
|
+
): GoalTelemetrySnapshot | null {
|
|
117
|
+
if (!telemetry) return null;
|
|
118
|
+
return {
|
|
119
|
+
...telemetry,
|
|
120
|
+
consecutiveAutoTurns: 0,
|
|
121
|
+
consecutiveNoProgressTurns: 0,
|
|
122
|
+
lastSafetyPauseReason: undefined,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function applyTurnTelemetry(
|
|
128
|
+
telemetry: GoalTelemetrySnapshot | null,
|
|
129
|
+
turn: TurnAccountingSnapshot,
|
|
130
|
+
madeProgress: boolean,
|
|
131
|
+
now = Date.now(),
|
|
132
|
+
): GoalTelemetrySnapshot | null {
|
|
133
|
+
if (!telemetry || telemetry.goalId !== turn.goalId) return telemetry;
|
|
134
|
+
const auto = turn.origin === "auto" || turn.origin === "budgetWrapUp";
|
|
135
|
+
return {
|
|
136
|
+
...telemetry,
|
|
137
|
+
totalTurns: telemetry.totalTurns + 1,
|
|
138
|
+
userTurns: telemetry.userTurns + (turn.origin === "user" ? 1 : 0),
|
|
139
|
+
autoTurns: telemetry.autoTurns + (auto ? 1 : 0),
|
|
140
|
+
consecutiveAutoTurns: auto ? telemetry.consecutiveAutoTurns + 1 : 0,
|
|
141
|
+
consecutiveNoProgressTurns: auto && !madeProgress ? telemetry.consecutiveNoProgressTurns + 1 : 0,
|
|
142
|
+
lastTurnOrigin: turn.origin,
|
|
143
|
+
lastTurnToolCallCount: turn.toolCallCount,
|
|
144
|
+
lastTurnToolResultCount: turn.toolResultCount,
|
|
145
|
+
lastTurnCompletedGoal: turn.completedGoal,
|
|
146
|
+
lastProgressAt: madeProgress ? now : telemetry.lastProgressAt,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function makeTurnSnapshot(goalId: string, origin: TurnOrigin, startedAt = Date.now()): TurnAccountingSnapshot {
|
|
152
|
+
return { goalId, origin, startedAt, toolCallCount: 0, toolResultCount: 0, progressCount: 0, completedGoal: false };
|
|
153
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
const TEMPLATE_DIR = ".pi-goals";
|
|
6
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
7
|
+
const DEFAULT_COMMAND_OUTPUT_LIMIT = 20_000;
|
|
8
|
+
const SKIP_DIRS = new Set([".git", "node_modules", "references", "dist", "build", ".next", ".cache"]);
|
|
9
|
+
|
|
10
|
+
export type GoalTemplate = {
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
aliases: string[];
|
|
15
|
+
allowCommands: boolean;
|
|
16
|
+
commandTimeoutMs: number;
|
|
17
|
+
commandOutputLimit: number;
|
|
18
|
+
body: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ResolvedGoalTemplate = {
|
|
22
|
+
name: string;
|
|
23
|
+
path: string;
|
|
24
|
+
objective: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type TemplateResolution = { ok: true; template: ResolvedGoalTemplate } | { ok: false; error: string } | { ok: false; notTemplate: true };
|
|
28
|
+
|
|
29
|
+
type ParsedInvocation = {
|
|
30
|
+
name: string;
|
|
31
|
+
flags: Record<string, string>;
|
|
32
|
+
args: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Frontmatter = Record<string, string>;
|
|
36
|
+
|
|
37
|
+
export function discoverGoalTemplates(root = process.cwd()): GoalTemplate[] {
|
|
38
|
+
const templates: GoalTemplate[] = [];
|
|
39
|
+
for (const dir of findTemplateDirs(root)) collectTemplates(root, dir, templates);
|
|
40
|
+
templates.sort((a, b) => a.name.localeCompare(b.name));
|
|
41
|
+
return templates;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveGoalTemplateInvocation(input: string, root = process.cwd()): TemplateResolution {
|
|
45
|
+
const parsed = parseInvocation(input);
|
|
46
|
+
if (!parsed) return { ok: false, notTemplate: true };
|
|
47
|
+
const template = findTemplate(parsed.name, root);
|
|
48
|
+
if (!template) return { ok: false, notTemplate: true };
|
|
49
|
+
try {
|
|
50
|
+
const values = { ...parsed.flags, args: parsed.args };
|
|
51
|
+
const interpolated = interpolate(template.body, values);
|
|
52
|
+
const objective = resolveInlineCommands(interpolated, template, root).trim();
|
|
53
|
+
return { ok: true, template: { name: template.name, path: template.path, objective } };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findTemplate(nameOrAlias: string, root: string): GoalTemplate | undefined {
|
|
60
|
+
return discoverGoalTemplates(root).find((template) => template.name === nameOrAlias || template.aliases.includes(nameOrAlias));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function findTemplateDirs(root: string): string[] {
|
|
64
|
+
const dirs: string[] = [];
|
|
65
|
+
walk(root, (path) => {
|
|
66
|
+
if (basename(path) === TEMPLATE_DIR) dirs.push(path);
|
|
67
|
+
});
|
|
68
|
+
return dirs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function walk(dir: string, visit: (path: string) => void): void {
|
|
72
|
+
let entries: string[];
|
|
73
|
+
try {
|
|
74
|
+
entries = readdirSync(dir);
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
80
|
+
const path = join(dir, entry);
|
|
81
|
+
let stats;
|
|
82
|
+
try {
|
|
83
|
+
stats = statSync(path);
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!stats.isDirectory()) continue;
|
|
88
|
+
visit(path);
|
|
89
|
+
if (entry !== TEMPLATE_DIR) walk(path, visit);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectTemplates(root: string, templateDir: string, templates: GoalTemplate[]): void {
|
|
94
|
+
collectMarkdown(templateDir, (path) => {
|
|
95
|
+
const raw = readFileSync(path, "utf8");
|
|
96
|
+
const parsed = parseFrontmatter(raw);
|
|
97
|
+
const relativeName = stripMarkdownExt(relative(templateDir, path).split(sep).join("/"));
|
|
98
|
+
templates.push({
|
|
99
|
+
name: relativeName,
|
|
100
|
+
path: relative(root, path),
|
|
101
|
+
description: parsed.frontmatter.description || firstContentLine(parsed.body),
|
|
102
|
+
aliases: parseList(parsed.frontmatter.aliases),
|
|
103
|
+
allowCommands: parseBoolean(parsed.frontmatter.allow_commands),
|
|
104
|
+
commandTimeoutMs: parsePositiveInt(parsed.frontmatter.command_timeout_ms, DEFAULT_COMMAND_TIMEOUT_MS),
|
|
105
|
+
commandOutputLimit: parsePositiveInt(parsed.frontmatter.command_output_limit, DEFAULT_COMMAND_OUTPUT_LIMIT),
|
|
106
|
+
body: parsed.body,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectMarkdown(dir: string, visit: (path: string) => void): void {
|
|
112
|
+
let entries: string[];
|
|
113
|
+
try {
|
|
114
|
+
entries = readdirSync(dir);
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const path = join(dir, entry);
|
|
120
|
+
const stats = statSync(path);
|
|
121
|
+
if (stats.isDirectory()) collectMarkdown(path, visit);
|
|
122
|
+
else if ([".md", ".markdown", ".txt"].includes(extname(entry))) visit(path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
|
127
|
+
if (!raw.startsWith("---\n")) return { frontmatter: {}, body: raw };
|
|
128
|
+
const end = raw.indexOf("\n---", 4);
|
|
129
|
+
if (end < 0) return { frontmatter: {}, body: raw };
|
|
130
|
+
const frontmatter: Frontmatter = {};
|
|
131
|
+
for (const line of raw.slice(4, end).split(/\r?\n/)) {
|
|
132
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
133
|
+
if (match) frontmatter[match[1]] = stripQuotes(match[2].trim());
|
|
134
|
+
}
|
|
135
|
+
return { frontmatter, body: raw.slice(end + 4).replace(/^\r?\n/, "") };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseInvocation(input: string): ParsedInvocation | undefined {
|
|
139
|
+
const trimmed = input.trim();
|
|
140
|
+
if (!trimmed) return undefined;
|
|
141
|
+
const match = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
142
|
+
if (!match) return undefined;
|
|
143
|
+
const name = match[1];
|
|
144
|
+
let rest = match[2] ?? "";
|
|
145
|
+
let args = "";
|
|
146
|
+
const delimiter = rest.indexOf(" -- ");
|
|
147
|
+
if (delimiter >= 0) {
|
|
148
|
+
args = rest.slice(delimiter + 4).trim();
|
|
149
|
+
rest = rest.slice(0, delimiter).trim();
|
|
150
|
+
}
|
|
151
|
+
return { name, flags: parseFlags(rest), args };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseFlags(input: string): Record<string, string> {
|
|
155
|
+
const values: Record<string, string> = {};
|
|
156
|
+
const tokens = input.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
157
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
158
|
+
const token = unquote(tokens[i]);
|
|
159
|
+
if (!token.startsWith("--")) continue;
|
|
160
|
+
const eq = token.indexOf("=");
|
|
161
|
+
if (eq > 2) {
|
|
162
|
+
values[token.slice(2, eq)] = token.slice(eq + 1);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const next = tokens[i + 1] && !tokens[i + 1].startsWith("--") ? unquote(tokens[++i]) : "true";
|
|
166
|
+
values[token.slice(2)] = next;
|
|
167
|
+
}
|
|
168
|
+
return values;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function interpolate(text: string, values: Record<string, string>): string {
|
|
172
|
+
return text.replace(/\{\{\s*([A-Za-z0-9_-]+)\s*\}\}/g, (_match, key: string) => {
|
|
173
|
+
if (values[key] === undefined) throw new Error(`Missing template value for {{${key}}}.`);
|
|
174
|
+
return values[key];
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveInlineCommands(text: string, template: GoalTemplate, cwd: string): string {
|
|
179
|
+
return text.replace(/!`([^`]+)`/g, (_match, command: string) => {
|
|
180
|
+
if (!template.allowCommands) throw new Error(`Template ${template.name} uses inline commands but allow_commands is not true.`);
|
|
181
|
+
return runCommand(command, template, cwd);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function runCommand(command: string, template: GoalTemplate, cwd: string): string {
|
|
186
|
+
try {
|
|
187
|
+
const output = execFileSync("/bin/bash", ["-lc", command], {
|
|
188
|
+
cwd,
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
timeout: template.commandTimeoutMs,
|
|
191
|
+
maxBuffer: template.commandOutputLimit + 1024,
|
|
192
|
+
});
|
|
193
|
+
return output.length > template.commandOutputLimit ? `${output.slice(0, template.commandOutputLimit)}\n[output truncated]` : output;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
+
throw new Error(`Inline command failed in template ${template.name}: ${message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function stripMarkdownExt(path: string): string {
|
|
201
|
+
return path.replace(/\.(md|markdown|txt)$/i, "");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseList(value?: string): string[] {
|
|
205
|
+
if (!value) return [];
|
|
206
|
+
return value.replace(/^\[|\]$/g, "").split(",").map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseBoolean(value?: string): boolean {
|
|
210
|
+
return value === "true" || value === "yes" || value === "1";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
214
|
+
const parsed = Number(value);
|
|
215
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function firstContentLine(body: string): string | undefined {
|
|
219
|
+
return body.split(/\r?\n/).map((line) => line.replace(/^#+\s*/, "").trim()).find(Boolean);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stripQuotes(value: string): string {
|
|
223
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function unquote(value: string): string {
|
|
227
|
+
return stripQuotes(value);
|
|
228
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { formatElapsed, validateObjective } from "./format";
|
|
4
|
+
import { isBudgetExhausted } from "./budget";
|
|
5
|
+
import { createTelemetry, noteBudgetLimit } from "./telemetry";
|
|
6
|
+
import { createGoalState, getGoal, getTelemetry, persistClearGoal, persistSetGoal, persistUpdateGoal } from "./state";
|
|
7
|
+
import { syncGoalUi } from "./ui";
|
|
8
|
+
import type { GoalCommandScheduler, GoalContinuationCanceller, GoalMonitorCanceller, GoalMonitorScheduler, GoalState, GoalStatus, GoalTelemetrySnapshot } from "./types";
|
|
9
|
+
|
|
10
|
+
const EmptyParams = Type.Object({});
|
|
11
|
+
const CreateGoalParams = Type.Object({
|
|
12
|
+
objective: Type.String({ description: "Goal objective explicitly requested by the user" }),
|
|
13
|
+
token_budget: Type.Optional(Type.Number({ description: "Optional positive token budget" })),
|
|
14
|
+
time_budget_seconds: Type.Optional(Type.Number({ description: "Optional positive time budget in seconds" })),
|
|
15
|
+
});
|
|
16
|
+
const NullableNumber = Type.Union([Type.Number(), Type.Null()]);
|
|
17
|
+
const UpdateGoalParams = Type.Object({
|
|
18
|
+
status: Type.Optional(Type.String({ description: "Optional status update: active, paused, or complete" })),
|
|
19
|
+
objective: Type.Optional(Type.String({ description: "Optional replacement objective explicitly requested by the user" })),
|
|
20
|
+
token_budget: Type.Optional(NullableNumber),
|
|
21
|
+
time_budget_seconds: Type.Optional(NullableNumber),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type ToolDetails = {
|
|
25
|
+
goal: GoalState | null;
|
|
26
|
+
telemetry?: GoalTelemetrySnapshot | null;
|
|
27
|
+
remaining_tokens?: number;
|
|
28
|
+
remaining_time_seconds?: number;
|
|
29
|
+
completion_budget_report?: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type GoalToolRuntime = {
|
|
34
|
+
scheduleContinuation?: GoalCommandScheduler;
|
|
35
|
+
cancelContinuation?: GoalContinuationCanceller;
|
|
36
|
+
scheduleMonitor?: GoalMonitorScheduler;
|
|
37
|
+
cancelMonitor?: GoalMonitorCanceller;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function registerGoalTools(
|
|
41
|
+
pi: ExtensionAPI,
|
|
42
|
+
scheduleContinuation?: GoalCommandScheduler,
|
|
43
|
+
cancelContinuation?: GoalContinuationCanceller,
|
|
44
|
+
scheduleMonitor?: GoalMonitorScheduler,
|
|
45
|
+
cancelMonitor?: GoalMonitorCanceller,
|
|
46
|
+
): void {
|
|
47
|
+
const runtime: GoalToolRuntime = { scheduleContinuation, cancelContinuation, scheduleMonitor, cancelMonitor };
|
|
48
|
+
registerGetGoalTool(pi);
|
|
49
|
+
registerCreateGoalTool(pi, runtime);
|
|
50
|
+
registerUpdateGoalTool(pi, runtime);
|
|
51
|
+
registerClearGoalTool(pi, runtime);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function registerGetGoalTool(pi: ExtensionAPI): void {
|
|
55
|
+
pi.registerTool({
|
|
56
|
+
name: "get_goal",
|
|
57
|
+
label: "Get Goal",
|
|
58
|
+
description: "Get the current pi-goal state, including status, budget, token usage, and time usage.",
|
|
59
|
+
promptSnippet: "Inspect the active pi-goal objective and progress state.",
|
|
60
|
+
promptGuidelines: ["Use get_goal to inspect an explicit active goal before deciding whether it is complete."],
|
|
61
|
+
parameters: EmptyParams,
|
|
62
|
+
async execute() {
|
|
63
|
+
return resultForGoal(getGoal(), getTelemetry());
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function registerCreateGoalTool(pi: ExtensionAPI, runtime: GoalToolRuntime): void {
|
|
69
|
+
pi.registerTool({
|
|
70
|
+
name: "create_goal",
|
|
71
|
+
label: "Create Goal",
|
|
72
|
+
description: "Create a new pi-goal only when the user explicitly asks for a persistent goal.",
|
|
73
|
+
promptSnippet: "Create an explicit persistent pi-goal when requested.",
|
|
74
|
+
promptGuidelines: [
|
|
75
|
+
"Use create_goal only when the user explicitly asks to create or pursue a persistent goal.",
|
|
76
|
+
"Do not infer goals from ordinary tasks; normal user requests are not automatically pi-goals.",
|
|
77
|
+
],
|
|
78
|
+
parameters: CreateGoalParams,
|
|
79
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
80
|
+
return createGoalFromTool(pi, runtime, params, ctx);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function registerUpdateGoalTool(pi: ExtensionAPI, runtime: GoalToolRuntime): void {
|
|
86
|
+
pi.registerTool({
|
|
87
|
+
name: "update_goal",
|
|
88
|
+
label: "Update Goal",
|
|
89
|
+
description: "Update the current pi-goal when the user explicitly asks to edit, pause, resume, or complete it.",
|
|
90
|
+
promptSnippet: "Update an explicit pi-goal field or status when requested by the user.",
|
|
91
|
+
promptGuidelines: updateGoalGuidelines(),
|
|
92
|
+
parameters: UpdateGoalParams,
|
|
93
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
94
|
+
return updateGoalFromTool(pi, runtime, params, ctx);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function registerClearGoalTool(pi: ExtensionAPI, runtime: GoalToolRuntime): void {
|
|
100
|
+
pi.registerTool({
|
|
101
|
+
name: "clear_goal",
|
|
102
|
+
label: "Clear Goal",
|
|
103
|
+
description: "Clear the current pi-goal when the user explicitly asks to remove the persistent goal or hide the goal widget.",
|
|
104
|
+
promptSnippet: "Clear the explicit persistent pi-goal when requested by the user.",
|
|
105
|
+
promptGuidelines: [
|
|
106
|
+
"Use clear_goal only when the user explicitly asks to clear, remove, delete, or dismiss the current goal.",
|
|
107
|
+
"Do not clear a goal merely because it is complete unless the user asks to clear it.",
|
|
108
|
+
],
|
|
109
|
+
parameters: EmptyParams,
|
|
110
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
111
|
+
const goal = getGoal();
|
|
112
|
+
runtime.cancelContinuation?.(goal?.goalId, "tool-clear");
|
|
113
|
+
runtime.cancelMonitor?.(goal?.goalId, "tool-clear");
|
|
114
|
+
persistClearGoal(pi, "tool");
|
|
115
|
+
syncGoalUi(ctx, null);
|
|
116
|
+
return resultForGoal(null, getTelemetry(), goal ? "Goal cleared." : "No goal was set.");
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function updateGoalGuidelines(): string[] {
|
|
122
|
+
return [
|
|
123
|
+
"Use update_goal with status complete only after verifying the goal objective is actually achieved.",
|
|
124
|
+
"Use update_goal for objective or budget edits only when the user explicitly asks to modify the persistent goal.",
|
|
125
|
+
"Use status paused or active only when the user explicitly asks to pause or resume the goal.",
|
|
126
|
+
"Do not infer goal edits from ordinary task discussion.",
|
|
127
|
+
"Use clear_goal, not update_goal, when the user asks to clear/remove the current goal.",
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type CreateGoalInput = {
|
|
132
|
+
objective: string;
|
|
133
|
+
token_budget?: number;
|
|
134
|
+
time_budget_seconds?: number;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type UpdateGoalInput = {
|
|
138
|
+
status?: string;
|
|
139
|
+
objective?: string;
|
|
140
|
+
token_budget?: number | null;
|
|
141
|
+
time_budget_seconds?: number | null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type GoalUpdateResult = { ok: true; goal: GoalState; prefix: string; budgetChanged?: boolean } | { ok: false; error: string };
|
|
145
|
+
|
|
146
|
+
function createGoalFromTool(pi: ExtensionAPI, runtime: GoalToolRuntime, params: CreateGoalInput, ctx: ExtensionContext) {
|
|
147
|
+
if (getGoal()) return errorResult("A goal already exists. Use clear_goal or ask the user before replacing it.");
|
|
148
|
+
const validation = validateObjective(params.objective);
|
|
149
|
+
if (!validation.ok) return errorResult(validation.hint ? `${validation.message} ${validation.hint}` : validation.message);
|
|
150
|
+
const budgetError = validateBudgets(params.token_budget, params.time_budget_seconds);
|
|
151
|
+
if (budgetError) return errorResult(budgetError);
|
|
152
|
+
const goal = createGoalState(validation.objective, params.token_budget, params.time_budget_seconds);
|
|
153
|
+
const telemetry = createTelemetry(goal.goalId, goal.createdAt);
|
|
154
|
+
persistSetGoal(pi, goal, telemetry, "tool");
|
|
155
|
+
syncGoalUi(ctx, goal);
|
|
156
|
+
runtime.scheduleMonitor?.(ctx);
|
|
157
|
+
return resultForGoal(goal, telemetry, "Goal created.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function updateGoalFromTool(pi: ExtensionAPI, runtime: GoalToolRuntime, params: UpdateGoalInput, ctx: ExtensionContext) {
|
|
161
|
+
const goal = getGoal();
|
|
162
|
+
if (!goal) return errorResult("No goal exists to update.");
|
|
163
|
+
const update = buildGoalUpdate(goal, params);
|
|
164
|
+
if (!update.ok) return errorResult(update.error);
|
|
165
|
+
if (update.goal.status !== "active") {
|
|
166
|
+
runtime.cancelContinuation?.(goal.goalId, "tool-status");
|
|
167
|
+
runtime.cancelMonitor?.(goal.goalId, "tool-status");
|
|
168
|
+
}
|
|
169
|
+
persistUpdateGoal(pi, update.goal, telemetryForUpdate(update.goal), "tool");
|
|
170
|
+
syncGoalUi(ctx, update.goal);
|
|
171
|
+
if (goal.status !== "active" && update.goal.status === "active") {
|
|
172
|
+
runtime.scheduleMonitor?.(ctx);
|
|
173
|
+
runtime.scheduleContinuation?.(ctx, "resumed");
|
|
174
|
+
}
|
|
175
|
+
return resultForGoal(update.goal, getTelemetry(), update.prefix);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateBudgets(tokenBudget?: number, timeBudgetSeconds?: number): string | undefined {
|
|
179
|
+
if (tokenBudget !== undefined && (!Number.isInteger(tokenBudget) || tokenBudget <= 0)) return "token_budget must be a positive integer when provided.";
|
|
180
|
+
if (timeBudgetSeconds !== undefined && (!Number.isInteger(timeBudgetSeconds) || timeBudgetSeconds <= 0)) {
|
|
181
|
+
return "time_budget_seconds must be a positive integer when provided.";
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildGoalUpdate(goal: GoalState, params: UpdateGoalInput): GoalUpdateResult {
|
|
187
|
+
let next: GoalState = { ...goal };
|
|
188
|
+
const changes: string[] = [];
|
|
189
|
+
const objectiveResult = applyObjectiveUpdate(next, params.objective, changes);
|
|
190
|
+
if (!objectiveResult.ok) return objectiveResult;
|
|
191
|
+
next = objectiveResult.goal;
|
|
192
|
+
const budgetResult = applyBudgetUpdates(next, params, changes);
|
|
193
|
+
if (!budgetResult.ok) return budgetResult;
|
|
194
|
+
next = budgetResult.budgetChanged && params.status === undefined ? recomputeStatusAfterBudgetEdit(budgetResult.goal) : budgetResult.goal;
|
|
195
|
+
if (params.status !== undefined) {
|
|
196
|
+
const status = parseToolStatus(params.status);
|
|
197
|
+
if (!status) return { ok: false, error: "status must be active, paused, or complete when provided." };
|
|
198
|
+
next = { ...next, status };
|
|
199
|
+
changes.push(`status ${status}`);
|
|
200
|
+
}
|
|
201
|
+
if (changes.length === 0) return { ok: false, error: "No goal updates were provided." };
|
|
202
|
+
return { ok: true, goal: { ...next, updatedAt: Date.now() }, prefix: next.status === "complete" ? "Goal achieved." : `Goal updated: ${changes.join(", ")}.` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function applyObjectiveUpdate(goal: GoalState, objective: string | undefined, changes: string[]): GoalUpdateResult {
|
|
206
|
+
if (objective === undefined) return { ok: true, goal, prefix: "" };
|
|
207
|
+
const validation = validateObjective(objective);
|
|
208
|
+
if (!validation.ok) return { ok: false, error: validation.hint ? `${validation.message} ${validation.hint}` : validation.message };
|
|
209
|
+
changes.push("objective");
|
|
210
|
+
return { ok: true, goal: { ...goal, objective: validation.objective }, prefix: "" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyBudgetUpdates(goal: GoalState, params: UpdateGoalInput, changes: string[]): GoalUpdateResult {
|
|
214
|
+
let next = goal;
|
|
215
|
+
let budgetChanged = false;
|
|
216
|
+
if (params.token_budget !== undefined) {
|
|
217
|
+
if (params.token_budget !== null && (!Number.isInteger(params.token_budget) || params.token_budget <= 0)) return { ok: false, error: "token_budget must be a positive integer or null when provided." };
|
|
218
|
+
next = { ...next, tokenBudget: params.token_budget === null ? undefined : params.token_budget };
|
|
219
|
+
changes.push("token budget");
|
|
220
|
+
budgetChanged = true;
|
|
221
|
+
}
|
|
222
|
+
if (params.time_budget_seconds !== undefined) {
|
|
223
|
+
if (params.time_budget_seconds !== null && (!Number.isInteger(params.time_budget_seconds) || params.time_budget_seconds <= 0)) return { ok: false, error: "time_budget_seconds must be a positive integer or null when provided." };
|
|
224
|
+
next = { ...next, timeBudgetSeconds: params.time_budget_seconds === null ? undefined : params.time_budget_seconds };
|
|
225
|
+
changes.push("time budget");
|
|
226
|
+
budgetChanged = true;
|
|
227
|
+
}
|
|
228
|
+
return { ok: true, goal: next, prefix: "", budgetChanged };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function recomputeStatusAfterBudgetEdit(goal: GoalState): GoalState {
|
|
232
|
+
if (goal.status !== "active" && goal.status !== "budgetLimited") return goal;
|
|
233
|
+
return isBudgetExhausted(goal) ? { ...goal, status: "budgetLimited" } : { ...goal, status: "active" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function telemetryForUpdate(goal: GoalState): GoalTelemetrySnapshot | null {
|
|
237
|
+
const reason = isBudgetExhausted(goal);
|
|
238
|
+
if (goal.status === "budgetLimited" && reason) return noteBudgetLimit(getTelemetry(), reason);
|
|
239
|
+
return getTelemetry();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseToolStatus(status: string): GoalStatus | undefined {
|
|
243
|
+
if (status === "active" || status === "paused" || status === "complete") return status;
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resultForGoal(goal: GoalState | null, telemetry: GoalTelemetrySnapshot | null, prefix?: string) {
|
|
248
|
+
const details: ToolDetails = { goal, telemetry, remaining_tokens: remainingTokens(goal), remaining_time_seconds: remainingTime(goal) };
|
|
249
|
+
if (goal?.status === "complete" && (goal.tokenBudget !== undefined || goal.timeBudgetSeconds !== undefined)) {
|
|
250
|
+
details.completion_budget_report = `Goal achieved. Report final budget usage to the user: tokens used: ${goal.tokensUsed}${goal.tokenBudget === undefined ? "" : ` of ${goal.tokenBudget}`}; time used: ${goal.timeUsedSeconds}${goal.timeBudgetSeconds === undefined ? "" : ` of ${goal.timeBudgetSeconds}`} seconds.`;
|
|
251
|
+
}
|
|
252
|
+
return { content: [{ type: "text" as const, text: `${prefix ? `${prefix}\n` : ""}${formatToolGoal(goal)}` }], details };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function errorResult(error: string) {
|
|
256
|
+
return { content: [{ type: "text" as const, text: `Error: ${error}` }], details: { goal: getGoal(), telemetry: getTelemetry(), error } as ToolDetails };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function remainingTokens(goal: GoalState | null): number | undefined {
|
|
260
|
+
return goal?.tokenBudget === undefined ? undefined : Math.max(0, goal.tokenBudget - goal.tokensUsed);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function remainingTime(goal: GoalState | null): number | undefined {
|
|
264
|
+
return goal?.timeBudgetSeconds === undefined ? undefined : Math.max(0, goal.timeBudgetSeconds - goal.timeUsedSeconds);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatToolGoal(goal: GoalState | null): string {
|
|
268
|
+
if (!goal) return "No goal is currently set.";
|
|
269
|
+
const tokenBudget = goal.tokenBudget === undefined ? "none" : `${goal.tokenBudget} (${remainingTokens(goal)} remaining)`;
|
|
270
|
+
const timeBudget = goal.timeBudgetSeconds === undefined ? "none" : `${formatElapsed(goal.timeBudgetSeconds)} (${remainingTime(goal)} seconds remaining)`;
|
|
271
|
+
return [
|
|
272
|
+
`Goal: ${goal.status}`,
|
|
273
|
+
`Objective: ${goal.objective}`,
|
|
274
|
+
`Time used: ${goal.timeUsedSeconds} seconds`,
|
|
275
|
+
`Tokens used: ${goal.tokensUsed}`,
|
|
276
|
+
`Token budget: ${tokenBudget}`,
|
|
277
|
+
`Time budget: ${timeBudget}`,
|
|
278
|
+
].join("\n");
|
|
279
|
+
}
|