pi-warden 0.36.0 → 0.40.1
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 +78 -0
- package/README.md +7 -3
- package/dist/backend.d.ts +4 -7
- package/dist/backend.js +11 -19
- package/dist/compact.d.ts +63 -0
- package/dist/compact.js +84 -0
- package/dist/config.d.ts +42 -1
- package/dist/config.js +44 -2
- package/dist/conscience.d.ts +163 -0
- package/dist/conscience.js +437 -0
- package/dist/extension.d.ts +4 -3
- package/dist/extension.js +735 -20
- package/dist/extension.js.bak +785 -25
- package/dist/guard.js +9 -5
- package/dist/hashing.d.ts +4 -0
- package/dist/hashing.js +22 -0
- package/dist/index-cmd.d.ts +87 -0
- package/dist/index-cmd.js +330 -0
- package/dist/load.d.ts +56 -0
- package/dist/load.js +257 -0
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +14 -2
- package/dist/trace.d.ts +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { choice, noul, score } from "pi-typesafe";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { redact } from "./redact.js";
|
|
4
|
+
import { fileContentHash, toolSourceHash } from "./hashing.js";
|
|
5
|
+
/** Score levels for the usefulness question. Levels are ordered from least to most useful. */
|
|
6
|
+
export const SCORE_LEVELS = [
|
|
7
|
+
"No useful contribution to the current request, or conflicts with the supplied constraints.",
|
|
8
|
+
"Related to the subject, but already covered, premature, or unable to resolve the current need.",
|
|
9
|
+
"Addresses a concrete unmet need at the current step without displacing the active workflow.",
|
|
10
|
+
"Supplies a missing prerequisite or directly applicable documented method needed for the current step.",
|
|
11
|
+
];
|
|
12
|
+
/* ─── Envelope limits (spec §3 "Bounded assessment envelope") ──────── */
|
|
13
|
+
/** Max eligible entries per category. */
|
|
14
|
+
const MAX_ELIGIBLE = 256;
|
|
15
|
+
/** Max UTF-8 bytes per candidate description. */
|
|
16
|
+
const MAX_DESC_BYTES = 8192;
|
|
17
|
+
/** Max questions per single judge request (including the shared disposition question). */
|
|
18
|
+
const MAX_QUESTIONS_PER_REQUEST = 32;
|
|
19
|
+
/* ─── Path/URL sanitizer (spec §6) ─────────────────────────────────── */
|
|
20
|
+
/** Strip absolute paths, URLs, and credentials from a description. */
|
|
21
|
+
export function sanitizeDescription(text) {
|
|
22
|
+
// Remove credentials: tokens, keys, secrets
|
|
23
|
+
let result = text.replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, "[credential]");
|
|
24
|
+
result = result.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[credential]");
|
|
25
|
+
result = result.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[credential]");
|
|
26
|
+
result = result.replace(/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "[credential]");
|
|
27
|
+
result = result.replace(/\bAIza[0-9A-Za-z_-]{30,}/g, "[credential]");
|
|
28
|
+
result = result.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "[credential]");
|
|
29
|
+
result = result.replace(/(?:TOKEN|SECRET|KEY|PASSWORD)\s*[=:]\s*['"]?[\w\-./]{8,}/gi, "[credential]");
|
|
30
|
+
// Remove absolute paths: /foo/bar, ~/foo, ~user/foo
|
|
31
|
+
result = result.replace(/(?:^|\s)\/[\w.~\-/]+(?:\s|$)/g, " ");
|
|
32
|
+
result = result.replace(/~\/[\w.~\-/]+/g, "[path]");
|
|
33
|
+
result = result.replace(/~\w+\/[\w.~\-/]+/g, "[path]");
|
|
34
|
+
// Remove URLs
|
|
35
|
+
result = result.replace(/https?:\/\/[\w.~\-/]+/g, "[url]");
|
|
36
|
+
return result.trim();
|
|
37
|
+
}
|
|
38
|
+
/* ─── Opaque ID mapping ─────────────────────────────────────────────── */
|
|
39
|
+
/** Opaque IDs: c1, c2, ... in stable order. */
|
|
40
|
+
export function opaqueId(index) {
|
|
41
|
+
return `c${index + 1}`;
|
|
42
|
+
}
|
|
43
|
+
/** Map candidates to opaque IDs, returning a stable ordered list. */
|
|
44
|
+
export function assignOpaqueIds(candidates) {
|
|
45
|
+
return candidates.map((c, i) => ({ opaqueId: opaqueId(i), candidate: c }));
|
|
46
|
+
}
|
|
47
|
+
/* ─── Question builder ──────────────────────────────────────────────── */
|
|
48
|
+
/**
|
|
49
|
+
* Build assessment questions for a batch of candidates.
|
|
50
|
+
* State carries structured data; each question names its candidate field.
|
|
51
|
+
* Returns questions, the disposition key, and the opaqueId→candidate map.
|
|
52
|
+
*/
|
|
53
|
+
export function buildBatchQuestions(batch, task, recentContext, activeSkills, suppliedSkills) {
|
|
54
|
+
const questions = {};
|
|
55
|
+
const idMap = new Map();
|
|
56
|
+
// Shared disposition question
|
|
57
|
+
const dispositionKey = "conscience_disposition";
|
|
58
|
+
questions[dispositionKey] = choice(`Disposition of the current request. A message that reports status, shares context, or narrates what the user is doing elsewhere, without asking the agent to do anything, is no_gap; select nothing on it.`, {
|
|
59
|
+
advance: "A useful next step can be taken now.",
|
|
60
|
+
awaiting_user: "The assistant has already asked for information and must wait.",
|
|
61
|
+
no_gap: "The request is satisfied with no useful missing step.",
|
|
62
|
+
unclear: "Insufficient evidence to determine the next step.",
|
|
63
|
+
});
|
|
64
|
+
// Per-candidate Score questions
|
|
65
|
+
for (const { opaqueId, candidate } of batch) {
|
|
66
|
+
idMap.set(opaqueId, candidate);
|
|
67
|
+
questions[opaqueId] = score(`Judge candidates.${opaqueId} against what the user is asking the agent to do now in task, not against the subjects the prompt mentions in passing; a candidate that matches words in the prompt but does not serve the actual request belongs at the lowest level. When the request needs information the repository cannot supply, a research-role candidate serves the request; when the request is about the repository's own code or behaviour, an evidence-role candidate does.`, [...SCORE_LEVELS]);
|
|
68
|
+
}
|
|
69
|
+
return { questions, dispositionKey, idMap };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the full state object for the judge request (spec §3 state contract).
|
|
73
|
+
*/
|
|
74
|
+
export function buildState(task, recentContext, activeSkills, suppliedSkills, batch) {
|
|
75
|
+
const candidates = {};
|
|
76
|
+
for (const { opaqueId, candidate } of batch) {
|
|
77
|
+
const entry = candidate.indexEntry;
|
|
78
|
+
candidates[opaqueId] = entry
|
|
79
|
+
? { kind: candidate.kind, name: candidate.id, lead: entry.lead, useWhen: entry.useWhen, examples: entry.examples }
|
|
80
|
+
: { kind: candidate.kind, name: candidate.id, description: candidate.description, unindexed: true };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
task: sanitizeDescription(redact(task)),
|
|
84
|
+
context: sanitizeDescription(redact(recentContext)),
|
|
85
|
+
active_skills: activeSkills,
|
|
86
|
+
supplied_skills: suppliedSkills,
|
|
87
|
+
candidates,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Compute a deterministic hash of the assessment questions for deduplication.
|
|
92
|
+
* Recursively sorts all object keys for canonical form.
|
|
93
|
+
*
|
|
94
|
+
* Opaque candidate ids (c1, c2, …) are positional, not semantic: which batch a prompt happens to
|
|
95
|
+
* produce must not change the hash the activation gate compares. A conscience question set
|
|
96
|
+
* (recognised by the `conscience_disposition` key) is therefore hashed as the disposition question
|
|
97
|
+
* plus one canonical candidate question, keyed `c1` — every candidate question carries the same
|
|
98
|
+
* wording by design (per-candidate state lives in the request state, not the question text), so any
|
|
99
|
+
* one of them is the canonical representative.
|
|
100
|
+
*
|
|
101
|
+
* Why the hash was constant before this change: each assessment issues several requests (one per
|
|
102
|
+
* batch), each with its own question keys, and `assess` kept only the hash of whichever batch wrote
|
|
103
|
+
* `state_.hash` last. Every recorded replay row showed `fb2d35042f667b3c` only because those final
|
|
104
|
+
* batches happened to serialize identically — an accident of batch order, not a guarantee.
|
|
105
|
+
*/
|
|
106
|
+
export function questionHash(questions) {
|
|
107
|
+
if (questions.conscience_disposition) {
|
|
108
|
+
const firstCandidate = Object.entries(questions).find(([key]) => /^c\d+$/.test(key));
|
|
109
|
+
questions = {
|
|
110
|
+
conscience_disposition: questions.conscience_disposition,
|
|
111
|
+
...(firstCandidate ? { [firstCandidate[0]]: firstCandidate[1] } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const sorted = JSON.parse(JSON.stringify(questions, (_key, value) => {
|
|
115
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
116
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}));
|
|
120
|
+
return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16);
|
|
121
|
+
}
|
|
122
|
+
/* ─── Candidate eligibility ─────────────────────────────────────────── */
|
|
123
|
+
/** Check if a candidate is excluded by the config's exclude list. */
|
|
124
|
+
function isExcluded(id, exclude) {
|
|
125
|
+
for (const pattern of exclude) {
|
|
126
|
+
if (pattern === "*")
|
|
127
|
+
return true;
|
|
128
|
+
if (pattern.endsWith("*") && id.startsWith(pattern.slice(0, -1)))
|
|
129
|
+
return true;
|
|
130
|
+
if (pattern === id)
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/** Filter candidates by eligibility rules, capped at MAX_ELIGIBLE per category. Attaches index entries when available. */
|
|
136
|
+
export function eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, globalIndex, projectIndex) {
|
|
137
|
+
const candidates = [];
|
|
138
|
+
let skillOverflow = false;
|
|
139
|
+
let toolOverflow = false;
|
|
140
|
+
const findEntry = (name, sourceHash) => {
|
|
141
|
+
const search = (index) => {
|
|
142
|
+
if (!index)
|
|
143
|
+
return undefined;
|
|
144
|
+
return index.entries.find(e => e.name === name && e.sourceHash === sourceHash);
|
|
145
|
+
};
|
|
146
|
+
return search(projectIndex) ?? search(globalIndex);
|
|
147
|
+
};
|
|
148
|
+
if (config.skills.mode !== "off") {
|
|
149
|
+
let count = 0;
|
|
150
|
+
for (const skill of skills) {
|
|
151
|
+
if (isExcluded(skill.name, config.skills.exclude))
|
|
152
|
+
continue;
|
|
153
|
+
// disableModelInvocation skills are never automatic candidates (spec §3 rule 4)
|
|
154
|
+
if (skill.disableModelInvocation)
|
|
155
|
+
continue;
|
|
156
|
+
// Already supplied: skip
|
|
157
|
+
if (suppliedSkills.includes(skill.name))
|
|
158
|
+
continue;
|
|
159
|
+
if (count >= MAX_ELIGIBLE) {
|
|
160
|
+
skillOverflow = true;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const sourceHash = skill.filePath ? fileContentHash(skill.filePath) : "missing";
|
|
164
|
+
const entry = findEntry(skill.name, sourceHash);
|
|
165
|
+
candidates.push({
|
|
166
|
+
kind: "skill",
|
|
167
|
+
id: skill.name,
|
|
168
|
+
description: sanitizeDescription(skill.description),
|
|
169
|
+
skill,
|
|
170
|
+
...(entry ? { role: entry.role, indexEntry: entry } : {}),
|
|
171
|
+
});
|
|
172
|
+
count++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (config.tools.enabled) {
|
|
176
|
+
let count = 0;
|
|
177
|
+
for (const tool of tools) {
|
|
178
|
+
if (isExcluded(tool.name, config.tools.exclude))
|
|
179
|
+
continue;
|
|
180
|
+
if (count >= MAX_ELIGIBLE) {
|
|
181
|
+
toolOverflow = true;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
const sourceHash = toolSourceHash(tool.name, tool.description);
|
|
185
|
+
const entry = findEntry(tool.name, sourceHash);
|
|
186
|
+
candidates.push({
|
|
187
|
+
kind: "tool",
|
|
188
|
+
id: tool.name,
|
|
189
|
+
description: sanitizeDescription(tool.description),
|
|
190
|
+
...(entry ? { role: entry.role, indexEntry: entry } : {}),
|
|
191
|
+
});
|
|
192
|
+
count++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { candidates, skillOverflow, toolOverflow };
|
|
196
|
+
}
|
|
197
|
+
/* ─── Description cap ───────────────────────────────────────────────── */
|
|
198
|
+
/** Cap a candidate description to MAX_DESC_BYTES UTF-8. Returns the candidate with overflow flag. */
|
|
199
|
+
function capDescription(candidate) {
|
|
200
|
+
const bytes = new TextEncoder().encode(candidate.description).byteLength;
|
|
201
|
+
if (bytes <= MAX_DESC_BYTES)
|
|
202
|
+
return { candidate, overLimit: false };
|
|
203
|
+
// Truncate by UTF-8 characters, not bytes, to avoid splitting multi-byte chars
|
|
204
|
+
const chars = [...candidate.description];
|
|
205
|
+
let truncated = "";
|
|
206
|
+
let byteCount = 0;
|
|
207
|
+
for (const ch of chars) {
|
|
208
|
+
const chBytes = new TextEncoder().encode(ch).byteLength;
|
|
209
|
+
if (byteCount + chBytes > MAX_DESC_BYTES - 3)
|
|
210
|
+
break;
|
|
211
|
+
truncated += ch;
|
|
212
|
+
byteCount += chBytes;
|
|
213
|
+
}
|
|
214
|
+
return { candidate: { ...candidate, description: truncated + "..." }, overLimit: true };
|
|
215
|
+
}
|
|
216
|
+
/* ─── Error classification ──────────────────────────────────────────── */
|
|
217
|
+
/** Classify an assessment error into a safe category (spec §6: never exception bodies). */
|
|
218
|
+
function classifyError(err) {
|
|
219
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
220
|
+
const code = String(err.code);
|
|
221
|
+
if (code === "timeout")
|
|
222
|
+
return "timeout";
|
|
223
|
+
if (code === "http" || code === "connection" || code === "response" || code === "network")
|
|
224
|
+
return "network";
|
|
225
|
+
if (code === "configuration" || code === "validation")
|
|
226
|
+
return "configuration";
|
|
227
|
+
}
|
|
228
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
229
|
+
if (/timeout|timed out/i.test(msg))
|
|
230
|
+
return "timeout";
|
|
231
|
+
if (/auth|key|credential|401|403/i.test(msg))
|
|
232
|
+
return "auth";
|
|
233
|
+
if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
|
|
234
|
+
return "network";
|
|
235
|
+
return "other";
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Parse a Choice answer from pi-typesafe.
|
|
239
|
+
* Shape: { choice: string, confidence: number, probabilities: Record<string, number> }
|
|
240
|
+
*/
|
|
241
|
+
function parseChoiceAnswer(answer) {
|
|
242
|
+
if (!answer || typeof answer !== "object")
|
|
243
|
+
return undefined;
|
|
244
|
+
const a = answer;
|
|
245
|
+
if (typeof a.choice !== "string")
|
|
246
|
+
return undefined;
|
|
247
|
+
const probs = a.probabilities;
|
|
248
|
+
return {
|
|
249
|
+
label: a.choice,
|
|
250
|
+
pAdvance: typeof probs?.advance === "number" ? probs.advance : 0,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Parse a Score answer from pi-typesafe.
|
|
255
|
+
* Shape: { score: number, confidence: number, legend: Record<number, string>, probabilities: Record<number, number> }
|
|
256
|
+
* Probabilities are keyed by numeric index (0, 1, 2, 3).
|
|
257
|
+
*/
|
|
258
|
+
function parseScoreAnswer(answer) {
|
|
259
|
+
if (!answer || typeof answer !== "object")
|
|
260
|
+
return undefined;
|
|
261
|
+
const a = answer;
|
|
262
|
+
if (typeof a.score !== "number")
|
|
263
|
+
return undefined;
|
|
264
|
+
const probs = a.probabilities;
|
|
265
|
+
if (!probs)
|
|
266
|
+
return undefined;
|
|
267
|
+
// Build an array of probabilities keyed by index
|
|
268
|
+
const p = [];
|
|
269
|
+
for (let i = 0; i < SCORE_LEVELS.length; i++) {
|
|
270
|
+
p.push(typeof probs[String(i)] === "number" ? probs[String(i)] : 0);
|
|
271
|
+
}
|
|
272
|
+
return { level: Math.round(a.score), probabilities: p };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Run assessment for the given prompt revision.
|
|
276
|
+
* Pre-measurement: returns trace-only results. No steers, no auto-loading.
|
|
277
|
+
* Issues concurrent requests (up to four in flight), skills first, shared deadline.
|
|
278
|
+
*/
|
|
279
|
+
export async function assess(prompt, recentContext, skills, tools, activeSkills, suppliedSkills, deps) {
|
|
280
|
+
const start = deps.now?.() ?? Date.now();
|
|
281
|
+
const { config, judge, sharedTimeoutMs } = deps;
|
|
282
|
+
if (!config.enabled) {
|
|
283
|
+
return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: "disabled" };
|
|
284
|
+
}
|
|
285
|
+
if (!judge) {
|
|
286
|
+
return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: "no_consent" };
|
|
287
|
+
}
|
|
288
|
+
const { candidates, skillOverflow, toolOverflow } = eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, deps.globalIndex, deps.projectIndex);
|
|
289
|
+
if (candidates.length === 0) {
|
|
290
|
+
return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: "no_match" };
|
|
291
|
+
}
|
|
292
|
+
const effectiveTimeout = Math.min(config.timeoutMs, sharedTimeoutMs);
|
|
293
|
+
const deadline = start + effectiveTimeout;
|
|
294
|
+
const MAX_IN_FLIGHT = 4;
|
|
295
|
+
const categoryMeta = [
|
|
296
|
+
{ category: "skill", overflow: skillOverflow },
|
|
297
|
+
{ category: "tool", overflow: toolOverflow },
|
|
298
|
+
];
|
|
299
|
+
const cappedByCategory = {
|
|
300
|
+
skill: candidates.filter(c => c.kind === "skill").map(c => ({ candidate: c, capped: capDescription(c) })),
|
|
301
|
+
tool: candidates.filter(c => c.kind === "tool").map(c => ({ candidate: c, capped: capDescription(c) })),
|
|
302
|
+
};
|
|
303
|
+
const maxCandidatesPerRequest = MAX_QUESTIONS_PER_REQUEST - 1; // -1 for disposition
|
|
304
|
+
const workQueue = [];
|
|
305
|
+
for (const meta of categoryMeta) {
|
|
306
|
+
const items = cappedByCategory[meta.category];
|
|
307
|
+
if (items.length === 0 && !meta.overflow)
|
|
308
|
+
continue;
|
|
309
|
+
for (let i = 0; i < items.length; i += maxCandidatesPerRequest) {
|
|
310
|
+
workQueue.push({ category: meta.category, items: items.slice(i, i + maxCandidatesPerRequest), overflow: false });
|
|
311
|
+
}
|
|
312
|
+
if (items.length === 0 && meta.overflow) {
|
|
313
|
+
workQueue.push({ category: meta.category, items: [], overflow: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Per-category tracking: a category whose every batch fails or times out is discarded.
|
|
317
|
+
const categoryCompleted = { skill: false, tool: false };
|
|
318
|
+
const categoryFailed = { skill: false, tool: false };
|
|
319
|
+
const judgeError = { value: null };
|
|
320
|
+
const state_ = { disposition: "unclear", pAdvance: 0, hash: "", requestCount: 0, bestScored: null };
|
|
321
|
+
// Execute chunks with at most MAX_IN_FLIGHT concurrent judge calls.
|
|
322
|
+
// Each chunk races its own evaluate against the shared deadline.
|
|
323
|
+
// A settled chunk (success or error) frees its slot for the next waiting chunk.
|
|
324
|
+
let nextIdx = 0;
|
|
325
|
+
const inFlight = [];
|
|
326
|
+
function launchNext() {
|
|
327
|
+
while (inFlight.length < MAX_IN_FLIGHT && nextIdx < workQueue.length) {
|
|
328
|
+
const work = workQueue[nextIdx];
|
|
329
|
+
nextIdx++;
|
|
330
|
+
const p = runChunk(work).finally(() => {
|
|
331
|
+
const pos = inFlight.indexOf(p);
|
|
332
|
+
if (pos !== -1)
|
|
333
|
+
inFlight.splice(pos, 1);
|
|
334
|
+
launchNext();
|
|
335
|
+
});
|
|
336
|
+
inFlight.push(p);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function runChunk(work) {
|
|
340
|
+
if (work.items.length === 0) {
|
|
341
|
+
if (work.overflow)
|
|
342
|
+
categoryCompleted[work.category] = true;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const opaqueBatch = assignOpaqueIds(work.items.map(e => e.capped.candidate));
|
|
346
|
+
const { questions, dispositionKey } = buildBatchQuestions(opaqueBatch, prompt, recentContext, activeSkills, suppliedSkills);
|
|
347
|
+
state_.hash = questionHash(questions);
|
|
348
|
+
const state = buildState(prompt, recentContext, activeSkills, suppliedSkills, opaqueBatch);
|
|
349
|
+
const remaining = deadline - (deps.now?.() ?? Date.now());
|
|
350
|
+
if (remaining <= 0) {
|
|
351
|
+
categoryFailed[work.category] = true;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
let answers;
|
|
355
|
+
const j = judge;
|
|
356
|
+
try {
|
|
357
|
+
const result = await Promise.race([
|
|
358
|
+
j.evaluate({ state, questions }),
|
|
359
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), remaining)),
|
|
360
|
+
]);
|
|
361
|
+
answers = result.answers;
|
|
362
|
+
state_.requestCount++;
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
categoryFailed[work.category] = true;
|
|
366
|
+
judgeError.value = { message: err instanceof Error ? err.message : String(err), category: classifyError(err) };
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
categoryCompleted[work.category] = true;
|
|
370
|
+
if (state_.disposition === "unclear") {
|
|
371
|
+
const dispRaw = parseChoiceAnswer(answers[dispositionKey]);
|
|
372
|
+
if (dispRaw) {
|
|
373
|
+
state_.disposition = dispRaw.label;
|
|
374
|
+
state_.pAdvance = dispRaw.pAdvance;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (state_.disposition !== "advance")
|
|
378
|
+
return;
|
|
379
|
+
for (const { opaqueId, candidate } of opaqueBatch) {
|
|
380
|
+
const answer = parseScoreAnswer(answers[opaqueId]);
|
|
381
|
+
if (!answer)
|
|
382
|
+
continue;
|
|
383
|
+
const pUseful = (answer.probabilities[2] ?? 0) + (answer.probabilities[3] ?? 0);
|
|
384
|
+
if (!state_.bestScored || pUseful > state_.bestScored.usefulness ||
|
|
385
|
+
(pUseful === state_.bestScored.usefulness && answer.level > state_.bestScored.level) ||
|
|
386
|
+
(pUseful === state_.bestScored.usefulness && answer.level === state_.bestScored.level &&
|
|
387
|
+
(candidate.kind === "skill" && state_.bestScored.candidate.kind !== "skill" ||
|
|
388
|
+
(candidate.kind === state_.bestScored.candidate.kind && candidate.id < state_.bestScored.candidate.id)))) {
|
|
389
|
+
state_.bestScored = { candidate, usefulness: pUseful, level: answer.level };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
launchNext();
|
|
394
|
+
await Promise.all(inFlight);
|
|
395
|
+
const elapsedMs = (deps.now?.() ?? Date.now()) - start;
|
|
396
|
+
const { disposition, pAdvance, hash, requestCount, bestScored } = state_;
|
|
397
|
+
const skillUsable = categoryCompleted.skill && !categoryFailed.skill;
|
|
398
|
+
const toolUsable = categoryCompleted.tool && !categoryFailed.tool;
|
|
399
|
+
// When every category failed, the judge is broken — surface the error, not no_match.
|
|
400
|
+
if (!skillUsable && !toolUsable && judgeError.value) {
|
|
401
|
+
return { disposition: "unclear", selected: null, usefulness: 0, pAdvance: 0, questionHash: state_.hash, elapsedMs, requestCount: state_.requestCount, skipReason: "error", errorCategory: judgeError.value.category };
|
|
402
|
+
}
|
|
403
|
+
if (disposition !== "advance") {
|
|
404
|
+
const skipReason = disposition === "awaiting_user" ? "awaiting_user" : undefined;
|
|
405
|
+
const result = { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount };
|
|
406
|
+
if (skipReason)
|
|
407
|
+
result.skipReason = skipReason;
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
if (!bestScored) {
|
|
411
|
+
const skipReason = skillOverflow ? "catalog_limit" : "no_match";
|
|
412
|
+
return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason };
|
|
413
|
+
}
|
|
414
|
+
if (bestScored.candidate.kind === "skill" && !skillUsable) {
|
|
415
|
+
return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "no_match" };
|
|
416
|
+
}
|
|
417
|
+
if (bestScored.candidate.kind === "tool" && !toolUsable) {
|
|
418
|
+
return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "no_match" };
|
|
419
|
+
}
|
|
420
|
+
const passesUsefulness = bestScored.usefulness >= config.recommendThreshold;
|
|
421
|
+
const passesAdvance = pAdvance >= config.advanceThreshold;
|
|
422
|
+
if (!passesUsefulness || !passesAdvance) {
|
|
423
|
+
return { disposition, selected: null, usefulness: bestScored.usefulness, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "below_threshold" };
|
|
424
|
+
}
|
|
425
|
+
if (bestScored.candidate.kind === "skill" && skillOverflow) {
|
|
426
|
+
return { disposition, selected: null, usefulness: bestScored.usefulness, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "catalog_limit" };
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
disposition,
|
|
430
|
+
selected: bestScored.candidate,
|
|
431
|
+
usefulness: bestScored.usefulness,
|
|
432
|
+
pAdvance,
|
|
433
|
+
questionHash: hash,
|
|
434
|
+
elapsedMs,
|
|
435
|
+
requestCount,
|
|
436
|
+
};
|
|
437
|
+
}
|
package/dist/extension.d.ts
CHANGED
|
@@ -2,9 +2,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import type { ToolCallRef } from "./action-guard.js";
|
|
3
3
|
import type { SlopSymptom, Verdict } from "./guard.js";
|
|
4
4
|
import type { ShapeResult } from "./shape.js";
|
|
5
|
-
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
5
|
+
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
6
6
|
/** Which guard spent the user's attention. The status line reports one count per guard. */
|
|
7
|
-
export type SteerGuard = "action" | "rules" | "security" | "stuck" | "done" | "prose" | "runaway" | "subagent";
|
|
7
|
+
export type SteerGuard = "action" | "rules" | "security" | "stuck" | "done" | "prose" | "runaway" | "subagent" | "conscience";
|
|
8
8
|
/**
|
|
9
9
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
10
10
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|
|
@@ -33,5 +33,6 @@ export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[
|
|
|
33
33
|
* failed until Pi was restarted. The sections this build reads are therefore checked here as well, in the module that reads them.
|
|
34
34
|
*/
|
|
35
35
|
export declare function guardCurrentSections(result: ShapeResult): ShapeResult;
|
|
36
|
-
/**
|
|
36
|
+
/** Test-only: set the index-running state and write paths for the action guard bypass. */
|
|
37
|
+
export declare function _testSetIndexRunning(running: boolean, paths?: string[]): void;
|
|
37
38
|
export default function wardenExtension(pi: ExtensionAPI): void;
|