@czottmann/pi-automode 1.6.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 +34 -4
- 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 +132 -22
- package/extensions/auto-mode/log.ts +100 -0
- package/extensions/auto-mode/types.ts +33 -1
- 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
|
|
@@ -52,14 +52,15 @@ AM● a:12 d:2 ca:5 cd:1
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
- `AM` — auto-mode prefix; `●` when enabled, `○` when disabled (via config or `/automode off`).
|
|
55
|
-
- `a:` — actions allowed so far (checked minus
|
|
56
|
-
- `d:` — actions
|
|
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
|
|
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
58
|
|
|
59
59
|
## Docs
|
|
60
60
|
|
|
61
61
|
- [Defaults and rule-list behavior](docs/defaults.md)
|
|
62
62
|
- [Auto-mode classifier flow](docs/automode-classifier-flow.md)
|
|
63
|
+
- [Observability logging](docs/observability-logging.md)
|
|
63
64
|
|
|
64
65
|
## Configuration
|
|
65
66
|
|
|
@@ -73,6 +74,18 @@ It reads `autoMode` from Pi-owned config only:
|
|
|
73
74
|
|
|
74
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`.
|
|
75
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
|
+
|
|
76
89
|
Set a global default classifier model in `~/.pi/agent/automode.json`; override it per project in `.pi/automode.local.json`.
|
|
77
90
|
|
|
78
91
|
Example:
|
|
@@ -107,6 +120,23 @@ Example:
|
|
|
107
120
|
|
|
108
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.
|
|
109
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
|
+
|
|
110
140
|
### Permission patterns
|
|
111
141
|
|
|
112
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
|
|
@@ -101,6 +134,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
101
134
|
function block(
|
|
102
135
|
ctx: ExtensionContext,
|
|
103
136
|
denial: DenialRecord,
|
|
137
|
+
logCtx: LogCtx,
|
|
104
138
|
): { block: true; reason: string } {
|
|
105
139
|
state.blockedActions += 1;
|
|
106
140
|
state.lastDecision = "block";
|
|
@@ -108,6 +142,21 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
108
142
|
pushDenial(state, denial);
|
|
109
143
|
persist();
|
|
110
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
|
+
}
|
|
111
160
|
if (ctx.hasUI) {
|
|
112
161
|
ctx.ui.notify(
|
|
113
162
|
`Auto mode blocked ${denial.toolName}: ${denial.reason}`,
|
|
@@ -117,6 +166,36 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
117
166
|
return { block: true, reason: `[pi-automode] ${denial.reason}` };
|
|
118
167
|
}
|
|
119
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
|
+
|
|
120
199
|
pi.on("session_start", (_event, ctx) => {
|
|
121
200
|
loadResult = loadConfigWithDiagnostics(ctx.cwd);
|
|
122
201
|
config = loadResult.config;
|
|
@@ -147,6 +226,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
147
226
|
const input = event.input as Record<string, unknown>;
|
|
148
227
|
const summary = actionSummary(event.toolName, input);
|
|
149
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
|
+
};
|
|
150
240
|
|
|
151
241
|
for (const pattern of cfg.permissionDeny) {
|
|
152
242
|
if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
|
|
@@ -156,7 +246,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
156
246
|
reason: `Blocked by permissions.deny: ${pattern.raw}`,
|
|
157
247
|
action: summary,
|
|
158
248
|
kind: "permissions.deny",
|
|
159
|
-
});
|
|
249
|
+
}, logCtx);
|
|
160
250
|
}
|
|
161
251
|
}
|
|
162
252
|
|
|
@@ -172,7 +262,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
172
262
|
`Matched permissions.ask (${pattern.raw}) but no UI is available`,
|
|
173
263
|
action: summary,
|
|
174
264
|
kind: "permissions.ask",
|
|
175
|
-
});
|
|
265
|
+
}, logCtx);
|
|
176
266
|
}
|
|
177
267
|
const allowed = await ctx.ui.confirm(
|
|
178
268
|
"Auto mode permission ask",
|
|
@@ -186,7 +276,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
186
276
|
reason: `Declined permissions.ask: ${pattern.raw}`,
|
|
187
277
|
action: summary,
|
|
188
278
|
kind: "permissions.ask",
|
|
189
|
-
});
|
|
279
|
+
}, logCtx);
|
|
190
280
|
}
|
|
191
281
|
}
|
|
192
282
|
|
|
@@ -202,15 +292,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
202
292
|
reason: deterministicReason,
|
|
203
293
|
action: summary,
|
|
204
294
|
kind: "deterministic-hard-deny",
|
|
205
|
-
});
|
|
295
|
+
}, logCtx);
|
|
206
296
|
}
|
|
207
297
|
|
|
208
298
|
if (READ_ONLY_TOOLS.has(event.toolName)) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
299
|
+
return allow(
|
|
300
|
+
ctx,
|
|
301
|
+
"read-only",
|
|
302
|
+
`Read-only built-in tool: ${event.toolName}`,
|
|
303
|
+
event.toolName,
|
|
304
|
+
summary,
|
|
305
|
+
logCtx,
|
|
306
|
+
);
|
|
214
307
|
}
|
|
215
308
|
|
|
216
309
|
// Protected paths go to the classifier regardless of allow rules.
|
|
@@ -218,13 +311,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
218
311
|
const path = resolveInputPath(ctx.cwd, input.path);
|
|
219
312
|
if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
|
|
220
313
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
314
|
+
logClassifierIo(decision, logCtx);
|
|
221
315
|
if (decision.decision === "allow") {
|
|
222
316
|
state.classifierAllowed += 1;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
317
|
+
return allow(
|
|
318
|
+
ctx,
|
|
319
|
+
"classifier",
|
|
320
|
+
decision.reason,
|
|
321
|
+
event.toolName,
|
|
322
|
+
summary,
|
|
323
|
+
logCtx,
|
|
324
|
+
);
|
|
228
325
|
}
|
|
229
326
|
state.classifierDenied += 1;
|
|
230
327
|
return block(ctx, {
|
|
@@ -233,18 +330,22 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
233
330
|
reason: decision.reason,
|
|
234
331
|
action: summary,
|
|
235
332
|
kind: "classifier",
|
|
236
|
-
});
|
|
333
|
+
}, logCtx);
|
|
237
334
|
}
|
|
238
335
|
}
|
|
239
336
|
|
|
240
337
|
const decision = await classify(ctx, cfg, summary, loadedContext);
|
|
338
|
+
logClassifierIo(decision, logCtx);
|
|
241
339
|
if (decision.decision === "allow") {
|
|
242
340
|
state.classifierAllowed += 1;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
341
|
+
return allow(
|
|
342
|
+
ctx,
|
|
343
|
+
"classifier",
|
|
344
|
+
decision.reason,
|
|
345
|
+
event.toolName,
|
|
346
|
+
summary,
|
|
347
|
+
logCtx,
|
|
348
|
+
);
|
|
248
349
|
}
|
|
249
350
|
|
|
250
351
|
state.classifierDenied += 1;
|
|
@@ -254,7 +355,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
254
355
|
reason: decision.reason,
|
|
255
356
|
action: summary,
|
|
256
357
|
kind: "classifier",
|
|
257
|
-
});
|
|
358
|
+
}, logCtx);
|
|
258
359
|
});
|
|
259
360
|
|
|
260
361
|
async function handleAutomodeCommand(
|
|
@@ -328,9 +429,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
|
|
|
328
429
|
return;
|
|
329
430
|
}
|
|
330
431
|
if (command === "config") {
|
|
432
|
+
const logFile = resolveLogPath(
|
|
433
|
+
ctx.sessionManager.getSessionFile?.(),
|
|
434
|
+
ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
|
|
435
|
+
ctx.sessionManager.getSessionId?.() ?? "unknown",
|
|
436
|
+
);
|
|
331
437
|
ctx.ui.notify(
|
|
332
438
|
safeJson(
|
|
333
|
-
{
|
|
439
|
+
{
|
|
440
|
+
config: effectiveConfig(),
|
|
441
|
+
logFile,
|
|
442
|
+
diagnostics: configDiagnostics,
|
|
443
|
+
},
|
|
334
444
|
16000,
|
|
335
445
|
),
|
|
336
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
|
+
}
|
|
@@ -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 = {
|
|
@@ -70,12 +79,35 @@ export type DenialRecord = {
|
|
|
70
79
|
| "setup";
|
|
71
80
|
};
|
|
72
81
|
|
|
82
|
+
/** Denial kind plus the read-only fast path, used for decision log entries. */
|
|
83
|
+
export type DecisionKind = DenialRecord["kind"] | "read-only";
|
|
84
|
+
|
|
73
85
|
export type ClassificationDecision = {
|
|
74
86
|
decision: "allow" | "block";
|
|
75
87
|
tier: "hard_deny" | "soft_deny" | "allow" | "explicit_intent" | "none";
|
|
76
88
|
reason: string;
|
|
77
89
|
};
|
|
78
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
|
+
|
|
79
111
|
export type SettingsSources = {
|
|
80
112
|
globalSettings?: SettingsFile[];
|
|
81
113
|
projectLocalSettings?: SettingsFile[];
|
|
@@ -93,4 +125,4 @@ export type ClassifyAction = (
|
|
|
93
125
|
config: EffectiveConfig,
|
|
94
126
|
action: string,
|
|
95
127
|
loadedContext: string,
|
|
96
|
-
) => 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";
|