@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.
- package/README.md +43 -9
- package/docs/automode-classifier-flow.md +43 -46
- package/docs/defaults.md +13 -2
- package/docs/observability-logging.md +109 -0
- package/extensions/auto-mode/classifier.ts +305 -59
- package/extensions/auto-mode/config.ts +72 -11
- package/extensions/auto-mode/constants.ts +15 -4
- package/extensions/auto-mode/extension.ts +140 -43
- package/extensions/auto-mode/hard-deny.ts +5 -2
- package/extensions/auto-mode/log.ts +115 -0
- package/extensions/auto-mode/paths.ts +67 -36
- package/extensions/auto-mode/transcript.ts +181 -33
- package/extensions/auto-mode/types.ts +51 -3
- package/extensions/auto-mode.ts +2 -1
- package/package.json +1 -1
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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)
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
144
|
+
) => Promise<ClassifyResult>;
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -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
|
|
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";
|