pi-warden 0.2.1 → 0.4.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 +61 -13
- package/dist/config.d.ts +19 -4
- package/dist/config.js +35 -6
- package/dist/done.d.ts +2 -2
- package/dist/done.js +11 -13
- package/dist/extension.d.ts +3 -1
- package/dist/extension.js +125 -28
- package/dist/guard.d.ts +20 -11
- package/dist/guard.js +53 -38
- package/dist/index.d.ts +11 -2
- package/dist/index.js +5 -0
- package/dist/panel.d.ts +44 -0
- package/dist/panel.js +121 -0
- package/dist/prose.d.ts +57 -0
- package/dist/prose.js +98 -0
- package/dist/stuck.d.ts +6 -3
- package/dist/stuck.js +11 -14
- package/dist/tools.d.ts +16 -0
- package/dist/tools.js +37 -0
- package/dist/trace.d.ts +31 -0
- package/dist/trace.js +93 -0
- package/dist/widget.d.ts +40 -0
- package/dist/widget.js +120 -0
- package/package.json +1 -1
package/dist/panel.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
const LEVEL_COLOR = { allow: "success", ok: "success", warn: "warning", unverified: "warning", nudged: "warning", confirm: "error", stuck: "error", "false claim": "error" };
|
|
3
|
+
/**
|
|
4
|
+
* Side panel listing the trace newest-first, live-updating while open. Keys: ↑/↓/j/k scroll a line, PgUp/PgDn a page,
|
|
5
|
+
* Home/End jump, c clears the trace, Esc or q closes. Wheel scrolling works in fullscreen mode.
|
|
6
|
+
*/
|
|
7
|
+
export class TracePanel {
|
|
8
|
+
trace;
|
|
9
|
+
theme;
|
|
10
|
+
close;
|
|
11
|
+
requestRender;
|
|
12
|
+
title;
|
|
13
|
+
focused = true;
|
|
14
|
+
scroll = 0;
|
|
15
|
+
viewport = 20;
|
|
16
|
+
unsubscribe;
|
|
17
|
+
constructor(trace, theme, close, requestRender, title = "pi-warden trace") {
|
|
18
|
+
this.trace = trace;
|
|
19
|
+
this.theme = theme;
|
|
20
|
+
this.close = close;
|
|
21
|
+
this.requestRender = requestRender;
|
|
22
|
+
this.title = title;
|
|
23
|
+
this.unsubscribe = trace.subscribe(() => this.requestRender());
|
|
24
|
+
}
|
|
25
|
+
dispose() {
|
|
26
|
+
this.unsubscribe();
|
|
27
|
+
}
|
|
28
|
+
/** Rendering is cheap and derived from the trace each time; nothing is cached. */
|
|
29
|
+
invalidate() { }
|
|
30
|
+
lines(width) {
|
|
31
|
+
const { theme } = this;
|
|
32
|
+
const entries = this.trace.entries();
|
|
33
|
+
const out = [];
|
|
34
|
+
out.push(theme.bold(theme.fg("accent", ` ${this.title}`)) + theme.fg("muted", ` · ${entries.length} event${entries.length === 1 ? "" : "s"} · ↑↓ PgUp PgDn scroll · c clear · esc close`));
|
|
35
|
+
out.push(theme.fg("muted", "─".repeat(Math.max(0, width))));
|
|
36
|
+
if (!entries.length) {
|
|
37
|
+
out.push(theme.fg("muted", " No guarded activity yet this session. Verdicts, Jev scores, and what the agent was told will appear here."));
|
|
38
|
+
return out.map(line => truncateToWidth(line, width, ""));
|
|
39
|
+
}
|
|
40
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
41
|
+
const entry = entries[index];
|
|
42
|
+
const status = entry.line.split(" · ").at(-1) ?? "";
|
|
43
|
+
const color = LEVEL_COLOR[status] ?? "text";
|
|
44
|
+
const head = `${theme.fg("muted", new Date(entry.at).toTimeString().slice(0, 8))} ${theme.fg(color, theme.bold(entry.guard.padEnd(6)))} `;
|
|
45
|
+
const headWidth = 9 + 7;
|
|
46
|
+
const body = wrapTextWithAnsi(theme.fg(color, entry.line), Math.max(10, width - headWidth));
|
|
47
|
+
out.push(head + (body[0] ?? ""));
|
|
48
|
+
for (const continuation of body.slice(1))
|
|
49
|
+
out.push(" ".repeat(headWidth) + continuation);
|
|
50
|
+
for (const detail of entry.details) {
|
|
51
|
+
const wrapped = wrapTextWithAnsi(detail, Math.max(10, width - headWidth - 2));
|
|
52
|
+
for (const [n, line] of wrapped.entries())
|
|
53
|
+
out.push(" ".repeat(headWidth) + theme.fg("muted", n === 0 ? "· " : " ") + theme.fg("dim", line));
|
|
54
|
+
}
|
|
55
|
+
out.push("");
|
|
56
|
+
}
|
|
57
|
+
return out.map(line => truncateToWidth(line, width, ""));
|
|
58
|
+
}
|
|
59
|
+
render(width) {
|
|
60
|
+
const all = this.lines(width);
|
|
61
|
+
const maxScroll = Math.max(0, all.length - this.viewport);
|
|
62
|
+
if (this.scroll > maxScroll)
|
|
63
|
+
this.scroll = maxScroll;
|
|
64
|
+
const visible = all.slice(this.scroll, this.scroll + this.viewport);
|
|
65
|
+
if (all.length > this.viewport) {
|
|
66
|
+
const last = visible.length - 1;
|
|
67
|
+
visible[last] = truncateToWidth(theme_fg(this.theme, "muted", ` … ${all.length - this.scroll - this.viewport > 0 ? `${all.length - this.scroll - this.viewport} more lines below` : "end"} · ${this.scroll} above`), width, "");
|
|
68
|
+
}
|
|
69
|
+
return visible;
|
|
70
|
+
}
|
|
71
|
+
/** The overlay tells us how tall we may be through the layout; fall back to a fixed viewport otherwise. */
|
|
72
|
+
setViewport(rows) {
|
|
73
|
+
this.viewport = Math.max(5, rows);
|
|
74
|
+
}
|
|
75
|
+
handleInput(data) {
|
|
76
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
77
|
+
this.close();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (matchesKey(data, Key.up) || data === "k")
|
|
81
|
+
this.scroll = Math.max(0, this.scroll - 1);
|
|
82
|
+
else if (matchesKey(data, Key.down) || data === "j")
|
|
83
|
+
this.scroll += 1;
|
|
84
|
+
else if (matchesKey(data, Key.pageUp))
|
|
85
|
+
this.scroll = Math.max(0, this.scroll - this.viewport);
|
|
86
|
+
else if (matchesKey(data, Key.pageDown))
|
|
87
|
+
this.scroll += this.viewport;
|
|
88
|
+
else if (matchesKey(data, Key.home))
|
|
89
|
+
this.scroll = 0;
|
|
90
|
+
else if (matchesKey(data, Key.end))
|
|
91
|
+
this.scroll = Number.MAX_SAFE_INTEGER;
|
|
92
|
+
else if (data === "c")
|
|
93
|
+
this.trace.clear();
|
|
94
|
+
else
|
|
95
|
+
return;
|
|
96
|
+
this.requestRender();
|
|
97
|
+
}
|
|
98
|
+
handleMouse(event) {
|
|
99
|
+
if (event.type === "wheel") {
|
|
100
|
+
this.scroll = Math.max(0, this.scroll + (event.wheelDelta ?? 0) * 3);
|
|
101
|
+
return { handled: true, render: true };
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function theme_fg(theme, color, text) {
|
|
107
|
+
return theme.fg(color, text);
|
|
108
|
+
}
|
|
109
|
+
/** Open the trace as a right-hand overlay. Resolves when the user closes it. */
|
|
110
|
+
export function openTracePanel(ui, trace) {
|
|
111
|
+
return ui.custom((tui, theme, _keybindings, done) => {
|
|
112
|
+
const panel = new TracePanel(trace, theme, () => done(), () => tui.requestRender());
|
|
113
|
+
const rows = tui.terminal?.rows;
|
|
114
|
+
if (typeof rows === "number")
|
|
115
|
+
panel.setViewport(Math.floor(rows * 0.9) - 2);
|
|
116
|
+
return panel;
|
|
117
|
+
}, {
|
|
118
|
+
overlay: true,
|
|
119
|
+
overlayOptions: { anchor: "right-center", width: "48%", minWidth: 56, maxHeight: "90%", margin: { right: 1 } },
|
|
120
|
+
});
|
|
121
|
+
}
|
package/dist/prose.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ProseConfig } from "./config.js";
|
|
2
|
+
import type { Judge } from "./guard.js";
|
|
3
|
+
export type ProseSymptom = "wordy" | "cliches" | "jargon";
|
|
4
|
+
export declare const PROSE_SYMPTOMS: readonly ProseSymptom[];
|
|
5
|
+
export declare const proseQuestions: {
|
|
6
|
+
wordy: import("pi-typesafe").NoulQuestion;
|
|
7
|
+
cliches: import("pi-typesafe").NoulQuestion;
|
|
8
|
+
jargon: import("pi-typesafe").NoulQuestion;
|
|
9
|
+
};
|
|
10
|
+
export declare const PROSE_LABELS: Record<ProseSymptom, string>;
|
|
11
|
+
/** Map the two built-in audience names to descriptions Jev can judge against; anything else is used as written. */
|
|
12
|
+
export declare function describeAudience(audience: string): string;
|
|
13
|
+
export interface ProseVerdict {
|
|
14
|
+
scores?: Record<ProseSymptom, number>;
|
|
15
|
+
/** Symptoms at or above the threshold for this reply, strongest first. */
|
|
16
|
+
flagged: ProseSymptom[];
|
|
17
|
+
/** Set by the caller when a nudge was sent. */
|
|
18
|
+
nudged?: boolean;
|
|
19
|
+
model?: string;
|
|
20
|
+
elapsedMs?: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildProseRequest(task: string | undefined, reply: string, audience: string): {
|
|
24
|
+
state: {
|
|
25
|
+
task: string;
|
|
26
|
+
audience: string;
|
|
27
|
+
reply: string;
|
|
28
|
+
};
|
|
29
|
+
questions: {
|
|
30
|
+
wordy: import("pi-typesafe").NoulQuestion;
|
|
31
|
+
cliches: import("pi-typesafe").NoulQuestion;
|
|
32
|
+
jargon: import("pi-typesafe").NoulQuestion;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export interface ProseOptions {
|
|
36
|
+
config: ProseConfig;
|
|
37
|
+
judge: Judge;
|
|
38
|
+
timeoutMs: number;
|
|
39
|
+
signal?: AbortSignal | undefined;
|
|
40
|
+
}
|
|
41
|
+
export declare function evaluateProse(task: string | undefined, reply: string, options: ProseOptions): Promise<ProseVerdict>;
|
|
42
|
+
/**
|
|
43
|
+
* Remembers the last three replies' symptoms. A nudge needs a symptom in `trend` of them, so one long answer to a long
|
|
44
|
+
* question is not punished, and after a nudge the next two replies are given time to change.
|
|
45
|
+
*/
|
|
46
|
+
export declare class ProseTrend {
|
|
47
|
+
private readonly history;
|
|
48
|
+
private cooldown;
|
|
49
|
+
readonly counts: Record<ProseSymptom, number>;
|
|
50
|
+
record(flagged: readonly ProseSymptom[]): void;
|
|
51
|
+
/** Symptoms that crossed the trend requirement, or an empty list during cool-down. */
|
|
52
|
+
due(trend: number): ProseSymptom[];
|
|
53
|
+
markNudged(): void;
|
|
54
|
+
reset(): void;
|
|
55
|
+
}
|
|
56
|
+
/** Queued for the next user prompt, so it shapes the next reply without spending a turn. */
|
|
57
|
+
export declare function proseNudge(symptoms: readonly ProseSymptom[], audience: string, counts: Record<ProseSymptom, number>): string;
|
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/stuck.d.ts
CHANGED
|
@@ -32,8 +32,11 @@ export declare function resultText(content: ReadonlyArray<{
|
|
|
32
32
|
type: string;
|
|
33
33
|
text?: string;
|
|
34
34
|
}>): string;
|
|
35
|
-
/** Non-zero exit codes count as failures even when the tool did not flag an error. */
|
|
36
|
-
export declare function resultFailed(isError: boolean, details: unknown
|
|
35
|
+
/** Non-zero exit codes count as failures even when the tool did not flag an error; context-mode reports them in the text. */
|
|
36
|
+
export declare function resultFailed(isError: boolean, details: unknown, content?: ReadonlyArray<{
|
|
37
|
+
type: string;
|
|
38
|
+
text?: string;
|
|
39
|
+
}>): boolean;
|
|
37
40
|
export declare function makeAttempt(tool: string, input: Record<string, unknown>, content: ReadonlyArray<{
|
|
38
41
|
type: string;
|
|
39
42
|
text?: string;
|
|
@@ -85,4 +88,4 @@ export interface StuckOptions {
|
|
|
85
88
|
export declare function evaluateStuck(window: AttemptWindow, task: string | undefined, options: StuckOptions): Promise<StuckVerdict>;
|
|
86
89
|
/** Steering text for the agent. Names the pattern and asks for a change of method, not another retry. */
|
|
87
90
|
export declare function stuckNudge(verdict: StuckVerdict): string;
|
|
88
|
-
export declare function formatStuck(verdict: StuckVerdict): string;
|
|
91
|
+
export declare function formatStuck(verdict: StuckVerdict, template?: string): string;
|
package/dist/stuck.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { noul, score, TypeSafeIntegrationError } from "pi-typesafe";
|
|
3
3
|
import { redact } from "./redact.js";
|
|
4
|
+
import { commandOf, outputReportsFailure } from "./tools.js";
|
|
5
|
+
import { DEFAULT_TEMPLATES, renderTemplate, stuckTokens } from "./widget.js";
|
|
4
6
|
const CALL_LIMIT = 300;
|
|
5
7
|
const OUTPUT_LIMIT = 400;
|
|
6
8
|
function head(text, limit) {
|
|
@@ -13,12 +15,14 @@ function tail(text, limit) {
|
|
|
13
15
|
export function resultText(content) {
|
|
14
16
|
return content.filter(part => part.type === "text" && typeof part.text === "string").map(part => part.text).join("\n");
|
|
15
17
|
}
|
|
16
|
-
/** Non-zero exit codes count as failures even when the tool did not flag an error. */
|
|
17
|
-
export function resultFailed(isError, details) {
|
|
18
|
+
/** Non-zero exit codes count as failures even when the tool did not flag an error; context-mode reports them in the text. */
|
|
19
|
+
export function resultFailed(isError, details, content = []) {
|
|
18
20
|
if (isError)
|
|
19
21
|
return true;
|
|
20
22
|
const exitCode = details && typeof details === "object" ? details.exitCode : undefined;
|
|
21
|
-
|
|
23
|
+
if (typeof exitCode === "number" && exitCode !== 0)
|
|
24
|
+
return true;
|
|
25
|
+
return outputReportsFailure(resultText(content));
|
|
22
26
|
}
|
|
23
27
|
/** Durations, timestamps, PIDs, and addresses change between identical runs; counts and line numbers stay. */
|
|
24
28
|
function normaliseOutput(text) {
|
|
@@ -29,7 +33,8 @@ function normaliseOutput(text) {
|
|
|
29
33
|
.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?Z?/g, "#date");
|
|
30
34
|
}
|
|
31
35
|
export function makeAttempt(tool, input, content, failed) {
|
|
32
|
-
const
|
|
36
|
+
const command = commandOf(tool, input)?.command;
|
|
37
|
+
const call = command !== undefined ? command
|
|
33
38
|
: typeof input.path === "string" ? `${tool} ${input.path}`
|
|
34
39
|
: JSON.stringify(input);
|
|
35
40
|
const text = resultText(content).trim();
|
|
@@ -135,14 +140,6 @@ export async function evaluateStuck(window, task, options) {
|
|
|
135
140
|
export function stuckNudge(verdict) {
|
|
136
141
|
return `pi-warden: ${verdict.reasons.join("; ")}. Stop retrying. Re-read the last error output carefully, state a new hypothesis about the cause, and either gather the missing information (read the relevant file, check versions or paths) or try a different method. If two different methods have failed, report the blocker to the user with the exact error instead of trying again.`;
|
|
137
142
|
}
|
|
138
|
-
export function formatStuck(verdict) {
|
|
139
|
-
|
|
140
|
-
if (verdict.judgment)
|
|
141
|
-
parts.push(`same strategy ${verdict.judgment.sameStrategy.toFixed(2)}`, `change ${verdict.judgment.approachChange.toFixed(1)}/2`, `progress ${verdict.judgment.progress.toFixed(2)}`);
|
|
142
|
-
if (verdict.source === "repeat" && verdict.stuck)
|
|
143
|
-
parts.push("exact repeat");
|
|
144
|
-
if (verdict.source === "error")
|
|
145
|
-
parts.push("typesafe error");
|
|
146
|
-
parts.push(verdict.stuck ? "stuck" : "ok");
|
|
147
|
-
return parts.join(" · ");
|
|
143
|
+
export function formatStuck(verdict, template = DEFAULT_TEMPLATES.stuck) {
|
|
144
|
+
return renderTemplate(template, stuckTokens(verdict));
|
|
148
145
|
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge of which tool inputs carry shell commands. Pi's built-ins plus context-mode's ctx_* tools, which many Pi users
|
|
3
|
+
* run instead of bash. Unknown tools return nothing and are judged from their JSON input only.
|
|
4
|
+
*/
|
|
5
|
+
export interface CommandView {
|
|
6
|
+
/** Shell text to pattern-match and judge. Several commands are joined with newlines. */
|
|
7
|
+
command: string;
|
|
8
|
+
/** False when the code is not shell (JavaScript, Python …); pattern rules still run, the read-only shortcut does not. */
|
|
9
|
+
shell: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Extract the command text a tool call would execute, or undefined for tools that run no commands. */
|
|
12
|
+
export declare function commandOf(tool: string, input: Record<string, unknown>): CommandView | undefined;
|
|
13
|
+
/** Tools whose calls carry commands and should be guarded by default. */
|
|
14
|
+
export declare const COMMAND_TOOLS: string[];
|
|
15
|
+
/** Text that signals failure in tool output when the tool itself did not flag an error (context-mode reports exit codes inline). */
|
|
16
|
+
export declare function outputReportsFailure(text: string): boolean;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge of which tool inputs carry shell commands. Pi's built-ins plus context-mode's ctx_* tools, which many Pi users
|
|
3
|
+
* run instead of bash. Unknown tools return nothing and are judged from their JSON input only.
|
|
4
|
+
*/
|
|
5
|
+
const SHELL_LANGUAGES = new Set(["shell", "bash", "sh", "zsh", "powershell"]);
|
|
6
|
+
function commandsOf(value) {
|
|
7
|
+
if (!Array.isArray(value))
|
|
8
|
+
return [];
|
|
9
|
+
return value.map(item => (item && typeof item === "object" ? item.command : undefined)).filter((command) => typeof command === "string");
|
|
10
|
+
}
|
|
11
|
+
/** Extract the command text a tool call would execute, or undefined for tools that run no commands. */
|
|
12
|
+
export function commandOf(tool, input) {
|
|
13
|
+
switch (tool) {
|
|
14
|
+
case "bash":
|
|
15
|
+
case "powershell":
|
|
16
|
+
return typeof input.command === "string" ? { command: input.command, shell: true } : undefined;
|
|
17
|
+
case "ctx_execute":
|
|
18
|
+
case "ctx_execute_file": {
|
|
19
|
+
if (typeof input.code !== "string")
|
|
20
|
+
return undefined;
|
|
21
|
+
const language = typeof input.language === "string" ? input.language.toLowerCase() : "";
|
|
22
|
+
return { command: input.code, shell: SHELL_LANGUAGES.has(language) };
|
|
23
|
+
}
|
|
24
|
+
case "ctx_batch_execute": {
|
|
25
|
+
const commands = commandsOf(input.commands);
|
|
26
|
+
return commands.length ? { command: commands.join("\n"), shell: true } : undefined;
|
|
27
|
+
}
|
|
28
|
+
default:
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Tools whose calls carry commands and should be guarded by default. */
|
|
33
|
+
export const COMMAND_TOOLS = ["bash", "powershell", "ctx_execute", "ctx_batch_execute", "ctx_execute_file"];
|
|
34
|
+
/** Text that signals failure in tool output when the tool itself did not flag an error (context-mode reports exit codes inline). */
|
|
35
|
+
export function outputReportsFailure(text) {
|
|
36
|
+
return /(?:^|\n)\s*Command exited with code [1-9]\d*\b/.test(text) || /(?:^|\n)\s*\(timed out\)\s*$/.test(text);
|
|
37
|
+
}
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DoneVerdict } from "./done.js";
|
|
2
|
+
import type { Verdict } from "./guard.js";
|
|
3
|
+
import type { ProseVerdict } from "./prose.js";
|
|
4
|
+
import type { Attempt, StuckVerdict } from "./stuck.js";
|
|
5
|
+
export type GuardName = "action" | "stuck" | "done" | "prose";
|
|
6
|
+
export interface TraceEntry {
|
|
7
|
+
at: number;
|
|
8
|
+
guard: GuardName;
|
|
9
|
+
/** The widget line for this event. */
|
|
10
|
+
line: string;
|
|
11
|
+
/** Redacted details: what was inspected, what Jev answered, what the agent was told. */
|
|
12
|
+
details: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Session memory of guard decisions; the widget shows the latest line per guard, the panel shows the history. */
|
|
15
|
+
export declare class Trace {
|
|
16
|
+
private readonly limit;
|
|
17
|
+
private readonly items;
|
|
18
|
+
private readonly listeners;
|
|
19
|
+
constructor(limit?: number);
|
|
20
|
+
push(entry: TraceEntry): void;
|
|
21
|
+
entries(): readonly TraceEntry[];
|
|
22
|
+
clear(): void;
|
|
23
|
+
subscribe(listener: () => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function actionDetails(verdict: Verdict, extra?: {
|
|
26
|
+
mode?: string;
|
|
27
|
+
told?: string;
|
|
28
|
+
}): string[];
|
|
29
|
+
export declare function stuckDetails(verdict: StuckVerdict, attempts: readonly Attempt[], told?: string): string[];
|
|
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
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Session memory of guard decisions; the widget shows the latest line per guard, the panel shows the history. */
|
|
2
|
+
export class Trace {
|
|
3
|
+
limit;
|
|
4
|
+
items = [];
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
constructor(limit = 100) {
|
|
7
|
+
this.limit = limit;
|
|
8
|
+
}
|
|
9
|
+
push(entry) {
|
|
10
|
+
this.items.push(entry);
|
|
11
|
+
if (this.items.length > this.limit)
|
|
12
|
+
this.items.splice(0, this.items.length - this.limit);
|
|
13
|
+
for (const listener of this.listeners)
|
|
14
|
+
listener();
|
|
15
|
+
}
|
|
16
|
+
entries() {
|
|
17
|
+
return this.items;
|
|
18
|
+
}
|
|
19
|
+
clear() {
|
|
20
|
+
this.items.length = 0;
|
|
21
|
+
for (const listener of this.listeners)
|
|
22
|
+
listener();
|
|
23
|
+
}
|
|
24
|
+
subscribe(listener) {
|
|
25
|
+
this.listeners.add(listener);
|
|
26
|
+
return () => { this.listeners.delete(listener); };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const clip = (text, limit) => (text.length <= limit ? text : `${text.slice(0, limit)}…`);
|
|
30
|
+
const percent = (value) => value.toFixed(2);
|
|
31
|
+
export function actionDetails(verdict, extra = {}) {
|
|
32
|
+
const { summary, judgment } = verdict;
|
|
33
|
+
const lines = [];
|
|
34
|
+
if (summary.command !== undefined)
|
|
35
|
+
lines.push(`ran: ${clip(summary.command.replace(/\s+/g, " "), 300)}`);
|
|
36
|
+
if (summary.path !== undefined)
|
|
37
|
+
lines.push(`${summary.tool} ${summary.path}${summary.location === "outside_project" ? " (outside the project)" : ""}${summary.exists === false ? " (new file)" : ""}${summary.bytes !== undefined ? ` · ${summary.bytes} bytes` : ""}${summary.editCount !== undefined ? ` · ${summary.editCount} edit${summary.editCount === 1 ? "" : "s"}` : ""}`);
|
|
38
|
+
if (summary.input !== undefined)
|
|
39
|
+
lines.push(`input: ${clip(summary.input, 300)}`);
|
|
40
|
+
if (verdict.patterns.length)
|
|
41
|
+
lines.push(`patterns: ${verdict.patterns.map(hit => `${hit.id} (${hit.severity})`).join(", ")}`);
|
|
42
|
+
if (judgment) {
|
|
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
|
+
}
|
|
45
|
+
if (verdict.slop)
|
|
46
|
+
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
|
+
if (verdict.reasons.length)
|
|
48
|
+
lines.push(`why: ${verdict.reasons.join("; ")}`);
|
|
49
|
+
if (verdict.error)
|
|
50
|
+
lines.push(`typesafe: ${verdict.error}`);
|
|
51
|
+
if (extra.mode && verdict.level === "confirm")
|
|
52
|
+
lines.push(`mode: ${extra.mode}`);
|
|
53
|
+
if (extra.told)
|
|
54
|
+
lines.push(`agent told: ${clip(extra.told, 400)}`);
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
export function stuckDetails(verdict, attempts, told) {
|
|
58
|
+
const lines = attempts.map((attempt, index) => `${index + 1}. ${attempt.failed ? "✗" : "✓"} ${clip(attempt.call.replace(/\s+/g, " "), 120)}${attempt.failed && attempt.output ? ` → ${clip(attempt.output.replace(/\s+/g, " "), 120)}` : ""}`);
|
|
59
|
+
if (verdict.judgment)
|
|
60
|
+
lines.push(`jev: same strategy ${percent(verdict.judgment.sameStrategy)} · approach change ${verdict.judgment.approachChange.toFixed(2)}/2 · progress ${percent(verdict.judgment.progress)} · ${verdict.judgment.model} · ${verdict.judgment.elapsedMs} ms`);
|
|
61
|
+
if (verdict.reasons.length)
|
|
62
|
+
lines.push(`why: ${verdict.reasons.join("; ")}`);
|
|
63
|
+
if (verdict.error)
|
|
64
|
+
lines.push(`typesafe: ${verdict.error}`);
|
|
65
|
+
if (told)
|
|
66
|
+
lines.push(`agent told: ${clip(told, 400)}`);
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
export function doneDetails(verdict, finalMessage, told) {
|
|
70
|
+
const lines = [`final message: ${clip(finalMessage.replace(/\s+/g, " "), 300)}`];
|
|
71
|
+
lines.push(`evidence: ${verdict.evidence.mutations} code change${verdict.evidence.mutations === 1 ? "" : "s"}${verdict.evidence.checks.length ? `; checks: ${verdict.evidence.checks.map(check => `${clip(check.call, 60)} → ${check.passed ? "passed" : "failed"}`).join(", ")}` : "; no checks ran"}`);
|
|
72
|
+
if (verdict.judgment)
|
|
73
|
+
lines.push(`jev: claims done ${percent(verdict.judgment.claimsDone)} · claims verified ${percent(verdict.judgment.claimsVerified)} · checks apply ${percent(verdict.judgment.verificationApplies)} · ${verdict.judgment.outcome} · ${verdict.judgment.model} · ${verdict.judgment.elapsedMs} ms`);
|
|
74
|
+
if (verdict.reasons.length)
|
|
75
|
+
lines.push(`why: ${verdict.reasons.join("; ")}`);
|
|
76
|
+
if (verdict.error)
|
|
77
|
+
lines.push(`typesafe: ${verdict.error}`);
|
|
78
|
+
if (told)
|
|
79
|
+
lines.push(`agent told: ${clip(told, 400)}`);
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
export function proseDetails(verdict, reply, audience, told) {
|
|
83
|
+
const lines = [`reply: ${clip(reply.replace(/\s+/g, " "), 300)}`, `audience: ${audience}`];
|
|
84
|
+
if (verdict.scores)
|
|
85
|
+
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` : ""}`);
|
|
86
|
+
if (verdict.flagged.length)
|
|
87
|
+
lines.push(`flagged: ${verdict.flagged.join(", ")}`);
|
|
88
|
+
if (verdict.error)
|
|
89
|
+
lines.push(`typesafe: ${verdict.error}`);
|
|
90
|
+
if (told)
|
|
91
|
+
lines.push(`agent told: ${clip(told, 400)}`);
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
package/dist/widget.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DoneVerdict } from "./done.js";
|
|
2
|
+
import type { Verdict } from "./guard.js";
|
|
3
|
+
import type { ProseVerdict } from "./prose.js";
|
|
4
|
+
import type { StuckVerdict } from "./stuck.js";
|
|
5
|
+
export type WidgetPlacement = "aboveEditor" | "belowEditor";
|
|
6
|
+
export interface WidgetConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
placement: WidgetPlacement;
|
|
9
|
+
/** Keyboard shortcut that opens the trace panel; empty string disables it. */
|
|
10
|
+
shortcut: string;
|
|
11
|
+
/** Templates per guard. Segments are separated by " · "; a segment whose token has no value is dropped. */
|
|
12
|
+
action: string;
|
|
13
|
+
stuck: string;
|
|
14
|
+
done: string;
|
|
15
|
+
prose: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_TEMPLATES: {
|
|
18
|
+
readonly action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}";
|
|
19
|
+
readonly stuck: "warden · stuck · {failures} failures · same strategy {sameStrategy} · change {approachChange} · progress {progress} · {flags} · {status}";
|
|
20
|
+
readonly done: "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}";
|
|
21
|
+
readonly prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}";
|
|
22
|
+
};
|
|
23
|
+
export declare function defaultWidgetConfig(): WidgetConfig;
|
|
24
|
+
export type Tokens = Record<string, string | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* Fill `{token}` placeholders. The template is split on " · "; a segment is dropped when any of its tokens is empty,
|
|
27
|
+
* so optional information disappears together with its label. Unknown tokens render as empty (and drop their segment).
|
|
28
|
+
*/
|
|
29
|
+
export declare function renderTemplate(template: string, tokens: Tokens): string;
|
|
30
|
+
export declare function actionTokens(verdict: Verdict, at?: number): Tokens;
|
|
31
|
+
export declare function stuckTokens(verdict: StuckVerdict, at?: number): Tokens;
|
|
32
|
+
export declare function doneTokens(verdict: DoneVerdict, at?: number): Tokens;
|
|
33
|
+
export declare function proseTokens(verdict: ProseVerdict, at?: number): Tokens;
|
|
34
|
+
/** Token names users can put in templates, for /warden status and the README. */
|
|
35
|
+
export declare const TOKEN_NAMES: {
|
|
36
|
+
readonly action: readonly ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"];
|
|
37
|
+
readonly prose: readonly ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
38
|
+
readonly stuck: readonly ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
39
|
+
readonly done: readonly ["changes", "checks", "checksPassed", "claimsDone", "claimsVerified", "checksApply", "outcome", "status", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
40
|
+
};
|