pi-warden 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -17
- package/dist/config.d.ts +30 -4
- package/dist/config.js +30 -5
- package/dist/extension.d.ts +4 -2
- package/dist/extension.js +140 -19
- package/dist/guard.d.ts +45 -18
- package/dist/guard.js +68 -29
- package/dist/index.d.ts +10 -5
- package/dist/index.js +6 -3
- package/dist/output.d.ts +61 -0
- package/dist/output.js +121 -0
- package/dist/panel.js +1 -1
- package/dist/prose.d.ts +57 -0
- package/dist/prose.js +98 -0
- package/dist/trace.d.ts +3 -1
- package/dist/trace.js +15 -1
- package/dist/widget.d.ts +13 -2
- package/dist/widget.js +27 -4
- package/package.json +1 -1
package/dist/prose.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { noul, TypeSafeIntegrationError } from "pi-typesafe";
|
|
2
|
+
import { redact } from "./redact.js";
|
|
3
|
+
export const PROSE_SYMPTOMS = ["wordy", "cliches", "jargon"];
|
|
4
|
+
export const proseQuestions = {
|
|
5
|
+
wordy: noul("Is `reply` longer than its content requires: a preamble, restating the request, summarising what it just said, or filler sentences that add no information?", {
|
|
6
|
+
true: "Yes: it opens with what it is about to do, repeats the question, ends with a summary of the same points, or pads with sentences that could be removed without losing anything.",
|
|
7
|
+
false: "No: each sentence carries information the reader needs; length matches the substance.",
|
|
8
|
+
}),
|
|
9
|
+
cliches: noul("Does `reply` use assistant clichés or filler phrasing: \"Great question\", \"Certainly!\", \"I hope this helps\", \"it's worth noting\", \"delve\", \"let me know if\", unrequested bullet lists of caveats, or emoji headings?", {
|
|
10
|
+
true: "Yes: one or more such phrases or patterns are present.",
|
|
11
|
+
false: "No: the wording is direct and specific to the subject.",
|
|
12
|
+
}),
|
|
13
|
+
jargon: noul("Would a reader described by `audience` struggle with `reply` because of unexplained technical terms or assumed background?", {
|
|
14
|
+
true: "Yes: terms, acronyms, or concepts appear without explanation that this audience would not know.",
|
|
15
|
+
false: "No: the reply matches what this audience can follow, or the audience is technical.",
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
export const PROSE_LABELS = {
|
|
19
|
+
wordy: "longer than the content needs (preamble, restating, summarising, filler)",
|
|
20
|
+
cliches: "assistant clichés or filler phrasing",
|
|
21
|
+
jargon: "unexplained technical terms for the configured audience",
|
|
22
|
+
};
|
|
23
|
+
const AUDIENCES = {
|
|
24
|
+
technical: "a software developer who knows this codebase and its tools",
|
|
25
|
+
plain: "a non-programmer who owns the product and reads the reply as a status update",
|
|
26
|
+
};
|
|
27
|
+
/** Map the two built-in audience names to descriptions Jev can judge against; anything else is used as written. */
|
|
28
|
+
export function describeAudience(audience) {
|
|
29
|
+
return AUDIENCES[audience.toLowerCase()] ?? audience;
|
|
30
|
+
}
|
|
31
|
+
export function buildProseRequest(task, reply, audience) {
|
|
32
|
+
return {
|
|
33
|
+
state: {
|
|
34
|
+
task: task?.trim() ? (task.trim().length > 1000 ? `${task.trim().slice(0, 1000)}…` : task.trim()) : "(no user request recorded in this session)",
|
|
35
|
+
audience: describeAudience(audience),
|
|
36
|
+
reply: redact(reply.length > 2500 ? `${reply.slice(0, 2500)}…` : reply),
|
|
37
|
+
},
|
|
38
|
+
questions: proseQuestions,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function evaluateProse(task, reply, options) {
|
|
42
|
+
try {
|
|
43
|
+
const timeout = AbortSignal.timeout(options.timeoutMs);
|
|
44
|
+
const result = await options.judge.evaluate(buildProseRequest(task, reply, options.config.audience), { signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout });
|
|
45
|
+
const scores = { wordy: result.answers.wordy.noul, cliches: result.answers.cliches.noul, jargon: result.answers.jargon.noul };
|
|
46
|
+
const flagged = PROSE_SYMPTOMS.filter(symptom => scores[symptom] >= options.config.threshold).sort((a, b) => scores[b] - scores[a]);
|
|
47
|
+
return { scores, flagged, model: result.model, elapsedMs: result.elapsedMs };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { flagged: [], error: error instanceof TypeSafeIntegrationError ? error.message : "TypeSafe request failed." };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Remembers the last three replies' symptoms. A nudge needs a symptom in `trend` of them, so one long answer to a long
|
|
55
|
+
* question is not punished, and after a nudge the next two replies are given time to change.
|
|
56
|
+
*/
|
|
57
|
+
export class ProseTrend {
|
|
58
|
+
history = [];
|
|
59
|
+
cooldown = 0;
|
|
60
|
+
counts = { wordy: 0, cliches: 0, jargon: 0 };
|
|
61
|
+
record(flagged) {
|
|
62
|
+
this.history.push([...flagged]);
|
|
63
|
+
if (this.history.length > 3)
|
|
64
|
+
this.history.shift();
|
|
65
|
+
for (const symptom of flagged)
|
|
66
|
+
this.counts[symptom]++;
|
|
67
|
+
if (this.cooldown > 0)
|
|
68
|
+
this.cooldown--;
|
|
69
|
+
}
|
|
70
|
+
/** Symptoms that crossed the trend requirement, or an empty list during cool-down. */
|
|
71
|
+
due(trend) {
|
|
72
|
+
if (this.cooldown > 0)
|
|
73
|
+
return [];
|
|
74
|
+
return PROSE_SYMPTOMS.filter(symptom => this.history.filter(set => set.includes(symptom)).length >= trend);
|
|
75
|
+
}
|
|
76
|
+
markNudged() {
|
|
77
|
+
this.cooldown = 2;
|
|
78
|
+
}
|
|
79
|
+
reset() {
|
|
80
|
+
this.history.length = 0;
|
|
81
|
+
this.cooldown = 0;
|
|
82
|
+
for (const symptom of PROSE_SYMPTOMS)
|
|
83
|
+
this.counts[symptom] = 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Queued for the next user prompt, so it shapes the next reply without spending a turn. */
|
|
87
|
+
export function proseNudge(symptoms, audience, counts) {
|
|
88
|
+
const parts = symptoms.map(symptom => `${PROSE_LABELS[symptom]}${counts[symptom] >= 3 ? ` (${counts[symptom]} replies this session)` : ""}`);
|
|
89
|
+
const fixes = [];
|
|
90
|
+
if (symptoms.includes("wordy"))
|
|
91
|
+
fixes.push("lead with the answer, cut preambles and closing summaries, and stop when the information is complete");
|
|
92
|
+
if (symptoms.includes("cliches"))
|
|
93
|
+
fixes.push("drop filler phrases and unrequested caveat lists");
|
|
94
|
+
if (symptoms.includes("jargon"))
|
|
95
|
+
fixes.push(`write for ${describeAudience(audience)}: explain or replace technical terms`);
|
|
96
|
+
const standing = symptoms.some(symptom => counts[symptom] >= 3) ? " Treat this as a standing rule for the rest of the session." : "";
|
|
97
|
+
return `pi-warden: your recent replies read as ${parts.join("; ")}. From the next reply on, ${fixes.join("; ")}.${standing}`;
|
|
98
|
+
}
|
package/dist/trace.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { DoneVerdict } from "./done.js";
|
|
2
2
|
import type { Verdict } from "./guard.js";
|
|
3
|
+
import type { ProseVerdict } from "./prose.js";
|
|
3
4
|
import type { Attempt, StuckVerdict } from "./stuck.js";
|
|
4
|
-
export type GuardName = "action" | "stuck" | "done";
|
|
5
|
+
export type GuardName = "action" | "stuck" | "done" | "prose" | "security" | "context";
|
|
5
6
|
export interface TraceEntry {
|
|
6
7
|
at: number;
|
|
7
8
|
guard: GuardName;
|
|
@@ -27,3 +28,4 @@ export declare function actionDetails(verdict: Verdict, extra?: {
|
|
|
27
28
|
}): string[];
|
|
28
29
|
export declare function stuckDetails(verdict: StuckVerdict, attempts: readonly Attempt[], told?: string): string[];
|
|
29
30
|
export declare function doneDetails(verdict: DoneVerdict, finalMessage: string, told?: string): string[];
|
|
31
|
+
export declare function proseDetails(verdict: ProseVerdict, reply: string, audience: string, told?: string): string[];
|
package/dist/trace.js
CHANGED
|
@@ -42,8 +42,10 @@ export function actionDetails(verdict, extra = {}) {
|
|
|
42
42
|
if (judgment) {
|
|
43
43
|
lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
|
|
44
44
|
}
|
|
45
|
+
if (judgment?.securityRisk !== undefined)
|
|
46
|
+
lines.push(`security risk: ${percent(judgment.securityRisk)}`);
|
|
45
47
|
if (verdict.slop)
|
|
46
|
-
lines.push(`slop:
|
|
48
|
+
lines.push(`slop: stub ${percent(verdict.slop.stub)} · comments ${percent(verdict.slop.comments)} · dead ${percent(verdict.slop.dead)} · hedging ${percent(verdict.slop.hedging)}${verdict.slopReasons?.length ? ` → ${verdict.slopReasons.join("; ")}` : ""}`);
|
|
47
49
|
if (verdict.reasons.length)
|
|
48
50
|
lines.push(`why: ${verdict.reasons.join("; ")}`);
|
|
49
51
|
if (verdict.error)
|
|
@@ -79,3 +81,15 @@ export function doneDetails(verdict, finalMessage, told) {
|
|
|
79
81
|
lines.push(`agent told: ${clip(told, 400)}`);
|
|
80
82
|
return lines;
|
|
81
83
|
}
|
|
84
|
+
export function proseDetails(verdict, reply, audience, told) {
|
|
85
|
+
const lines = [`reply: ${clip(reply.replace(/\s+/g, " "), 300)}`, `audience: ${audience}`];
|
|
86
|
+
if (verdict.scores)
|
|
87
|
+
lines.push(`jev: wordy ${percent(verdict.scores.wordy)} · clichés ${percent(verdict.scores.cliches)} · jargon ${percent(verdict.scores.jargon)}${verdict.model ? ` · ${verdict.model} · ${verdict.elapsedMs} ms` : ""}`);
|
|
88
|
+
if (verdict.flagged.length)
|
|
89
|
+
lines.push(`flagged: ${verdict.flagged.join(", ")}`);
|
|
90
|
+
if (verdict.error)
|
|
91
|
+
lines.push(`typesafe: ${verdict.error}`);
|
|
92
|
+
if (told)
|
|
93
|
+
lines.push(`agent told: ${clip(told, 400)}`);
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
package/dist/widget.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DoneVerdict } from "./done.js";
|
|
2
2
|
import type { Verdict } from "./guard.js";
|
|
3
|
+
import type { ProseVerdict } from "./prose.js";
|
|
3
4
|
import type { StuckVerdict } from "./stuck.js";
|
|
4
5
|
export type WidgetPlacement = "aboveEditor" | "belowEditor";
|
|
5
6
|
export interface WidgetConfig {
|
|
@@ -11,11 +12,17 @@ export interface WidgetConfig {
|
|
|
11
12
|
action: string;
|
|
12
13
|
stuck: string;
|
|
13
14
|
done: string;
|
|
15
|
+
prose: string;
|
|
16
|
+
security: string;
|
|
17
|
+
context: string;
|
|
14
18
|
}
|
|
15
19
|
export declare const DEFAULT_TEMPLATES: {
|
|
16
|
-
readonly action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop {
|
|
20
|
+
readonly action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}";
|
|
17
21
|
readonly stuck: "warden · stuck · {failures} failures · same strategy {sameStrategy} · change {approachChange} · progress {progress} · {flags} · {status}";
|
|
18
22
|
readonly done: "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}";
|
|
23
|
+
readonly prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}";
|
|
24
|
+
readonly security: "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}";
|
|
25
|
+
readonly context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes";
|
|
19
26
|
};
|
|
20
27
|
export declare function defaultWidgetConfig(): WidgetConfig;
|
|
21
28
|
export type Tokens = Record<string, string | undefined>;
|
|
@@ -27,9 +34,13 @@ export declare function renderTemplate(template: string, tokens: Tokens): string
|
|
|
27
34
|
export declare function actionTokens(verdict: Verdict, at?: number): Tokens;
|
|
28
35
|
export declare function stuckTokens(verdict: StuckVerdict, at?: number): Tokens;
|
|
29
36
|
export declare function doneTokens(verdict: DoneVerdict, at?: number): Tokens;
|
|
37
|
+
export declare function proseTokens(verdict: ProseVerdict, at?: number): Tokens;
|
|
30
38
|
/** Token names users can put in templates, for /warden status and the README. */
|
|
31
39
|
export declare const TOKEN_NAMES: {
|
|
32
|
-
readonly
|
|
40
|
+
readonly security: readonly ["tool", "injection", "exfiltration", "status"];
|
|
41
|
+
readonly context: readonly ["tool", "retention", "bytesSaved"];
|
|
42
|
+
readonly action: readonly ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"];
|
|
43
|
+
readonly prose: readonly ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
33
44
|
readonly stuck: readonly ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
34
45
|
readonly done: readonly ["changes", "checks", "checksPassed", "claimsDone", "claimsVerified", "checksApply", "outcome", "status", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
35
46
|
};
|
package/dist/widget.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export const DEFAULT_TEMPLATES = {
|
|
2
|
-
action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop {
|
|
2
|
+
action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}",
|
|
3
3
|
stuck: "warden · stuck · {failures} failures · same strategy {sameStrategy} · change {approachChange} · progress {progress} · {flags} · {status}",
|
|
4
4
|
done: "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}",
|
|
5
|
+
prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}",
|
|
6
|
+
security: "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}",
|
|
7
|
+
context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes",
|
|
5
8
|
};
|
|
6
9
|
export function defaultWidgetConfig() {
|
|
7
10
|
return { enabled: true, placement: "aboveEditor", shortcut: "ctrl+shift+w", ...DEFAULT_TEMPLATES };
|
|
@@ -45,8 +48,11 @@ export function actionTokens(verdict, at = Date.now()) {
|
|
|
45
48
|
offTask: fixed(verdict.judgment?.offTask),
|
|
46
49
|
scope: verdict.judgment?.scope.replace(/_/g, " "),
|
|
47
50
|
approved: fixed(verdict.judgment?.approved),
|
|
48
|
-
|
|
49
|
-
slopStub: fixed(verdict.slop?.
|
|
51
|
+
slop: verdict.slopSymptoms?.length ? verdict.slopSymptoms.map(symptom => `${symptom} ${verdict.slop[symptom].toFixed(2)}`).join(", ") : verdict.slop ? "none" : undefined,
|
|
52
|
+
slopStub: fixed(verdict.slop?.stub),
|
|
53
|
+
slopComments: fixed(verdict.slop?.comments),
|
|
54
|
+
slopDead: fixed(verdict.slop?.dead),
|
|
55
|
+
slopHedging: fixed(verdict.slop?.hedging),
|
|
50
56
|
patterns: verdict.patterns.length ? verdict.patterns.map(hit => hit.id).join(", ") : undefined,
|
|
51
57
|
reasons: verdict.reasons.length ? verdict.reasons.join("; ") : undefined,
|
|
52
58
|
path: verdict.summary.path,
|
|
@@ -93,9 +99,26 @@ export function doneTokens(verdict, at = Date.now()) {
|
|
|
93
99
|
flags: verdict.error ? "typesafe error" : undefined,
|
|
94
100
|
};
|
|
95
101
|
}
|
|
102
|
+
export function proseTokens(verdict, at = Date.now()) {
|
|
103
|
+
return {
|
|
104
|
+
guard: "prose",
|
|
105
|
+
time: time(at),
|
|
106
|
+
wordy: fixed(verdict.scores?.wordy),
|
|
107
|
+
cliches: fixed(verdict.scores?.cliches),
|
|
108
|
+
jargon: fixed(verdict.scores?.jargon),
|
|
109
|
+
status: verdict.nudged ? "nudged" : verdict.flagged.length ? verdict.flagged.join(", ") : "ok",
|
|
110
|
+
reasons: verdict.flagged.length ? verdict.flagged.join(", ") : undefined,
|
|
111
|
+
model: verdict.model,
|
|
112
|
+
ms: verdict.elapsedMs === undefined ? undefined : String(verdict.elapsedMs),
|
|
113
|
+
flags: verdict.error ? "typesafe error" : undefined,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
96
116
|
/** Token names users can put in templates, for /warden status and the README. */
|
|
97
117
|
export const TOKEN_NAMES = {
|
|
98
|
-
|
|
118
|
+
security: ["tool", "injection", "exfiltration", "status"],
|
|
119
|
+
context: ["tool", "retention", "bytesSaved"],
|
|
120
|
+
action: ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"],
|
|
121
|
+
prose: ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"],
|
|
99
122
|
stuck: ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"],
|
|
100
123
|
done: ["changes", "checks", "checksPassed", "claimsDone", "claimsVerified", "checksApply", "outcome", "status", "reasons", "model", "ms", "flags", "time", "guard"],
|
|
101
124
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, detects stuck loops, checks unverified done claims, and flags slop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|