@czottmann/pi-automode 1.3.0 → 1.5.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/README.md
CHANGED
|
@@ -141,6 +141,12 @@ GitHub Actions publishes the package to npm when a GitHub Release is published.
|
|
|
141
141
|
|
|
142
142
|
The workflow uses npm Trusted Publishing, so it does not need an npm token secret. Configure this package on npm with this repository and workflow file (`.github/workflows/publish.yml`). The workflow builds the package, runs `npm run check`, and publishes with npm provenance.
|
|
143
143
|
|
|
144
|
+
### Release tag must point at the version bump
|
|
145
|
+
|
|
146
|
+
The publish workflow checks out the commit the release tag points at and compares `package.json` against the tag name. **The tag must point at a commit where `package.json` already carries the new version.** Concretely: commit the version bump (`chore: release X.Y.Z`), push `main`, then create the GitHub release targeting that pushed commit. Creating the release before pushing the version bump — or targeting a commit that still has the old version — fails the `Check release tag` step with `Release tag (vX.Y.Z) does not match package.json version (x.y.z)`.
|
|
147
|
+
|
|
148
|
+
If the tag was cut against the wrong commit, fix it by force-moving it to the version-bump commit and pushing, then trigger the workflow via `gh workflow run publish.yml --ref vX.Y.Z` (the `release` event fires on tag creation; re-running a failed `release`-triggered run reuses the original ref and won't pick up the moved tag).
|
|
149
|
+
|
|
144
150
|
## Author
|
|
145
151
|
|
|
146
152
|
Carlo Zottmann, <carlo@zottmann.dev>
|
|
@@ -55,6 +55,24 @@ async function resolveClassifier(
|
|
|
55
55
|
return { model, apiKey: auth.apiKey, headers: auth.headers };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type ClassifierCompletionFn = (
|
|
59
|
+
model: Model<any>,
|
|
60
|
+
options: { systemPrompt: string; messages: UserMessage[] },
|
|
61
|
+
callOptions: {
|
|
62
|
+
apiKey?: string;
|
|
63
|
+
headers?: Record<string, string>;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
maxTokens: number;
|
|
66
|
+
temperature: number;
|
|
67
|
+
},
|
|
68
|
+
) => Promise<AssistantMessage>;
|
|
69
|
+
|
|
70
|
+
export type RetryOptions = {
|
|
71
|
+
maxAttempts?: number;
|
|
72
|
+
maxTokens?: number;
|
|
73
|
+
temperature?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
58
76
|
/** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
|
|
59
77
|
export function parseClassifierDecision(
|
|
60
78
|
message: AssistantMessage,
|
|
@@ -90,6 +108,66 @@ export function parseClassifierDecision(
|
|
|
90
108
|
return undefined;
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Call the classifier model and parse its decision, retrying when the model
|
|
113
|
+
* returns malformed or truncated output. Small classifier models occasionally
|
|
114
|
+
* ramble into the token cap (stopReason "length") or emit prose instead of
|
|
115
|
+
* JSON; a single retry recovers most of these transient failures.
|
|
116
|
+
*
|
|
117
|
+
* Safety is preserved: an "allow" is only returned when a response actually
|
|
118
|
+
* parses to a valid allow decision. If every attempt fails to parse, the
|
|
119
|
+
* function fails closed with a block decision. Thrown errors (network/auth)
|
|
120
|
+
* are not retried — they fail closed immediately, matching prior behavior.
|
|
121
|
+
*/
|
|
122
|
+
export async function classifyWithRetry(
|
|
123
|
+
completeFn: ClassifierCompletionFn,
|
|
124
|
+
classifier: {
|
|
125
|
+
model: Model<any>;
|
|
126
|
+
apiKey?: string;
|
|
127
|
+
headers?: Record<string, string>;
|
|
128
|
+
},
|
|
129
|
+
prompt: { systemPrompt: string; messages: UserMessage[] },
|
|
130
|
+
signal: AbortSignal | undefined,
|
|
131
|
+
options: RetryOptions = {},
|
|
132
|
+
): Promise<ClassificationDecision> {
|
|
133
|
+
const maxAttempts = options.maxAttempts ?? 2;
|
|
134
|
+
const maxTokens = options.maxTokens ?? 1200;
|
|
135
|
+
const temperature = options.temperature ?? 0;
|
|
136
|
+
let lastReason =
|
|
137
|
+
"Classifier response was not valid decision JSON; auto mode fails closed.";
|
|
138
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
139
|
+
let response: AssistantMessage;
|
|
140
|
+
try {
|
|
141
|
+
response = await completeFn(
|
|
142
|
+
classifier.model,
|
|
143
|
+
prompt,
|
|
144
|
+
{
|
|
145
|
+
apiKey: classifier.apiKey,
|
|
146
|
+
headers: classifier.headers,
|
|
147
|
+
signal,
|
|
148
|
+
maxTokens,
|
|
149
|
+
temperature,
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
decision: "block",
|
|
155
|
+
tier: "none",
|
|
156
|
+
reason: `Classifier failed; auto mode fails closed: ${
|
|
157
|
+
error instanceof Error ? error.message : String(error)
|
|
158
|
+
}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const decision = parseClassifierDecision(response);
|
|
162
|
+
if (decision) return decision;
|
|
163
|
+
lastReason =
|
|
164
|
+
response.stopReason === "length"
|
|
165
|
+
? "Classifier response was truncated before producing valid decision JSON; auto mode fails closed."
|
|
166
|
+
: "Classifier response was not valid decision JSON; auto mode fails closed.";
|
|
167
|
+
}
|
|
168
|
+
return { decision: "block", tier: "none", reason: lastReason };
|
|
169
|
+
}
|
|
170
|
+
|
|
93
171
|
export const defaultClassifyAction: ClassifyAction = async (
|
|
94
172
|
ctx,
|
|
95
173
|
config,
|
|
@@ -120,33 +198,10 @@ export const defaultClassifyAction: ClassifyAction = async (
|
|
|
120
198
|
timestamp: Date.now(),
|
|
121
199
|
};
|
|
122
200
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
headers: classifier.headers,
|
|
130
|
-
signal: ctx.signal,
|
|
131
|
-
maxTokens: 700,
|
|
132
|
-
temperature: 0,
|
|
133
|
-
},
|
|
134
|
-
);
|
|
135
|
-
return (
|
|
136
|
-
parseClassifierDecision(response) ?? {
|
|
137
|
-
decision: "block",
|
|
138
|
-
tier: "none",
|
|
139
|
-
reason:
|
|
140
|
-
"Classifier response was not valid decision JSON; auto mode fails closed.",
|
|
141
|
-
}
|
|
142
|
-
);
|
|
143
|
-
} catch (error) {
|
|
144
|
-
return {
|
|
145
|
-
decision: "block",
|
|
146
|
-
tier: "none",
|
|
147
|
-
reason: `Classifier failed; auto mode fails closed: ${
|
|
148
|
-
error instanceof Error ? error.message : String(error)
|
|
149
|
-
}`,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
201
|
+
return classifyWithRetry(
|
|
202
|
+
complete,
|
|
203
|
+
classifier,
|
|
204
|
+
{ systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
|
|
205
|
+
ctx.signal,
|
|
206
|
+
);
|
|
152
207
|
};
|
|
@@ -69,6 +69,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
69
69
|
let state: AutoModeState = {
|
|
70
70
|
checkedActions: 0,
|
|
71
71
|
blockedActions: 0,
|
|
72
|
+
classifierChecks: 0,
|
|
72
73
|
recentDenials: [],
|
|
73
74
|
};
|
|
74
75
|
let loadedContext = "";
|
|
@@ -215,6 +216,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
215
216
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
216
217
|
const path = resolveInputPath(ctx.cwd, input.path);
|
|
217
218
|
if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
|
|
219
|
+
state.classifierChecks += 1;
|
|
218
220
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
219
221
|
if (decision.decision === "allow") {
|
|
220
222
|
state.lastDecision = "allow";
|
|
@@ -233,6 +235,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
237
|
|
|
238
|
+
state.classifierChecks += 1;
|
|
236
239
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
237
240
|
if (decision.decision === "allow") {
|
|
238
241
|
state.lastDecision = "allow";
|
|
@@ -295,6 +298,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
295
298
|
state = {
|
|
296
299
|
checkedActions: 0,
|
|
297
300
|
blockedActions: 0,
|
|
301
|
+
classifierChecks: 0,
|
|
298
302
|
recentDenials: [],
|
|
299
303
|
enabledOverride: state.enabledOverride,
|
|
300
304
|
};
|
|
@@ -15,15 +15,12 @@ export function statusLine(
|
|
|
15
15
|
state: AutoModeState,
|
|
16
16
|
): string {
|
|
17
17
|
const enabled = state.enabledOverride ?? config.enabled;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (last) line += ` · last: ${truncateMiddle(last.reason, 60)}`;
|
|
25
|
-
}
|
|
26
|
-
return line;
|
|
18
|
+
const circle = enabled ? "●" : "○";
|
|
19
|
+
const allowed = state.checkedActions - state.blockedActions;
|
|
20
|
+
const classifier = state.classifierChecks > 0
|
|
21
|
+
? ` c:${state.classifierChecks}`
|
|
22
|
+
: "";
|
|
23
|
+
return `AM${circle} a:${allowed} d:${state.blockedActions}${classifier}`;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export function statusText(
|
|
@@ -35,6 +32,7 @@ export function statusText(
|
|
|
35
32
|
`classifier: ${config.classifierModel ?? "current session model"}`,
|
|
36
33
|
`checked actions: ${state.checkedActions}`,
|
|
37
34
|
`blocked actions: ${state.blockedActions}`,
|
|
35
|
+
`classifier checks: ${state.classifierChecks}`,
|
|
38
36
|
`permissions.deny rules: ${config.permissionDeny.length}`,
|
|
39
37
|
`permissions.ask rules: ${config.permissionAsk.length}`,
|
|
40
38
|
`environment entries: ${config.environment.length}`,
|
|
@@ -90,10 +88,11 @@ export function restoreState(ctx: ExtensionContext): AutoModeState {
|
|
|
90
88
|
lastReason: entry.data.lastReason,
|
|
91
89
|
checkedActions: entry.data.checkedActions ?? 0,
|
|
92
90
|
blockedActions: entry.data.blockedActions ?? 0,
|
|
91
|
+
classifierChecks: entry.data.classifierChecks ?? 0,
|
|
93
92
|
recentDenials: Array.isArray(entry.data.recentDenials)
|
|
94
93
|
? entry.data.recentDenials.slice(-DENIAL_HISTORY_LIMIT)
|
|
95
94
|
: [],
|
|
96
95
|
};
|
|
97
96
|
}
|
|
98
|
-
return { checkedActions: 0, blockedActions: 0, recentDenials: [] };
|
|
97
|
+
return { checkedActions: 0, blockedActions: 0, classifierChecks: 0, recentDenials: [] };
|
|
99
98
|
}
|