@czottmann/pi-automode 1.6.0 → 1.8.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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { complete } from "@earendil-works/pi-ai";
2
3
  import type {
3
4
  AssistantMessage,
@@ -5,12 +6,18 @@ import type {
5
6
  UserMessage,
6
7
  } from "@earendil-works/pi-ai";
7
8
  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";
9
+ import {
10
+ CLASSIFIER_DETAILED_INSTRUCTION,
11
+ CLASSIFIER_FAST_INSTRUCTION,
12
+ CLASSIFIER_SYSTEM_PROMPT,
13
+ } from "./constants.ts";
14
+ import { formatModelSpec, parseModelSpec } from "./model.ts";
15
+ import { buildClassifierTranscript } from "./transcript.ts";
11
16
  import type {
12
17
  ClassificationDecision,
13
18
  ClassifyAction,
19
+ ClassifierIoAttempt,
20
+ ClassifyResult,
14
21
  EffectiveConfig,
15
22
  } from "./types.ts";
16
23
 
@@ -63,7 +70,9 @@ export type ClassifierCompletionFn = (
63
70
  headers?: Record<string, string>;
64
71
  signal?: AbortSignal;
65
72
  maxTokens: number;
66
- temperature: number;
73
+ temperature?: number;
74
+ sessionId?: string;
75
+ cacheRetention?: "none" | "short" | "long";
67
76
  },
68
77
  ) => Promise<AssistantMessage>;
69
78
 
@@ -71,53 +80,144 @@ export type RetryOptions = {
71
80
  maxAttempts?: number;
72
81
  maxTokens?: number;
73
82
  temperature?: number;
83
+ sessionId?: string;
84
+ cacheRetention?: "none" | "short" | "long";
85
+ stage?: "fast" | "detailed";
86
+ /** Receives each attempt's raw response (or error) and parsed decision, for observability logging. */
87
+ onAttempt?: (attempt: ClassifierIoAttempt) => void;
74
88
  };
75
89
 
76
- /** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
77
- export function parseClassifierDecision(
78
- message: AssistantMessage,
79
- ): ClassificationDecision | undefined {
90
+ const FAST_CLASSIFIER_MAX_TOKENS = 4;
91
+
92
+ export type StagedClassifierOptions = {
93
+ sessionId: string;
94
+ onAttempt?: (attempt: ClassifierIoAttempt) => void;
95
+ };
96
+
97
+ /** Concatenate all text blocks of an assistant message into a single string. */
98
+ function extractAssistantText(message: AssistantMessage, trim = true): string {
80
99
  const text = message.content
81
100
  .filter(
82
101
  (block): block is { type: "text"; text: string } => block.type === "text",
83
102
  )
84
103
  .map((block) => block.text)
85
- .join("\n")
86
- .trim();
87
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
88
- const candidates = [fenced, text, text.match(/\{[\s\S]*\}/)?.[0]].filter(
89
- Boolean,
90
- ) as string[];
91
- for (const candidate of candidates) {
92
- try {
93
- const parsed = JSON.parse(candidate) as Partial<ClassificationDecision>;
94
- if (
95
- (parsed.decision === "allow" || parsed.decision === "block") &&
96
- typeof parsed.reason === "string"
97
- ) {
98
- return {
99
- decision: parsed.decision,
100
- tier: parsed.tier ?? "none",
101
- reason: parsed.reason,
102
- };
103
- }
104
- } catch {
105
- // Try next candidate.
104
+ .join("\n");
105
+ return trim ? text.trim() : text;
106
+ }
107
+
108
+ /** Parse the exact detailed-stage JSON contract; any wrapper or shape drift fails closed. */
109
+ export function parseClassifierDecision(
110
+ message: AssistantMessage,
111
+ ): ClassificationDecision | undefined {
112
+ const text = extractAssistantText(message);
113
+ const validTiers = new Set<ClassificationDecision["tier"]>([
114
+ "hard_deny",
115
+ "soft_deny",
116
+ "allow",
117
+ "explicit_intent",
118
+ "none",
119
+ ]);
120
+ try {
121
+ for (const key of ["decision", "tier", "reason"]) {
122
+ const occurrences = text.match(new RegExp(`"${key}"\\s*:`, "g"))?.length ?? 0;
123
+ if (occurrences !== 1) return undefined;
124
+ }
125
+ const parsed = JSON.parse(text) as Record<string, unknown>;
126
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
127
+ return undefined;
128
+ }
129
+ const keys = Object.keys(parsed).sort();
130
+ if (keys.join(",") !== "decision,reason,tier") return undefined;
131
+ if (parsed.decision !== "allow" && parsed.decision !== "block") {
132
+ return undefined;
133
+ }
134
+ if (!validTiers.has(parsed.tier as ClassificationDecision["tier"])) {
135
+ return undefined;
106
136
  }
137
+ const tier = parsed.tier as ClassificationDecision["tier"];
138
+ if (
139
+ (parsed.decision === "allow" &&
140
+ !["allow", "explicit_intent", "none"].includes(tier)) ||
141
+ (parsed.decision === "block" &&
142
+ !["hard_deny", "soft_deny", "none"].includes(tier))
143
+ ) {
144
+ return undefined;
145
+ }
146
+ if (typeof parsed.reason !== "string" || parsed.reason.trim() === "") {
147
+ return undefined;
148
+ }
149
+ return {
150
+ decision: parsed.decision,
151
+ tier,
152
+ reason: parsed.reason,
153
+ };
154
+ } catch {
155
+ return undefined;
107
156
  }
108
- return undefined;
157
+ }
158
+
159
+ function stageMessage(text: string): UserMessage {
160
+ return {
161
+ role: "user",
162
+ content: [{ type: "text", text }],
163
+ timestamp: Date.now(),
164
+ };
165
+ }
166
+
167
+ function responseAttempt(
168
+ stage: "fast" | "detailed",
169
+ attempt: number,
170
+ response: AssistantMessage,
171
+ durationMs: number,
172
+ parsed?: ClassificationDecision,
173
+ trimText = true,
174
+ ): ClassifierIoAttempt {
175
+ return {
176
+ stage,
177
+ attempt,
178
+ response: {
179
+ stopReason: response.stopReason,
180
+ text: extractAssistantText(response, trimText),
181
+ model: response.model,
182
+ timestamp: response.timestamp,
183
+ usage: response.usage,
184
+ ...(response.errorMessage === undefined
185
+ ? {}
186
+ : { errorMessage: response.errorMessage }),
187
+ },
188
+ parsed,
189
+ durationMs,
190
+ };
191
+ }
192
+
193
+ function classifierFailure(
194
+ response: AssistantMessage,
195
+ label: "Classifier" | "Fast classifier",
196
+ retryLength = false,
197
+ ): ClassificationDecision | undefined {
198
+ if (
199
+ response.stopReason === "stop" ||
200
+ (retryLength && response.stopReason === "length")
201
+ ) {
202
+ return undefined;
203
+ }
204
+ const fallback = response.stopReason === "aborted"
205
+ ? "Classifier model request was aborted."
206
+ : response.stopReason === "error"
207
+ ? "Classifier model returned an error response."
208
+ : `${label} response did not stop cleanly (${response.stopReason}).`;
209
+ return {
210
+ decision: "block",
211
+ tier: "none",
212
+ reason: `${label} failed; auto mode fails closed: ${
213
+ response.errorMessage || fallback
214
+ }`,
215
+ };
109
216
  }
110
217
 
111
218
  /**
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.
219
+ * Call the detailed classifier and parse its decision, retrying malformed or
220
+ * truncated output. Provider errors and exhausted retries fail closed.
121
221
  */
122
222
  export async function classifyWithRetry(
123
223
  completeFn: ClassifierCompletionFn,
@@ -132,10 +232,13 @@ export async function classifyWithRetry(
132
232
  ): Promise<ClassificationDecision> {
133
233
  const maxAttempts = options.maxAttempts ?? 2;
134
234
  const maxTokens = options.maxTokens ?? 1200;
135
- const temperature = options.temperature ?? 0;
235
+ const temperature = options.temperature;
236
+ const stage = options.stage ?? "detailed";
237
+ const onAttempt = options.onAttempt;
136
238
  let lastReason =
137
239
  "Classifier response was not valid decision JSON; auto mode fails closed.";
138
240
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
241
+ const started = Date.now();
139
242
  let response: AssistantMessage;
140
243
  try {
141
244
  response = await completeFn(
@@ -146,19 +249,34 @@ export async function classifyWithRetry(
146
249
  headers: classifier.headers,
147
250
  signal,
148
251
  maxTokens,
149
- temperature,
252
+ ...(temperature === undefined ? {} : { temperature }),
253
+ sessionId: options.sessionId,
254
+ cacheRetention: options.cacheRetention,
150
255
  },
151
256
  );
152
257
  } catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ onAttempt?.({
260
+ stage,
261
+ attempt: attempt + 1,
262
+ error: message,
263
+ durationMs: Date.now() - started,
264
+ });
153
265
  return {
154
266
  decision: "block",
155
267
  tier: "none",
156
- reason: `Classifier failed; auto mode fails closed: ${
157
- error instanceof Error ? error.message : String(error)
158
- }`,
268
+ reason: `Classifier failed; auto mode fails closed: ${message}`,
159
269
  };
160
270
  }
161
- const decision = parseClassifierDecision(response);
271
+ const durationMs = Date.now() - started;
272
+ const failure = classifierFailure(response, "Classifier", true);
273
+ const decision = response.stopReason === "stop"
274
+ ? parseClassifierDecision(response)
275
+ : undefined;
276
+ onAttempt?.(
277
+ responseAttempt(stage, attempt + 1, response, durationMs, decision, false),
278
+ );
279
+ if (failure) return failure;
162
280
  if (decision) return decision;
163
281
  lastReason =
164
282
  response.stopReason === "length"
@@ -168,12 +286,118 @@ export async function classifyWithRetry(
168
286
  return { decision: "block", tier: "none", reason: lastReason };
169
287
  }
170
288
 
289
+ /** Run the one-token conservative gate, then detailed review only when requested. */
290
+ export async function classifyInStages(
291
+ completeFn: ClassifierCompletionFn,
292
+ classifier: {
293
+ model: Model<any>;
294
+ apiKey?: string;
295
+ headers?: Record<string, string>;
296
+ },
297
+ prompt: { systemPrompt: string; contextMessage: UserMessage },
298
+ signal: AbortSignal | undefined,
299
+ options: StagedClassifierOptions,
300
+ ): Promise<ClassificationDecision> {
301
+ const fastStarted = Date.now();
302
+ let fastResponse: AssistantMessage;
303
+ try {
304
+ fastResponse = await completeFn(
305
+ classifier.model,
306
+ {
307
+ systemPrompt: prompt.systemPrompt,
308
+ messages: [
309
+ prompt.contextMessage,
310
+ stageMessage(CLASSIFIER_FAST_INSTRUCTION),
311
+ ],
312
+ },
313
+ {
314
+ apiKey: classifier.apiKey,
315
+ headers: classifier.headers,
316
+ signal,
317
+ // Some OpenAI-compatible servers count an initial control token and
318
+ // EOS against max_tokens. Four tokens reliably permit one visible digit.
319
+ maxTokens: FAST_CLASSIFIER_MAX_TOKENS,
320
+ sessionId: options.sessionId,
321
+ cacheRetention: "short",
322
+ },
323
+ );
324
+ } catch (error) {
325
+ const message = error instanceof Error ? error.message : String(error);
326
+ options.onAttempt?.({
327
+ stage: "fast",
328
+ attempt: 1,
329
+ error: message,
330
+ durationMs: Date.now() - fastStarted,
331
+ });
332
+ return {
333
+ decision: "block",
334
+ tier: "none",
335
+ reason: `Fast classifier failed; auto mode fails closed: ${message}`,
336
+ };
337
+ }
338
+
339
+ const fastText = extractAssistantText(fastResponse, false);
340
+ const failure = classifierFailure(fastResponse, "Fast classifier");
341
+ options.onAttempt?.(
342
+ responseAttempt(
343
+ "fast",
344
+ 1,
345
+ fastResponse,
346
+ Date.now() - fastStarted,
347
+ undefined,
348
+ false,
349
+ ),
350
+ );
351
+ if (failure) return failure;
352
+ if (fastText === "0") {
353
+ return {
354
+ decision: "allow",
355
+ tier: "none",
356
+ reason: "Fast classifier found no policy-relevant risk.",
357
+ };
358
+ }
359
+ if (fastText !== "1") {
360
+ return {
361
+ decision: "block",
362
+ tier: "none",
363
+ reason:
364
+ "Fast classifier response was not exactly 0 or 1; auto mode fails closed.",
365
+ };
366
+ }
367
+
368
+ return classifyWithRetry(
369
+ completeFn,
370
+ classifier,
371
+ {
372
+ systemPrompt: prompt.systemPrompt,
373
+ messages: [
374
+ prompt.contextMessage,
375
+ stageMessage(CLASSIFIER_DETAILED_INSTRUCTION),
376
+ ],
377
+ },
378
+ signal,
379
+ {
380
+ stage: "detailed",
381
+ sessionId: options.sessionId,
382
+ cacheRetention: "short",
383
+ onAttempt: options.onAttempt,
384
+ },
385
+ );
386
+ }
387
+
388
+ export function classifierCacheSessionId(ctx: ExtensionContext): string {
389
+ const source = ctx.sessionManager.getSessionId?.() ??
390
+ ctx.sessionManager.getSessionFile?.() ?? ctx.cwd;
391
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 32);
392
+ return `pi-automode-${digest}`;
393
+ }
394
+
171
395
  export const defaultClassifyAction: ClassifyAction = async (
172
396
  ctx,
173
397
  config,
174
398
  action,
175
399
  loadedContext,
176
- ): Promise<ClassificationDecision> => {
400
+ ): Promise<ClassifyResult> => {
177
401
  const classifier = await resolveClassifier(ctx, config);
178
402
  if (!classifier) {
179
403
  return {
@@ -183,25 +407,47 @@ export const defaultClassifyAction: ClassifyAction = async (
183
407
  };
184
408
  }
185
409
 
186
- const userMessage: UserMessage = {
410
+ const systemPrompt = buildClassifierPrompt(config);
411
+ const transcript = buildClassifierTranscript(ctx, {
412
+ maxUserTokens: config.maxUserTranscriptTokens,
413
+ maxToolTokens: config.maxToolTranscriptTokens,
414
+ });
415
+ const contextText = `<loaded-project-instructions>\n${
416
+ loadedContext || "(none)"
417
+ }\n</loaded-project-instructions>\n\n<classifier-transcript>\n${
418
+ transcript || "(none)"
419
+ }\n</classifier-transcript>\n\nLatest action to classify:\n${action}`;
420
+ const contextMessage: UserMessage = {
187
421
  role: "user",
188
- content: [
189
- {
190
- type: "text",
191
- text: `<loaded-project-instructions>\n${
192
- loadedContext || "(none)"
193
- }\n</loaded-project-instructions>\n\n<transcript>\n${
194
- buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
195
- }\n</transcript>\n\nLatest action to classify:\n${action}`,
196
- },
197
- ],
422
+ content: [{ type: "text", text: contextText }],
198
423
  timestamp: Date.now(),
199
424
  };
200
425
 
201
- return classifyWithRetry(
426
+ const attempts: ClassifierIoAttempt[] = [];
427
+ const started = Date.now();
428
+ const decision = await classifyInStages(
202
429
  complete,
203
430
  classifier,
204
- { systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
431
+ { systemPrompt, contextMessage },
205
432
  ctx.signal,
433
+ {
434
+ sessionId: classifierCacheSessionId(ctx),
435
+ onAttempt: (attempt) => attempts.push(attempt),
436
+ },
206
437
  );
438
+
439
+ return {
440
+ ...decision,
441
+ io: {
442
+ model: formatModelSpec(classifier.model),
443
+ prompt: {
444
+ system: systemPrompt,
445
+ context: contextText,
446
+ fastInstruction: CLASSIFIER_FAST_INSTRUCTION,
447
+ detailedInstruction: CLASSIFIER_DETAILED_INSTRUCTION,
448
+ },
449
+ attempts,
450
+ durationMs: Date.now() - started,
451
+ },
452
+ };
207
453
  };
@@ -4,7 +4,9 @@ import {
4
4
  DEFAULT_ALLOW,
5
5
  DEFAULT_ENVIRONMENT,
6
6
  DEFAULT_HARD_DENY,
7
- DEFAULT_MAX_TRANSCRIPT_LINES,
7
+ DEFAULT_LOG_CONFIG,
8
+ DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
9
+ DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
8
10
  DEFAULT_PROTECTED_PATHS,
9
11
  DEFAULT_SOFT_DENY,
10
12
  PI_GLOBAL_SETTINGS,
@@ -17,6 +19,7 @@ import type {
17
19
  ConfigLoadResult,
18
20
  EffectiveConfig,
19
21
  LoadedSettingsFile,
22
+ LogConfig,
20
23
  SettingsFile,
21
24
  SettingsSources,
22
25
  ToolPattern,
@@ -94,7 +97,8 @@ export function validateSettingsFile(
94
97
  const knownAutoMode = new Set([
95
98
  "enabled",
96
99
  "classifierModel",
97
- "maxTranscriptLines",
100
+ "maxUserTranscriptTokens",
101
+ "maxToolTranscriptTokens",
98
102
  "environment",
99
103
  "allow",
100
104
  "protectedPaths",
@@ -102,6 +106,7 @@ export function validateSettingsFile(
102
106
  "softDeny",
103
107
  "hard_deny",
104
108
  "hardDeny",
109
+ "log",
105
110
  ]);
106
111
  for (const key of Object.keys(autoMode)) {
107
112
  if (!knownAutoMode.has(key)) {
@@ -121,14 +126,20 @@ export function validateSettingsFile(
121
126
  `${source}: autoMode.classifierModel must be a provider/model string`,
122
127
  );
123
128
  }
124
- if (
125
- hasOwn(autoMode, "maxTranscriptLines") &&
126
- (!Number.isInteger(autoMode.maxTranscriptLines) ||
127
- Number(autoMode.maxTranscriptLines) <= 0)
129
+ for (
130
+ const key of [
131
+ "maxUserTranscriptTokens",
132
+ "maxToolTranscriptTokens",
133
+ ] as const
128
134
  ) {
129
- diagnostics.push(
130
- `${source}: autoMode.maxTranscriptLines must be a positive integer`,
131
- );
135
+ if (
136
+ hasOwn(autoMode, key) &&
137
+ (!Number.isInteger(autoMode[key]) || Number(autoMode[key]) < 32)
138
+ ) {
139
+ diagnostics.push(
140
+ `${source}: autoMode.${key} must be an integer of at least 32`,
141
+ );
142
+ }
132
143
  }
133
144
  validateStringArraySetting(
134
145
  autoMode.environment,
@@ -160,6 +171,9 @@ export function validateSettingsFile(
160
171
  "autoMode.hard_deny",
161
172
  diagnostics,
162
173
  );
174
+ if (hasOwn(autoMode, "log")) {
175
+ validateLogSetting(autoMode.log, source, diagnostics);
176
+ }
163
177
  }
164
178
  }
165
179
 
@@ -228,6 +242,41 @@ function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
228
242
  return [...new Set([...base, ...accumulator.entries])];
229
243
  }
230
244
 
245
+ function validateLogSetting(
246
+ value: unknown,
247
+ source: string,
248
+ diagnostics: string[],
249
+ ): void {
250
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
251
+ diagnostics.push(`${source}: autoMode.log must be an object`);
252
+ return;
253
+ }
254
+ const log = value as Record<string, unknown>;
255
+ if (hasOwn(log, "enabled") && typeof log.enabled !== "boolean") {
256
+ diagnostics.push(`${source}: autoMode.log.enabled must be a boolean`);
257
+ }
258
+ if (
259
+ hasOwn(log, "classifierIo") && typeof log.classifierIo !== "boolean"
260
+ ) {
261
+ diagnostics.push(`${source}: autoMode.log.classifierIo must be a boolean`);
262
+ }
263
+ }
264
+
265
+ function mergeLog(
266
+ base: LogConfig,
267
+ patch: Partial<LogConfig> | undefined,
268
+ ): LogConfig {
269
+ if (!patch) return base;
270
+ return {
271
+ enabled: patch.enabled ?? base.enabled,
272
+ classifierIo: patch.classifierIo ?? base.classifierIo,
273
+ };
274
+ }
275
+
276
+ function validTranscriptBudget(value: unknown): value is number {
277
+ return Number.isInteger(value) && Number(value) >= 32;
278
+ }
279
+
231
280
  function applyAutoModeScalars(
232
281
  base: EffectiveConfig,
233
282
  settings: AutoModeSettings | undefined,
@@ -237,7 +286,17 @@ function applyAutoModeScalars(
237
286
  ...base,
238
287
  enabled: settings.enabled ?? base.enabled,
239
288
  classifierModel: settings.classifierModel ?? base.classifierModel,
240
- maxTranscriptLines: settings.maxTranscriptLines ?? base.maxTranscriptLines,
289
+ maxUserTranscriptTokens: validTranscriptBudget(
290
+ settings.maxUserTranscriptTokens,
291
+ )
292
+ ? settings.maxUserTranscriptTokens
293
+ : base.maxUserTranscriptTokens,
294
+ maxToolTranscriptTokens: validTranscriptBudget(
295
+ settings.maxToolTranscriptTokens,
296
+ )
297
+ ? settings.maxToolTranscriptTokens
298
+ : base.maxToolTranscriptTokens,
299
+ log: mergeLog(base.log, settings.log),
241
300
  };
242
301
  }
243
302
 
@@ -268,7 +327,8 @@ export function buildEffectiveConfigFromSources(
268
327
  ): EffectiveConfig {
269
328
  let config: EffectiveConfig = {
270
329
  enabled: true,
271
- maxTranscriptLines: DEFAULT_MAX_TRANSCRIPT_LINES,
330
+ maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
331
+ maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
272
332
  environment: [...DEFAULT_ENVIRONMENT],
273
333
  allow: [...DEFAULT_ALLOW],
274
334
  protectedPaths: [...DEFAULT_PROTECTED_PATHS],
@@ -276,6 +336,7 @@ export function buildEffectiveConfigFromSources(
276
336
  hardDeny: [...DEFAULT_HARD_DENY],
277
337
  permissionDeny: [],
278
338
  permissionAsk: [],
339
+ log: { ...DEFAULT_LOG_CONFIG },
279
340
  };
280
341
 
281
342
  const globalSettings = sources.globalSettings ?? [];
@@ -55,7 +55,8 @@ export const DEFAULT_PROTECTED_PATHS = [
55
55
  ".mcp.json",
56
56
  ];
57
57
 
58
- export const DEFAULT_MAX_TRANSCRIPT_LINES = 80;
58
+ export const DEFAULT_MAX_USER_TRANSCRIPT_TOKENS = 4000;
59
+ export const DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS = 4000;
59
60
  export const DENIAL_HISTORY_LIMIT = 12;
60
61
 
61
62
  /** Built-in trusted environment. Users extend this with `$defaults`. */
@@ -115,7 +116,7 @@ Auto mode is active. Routine permission prompts are replaced by a pre-execution
115
116
  export const CLASSIFIER_SYSTEM_PROMPT =
116
117
  `You are an auto-mode security classifier for an autonomous coding agent.
117
118
 
118
- You evaluate the agent's latest tool call before it runs. Return only JSON.
119
+ You evaluate the agent's latest tool call before it runs. Follow the final output instruction exactly.
119
120
 
120
121
  Core semantics:
121
122
  1. HARD_DENY rules block unconditionally. User intent and allow exceptions do not override them.
@@ -144,9 +145,13 @@ Examples:
144
145
  - cat ~/.ssh/id_rsa or echo $API_TOKEN => block, hard_deny, credential exposure.
145
146
  - npm test, swift test, xcodebuild test, or local formatting/linting => allow unless the action includes a blocked side effect.
146
147
  - Uploading repository contents, logs with secrets, or internal data to an untrusted domain => block, hard_deny.
147
- - Modifying .pi/automode*, this extension, or permission rules => block, hard_deny.
148
+ - Modifying .pi/automode*, this extension, or permission rules => block, hard_deny.`;
148
149
 
149
- Respond with JSON exactly matching:
150
+ export const CLASSIFIER_FAST_INSTRUCTION =
151
+ `Return exactly one digit and nothing else: 0 only when the action is clearly allowed by the policy; 1 when it may need blocking or you are uncertain. Err on 1.`;
152
+
153
+ export const CLASSIFIER_DETAILED_INSTRUCTION =
154
+ `Return only JSON exactly matching:
150
155
  {"decision":"allow"|"block","tier":"hard_deny"|"soft_deny"|"allow"|"explicit_intent"|"none","reason":"brief concrete reason"}`;
151
156
 
152
157
  export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/agent/automode.json")];
@@ -166,3 +171,9 @@ export const PROFILE_FILES = new Set([
166
171
  ]);
167
172
 
168
173
  export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
174
+
175
+ /** Default observability log config: off, classifier I/O off. */
176
+ export const DEFAULT_LOG_CONFIG = {
177
+ enabled: false,
178
+ classifierIo: false,
179
+ };