@mocito/pi-goal 0.1.2 → 0.1.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.
- package/CHANGELOG.md +21 -1
- package/README.md +4 -2
- package/extensions/index.ts +33 -8
- package/index.ts +1 -0
- package/package.json +9 -9
- package/src/accounting.ts +2 -4
- package/src/index.ts +1 -0
- package/src/provider-limits.ts +125 -0
- package/src/state.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.7 - 2026-06-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Stop compounding active elapsed time during token accounting; active time is now materialized only on status transitions and computed live for display.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Pause goals on assistant-surfaced provider usage/quota/billing limit errors and repeated provider failures, not only HTTP 429 response hooks.
|
|
14
|
+
|
|
15
|
+
## 0.1.6 - 2026-06-06
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Revert package name to `@mocito/pi-goal` (scoped) due to npm name conflict.
|
|
20
|
+
- Aligned package structure with monorepo guidelines (added root `index.ts` re-export, `.editorconfig`, `.gitignore`; fixed extension entry point; removed non-canonical tsconfig flags).
|
|
21
|
+
- Update `author` field to full name for monorepo consistency.
|
|
22
|
+
|
|
23
|
+
## 0.1.3
|
|
4
24
|
|
|
5
25
|
- Move the extension entry point to `extensions/index.ts` so Pi displays the package extension compactly.
|
|
6
26
|
|
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ When an active goal is idle, the extension injects a hidden `pi-goal-context` me
|
|
|
44
44
|
|
|
45
45
|
The footer and optional editor widget show status, elapsed active time, token usage, and budget.
|
|
46
46
|
|
|
47
|
+
Provider usage-limit handling pauses active goals when Pi exposes HTTP 429 responses or assistant error messages that indicate subscription, quota, billing, balance, or repeated provider failures. This prevents automatic continuation from retrying indefinitely after provider limits such as 5-hour subscription caps.
|
|
48
|
+
|
|
47
49
|
## Examples
|
|
48
50
|
|
|
49
51
|
Simple goal:
|
|
@@ -81,14 +83,14 @@ Environment flags:
|
|
|
81
83
|
## Troubleshooting
|
|
82
84
|
|
|
83
85
|
- If continuation does not start, run `/goal status` and confirm the goal is `active`.
|
|
84
|
-
- If a goal stops unexpectedly, check whether it reached its token budget or the provider returned a rate/usage limit.
|
|
86
|
+
- If a goal stops unexpectedly, check whether it reached its token budget or the provider returned a rate/usage limit. Usage-limit pauses may include a provider reset hint when one is available.
|
|
85
87
|
- If context appears stale after tree navigation or reload, run `/goal status`; branch state is reconstructed from the active branch.
|
|
86
88
|
- In print/JSON modes, commands and tools work, but interactive confirmations/editors are unavailable.
|
|
87
89
|
|
|
88
90
|
## Limitations
|
|
89
91
|
|
|
90
92
|
- Token budget is enforced after finalized assistant usage is visible; v1 cannot hard-stop mid-turn.
|
|
91
|
-
- Usage-limit handling is best-effort via HTTP
|
|
93
|
+
- Usage-limit handling is best-effort via HTTP responses and assistant error messages; provider transports vary in how much structured limit information they expose.
|
|
92
94
|
- Automatic continuation is session-local, not a background daemon.
|
|
93
95
|
|
|
94
96
|
## Development and validation
|
package/extensions/index.ts
CHANGED
|
@@ -9,17 +9,21 @@ import { registerGoalTools } from "../src/tools.js";
|
|
|
9
9
|
import type { GoalState } from "../src/types.js";
|
|
10
10
|
import { GOAL_EVENT_TYPE } from "../src/types.js";
|
|
11
11
|
import { nowIso, realizedTimeUsed } from "../src/utils.js";
|
|
12
|
+
import { classifyAssistantError, classifyProviderLimit } from "../src/provider-limits.js";
|
|
13
|
+
import type { ProviderLimitClassification } from "../src/provider-limits.js";
|
|
12
14
|
import { clearGoalUi, updateGoalUi } from "../src/ui.js";
|
|
13
15
|
|
|
14
16
|
export default function piGoal(pi: ExtensionAPI) {
|
|
15
17
|
reportInstallTelemetry();
|
|
16
18
|
|
|
17
19
|
let goal: GoalState | null = null;
|
|
20
|
+
let consecutiveAssistantErrors = 0;
|
|
18
21
|
const scheduler = new GoalContinuationScheduler(pi, { getGoal: () => goal });
|
|
19
22
|
|
|
20
23
|
const setGoal = (next: GoalState | null) => { goal = next; };
|
|
21
24
|
|
|
22
25
|
function afterGoalChanged(ctx: ExtensionContext, event?: string): void {
|
|
26
|
+
if (event) consecutiveAssistantErrors = 0;
|
|
23
27
|
updateGoalUi(ctx, goal);
|
|
24
28
|
if (event) {
|
|
25
29
|
ctx.ui.notify(event, "info");
|
|
@@ -28,6 +32,17 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
28
32
|
if (!goal || goal.status !== "active") scheduler.clear();
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
function pauseForProviderLimit(ctx: ExtensionContext, classification: ProviderLimitClassification): void {
|
|
36
|
+
if (!goal || goal.status !== "active" || !classification.pause) return;
|
|
37
|
+
const limited = statusMutation(goal, "usage_limited", realizedTimeUsed(goal), undefined);
|
|
38
|
+
appendGoalMutation(pi, limited);
|
|
39
|
+
goal = applyGoalMutation(goal, limited);
|
|
40
|
+
const suffix = classification.resetHint ? ` (${classification.resetHint})` : classification.retryAfterSeconds != null ? ` (retry after ${classification.retryAfterSeconds}s)` : "";
|
|
41
|
+
ctx.ui.notify(`Goal paused because the provider hit a usage/rate limit${suffix}.`, "warning");
|
|
42
|
+
updateGoalUi(ctx, goal);
|
|
43
|
+
scheduler.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function accountAndEnforceBudget(ctx: ExtensionContext): void {
|
|
32
47
|
if (!goal) return;
|
|
33
48
|
const result = accountUsageFromBranch(goal, ctx.sessionManager.getBranch() as any[]);
|
|
@@ -90,7 +105,22 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
90
105
|
});
|
|
91
106
|
|
|
92
107
|
pi.on("message_end", async (event: any, ctx) => {
|
|
93
|
-
if (event.message?.role
|
|
108
|
+
if (event.message?.role !== "assistant") return;
|
|
109
|
+
const classification = classifyAssistantError(event.message);
|
|
110
|
+
if (classification.pause) {
|
|
111
|
+
consecutiveAssistantErrors = 0;
|
|
112
|
+
pauseForProviderLimit(ctx, classification);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event.message?.stopReason === "error" || event.message?.errorMessage) {
|
|
116
|
+
consecutiveAssistantErrors++;
|
|
117
|
+
if (consecutiveAssistantErrors >= 3 && goal?.status === "active") {
|
|
118
|
+
pauseForProviderLimit(ctx, { kind: "provider_error", pause: true, reason: "repeated assistant provider errors" });
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
consecutiveAssistantErrors = 0;
|
|
123
|
+
accountAndEnforceBudget(ctx);
|
|
94
124
|
});
|
|
95
125
|
|
|
96
126
|
pi.on("turn_end", async (_event, ctx) => {
|
|
@@ -103,13 +133,8 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
103
133
|
});
|
|
104
134
|
|
|
105
135
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
appendGoalMutation(pi, limited);
|
|
109
|
-
goal = applyGoalMutation(goal, limited);
|
|
110
|
-
ctx.ui.notify("Goal paused because the provider returned a usage/rate limit.", "warning");
|
|
111
|
-
updateGoalUi(ctx, goal);
|
|
112
|
-
scheduler.clear();
|
|
136
|
+
if (!goal || goal.status !== "active") return;
|
|
137
|
+
pauseForProviderLimit(ctx, classifyProviderLimit({ status: event.status, headers: event.headers }));
|
|
113
138
|
});
|
|
114
139
|
|
|
115
140
|
pi.on("context", async (event) => {
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extensions/index.js";
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mocito/pi-goal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Persistent long-running goals for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"author": "
|
|
7
|
+
"author": "Jose Mocito",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/jvm/pi-mono.git",
|
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
},
|
|
27
27
|
"pi": {
|
|
28
28
|
"extensions": [
|
|
29
|
-
"./
|
|
29
|
+
"./index.ts"
|
|
30
30
|
]
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
|
+
"index.ts",
|
|
33
34
|
"extensions",
|
|
34
35
|
"src",
|
|
35
36
|
"README.md",
|
|
@@ -43,7 +44,6 @@
|
|
|
43
44
|
"check": "tsc --noEmit",
|
|
44
45
|
"typecheck": "tsc --noEmit",
|
|
45
46
|
"test": "node --import tsx --test tests/*.test.mjs",
|
|
46
|
-
"format": "tsc --noEmit",
|
|
47
47
|
"pack:dry-run": "npm pack --dry-run"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "^0.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
58
|
-
"@earendil-works/pi-tui": "^0.
|
|
56
|
+
"@earendil-works/pi-ai": "^0.78.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
59
59
|
"@types/node": "^25.6.2",
|
|
60
|
-
"typebox": "^1.1.
|
|
61
|
-
"tsx": "^4.
|
|
60
|
+
"typebox": "^1.1.39",
|
|
61
|
+
"tsx": "^4.22.4",
|
|
62
62
|
"typescript": "^6.0.3"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
package/src/accounting.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BranchEntry, GoalMutation, GoalState } from "./types.js";
|
|
2
2
|
import { GOAL_SCHEMA_VERSION } from "./types.js";
|
|
3
|
-
import { nowIso
|
|
3
|
+
import { nowIso } from "./utils.js";
|
|
4
4
|
|
|
5
5
|
export interface UsageAccountingResult {
|
|
6
6
|
mutation?: GoalMutation;
|
|
@@ -34,14 +34,13 @@ export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEnt
|
|
|
34
34
|
addedEntryIds.push(entry.id);
|
|
35
35
|
}
|
|
36
36
|
if (addedTokens === 0) return { goal, addedTokens: 0, addedEntryIds };
|
|
37
|
-
|
|
37
|
+
void endMs;
|
|
38
38
|
const mutation: GoalMutation = {
|
|
39
39
|
schemaVersion: GOAL_SCHEMA_VERSION,
|
|
40
40
|
kind: "account",
|
|
41
41
|
goalId: goal.goalId,
|
|
42
42
|
tokens: addedTokens,
|
|
43
43
|
entryIds: addedEntryIds,
|
|
44
|
-
timeUsedSeconds,
|
|
45
44
|
at: nowIso(),
|
|
46
45
|
};
|
|
47
46
|
return {
|
|
@@ -51,7 +50,6 @@ export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEnt
|
|
|
51
50
|
goal: {
|
|
52
51
|
...goal,
|
|
53
52
|
tokensUsed: goal.tokensUsed + addedTokens,
|
|
54
|
-
timeUsedSeconds,
|
|
55
53
|
accountedUsage: {
|
|
56
54
|
tokens: goal.tokensUsed + addedTokens,
|
|
57
55
|
entryIds: [...goal.accountedUsage.entryIds, ...addedEntryIds],
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export type ProviderLimitKind = "none" | "rate_limit" | "usage_limit" | "quota_exceeded" | "billing_required" | "auth_required" | "provider_error";
|
|
2
|
+
|
|
3
|
+
export interface ProviderLimitInput {
|
|
4
|
+
status?: number;
|
|
5
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
6
|
+
errorMessage?: string;
|
|
7
|
+
provider?: string;
|
|
8
|
+
model?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProviderLimitClassification {
|
|
12
|
+
kind: ProviderLimitKind;
|
|
13
|
+
pause: boolean;
|
|
14
|
+
reason?: string;
|
|
15
|
+
retryAfterSeconds?: number;
|
|
16
|
+
resetHint?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const USAGE_LIMIT_PATTERNS = [
|
|
20
|
+
/GoUsageLimitError/i,
|
|
21
|
+
/\busage limit (?:reached|exceeded)/i,
|
|
22
|
+
/\b\d+\s*-?\s*hour usage limit/i,
|
|
23
|
+
/\bdaily limit\b/i,
|
|
24
|
+
/\bmonthly limit\b/i,
|
|
25
|
+
/\bweekly limit\b/i,
|
|
26
|
+
/\btoken plan\b/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const QUOTA_PATTERNS = [
|
|
30
|
+
/insufficient[_\s-]?quota/i,
|
|
31
|
+
/quota (?:exceeded|reached)/i,
|
|
32
|
+
/exceeded (?:your )?quota/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const BILLING_PATTERNS = [
|
|
36
|
+
/billing/i,
|
|
37
|
+
/available balance/i,
|
|
38
|
+
/enable usage from your available balance/i,
|
|
39
|
+
/payment required/i,
|
|
40
|
+
/credits? exhausted/i,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const AUTH_PATTERNS = [
|
|
44
|
+
/invalid api key/i,
|
|
45
|
+
/unauthorized/i,
|
|
46
|
+
/forbidden/i,
|
|
47
|
+
/authentication/i,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function classifyProviderLimit(input: ProviderLimitInput): ProviderLimitClassification {
|
|
51
|
+
const status = input.status;
|
|
52
|
+
const text = extractSearchableText(input);
|
|
53
|
+
const retryAfterSeconds = parseRetryAfter(input.headers);
|
|
54
|
+
const resetHint = extractResetHint(text);
|
|
55
|
+
|
|
56
|
+
if (matchesAny(text, USAGE_LIMIT_PATTERNS)) return { kind: "usage_limit", pause: true, reason: "provider usage limit reached", retryAfterSeconds, resetHint };
|
|
57
|
+
if (matchesAny(text, QUOTA_PATTERNS)) return { kind: "quota_exceeded", pause: true, reason: "provider quota exceeded", retryAfterSeconds, resetHint };
|
|
58
|
+
if (matchesAny(text, BILLING_PATTERNS)) return { kind: "billing_required", pause: true, reason: "provider billing or balance limit", retryAfterSeconds, resetHint };
|
|
59
|
+
if ((status === 401 || status === 403) && matchesAny(text, AUTH_PATTERNS)) return { kind: "auth_required", pause: true, reason: "provider authentication failed", retryAfterSeconds, resetHint };
|
|
60
|
+
if (status === 402) return { kind: "billing_required", pause: true, reason: "provider payment required", retryAfterSeconds, resetHint };
|
|
61
|
+
if (status === 429) return { kind: "rate_limit", pause: true, reason: "provider returned HTTP 429", retryAfterSeconds, resetHint };
|
|
62
|
+
if (status && status >= 500) return { kind: "provider_error", pause: false, reason: `provider returned HTTP ${status}`, retryAfterSeconds, resetHint };
|
|
63
|
+
if (/\b429\b/.test(text) && /rate limit|too many requests|limit/i.test(text)) return { kind: "rate_limit", pause: true, reason: "provider returned a rate limit error", retryAfterSeconds, resetHint };
|
|
64
|
+
return { kind: "none", pause: false, retryAfterSeconds, resetHint };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function classifyAssistantError(message: any): ProviderLimitClassification {
|
|
68
|
+
if (!message || message.role !== "assistant") return { kind: "none", pause: false };
|
|
69
|
+
const raw = typeof message.errorMessage === "string" ? message.errorMessage : "";
|
|
70
|
+
if (!raw && message.stopReason !== "error") return { kind: "none", pause: false };
|
|
71
|
+
return classifyProviderLimit({
|
|
72
|
+
status: extractStatus(raw),
|
|
73
|
+
errorMessage: raw || String(message.stopReason ?? ""),
|
|
74
|
+
provider: message.provider,
|
|
75
|
+
model: message.model,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractSearchableText(input: ProviderLimitInput): string {
|
|
80
|
+
const parts = [input.errorMessage ?? "", input.provider ?? "", input.model ?? ""];
|
|
81
|
+
if (input.headers) parts.push(JSON.stringify(input.headers));
|
|
82
|
+
try {
|
|
83
|
+
const match = (input.errorMessage ?? "").match(/\{[\s\S]*\}/);
|
|
84
|
+
if (match) parts.push(JSON.stringify(JSON.parse(match[0])));
|
|
85
|
+
} catch {
|
|
86
|
+
// Keep the raw error text if provider payload is not valid JSON.
|
|
87
|
+
}
|
|
88
|
+
return parts.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function matchesAny(text: string, patterns: RegExp[]): boolean {
|
|
92
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractStatus(errorMessage: string): number | undefined {
|
|
96
|
+
const match = errorMessage.match(/^\s*(\d{3})\b/);
|
|
97
|
+
if (!match) return undefined;
|
|
98
|
+
const status = Number(match[1]);
|
|
99
|
+
return Number.isFinite(status) ? status : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseRetryAfter(headers: ProviderLimitInput["headers"]): number | undefined {
|
|
103
|
+
const value = headerValue(headers, "retry-after");
|
|
104
|
+
if (!value) return undefined;
|
|
105
|
+
const seconds = Number(value);
|
|
106
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.floor(seconds);
|
|
107
|
+
const date = Date.parse(value);
|
|
108
|
+
if (!Number.isFinite(date)) return undefined;
|
|
109
|
+
return Math.max(0, Math.ceil((date - Date.now()) / 1000));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function headerValue(headers: ProviderLimitInput["headers"], name: string): string | undefined {
|
|
113
|
+
if (!headers) return undefined;
|
|
114
|
+
const found = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
|
|
115
|
+
const value = found?.[1];
|
|
116
|
+
return Array.isArray(value) ? value[0] : value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractResetHint(text: string): string | undefined {
|
|
120
|
+
const resets = text.match(/resets?\s+in\s+([^".\n]+)/i);
|
|
121
|
+
if (resets) return `resets in ${resets[1].trim()}`;
|
|
122
|
+
const retry = text.match(/try again\s+in\s+([^".\n]+)/i);
|
|
123
|
+
if (retry) return `try again in ${retry[1].trim()}`;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function applyGoalMutation(current: GoalState | null, mutation: GoalMutat
|
|
|
63
63
|
case "account": {
|
|
64
64
|
const ids = [...new Set([...current.accountedUsage.entryIds, ...mutation.entryIds])];
|
|
65
65
|
const tokens = current.tokensUsed + mutation.tokens;
|
|
66
|
-
return { ...current, tokensUsed: tokens,
|
|
66
|
+
return { ...current, tokensUsed: tokens, accountedUsage: { tokens, entryIds: ids }, updatedAt: mutation.at };
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
}
|