@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,6 +1,23 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { safeJson, truncateMiddle } from "./utils.ts";
3
3
 
4
+ const MAX_USER_ENTRY_TOKENS = 1000;
5
+ const MAX_TOOL_ENTRY_TOKENS = 1000;
6
+ const MAX_RECENT_TOOL_ENTRIES = 40;
7
+ const CHARS_PER_APPROX_TOKEN = 4;
8
+
9
+ type TranscriptEntry = {
10
+ index: number;
11
+ order: number;
12
+ kind: "user" | "tool";
13
+ text: string;
14
+ };
15
+
16
+ export type ClassifierTranscriptBudgets = {
17
+ maxUserTokens: number;
18
+ maxToolTokens: number;
19
+ };
20
+
4
21
  function flattenUserContent(content: unknown): string {
5
22
  if (typeof content === "string") return content;
6
23
  if (!Array.isArray(content)) return "";
@@ -14,19 +31,10 @@ function flattenUserContent(content: unknown): string {
14
31
  .join("\n");
15
32
  }
16
33
 
17
- function flattenAssistantText(content: unknown): string {
18
- if (!Array.isArray(content)) return "";
19
- return content
20
- .filter(
21
- (block): block is { type: string; text?: string } =>
22
- !!block && typeof block === "object" && "type" in block,
23
- )
24
- .filter((block) => block.type === "text" && typeof block.text === "string")
25
- .map((block) => block.text ?? "")
26
- .join("\n");
27
- }
28
-
29
- function collectAssistantToolCalls(content: unknown): string[] {
34
+ function collectAssistantToolCalls(content: unknown): Array<{
35
+ name: string;
36
+ input: unknown;
37
+ }> {
30
38
  if (!Array.isArray(content)) return [];
31
39
  return content
32
40
  .filter(
@@ -40,34 +48,174 @@ function collectAssistantToolCalls(content: unknown): string[] {
40
48
  } => !!block && typeof block === "object" && "type" in block,
41
49
  )
42
50
  .filter((block) => block.type === "toolCall" || block.type === "tool_use")
43
- .map(
44
- (block) =>
45
- `${String(block.name ?? "tool")} ${
46
- safeJson("arguments" in block ? block.arguments : block.input, 1200)
47
- }`,
48
- );
51
+ .map((block) => ({
52
+ name: String(block.name ?? "tool"),
53
+ input: "arguments" in block ? block.arguments : block.input,
54
+ }));
49
55
  }
50
56
 
51
- export function buildTranscript(
52
- ctx: ExtensionContext,
53
- maxLines: number,
54
- ): string {
55
- const lines: string[] = [];
56
- for (const entry of ctx.sessionManager.getBranch()) {
57
+ export function approximateTokenCount(text: string): number {
58
+ return Math.ceil(text.length / CHARS_PER_APPROX_TOKEN);
59
+ }
60
+
61
+ function truncateToTokenCap(
62
+ text: string,
63
+ maxTokens: number,
64
+ ): { text: string; truncated: boolean } {
65
+ if (approximateTokenCount(text) <= maxTokens) {
66
+ return { text, truncated: false };
67
+ }
68
+ const maxCharacters = Math.max(1, maxTokens * CHARS_PER_APPROX_TOKEN);
69
+ const omittedTokens = Math.max(
70
+ 1,
71
+ approximateTokenCount(text) - maxTokens,
72
+ );
73
+ const marker = `<truncated approx_tokens="${omittedTokens}" />`;
74
+ if (marker.length >= maxCharacters) {
75
+ return { text: marker.slice(0, maxCharacters), truncated: true };
76
+ }
77
+
78
+ const retainedCharacters = maxCharacters - marker.length;
79
+ const prefixCharacters = Math.ceil(retainedCharacters * 0.65);
80
+ const suffixCharacters = retainedCharacters - prefixCharacters;
81
+ return {
82
+ text: `${text.slice(0, prefixCharacters)}${marker}${
83
+ suffixCharacters > 0 ? text.slice(-suffixCharacters) : ""
84
+ }`,
85
+ truncated: true,
86
+ };
87
+ }
88
+
89
+ function collectTranscriptEntries(ctx: ExtensionContext): TranscriptEntry[] {
90
+ const entries: TranscriptEntry[] = [];
91
+ const sessionManager = ctx.sessionManager as typeof ctx.sessionManager & {
92
+ buildContextEntries?: () => ReturnType<typeof ctx.sessionManager.getBranch>;
93
+ };
94
+ const contextEntries = sessionManager.buildContextEntries?.() ??
95
+ sessionManager.getBranch();
96
+
97
+ for (const [index, entry] of contextEntries.entries()) {
57
98
  if (entry.type !== "message") continue;
58
99
  const message = entry.message as { role?: string; content?: unknown };
59
100
  if (message.role === "user") {
60
101
  const text = flattenUserContent(message.content).trim();
61
- if (text) lines.push(`User: ${truncateMiddle(text, 2000)}`);
62
- } else if (message.role === "assistant") {
63
- const text = flattenAssistantText(message.content).trim();
64
- if (text) lines.push(`Assistant: ${truncateMiddle(text, 2000)}`);
65
- for (const toolCall of collectAssistantToolCalls(message.content)) {
66
- lines.push(`AssistantAction: ${toolCall}`);
67
- }
102
+ if (text) entries.push({ index, order: 0, kind: "user", text });
103
+ continue;
68
104
  }
105
+ if (message.role !== "assistant") continue;
106
+
107
+ for (const [order, toolCall] of collectAssistantToolCalls(
108
+ message.content,
109
+ ).entries()) {
110
+ entries.push({
111
+ index,
112
+ order,
113
+ kind: "tool",
114
+ text: `${toolCall.name}: ${safeJson(toolCall.input, 8000)}`,
115
+ });
116
+ }
117
+ }
118
+
119
+ return entries;
120
+ }
121
+
122
+ function selectUserEntries(
123
+ entries: TranscriptEntry[],
124
+ maxTokens: number,
125
+ ): { selected: TranscriptEntry[]; omitted: boolean } {
126
+ const users = entries.filter((entry) => entry.kind === "user");
127
+ if (users.length === 0) return { selected: [], omitted: false };
128
+
129
+ const distinctAnchors = users.length > 1;
130
+ const anchorBudget = distinctAnchors
131
+ ? Math.max(1, Math.floor(maxTokens / 2))
132
+ : maxTokens;
133
+ const entryCap = Math.min(MAX_USER_ENTRY_TOKENS, anchorBudget);
134
+ const rendered = users.map((entry) => {
135
+ const truncated = truncateToTokenCap(`User: ${entry.text}`, entryCap);
136
+ return { ...entry, text: truncated.text, truncated: truncated.truncated };
137
+ });
138
+ const selectedIndices = new Set<number>();
139
+ let usedTokens = 0;
140
+
141
+ const include = (index: number): void => {
142
+ if (selectedIndices.has(index)) return;
143
+ const entry = rendered[index];
144
+ if (!entry) return;
145
+ const tokens = approximateTokenCount(entry.text);
146
+ if (usedTokens + tokens > maxTokens) return;
147
+ selectedIndices.add(index);
148
+ usedTokens += tokens;
149
+ };
150
+
151
+ // Prefer the latest user instruction when an extremely small configured
152
+ // budget cannot retain both intent anchors.
153
+ include(rendered.length - 1);
154
+ include(0);
155
+ for (let index = rendered.length - 2; index > 0; index -= 1) {
156
+ include(index);
157
+ }
158
+
159
+ return {
160
+ selected: rendered.filter((_entry, index) => selectedIndices.has(index)),
161
+ omitted: selectedIndices.size < users.length || rendered.some((entry) =>
162
+ entry.truncated
163
+ ),
164
+ };
165
+ }
166
+
167
+ function selectToolEntries(
168
+ entries: TranscriptEntry[],
169
+ maxTokens: number,
170
+ ): { selected: TranscriptEntry[]; omitted: boolean } {
171
+ const tools = entries.filter((entry) => entry.kind === "tool");
172
+ const selected: TranscriptEntry[] = [];
173
+ let usedTokens = 0;
174
+
175
+ for (let index = tools.length - 1; index >= 0; index -= 1) {
176
+ if (selected.length >= MAX_RECENT_TOOL_ENTRIES) break;
177
+ const entry = tools[index];
178
+ if (!entry) continue;
179
+ const truncated = truncateToTokenCap(
180
+ `ToolCall ${entry.text}`,
181
+ Math.min(MAX_TOOL_ENTRY_TOKENS, maxTokens),
182
+ );
183
+ const tokens = approximateTokenCount(truncated.text);
184
+ if (usedTokens + tokens > maxTokens) continue;
185
+ selected.push({ ...entry, text: truncated.text });
186
+ usedTokens += tokens;
187
+ }
188
+
189
+ selected.reverse();
190
+ return {
191
+ selected,
192
+ omitted: selected.length < tools.length || tools.some((entry) =>
193
+ approximateTokenCount(`ToolCall ${entry.text}`) >
194
+ Math.min(MAX_TOOL_ENTRY_TOKENS, maxTokens)
195
+ ),
196
+ };
197
+ }
198
+
199
+ /** Build classifier evidence from user text and assistant tool-call payloads only. */
200
+ export function buildClassifierTranscript(
201
+ ctx: ExtensionContext,
202
+ budgets: ClassifierTranscriptBudgets,
203
+ ): string {
204
+ const entries = collectTranscriptEntries(ctx);
205
+ const users = selectUserEntries(entries, budgets.maxUserTokens);
206
+ const tools = selectToolEntries(entries, budgets.maxToolTokens);
207
+ const selected = [...users.selected, ...tools.selected].sort(
208
+ (left, right) => left.index - right.index || left.order - right.order,
209
+ );
210
+ if (users.omitted || tools.omitted) {
211
+ selected.push({
212
+ index: Number.MAX_SAFE_INTEGER,
213
+ order: 0,
214
+ kind: "tool",
215
+ text: "<transcript_entries_omitted />",
216
+ });
69
217
  }
70
- return lines.slice(-maxLines).join("\n");
218
+ return selected.map((entry) => entry.text).join("\n");
71
219
  }
72
220
 
73
221
  export function loadedContextFromSystemPromptOptions(options: unknown): string {
@@ -1,9 +1,18 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
 
4
+ /** Observability log configuration. Off by default. */
5
+ export type LogConfig = {
6
+ enabled: boolean;
7
+ /** When true, also log classifier prompt/response payloads. */
8
+ classifierIo: boolean;
9
+ };
10
+
3
11
  export type AutoModeSettings = {
4
12
  enabled?: boolean;
5
13
  classifierModel?: string;
6
- maxTranscriptLines?: number;
14
+ maxUserTranscriptTokens?: number;
15
+ maxToolTranscriptTokens?: number;
7
16
  environment?: unknown;
8
17
  allow?: unknown;
9
18
  protectedPaths?: unknown;
@@ -11,6 +20,7 @@ export type AutoModeSettings = {
11
20
  softDeny?: unknown;
12
21
  hard_deny?: unknown;
13
22
  hardDeny?: unknown;
23
+ log?: Partial<LogConfig>;
14
24
  };
15
25
 
16
26
  export type SettingsFile = {
@@ -36,7 +46,8 @@ export type ToolPattern = {
36
46
  export type EffectiveConfig = {
37
47
  enabled: boolean;
38
48
  classifierModel?: string;
39
- maxTranscriptLines: number;
49
+ maxUserTranscriptTokens: number;
50
+ maxToolTranscriptTokens: number;
40
51
  environment: string[];
41
52
  allow: string[];
42
53
  protectedPaths: string[];
@@ -44,6 +55,7 @@ export type EffectiveConfig = {
44
55
  hardDeny: string[];
45
56
  permissionDeny: ToolPattern[];
46
57
  permissionAsk: ToolPattern[];
58
+ log: LogConfig;
47
59
  };
48
60
 
49
61
  export type AutoModeState = {
@@ -70,12 +82,48 @@ export type DenialRecord = {
70
82
  | "setup";
71
83
  };
72
84
 
85
+ /** Denial kind plus the read-only fast path, used for decision log entries. */
86
+ export type DecisionKind = DenialRecord["kind"] | "read-only";
87
+
73
88
  export type ClassificationDecision = {
74
89
  decision: "allow" | "block";
75
90
  tier: "hard_deny" | "soft_deny" | "allow" | "explicit_intent" | "none";
76
91
  reason: string;
77
92
  };
78
93
 
94
+ /** One classifier attempt: the raw model response (or error) and parsed decision. */
95
+ export type ClassifierIoAttempt = {
96
+ stage: "fast" | "detailed";
97
+ attempt: number;
98
+ response?: {
99
+ stopReason?: string;
100
+ text: string;
101
+ model: string;
102
+ timestamp: number;
103
+ usage: AssistantMessage["usage"];
104
+ errorMessage?: string;
105
+ };
106
+ parsed?: ClassificationDecision;
107
+ error?: string;
108
+ durationMs: number;
109
+ };
110
+
111
+ /** Full classifier I/O for an action, surfaced for optional observability logging. */
112
+ export type ClassifierIo = {
113
+ model: string;
114
+ prompt: {
115
+ system: string;
116
+ context: string;
117
+ fastInstruction: string;
118
+ detailedInstruction: string;
119
+ };
120
+ attempts: ClassifierIoAttempt[];
121
+ durationMs: number;
122
+ };
123
+
124
+ /** Classification decision plus the I/O that produced it (when available). */
125
+ export type ClassifyResult = ClassificationDecision & { io?: ClassifierIo };
126
+
79
127
  export type SettingsSources = {
80
128
  globalSettings?: SettingsFile[];
81
129
  projectLocalSettings?: SettingsFile[];
@@ -93,4 +141,4 @@ export type ClassifyAction = (
93
141
  config: EffectiveConfig,
94
142
  action: string,
95
143
  loadedContext: string,
96
- ) => Promise<ClassificationDecision>;
144
+ ) => Promise<ClassifyResult>;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The enforcement order is deliberately different from simple "auto reviewer" plugins:
5
5
  * permission deny/ask rules and deterministic hard-deny checks run before any fast-path allow.
6
- * Only then do read-only tools pass, and all remaining tools go through the classifier.
6
+ * Only read-only built-in tools bypass classification; every side-effecting action goes through the classifier.
7
7
  */
8
8
 
9
9
  export * from "./auto-mode/classifier.ts";
@@ -11,6 +11,7 @@ export * from "./auto-mode/config.ts";
11
11
  export * from "./auto-mode/constants.ts";
12
12
  export * from "./auto-mode/extension.ts";
13
13
  export * from "./auto-mode/hard-deny.ts";
14
+ export * from "./auto-mode/log.ts";
14
15
  export * from "./auto-mode/model.ts";
15
16
  export * from "./auto-mode/model-selector.ts";
16
17
  export * from "./auto-mode/paths.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"