@czottmann/pi-automode 1.1.0 → 1.2.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.
@@ -0,0 +1,152 @@
1
+ import { complete } from "@earendil-works/pi-ai";
2
+ import type {
3
+ AssistantMessage,
4
+ Model,
5
+ UserMessage,
6
+ } from "@earendil-works/pi-ai";
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { CLASSIFIER_SYSTEM_PROMPT } from "./constants.ts";
9
+ import { parseModelSpec } from "./model.ts";
10
+ import { buildTranscript } from "./transcript.ts";
11
+ import type {
12
+ ClassificationDecision,
13
+ ClassifyAction,
14
+ EffectiveConfig,
15
+ } from "./types.ts";
16
+
17
+ export function buildClassifierPrompt(config: EffectiveConfig): string {
18
+ return CLASSIFIER_SYSTEM_PROMPT.replace(
19
+ "<ENVIRONMENT>",
20
+ config.environment.map((line) => `- ${line}`).join("\n"),
21
+ )
22
+ .replace(
23
+ "<ALLOW_RULES>",
24
+ config.allow.map((line) => `- ${line}`).join("\n"),
25
+ )
26
+ .replace(
27
+ "<SOFT_DENY_RULES>",
28
+ config.softDeny.map((line) => `- ${line}`).join("\n"),
29
+ )
30
+ .replace(
31
+ "<HARD_DENY_RULES>",
32
+ config.hardDeny.map((line) => `- ${line}`).join("\n"),
33
+ );
34
+ }
35
+
36
+ async function resolveClassifier(
37
+ ctx: ExtensionContext,
38
+ config: EffectiveConfig,
39
+ ): Promise<
40
+ | { model: Model<any>; apiKey?: string; headers?: Record<string, string> }
41
+ | undefined
42
+ > {
43
+ const configured = config.classifierModel;
44
+ const model = configured
45
+ ? (() => {
46
+ const parsed = parseModelSpec(configured);
47
+ return parsed
48
+ ? ctx.modelRegistry.find(parsed.provider, parsed.id)
49
+ : undefined;
50
+ })()
51
+ : ctx.model;
52
+ if (!model) return undefined;
53
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
54
+ if (!auth.ok) return undefined;
55
+ return { model, apiKey: auth.apiKey, headers: auth.headers };
56
+ }
57
+
58
+ /** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
59
+ export function parseClassifierDecision(
60
+ message: AssistantMessage,
61
+ ): ClassificationDecision | undefined {
62
+ const text = message.content
63
+ .filter(
64
+ (block): block is { type: "text"; text: string } => block.type === "text",
65
+ )
66
+ .map((block) => block.text)
67
+ .join("\n")
68
+ .trim();
69
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
70
+ const candidates = [fenced, text, text.match(/\{[\s\S]*\}/)?.[0]].filter(
71
+ Boolean,
72
+ ) as string[];
73
+ for (const candidate of candidates) {
74
+ try {
75
+ const parsed = JSON.parse(candidate) as Partial<ClassificationDecision>;
76
+ if (
77
+ (parsed.decision === "allow" || parsed.decision === "block") &&
78
+ typeof parsed.reason === "string"
79
+ ) {
80
+ return {
81
+ decision: parsed.decision,
82
+ tier: parsed.tier ?? "none",
83
+ reason: parsed.reason,
84
+ };
85
+ }
86
+ } catch {
87
+ // Try next candidate.
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ export const defaultClassifyAction: ClassifyAction = async (
94
+ ctx,
95
+ config,
96
+ action,
97
+ loadedContext,
98
+ ): Promise<ClassificationDecision> => {
99
+ const classifier = await resolveClassifier(ctx, config);
100
+ if (!classifier) {
101
+ return {
102
+ decision: "block",
103
+ tier: "none",
104
+ reason: "No classifier model/API key available; auto mode fails closed.",
105
+ };
106
+ }
107
+
108
+ const userMessage: UserMessage = {
109
+ role: "user",
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: `<loaded-project-instructions>\n${
114
+ loadedContext || "(none)"
115
+ }\n</loaded-project-instructions>\n\n<transcript>\n${
116
+ buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
117
+ }\n</transcript>\n\nLatest action to classify:\n${action}`,
118
+ },
119
+ ],
120
+ timestamp: Date.now(),
121
+ };
122
+
123
+ try {
124
+ const response = await complete(
125
+ classifier.model,
126
+ { systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
127
+ {
128
+ apiKey: classifier.apiKey,
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
+ }
152
+ };
@@ -0,0 +1,399 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import {
4
+ DEFAULT_ALLOW,
5
+ DEFAULT_ENVIRONMENT,
6
+ DEFAULT_HARD_DENY,
7
+ DEFAULT_MAX_TRANSCRIPT_LINES,
8
+ DEFAULT_PROTECTED_PATHS,
9
+ DEFAULT_SOFT_DENY,
10
+ PI_GLOBAL_SETTINGS,
11
+ PI_PROJECT_LOCAL_SETTINGS,
12
+ PI_PROJECT_SHARED_SETTINGS,
13
+ } from "./constants.ts";
14
+ import { parseToolPattern } from "./permissions.ts";
15
+ import type {
16
+ AutoModeSettings,
17
+ ConfigLoadResult,
18
+ EffectiveConfig,
19
+ LoadedSettingsFile,
20
+ SettingsFile,
21
+ SettingsSources,
22
+ ToolPattern,
23
+ } from "./types.ts";
24
+ import { hasOwn, stringArray } from "./utils.ts";
25
+
26
+ function readSettingsFile(path: string): LoadedSettingsFile | undefined {
27
+ if (!existsSync(path)) return undefined;
28
+ try {
29
+ const settings = JSON.parse(readFileSync(path, "utf8")) as SettingsFile;
30
+ return {
31
+ path,
32
+ settings,
33
+ diagnostics: validateSettingsFile(settings, path),
34
+ };
35
+ } catch (error) {
36
+ return {
37
+ path,
38
+ diagnostics: [
39
+ `${path}: invalid JSON (${
40
+ error instanceof Error ? error.message : String(error)
41
+ })`,
42
+ ],
43
+ };
44
+ }
45
+ }
46
+
47
+ function validateStringArraySetting(
48
+ value: unknown,
49
+ source: string,
50
+ key: string,
51
+ diagnostics: string[],
52
+ ): void {
53
+ if (value === undefined) return;
54
+ if (!Array.isArray(value)) {
55
+ diagnostics.push(`${source}: ${key} must be an array of strings`);
56
+ return;
57
+ }
58
+ for (const [index, entry] of value.entries()) {
59
+ if (typeof entry !== "string" || entry.trim() === "") {
60
+ diagnostics.push(
61
+ `${source}: ${key}[${index}] must be a non-empty string`,
62
+ );
63
+ }
64
+ }
65
+ if (value.length > 0 && !value.includes("$defaults")) {
66
+ diagnostics.push(
67
+ `${source}: ${key} omits "$defaults" and replaces the built-in ${key} rules`,
68
+ );
69
+ }
70
+ }
71
+
72
+ /** Validate config shape and emit human-readable diagnostics for `/automode config`. */
73
+ export function validateSettingsFile(
74
+ settings: SettingsFile,
75
+ source: string,
76
+ ): string[] {
77
+ const diagnostics: string[] = [];
78
+ const root = settings as Record<string, unknown>;
79
+ for (const key of Object.keys(root)) {
80
+ if (key !== "autoMode" && key !== "permissions") {
81
+ diagnostics.push(`${source}: unknown top-level key ${key}`);
82
+ }
83
+ }
84
+
85
+ if (settings.autoMode !== undefined) {
86
+ if (
87
+ !settings.autoMode ||
88
+ typeof settings.autoMode !== "object" ||
89
+ Array.isArray(settings.autoMode)
90
+ ) {
91
+ diagnostics.push(`${source}: autoMode must be an object`);
92
+ } else {
93
+ const autoMode = settings.autoMode as Record<string, unknown>;
94
+ const knownAutoMode = new Set([
95
+ "enabled",
96
+ "classifierModel",
97
+ "maxTranscriptLines",
98
+ "environment",
99
+ "allow",
100
+ "protectedPaths",
101
+ "soft_deny",
102
+ "softDeny",
103
+ "hard_deny",
104
+ "hardDeny",
105
+ ]);
106
+ for (const key of Object.keys(autoMode)) {
107
+ if (!knownAutoMode.has(key)) {
108
+ diagnostics.push(`${source}: unknown autoMode key ${key}`);
109
+ }
110
+ }
111
+ if (
112
+ hasOwn(autoMode, "enabled") && typeof autoMode.enabled !== "boolean"
113
+ ) {
114
+ diagnostics.push(`${source}: autoMode.enabled must be a boolean`);
115
+ }
116
+ if (
117
+ hasOwn(autoMode, "classifierModel") &&
118
+ typeof autoMode.classifierModel !== "string"
119
+ ) {
120
+ diagnostics.push(
121
+ `${source}: autoMode.classifierModel must be a provider/model string`,
122
+ );
123
+ }
124
+ if (
125
+ hasOwn(autoMode, "maxTranscriptLines") &&
126
+ (!Number.isInteger(autoMode.maxTranscriptLines) ||
127
+ Number(autoMode.maxTranscriptLines) <= 0)
128
+ ) {
129
+ diagnostics.push(
130
+ `${source}: autoMode.maxTranscriptLines must be a positive integer`,
131
+ );
132
+ }
133
+ validateStringArraySetting(
134
+ autoMode.environment,
135
+ source,
136
+ "autoMode.environment",
137
+ diagnostics,
138
+ );
139
+ validateStringArraySetting(
140
+ autoMode.allow,
141
+ source,
142
+ "autoMode.allow",
143
+ diagnostics,
144
+ );
145
+ validateStringArraySetting(
146
+ autoMode.protectedPaths,
147
+ source,
148
+ "autoMode.protectedPaths",
149
+ diagnostics,
150
+ );
151
+ validateStringArraySetting(
152
+ autoMode.soft_deny ?? autoMode.softDeny,
153
+ source,
154
+ "autoMode.soft_deny",
155
+ diagnostics,
156
+ );
157
+ validateStringArraySetting(
158
+ autoMode.hard_deny ?? autoMode.hardDeny,
159
+ source,
160
+ "autoMode.hard_deny",
161
+ diagnostics,
162
+ );
163
+ }
164
+ }
165
+
166
+ if (settings.permissions !== undefined) {
167
+ if (
168
+ !settings.permissions ||
169
+ typeof settings.permissions !== "object" ||
170
+ Array.isArray(settings.permissions)
171
+ ) {
172
+ diagnostics.push(`${source}: permissions must be an object`);
173
+ } else {
174
+ const permissions = settings.permissions as Record<string, unknown>;
175
+ for (const key of Object.keys(permissions)) {
176
+ if (key !== "deny" && key !== "ask") {
177
+ diagnostics.push(`${source}: unknown permissions key ${key}`);
178
+ }
179
+ }
180
+ for (const key of ["deny", "ask"] as const) {
181
+ const value = permissions[key];
182
+ if (value === undefined) continue;
183
+ if (!Array.isArray(value)) {
184
+ diagnostics.push(
185
+ `${source}: permissions.${key} must be an array of tool patterns`,
186
+ );
187
+ continue;
188
+ }
189
+ for (const [index, entry] of value.entries()) {
190
+ if (typeof entry !== "string" || !parseToolPattern(entry)) {
191
+ diagnostics.push(
192
+ `${source}: permissions.${key}[${index}] must be a tool pattern string`,
193
+ );
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ return diagnostics;
201
+ }
202
+
203
+ type RuleAccumulator = {
204
+ defaults: string[];
205
+ includeDefaults: boolean;
206
+ seen: boolean;
207
+ entries: string[];
208
+ };
209
+
210
+ function createRuleAccumulator(defaults: string[]): RuleAccumulator {
211
+ return { defaults, includeDefaults: true, seen: false, entries: [] };
212
+ }
213
+
214
+ function applyRuleSetting(accumulator: RuleAccumulator, value: unknown): void {
215
+ const entries = stringArray(value);
216
+ if (!entries) return;
217
+ accumulator.seen = true;
218
+ accumulator.includeDefaults = entries.includes("$defaults");
219
+ for (const entry of entries) {
220
+ if (entry !== "$defaults") accumulator.entries.push(entry);
221
+ }
222
+ }
223
+
224
+ function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
225
+ const base = accumulator.includeDefaults || !accumulator.seen
226
+ ? accumulator.defaults
227
+ : [];
228
+ return [...new Set([...base, ...accumulator.entries])];
229
+ }
230
+
231
+ function applyAutoModeScalars(
232
+ base: EffectiveConfig,
233
+ settings: AutoModeSettings | undefined,
234
+ ): EffectiveConfig {
235
+ if (!settings) return base;
236
+ return {
237
+ ...base,
238
+ enabled: settings.enabled ?? base.enabled,
239
+ classifierModel: settings.classifierModel ?? base.classifierModel,
240
+ maxTranscriptLines: settings.maxTranscriptLines ?? base.maxTranscriptLines,
241
+ };
242
+ }
243
+
244
+ function appendPermissionPatterns(
245
+ target: ToolPattern[],
246
+ settings: SettingsFile | undefined,
247
+ key: "deny" | "ask",
248
+ ): void {
249
+ const values = stringArray(settings?.permissions?.[key]);
250
+ if (!values) return;
251
+ for (const value of values) {
252
+ const pattern = parseToolPattern(value);
253
+ if (pattern) target.push(pattern);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Merge settings with Claude Code-style precedence using Pi-owned config files.
259
+ *
260
+ * Important details:
261
+ * - shared project `.pi/automode.json` contributes `permissions.*` but not `autoMode`,
262
+ * so a checked-in repo cannot weaken classifier rules;
263
+ * - global, project-local, and inline `autoMode` settings combine additively across scopes;
264
+ * - omitting `$defaults` in any scope for a rule list means "replace built-ins" for that list.
265
+ */
266
+ export function buildEffectiveConfigFromSources(
267
+ sources: SettingsSources = {},
268
+ ): EffectiveConfig {
269
+ let config: EffectiveConfig = {
270
+ enabled: true,
271
+ maxTranscriptLines: DEFAULT_MAX_TRANSCRIPT_LINES,
272
+ environment: [...DEFAULT_ENVIRONMENT],
273
+ allow: [...DEFAULT_ALLOW],
274
+ protectedPaths: [...DEFAULT_PROTECTED_PATHS],
275
+ softDeny: [...DEFAULT_SOFT_DENY],
276
+ hardDeny: [...DEFAULT_HARD_DENY],
277
+ permissionDeny: [],
278
+ permissionAsk: [],
279
+ };
280
+
281
+ const globalSettings = sources.globalSettings ?? [];
282
+ const projectLocalSettings = sources.projectLocalSettings ?? [];
283
+ const projectSharedSettings = sources.projectSharedSettings ?? [];
284
+ const inlineSettings = sources.inlineSettings ?? [];
285
+
286
+ const configurableSettings = [
287
+ ...globalSettings,
288
+ ...projectLocalSettings,
289
+ ...inlineSettings,
290
+ ];
291
+ const environment = createRuleAccumulator(DEFAULT_ENVIRONMENT);
292
+ const allow = createRuleAccumulator(DEFAULT_ALLOW);
293
+ const protectedPaths = createRuleAccumulator(DEFAULT_PROTECTED_PATHS);
294
+ const softDeny = createRuleAccumulator(DEFAULT_SOFT_DENY);
295
+ const hardDeny = createRuleAccumulator(DEFAULT_HARD_DENY);
296
+
297
+ for (const settings of configurableSettings) {
298
+ config = applyAutoModeScalars(config, settings.autoMode);
299
+ applyRuleSetting(environment, settings.autoMode?.environment);
300
+ applyRuleSetting(allow, settings.autoMode?.allow);
301
+ applyRuleSetting(protectedPaths, settings.autoMode?.protectedPaths);
302
+ applyRuleSetting(
303
+ softDeny,
304
+ settings.autoMode?.soft_deny ?? settings.autoMode?.softDeny,
305
+ );
306
+ applyRuleSetting(
307
+ hardDeny,
308
+ settings.autoMode?.hard_deny ?? settings.autoMode?.hardDeny,
309
+ );
310
+ }
311
+
312
+ config = {
313
+ ...config,
314
+ environment: finalizeRuleSetting(environment),
315
+ allow: finalizeRuleSetting(allow),
316
+ protectedPaths: finalizeRuleSetting(protectedPaths),
317
+ softDeny: finalizeRuleSetting(softDeny),
318
+ hardDeny: finalizeRuleSetting(hardDeny),
319
+ };
320
+
321
+ for (
322
+ const settings of [
323
+ ...globalSettings,
324
+ ...projectSharedSettings,
325
+ ...projectLocalSettings,
326
+ ...inlineSettings,
327
+ ]
328
+ ) {
329
+ appendPermissionPatterns(config.permissionDeny, settings, "deny");
330
+ appendPermissionPatterns(config.permissionAsk, settings, "ask");
331
+ }
332
+
333
+ return config;
334
+ }
335
+
336
+ function loadedSettingsToSettings(
337
+ files: Array<LoadedSettingsFile | undefined>,
338
+ ): SettingsFile[] {
339
+ return files.flatMap((file) => (file?.settings ? [file.settings] : []));
340
+ }
341
+
342
+ function loadedSettingsDiagnostics(
343
+ files: Array<LoadedSettingsFile | undefined>,
344
+ ): string[] {
345
+ return files.flatMap((file) => file?.diagnostics ?? []);
346
+ }
347
+
348
+ /** Load config from disk and environment variables, including diagnostics for `/automode config`. */
349
+ export function loadEffectiveConfigWithDiagnostics(
350
+ cwd: string,
351
+ ): ConfigLoadResult {
352
+ const inlineSettings: SettingsFile[] = [];
353
+ const diagnostics: string[] = [];
354
+ if (process.env.PI_AUTOMODE_SETTINGS_JSON) {
355
+ try {
356
+ const parsed = JSON.parse(
357
+ process.env.PI_AUTOMODE_SETTINGS_JSON,
358
+ ) as SettingsFile;
359
+ inlineSettings.push(parsed);
360
+ diagnostics.push(
361
+ ...validateSettingsFile(parsed, "PI_AUTOMODE_SETTINGS_JSON"),
362
+ );
363
+ } catch (error) {
364
+ diagnostics.push(
365
+ `PI_AUTOMODE_SETTINGS_JSON: invalid JSON (${
366
+ error instanceof Error ? error.message : String(error)
367
+ })`,
368
+ );
369
+ }
370
+ }
371
+
372
+ const globalFiles = PI_GLOBAL_SETTINGS.map(readSettingsFile);
373
+ const projectLocalFiles = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
374
+ readSettingsFile(resolve(cwd, file))
375
+ );
376
+ const projectSharedFiles = PI_PROJECT_SHARED_SETTINGS.map((file) =>
377
+ readSettingsFile(resolve(cwd, file))
378
+ );
379
+ const fileDiagnostics = loadedSettingsDiagnostics([
380
+ ...globalFiles,
381
+ ...projectLocalFiles,
382
+ ...projectSharedFiles,
383
+ ]);
384
+
385
+ return {
386
+ config: buildEffectiveConfigFromSources({
387
+ globalSettings: loadedSettingsToSettings(globalFiles),
388
+ projectLocalSettings: loadedSettingsToSettings(projectLocalFiles),
389
+ projectSharedSettings: loadedSettingsToSettings(projectSharedFiles),
390
+ inlineSettings,
391
+ }),
392
+ diagnostics: [...fileDiagnostics, ...diagnostics],
393
+ };
394
+ }
395
+
396
+ /** Load config from disk and environment variables. Exported for tests and diagnostics. */
397
+ export function loadEffectiveConfig(cwd: string): EffectiveConfig {
398
+ return loadEffectiveConfigWithDiagnostics(cwd).config;
399
+ }