@czottmann/pi-automode 1.5.0 → 1.7.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 +44 -1
- package/docs/automode-classifier-flow.md +13 -1
- package/docs/observability-logging.md +93 -0
- package/extensions/auto-mode/classifier.ts +57 -22
- package/extensions/auto-mode/config.ts +39 -0
- package/extensions/auto-mode/constants.ts +6 -0
- package/extensions/auto-mode/extension.ts +140 -26
- package/extensions/auto-mode/log.ts +100 -0
- package/extensions/auto-mode/state.ts +7 -5
- package/extensions/auto-mode/types.ts +35 -2
- package/extensions/auto-mode.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ pi -e ./extensions/auto-mode.ts
|
|
|
35
35
|
/automode reload # reload config from disk
|
|
36
36
|
/automode reset # reset denial counters only
|
|
37
37
|
/automode defaults # print the built-in rule lists
|
|
38
|
-
/automode config #
|
|
38
|
+
/automode config # effective config, resolved log file path, + diagnostics
|
|
39
39
|
/automode denials # denial history for this session
|
|
40
40
|
/automode model # open classifier model selector and save to ~/.pi/agent/automode.json
|
|
41
41
|
/automode model provider/model-id # save classifier model to ~/.pi/agent/automode.json
|
|
@@ -43,10 +43,24 @@ pi -e ./extensions/auto-mode.ts
|
|
|
43
43
|
|
|
44
44
|
`/auto-mode` is an alias.
|
|
45
45
|
|
|
46
|
+
## Status line
|
|
47
|
+
|
|
48
|
+
When the Pi TUI is available, the extension renders a persistent status line:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
AM● a:12 d:2 ca:5 cd:1
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `AM` — auto-mode prefix; `●` when enabled, `○` when disabled (via config or `/automode off`).
|
|
55
|
+
- `a:` — actions allowed so far (checked minus denied).
|
|
56
|
+
- `d:` — actions denied so far, for any reason (permission rule, deterministic hard-deny, or classifier).
|
|
57
|
+
- `ca:` / `cd:` — classifier decisions split into allowed vs denied. These segments appear only after the classifier has run at least once; `d:` counts all denials, so `d:` is always `>= cd:`.
|
|
58
|
+
|
|
46
59
|
## Docs
|
|
47
60
|
|
|
48
61
|
- [Defaults and rule-list behavior](docs/defaults.md)
|
|
49
62
|
- [Auto-mode classifier flow](docs/automode-classifier-flow.md)
|
|
63
|
+
- [Observability logging](docs/observability-logging.md)
|
|
50
64
|
|
|
51
65
|
## Configuration
|
|
52
66
|
|
|
@@ -60,6 +74,18 @@ It reads `autoMode` from Pi-owned config only:
|
|
|
60
74
|
|
|
61
75
|
It deliberately does not read `autoMode` from shared project `.pi/automode.json`, because a checked-in repo should not be able to weaken auto-mode rules. Shared project config may still contribute `permissions.deny` and `permissions.ask`.
|
|
62
76
|
|
|
77
|
+
To disable pi-automode for the current project, create or edit `.pi/automode.local.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"autoMode": {
|
|
82
|
+
"enabled": false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This is project-local and should not be committed. Shared project `.pi/automode.json` cannot disable auto-mode.
|
|
88
|
+
|
|
63
89
|
Set a global default classifier model in `~/.pi/agent/automode.json`; override it per project in `.pi/automode.local.json`.
|
|
64
90
|
|
|
65
91
|
Example:
|
|
@@ -94,6 +120,23 @@ Example:
|
|
|
94
120
|
|
|
95
121
|
See [Defaults and rule-list behavior](docs/defaults.md) for built-in `environment`, `allow`, `protectedPaths`, `soft_deny`, and `hard_deny` entries, plus replacement behavior when `$defaults` is omitted.
|
|
96
122
|
|
|
123
|
+
### Observability logging
|
|
124
|
+
|
|
125
|
+
Auto mode can write a JSONL decision log next to the current Pi session file, so you can see what it allowed or blocked and how. It is off by default.
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"autoMode": {
|
|
130
|
+
"log": {
|
|
131
|
+
"enabled": true,
|
|
132
|
+
"classifierIo": false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
See [Observability logging](docs/observability-logging.md) for the log file location, entry schema, and the `classifierIo` privacy tradeoff. Run `/automode config` to see the resolved log file path.
|
|
139
|
+
|
|
97
140
|
### Permission patterns
|
|
98
141
|
|
|
99
142
|
Permission patterns use Pi tool names, for example `bash(...)`, `write(...)`, `edit(...)`, `read(...)`. The parser accepts capitalized names like `Bash(...)` for convenience, but the documented form is lowercase because Pi tool names are lowercase.
|
|
@@ -80,6 +80,18 @@ The effective config combines these sources:
|
|
|
80
80
|
|
|
81
81
|
Shared project `.pi/automode.json` cannot change `autoMode` rules. That is deliberate: a checked-in repo must not be able to weaken auto-mode. It may still add Pi permission rules.
|
|
82
82
|
|
|
83
|
+
To disable pi-automode for the current project, set `autoMode.enabled` to `false` in `.pi/automode.local.json`:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"autoMode": {
|
|
88
|
+
"enabled": false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This affects only that project-local config. Shared project `.pi/automode.json` cannot disable auto-mode.
|
|
94
|
+
|
|
83
95
|
List settings such as `allow`, `soft_deny`, `hard_deny`, `environment`, and `protectedPaths` support `$defaults`. Omitting `$defaults` replaces the built-ins for that section only. See [Defaults and rule-list behavior](defaults.md).
|
|
84
96
|
|
|
85
97
|
## Context captured before classification
|
|
@@ -157,7 +169,7 @@ At the moment, all non-read-only actions reach the classifier anyway, so the pro
|
|
|
157
169
|
|
|
158
170
|
The classifier call is made by `defaultClassifyAction`.
|
|
159
171
|
|
|
160
|
-
The model receives a system prompt and one user message.
|
|
172
|
+
The model receives a system prompt and one user message. To inspect exactly what's sent on each call — and the model's raw response — enable `autoMode.log.classifierIo`; see [Observability logging](observability-logging.md).
|
|
161
173
|
|
|
162
174
|
### System prompt
|
|
163
175
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Observability logging
|
|
2
|
+
|
|
3
|
+
Auto mode can write a JSONL decision log next to the current Pi session file, so you can see what it allowed or blocked, and how. It is off by default and fail-open: a write error never changes an allow/block decision.
|
|
4
|
+
|
|
5
|
+
## Enabling
|
|
6
|
+
|
|
7
|
+
Set `autoMode.log` in any Pi-owned config source (`~/.pi/agent/automode.json`, `.pi/automode.local.json`, or `PI_AUTOMODE_SETTINGS_JSON`):
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"autoMode": {
|
|
12
|
+
"log": {
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"classifierIo": false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `enabled` — write one `decision` line per tool-call decision.
|
|
21
|
+
- `classifierIo` — also write the classifier's prompt, raw model responses, and parsed decision for classifier-routed actions. Off by default; see [Privacy](#privacy) below.
|
|
22
|
+
|
|
23
|
+
Fields merge independently across config scopes (set `enabled` globally and `classifierIo` per project, for example). Shared project `.pi/automode.json` cannot set `log` — it follows the same `autoMode` exclusion as the rest of the config. Shape is validated and reported by `/automode config`.
|
|
24
|
+
|
|
25
|
+
Logging only writes entries while auto-mode is **enabled**. With auto-mode off, no tool calls reach the hook, so no entries are produced.
|
|
26
|
+
|
|
27
|
+
## Log file location
|
|
28
|
+
|
|
29
|
+
The log file is colocated with the current Pi session file, with `-pi-automode` inserted before the extension:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
<session-file> → <dir>/<id>.jsonl
|
|
33
|
+
<session-file>-pi-automode.jsonl → <dir>/<id>-pi-automode.jsonl
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For example: `~/.pi/agent/sessions/<slug>/<id>-pi-automode.jsonl`. If no session file is set, it falls back to `<sessionDir>/<sessionId>-pi-automode.jsonl`. Run `/automode config` to see the resolved path.
|
|
37
|
+
|
|
38
|
+
There is one combined file per session. Each line is one JSON object with a `type` discriminator. Entries for the same tool call share a `decisionId`.
|
|
39
|
+
|
|
40
|
+
## Entry types
|
|
41
|
+
|
|
42
|
+
### `decision`
|
|
43
|
+
|
|
44
|
+
One per tool-call decision. Every allow and every block goes through exactly one of these.
|
|
45
|
+
|
|
46
|
+
| field | meaning |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `ts` | ISO timestamp |
|
|
49
|
+
| `decisionId` | links to the `classifier` entry (if any) for the same call |
|
|
50
|
+
| `sessionId` | Pi session id |
|
|
51
|
+
| `cwd` | working directory |
|
|
52
|
+
| `tool` | tool name, e.g. `bash`, `write` |
|
|
53
|
+
| `summary` | `actionSummary` — tool name + input JSON (truncated) |
|
|
54
|
+
| `kind` | enforcement path: `permissions.deny`, `permissions.ask`, `deterministic-hard-deny`, `classifier`, or `read-only` |
|
|
55
|
+
| `outcome` | `allow` or `block` |
|
|
56
|
+
| `reason` | the reason string (classifier reason, or the deterministic/permission reason) |
|
|
57
|
+
| `classifierModel` | the configured classifier model, when relevant |
|
|
58
|
+
|
|
59
|
+
### `classifier`
|
|
60
|
+
|
|
61
|
+
Written only for classifier-routed actions, and only when `classifierIo: true`. It precedes the matching `decision` line in the file.
|
|
62
|
+
|
|
63
|
+
| field | meaning |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `ts` | ISO timestamp |
|
|
66
|
+
| `decisionId` | matches the `decision` entry for the same call |
|
|
67
|
+
| `model` | classifier model used, e.g. `anthropic/claude-haiku-4` |
|
|
68
|
+
| `prompt.system` | the full system prompt with `environment`/`allow`/`soft_deny`/`hard_deny` rules interpolated |
|
|
69
|
+
| `prompt.user` | the full user message: loaded project instructions + transcript + action |
|
|
70
|
+
| `attempts` | one entry per classifier attempt (see below) |
|
|
71
|
+
| `durationMs` | total classifier time |
|
|
72
|
+
| `parsed` | the final decision that was acted on (`{ decision, tier, reason }`) |
|
|
73
|
+
|
|
74
|
+
Each `attempts[]` entry is `{ attempt, response?, parsed?, error?, durationMs }`:
|
|
75
|
+
|
|
76
|
+
- `response` — `{ stopReason, text }`, the raw model output for that attempt.
|
|
77
|
+
- `parsed` — the decision parsed from the response, or absent if it did not parse.
|
|
78
|
+
- `error` — present when the call threw (network/auth); `response` is then absent.
|
|
79
|
+
|
|
80
|
+
This records retries and fail-closed cases verbatim: a call that returned garbage, retried, then succeeded shows two attempts with the first `parsed` absent.
|
|
81
|
+
|
|
82
|
+
## Privacy
|
|
83
|
+
|
|
84
|
+
`prompt.user` is **exactly what is sent to the classifier model** — loaded project instructions, the recent transcript, and the action being classified. See [Auto-mode classifier flow → What is sent to the classifier](automode-classifier-flow.md#what-is-sent-to-the-classifier) for how that message is assembled and truncated.
|
|
85
|
+
|
|
86
|
+
The log records this payload locally, but the same data is also sent to the classifier model endpoint on every classifier-routed call. If `classifierModel` points at a cloud provider, that payload leaves the machine. Enable `classifierIo` when debugging classifier behavior or tuning rules; leave it off for routine outcome logging.
|
|
87
|
+
|
|
88
|
+
## Sizing
|
|
89
|
+
|
|
90
|
+
- `decision` line: ~0.4–2 KB (driven by `summary`, which carries the tool input, capped at 6 KB).
|
|
91
|
+
- `classifier` line: ~5 KB fixed (the system prompt) plus the transcript, which is variable. In a long session the transcript dominates — roughly 30–40 KB per classifier-routed action.
|
|
92
|
+
|
|
93
|
+
The transcript is bounded by `autoMode.maxTranscriptLines` (default 80). Lowering it shrinks both the `classifier` log line and what the classifier receives, at the cost of less context for the classifier's decision.
|
|
@@ -6,11 +6,13 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-ai";
|
|
7
7
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { CLASSIFIER_SYSTEM_PROMPT } from "./constants.ts";
|
|
9
|
-
import { parseModelSpec } from "./model.ts";
|
|
9
|
+
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
10
10
|
import { buildTranscript } from "./transcript.ts";
|
|
11
11
|
import type {
|
|
12
12
|
ClassificationDecision,
|
|
13
13
|
ClassifyAction,
|
|
14
|
+
ClassifierIoAttempt,
|
|
15
|
+
ClassifyResult,
|
|
14
16
|
EffectiveConfig,
|
|
15
17
|
} from "./types.ts";
|
|
16
18
|
|
|
@@ -71,19 +73,26 @@ export type RetryOptions = {
|
|
|
71
73
|
maxAttempts?: number;
|
|
72
74
|
maxTokens?: number;
|
|
73
75
|
temperature?: number;
|
|
76
|
+
/** Receives each attempt's raw response (or error) and parsed decision, for observability logging. */
|
|
77
|
+
onAttempt?: (attempt: ClassifierIoAttempt) => void;
|
|
74
78
|
};
|
|
75
79
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
message
|
|
79
|
-
): ClassificationDecision | undefined {
|
|
80
|
-
const text = message.content
|
|
80
|
+
/** Concatenate all text blocks of an assistant message into a single string. */
|
|
81
|
+
function extractAssistantText(message: AssistantMessage): string {
|
|
82
|
+
return message.content
|
|
81
83
|
.filter(
|
|
82
84
|
(block): block is { type: "text"; text: string } => block.type === "text",
|
|
83
85
|
)
|
|
84
86
|
.map((block) => block.text)
|
|
85
87
|
.join("\n")
|
|
86
88
|
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
|
|
92
|
+
export function parseClassifierDecision(
|
|
93
|
+
message: AssistantMessage,
|
|
94
|
+
): ClassificationDecision | undefined {
|
|
95
|
+
const text = extractAssistantText(message);
|
|
87
96
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
|
|
88
97
|
const candidates = [fenced, text, text.match(/\{[\s\S]*\}/)?.[0]].filter(
|
|
89
98
|
Boolean,
|
|
@@ -133,9 +142,11 @@ export async function classifyWithRetry(
|
|
|
133
142
|
const maxAttempts = options.maxAttempts ?? 2;
|
|
134
143
|
const maxTokens = options.maxTokens ?? 1200;
|
|
135
144
|
const temperature = options.temperature ?? 0;
|
|
145
|
+
const onAttempt = options.onAttempt;
|
|
136
146
|
let lastReason =
|
|
137
147
|
"Classifier response was not valid decision JSON; auto mode fails closed.";
|
|
138
148
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
149
|
+
const started = Date.now();
|
|
139
150
|
let response: AssistantMessage;
|
|
140
151
|
try {
|
|
141
152
|
response = await completeFn(
|
|
@@ -150,15 +161,29 @@ export async function classifyWithRetry(
|
|
|
150
161
|
},
|
|
151
162
|
);
|
|
152
163
|
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
onAttempt?.({
|
|
166
|
+
attempt: attempt + 1,
|
|
167
|
+
error: message,
|
|
168
|
+
durationMs: Date.now() - started,
|
|
169
|
+
});
|
|
153
170
|
return {
|
|
154
171
|
decision: "block",
|
|
155
172
|
tier: "none",
|
|
156
|
-
reason: `Classifier failed; auto mode fails closed: ${
|
|
157
|
-
error instanceof Error ? error.message : String(error)
|
|
158
|
-
}`,
|
|
173
|
+
reason: `Classifier failed; auto mode fails closed: ${message}`,
|
|
159
174
|
};
|
|
160
175
|
}
|
|
176
|
+
const durationMs = Date.now() - started;
|
|
161
177
|
const decision = parseClassifierDecision(response);
|
|
178
|
+
onAttempt?.({
|
|
179
|
+
attempt: attempt + 1,
|
|
180
|
+
response: {
|
|
181
|
+
stopReason: response.stopReason,
|
|
182
|
+
text: extractAssistantText(response),
|
|
183
|
+
},
|
|
184
|
+
parsed: decision,
|
|
185
|
+
durationMs,
|
|
186
|
+
});
|
|
162
187
|
if (decision) return decision;
|
|
163
188
|
lastReason =
|
|
164
189
|
response.stopReason === "length"
|
|
@@ -173,7 +198,7 @@ export const defaultClassifyAction: ClassifyAction = async (
|
|
|
173
198
|
config,
|
|
174
199
|
action,
|
|
175
200
|
loadedContext,
|
|
176
|
-
): Promise<
|
|
201
|
+
): Promise<ClassifyResult> => {
|
|
177
202
|
const classifier = await resolveClassifier(ctx, config);
|
|
178
203
|
if (!classifier) {
|
|
179
204
|
return {
|
|
@@ -183,25 +208,35 @@ export const defaultClassifyAction: ClassifyAction = async (
|
|
|
183
208
|
};
|
|
184
209
|
}
|
|
185
210
|
|
|
211
|
+
const systemPrompt = buildClassifierPrompt(config);
|
|
212
|
+
const userText = `<loaded-project-instructions>\n${
|
|
213
|
+
loadedContext || "(none)"
|
|
214
|
+
}\n</loaded-project-instructions>\n\n<transcript>\n${
|
|
215
|
+
buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
|
|
216
|
+
}\n</transcript>\n\nLatest action to classify:\n${action}`;
|
|
186
217
|
const userMessage: UserMessage = {
|
|
187
218
|
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
|
-
],
|
|
219
|
+
content: [{ type: "text", text: userText }],
|
|
198
220
|
timestamp: Date.now(),
|
|
199
221
|
};
|
|
200
222
|
|
|
201
|
-
|
|
223
|
+
const attempts: ClassifierIoAttempt[] = [];
|
|
224
|
+
const started = Date.now();
|
|
225
|
+
const decision = await classifyWithRetry(
|
|
202
226
|
complete,
|
|
203
227
|
classifier,
|
|
204
|
-
{ systemPrompt
|
|
228
|
+
{ systemPrompt, messages: [userMessage] },
|
|
205
229
|
ctx.signal,
|
|
230
|
+
{ onAttempt: (attempt) => attempts.push(attempt) },
|
|
206
231
|
);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
...decision,
|
|
235
|
+
io: {
|
|
236
|
+
model: formatModelSpec(classifier.model),
|
|
237
|
+
prompt: { system: systemPrompt, user: userText },
|
|
238
|
+
attempts,
|
|
239
|
+
durationMs: Date.now() - started,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
207
242
|
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DEFAULT_ALLOW,
|
|
5
5
|
DEFAULT_ENVIRONMENT,
|
|
6
6
|
DEFAULT_HARD_DENY,
|
|
7
|
+
DEFAULT_LOG_CONFIG,
|
|
7
8
|
DEFAULT_MAX_TRANSCRIPT_LINES,
|
|
8
9
|
DEFAULT_PROTECTED_PATHS,
|
|
9
10
|
DEFAULT_SOFT_DENY,
|
|
@@ -17,6 +18,7 @@ import type {
|
|
|
17
18
|
ConfigLoadResult,
|
|
18
19
|
EffectiveConfig,
|
|
19
20
|
LoadedSettingsFile,
|
|
21
|
+
LogConfig,
|
|
20
22
|
SettingsFile,
|
|
21
23
|
SettingsSources,
|
|
22
24
|
ToolPattern,
|
|
@@ -102,6 +104,7 @@ export function validateSettingsFile(
|
|
|
102
104
|
"softDeny",
|
|
103
105
|
"hard_deny",
|
|
104
106
|
"hardDeny",
|
|
107
|
+
"log",
|
|
105
108
|
]);
|
|
106
109
|
for (const key of Object.keys(autoMode)) {
|
|
107
110
|
if (!knownAutoMode.has(key)) {
|
|
@@ -160,6 +163,9 @@ export function validateSettingsFile(
|
|
|
160
163
|
"autoMode.hard_deny",
|
|
161
164
|
diagnostics,
|
|
162
165
|
);
|
|
166
|
+
if (hasOwn(autoMode, "log")) {
|
|
167
|
+
validateLogSetting(autoMode.log, source, diagnostics);
|
|
168
|
+
}
|
|
163
169
|
}
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -228,6 +234,37 @@ function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
|
|
|
228
234
|
return [...new Set([...base, ...accumulator.entries])];
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
function validateLogSetting(
|
|
238
|
+
value: unknown,
|
|
239
|
+
source: string,
|
|
240
|
+
diagnostics: string[],
|
|
241
|
+
): void {
|
|
242
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
243
|
+
diagnostics.push(`${source}: autoMode.log must be an object`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const log = value as Record<string, unknown>;
|
|
247
|
+
if (hasOwn(log, "enabled") && typeof log.enabled !== "boolean") {
|
|
248
|
+
diagnostics.push(`${source}: autoMode.log.enabled must be a boolean`);
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
hasOwn(log, "classifierIo") && typeof log.classifierIo !== "boolean"
|
|
252
|
+
) {
|
|
253
|
+
diagnostics.push(`${source}: autoMode.log.classifierIo must be a boolean`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function mergeLog(
|
|
258
|
+
base: LogConfig,
|
|
259
|
+
patch: Partial<LogConfig> | undefined,
|
|
260
|
+
): LogConfig {
|
|
261
|
+
if (!patch) return base;
|
|
262
|
+
return {
|
|
263
|
+
enabled: patch.enabled ?? base.enabled,
|
|
264
|
+
classifierIo: patch.classifierIo ?? base.classifierIo,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
231
268
|
function applyAutoModeScalars(
|
|
232
269
|
base: EffectiveConfig,
|
|
233
270
|
settings: AutoModeSettings | undefined,
|
|
@@ -238,6 +275,7 @@ function applyAutoModeScalars(
|
|
|
238
275
|
enabled: settings.enabled ?? base.enabled,
|
|
239
276
|
classifierModel: settings.classifierModel ?? base.classifierModel,
|
|
240
277
|
maxTranscriptLines: settings.maxTranscriptLines ?? base.maxTranscriptLines,
|
|
278
|
+
log: mergeLog(base.log, settings.log),
|
|
241
279
|
};
|
|
242
280
|
}
|
|
243
281
|
|
|
@@ -276,6 +314,7 @@ export function buildEffectiveConfigFromSources(
|
|
|
276
314
|
hardDeny: [...DEFAULT_HARD_DENY],
|
|
277
315
|
permissionDeny: [],
|
|
278
316
|
permissionAsk: [],
|
|
317
|
+
log: { ...DEFAULT_LOG_CONFIG },
|
|
279
318
|
};
|
|
280
319
|
|
|
281
320
|
const globalSettings = sources.globalSettings ?? [];
|
|
@@ -166,3 +166,9 @@ export const PROFILE_FILES = new Set([
|
|
|
166
166
|
]);
|
|
167
167
|
|
|
168
168
|
export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
169
|
+
|
|
170
|
+
/** Default observability log config: off, classifier I/O off. */
|
|
171
|
+
export const DEFAULT_LOG_CONFIG = {
|
|
172
|
+
enabled: false,
|
|
173
|
+
classifierIo: false,
|
|
174
|
+
};
|
|
@@ -19,6 +19,12 @@ import {
|
|
|
19
19
|
writeGlobalClassifierModel,
|
|
20
20
|
} from "./config.ts";
|
|
21
21
|
import { deterministicHardDeny } from "./hard-deny.ts";
|
|
22
|
+
import {
|
|
23
|
+
createLogger,
|
|
24
|
+
newDecisionId,
|
|
25
|
+
resolveLogPath,
|
|
26
|
+
type Logger,
|
|
27
|
+
} from "./log.ts";
|
|
22
28
|
import { formatModelSpec, parseModelSpec } from "./model.ts";
|
|
23
29
|
import { promptForClassifierModel } from "./model-selector.ts";
|
|
24
30
|
import { matchesToolPattern } from "./permissions.ts";
|
|
@@ -35,7 +41,9 @@ import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
|
|
|
35
41
|
import type {
|
|
36
42
|
AutoModeState,
|
|
37
43
|
ClassifyAction,
|
|
44
|
+
ClassifyResult,
|
|
38
45
|
ConfigLoadResult,
|
|
46
|
+
DecisionKind,
|
|
39
47
|
DenialRecord,
|
|
40
48
|
EffectiveConfig,
|
|
41
49
|
} from "./types.ts";
|
|
@@ -50,6 +58,31 @@ export type PiAutomodeOptions = {
|
|
|
50
58
|
saveClassifierModel?: (classifierModel: string) => void;
|
|
51
59
|
};
|
|
52
60
|
|
|
61
|
+
type LogCtx = {
|
|
62
|
+
logger: Logger;
|
|
63
|
+
decisionId: string;
|
|
64
|
+
classifierModel?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Append a classifier I/O entry when classifier logging is enabled. */
|
|
68
|
+
function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
|
|
69
|
+
if (!log.logger.enabled || !log.logger.classifierIo || !decision.io) return;
|
|
70
|
+
log.logger.append({
|
|
71
|
+
type: "classifier",
|
|
72
|
+
ts: new Date().toISOString(),
|
|
73
|
+
decisionId: log.decisionId,
|
|
74
|
+
model: decision.io.model,
|
|
75
|
+
prompt: decision.io.prompt,
|
|
76
|
+
attempts: decision.io.attempts,
|
|
77
|
+
durationMs: decision.io.durationMs,
|
|
78
|
+
parsed: {
|
|
79
|
+
decision: decision.decision,
|
|
80
|
+
tier: decision.tier,
|
|
81
|
+
reason: decision.reason,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
53
86
|
/** Create a Pi extension instance. Default export uses production dependencies. */
|
|
54
87
|
export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
55
88
|
const loadConfigWithDiagnostics = options.loadConfig
|
|
@@ -69,7 +102,8 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
69
102
|
let state: AutoModeState = {
|
|
70
103
|
checkedActions: 0,
|
|
71
104
|
blockedActions: 0,
|
|
72
|
-
|
|
105
|
+
classifierAllowed: 0,
|
|
106
|
+
classifierDenied: 0,
|
|
73
107
|
recentDenials: [],
|
|
74
108
|
};
|
|
75
109
|
let loadedContext = "";
|
|
@@ -100,6 +134,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
100
134
|
function block(
|
|
101
135
|
ctx: ExtensionContext,
|
|
102
136
|
denial: DenialRecord,
|
|
137
|
+
logCtx: LogCtx,
|
|
103
138
|
): { block: true; reason: string } {
|
|
104
139
|
state.blockedActions += 1;
|
|
105
140
|
state.lastDecision = "block";
|
|
@@ -107,6 +142,21 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
107
142
|
pushDenial(state, denial);
|
|
108
143
|
persist();
|
|
109
144
|
updateUi(ctx);
|
|
145
|
+
if (logCtx.logger.enabled) {
|
|
146
|
+
logCtx.logger.append({
|
|
147
|
+
type: "decision",
|
|
148
|
+
ts: new Date().toISOString(),
|
|
149
|
+
decisionId: logCtx.decisionId,
|
|
150
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
151
|
+
cwd: ctx.cwd,
|
|
152
|
+
tool: denial.toolName,
|
|
153
|
+
summary: denial.action,
|
|
154
|
+
kind: denial.kind,
|
|
155
|
+
outcome: "block",
|
|
156
|
+
reason: denial.reason,
|
|
157
|
+
classifierModel: logCtx.classifierModel,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
110
160
|
if (ctx.hasUI) {
|
|
111
161
|
ctx.ui.notify(
|
|
112
162
|
`Auto mode blocked ${denial.toolName}: ${denial.reason}`,
|
|
@@ -116,6 +166,36 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
116
166
|
return { block: true, reason: `[pi-automode] ${denial.reason}` };
|
|
117
167
|
}
|
|
118
168
|
|
|
169
|
+
function allow(
|
|
170
|
+
ctx: ExtensionContext,
|
|
171
|
+
kind: DecisionKind,
|
|
172
|
+
reason: string,
|
|
173
|
+
toolName: string,
|
|
174
|
+
summary: string,
|
|
175
|
+
logCtx: LogCtx,
|
|
176
|
+
): undefined {
|
|
177
|
+
state.lastDecision = "allow";
|
|
178
|
+
state.lastReason = reason;
|
|
179
|
+
persist();
|
|
180
|
+
updateUi(ctx);
|
|
181
|
+
if (logCtx.logger.enabled) {
|
|
182
|
+
logCtx.logger.append({
|
|
183
|
+
type: "decision",
|
|
184
|
+
ts: new Date().toISOString(),
|
|
185
|
+
decisionId: logCtx.decisionId,
|
|
186
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
187
|
+
cwd: ctx.cwd,
|
|
188
|
+
tool: toolName,
|
|
189
|
+
summary,
|
|
190
|
+
kind,
|
|
191
|
+
outcome: "allow",
|
|
192
|
+
reason,
|
|
193
|
+
classifierModel: logCtx.classifierModel,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
119
199
|
pi.on("session_start", (_event, ctx) => {
|
|
120
200
|
loadResult = loadConfigWithDiagnostics(ctx.cwd);
|
|
121
201
|
config = loadResult.config;
|
|
@@ -146,6 +226,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
146
226
|
const input = event.input as Record<string, unknown>;
|
|
147
227
|
const summary = actionSummary(event.toolName, input);
|
|
148
228
|
state.checkedActions += 1;
|
|
229
|
+
const logCtx: LogCtx = {
|
|
230
|
+
logger: createLogger({
|
|
231
|
+
enabled: cfg.log.enabled,
|
|
232
|
+
classifierIo: cfg.log.classifierIo,
|
|
233
|
+
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
234
|
+
sessionDir: ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
|
|
235
|
+
sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
236
|
+
}),
|
|
237
|
+
decisionId: newDecisionId(),
|
|
238
|
+
classifierModel: cfg.classifierModel,
|
|
239
|
+
};
|
|
149
240
|
|
|
150
241
|
for (const pattern of cfg.permissionDeny) {
|
|
151
242
|
if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
|
|
@@ -155,7 +246,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
155
246
|
reason: `Blocked by permissions.deny: ${pattern.raw}`,
|
|
156
247
|
action: summary,
|
|
157
248
|
kind: "permissions.deny",
|
|
158
|
-
});
|
|
249
|
+
}, logCtx);
|
|
159
250
|
}
|
|
160
251
|
}
|
|
161
252
|
|
|
@@ -171,7 +262,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
171
262
|
`Matched permissions.ask (${pattern.raw}) but no UI is available`,
|
|
172
263
|
action: summary,
|
|
173
264
|
kind: "permissions.ask",
|
|
174
|
-
});
|
|
265
|
+
}, logCtx);
|
|
175
266
|
}
|
|
176
267
|
const allowed = await ctx.ui.confirm(
|
|
177
268
|
"Auto mode permission ask",
|
|
@@ -185,7 +276,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
185
276
|
reason: `Declined permissions.ask: ${pattern.raw}`,
|
|
186
277
|
action: summary,
|
|
187
278
|
kind: "permissions.ask",
|
|
188
|
-
});
|
|
279
|
+
}, logCtx);
|
|
189
280
|
}
|
|
190
281
|
}
|
|
191
282
|
|
|
@@ -201,57 +292,70 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
201
292
|
reason: deterministicReason,
|
|
202
293
|
action: summary,
|
|
203
294
|
kind: "deterministic-hard-deny",
|
|
204
|
-
});
|
|
295
|
+
}, logCtx);
|
|
205
296
|
}
|
|
206
297
|
|
|
207
298
|
if (READ_ONLY_TOOLS.has(event.toolName)) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
299
|
+
return allow(
|
|
300
|
+
ctx,
|
|
301
|
+
"read-only",
|
|
302
|
+
`Read-only built-in tool: ${event.toolName}`,
|
|
303
|
+
event.toolName,
|
|
304
|
+
summary,
|
|
305
|
+
logCtx,
|
|
306
|
+
);
|
|
213
307
|
}
|
|
214
308
|
|
|
215
309
|
// Protected paths go to the classifier regardless of allow rules.
|
|
216
310
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
217
311
|
const path = resolveInputPath(ctx.cwd, input.path);
|
|
218
312
|
if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
|
|
219
|
-
state.classifierChecks += 1;
|
|
220
313
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
314
|
+
logClassifierIo(decision, logCtx);
|
|
221
315
|
if (decision.decision === "allow") {
|
|
222
|
-
state.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
316
|
+
state.classifierAllowed += 1;
|
|
317
|
+
return allow(
|
|
318
|
+
ctx,
|
|
319
|
+
"classifier",
|
|
320
|
+
decision.reason,
|
|
321
|
+
event.toolName,
|
|
322
|
+
summary,
|
|
323
|
+
logCtx,
|
|
324
|
+
);
|
|
227
325
|
}
|
|
326
|
+
state.classifierDenied += 1;
|
|
228
327
|
return block(ctx, {
|
|
229
328
|
timestamp: Date.now(),
|
|
230
329
|
toolName: event.toolName,
|
|
231
330
|
reason: decision.reason,
|
|
232
331
|
action: summary,
|
|
233
332
|
kind: "classifier",
|
|
234
|
-
});
|
|
333
|
+
}, logCtx);
|
|
235
334
|
}
|
|
236
335
|
}
|
|
237
336
|
|
|
238
|
-
state.classifierChecks += 1;
|
|
239
337
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
338
|
+
logClassifierIo(decision, logCtx);
|
|
240
339
|
if (decision.decision === "allow") {
|
|
241
|
-
state.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
340
|
+
state.classifierAllowed += 1;
|
|
341
|
+
return allow(
|
|
342
|
+
ctx,
|
|
343
|
+
"classifier",
|
|
344
|
+
decision.reason,
|
|
345
|
+
event.toolName,
|
|
346
|
+
summary,
|
|
347
|
+
logCtx,
|
|
348
|
+
);
|
|
246
349
|
}
|
|
247
350
|
|
|
351
|
+
state.classifierDenied += 1;
|
|
248
352
|
return block(ctx, {
|
|
249
353
|
timestamp: Date.now(),
|
|
250
354
|
toolName: event.toolName,
|
|
251
355
|
reason: decision.reason,
|
|
252
356
|
action: summary,
|
|
253
357
|
kind: "classifier",
|
|
254
|
-
});
|
|
358
|
+
}, logCtx);
|
|
255
359
|
});
|
|
256
360
|
|
|
257
361
|
async function handleAutomodeCommand(
|
|
@@ -298,7 +402,8 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
298
402
|
state = {
|
|
299
403
|
checkedActions: 0,
|
|
300
404
|
blockedActions: 0,
|
|
301
|
-
|
|
405
|
+
classifierAllowed: 0,
|
|
406
|
+
classifierDenied: 0,
|
|
302
407
|
recentDenials: [],
|
|
303
408
|
enabledOverride: state.enabledOverride,
|
|
304
409
|
};
|
|
@@ -324,9 +429,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
324
429
|
return;
|
|
325
430
|
}
|
|
326
431
|
if (command === "config") {
|
|
432
|
+
const logFile = resolveLogPath(
|
|
433
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
434
|
+
ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
|
|
435
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
436
|
+
);
|
|
327
437
|
ctx.ui.notify(
|
|
328
438
|
safeJson(
|
|
329
|
-
{
|
|
439
|
+
{
|
|
440
|
+
config: effectiveConfig(),
|
|
441
|
+
logFile,
|
|
442
|
+
diagnostics: configDiagnostics,
|
|
443
|
+
},
|
|
330
444
|
16000,
|
|
331
445
|
),
|
|
332
446
|
configDiagnostics.length > 0 ? "warning" : "info",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
ClassifierIoAttempt,
|
|
6
|
+
ClassificationDecision,
|
|
7
|
+
DecisionKind,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** A final allow/block decision for a tool call. */
|
|
11
|
+
export type DecisionLogEntry = {
|
|
12
|
+
type: "decision";
|
|
13
|
+
ts: string;
|
|
14
|
+
decisionId: string;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
tool: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
kind: DecisionKind;
|
|
20
|
+
outcome: "allow" | "block";
|
|
21
|
+
reason: string;
|
|
22
|
+
classifierModel?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The classifier prompt, raw responses, and parsed decision for one action. */
|
|
26
|
+
export type ClassifierLogEntry = {
|
|
27
|
+
type: "classifier";
|
|
28
|
+
ts: string;
|
|
29
|
+
decisionId: string;
|
|
30
|
+
model: string;
|
|
31
|
+
prompt: { system: string; user: string };
|
|
32
|
+
attempts: ClassifierIoAttempt[];
|
|
33
|
+
durationMs: number;
|
|
34
|
+
parsed: ClassificationDecision;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type LogEntry = DecisionLogEntry | ClassifierLogEntry;
|
|
38
|
+
|
|
39
|
+
export type Logger = {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
classifierIo: boolean;
|
|
42
|
+
append(entry: LogEntry): void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type LoggerOptions = {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
classifierIo: boolean;
|
|
48
|
+
sessionFile?: string;
|
|
49
|
+
sessionDir: string;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Short id linking a classifier entry to its decision entry in the same file. */
|
|
54
|
+
export function newDecisionId(): string {
|
|
55
|
+
return randomBytes(4).toString("hex");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive the log file path from the current session: the session file's
|
|
60
|
+
* directory with `-pi-automode` inserted before the extension. Falls back to
|
|
61
|
+
* `<sessionDir>/<sessionId>-pi-automode.jsonl` when no session file is set.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveLogPath(
|
|
64
|
+
sessionFile: string | undefined,
|
|
65
|
+
sessionDir: string,
|
|
66
|
+
sessionId: string,
|
|
67
|
+
): string {
|
|
68
|
+
if (sessionFile) {
|
|
69
|
+
const ext = extname(sessionFile);
|
|
70
|
+
const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
|
|
71
|
+
return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
|
|
72
|
+
}
|
|
73
|
+
return join(sessionDir, `${sessionId}-pi-automode.jsonl`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Append one JSON object as a line. Failures are swallowed: logging must
|
|
77
|
+
* never change a safety decision. */
|
|
78
|
+
function appendJsonl(path: string, entry: unknown): void {
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
// Fail open.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build a logger bound to one session's log path. No-ops when disabled. */
|
|
88
|
+
export function createLogger(opts: LoggerOptions): Logger {
|
|
89
|
+
const { enabled, classifierIo } = opts;
|
|
90
|
+
const path = resolveLogPath(opts.sessionFile, opts.sessionDir, opts.sessionId);
|
|
91
|
+
return {
|
|
92
|
+
enabled,
|
|
93
|
+
classifierIo,
|
|
94
|
+
append(entry) {
|
|
95
|
+
if (!enabled) return;
|
|
96
|
+
if (entry.type === "classifier" && !classifierIo) return;
|
|
97
|
+
appendJsonl(path, entry);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -17,8 +17,8 @@ export function statusLine(
|
|
|
17
17
|
const enabled = state.enabledOverride ?? config.enabled;
|
|
18
18
|
const circle = enabled ? "●" : "○";
|
|
19
19
|
const allowed = state.checkedActions - state.blockedActions;
|
|
20
|
-
const classifier = state.
|
|
21
|
-
? `
|
|
20
|
+
const classifier = state.classifierAllowed > 0 || state.classifierDenied > 0
|
|
21
|
+
? ` ca:${state.classifierAllowed} cd:${state.classifierDenied}`
|
|
22
22
|
: "";
|
|
23
23
|
return `AM${circle} a:${allowed} d:${state.blockedActions}${classifier}`;
|
|
24
24
|
}
|
|
@@ -32,7 +32,8 @@ export function statusText(
|
|
|
32
32
|
`classifier: ${config.classifierModel ?? "current session model"}`,
|
|
33
33
|
`checked actions: ${state.checkedActions}`,
|
|
34
34
|
`blocked actions: ${state.blockedActions}`,
|
|
35
|
-
`classifier
|
|
35
|
+
`classifier allowed: ${state.classifierAllowed}`,
|
|
36
|
+
`classifier denied: ${state.classifierDenied}`,
|
|
36
37
|
`permissions.deny rules: ${config.permissionDeny.length}`,
|
|
37
38
|
`permissions.ask rules: ${config.permissionAsk.length}`,
|
|
38
39
|
`environment entries: ${config.environment.length}`,
|
|
@@ -88,11 +89,12 @@ export function restoreState(ctx: ExtensionContext): AutoModeState {
|
|
|
88
89
|
lastReason: entry.data.lastReason,
|
|
89
90
|
checkedActions: entry.data.checkedActions ?? 0,
|
|
90
91
|
blockedActions: entry.data.blockedActions ?? 0,
|
|
91
|
-
|
|
92
|
+
classifierAllowed: entry.data.classifierAllowed ?? 0,
|
|
93
|
+
classifierDenied: entry.data.classifierDenied ?? 0,
|
|
92
94
|
recentDenials: Array.isArray(entry.data.recentDenials)
|
|
93
95
|
? entry.data.recentDenials.slice(-DENIAL_HISTORY_LIMIT)
|
|
94
96
|
: [],
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
|
-
return { checkedActions: 0, blockedActions: 0,
|
|
99
|
+
return { checkedActions: 0, blockedActions: 0, classifierAllowed: 0, classifierDenied: 0, recentDenials: [] };
|
|
98
100
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
/** Observability log configuration. Off by default. */
|
|
4
|
+
export type LogConfig = {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
/** When true, also log classifier prompt/response payloads. */
|
|
7
|
+
classifierIo: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
3
10
|
export type AutoModeSettings = {
|
|
4
11
|
enabled?: boolean;
|
|
5
12
|
classifierModel?: string;
|
|
@@ -11,6 +18,7 @@ export type AutoModeSettings = {
|
|
|
11
18
|
softDeny?: unknown;
|
|
12
19
|
hard_deny?: unknown;
|
|
13
20
|
hardDeny?: unknown;
|
|
21
|
+
log?: Partial<LogConfig>;
|
|
14
22
|
};
|
|
15
23
|
|
|
16
24
|
export type SettingsFile = {
|
|
@@ -44,6 +52,7 @@ export type EffectiveConfig = {
|
|
|
44
52
|
hardDeny: string[];
|
|
45
53
|
permissionDeny: ToolPattern[];
|
|
46
54
|
permissionAsk: ToolPattern[];
|
|
55
|
+
log: LogConfig;
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
export type AutoModeState = {
|
|
@@ -52,7 +61,8 @@ export type AutoModeState = {
|
|
|
52
61
|
lastReason?: string;
|
|
53
62
|
checkedActions: number;
|
|
54
63
|
blockedActions: number;
|
|
55
|
-
|
|
64
|
+
classifierAllowed: number;
|
|
65
|
+
classifierDenied: number;
|
|
56
66
|
recentDenials: DenialRecord[];
|
|
57
67
|
};
|
|
58
68
|
|
|
@@ -69,12 +79,35 @@ export type DenialRecord = {
|
|
|
69
79
|
| "setup";
|
|
70
80
|
};
|
|
71
81
|
|
|
82
|
+
/** Denial kind plus the read-only fast path, used for decision log entries. */
|
|
83
|
+
export type DecisionKind = DenialRecord["kind"] | "read-only";
|
|
84
|
+
|
|
72
85
|
export type ClassificationDecision = {
|
|
73
86
|
decision: "allow" | "block";
|
|
74
87
|
tier: "hard_deny" | "soft_deny" | "allow" | "explicit_intent" | "none";
|
|
75
88
|
reason: string;
|
|
76
89
|
};
|
|
77
90
|
|
|
91
|
+
/** One classifier attempt: the raw model response (or error) and parsed decision. */
|
|
92
|
+
export type ClassifierIoAttempt = {
|
|
93
|
+
attempt: number;
|
|
94
|
+
response?: { stopReason?: string; text: string };
|
|
95
|
+
parsed?: ClassificationDecision;
|
|
96
|
+
error?: string;
|
|
97
|
+
durationMs: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Full classifier I/O for an action, surfaced for optional observability logging. */
|
|
101
|
+
export type ClassifierIo = {
|
|
102
|
+
model: string;
|
|
103
|
+
prompt: { system: string; user: string };
|
|
104
|
+
attempts: ClassifierIoAttempt[];
|
|
105
|
+
durationMs: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Classification decision plus the I/O that produced it (when available). */
|
|
109
|
+
export type ClassifyResult = ClassificationDecision & { io?: ClassifierIo };
|
|
110
|
+
|
|
78
111
|
export type SettingsSources = {
|
|
79
112
|
globalSettings?: SettingsFile[];
|
|
80
113
|
projectLocalSettings?: SettingsFile[];
|
|
@@ -92,4 +125,4 @@ export type ClassifyAction = (
|
|
|
92
125
|
config: EffectiveConfig,
|
|
93
126
|
action: string,
|
|
94
127
|
loadedContext: string,
|
|
95
|
-
) => Promise<
|
|
128
|
+
) => Promise<ClassifyResult>;
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -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";
|