pi-warden 0.64.1 → 0.71.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/CHANGELOG.md +95 -0
- package/README.md +49 -93
- package/dist/adaptive.d.ts +132 -0
- package/dist/adaptive.js +274 -0
- package/dist/atomic.d.ts +10 -0
- package/dist/atomic.js +51 -0
- package/dist/config.d.ts +40 -1
- package/dist/config.js +37 -9
- package/dist/evidence.d.ts +93 -0
- package/dist/evidence.js +409 -0
- package/dist/extension.js +213 -40
- package/dist/extension.js.bak +668 -102
- package/dist/index.d.ts +10 -2
- package/dist/index.js +5 -1
- package/dist/loops.d.ts +10 -0
- package/dist/loops.js +15 -5
- package/dist/prefs.d.ts +8 -0
- package/dist/prefs.js +15 -5
- package/dist/rules-lint.d.ts +84 -0
- package/dist/rules-lint.js +191 -0
- package/dist/rules-log.d.ts +98 -0
- package/dist/rules-log.js +190 -0
- package/dist/rules-report.d.ts +52 -0
- package/dist/rules-report.js +83 -0
- package/dist/rules.d.ts +24 -2
- package/dist/rules.js +154 -33
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +14 -3
- package/dist/stuck.d.ts +34 -11
- package/dist/stuck.js +20 -8
- package/dist/trace.d.ts +1 -1
- package/dist/waste.d.ts +98 -0
- package/dist/waste.js +582 -0
- package/package.json +4 -2
package/dist/adaptive.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { writeFileAtomicSync } from "./atomic.js";
|
|
4
|
+
import { userConfigPath } from "./config.js";
|
|
5
|
+
export const STEER_KINDS = [
|
|
6
|
+
"intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "sensitive-path",
|
|
7
|
+
"security-write", "stuck", "repeat", "done", "prose", "conscience", "loops", "runaway", "subagent",
|
|
8
|
+
"hold", "confirm", "deny", "masking", "credential-notice",
|
|
9
|
+
];
|
|
10
|
+
/**
|
|
11
|
+
* Never trace-only, whatever a model does with them: holds, confirm and deny verdicts, security masking, the credential
|
|
12
|
+
* notice on masked output, and the done-check. Runaway recovery and subagent wakes start a turn of their own; muting
|
|
13
|
+
* them would change what the run does, not only what the agent is told.
|
|
14
|
+
*/
|
|
15
|
+
export const NEVER_MUTED = new Set([
|
|
16
|
+
"hold", "confirm", "deny", "masking", "credential-notice", "done", "runaway", "subagent",
|
|
17
|
+
]);
|
|
18
|
+
/** Where the counts live: next to the user config, in pi-warden's data folder. */
|
|
19
|
+
export function steerStatsPath() {
|
|
20
|
+
return process.env.PI_WARDEN_STEER_STATS ?? join(dirname(userConfigPath()), "steer-stats.json");
|
|
21
|
+
}
|
|
22
|
+
export const DEFAULT_STEERS = { adaptive: true, minSteers: 30, minFollowed: 0.2, maxDisputed: 0.4, recheckEvery: 30, probeEvery: 5 };
|
|
23
|
+
const zero = () => ({ sent: 0, followed: 0, disputed: 0 });
|
|
24
|
+
const fresh = () => ({ ...zero(), muted: false, asked: 0, probe: zero() });
|
|
25
|
+
/** Kinds whose "followed" can be read from the next calls or text; the rest are judged by disputes alone. */
|
|
26
|
+
const FOLLOW_MEASURED = new Set([
|
|
27
|
+
"intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "security-write",
|
|
28
|
+
"stuck", "repeat", "done", "conscience", "loops",
|
|
29
|
+
]);
|
|
30
|
+
/** True when the counts put the pair under the follow floor or over the dispute ceiling. */
|
|
31
|
+
export function failing(kind, counts, config) {
|
|
32
|
+
if (!counts.sent)
|
|
33
|
+
return false;
|
|
34
|
+
return (FOLLOW_MEASURED.has(kind) && counts.followed / counts.sent < config.minFollowed) || counts.disputed / counts.sent > config.maxDisputed;
|
|
35
|
+
}
|
|
36
|
+
/** The counts per (model, kind) pair, read once and written after every change. */
|
|
37
|
+
export class SteerStats {
|
|
38
|
+
path;
|
|
39
|
+
data;
|
|
40
|
+
constructor(path = steerStatsPath()) {
|
|
41
|
+
this.path = path;
|
|
42
|
+
}
|
|
43
|
+
load() {
|
|
44
|
+
if (this.data)
|
|
45
|
+
return this.data;
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(readFileSync(this.path, "utf8"));
|
|
48
|
+
this.data = raw?.version === 1 && typeof raw.models === "object" && raw.models ? raw : { version: 1, models: {} };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
this.data = { version: 1, models: {} };
|
|
52
|
+
}
|
|
53
|
+
return this.data;
|
|
54
|
+
}
|
|
55
|
+
save() {
|
|
56
|
+
try {
|
|
57
|
+
writeFileAtomicSync(this.path, `${JSON.stringify(this.data)}\n`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.warn("pi-warden: steer stats write failed:", error instanceof Error ? error.message : String(error));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
pair(model, kind) {
|
|
64
|
+
return this.load().models[model]?.[kind];
|
|
65
|
+
}
|
|
66
|
+
ensure(model, kind) {
|
|
67
|
+
const models = this.load().models;
|
|
68
|
+
const kinds = models[model] ??= {};
|
|
69
|
+
return kinds[kind] ??= fresh();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Called before a steer of this kind goes out. A trace-only pair still sends the first of every `probeEvery` steers;
|
|
73
|
+
* after `recheckEvery` steers the probes decide: rates within the thresholds end trace-only, and the probe counts
|
|
74
|
+
* become the pair's new counts, so a fresh `minSteers` must pass before it can be muted again.
|
|
75
|
+
*/
|
|
76
|
+
request(model, kind, config) {
|
|
77
|
+
if (!config.adaptive || NEVER_MUTED.has(kind))
|
|
78
|
+
return "send";
|
|
79
|
+
const pair = this.pair(model, kind);
|
|
80
|
+
if (!pair?.muted)
|
|
81
|
+
return "send";
|
|
82
|
+
const probe = pair.asked % Math.max(1, config.probeEvery) === 0;
|
|
83
|
+
pair.asked++;
|
|
84
|
+
if (pair.asked >= config.recheckEvery) {
|
|
85
|
+
if (pair.probe.sent > 0 && !failing(kind, pair.probe, config))
|
|
86
|
+
Object.assign(pair, { ...pair.probe, muted: false, asked: 0, probe: zero() });
|
|
87
|
+
else
|
|
88
|
+
Object.assign(pair, { asked: 0, probe: zero() });
|
|
89
|
+
}
|
|
90
|
+
this.save();
|
|
91
|
+
return probe ? "probe" : "trace-only";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The outcome of one sent steer. Crossing the thresholds at `minSteers` or later makes the pair trace-only. Kinds in
|
|
95
|
+
* NEVER_MUTED are counted, for the status, and never muted.
|
|
96
|
+
*/
|
|
97
|
+
observe(model, kind, outcome, config) {
|
|
98
|
+
if (!config.adaptive)
|
|
99
|
+
return;
|
|
100
|
+
const pair = this.ensure(model, kind);
|
|
101
|
+
const add = (counts) => {
|
|
102
|
+
counts.sent++;
|
|
103
|
+
if (outcome.followed)
|
|
104
|
+
counts.followed++;
|
|
105
|
+
if (outcome.disputed)
|
|
106
|
+
counts.disputed++;
|
|
107
|
+
};
|
|
108
|
+
add(pair);
|
|
109
|
+
if (pair.muted)
|
|
110
|
+
add(pair.probe);
|
|
111
|
+
else if (!NEVER_MUTED.has(kind) && pair.sent >= config.minSteers && failing(kind, pair, config))
|
|
112
|
+
Object.assign(pair, { muted: true, asked: 0, probe: zero() });
|
|
113
|
+
this.save();
|
|
114
|
+
}
|
|
115
|
+
/** Drops the pair's counts; its steers are sent again. True when there was something to reset. */
|
|
116
|
+
unmute(model, kind) {
|
|
117
|
+
const kinds = this.load().models[model];
|
|
118
|
+
if (!kinds?.[kind])
|
|
119
|
+
return false;
|
|
120
|
+
delete kinds[kind];
|
|
121
|
+
if (!Object.keys(kinds).length)
|
|
122
|
+
delete this.load().models[model];
|
|
123
|
+
this.save();
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/** Every trace-only pair, per model. */
|
|
127
|
+
muted() {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const [model, kinds] of Object.entries(this.load().models)) {
|
|
130
|
+
for (const [kind, pair] of Object.entries(kinds)) {
|
|
131
|
+
if (pair.muted)
|
|
132
|
+
out.push({ model, kind, sent: pair.sent, followed: pair.followed, disputed: pair.disputed });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out.sort((a, b) => a.model.localeCompare(b.model) || a.kind.localeCompare(b.kind));
|
|
136
|
+
}
|
|
137
|
+
/** Models with a pair of this kind, for `/warden unmute` without a model. */
|
|
138
|
+
modelsWith(kind) {
|
|
139
|
+
return Object.entries(this.load().models).filter(([, kinds]) => kinds[kind]).map(([model]) => model);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const percent = (part, whole) => `${whole ? Math.round((part / whole) * 100) : 0}%`;
|
|
143
|
+
export function formatMuted(pairs, config) {
|
|
144
|
+
if (!config.adaptive)
|
|
145
|
+
return "Adaptive steers: off.";
|
|
146
|
+
if (!pairs.length)
|
|
147
|
+
return "Adaptive steers: no steer kind is trace-only for any model.";
|
|
148
|
+
const byModel = new Map();
|
|
149
|
+
for (const pair of pairs)
|
|
150
|
+
byModel.set(pair.model, [...(byModel.get(pair.model) ?? []), pair]);
|
|
151
|
+
const models = [...byModel].map(([model, list]) => `${model}: ${list.map(pair => `${pair.kind} (${pair.sent} steers, ${percent(pair.followed, pair.sent)} followed, ${percent(pair.disputed, pair.sent)} disputed)`).join(", ")}`);
|
|
152
|
+
return `Adaptive steers, trace-only per model: ${models.join("; ")}. /warden unmute <kind> resets a pair.`;
|
|
153
|
+
}
|
|
154
|
+
/** Agent replies that argue with a note instead of acting on it. The same pattern the field replays used. */
|
|
155
|
+
export const DISPUTE = /false positive|false alarm|no (real )?(credentials|secrets)|not (a |real )?(secret|credential|token)s?|(only|just) (placeholder|fixture|test|dummy|fake)|as (I )?(said|planned|stated)|matches (what I said|the plan)|this is expected|warden (is wrong|misfired|misread)|misfire|spurious|harmless|benign|nothing sensitive|not sensitive|was intended|intended/i;
|
|
156
|
+
const CHECK = /\b(test|tests|vitest|jest|pytest|build|lint|tsc|check|make|cargo|go test|flutter (test|analyze)|npm run|eslint|typecheck)\b/i;
|
|
157
|
+
const UNVERIFIED = /not (yet )?(verified|tested)|unverified|did not run|didn't run/i;
|
|
158
|
+
const REPLAN = /\b(instead|re-?plan(ned|ning)?|ask(ing)? (you|the user|first|before)|should I|do you want)\b/i;
|
|
159
|
+
const commandText = (input) => typeof input.command === "string" ? input.command : typeof input.path === "string" ? input.path : JSON.stringify(input);
|
|
160
|
+
/**
|
|
161
|
+
* Whether the next assistant messages (at most two) changed course after a steer of this kind, and whether they
|
|
162
|
+
* disputed it. A port of the heuristic the field replays used; it is a proxy and does not prove the note caused the change.
|
|
163
|
+
*/
|
|
164
|
+
export function steerOutcome(kind, subject, views) {
|
|
165
|
+
const text = views.map(view => view.text).join("\n");
|
|
166
|
+
const calls = views.flatMap(view => view.calls);
|
|
167
|
+
const disputed = DISPUTE.test(text);
|
|
168
|
+
let followed = false;
|
|
169
|
+
switch (kind) {
|
|
170
|
+
case "done":
|
|
171
|
+
followed = calls.some(call => call.name === "bash" && CHECK.test(commandText(call.input))) || UNVERIFIED.test(text);
|
|
172
|
+
break;
|
|
173
|
+
case "rules":
|
|
174
|
+
case "slop":
|
|
175
|
+
case "security-write": {
|
|
176
|
+
const path = subject.path;
|
|
177
|
+
followed = !!path && calls.some(call => /edit|write/i.test(call.name) ? commandText(call.input).endsWith(path) : call.name === "bash" && commandText(call.input).includes(path));
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "stuck":
|
|
181
|
+
case "repeat": {
|
|
182
|
+
const next = calls[0];
|
|
183
|
+
followed = !!next && (!subject.call || next.name !== subject.call.name || commandText(next.input) !== commandText(subject.call.input));
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case "intent-mismatch":
|
|
187
|
+
case "off-task":
|
|
188
|
+
case "should-proceed":
|
|
189
|
+
case "warn-headless":
|
|
190
|
+
case "large-output":
|
|
191
|
+
followed = (!calls.length && /\?\s*$/.test(views[0]?.text ?? "")) || REPLAN.test(text);
|
|
192
|
+
break;
|
|
193
|
+
case "conscience": {
|
|
194
|
+
const target = subject.capability;
|
|
195
|
+
followed = !!target && calls.some(call => target.kind === "tool" ? call.name === target.id || call.name.endsWith(target.id) : commandText(call.input).includes(`/${target.id}/`));
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "loops":
|
|
199
|
+
followed = calls.some(call => call.name === "warden_loops");
|
|
200
|
+
break;
|
|
201
|
+
default:
|
|
202
|
+
followed = false;
|
|
203
|
+
}
|
|
204
|
+
return { followed, disputed };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Sent steers waiting for the agent's next two messages. A steer queued for the next turn waits past the next user
|
|
208
|
+
* message; any other steer that meets a user message first is settled on what it saw, or dropped when it saw nothing.
|
|
209
|
+
*/
|
|
210
|
+
export class SteerWatch {
|
|
211
|
+
pending = [];
|
|
212
|
+
add(model, kinds, subject = {}, options = {}) {
|
|
213
|
+
if (!kinds.length)
|
|
214
|
+
return;
|
|
215
|
+
this.pending.push({ model, kinds: [...kinds], subject, views: [], waitUser: options.nextTurn === true });
|
|
216
|
+
if (this.pending.length > 50)
|
|
217
|
+
this.pending.shift();
|
|
218
|
+
}
|
|
219
|
+
/** Feeds one assistant message; returns the steers it settled. */
|
|
220
|
+
assistant(view) {
|
|
221
|
+
const settled = [];
|
|
222
|
+
const keep = [];
|
|
223
|
+
for (const item of this.pending) {
|
|
224
|
+
if (item.waitUser) {
|
|
225
|
+
keep.push(item);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
item.views.push(view);
|
|
229
|
+
const outcomes = item.kinds.map(kind => ({ kind, ...steerOutcome(kind, item.subject, item.views) }));
|
|
230
|
+
if (item.views.length >= 2 || outcomes.every(outcome => outcome.followed || outcome.disputed)) {
|
|
231
|
+
for (const outcome of outcomes)
|
|
232
|
+
settled.push({ model: item.model, ...outcome });
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
keep.push(item);
|
|
236
|
+
}
|
|
237
|
+
this.pending = keep;
|
|
238
|
+
return settled;
|
|
239
|
+
}
|
|
240
|
+
/** A user message arrived. */
|
|
241
|
+
user() {
|
|
242
|
+
const settled = [];
|
|
243
|
+
const keep = [];
|
|
244
|
+
for (const item of this.pending) {
|
|
245
|
+
if (item.waitUser) {
|
|
246
|
+
item.waitUser = false;
|
|
247
|
+
keep.push(item);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (item.views.length)
|
|
251
|
+
for (const kind of item.kinds)
|
|
252
|
+
settled.push({ model: item.model, kind, ...steerOutcome(kind, item.subject, item.views) });
|
|
253
|
+
}
|
|
254
|
+
this.pending = keep;
|
|
255
|
+
return settled;
|
|
256
|
+
}
|
|
257
|
+
clear() { this.pending = []; }
|
|
258
|
+
}
|
|
259
|
+
/** The text and tool calls of an assistant message as Pi stores it. */
|
|
260
|
+
export function assistantView(content) {
|
|
261
|
+
if (typeof content === "string")
|
|
262
|
+
return { text: content, calls: [] };
|
|
263
|
+
if (!Array.isArray(content))
|
|
264
|
+
return { text: "", calls: [] };
|
|
265
|
+
const text = [];
|
|
266
|
+
const calls = [];
|
|
267
|
+
for (const part of content) {
|
|
268
|
+
if (part?.type === "text" && typeof part.text === "string")
|
|
269
|
+
text.push(part.text);
|
|
270
|
+
else if (part?.type === "toolCall" && typeof part.name === "string")
|
|
271
|
+
calls.push({ name: part.name, input: (part.arguments && typeof part.arguments === "object" ? part.arguments : {}) });
|
|
272
|
+
}
|
|
273
|
+
return { text: text.join("\n"), calls };
|
|
274
|
+
}
|
package/dist/atomic.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Writes `text` to `path` through a temporary file and a rename, so a crash or a reader never sees half a file. */
|
|
2
|
+
export declare function writeFileAtomic(path: string, text: string): Promise<void>;
|
|
3
|
+
/** `writeFileAtomic` for the synchronous stores, which cannot interleave with each other inside one process. */
|
|
4
|
+
export declare function writeFileAtomicSync(path: string, text: string): void;
|
|
5
|
+
/**
|
|
6
|
+
* Runs `task` after every earlier task for `key`, so the read-modify-write sequences that share a store file run one
|
|
7
|
+
* after another instead of all reading the same old store and then writing over each other's change. A task's rejection
|
|
8
|
+
* reaches its own caller and does not stop the queue.
|
|
9
|
+
*/
|
|
10
|
+
export declare function serialize<T>(key: string, task: () => Promise<T> | T): Promise<T>;
|
package/dist/atomic.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-file writes and per-file queues for pi-warden's small JSON stores. Two operations on one store in one process
|
|
3
|
+
* share no temporary name and do not interleave: every write goes to a temporary file that belongs to this process and
|
|
4
|
+
* this write, then a rename puts it in place, and every read-modify-write of one file runs through a queue keyed by that
|
|
5
|
+
* path. Cross-process locking is out of scope: a second process writing the same store can still lose this process's
|
|
6
|
+
* update, and nothing here stops it.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
let writes = 0;
|
|
12
|
+
/** The temporary file one write lands in: the pid keeps two processes apart, the counter two writes within one process. */
|
|
13
|
+
function temporaryPath(path) {
|
|
14
|
+
writes += 1;
|
|
15
|
+
return `${path}.${process.pid}.${writes}.tmp`;
|
|
16
|
+
}
|
|
17
|
+
/** Writes `text` to `path` through a temporary file and a rename, so a crash or a reader never sees half a file. */
|
|
18
|
+
export async function writeFileAtomic(path, text) {
|
|
19
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
20
|
+
const temporary = temporaryPath(path);
|
|
21
|
+
await writeFile(temporary, text, { mode: 0o600 });
|
|
22
|
+
// The mode applies only when the temporary file is created; force it so a leftover file cannot change the store's permissions.
|
|
23
|
+
await chmod(temporary, 0o600);
|
|
24
|
+
await rename(temporary, path);
|
|
25
|
+
}
|
|
26
|
+
/** `writeFileAtomic` for the synchronous stores, which cannot interleave with each other inside one process. */
|
|
27
|
+
export function writeFileAtomicSync(path, text) {
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
29
|
+
const temporary = temporaryPath(path);
|
|
30
|
+
writeFileSync(temporary, text, { mode: 0o600 });
|
|
31
|
+
chmodSync(temporary, 0o600);
|
|
32
|
+
renameSync(temporary, path);
|
|
33
|
+
}
|
|
34
|
+
/** The tail of each path's queue: a task starts once the previous task for the same path has settled. */
|
|
35
|
+
const queues = new Map();
|
|
36
|
+
/**
|
|
37
|
+
* Runs `task` after every earlier task for `key`, so the read-modify-write sequences that share a store file run one
|
|
38
|
+
* after another instead of all reading the same old store and then writing over each other's change. A task's rejection
|
|
39
|
+
* reaches its own caller and does not stop the queue.
|
|
40
|
+
*/
|
|
41
|
+
export function serialize(key, task) {
|
|
42
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
43
|
+
const run = previous.then(task);
|
|
44
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
45
|
+
queues.set(key, settled);
|
|
46
|
+
void settled.then(() => {
|
|
47
|
+
if (queues.get(key) === settled)
|
|
48
|
+
queues.delete(key);
|
|
49
|
+
});
|
|
50
|
+
return run;
|
|
51
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface StuckGuardConfig {
|
|
|
131
131
|
nudge: boolean;
|
|
132
132
|
/** Steer on the 2nd identical call when nothing changed between and it failed the same way or re-read the same output. Code only. */
|
|
133
133
|
repeatSteer: boolean;
|
|
134
|
+
/** Send the judge a compact structured `evidence` section: the parsed failure per run, the edit diffs, and a digest. */
|
|
135
|
+
evidence: boolean;
|
|
134
136
|
/** Maximum characters of the unified line diff in a stuck-loop diff note. */
|
|
135
137
|
diffLimit: number;
|
|
136
138
|
/** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
|
|
@@ -190,6 +192,8 @@ export interface RulesConfig {
|
|
|
190
192
|
enabled: boolean;
|
|
191
193
|
/** P(violation) at or above this names the rule to the agent. */
|
|
192
194
|
threshold: number;
|
|
195
|
+
/** A score from here up to the rule's cutoff becomes a soft "please double-check" steer; `0` turns the tier off. */
|
|
196
|
+
softThreshold: number;
|
|
193
197
|
/** Project-relative Markdown rule files, used when the root pi-warden.md is absent. All are sent in one request. */
|
|
194
198
|
files: string[];
|
|
195
199
|
/** With no rules file, README.md, CLAUDE.md, or AGENTS.md (first found) is judged as one document. */
|
|
@@ -270,12 +274,43 @@ export interface SubagentConfig {
|
|
|
270
274
|
export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
|
|
271
275
|
export declare function isRecallTool(value: unknown): value is RecallTool;
|
|
272
276
|
export type WardenMode = "steer" | "confirm" | "advise";
|
|
277
|
+
export interface WasteConfig {
|
|
278
|
+
/** Master switch for the call-waste notes and the session tip. */
|
|
279
|
+
enabled: boolean;
|
|
280
|
+
/** Append the session tip to the prompt once per session. */
|
|
281
|
+
tip: boolean;
|
|
282
|
+
/** Tool calls between two notes from the same detector. */
|
|
283
|
+
every: number;
|
|
284
|
+
/** Note on repeated poll calls (`sleep`). */
|
|
285
|
+
sleep: boolean;
|
|
286
|
+
/** Note on adjacent range reads of one file. */
|
|
287
|
+
paging: boolean;
|
|
288
|
+
/** Note on repeated searches of one file with an overlapping pattern. */
|
|
289
|
+
search: boolean;
|
|
290
|
+
/** Note on a filtered check re-run. */
|
|
291
|
+
recheck: boolean;
|
|
292
|
+
}
|
|
273
293
|
export interface PrefsConfig {
|
|
274
294
|
/** Read this project's earlier session files for preferences the user repeated (`/warden prefs`). Local, code only. */
|
|
275
295
|
enabled: boolean;
|
|
276
296
|
/** At session start, send the standing preferences that pass every injection rule to the agent as one context message. */
|
|
277
297
|
inject: boolean;
|
|
278
298
|
}
|
|
299
|
+
/** Per-model steer adaptation: a steer kind a model rarely follows or often disputes becomes trace-only for it. */
|
|
300
|
+
export interface SteersConfig {
|
|
301
|
+
/** Make a steer kind trace-only for a model that rarely follows or often disputes it. */
|
|
302
|
+
adaptive: boolean;
|
|
303
|
+
/** Steers observed for a (model, kind) pair before it can become trace-only. */
|
|
304
|
+
minSteers: number;
|
|
305
|
+
/** Trace-only when the followed share is under this. Kinds with no follow measure are judged by disputes alone. */
|
|
306
|
+
minFollowed: number;
|
|
307
|
+
/** Trace-only when the disputed share is over this. */
|
|
308
|
+
maxDisputed: number;
|
|
309
|
+
/** A trace-only pair is re-checked after this many further steers. */
|
|
310
|
+
recheckEvery: number;
|
|
311
|
+
/** While trace-only, 1 in this many steers is still sent, so the re-check has fresh data. */
|
|
312
|
+
probeEvery: number;
|
|
313
|
+
}
|
|
279
314
|
export interface LearningConfig {
|
|
280
315
|
/** Enable adaptive thresholds based on learning data. */
|
|
281
316
|
adaptiveThresholds: boolean;
|
|
@@ -366,16 +401,20 @@ export interface WardenConfig {
|
|
|
366
401
|
* steer costs at least one LLM turn, and a closing run that collects six notices collects six restatements of the final
|
|
367
402
|
* status. 0 disables the budget. Critical guards (stuck, done, runaway, subagent wake) always deliver. */
|
|
368
403
|
steerBudget: number;
|
|
404
|
+
/** Per-model steer adaptation. */
|
|
405
|
+
steers: SteersConfig;
|
|
369
406
|
/** Learning and adaptation settings. */
|
|
370
407
|
learning: LearningConfig;
|
|
371
408
|
/** Broad-consideration coach: recommends or loads skills and tools before the agent acts. */
|
|
372
409
|
conscience: ConscienceConfig;
|
|
373
410
|
/** Standing preferences: corrections the user repeated in earlier sessions of this project. */
|
|
374
411
|
prefs: PrefsConfig;
|
|
412
|
+
/** Call-waste notes: advisory sentences attached to the tool result that triggers them. */
|
|
413
|
+
waste: WasteConfig;
|
|
375
414
|
}
|
|
376
415
|
export declare const PACKAGE_NAME = "pi-warden";
|
|
377
416
|
/** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
|
|
378
|
-
export declare const CONFIG_SCHEMA =
|
|
417
|
+
export declare const CONFIG_SCHEMA = 10;
|
|
379
418
|
export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
|
|
380
419
|
export declare function defaultConfig(): WardenConfig;
|
|
381
420
|
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { writeFileAtomicSync } from "./atomic.js";
|
|
4
5
|
import { resolveBackend } from "./backend.js";
|
|
5
6
|
import { COMMAND_TOOLS } from "./tools.js";
|
|
6
7
|
import { defaultWidgetConfig } from "./widget.js";
|
|
@@ -23,7 +24,7 @@ export function isRecallTool(value) {
|
|
|
23
24
|
}
|
|
24
25
|
export const PACKAGE_NAME = "pi-warden";
|
|
25
26
|
/** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
|
|
26
|
-
export const CONFIG_SCHEMA =
|
|
27
|
+
export const CONFIG_SCHEMA = 10;
|
|
27
28
|
export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
|
|
28
29
|
export function defaultConfig() {
|
|
29
30
|
return {
|
|
@@ -53,11 +54,11 @@ export function defaultConfig() {
|
|
|
53
54
|
escalationThreshold: 0.85,
|
|
54
55
|
floor: "evidence",
|
|
55
56
|
},
|
|
56
|
-
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, repeatSteer: true, diffLimit: 3000, tailLimit: 1000 },
|
|
57
|
+
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, repeatSteer: true, evidence: true, diffLimit: 3000, tailLimit: 1000 },
|
|
57
58
|
done: { enabled: true, claimsDone: 0.7, nudge: true, uiProof: true, uiFiles: [...DEFAULT_UI_FILES], visualTools: defaultVisualTools() },
|
|
58
59
|
slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
|
|
59
60
|
security: { enabled: true, threshold: 0.7, maskOutput: true },
|
|
60
|
-
rules: { enabled: true, threshold: 0.7, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
|
|
61
|
+
rules: { enabled: true, threshold: 0.7, softThreshold: 0, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
|
|
61
62
|
context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7, compactAppendix: true, dedupeRuns: true, dedupeMessages: false, largeOutput: { enabled: true, threshold: 0.85 } },
|
|
62
63
|
runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
|
|
63
64
|
notify: { enabled: false, cooldownMs: 10000, command: [] },
|
|
@@ -67,6 +68,7 @@ export function defaultConfig() {
|
|
|
67
68
|
steerVisible: false,
|
|
68
69
|
notices: false,
|
|
69
70
|
steerBudget: 3,
|
|
71
|
+
steers: { adaptive: true, minSteers: 30, minFollowed: 0.2, maxDisputed: 0.4, recheckEvery: 30, probeEvery: 5 },
|
|
70
72
|
learning: { adaptiveThresholds: true, patternAnalysis: true, minHoldsForAdaptive: 20, adaptationRate: 0.1, retentionDays: 365 },
|
|
71
73
|
conscience: {
|
|
72
74
|
enabled: false,
|
|
@@ -84,6 +86,7 @@ export function defaultConfig() {
|
|
|
84
86
|
loadThreshold: 1.0,
|
|
85
87
|
},
|
|
86
88
|
prefs: { enabled: true, inject: true },
|
|
89
|
+
waste: { enabled: true, tip: false, every: 20, sleep: true, paging: true, search: true, recheck: true },
|
|
87
90
|
};
|
|
88
91
|
}
|
|
89
92
|
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|
|
@@ -317,6 +320,7 @@ function applyStuck(base, raw) {
|
|
|
317
320
|
churnThreshold: Math.min(window, positiveInteger(raw.churnThreshold, base.churnThreshold)),
|
|
318
321
|
nudge: boolean(raw.nudge, base.nudge),
|
|
319
322
|
repeatSteer: boolean(raw.repeatSteer, base.repeatSteer),
|
|
323
|
+
evidence: boolean(raw.evidence, base.evidence),
|
|
320
324
|
diffLimit: positiveInteger(raw.diffLimit, base.diffLimit),
|
|
321
325
|
tailLimit: positiveInteger(raw.tailLimit, base.tailLimit),
|
|
322
326
|
};
|
|
@@ -402,6 +406,7 @@ function applyRules(base, raw) {
|
|
|
402
406
|
return {
|
|
403
407
|
enabled: boolean(raw.enabled, base.enabled),
|
|
404
408
|
threshold: probability(raw.threshold, base.threshold),
|
|
409
|
+
softThreshold: probability(raw.softThreshold, base.softThreshold),
|
|
405
410
|
files: globList(raw.files, base.files),
|
|
406
411
|
fallback: boolean(raw.fallback, base.fallback),
|
|
407
412
|
maxChars: Math.max(500, positiveInteger(raw.maxChars, base.maxChars)),
|
|
@@ -442,6 +447,7 @@ function applyShared(base, raw) {
|
|
|
442
447
|
}
|
|
443
448
|
function applyGuards(base, raw, timeoutMs, source) {
|
|
444
449
|
return {
|
|
450
|
+
waste: applyWaste(base.waste, raw.waste),
|
|
445
451
|
rules: applyRules(base.rules, raw.rules),
|
|
446
452
|
runaway: applyRunaway(base.runaway, raw.runaway),
|
|
447
453
|
subagent: applySubagent(base.subagent, raw.subagent),
|
|
@@ -491,16 +497,42 @@ export function applyUserOverrides(base, raw) {
|
|
|
491
497
|
steerVisible: boolean(raw.steerVisible, base.steerVisible),
|
|
492
498
|
notices: boolean(raw.notices, base.notices),
|
|
493
499
|
steerBudget: typeof raw.steerBudget === "number" && Number.isInteger(raw.steerBudget) && raw.steerBudget >= 0 ? raw.steerBudget : base.steerBudget,
|
|
500
|
+
steers: applySteers(base.steers, raw.steers),
|
|
494
501
|
learning: applyLearning(base.learning, raw.learning),
|
|
495
502
|
conscience: applyConscience(base.conscience, raw.conscience),
|
|
496
503
|
prefs: applyPrefs(base.prefs, raw.prefs),
|
|
497
504
|
};
|
|
498
505
|
}
|
|
506
|
+
function applyWaste(base, raw) {
|
|
507
|
+
if (!isObject(raw))
|
|
508
|
+
return base;
|
|
509
|
+
return {
|
|
510
|
+
enabled: boolean(raw.enabled, base.enabled),
|
|
511
|
+
tip: boolean(raw.tip, base.tip),
|
|
512
|
+
every: Math.max(2, positiveInteger(raw.every, base.every)),
|
|
513
|
+
sleep: boolean(raw.sleep, base.sleep),
|
|
514
|
+
paging: boolean(raw.paging, base.paging),
|
|
515
|
+
search: boolean(raw.search, base.search),
|
|
516
|
+
recheck: boolean(raw.recheck, base.recheck),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
499
519
|
function applyPrefs(base, raw) {
|
|
500
520
|
if (!isObject(raw))
|
|
501
521
|
return base;
|
|
502
522
|
return { enabled: boolean(raw.enabled, base.enabled), inject: boolean(raw.inject, base.inject) };
|
|
503
523
|
}
|
|
524
|
+
function applySteers(base, raw) {
|
|
525
|
+
if (!isObject(raw))
|
|
526
|
+
return base;
|
|
527
|
+
return {
|
|
528
|
+
adaptive: boolean(raw.adaptive, base.adaptive),
|
|
529
|
+
minSteers: positiveInteger(raw.minSteers, base.minSteers),
|
|
530
|
+
minFollowed: probability(raw.minFollowed, base.minFollowed),
|
|
531
|
+
maxDisputed: probability(raw.maxDisputed, base.maxDisputed),
|
|
532
|
+
recheckEvery: positiveInteger(raw.recheckEvery, base.recheckEvery),
|
|
533
|
+
probeEvery: positiveInteger(raw.probeEvery, base.probeEvery),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
504
536
|
function applyLearning(base, raw) {
|
|
505
537
|
if (!isObject(raw))
|
|
506
538
|
return base;
|
|
@@ -557,11 +589,7 @@ export function readUserConfig() {
|
|
|
557
589
|
}
|
|
558
590
|
export function writeUserConfig(raw) {
|
|
559
591
|
const path = userConfigPath();
|
|
560
|
-
|
|
561
|
-
const temporary = `${path}.${process.pid}.tmp`;
|
|
562
|
-
writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600, flag: "w" });
|
|
563
|
-
chmodSync(temporary, 0o600);
|
|
564
|
-
renameSync(temporary, path);
|
|
592
|
+
writeFileAtomicSync(path, `${JSON.stringify(raw, null, 2)}\n`);
|
|
565
593
|
return path;
|
|
566
594
|
}
|
|
567
595
|
/** Persists one top-level user setting without disturbing the rest of the file. */
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Attempt } from "./stuck.js";
|
|
2
|
+
/** Byte cap for the whole `evidence` object; over it, the oldest runs go first, then the oldest edits. */
|
|
3
|
+
export declare const EVIDENCE_LIMIT = 4096;
|
|
4
|
+
/** A failed or check output parsed into failing tests, error lines, a location, a summary and an exit code. */
|
|
5
|
+
export type ParsedFailure = {
|
|
6
|
+
format: string;
|
|
7
|
+
failing: string[];
|
|
8
|
+
failing_more?: number;
|
|
9
|
+
errors: string[];
|
|
10
|
+
location?: string;
|
|
11
|
+
summary?: string;
|
|
12
|
+
exit?: number;
|
|
13
|
+
};
|
|
14
|
+
/** Parses a failed or check output into failing tests, error lines, a location, a summary and an exit code. */
|
|
15
|
+
export declare function parseFailure(text: string): ParsedFailure;
|
|
16
|
+
/**
|
|
17
|
+
* Normalises what changes between identical runs: durations, clock times and dates, addresses, long numbers, thread
|
|
18
|
+
* ids, temp paths, and line:column positions. Other digits stay, so "expected 3" and "expected 4" still differ.
|
|
19
|
+
*/
|
|
20
|
+
export declare function normaliseFailureText(value: string | undefined): string;
|
|
21
|
+
/**
|
|
22
|
+
* Order-free signature of a failure: which tests failed and with which errors, both normalised. Pass counts are left
|
|
23
|
+
* out, so a run narrowed by a flag (`-x`, a test filter) still matches the full run that failed the same way.
|
|
24
|
+
*/
|
|
25
|
+
export declare function failureSignature(parsed: ParsedFailure, raw?: string): string;
|
|
26
|
+
/** The edit pairs of an `edit` input, as `[oldText, newText]`: the `edits` array, or one top-level pair. */
|
|
27
|
+
export declare function editPairs(input: Record<string, unknown>): Array<[string, string]>;
|
|
28
|
+
/** Removed and added lines of one edit, as a multiset difference, so unchanged context lines drop out. */
|
|
29
|
+
export declare function lineDelta(oldText: string, newText: string): {
|
|
30
|
+
removed: string[];
|
|
31
|
+
added: string[];
|
|
32
|
+
};
|
|
33
|
+
/** What an `edit` or `write` changed: the path, the line counts, and a diff capped at `DIFF_CAP` characters. */
|
|
34
|
+
export type EditDiff = {
|
|
35
|
+
path: string;
|
|
36
|
+
added: number;
|
|
37
|
+
removed: number;
|
|
38
|
+
removedLines: string[];
|
|
39
|
+
addedLines: string[];
|
|
40
|
+
diff: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The change an `edit` or `write` made. Every string is as the agent wrote it: the caller redacts what leaves the
|
|
44
|
+
* machine. The diff is the whole added and removed text, not a line-by-line patch, because the judge compares one
|
|
45
|
+
* change against the next.
|
|
46
|
+
*/
|
|
47
|
+
export declare function editDiff(tool: string, input: Record<string, unknown>): EditDiff;
|
|
48
|
+
/** One run in the evidence: a failed call, or a call that ran a test, build or lint command and passed. */
|
|
49
|
+
export type EvidenceRun = {
|
|
50
|
+
n: number;
|
|
51
|
+
outcome: "failed" | "ok";
|
|
52
|
+
parser?: string;
|
|
53
|
+
failing_tests?: string[];
|
|
54
|
+
failing_tests_more?: number;
|
|
55
|
+
errors?: string[];
|
|
56
|
+
location?: string;
|
|
57
|
+
summary?: string;
|
|
58
|
+
output_tail?: string;
|
|
59
|
+
output_head_tail?: string;
|
|
60
|
+
/** The run number of the earlier run whose failure matches, after normalising times, temp paths and line numbers. */
|
|
61
|
+
same_failure_as_run?: number | null;
|
|
62
|
+
exit_code?: number;
|
|
63
|
+
};
|
|
64
|
+
/** One `edit`/`write` call in the evidence. */
|
|
65
|
+
export type EvidenceEdit = {
|
|
66
|
+
n: number;
|
|
67
|
+
path: string;
|
|
68
|
+
added_lines: number;
|
|
69
|
+
removed_lines: number;
|
|
70
|
+
diff: string;
|
|
71
|
+
};
|
|
72
|
+
/** The counts a judge can weigh without reading every run. */
|
|
73
|
+
export type EvidenceDigest = {
|
|
74
|
+
failed_runs: number;
|
|
75
|
+
distinct_failures: number;
|
|
76
|
+
/** Runs whose command repeats an earlier run's command, whatever the outcome. */
|
|
77
|
+
same_command_runs: number[];
|
|
78
|
+
latest_failure_seen_before: boolean;
|
|
79
|
+
edits_between_failed_runs: number;
|
|
80
|
+
information_calls_between_failed_runs: number;
|
|
81
|
+
};
|
|
82
|
+
export type StuckEvidence = {
|
|
83
|
+
runs: EvidenceRun[];
|
|
84
|
+
edits: EvidenceEdit[];
|
|
85
|
+
digest: EvidenceDigest;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Builds the evidence for one stuck window: a run per failed call and per passing check command, an edit per
|
|
89
|
+
* `edit`/`write`, and a digest. The oldest runs are dropped first when the whole object is over `EVIDENCE_LIMIT`
|
|
90
|
+
* bytes, then the oldest edits; the digest is then recomputed over what is left, and a run that pointed at a dropped
|
|
91
|
+
* run points at nothing. The floor is one run plus the digest, which is far under the cap.
|
|
92
|
+
*/
|
|
93
|
+
export declare function buildStuckEvidence(attempts: readonly Attempt[], limit?: number): StuckEvidence;
|