@czottmann/pi-automode 1.7.0 → 1.8.1

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.
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
3
  import { basename, dirname, extname, join } from "node:path";
4
4
  import type {
5
+ ClassifierIo,
5
6
  ClassifierIoAttempt,
6
7
  ClassificationDecision,
7
8
  DecisionKind,
@@ -28,13 +29,27 @@ export type ClassifierLogEntry = {
28
29
  ts: string;
29
30
  decisionId: string;
30
31
  model: string;
31
- prompt: { system: string; user: string };
32
+ prompt: ClassifierIo["prompt"];
32
33
  attempts: ClassifierIoAttempt[];
33
34
  durationMs: number;
34
35
  parsed: ClassificationDecision;
35
36
  };
36
37
 
37
- export type LogEntry = DecisionLogEntry | ClassifierLogEntry;
38
+ /** A ccusage-compatible record for one classifier model response. */
39
+ export type ClassifierUsageLogEntry = {
40
+ type: "message";
41
+ timestamp: string;
42
+ message: {
43
+ role: "assistant";
44
+ model: string;
45
+ usage: NonNullable<ClassifierIoAttempt["response"]>["usage"];
46
+ };
47
+ };
48
+
49
+ export type LogEntry =
50
+ | DecisionLogEntry
51
+ | ClassifierLogEntry
52
+ | ClassifierUsageLogEntry;
38
53
 
39
54
  export type Logger = {
40
55
  enabled: boolean;
@@ -1,9 +1,8 @@
1
- import { realpathSync } from "node:fs";
1
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import {
3
3
  basename,
4
4
  dirname,
5
5
  isAbsolute,
6
- join,
7
6
  normalize,
8
7
  relative,
9
8
  resolve,
@@ -34,53 +33,85 @@ export function isInside(child: string, parent: string): boolean {
34
33
  return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
35
34
  }
36
35
 
37
- export function isProtectedPath(
36
+ /** Resolve symlinks through the nearest existing ancestor of a path. */
37
+ export function resolvePathForPolicy(path: string): string | undefined {
38
+ return resolvePathForPolicyInner(resolve(path), new Set<string>());
39
+ }
40
+
41
+ function resolvePathForPolicyInner(
38
42
  path: string,
39
- cwd: string,
40
- protectedPaths: string[],
41
- ): boolean {
42
- // Resolve symlinks so writes through symlinks (e.g. not-git -> .git) are caught.
43
- let resolved = path;
44
- try {
45
- resolved = realpathSync(path);
46
- } catch {
47
- // File doesn't exist yet — try resolving the parent directory.
43
+ visitedSymlinks: Set<string>,
44
+ ): string | undefined {
45
+ let current = path;
46
+ const missingSegments: string[] = [];
47
+
48
+ while (true) {
48
49
  try {
49
- const dir = dirname(path);
50
- const base = basename(path);
51
- resolved = join(realpathSync(dir), base);
50
+ return resolve(realpathSync(current), ...missingSegments);
52
51
  } catch {
53
- // Parent doesn't exist either — fall through with raw path.
52
+ try {
53
+ const stat = lstatSync(current);
54
+ if (!stat.isSymbolicLink() || visitedSymlinks.has(current)) {
55
+ return undefined;
56
+ }
57
+ visitedSymlinks.add(current);
58
+ const target = resolve(dirname(current), readlinkSync(current));
59
+ return resolvePathForPolicyInner(
60
+ resolve(target, ...missingSegments),
61
+ visitedSymlinks,
62
+ );
63
+ } catch (error) {
64
+ const code = error && typeof error === "object" && "code" in error
65
+ ? String(error.code)
66
+ : undefined;
67
+ if (code !== "ENOENT" && code !== "ENOTDIR") return undefined;
68
+ const parent = dirname(current);
69
+ if (parent === current) return undefined;
70
+ missingSegments.unshift(basename(current));
71
+ current = parent;
72
+ }
54
73
  }
55
74
  }
75
+ }
76
+
77
+ export function matchesProtectedPath(
78
+ relativePath: string,
79
+ protectedPaths: string[],
80
+ ): boolean {
81
+ const normalizedPath = relativePath.replace(/\\/g, "/");
82
+ return protectedPaths.some((pattern) => {
83
+ const normalizedPattern = pattern.replace(/\\/g, "/");
84
+ return normalizedPath === normalizedPattern ||
85
+ normalizedPath.startsWith(`${normalizedPattern}/`);
86
+ });
87
+ }
88
+
89
+ export function isProtectedPath(
90
+ path: string,
91
+ cwd: string,
92
+ protectedPaths: string[],
93
+ ): boolean {
94
+ // Resolve through the nearest existing ancestor so symlinked directories are
95
+ // respected even when the final write target does not exist yet.
96
+ const resolved = resolvePathForPolicy(path) ?? path;
97
+ const resolvedCwd = resolvePathForPolicy(cwd) ?? cwd;
56
98
 
57
99
  // For paths inside the project: use relative path for matching.
58
- if (resolved.startsWith(cwd)) {
59
- const relativePath = relative(cwd, resolved);
60
- for (const pattern of protectedPaths) {
61
- if (
62
- relativePath === pattern ||
63
- relativePath.startsWith(`${pattern}/`)
64
- ) {
65
- return true;
66
- }
67
- }
68
- return false;
100
+ if (isInside(resolved, resolvedCwd)) {
101
+ return matchesProtectedPath(
102
+ relative(resolvedCwd, resolved),
103
+ protectedPaths,
104
+ );
69
105
  }
70
106
 
71
107
  // For paths outside the project: check every path component suffix.
72
108
  // This catches writes like ../other-project/.git/config even when cwd
73
109
  // doesn't contain the target.
74
- const segments = resolved.split("/").filter(Boolean);
110
+ const normalizedResolved = resolved.replace(/\\/g, "/");
111
+ const segments = normalizedResolved.split("/").filter(Boolean);
75
112
  for (let i = 0; i < segments.length; i++) {
76
- const suffix = segments.slice(i).join("/");
77
- for (const pattern of protectedPaths) {
78
- if (
79
- suffix === pattern ||
80
- suffix.startsWith(`${pattern}/`)
81
- ) {
82
- return true;
83
- }
113
+ if (matchesProtectedPath(segments.slice(i).join("/"), protectedPaths)) {
114
+ return true;
84
115
  }
85
116
  }
86
117
  return false;
@@ -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,3 +1,4 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
 
3
4
  /** Observability log configuration. Off by default. */
@@ -10,7 +11,8 @@ export type LogConfig = {
10
11
  export type AutoModeSettings = {
11
12
  enabled?: boolean;
12
13
  classifierModel?: string;
13
- maxTranscriptLines?: number;
14
+ maxUserTranscriptTokens?: number;
15
+ maxToolTranscriptTokens?: number;
14
16
  environment?: unknown;
15
17
  allow?: unknown;
16
18
  protectedPaths?: unknown;
@@ -44,7 +46,8 @@ export type ToolPattern = {
44
46
  export type EffectiveConfig = {
45
47
  enabled: boolean;
46
48
  classifierModel?: string;
47
- maxTranscriptLines: number;
49
+ maxUserTranscriptTokens: number;
50
+ maxToolTranscriptTokens: number;
48
51
  environment: string[];
49
52
  allow: string[];
50
53
  protectedPaths: string[];
@@ -90,8 +93,16 @@ export type ClassificationDecision = {
90
93
 
91
94
  /** One classifier attempt: the raw model response (or error) and parsed decision. */
92
95
  export type ClassifierIoAttempt = {
96
+ stage: "fast" | "detailed";
93
97
  attempt: number;
94
- response?: { stopReason?: string; text: string };
98
+ response?: {
99
+ stopReason?: string;
100
+ text: string;
101
+ model: string;
102
+ timestamp: number;
103
+ usage: AssistantMessage["usage"];
104
+ errorMessage?: string;
105
+ };
95
106
  parsed?: ClassificationDecision;
96
107
  error?: string;
97
108
  durationMs: number;
@@ -100,7 +111,12 @@ export type ClassifierIoAttempt = {
100
111
  /** Full classifier I/O for an action, surfaced for optional observability logging. */
101
112
  export type ClassifierIo = {
102
113
  model: string;
103
- prompt: { system: string; user: string };
114
+ prompt: {
115
+ system: string;
116
+ context: string;
117
+ fastInstruction: string;
118
+ detailedInstruction: string;
119
+ };
104
120
  attempts: ClassifierIoAttempt[];
105
121
  durationMs: number;
106
122
  };
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"