pi-warden 0.60.0 → 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 +140 -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/compact.d.ts +9 -0
- package/dist/compact.js +34 -10
- package/dist/config.d.ts +47 -2
- package/dist/config.js +43 -11
- package/dist/dedupe.d.ts +41 -0
- package/dist/dedupe.js +162 -0
- package/dist/evidence.d.ts +93 -0
- package/dist/evidence.js +409 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +460 -62
- package/dist/extension.js.bak +668 -102
- package/dist/guard.d.ts +4 -0
- package/dist/guard.js +132 -9
- package/dist/index.d.ts +10 -2
- package/dist/index.js +5 -1
- package/dist/loops.d.ts +64 -0
- package/dist/loops.js +147 -0
- package/dist/prefs.d.ts +161 -10
- package/dist/prefs.js +371 -48
- 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/saver.d.ts +9 -1
- package/dist/saver.js +14 -4
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +23 -5
- 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/compact.d.ts
CHANGED
|
@@ -56,9 +56,17 @@ export interface CompactSnapshot {
|
|
|
56
56
|
verification: Verification | undefined;
|
|
57
57
|
stuck: StuckState | undefined;
|
|
58
58
|
activeTask: string | undefined;
|
|
59
|
+
/** The session's open loops, already formatted and capped by `formatOpenLoops`; absent or empty when none is open. */
|
|
60
|
+
openLoops?: string;
|
|
59
61
|
}
|
|
60
62
|
/** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
|
|
61
63
|
export declare function compactAppendix(snapshot: CompactSnapshot): string;
|
|
64
|
+
/**
|
|
65
|
+
* `warden_recall`: what was already tried in this session, from the same snapshot and the same section text as the
|
|
66
|
+
* compaction appendix: the failed attempts with their error lines, the last passing check with whether the code
|
|
67
|
+
* changed since, and the saved-output paths. Capped like the appendix.
|
|
68
|
+
*/
|
|
69
|
+
export declare function recallText(snapshot: CompactSnapshot): string;
|
|
62
70
|
/** The line of a failed output most likely to name the error: the last specific error line, else the last line. */
|
|
63
71
|
export declare function errorLine(output: string): string;
|
|
64
72
|
/** Distinct failed calls, the latest occurrence of each, oldest first; at most `MAX_FAILED`. */
|
|
@@ -101,4 +109,5 @@ export declare function buildCompactSnapshot(options: {
|
|
|
101
109
|
evidence?: Pick<RunEvidence, "mutations" | "checks" | "checksBeforeMutation">;
|
|
102
110
|
activeTask: string | undefined;
|
|
103
111
|
runs: number;
|
|
112
|
+
openLoops?: string;
|
|
104
113
|
}): CompactSnapshot;
|
package/dist/compact.js
CHANGED
|
@@ -8,8 +8,10 @@ const TASK_CHARS_WHEN_OVER = 300;
|
|
|
8
8
|
function clip(text, limit) {
|
|
9
9
|
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** Every section in appendix order; `warden_recall` prints some of the same strings, so the two never disagree. */
|
|
12
|
+
function renderSections(snapshot) {
|
|
12
13
|
const sections = [];
|
|
14
|
+
const push = (name, text) => sections.push([name, text]);
|
|
13
15
|
if (snapshot.failedAttempts.length) {
|
|
14
16
|
const items = snapshot.failedAttempts.map(attempt => {
|
|
15
17
|
const call = clip(redact(attempt.call), FAILED_CALL_CHARS);
|
|
@@ -17,39 +19,46 @@ function render(snapshot) {
|
|
|
17
19
|
const entry = error ? `${call} → ${clip(error, FAILED_ENTRY_CHARS - call.length - 3)}` : call;
|
|
18
20
|
return `- ${entry}`;
|
|
19
21
|
}).join("\n");
|
|
20
|
-
|
|
22
|
+
push("failed", `### Tried and failed\nDo not retry these unchanged.\n${items}`);
|
|
21
23
|
}
|
|
22
24
|
if (snapshot.verification) {
|
|
23
25
|
const v = snapshot.verification;
|
|
24
26
|
const line = v.kind === "passed"
|
|
25
27
|
? `- last passing check: ${redact(v.command)}; code changed since last passing check: ${v.changedSince ? "yes" : "no"}`
|
|
26
28
|
: "- no check has passed yet; code was changed";
|
|
27
|
-
|
|
29
|
+
push("verification", `### Verification\n${line}`);
|
|
30
|
+
}
|
|
31
|
+
if (snapshot.openLoops) {
|
|
32
|
+
push("loops", `### Open loops\nPromised earlier in this session and not done yet; close each with warden_loops done or drop.\n${snapshot.openLoops}`);
|
|
28
33
|
}
|
|
29
34
|
if (snapshot.checks.length) {
|
|
30
35
|
const items = snapshot.checks
|
|
31
36
|
.map(c => `- ${c.passed ? "✓" : "✗"} ${c.when}: ${redact(c.command)}`)
|
|
32
37
|
.join("\n");
|
|
33
|
-
|
|
38
|
+
push("checks", `### Last checks\n${items}`);
|
|
34
39
|
}
|
|
35
40
|
if (snapshot.stuck) {
|
|
36
|
-
|
|
41
|
+
push("stuck", `### Stuck state\n- failures: ${snapshot.stuck.failures} of the last ${snapshot.stuck.window} tool results`);
|
|
37
42
|
}
|
|
38
43
|
if (snapshot.holds.length) {
|
|
39
44
|
const items = snapshot.holds
|
|
40
45
|
.map(h => `- ${redact(h.tool)}: ${redact(h.outcome)}${h.preview ? ` (${redact(h.preview)})` : ""}`)
|
|
41
46
|
.join("\n");
|
|
42
|
-
|
|
47
|
+
push("holds", `### Held actions\n${items}`);
|
|
43
48
|
}
|
|
44
49
|
if (snapshot.savedOutputs.length) {
|
|
45
50
|
const items = snapshot.savedOutputs
|
|
46
51
|
.map(item => `- ${item.tool ? `${redact(item.tool)} → ` : ""}${redact(item.path)}${item.bytes === undefined ? "" : ` (${item.bytes} bytes)`}`)
|
|
47
52
|
.join("\n");
|
|
48
|
-
|
|
53
|
+
push("saved", `### Saved full outputs\n${items}`);
|
|
49
54
|
}
|
|
50
55
|
if (snapshot.activeTask) {
|
|
51
|
-
|
|
56
|
+
push("task", `### Active task\n${redact(snapshot.activeTask)}`);
|
|
52
57
|
}
|
|
58
|
+
return sections;
|
|
59
|
+
}
|
|
60
|
+
function render(snapshot) {
|
|
61
|
+
const sections = renderSections(snapshot).map(([, text]) => text);
|
|
53
62
|
if (sections.length === 0)
|
|
54
63
|
return "";
|
|
55
64
|
return [
|
|
@@ -61,8 +70,9 @@ function render(snapshot) {
|
|
|
61
70
|
].join("\n");
|
|
62
71
|
}
|
|
63
72
|
/**
|
|
64
|
-
* Lower-value content goes first when the appendix is over the cap. Failed attempts
|
|
65
|
-
* never reduced here; they come first in the text, so the final cut reaches
|
|
73
|
+
* Lower-value content goes first when the appendix is over the cap. Failed attempts, the verification line, and the
|
|
74
|
+
* open loops (at most 600 characters) are never reduced here; they come first in the text, so the final cut reaches
|
|
75
|
+
* them last.
|
|
66
76
|
*/
|
|
67
77
|
const REDUCERS = [
|
|
68
78
|
s => ({ ...s, savedOutputs: s.savedOutputs.slice(-SAVED_KEEP_WHEN_OVER) }),
|
|
@@ -88,6 +98,19 @@ export function compactAppendix(snapshot) {
|
|
|
88
98
|
const marker = "\n… [truncated]";
|
|
89
99
|
return appendix.slice(0, MAX_CHARS - marker.length) + marker;
|
|
90
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* `warden_recall`: what was already tried in this session, from the same snapshot and the same section text as the
|
|
103
|
+
* compaction appendix: the failed attempts with their error lines, the last passing check with whether the code
|
|
104
|
+
* changed since, and the saved-output paths. Capped like the appendix.
|
|
105
|
+
*/
|
|
106
|
+
export function recallText(snapshot) {
|
|
107
|
+
const kept = renderSections(snapshot).filter(([name]) => name === "failed" || name === "verification" || name === "saved").map(([, text]) => text);
|
|
108
|
+
const text = kept.length ? kept.join("\n\n") : "Nothing recorded yet in this session: no failed attempt, no check, and no saved output.";
|
|
109
|
+
if (text.length <= MAX_CHARS)
|
|
110
|
+
return text;
|
|
111
|
+
const marker = "\n… [truncated]";
|
|
112
|
+
return text.slice(0, MAX_CHARS - marker.length) + marker;
|
|
113
|
+
}
|
|
91
114
|
const ERROR_LINE = /\b(?:error|errors|fail(?:ed|ure|s)?|exception|cannot|can't|could not|not found|no such|denied|refused|invalid|unexpected|missing|undefined|timed? ?out|abort(?:ed)?|panic|fatal|traceback)\b|✗|✖|✘/i;
|
|
92
115
|
/** Runner tallies ("Found 1 error.", "3 failing") say that something failed, not what; an earlier line says what. */
|
|
93
116
|
const SUMMARY_LINE = /^(?:found \d+ errors?\b|\d+ (?:failing|failed|errors?)\b|tests?:\s|ℹ fail \d+|npm (?:err!|error) (?:code|errno|a complete log|command failed|lifecycle)|error: command failed|command failed with exit code)/i;
|
|
@@ -159,5 +182,6 @@ export function buildCompactSnapshot(options) {
|
|
|
159
182
|
verification: options.evidence ? verificationOf(options.evidence) : undefined,
|
|
160
183
|
stuck,
|
|
161
184
|
activeTask,
|
|
185
|
+
...(options.openLoops ? { openLoops: options.openLoops } : {}),
|
|
162
186
|
};
|
|
163
187
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface ActionGuardConfig {
|
|
|
41
41
|
intentMismatch: number;
|
|
42
42
|
/** The same, for a command whose effect is visible outside the working tree (commit, push, merge, publish, launch): less mismatch is enough. */
|
|
43
43
|
visibleMismatch: number;
|
|
44
|
+
/** Which intent mismatches stay in the trace without a steer: "invisible" (default) a call with no visible effect (neither a commit, push, merge, tag, reset, pull request, release, or publish by `isVisibleCommand`, nor judged `visible` at 0.8 or more), "all" every one, "none" none. The steer arrives after the call ran: 275 of 275 recorded steers did. */
|
|
45
|
+
intentTraceOnly: "invisible" | "all" | "none";
|
|
44
46
|
/** Low P(should_proceed) is trace-only unless steer is enabled; hold is the inclusive threshold, not a blocking decision. Calibration: AUC 0.26 against regret, 44% flagged at 0.6 (100 targeted sessions, 2026-09-20). */
|
|
45
47
|
shouldProceed: {
|
|
46
48
|
hold: number;
|
|
@@ -129,6 +131,8 @@ export interface StuckGuardConfig {
|
|
|
129
131
|
nudge: boolean;
|
|
130
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. */
|
|
131
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;
|
|
132
136
|
/** Maximum characters of the unified line diff in a stuck-loop diff note. */
|
|
133
137
|
diffLimit: number;
|
|
134
138
|
/** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
|
|
@@ -188,6 +192,8 @@ export interface RulesConfig {
|
|
|
188
192
|
enabled: boolean;
|
|
189
193
|
/** P(violation) at or above this names the rule to the agent. */
|
|
190
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;
|
|
191
197
|
/** Project-relative Markdown rule files, used when the root pi-warden.md is absent. All are sent in one request. */
|
|
192
198
|
files: string[];
|
|
193
199
|
/** With no rules file, README.md, CLAUDE.md, or AGENTS.md (first found) is judged as one document. */
|
|
@@ -215,6 +221,10 @@ export interface ContextConfig {
|
|
|
215
221
|
formatConfidence: number;
|
|
216
222
|
/** Append a compact evidence appendix to the summary during compaction. */
|
|
217
223
|
compactAppendix: boolean;
|
|
224
|
+
/** A run of at least 20 lines and 1500 characters in a new tool result that repeats text already in context becomes one pointer line (code only). */
|
|
225
|
+
dedupeRuns: boolean;
|
|
226
|
+
/** The same for user and custom messages, when `dedupeRuns` is also on. Off by default: a repeat the user sends can itself carry meaning ("here it is again, still failing"). */
|
|
227
|
+
dedupeMessages: boolean;
|
|
218
228
|
/** Prevention before the call: a bash action request asks whether the command will print far more than the agent needs. Never holds. */
|
|
219
229
|
largeOutput: LargeOutputConfig;
|
|
220
230
|
}
|
|
@@ -264,12 +274,43 @@ export interface SubagentConfig {
|
|
|
264
274
|
export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
|
|
265
275
|
export declare function isRecallTool(value: unknown): value is RecallTool;
|
|
266
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
|
+
}
|
|
267
293
|
export interface PrefsConfig {
|
|
268
294
|
/** Read this project's earlier session files for preferences the user repeated (`/warden prefs`). Local, code only. */
|
|
269
295
|
enabled: boolean;
|
|
270
|
-
/** At session start, send the standing preferences to the agent as one context message.
|
|
296
|
+
/** At session start, send the standing preferences that pass every injection rule to the agent as one context message. */
|
|
271
297
|
inject: boolean;
|
|
272
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
|
+
}
|
|
273
314
|
export interface LearningConfig {
|
|
274
315
|
/** Enable adaptive thresholds based on learning data. */
|
|
275
316
|
adaptiveThresholds: boolean;
|
|
@@ -360,16 +401,20 @@ export interface WardenConfig {
|
|
|
360
401
|
* steer costs at least one LLM turn, and a closing run that collects six notices collects six restatements of the final
|
|
361
402
|
* status. 0 disables the budget. Critical guards (stuck, done, runaway, subagent wake) always deliver. */
|
|
362
403
|
steerBudget: number;
|
|
404
|
+
/** Per-model steer adaptation. */
|
|
405
|
+
steers: SteersConfig;
|
|
363
406
|
/** Learning and adaptation settings. */
|
|
364
407
|
learning: LearningConfig;
|
|
365
408
|
/** Broad-consideration coach: recommends or loads skills and tools before the agent acts. */
|
|
366
409
|
conscience: ConscienceConfig;
|
|
367
410
|
/** Standing preferences: corrections the user repeated in earlier sessions of this project. */
|
|
368
411
|
prefs: PrefsConfig;
|
|
412
|
+
/** Call-waste notes: advisory sentences attached to the tool result that triggers them. */
|
|
413
|
+
waste: WasteConfig;
|
|
369
414
|
}
|
|
370
415
|
export declare const PACKAGE_NAME = "pi-warden";
|
|
371
416
|
/** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
|
|
372
|
-
export declare const CONFIG_SCHEMA =
|
|
417
|
+
export declare const CONFIG_SCHEMA = 10;
|
|
373
418
|
export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
|
|
374
419
|
export declare function defaultConfig(): WardenConfig;
|
|
375
420
|
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|