@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2
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 +79 -0
- package/examples/extensions/plan-mode.ts +0 -1
- package/package.json +10 -10
- package/scripts/build-binary.ts +5 -0
- package/src/autoresearch/helpers.ts +17 -0
- package/src/autoresearch/tools/log-experiment.ts +9 -17
- package/src/autoresearch/tools/run-experiment.ts +2 -17
- package/src/capability/skill.ts +7 -0
- package/src/cli/list-models.ts +1 -1
- package/src/cli/shell-cli.ts +3 -13
- package/src/cli/update-cli.ts +1 -1
- package/src/cli.ts +10 -29
- package/src/commands/commit.ts +10 -0
- package/src/commit/agentic/tools/propose-changelog.ts +8 -1
- package/src/commit/analysis/conventional.ts +8 -66
- package/src/commit/map-reduce/reduce-phase.ts +6 -65
- package/src/commit/pipeline.ts +2 -2
- package/src/commit/shared-llm.ts +89 -0
- package/src/config/config-file.ts +210 -0
- package/src/config/model-equivalence.ts +8 -11
- package/src/config/model-registry.ts +44 -3
- package/src/config/model-resolver.ts +1 -4
- package/src/config/settings-schema.ts +82 -1
- package/src/config/settings.ts +1 -1
- package/src/config.ts +3 -219
- package/src/discovery/claude-plugins.ts +19 -7
- package/src/edit/renderer.ts +7 -1
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/shared/rewrite-imports.ts +2 -2
- package/src/eval/py/executor.ts +5 -0
- package/src/eval/py/runner.py +42 -11
- package/src/eval/py/runtime.ts +1 -0
- package/src/exa/factory.ts +2 -2
- package/src/exa/mcp-client.ts +74 -1
- package/src/exec/bash-executor.ts +5 -1
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -11
- package/src/extensibility/extensions/get-commands-handler.ts +77 -0
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +89 -223
- package/src/extensibility/hooks/types.ts +89 -314
- package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
- package/src/extensibility/shared-events.ts +343 -0
- package/src/extensibility/skills.ts +9 -0
- package/src/goals/index.ts +3 -0
- package/src/goals/runtime.ts +500 -0
- package/src/goals/state.ts +37 -0
- package/src/goals/tools/goal-tool.ts +237 -0
- package/src/hashline/anchors.ts +2 -2
- package/src/hashline/input.ts +2 -1
- package/src/hashline/parser.ts +27 -3
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/agent-protocol.ts +1 -20
- package/src/internal-urls/artifact-protocol.ts +1 -19
- package/src/internal-urls/docs-index.generated.ts +11 -12
- package/src/internal-urls/registry-helpers.ts +25 -0
- package/src/internal-urls/router.ts +8 -0
- package/src/internal-urls/types.ts +21 -0
- package/src/lsp/config.ts +15 -6
- package/src/lsp/defaults.json +6 -2
- package/src/main.ts +11 -2
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/modes/acp/acp-agent.ts +327 -95
- package/src/modes/components/assistant-message.ts +14 -8
- package/src/modes/components/bash-execution.ts +24 -63
- package/src/modes/components/custom-message.ts +14 -40
- package/src/modes/components/eval-execution.ts +27 -57
- package/src/modes/components/execution-shared.ts +102 -0
- package/src/modes/components/hook-message.ts +17 -49
- package/src/modes/components/mcp-add-wizard.ts +26 -5
- package/src/modes/components/message-frame.ts +88 -0
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +6 -2
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +93 -8
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +28 -10
- package/src/modes/components/tool-execution.ts +7 -8
- package/src/modes/controllers/command-controller-shared.ts +108 -0
- package/src/modes/controllers/command-controller.ts +13 -4
- package/src/modes/controllers/event-controller.ts +36 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +13 -0
- package/src/modes/controllers/mcp-command-controller.ts +56 -61
- package/src/modes/controllers/ssh-command-controller.ts +18 -57
- package/src/modes/interactive-mode.ts +624 -52
- package/src/modes/print-mode.ts +16 -86
- package/src/modes/rpc/host-uris.ts +235 -0
- package/src/modes/rpc/rpc-mode.ts +41 -88
- package/src/modes/rpc/rpc-types.ts +57 -0
- package/src/modes/runtime-init.ts +116 -0
- package/src/modes/theme/defaults/dark-poimandres.json +3 -0
- package/src/modes/theme/defaults/light-poimandres.json +3 -0
- package/src/modes/theme/theme.ts +24 -6
- package/src/modes/types.ts +14 -3
- package/src/modes/utils/context-usage.ts +13 -13
- package/src/modes/utils/ui-helpers.ts +10 -3
- package/src/plan-mode/approved-plan.ts +35 -1
- package/src/prompts/goals/goal-budget-limit.md +16 -0
- package/src/prompts/goals/goal-continuation.md +28 -0
- package/src/prompts/goals/goal-mode-active.md +23 -0
- package/src/prompts/system/plan-mode-active.md +5 -5
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
- package/src/prompts/tools/bash.md +6 -0
- package/src/prompts/tools/github.md +4 -4
- package/src/prompts/tools/goal.md +13 -0
- package/src/prompts/tools/hashline.md +101 -117
- package/src/prompts/tools/read.md +55 -36
- package/src/prompts/tools/resolve.md +6 -5
- package/src/sdk.ts +12 -5
- package/src/session/agent-session.ts +428 -106
- package/src/session/blob-store.ts +36 -3
- package/src/session/messages.ts +67 -2
- package/src/session/session-manager.ts +131 -12
- package/src/session/session-storage.ts +33 -15
- package/src/session/streaming-output.ts +309 -13
- package/src/slash-commands/builtin-registry.ts +18 -0
- package/src/ssh/ssh-executor.ts +5 -0
- package/src/system-prompt.ts +4 -2
- package/src/task/discovery.ts +5 -2
- package/src/task/executor.ts +19 -8
- package/src/task/index.ts +3 -0
- package/src/task/render.ts +21 -15
- package/src/task/types.ts +4 -0
- package/src/tools/ast-edit.ts +21 -120
- package/src/tools/ast-grep.ts +21 -119
- package/src/tools/bash-command-fixup.ts +47 -0
- package/src/tools/bash-interactive.ts +9 -1
- package/src/tools/bash.ts +66 -19
- package/src/tools/browser/attach.ts +3 -3
- package/src/tools/browser/launch.ts +81 -18
- package/src/tools/browser/registry.ts +1 -5
- package/src/tools/browser/render.ts +2 -2
- package/src/tools/browser/tab-supervisor.ts +51 -14
- package/src/tools/conflict-detect.ts +15 -4
- package/src/tools/eval.ts +12 -2
- package/src/tools/find.ts +20 -38
- package/src/tools/gh.ts +44 -10
- package/src/tools/index.ts +22 -11
- package/src/tools/inspect-image.ts +3 -10
- package/src/tools/job.ts +16 -7
- package/src/tools/output-meta.ts +202 -37
- package/src/tools/path-utils.ts +125 -2
- package/src/tools/read.ts +548 -237
- package/src/tools/render-utils.ts +92 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resolve.ts +72 -44
- package/src/tools/search.ts +120 -186
- package/src/tools/ssh.ts +3 -2
- package/src/tools/write.ts +64 -9
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/image-loading.ts +7 -3
- package/src/utils/image-resize.ts +32 -43
- package/src/vim/parser.ts +0 -17
- package/src/vim/render.ts +1 -1
- package/src/vim/types.ts +1 -1
- package/src/web/search/providers/anthropic.ts +5 -0
- package/src/web/search/providers/exa.ts +3 -0
- package/src/web/search/providers/gemini.ts +40 -95
- package/src/web/search/providers/jina.ts +5 -2
- package/src/web/search/providers/zai.ts +5 -2
- package/src/prompts/tools/exit-plan-mode.md +0 -6
- package/src/tools/exit-plan-mode.ts +0 -97
- package/src/utils/fuzzy.ts +0 -108
- package/src/utils/image-convert.ts +0 -27
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import goalBudgetLimitPrompt from "../prompts/goals/goal-budget-limit.md" with { type: "text" };
|
|
3
|
+
import goalContinuationPrompt from "../prompts/goals/goal-continuation.md" with { type: "text" };
|
|
4
|
+
import goalModeActivePrompt from "../prompts/goals/goal-mode-active.md" with { type: "text" };
|
|
5
|
+
import type { Goal, GoalBudgetSteering, GoalModeState, GoalRuntimeEvent, GoalTokenUsage } from "./state";
|
|
6
|
+
|
|
7
|
+
export interface GoalRuntimeHost {
|
|
8
|
+
getState(): GoalModeState | undefined;
|
|
9
|
+
setState(state: GoalModeState | undefined): void;
|
|
10
|
+
getCurrentUsage(): GoalTokenUsage;
|
|
11
|
+
emit(event: GoalRuntimeEvent): void | Promise<void>;
|
|
12
|
+
persist(mode: "goal" | "goal_paused" | "none", state?: GoalModeState): void;
|
|
13
|
+
sendHiddenMessage(message: {
|
|
14
|
+
customType: string;
|
|
15
|
+
content: string;
|
|
16
|
+
deliverAs?: "steer" | "followUp" | "nextTurn";
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
now?(): number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GoalTurnSnapshot {
|
|
22
|
+
turnId: string;
|
|
23
|
+
baselineUsage: GoalTokenUsage;
|
|
24
|
+
activeGoalId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GoalWallClockSnapshot {
|
|
28
|
+
lastAccountedAt: number;
|
|
29
|
+
activeGoalId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GoalRuntimeSnapshot {
|
|
33
|
+
turnSnapshot?: GoalTurnSnapshot;
|
|
34
|
+
wallClock: GoalWallClockSnapshot;
|
|
35
|
+
budgetReportedFor?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GoalPromptKind = "active" | "continuation" | "budget-limit";
|
|
39
|
+
|
|
40
|
+
function cloneGoal(goal: Goal): Goal {
|
|
41
|
+
return { ...goal };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cloneState(state: GoalModeState): GoalModeState {
|
|
45
|
+
return { ...state, goal: cloneGoal(state.goal) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function budgetValue(goal: Goal): string {
|
|
49
|
+
return goal.tokenBudget === undefined ? "none" : String(goal.tokenBudget);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function remainingValue(goal: Goal): string {
|
|
53
|
+
return goal.tokenBudget === undefined ? "unbounded" : String(Math.max(0, goal.tokenBudget - goal.tokensUsed));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function remainingTokens(goal: Goal | null | undefined): number | null {
|
|
57
|
+
if (!goal || goal.tokenBudget === undefined) return null;
|
|
58
|
+
return Math.max(0, goal.tokenBudget - goal.tokensUsed);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function escapeXmlText(input: string): string {
|
|
62
|
+
let firstEscapable = -1;
|
|
63
|
+
for (let index = 0; index < input.length; index++) {
|
|
64
|
+
const char = input.charCodeAt(index);
|
|
65
|
+
if (char === 38 || char === 60 || char === 62) {
|
|
66
|
+
firstEscapable = index;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (firstEscapable === -1) return input;
|
|
71
|
+
|
|
72
|
+
let output = input.slice(0, firstEscapable);
|
|
73
|
+
for (let index = firstEscapable; index < input.length; index++) {
|
|
74
|
+
const char = input[index];
|
|
75
|
+
if (char === "&") output += "&";
|
|
76
|
+
else if (char === "<") output += "<";
|
|
77
|
+
else if (char === ">") output += ">";
|
|
78
|
+
else output += char;
|
|
79
|
+
}
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderTrustedObjective(objective: string): string {
|
|
84
|
+
return `<objective>\n${escapeXmlText(objective)}\n</objective>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function goalTokenDelta(current: GoalTokenUsage, baseline: GoalTokenUsage): number {
|
|
88
|
+
// Diverges from codex-rs: codex omits cache creation because its target providers
|
|
89
|
+
// do not bill cache writes distinctly through the token-usage stream. Pi receives
|
|
90
|
+
// cacheWrite separately on Anthropic/Bedrock; rotating a 1h ephemeral cache or
|
|
91
|
+
// re-anchoring a changed system prompt can write 100K+ tokens, which the goal
|
|
92
|
+
// budget must account for. cacheRead is excluded because it is reused prefix,
|
|
93
|
+
// not new work consumed by the goal.
|
|
94
|
+
return (
|
|
95
|
+
Math.max(0, current.input - baseline.input) +
|
|
96
|
+
Math.max(0, current.cacheWrite - baseline.cacheWrite) +
|
|
97
|
+
Math.max(0, current.output - baseline.output)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderGoalPrompt(kind: GoalPromptKind, goal: Goal): string {
|
|
102
|
+
const template =
|
|
103
|
+
kind === "active"
|
|
104
|
+
? goalModeActivePrompt
|
|
105
|
+
: kind === "continuation"
|
|
106
|
+
? goalContinuationPrompt
|
|
107
|
+
: goalBudgetLimitPrompt;
|
|
108
|
+
return prompt.render(template, {
|
|
109
|
+
objective: escapeXmlText(goal.objective),
|
|
110
|
+
tokensUsed: String(goal.tokensUsed),
|
|
111
|
+
tokenBudget: budgetValue(goal),
|
|
112
|
+
remainingTokens: remainingValue(goal),
|
|
113
|
+
timeUsedSeconds: String(goal.timeUsedSeconds),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function completionBudgetReport(goal: Goal): string | null {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
if (goal.tokenBudget !== undefined) {
|
|
120
|
+
parts.push(`tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}`);
|
|
121
|
+
}
|
|
122
|
+
if (goal.timeUsedSeconds > 0) {
|
|
123
|
+
parts.push(`time used: ${goal.timeUsedSeconds} seconds`);
|
|
124
|
+
}
|
|
125
|
+
if (parts.length === 0) return null;
|
|
126
|
+
return `Goal achieved. Report final budget usage to the user: ${parts.join("; ")}.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateTokenBudget(tokenBudget: number | undefined): void {
|
|
130
|
+
if (tokenBudget !== undefined && (!Number.isInteger(tokenBudget) || tokenBudget <= 0)) {
|
|
131
|
+
throw new Error("goal token_budget must be a positive integer when provided");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isAccountingStatus(goal: Goal): boolean {
|
|
136
|
+
return goal.status === "active" || goal.status === "budget-limited";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class GoalRuntime {
|
|
140
|
+
readonly #host: GoalRuntimeHost;
|
|
141
|
+
#turnSnapshot: GoalTurnSnapshot | undefined;
|
|
142
|
+
#wallClock: GoalWallClockSnapshot;
|
|
143
|
+
#budgetReportedFor: string | undefined;
|
|
144
|
+
#accountingTail: Promise<void> = Promise.resolve();
|
|
145
|
+
|
|
146
|
+
constructor(host: GoalRuntimeHost) {
|
|
147
|
+
this.#host = host;
|
|
148
|
+
this.#wallClock = { lastAccountedAt: this.#now() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get snapshot(): GoalRuntimeSnapshot {
|
|
152
|
+
return {
|
|
153
|
+
turnSnapshot: this.#turnSnapshot
|
|
154
|
+
? { ...this.#turnSnapshot, baselineUsage: { ...this.#turnSnapshot.baselineUsage } }
|
|
155
|
+
: undefined,
|
|
156
|
+
wallClock: { ...this.#wallClock },
|
|
157
|
+
budgetReportedFor: this.#budgetReportedFor,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#now(): number {
|
|
162
|
+
return this.#host.now?.() ?? Date.now();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#hasAccountingState(): boolean {
|
|
166
|
+
const state = this.#host.getState();
|
|
167
|
+
return Boolean(state?.enabled && isAccountingStatus(state.goal));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #withAccounting<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
171
|
+
const previous = this.#accountingTail;
|
|
172
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
173
|
+
this.#accountingTail = previous.then(
|
|
174
|
+
() => promise,
|
|
175
|
+
() => promise,
|
|
176
|
+
);
|
|
177
|
+
await previous.catch(() => {});
|
|
178
|
+
try {
|
|
179
|
+
return await fn();
|
|
180
|
+
} finally {
|
|
181
|
+
resolve();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#getStateClone(): GoalModeState | undefined {
|
|
186
|
+
const state = this.#host.getState();
|
|
187
|
+
return state ? cloneState(state) : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async #commitState(
|
|
191
|
+
state: GoalModeState | undefined,
|
|
192
|
+
options?: { persist?: "goal" | "goal_paused" | "none"; emit?: boolean },
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
this.#host.setState(state ? cloneState(state) : undefined);
|
|
195
|
+
if (options?.persist) {
|
|
196
|
+
this.#host.persist(options.persist, state);
|
|
197
|
+
}
|
|
198
|
+
if (options?.emit !== false) {
|
|
199
|
+
await this.#host.emit({ type: "goal_updated", goal: state ? cloneGoal(state.goal) : null, state });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#markActiveAccounting(goal: Goal): void {
|
|
204
|
+
if (this.#wallClock.activeGoalId !== goal.id) {
|
|
205
|
+
this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id };
|
|
206
|
+
}
|
|
207
|
+
if (this.#turnSnapshot) {
|
|
208
|
+
this.#turnSnapshot.activeGoalId = goal.id;
|
|
209
|
+
this.#turnSnapshot.baselineUsage = { ...this.#host.getCurrentUsage() };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#clearActiveAccounting(): void {
|
|
214
|
+
this.#wallClock = { lastAccountedAt: this.#now() };
|
|
215
|
+
if (this.#turnSnapshot) {
|
|
216
|
+
this.#turnSnapshot.activeGoalId = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void {
|
|
221
|
+
this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } };
|
|
222
|
+
const state = this.#host.getState();
|
|
223
|
+
if (state?.enabled && isAccountingStatus(state.goal)) {
|
|
224
|
+
this.#turnSnapshot.activeGoalId = state.goal.id;
|
|
225
|
+
if (this.#wallClock.activeGoalId !== state.goal.id) {
|
|
226
|
+
this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: state.goal.id };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async onToolCompleted(toolName: string): Promise<void> {
|
|
232
|
+
if (toolName === "goal") return;
|
|
233
|
+
if (!this.#hasAccountingState()) return;
|
|
234
|
+
await this.flushUsage("allowed");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async onGoalToolCompleted(): Promise<void> {
|
|
238
|
+
if (!this.#hasAccountingState()) return;
|
|
239
|
+
await this.flushUsage("suppressed");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async onAgentEnd(options?: { turnCompleted?: boolean; currentUsage?: GoalTokenUsage }): Promise<void> {
|
|
243
|
+
if (!this.#hasAccountingState()) {
|
|
244
|
+
this.#turnSnapshot = undefined;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
await this.flushUsage("suppressed", options?.currentUsage);
|
|
248
|
+
this.#turnSnapshot = undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async onTaskAborted(options?: { reason?: "interrupted" | "internal" }): Promise<void> {
|
|
252
|
+
const state = this.#host.getState();
|
|
253
|
+
const needsAccounting = state?.enabled && isAccountingStatus(state.goal);
|
|
254
|
+
const needsPause = options?.reason === "interrupted" && state?.enabled && state.goal.status === "active";
|
|
255
|
+
if (!needsAccounting && !needsPause) {
|
|
256
|
+
this.#turnSnapshot = undefined;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
await this.#withAccounting(async () => {
|
|
260
|
+
await this.#flushUsageLocked("suppressed");
|
|
261
|
+
this.#turnSnapshot = undefined;
|
|
262
|
+
if (options?.reason !== "interrupted") return;
|
|
263
|
+
const cloned = this.#getStateClone();
|
|
264
|
+
if (!cloned?.enabled || cloned.goal.status !== "active") return;
|
|
265
|
+
cloned.enabled = false;
|
|
266
|
+
cloned.goal.status = "paused";
|
|
267
|
+
cloned.goal.updatedAt = this.#now();
|
|
268
|
+
this.#clearActiveAccounting();
|
|
269
|
+
this.#budgetReportedFor = undefined;
|
|
270
|
+
await this.#commitState(cloned, { persist: "goal_paused" });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async onThreadResumed(): Promise<GoalModeState | undefined> {
|
|
275
|
+
const state = this.#getStateClone();
|
|
276
|
+
if (!state) return undefined;
|
|
277
|
+
if (state.goal.status === "active") {
|
|
278
|
+
state.enabled = false;
|
|
279
|
+
state.goal.status = "paused";
|
|
280
|
+
state.goal.updatedAt = this.#now();
|
|
281
|
+
this.#clearActiveAccounting();
|
|
282
|
+
this.#budgetReportedFor = undefined;
|
|
283
|
+
await this.#commitState(state, { persist: "goal_paused" });
|
|
284
|
+
return state;
|
|
285
|
+
}
|
|
286
|
+
if (state.enabled && isAccountingStatus(state.goal)) {
|
|
287
|
+
this.#markActiveAccounting(state.goal);
|
|
288
|
+
} else {
|
|
289
|
+
this.#clearActiveAccounting();
|
|
290
|
+
}
|
|
291
|
+
await this.#commitState(state, { emit: true });
|
|
292
|
+
return state;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async onBudgetMutated(newBudget: number | undefined): Promise<GoalModeState | undefined> {
|
|
296
|
+
validateTokenBudget(newBudget);
|
|
297
|
+
return await this.#withAccounting(async () => {
|
|
298
|
+
this.#budgetReportedFor = undefined;
|
|
299
|
+
await this.#flushUsageLocked("suppressed");
|
|
300
|
+
const state = this.#getStateClone();
|
|
301
|
+
if (!state?.goal) return undefined;
|
|
302
|
+
state.goal.tokenBudget = newBudget;
|
|
303
|
+
state.goal.updatedAt = this.#now();
|
|
304
|
+
let shouldSteer = false;
|
|
305
|
+
if (newBudget !== undefined && state.goal.tokensUsed >= newBudget) {
|
|
306
|
+
if (state.goal.status === "active") {
|
|
307
|
+
state.goal.status = "budget-limited";
|
|
308
|
+
shouldSteer = true;
|
|
309
|
+
}
|
|
310
|
+
} else if (state.goal.status === "budget-limited") {
|
|
311
|
+
state.goal.status = "active";
|
|
312
|
+
state.enabled = true;
|
|
313
|
+
this.#markActiveAccounting(state.goal);
|
|
314
|
+
}
|
|
315
|
+
await this.#commitState(state, { persist: state.enabled ? "goal" : "goal_paused" });
|
|
316
|
+
if (shouldSteer) {
|
|
317
|
+
await this.#sendBudgetLimitSteer(state.goal);
|
|
318
|
+
}
|
|
319
|
+
return state;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async #flushUsageLocked(
|
|
324
|
+
steering: GoalBudgetSteering,
|
|
325
|
+
currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(),
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const state = this.#getStateClone();
|
|
328
|
+
if (!state?.enabled || !isAccountingStatus(state.goal)) return;
|
|
329
|
+
if (this.#turnSnapshot?.activeGoalId !== state.goal.id && this.#wallClock.activeGoalId !== state.goal.id) return;
|
|
330
|
+
|
|
331
|
+
const tokenDelta =
|
|
332
|
+
this.#turnSnapshot?.activeGoalId === state.goal.id
|
|
333
|
+
? goalTokenDelta(currentUsage, this.#turnSnapshot.baselineUsage)
|
|
334
|
+
: 0;
|
|
335
|
+
const wallSeconds =
|
|
336
|
+
this.#wallClock.activeGoalId === state.goal.id
|
|
337
|
+
? Math.max(0, Math.floor((this.#now() - this.#wallClock.lastAccountedAt) / 1000))
|
|
338
|
+
: 0;
|
|
339
|
+
if (tokenDelta <= 0 && wallSeconds <= 0) return;
|
|
340
|
+
|
|
341
|
+
state.goal.tokensUsed += tokenDelta;
|
|
342
|
+
state.goal.timeUsedSeconds += wallSeconds;
|
|
343
|
+
state.goal.updatedAt = this.#now();
|
|
344
|
+
const flippedToBudgetLimited =
|
|
345
|
+
state.goal.tokenBudget !== undefined &&
|
|
346
|
+
state.goal.tokensUsed >= state.goal.tokenBudget &&
|
|
347
|
+
state.goal.status === "active";
|
|
348
|
+
if (flippedToBudgetLimited) {
|
|
349
|
+
state.goal.status = "budget-limited";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (this.#turnSnapshot?.activeGoalId === state.goal.id) {
|
|
353
|
+
this.#turnSnapshot.baselineUsage = { ...currentUsage };
|
|
354
|
+
}
|
|
355
|
+
if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) {
|
|
356
|
+
this.#wallClock.lastAccountedAt += wallSeconds * 1000;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
await this.#commitState(state, { persist: "goal" });
|
|
360
|
+
|
|
361
|
+
if (state.goal.status !== "budget-limited") {
|
|
362
|
+
this.#budgetReportedFor = undefined;
|
|
363
|
+
}
|
|
364
|
+
if (steering === "allowed" && flippedToBudgetLimited && this.#budgetReportedFor !== state.goal.id) {
|
|
365
|
+
await this.#sendBudgetLimitSteer(state.goal);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async flushUsage(
|
|
370
|
+
steering: GoalBudgetSteering,
|
|
371
|
+
currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(),
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
await this.#withAccounting(() => this.#flushUsageLocked(steering, currentUsage));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async createGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
|
|
377
|
+
const objective = input.objective.trim();
|
|
378
|
+
if (!objective) throw new Error("objective is required when op=create");
|
|
379
|
+
validateTokenBudget(input.tokenBudget);
|
|
380
|
+
return await this.#withAccounting(async () => {
|
|
381
|
+
const existing = this.#host.getState();
|
|
382
|
+
if (existing?.goal && existing.goal.status !== "dropped") {
|
|
383
|
+
throw new Error("cannot create a new goal because this session already has a goal");
|
|
384
|
+
}
|
|
385
|
+
const now = this.#now();
|
|
386
|
+
const goal: Goal = {
|
|
387
|
+
id: String(Snowflake.next()),
|
|
388
|
+
objective,
|
|
389
|
+
status: "active",
|
|
390
|
+
tokenBudget: input.tokenBudget,
|
|
391
|
+
tokensUsed: 0,
|
|
392
|
+
timeUsedSeconds: 0,
|
|
393
|
+
createdAt: now,
|
|
394
|
+
updatedAt: now,
|
|
395
|
+
};
|
|
396
|
+
const state: GoalModeState = { enabled: true, mode: "active", goal };
|
|
397
|
+
this.#budgetReportedFor = undefined;
|
|
398
|
+
this.#markActiveAccounting(goal);
|
|
399
|
+
await this.#commitState(state, { persist: "goal" });
|
|
400
|
+
return state;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async resumeGoal(): Promise<GoalModeState> {
|
|
405
|
+
return await this.#withAccounting(async () => {
|
|
406
|
+
const state = this.#getStateClone();
|
|
407
|
+
if (!state?.goal) throw new Error("No paused goal.");
|
|
408
|
+
if (state.goal.status === "complete") throw new Error("Goal is already complete.");
|
|
409
|
+
state.enabled = true;
|
|
410
|
+
state.mode = "active";
|
|
411
|
+
state.reason = undefined;
|
|
412
|
+
state.goal.status = "active";
|
|
413
|
+
state.goal.updatedAt = this.#now();
|
|
414
|
+
this.#budgetReportedFor = undefined;
|
|
415
|
+
this.#markActiveAccounting(state.goal);
|
|
416
|
+
await this.#commitState(state, { persist: "goal" });
|
|
417
|
+
return state;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async pauseGoal(): Promise<GoalModeState | undefined> {
|
|
422
|
+
return await this.#withAccounting(async () => {
|
|
423
|
+
await this.#flushUsageLocked("suppressed");
|
|
424
|
+
const state = this.#getStateClone();
|
|
425
|
+
if (!state?.goal) return undefined;
|
|
426
|
+
state.enabled = false;
|
|
427
|
+
state.mode = "active";
|
|
428
|
+
state.reason = undefined;
|
|
429
|
+
if (state.goal.status === "active" || state.goal.status === "budget-limited") {
|
|
430
|
+
state.goal.status = "paused";
|
|
431
|
+
}
|
|
432
|
+
state.goal.updatedAt = this.#now();
|
|
433
|
+
this.#clearActiveAccounting();
|
|
434
|
+
this.#budgetReportedFor = undefined;
|
|
435
|
+
await this.#commitState(state, { persist: "goal_paused" });
|
|
436
|
+
return state;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async dropGoal(): Promise<Goal | undefined> {
|
|
441
|
+
return await this.#withAccounting(async () => {
|
|
442
|
+
await this.#flushUsageLocked("suppressed");
|
|
443
|
+
const state = this.#getStateClone();
|
|
444
|
+
if (!state?.goal) return undefined;
|
|
445
|
+
const dropped = { ...state.goal, status: "dropped" as const, updatedAt: this.#now() };
|
|
446
|
+
this.#clearActiveAccounting();
|
|
447
|
+
this.#budgetReportedFor = undefined;
|
|
448
|
+
await this.#host.emit({
|
|
449
|
+
type: "goal_updated",
|
|
450
|
+
goal: dropped,
|
|
451
|
+
state: { ...state, enabled: false, goal: dropped },
|
|
452
|
+
});
|
|
453
|
+
await this.#commitState(undefined, { persist: "none", emit: false });
|
|
454
|
+
return dropped;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async completeGoalFromTool(): Promise<Goal> {
|
|
459
|
+
return await this.#withAccounting(async () => {
|
|
460
|
+
await this.#flushUsageLocked("suppressed");
|
|
461
|
+
const state = this.#getStateClone();
|
|
462
|
+
if (!state?.enabled || !state.goal) {
|
|
463
|
+
throw new Error("cannot complete goal because goal mode is not active");
|
|
464
|
+
}
|
|
465
|
+
state.enabled = false;
|
|
466
|
+
state.goal.status = "complete";
|
|
467
|
+
state.goal.updatedAt = this.#now();
|
|
468
|
+
state.mode = "exiting";
|
|
469
|
+
state.reason = "completed";
|
|
470
|
+
this.#clearActiveAccounting();
|
|
471
|
+
this.#budgetReportedFor = undefined;
|
|
472
|
+
await this.#commitState(state, { persist: "goal" });
|
|
473
|
+
return state.goal;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
buildActivePrompt(): string | undefined {
|
|
478
|
+
const state = this.#host.getState();
|
|
479
|
+
return state?.enabled && state.goal && state.goal.status === "active"
|
|
480
|
+
? renderGoalPrompt("active", state.goal)
|
|
481
|
+
: undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
buildContinuationPrompt(): string | undefined {
|
|
485
|
+
const state = this.#host.getState();
|
|
486
|
+
return state?.enabled && state.goal.status === "active"
|
|
487
|
+
? renderGoalPrompt("continuation", state.goal)
|
|
488
|
+
: undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async #sendBudgetLimitSteer(goal: Goal): Promise<void> {
|
|
492
|
+
if (this.#budgetReportedFor === goal.id) return;
|
|
493
|
+
this.#budgetReportedFor = goal.id;
|
|
494
|
+
await this.#host.sendHiddenMessage({
|
|
495
|
+
customType: "goal-budget-limit",
|
|
496
|
+
content: renderGoalPrompt("budget-limit", goal),
|
|
497
|
+
deliverAs: "steer",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { UsageStatistics } from "../session/session-manager";
|
|
2
|
+
|
|
3
|
+
export type GoalStatus = "active" | "paused" | "budget-limited" | "complete" | "dropped";
|
|
4
|
+
|
|
5
|
+
export interface Goal {
|
|
6
|
+
id: string;
|
|
7
|
+
objective: string;
|
|
8
|
+
status: GoalStatus;
|
|
9
|
+
tokenBudget?: number;
|
|
10
|
+
tokensUsed: number;
|
|
11
|
+
timeUsedSeconds: number;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GoalModeState {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
mode: "active" | "exiting";
|
|
19
|
+
reason?: "completed";
|
|
20
|
+
goal: Goal;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GoalToolDetails {
|
|
24
|
+
op: "create" | "get" | "complete";
|
|
25
|
+
goal?: Goal | null;
|
|
26
|
+
remainingTokens?: number | null;
|
|
27
|
+
completionBudgetReport?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type GoalRuntimeEvent =
|
|
31
|
+
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState }
|
|
32
|
+
| { type: "goal_continuation_requested"; prompt: string };
|
|
33
|
+
|
|
34
|
+
export type GoalTokenUsage = Pick<UsageStatistics, "input" | "output" | "cacheRead" | "cacheWrite">;
|
|
35
|
+
|
|
36
|
+
export type GoalBudgetSteering = "allowed" | "suppressed";
|
|
37
|
+
export type GoalTerminalMetricEmission = "emit" | "suppress";
|