pi-warden 0.6.0 → 0.8.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 +30 -14
- package/dist/config.d.ts +33 -1
- package/dist/config.js +38 -5
- package/dist/excerpt.d.ts +25 -0
- package/dist/excerpt.js +171 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +130 -15
- package/dist/index.d.ts +14 -6
- package/dist/index.js +8 -4
- package/dist/notify.d.ts +42 -0
- package/dist/notify.js +99 -0
- package/dist/output.d.ts +26 -4
- package/dist/output.js +31 -5
- package/dist/panel.js +1 -1
- package/dist/recall.d.ts +13 -0
- package/dist/recall.js +68 -0
- package/dist/runaway.d.ts +66 -0
- package/dist/runaway.js +115 -0
- package/dist/saver.d.ts +23 -6
- package/dist/saver.js +44 -6
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +14 -3
- package/dist/trace.d.ts +3 -1
- package/dist/trace.js +10 -0
- package/dist/widget.d.ts +5 -0
- package/dist/widget.js +14 -0
- package/package.json +1 -1
package/dist/recall.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RecallTool } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* A recall is the agent going back to a stored full output. A whole-file read undoes the saving; a scoped search or a
|
|
4
|
+
* ranged read keeps it. The footer under every excerpt names a search command that exists on this machine, detected
|
|
5
|
+
* once at load. Detection never runs inside a tool_result handler.
|
|
6
|
+
*/
|
|
7
|
+
export type SearchTool = Exclude<RecallTool, "auto">;
|
|
8
|
+
/** First working tool in the chain, or `none`. A configured tool other than `auto` is trusted without a probe. */
|
|
9
|
+
export declare function detectSearchTool(configured?: RecallTool, timeoutMs?: number): Promise<SearchTool>;
|
|
10
|
+
/** Footer under an excerpt or duplicate note. Tells the agent how to get a part back without re-reading everything. */
|
|
11
|
+
export declare function recallInstruction(tool: SearchTool, path: string): string;
|
|
12
|
+
/** `full` when the call loads the entire stored file into context; `scoped` for searches, ranged reads, and sandboxed processing. */
|
|
13
|
+
export declare function classifyRecall(toolName: string, input: unknown, path: string): "full" | "scoped";
|
package/dist/recall.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/** Fastest first; the last entries cover Windows shells. */
|
|
3
|
+
const CHAIN = [
|
|
4
|
+
{ tool: "rg", command: "rg", args: ["--version"] },
|
|
5
|
+
{ tool: "ag", command: "ag", args: ["--version"] },
|
|
6
|
+
{ tool: "ugrep", command: "ugrep", args: ["--version"] },
|
|
7
|
+
{ tool: "git-grep", command: "git", args: ["--version"] },
|
|
8
|
+
{ tool: "grep", command: "grep", args: ["--version"] },
|
|
9
|
+
{ tool: "select-string", command: process.platform === "win32" ? "powershell" : "pwsh", args: ["-NoProfile", "-NonInteractive", "-Command", "exit 0"] },
|
|
10
|
+
{ tool: "findstr", command: "where.exe", args: ["findstr"] },
|
|
11
|
+
];
|
|
12
|
+
function available(command, args, timeoutMs) {
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
try {
|
|
15
|
+
execFile(command, [...args], { timeout: timeoutMs, windowsHide: true, maxBuffer: 64 * 1024 }, error => resolve(!error));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
resolve(false);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** First working tool in the chain, or `none`. A configured tool other than `auto` is trusted without a probe. */
|
|
23
|
+
export async function detectSearchTool(configured = "auto", timeoutMs = 1500) {
|
|
24
|
+
if (configured !== "auto")
|
|
25
|
+
return configured;
|
|
26
|
+
for (const entry of CHAIN) {
|
|
27
|
+
if (entry.tool === "findstr" && process.platform !== "win32")
|
|
28
|
+
continue;
|
|
29
|
+
if (await available(entry.command, entry.args, timeoutMs))
|
|
30
|
+
return entry.tool;
|
|
31
|
+
}
|
|
32
|
+
return "none";
|
|
33
|
+
}
|
|
34
|
+
function searchExample(tool, path) {
|
|
35
|
+
switch (tool) {
|
|
36
|
+
case "rg": return `rg -n -C 3 '<pattern>' '${path}'`;
|
|
37
|
+
case "ag": return `ag -C 3 '<pattern>' '${path}'`;
|
|
38
|
+
case "ugrep": return `ugrep -n -C 3 '<pattern>' '${path}'`;
|
|
39
|
+
case "git-grep": return `git grep --no-index -n -C 3 '<pattern>' -- '${path}'`;
|
|
40
|
+
case "grep": return `grep -n -C 3 -E '<pattern>' '${path}'`;
|
|
41
|
+
case "select-string": return `Select-String -Path '${path}' -Pattern '<pattern>' -Context 3`;
|
|
42
|
+
case "findstr": return `findstr /n /c:"<pattern>" "${path}"`;
|
|
43
|
+
case "none": return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Footer under an excerpt or duplicate note. Tells the agent how to get a part back without re-reading everything. */
|
|
47
|
+
export function recallInstruction(tool, path) {
|
|
48
|
+
const example = searchExample(tool, path);
|
|
49
|
+
const how = example ? `search it (${example}) or read a known region with offset and limit` : "read a known region with offset and limit";
|
|
50
|
+
return `Full output: ${path}\nTo recall a part, ${how}. Do not read the whole file.`;
|
|
51
|
+
}
|
|
52
|
+
/** `full` when the call loads the entire stored file into context; `scoped` for searches, ranged reads, and sandboxed processing. */
|
|
53
|
+
export function classifyRecall(toolName, input, path) {
|
|
54
|
+
const record = typeof input === "object" && input !== null ? input : {};
|
|
55
|
+
if (toolName === "read")
|
|
56
|
+
return typeof record.offset === "number" || typeof record.limit === "number" ? "scoped" : "full";
|
|
57
|
+
const command = typeof record.command === "string" ? record.command : typeof record.code === "string" ? record.code : undefined;
|
|
58
|
+
if (!command)
|
|
59
|
+
return "scoped";
|
|
60
|
+
// The pipeline segment that names the path decides: a bare cat/type/Get-Content dumps everything; anything else scopes it.
|
|
61
|
+
for (const segment of command.split(/&&|\|\||[;|]|\n/)) {
|
|
62
|
+
if (!segment.includes(path))
|
|
63
|
+
continue;
|
|
64
|
+
if (/^\s*(?:cat|type|Get-Content|gc)\b/.test(segment) && !/\s-(?:TotalCount|Head|Tail)\b/i.test(segment))
|
|
65
|
+
return "full";
|
|
66
|
+
}
|
|
67
|
+
return "scoped";
|
|
68
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { RunawayConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runaway guard: a model that degenerates mid-reply repeats the same block of text over and over without calling a tool,
|
|
4
|
+
* and nothing else stops it before the token limit. The guard reads the stream, counts identical blocks with code only,
|
|
5
|
+
* and stops the run when a block repeats past the threshold. No request leaves the machine.
|
|
6
|
+
*
|
|
7
|
+
* Calibration over 675 local sessions (2634 assistant text blocks of 300+ characters): the most repeated paragraph in an
|
|
8
|
+
* ordinary reply occurs twice at most; the one runaway repeated its block 28 times. Thinking drafts code and repeats
|
|
9
|
+
* paragraphs up to 7 times legitimately, so it has its own higher threshold.
|
|
10
|
+
*/
|
|
11
|
+
export type StreamKind = "text" | "thinking";
|
|
12
|
+
export interface RepeatSignal {
|
|
13
|
+
count: number;
|
|
14
|
+
/** Normalised block that repeated; empty when nothing qualified. */
|
|
15
|
+
block: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RunawayVerdict {
|
|
18
|
+
kind: StreamKind;
|
|
19
|
+
count: number;
|
|
20
|
+
block: string;
|
|
21
|
+
/** Characters streamed for this kind when the run was stopped. */
|
|
22
|
+
chars: number;
|
|
23
|
+
/** block: an identical paragraph; phrase: the text ends with one unit repeated back to back. */
|
|
24
|
+
signal: "block" | "phrase";
|
|
25
|
+
}
|
|
26
|
+
/** Paragraphs shorter than this (normalised) are ignored: code fences, list markers, and short labels repeat legitimately. */
|
|
27
|
+
export declare const BLOCK_MIN_CHARS = 24;
|
|
28
|
+
/** Length of the trailing phrase whose recurrence is counted. */
|
|
29
|
+
export declare const PHRASE_CHARS = 60;
|
|
30
|
+
/** Characters streamed between two checks; keeps the per-token cost to a string append. */
|
|
31
|
+
export declare const CHECK_EVERY = 256;
|
|
32
|
+
/** The paragraph (blank-line separated, normalised, at least `minChars`) that occurs most often. */
|
|
33
|
+
export declare function repeatedBlock(text: string, minChars?: number): RepeatSignal;
|
|
34
|
+
/**
|
|
35
|
+
* Run-on repetition without paragraph breaks: the text ends with one unit repeated back to back. The trailing `chars`
|
|
36
|
+
* locate the period (distance to their previous occurrence); the whole unit must match on every step back, so templated
|
|
37
|
+
* sentences that share only a suffix ("item 3 … see below.", "item 4 … see below.") count once. Units shorter than
|
|
38
|
+
* `minPeriod` (table separators, ellipses) are ignored.
|
|
39
|
+
*/
|
|
40
|
+
export declare function repeatedTail(text: string, chars?: number, minPeriod?: number): RepeatSignal;
|
|
41
|
+
/** The stronger of the two signals for one stream. */
|
|
42
|
+
export declare function findRepeats(text: string): RepeatSignal & {
|
|
43
|
+
signal: "block" | "phrase";
|
|
44
|
+
};
|
|
45
|
+
interface StreamEvent {
|
|
46
|
+
type: string;
|
|
47
|
+
delta?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Follows one assistant message as it streams. `feed` is called per token and only appends; `check` runs every CHECK_EVERY characters. */
|
|
50
|
+
export declare class RunawayMonitor {
|
|
51
|
+
private streams;
|
|
52
|
+
private stoppedKind;
|
|
53
|
+
reset(): void;
|
|
54
|
+
/** True once this message triggered a stop; later deltas from the same stream are ignored. */
|
|
55
|
+
get stopped(): boolean;
|
|
56
|
+
/** Text seen so far for one stream kind. */
|
|
57
|
+
streamed(kind: StreamKind): string;
|
|
58
|
+
/** Feeds one AssistantMessageEvent; returns the stream kind that has grown enough for a check, else undefined. */
|
|
59
|
+
feed(event: StreamEvent): StreamKind | undefined;
|
|
60
|
+
/** Judges one stream against the config; at most one verdict per message. */
|
|
61
|
+
check(kind: StreamKind, config: RunawayConfig): RunawayVerdict | undefined;
|
|
62
|
+
}
|
|
63
|
+
/** Follow-up for the agent after its run was stopped. `recover` true: this message starts the next turn. */
|
|
64
|
+
export declare function runawayNudge(verdict: RunawayVerdict, recover: boolean): string;
|
|
65
|
+
export declare function formatRunaway(verdict: RunawayVerdict, recovering: boolean, template?: string): string;
|
|
66
|
+
export {};
|
package/dist/runaway.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { DEFAULT_TEMPLATES, renderTemplate, runawayTokens } from "./widget.js";
|
|
2
|
+
/** Paragraphs shorter than this (normalised) are ignored: code fences, list markers, and short labels repeat legitimately. */
|
|
3
|
+
export const BLOCK_MIN_CHARS = 24;
|
|
4
|
+
/** Length of the trailing phrase whose recurrence is counted. */
|
|
5
|
+
export const PHRASE_CHARS = 60;
|
|
6
|
+
/** Characters streamed between two checks; keeps the per-token cost to a string append. */
|
|
7
|
+
export const CHECK_EVERY = 256;
|
|
8
|
+
const normalise = (text) => text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
9
|
+
/** The paragraph (blank-line separated, normalised, at least `minChars`) that occurs most often. */
|
|
10
|
+
export function repeatedBlock(text, minChars = BLOCK_MIN_CHARS) {
|
|
11
|
+
const counts = new Map();
|
|
12
|
+
let best = { count: 0, block: "" };
|
|
13
|
+
for (const raw of text.split(/\n\s*\n/)) {
|
|
14
|
+
const block = normalise(raw);
|
|
15
|
+
if (block.length < minChars)
|
|
16
|
+
continue;
|
|
17
|
+
const count = (counts.get(block) ?? 0) + 1;
|
|
18
|
+
counts.set(block, count);
|
|
19
|
+
if (count > best.count)
|
|
20
|
+
best = { count, block };
|
|
21
|
+
}
|
|
22
|
+
return best;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run-on repetition without paragraph breaks: the text ends with one unit repeated back to back. The trailing `chars`
|
|
26
|
+
* locate the period (distance to their previous occurrence); the whole unit must match on every step back, so templated
|
|
27
|
+
* sentences that share only a suffix ("item 3 … see below.", "item 4 … see below.") count once. Units shorter than
|
|
28
|
+
* `minPeriod` (table separators, ellipses) are ignored.
|
|
29
|
+
*/
|
|
30
|
+
export function repeatedTail(text, chars = PHRASE_CHARS, minPeriod = BLOCK_MIN_CHARS) {
|
|
31
|
+
const normal = normalise(text);
|
|
32
|
+
if (normal.length < chars * 2)
|
|
33
|
+
return { count: 0, block: "" };
|
|
34
|
+
const start = normal.length - chars;
|
|
35
|
+
const phrase = normal.slice(start);
|
|
36
|
+
const previous = normal.lastIndexOf(phrase, start - 1);
|
|
37
|
+
if (previous === -1)
|
|
38
|
+
return { count: 1, block: phrase };
|
|
39
|
+
const period = start - previous;
|
|
40
|
+
if (period < minPeriod)
|
|
41
|
+
return { count: 1, block: phrase };
|
|
42
|
+
const unit = normal.slice(-period);
|
|
43
|
+
let count = 1;
|
|
44
|
+
for (let end = normal.length - period; end >= period && normal.slice(end - period, end) === unit; end -= period)
|
|
45
|
+
count++;
|
|
46
|
+
return { count, block: unit };
|
|
47
|
+
}
|
|
48
|
+
/** The stronger of the two signals for one stream. */
|
|
49
|
+
export function findRepeats(text) {
|
|
50
|
+
const block = repeatedBlock(text);
|
|
51
|
+
const phrase = repeatedTail(text);
|
|
52
|
+
return block.count >= phrase.count ? { ...block, signal: "block" } : { ...phrase, signal: "phrase" };
|
|
53
|
+
}
|
|
54
|
+
const fresh = () => ({ text: { text: "", checkedAt: 0 }, thinking: { text: "", checkedAt: 0 } });
|
|
55
|
+
/** Follows one assistant message as it streams. `feed` is called per token and only appends; `check` runs every CHECK_EVERY characters. */
|
|
56
|
+
export class RunawayMonitor {
|
|
57
|
+
streams = fresh();
|
|
58
|
+
stoppedKind;
|
|
59
|
+
reset() {
|
|
60
|
+
this.streams = fresh();
|
|
61
|
+
this.stoppedKind = undefined;
|
|
62
|
+
}
|
|
63
|
+
/** True once this message triggered a stop; later deltas from the same stream are ignored. */
|
|
64
|
+
get stopped() {
|
|
65
|
+
return this.stoppedKind !== undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Text seen so far for one stream kind. */
|
|
68
|
+
streamed(kind) {
|
|
69
|
+
return this.streams[kind].text;
|
|
70
|
+
}
|
|
71
|
+
/** Feeds one AssistantMessageEvent; returns the stream kind that has grown enough for a check, else undefined. */
|
|
72
|
+
feed(event) {
|
|
73
|
+
if (event.type === "start") {
|
|
74
|
+
this.reset();
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const kind = event.type.startsWith("text_") ? "text" : event.type.startsWith("thinking_") ? "thinking" : undefined;
|
|
78
|
+
if (!kind)
|
|
79
|
+
return undefined;
|
|
80
|
+
const stream = this.streams[kind];
|
|
81
|
+
if (event.type.endsWith("_start")) {
|
|
82
|
+
if (stream.text)
|
|
83
|
+
stream.text += "\n\n";
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (!event.type.endsWith("_delta") || typeof event.delta !== "string")
|
|
87
|
+
return undefined;
|
|
88
|
+
stream.text += event.delta;
|
|
89
|
+
return stream.text.length - stream.checkedAt >= CHECK_EVERY ? kind : undefined;
|
|
90
|
+
}
|
|
91
|
+
/** Judges one stream against the config; at most one verdict per message. */
|
|
92
|
+
check(kind, config) {
|
|
93
|
+
const stream = this.streams[kind];
|
|
94
|
+
stream.checkedAt = stream.text.length;
|
|
95
|
+
if (this.stoppedKind || stream.text.length < config.minChars)
|
|
96
|
+
return undefined;
|
|
97
|
+
const limit = kind === "text" ? config.repeats : config.thinkingRepeats;
|
|
98
|
+
const found = findRepeats(stream.text);
|
|
99
|
+
if (found.count < limit)
|
|
100
|
+
return undefined;
|
|
101
|
+
this.stoppedKind = kind;
|
|
102
|
+
return { kind, count: found.count, block: found.block, chars: stream.text.length, signal: found.signal };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const clip = (text, limit) => (text.length <= limit ? text : `${text.slice(0, limit)}…`);
|
|
106
|
+
/** Follow-up for the agent after its run was stopped. `recover` true: this message starts the next turn. */
|
|
107
|
+
export function runawayNudge(verdict, recover) {
|
|
108
|
+
const what = `pi-warden stopped your reply: the same ${verdict.kind} block repeated ${verdict.count} times ("${clip(verdict.block, 80)}") and no tool was called.`;
|
|
109
|
+
if (!recover)
|
|
110
|
+
return `${what} The run was stopped again for this prompt and was not restarted. Wait for the user.`;
|
|
111
|
+
return `${what} Do not restate what you are about to do. If a command is the next step, call the tool with it now. Otherwise answer in at most three sentences: the current state and the one next action, or a question for the user.`;
|
|
112
|
+
}
|
|
113
|
+
export function formatRunaway(verdict, recovering, template = DEFAULT_TEMPLATES.runaway) {
|
|
114
|
+
return renderTemplate(template, runawayTokens(verdict, recovering));
|
|
115
|
+
}
|
package/dist/saver.d.ts
CHANGED
|
@@ -1,35 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session accounting for the context saver, so its value can be measured instead of assumed:
|
|
3
|
-
* how often large outputs appear, how many were compressed, how much was removed, how many
|
|
4
|
-
* spared from (token-turns), and how often the agent went back for the full text
|
|
5
|
-
* not enough
|
|
3
|
+
* how often large outputs appear, how many were compressed or dropped as duplicates, how much was removed, how many
|
|
4
|
+
* turns that removal was spared from (token-turns), and how often the agent went back for the full text. A recall means
|
|
5
|
+
* the excerpt was not enough; a whole-file recall also undoes the saving, a scoped one keeps it. Deterministic; no requests.
|
|
6
6
|
*/
|
|
7
7
|
export interface ContextLedgerSnapshot {
|
|
8
8
|
/** Single-text-block outputs at or above tailMinChars, i.e. candidates for compression. */
|
|
9
9
|
large: number;
|
|
10
10
|
compressed: number;
|
|
11
|
+
/** Results replaced by a duplicate note because an identical result already exists in this session. */
|
|
12
|
+
duplicates: number;
|
|
11
13
|
bytesSaved: number;
|
|
12
14
|
/** Assistant turns completed since the session started. */
|
|
13
15
|
turns: number;
|
|
14
16
|
/** Sum over turns of the bytes that were no longer in context on that turn, in rough tokens (bytes / 4). */
|
|
15
17
|
tokenTurnsSaved: number;
|
|
16
|
-
/** Times the agent
|
|
18
|
+
/** Times the agent went back to a stored full output after compression, by kind of access. */
|
|
17
19
|
recalls: number;
|
|
20
|
+
recallsFull: number;
|
|
18
21
|
}
|
|
22
|
+
export type RecallKind = "full" | "scoped";
|
|
19
23
|
export declare class ContextLedger {
|
|
20
24
|
private large;
|
|
21
25
|
private compressed;
|
|
26
|
+
private duplicates;
|
|
22
27
|
private bytesSaved;
|
|
23
28
|
private turns;
|
|
24
29
|
private tokenTurnsSaved;
|
|
25
30
|
private recalls;
|
|
31
|
+
private recallsFull;
|
|
26
32
|
private readonly stored;
|
|
33
|
+
/** Every sizeable text result seen this session, by content key, with the tool that produced it and its stored copy if any. */
|
|
34
|
+
private readonly seen;
|
|
27
35
|
candidate(): void;
|
|
28
36
|
record(path: string, bytesSaved: number): void;
|
|
37
|
+
/** Remember a result's identity so a later identical result can be dropped. `path` is set when a full copy exists. */
|
|
38
|
+
remember(key: string, tool: string, path?: string): void;
|
|
39
|
+
duplicateOf(key: string): {
|
|
40
|
+
tool: string;
|
|
41
|
+
path?: string;
|
|
42
|
+
} | undefined;
|
|
43
|
+
duplicate(bytesSaved: number): void;
|
|
29
44
|
/** One LLM turn finished: everything removed so far was absent from this turn's prompt. */
|
|
30
45
|
turnEnd(): void;
|
|
31
|
-
/**
|
|
32
|
-
|
|
46
|
+
/** The stored path that `text` (a path, command, or serialized input) mentions, if any. */
|
|
47
|
+
storedPathIn(text: string): string | undefined;
|
|
48
|
+
/** Counts the first access to a compressed output; later accesses and duplicate-only copies are not new recalls. */
|
|
49
|
+
noteAccess(text: string, kind?: RecallKind): string | undefined;
|
|
33
50
|
snapshot(): ContextLedgerSnapshot;
|
|
34
51
|
reset(): void;
|
|
35
52
|
}
|
package/dist/saver.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
/** Serialized tool input escapes backslashes, so a Windows path must also match in its JSON form. */
|
|
2
|
+
function mentions(text, path) {
|
|
3
|
+
return text.includes(path) || text.includes(JSON.stringify(path).slice(1, -1));
|
|
4
|
+
}
|
|
1
5
|
export class ContextLedger {
|
|
2
6
|
large = 0;
|
|
3
7
|
compressed = 0;
|
|
8
|
+
duplicates = 0;
|
|
4
9
|
bytesSaved = 0;
|
|
5
10
|
turns = 0;
|
|
6
11
|
tokenTurnsSaved = 0;
|
|
7
12
|
recalls = 0;
|
|
13
|
+
recallsFull = 0;
|
|
8
14
|
stored = new Map();
|
|
15
|
+
/** Every sizeable text result seen this session, by content key, with the tool that produced it and its stored copy if any. */
|
|
16
|
+
seen = new Map();
|
|
9
17
|
candidate() {
|
|
10
18
|
this.large++;
|
|
11
19
|
}
|
|
@@ -14,42 +22,72 @@ export class ContextLedger {
|
|
|
14
22
|
this.bytesSaved += bytesSaved;
|
|
15
23
|
this.stored.set(path, { recalled: false });
|
|
16
24
|
}
|
|
25
|
+
/** Remember a result's identity so a later identical result can be dropped. `path` is set when a full copy exists. */
|
|
26
|
+
remember(key, tool, path) {
|
|
27
|
+
const existing = this.seen.get(key);
|
|
28
|
+
if (existing && existing.path && !path)
|
|
29
|
+
return;
|
|
30
|
+
this.seen.set(key, path ? { tool, path } : { tool });
|
|
31
|
+
}
|
|
32
|
+
duplicateOf(key) {
|
|
33
|
+
return this.seen.get(key);
|
|
34
|
+
}
|
|
35
|
+
duplicate(bytesSaved) {
|
|
36
|
+
this.duplicates++;
|
|
37
|
+
this.bytesSaved += bytesSaved;
|
|
38
|
+
}
|
|
17
39
|
/** One LLM turn finished: everything removed so far was absent from this turn's prompt. */
|
|
18
40
|
turnEnd() {
|
|
19
41
|
this.turns++;
|
|
20
42
|
this.tokenTurnsSaved += Math.round(this.bytesSaved / 4);
|
|
21
43
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
44
|
+
/** The stored path that `text` (a path, command, or serialized input) mentions, if any. */
|
|
45
|
+
storedPathIn(text) {
|
|
46
|
+
for (const path of this.stored.keys())
|
|
47
|
+
if (mentions(text, path))
|
|
48
|
+
return path;
|
|
49
|
+
for (const entry of this.seen.values())
|
|
50
|
+
if (entry.path && mentions(text, entry.path))
|
|
51
|
+
return entry.path;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/** Counts the first access to a compressed output; later accesses and duplicate-only copies are not new recalls. */
|
|
55
|
+
noteAccess(text, kind = "full") {
|
|
24
56
|
for (const [path, state] of this.stored) {
|
|
25
|
-
if (!text
|
|
57
|
+
if (!mentions(text, path))
|
|
26
58
|
continue;
|
|
27
59
|
if (!state.recalled) {
|
|
28
60
|
state.recalled = true;
|
|
29
61
|
this.recalls++;
|
|
62
|
+
if (kind === "full")
|
|
63
|
+
this.recallsFull++;
|
|
30
64
|
}
|
|
31
65
|
return path;
|
|
32
66
|
}
|
|
33
67
|
return undefined;
|
|
34
68
|
}
|
|
35
69
|
snapshot() {
|
|
36
|
-
return { large: this.large, compressed: this.compressed, bytesSaved: this.bytesSaved, turns: this.turns, tokenTurnsSaved: this.tokenTurnsSaved, recalls: this.recalls };
|
|
70
|
+
return { large: this.large, compressed: this.compressed, duplicates: this.duplicates, bytesSaved: this.bytesSaved, turns: this.turns, tokenTurnsSaved: this.tokenTurnsSaved, recalls: this.recalls, recallsFull: this.recallsFull };
|
|
37
71
|
}
|
|
38
72
|
reset() {
|
|
39
73
|
this.large = 0;
|
|
40
74
|
this.compressed = 0;
|
|
75
|
+
this.duplicates = 0;
|
|
41
76
|
this.bytesSaved = 0;
|
|
42
77
|
this.turns = 0;
|
|
43
78
|
this.tokenTurnsSaved = 0;
|
|
44
79
|
this.recalls = 0;
|
|
80
|
+
this.recallsFull = 0;
|
|
45
81
|
this.stored.clear();
|
|
82
|
+
this.seen.clear();
|
|
46
83
|
}
|
|
47
84
|
}
|
|
48
85
|
/** One line for /warden status. Says when there is nothing to report instead of printing zeros. */
|
|
49
86
|
export function formatLedger(snapshot) {
|
|
50
|
-
if (snapshot.large === 0)
|
|
87
|
+
if (snapshot.large === 0 && snapshot.duplicates === 0)
|
|
51
88
|
return "Context saver: no tool output large enough to consider this session.";
|
|
52
89
|
const kb = (snapshot.bytesSaved / 1024).toFixed(1);
|
|
53
90
|
const recallRate = snapshot.compressed ? Math.round((snapshot.recalls / snapshot.compressed) * 100) : 0;
|
|
54
|
-
|
|
91
|
+
const scoped = snapshot.recalls - snapshot.recallsFull;
|
|
92
|
+
return `Context saver: ${snapshot.large} large outputs, ${snapshot.compressed} compressed, ${snapshot.duplicates} duplicate${snapshot.duplicates === 1 ? "" : "s"} dropped, ${kb} KB removed (~${Math.round(snapshot.bytesSaved / 4)} tokens), ~${snapshot.tokenTurnsSaved} token-turns spared over ${snapshot.turns} turns, ${snapshot.recalls} recall${snapshot.recalls === 1 ? "" : "s"} of the full output (${recallRate}%; ${snapshot.recallsFull} whole-file, ${scoped} scoped).`;
|
|
55
93
|
}
|
package/dist/shape.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WardenConfig } from "./config.js";
|
|
2
2
|
/** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
|
|
3
|
-
export declare const EXPECTED_SCHEMA =
|
|
3
|
+
export declare const EXPECTED_SCHEMA = 4;
|
|
4
4
|
export interface ShapeResult {
|
|
5
5
|
config: WardenConfig;
|
|
6
6
|
/** Sections that were absent from the loaded config and are now disabled. Empty when the config was complete. */
|
package/dist/shape.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isMode } from "./config.js";
|
|
2
|
+
import { DEFAULT_TEMPLATES } from "./widget.js";
|
|
2
3
|
/** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
|
|
3
|
-
export const EXPECTED_SCHEMA =
|
|
4
|
+
export const EXPECTED_SCHEMA = 4;
|
|
4
5
|
const off = { enabled: false };
|
|
5
6
|
const proseOff = () => ({ ...off, audience: "technical", threshold: 1, trend: 3, minChars: 1 });
|
|
6
7
|
/**
|
|
@@ -30,15 +31,25 @@ export function completeConfig(loaded) {
|
|
|
30
31
|
done: section("done", { ...off, claimsDone: 1, nudge: false }),
|
|
31
32
|
slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
|
|
32
33
|
security: section("security", { ...off, threshold: 1 }),
|
|
33
|
-
context: section("context", { ...off, tailMinChars: 1, confidence: 1 }),
|
|
34
|
-
|
|
34
|
+
context: section("context", { ...off, tailMinChars: 1, confidence: 1, duplicateMinChars: Number.MAX_SAFE_INTEGER, recallTool: "none", formatConfidence: 1 }),
|
|
35
|
+
runaway: section("runaway", { ...off, repeats: Number.MAX_SAFE_INTEGER, thinkingRepeats: Number.MAX_SAFE_INTEGER, minChars: Number.MAX_SAFE_INTEGER, recover: false }),
|
|
36
|
+
notify: section("notify", { ...off, cooldownMs: 0, command: [] }),
|
|
37
|
+
widget: section("widget", { ...off, placement: "aboveEditor", shortcut: "", panelWidth: "40%", action: "", stuck: "", done: "", prose: "", security: "", context: "", runaway: "" }),
|
|
35
38
|
};
|
|
36
39
|
if (typeof config.slop.prose !== "object" || config.slop.prose === null) {
|
|
37
40
|
missing.push("slop.prose");
|
|
38
41
|
config.slop = { ...config.slop, prose: proseOff() };
|
|
39
42
|
}
|
|
43
|
+
// 0.7 added fields inside the context section; an older config module leaves them undefined.
|
|
44
|
+
if (typeof config.context.duplicateMinChars !== "number" || typeof config.context.formatConfidence !== "number" || typeof config.context.recallTool !== "string") {
|
|
45
|
+
missing.push("context.saver");
|
|
46
|
+
config.context = { ...config.context, duplicateMinChars: Number.MAX_SAFE_INTEGER, recallTool: "none", formatConfidence: 1 };
|
|
47
|
+
}
|
|
40
48
|
if (typeof config.widget.panelWidth !== "string" && typeof config.widget.panelWidth !== "number")
|
|
41
49
|
config.widget = { ...config.widget, panelWidth: "40%" };
|
|
50
|
+
// The runaway guard added its template later than the other sections; an older widget section renders the default line.
|
|
51
|
+
if (typeof config.widget.runaway !== "string")
|
|
52
|
+
config.widget = { ...config.widget, runaway: DEFAULT_TEMPLATES.runaway };
|
|
42
53
|
return { config, missing };
|
|
43
54
|
}
|
|
44
55
|
export function shapeWarning(missing, loadedSchema) {
|
package/dist/trace.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { DoneVerdict } from "./done.js";
|
|
2
2
|
import type { Verdict } from "./guard.js";
|
|
3
3
|
import type { ProseVerdict } from "./prose.js";
|
|
4
|
+
import type { RunawayVerdict } from "./runaway.js";
|
|
4
5
|
import type { Attempt, StuckVerdict } from "./stuck.js";
|
|
5
|
-
export type GuardName = "action" | "stuck" | "done" | "prose" | "security" | "context";
|
|
6
|
+
export type GuardName = "action" | "stuck" | "done" | "prose" | "security" | "context" | "runaway";
|
|
6
7
|
export interface TraceEntry {
|
|
7
8
|
at: number;
|
|
8
9
|
guard: GuardName;
|
|
@@ -28,4 +29,5 @@ export declare function actionDetails(verdict: Verdict, extra?: {
|
|
|
28
29
|
}): string[];
|
|
29
30
|
export declare function stuckDetails(verdict: StuckVerdict, attempts: readonly Attempt[], told?: string): string[];
|
|
30
31
|
export declare function doneDetails(verdict: DoneVerdict, finalMessage: string, told?: string): string[];
|
|
32
|
+
export declare function runawayDetails(verdict: RunawayVerdict, told: string | undefined, recovering: boolean): string[];
|
|
31
33
|
export declare function proseDetails(verdict: ProseVerdict, reply: string, audience: string, told?: string): string[];
|
package/dist/trace.js
CHANGED
|
@@ -81,6 +81,16 @@ export function doneDetails(verdict, finalMessage, told) {
|
|
|
81
81
|
lines.push(`agent told: ${clip(told, 400)}`);
|
|
82
82
|
return lines;
|
|
83
83
|
}
|
|
84
|
+
export function runawayDetails(verdict, told, recovering) {
|
|
85
|
+
const lines = [
|
|
86
|
+
`repeated: ${clip(verdict.block, 200)}`,
|
|
87
|
+
`${verdict.count} times in ${verdict.chars} chars of ${verdict.kind}; signal: ${verdict.signal === "block" ? "identical paragraph" : "recurring trailing phrase"}`,
|
|
88
|
+
recovering ? "run stopped; one follow-up turn started" : "run stopped; not restarted",
|
|
89
|
+
];
|
|
90
|
+
if (told)
|
|
91
|
+
lines.push(`agent told: ${clip(told, 400)}`);
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
84
94
|
export function proseDetails(verdict, reply, audience, told) {
|
|
85
95
|
const lines = [`reply: ${clip(reply.replace(/\s+/g, " "), 300)}`, `audience: ${audience}`];
|
|
86
96
|
if (verdict.scores)
|
package/dist/widget.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DoneVerdict } from "./done.js";
|
|
2
2
|
import type { Verdict } from "./guard.js";
|
|
3
3
|
import type { ProseVerdict } from "./prose.js";
|
|
4
|
+
import type { RunawayVerdict } from "./runaway.js";
|
|
4
5
|
import type { StuckVerdict } from "./stuck.js";
|
|
5
6
|
export type WidgetPlacement = "aboveEditor" | "belowEditor";
|
|
6
7
|
export interface WidgetConfig {
|
|
@@ -17,6 +18,7 @@ export interface WidgetConfig {
|
|
|
17
18
|
prose: string;
|
|
18
19
|
security: string;
|
|
19
20
|
context: string;
|
|
21
|
+
runaway: string;
|
|
20
22
|
}
|
|
21
23
|
export declare const DEFAULT_TEMPLATES: {
|
|
22
24
|
readonly action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}";
|
|
@@ -25,6 +27,7 @@ export declare const DEFAULT_TEMPLATES: {
|
|
|
25
27
|
readonly prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}";
|
|
26
28
|
readonly security: "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}";
|
|
27
29
|
readonly context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes";
|
|
30
|
+
readonly runaway: "warden · runaway · {kind} · {count}× repeated · {chars} chars · {signal} · {status}";
|
|
28
31
|
};
|
|
29
32
|
export declare function defaultWidgetConfig(): WidgetConfig;
|
|
30
33
|
export type Tokens = Record<string, string | undefined>;
|
|
@@ -35,12 +38,14 @@ export type Tokens = Record<string, string | undefined>;
|
|
|
35
38
|
export declare function renderTemplate(template: string, tokens: Tokens): string;
|
|
36
39
|
export declare function actionTokens(verdict: Verdict, at?: number): Tokens;
|
|
37
40
|
export declare function stuckTokens(verdict: StuckVerdict, at?: number): Tokens;
|
|
41
|
+
export declare function runawayTokens(verdict: RunawayVerdict, recovering: boolean, at?: number): Tokens;
|
|
38
42
|
export declare function doneTokens(verdict: DoneVerdict, at?: number): Tokens;
|
|
39
43
|
export declare function proseTokens(verdict: ProseVerdict, at?: number): Tokens;
|
|
40
44
|
/** Token names users can put in templates, for /warden status and the README. */
|
|
41
45
|
export declare const TOKEN_NAMES: {
|
|
42
46
|
readonly security: readonly ["tool", "injection", "exfiltration", "status"];
|
|
43
47
|
readonly context: readonly ["tool", "retention", "bytesSaved"];
|
|
48
|
+
readonly runaway: readonly ["kind", "count", "chars", "signal", "block", "status", "time", "guard"];
|
|
44
49
|
readonly action: readonly ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"];
|
|
45
50
|
readonly prose: readonly ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"];
|
|
46
51
|
readonly stuck: readonly ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"];
|
package/dist/widget.js
CHANGED
|
@@ -5,6 +5,7 @@ export const DEFAULT_TEMPLATES = {
|
|
|
5
5
|
prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}",
|
|
6
6
|
security: "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}",
|
|
7
7
|
context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes",
|
|
8
|
+
runaway: "warden · runaway · {kind} · {count}× repeated · {chars} chars · {signal} · {status}",
|
|
8
9
|
};
|
|
9
10
|
export function defaultWidgetConfig() {
|
|
10
11
|
return { enabled: true, placement: "aboveEditor", shortcut: "ctrl+shift+w", panelWidth: "40%", ...DEFAULT_TEMPLATES };
|
|
@@ -81,6 +82,18 @@ export function stuckTokens(verdict, at = Date.now()) {
|
|
|
81
82
|
flags: flags || undefined,
|
|
82
83
|
};
|
|
83
84
|
}
|
|
85
|
+
export function runawayTokens(verdict, recovering, at = Date.now()) {
|
|
86
|
+
return {
|
|
87
|
+
guard: "runaway",
|
|
88
|
+
time: time(at),
|
|
89
|
+
kind: verdict.kind,
|
|
90
|
+
count: String(verdict.count),
|
|
91
|
+
chars: String(verdict.chars),
|
|
92
|
+
signal: verdict.signal,
|
|
93
|
+
block: verdict.block,
|
|
94
|
+
status: recovering ? "stopped, recovering" : "stopped",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
84
97
|
export function doneTokens(verdict, at = Date.now()) {
|
|
85
98
|
return {
|
|
86
99
|
guard: "done",
|
|
@@ -117,6 +130,7 @@ export function proseTokens(verdict, at = Date.now()) {
|
|
|
117
130
|
export const TOKEN_NAMES = {
|
|
118
131
|
security: ["tool", "injection", "exfiltration", "status"],
|
|
119
132
|
context: ["tool", "retention", "bytesSaved"],
|
|
133
|
+
runaway: ["kind", "count", "chars", "signal", "block", "status", "time", "guard"],
|
|
120
134
|
action: ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"],
|
|
121
135
|
prose: ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"],
|
|
122
136
|
stuck: ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|