pi-warden 0.60.0 → 0.64.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 +45 -0
- package/dist/compact.d.ts +9 -0
- package/dist/compact.js +34 -10
- package/dist/config.d.ts +7 -1
- package/dist/config.js +6 -2
- package/dist/dedupe.d.ts +41 -0
- package/dist/dedupe.js +162 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +263 -38
- package/dist/guard.d.ts +4 -0
- package/dist/guard.js +132 -9
- package/dist/loops.d.ts +54 -0
- package/dist/loops.js +137 -0
- package/dist/prefs.d.ts +153 -10
- package/dist/prefs.js +362 -49
- package/dist/saver.d.ts +9 -1
- package/dist/saver.js +14 -4
- package/dist/shape.js +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,51 @@ How to keep this current: add the entry in the same pull request as the change,
|
|
|
8
8
|
|
|
9
9
|
<!-- Empty. Next release starts here. -->
|
|
10
10
|
|
|
11
|
+
## 0.64.1
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- A recursive `rm` of recorded session scratch now keeps its hold when the same command also moves, links, copies, extracts, or mounts data: `mv`, `ln`, `cp`, `tar` (also `g`- or `bsd`-prefixed), `rsync`, `mount`, `hdiutil`, `bindfs`, or `git … clone`, anywhere in the command and also quoted or escaped (`\mv`, `"ln"`, `l''n`). The scratch tree walk runs before the command, so it could not see data the command itself put into the scratch path before its `rm`. A plain `rm -rf` of recorded scratch is still released.
|
|
16
|
+
|
|
17
|
+
## 0.64.0
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Open loops. New agent tool `warden_loops` keeps what the agent promised to do later in this session: `add` (at most 160 characters, with an optional `when` condition such as "after CI passes"), `done`, `drop` with a reason, and `list`; its description tells the agent to add a loop whenever it promises something for later. The open loops (at most 8 and 600 characters) appear in the compaction appendix, in one end-of-run notice for the next turn (counted against the steer budget as `loops`, never twice for the same unchanged list), and in one message on resume. Loops are stored per session and project in pi-warden's data folder, so they survive compaction and resume and never reach another session or project. `/warden loops` lists them for the user.
|
|
22
|
+
- New agent tool `warden_recall`: the failed attempts of this session with their error lines, the last passing check with whether the code changed since, and the saved-output paths. It prints the same sections the compaction appendix builds, from the same session state. Read-only.
|
|
23
|
+
- Both run in code, with no TypeSafe request.
|
|
24
|
+
|
|
25
|
+
## 0.63.0
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- An intent mismatch on a call with no visible effect is trace-only: the trace, the warn notice, and the status line (`N off plan (M trace-only)`) record it, and the agent gets no steer. A commit, push, merge, tag, reset, pull request, release, or publish still steers (decided in code by `isVisibleCommand`), and so does any call Jev judges `visible` at 0.8 or more, such as an install, a launched program, or a message sent from a script. The steer reaches the agent only after the call ran: on 275 recorded intent-mismatch steers over 7 days, 275 arrived after the call, and a strict course change (the agent asked the user, reverted, or changed approach) followed 8% of them. New `action.intentTraceOnly` sets which mismatches are trace-only: `"invisible"` (default), `"all"`, or `"none"` (every mismatch steers, as before).
|
|
30
|
+
|
|
31
|
+
## 0.62.0
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `prefs.inject` is on by default, and strict: a wrong memory is worse than none. A standing preference reaches the agent only when you typed it, in a standing form (no question and no temporary word; `now`, `until`, `this PR`, `this branch`, and `for this` join `yet`, `for now`, `today`, and `this time`), not bound to one task (a pronoun-only object such as "don't commit it", a ticket, branch, PR number, or hash), in 3 or more sessions on 2 or more days, last within 30 days, not lifted by a later message of the opposite polarity ("do use subagents here"), and not weakening a check (skipping tests, checks, reviews, or confirmations, turning warden off, or pushing, deploying, or deleting without asking; the markers are in `WEAKENS_CHECK`). At most 5 items and 400 characters, each quoted as said with its session count, ending with "If the current request says otherwise, follow the current request." Still one message per session, not a steer, and not repeated on resume.
|
|
36
|
+
- `/warden prefs` shows each item as injected or the rule that kept it out ("not injected: seen in 2 sessions", "not injected: weakens a check"). `/warden prefs forget <n>` drops an item for the project for good, rewordings included; the key is kept in pi-warden's data folder.
|
|
37
|
+
- New agent tool `warden_remember`: after a user correction, or a stuck, repeat, or done-check steer, within the last 5 assistant turns of the run, the agent can record one standing lesson (at most 160 characters) for the project. The same rules apply; a lesson that repeats a stored lesson or a listed preference confirms it instead of adding one. A lesson is injected only once confirmed (recorded again in a later session, or said by the user), after your preferences, marked `(agent lesson)`, and expires after 30 days without a confirmation. Lessons are stored with the date and session id under pi-warden's data folder, never in a rules file or a session file. Preference and lesson text goes to the session model only, never to TypeSafe.
|
|
38
|
+
|
|
39
|
+
## 0.61.0
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- The context saver cuts repeated runs. In a new tool result, a run of at least 20 lines and 1500 characters that exactly matches text already in context on the current branch becomes one pointer line naming where the earlier copy is and the file that holds the full text. The last 2000 characters and images are never changed. The ledger counts the removed bytes and their token-turns; `/warden status` shows the repeats cut. `context.dedupeRuns` (default `true`) turns it off. `context.dedupeMessages` (default `false`) applies the same cut to new user and custom messages; it is off by default because a repeat the user sends can itself carry meaning.
|
|
44
|
+
|
|
45
|
+
### Tests
|
|
46
|
+
|
|
47
|
+
- A repeated report in a new tool result, and with `context.dedupeMessages` in a custom or user message, is cut to one pointer line; messages stay whole by default; one changed line breaks the match; the tail stays; the stored copy holds the full text and reading it back is a recall; `context.dedupeRuns: false` leaves everything whole.
|
|
48
|
+
|
|
49
|
+
## 0.60.1
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Message text in a command is no longer read as a command. The quoted values of `--body`, `-b`, `--title`, `-t`, and `--notes` on `gh pr|issue|release create|edit|comment`, and of `-m` and `--message` on `git commit` and `git tag`, are blanked before the patterns run, also in `=` form and as `$'…'`. A body that held `;`, `&&`, or new lines was cut into segments before, so a PR body that quoted `rm -rf /` was held as destructive. A value with `$(`, backticks, or an unclosed quote is still read, because the shell runs it.
|
|
54
|
+
- A recursive `rm` right after a backtick (`` `rm -rf ~` ``) is now classified like one after `$(`.
|
|
55
|
+
|
|
11
56
|
## 0.60.0
|
|
12
57
|
|
|
13
58
|
### Changed
|
package/dist/compact.d.ts
CHANGED
|
@@ -56,9 +56,17 @@ export interface CompactSnapshot {
|
|
|
56
56
|
verification: Verification | undefined;
|
|
57
57
|
stuck: StuckState | undefined;
|
|
58
58
|
activeTask: string | undefined;
|
|
59
|
+
/** The session's open loops, already formatted and capped by `formatOpenLoops`; absent or empty when none is open. */
|
|
60
|
+
openLoops?: string;
|
|
59
61
|
}
|
|
60
62
|
/** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
|
|
61
63
|
export declare function compactAppendix(snapshot: CompactSnapshot): string;
|
|
64
|
+
/**
|
|
65
|
+
* `warden_recall`: what was already tried in this session, from the same snapshot and the same section text as the
|
|
66
|
+
* compaction appendix: the failed attempts with their error lines, the last passing check with whether the code
|
|
67
|
+
* changed since, and the saved-output paths. Capped like the appendix.
|
|
68
|
+
*/
|
|
69
|
+
export declare function recallText(snapshot: CompactSnapshot): string;
|
|
62
70
|
/** The line of a failed output most likely to name the error: the last specific error line, else the last line. */
|
|
63
71
|
export declare function errorLine(output: string): string;
|
|
64
72
|
/** Distinct failed calls, the latest occurrence of each, oldest first; at most `MAX_FAILED`. */
|
|
@@ -101,4 +109,5 @@ export declare function buildCompactSnapshot(options: {
|
|
|
101
109
|
evidence?: Pick<RunEvidence, "mutations" | "checks" | "checksBeforeMutation">;
|
|
102
110
|
activeTask: string | undefined;
|
|
103
111
|
runs: number;
|
|
112
|
+
openLoops?: string;
|
|
104
113
|
}): CompactSnapshot;
|
package/dist/compact.js
CHANGED
|
@@ -8,8 +8,10 @@ const TASK_CHARS_WHEN_OVER = 300;
|
|
|
8
8
|
function clip(text, limit) {
|
|
9
9
|
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** Every section in appendix order; `warden_recall` prints some of the same strings, so the two never disagree. */
|
|
12
|
+
function renderSections(snapshot) {
|
|
12
13
|
const sections = [];
|
|
14
|
+
const push = (name, text) => sections.push([name, text]);
|
|
13
15
|
if (snapshot.failedAttempts.length) {
|
|
14
16
|
const items = snapshot.failedAttempts.map(attempt => {
|
|
15
17
|
const call = clip(redact(attempt.call), FAILED_CALL_CHARS);
|
|
@@ -17,39 +19,46 @@ function render(snapshot) {
|
|
|
17
19
|
const entry = error ? `${call} → ${clip(error, FAILED_ENTRY_CHARS - call.length - 3)}` : call;
|
|
18
20
|
return `- ${entry}`;
|
|
19
21
|
}).join("\n");
|
|
20
|
-
|
|
22
|
+
push("failed", `### Tried and failed\nDo not retry these unchanged.\n${items}`);
|
|
21
23
|
}
|
|
22
24
|
if (snapshot.verification) {
|
|
23
25
|
const v = snapshot.verification;
|
|
24
26
|
const line = v.kind === "passed"
|
|
25
27
|
? `- last passing check: ${redact(v.command)}; code changed since last passing check: ${v.changedSince ? "yes" : "no"}`
|
|
26
28
|
: "- no check has passed yet; code was changed";
|
|
27
|
-
|
|
29
|
+
push("verification", `### Verification\n${line}`);
|
|
30
|
+
}
|
|
31
|
+
if (snapshot.openLoops) {
|
|
32
|
+
push("loops", `### Open loops\nPromised earlier in this session and not done yet; close each with warden_loops done or drop.\n${snapshot.openLoops}`);
|
|
28
33
|
}
|
|
29
34
|
if (snapshot.checks.length) {
|
|
30
35
|
const items = snapshot.checks
|
|
31
36
|
.map(c => `- ${c.passed ? "✓" : "✗"} ${c.when}: ${redact(c.command)}`)
|
|
32
37
|
.join("\n");
|
|
33
|
-
|
|
38
|
+
push("checks", `### Last checks\n${items}`);
|
|
34
39
|
}
|
|
35
40
|
if (snapshot.stuck) {
|
|
36
|
-
|
|
41
|
+
push("stuck", `### Stuck state\n- failures: ${snapshot.stuck.failures} of the last ${snapshot.stuck.window} tool results`);
|
|
37
42
|
}
|
|
38
43
|
if (snapshot.holds.length) {
|
|
39
44
|
const items = snapshot.holds
|
|
40
45
|
.map(h => `- ${redact(h.tool)}: ${redact(h.outcome)}${h.preview ? ` (${redact(h.preview)})` : ""}`)
|
|
41
46
|
.join("\n");
|
|
42
|
-
|
|
47
|
+
push("holds", `### Held actions\n${items}`);
|
|
43
48
|
}
|
|
44
49
|
if (snapshot.savedOutputs.length) {
|
|
45
50
|
const items = snapshot.savedOutputs
|
|
46
51
|
.map(item => `- ${item.tool ? `${redact(item.tool)} → ` : ""}${redact(item.path)}${item.bytes === undefined ? "" : ` (${item.bytes} bytes)`}`)
|
|
47
52
|
.join("\n");
|
|
48
|
-
|
|
53
|
+
push("saved", `### Saved full outputs\n${items}`);
|
|
49
54
|
}
|
|
50
55
|
if (snapshot.activeTask) {
|
|
51
|
-
|
|
56
|
+
push("task", `### Active task\n${redact(snapshot.activeTask)}`);
|
|
52
57
|
}
|
|
58
|
+
return sections;
|
|
59
|
+
}
|
|
60
|
+
function render(snapshot) {
|
|
61
|
+
const sections = renderSections(snapshot).map(([, text]) => text);
|
|
53
62
|
if (sections.length === 0)
|
|
54
63
|
return "";
|
|
55
64
|
return [
|
|
@@ -61,8 +70,9 @@ function render(snapshot) {
|
|
|
61
70
|
].join("\n");
|
|
62
71
|
}
|
|
63
72
|
/**
|
|
64
|
-
* Lower-value content goes first when the appendix is over the cap. Failed attempts
|
|
65
|
-
* never reduced here; they come first in the text, so the final cut reaches
|
|
73
|
+
* Lower-value content goes first when the appendix is over the cap. Failed attempts, the verification line, and the
|
|
74
|
+
* open loops (at most 600 characters) are never reduced here; they come first in the text, so the final cut reaches
|
|
75
|
+
* them last.
|
|
66
76
|
*/
|
|
67
77
|
const REDUCERS = [
|
|
68
78
|
s => ({ ...s, savedOutputs: s.savedOutputs.slice(-SAVED_KEEP_WHEN_OVER) }),
|
|
@@ -88,6 +98,19 @@ export function compactAppendix(snapshot) {
|
|
|
88
98
|
const marker = "\n… [truncated]";
|
|
89
99
|
return appendix.slice(0, MAX_CHARS - marker.length) + marker;
|
|
90
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* `warden_recall`: what was already tried in this session, from the same snapshot and the same section text as the
|
|
103
|
+
* compaction appendix: the failed attempts with their error lines, the last passing check with whether the code
|
|
104
|
+
* changed since, and the saved-output paths. Capped like the appendix.
|
|
105
|
+
*/
|
|
106
|
+
export function recallText(snapshot) {
|
|
107
|
+
const kept = renderSections(snapshot).filter(([name]) => name === "failed" || name === "verification" || name === "saved").map(([, text]) => text);
|
|
108
|
+
const text = kept.length ? kept.join("\n\n") : "Nothing recorded yet in this session: no failed attempt, no check, and no saved output.";
|
|
109
|
+
if (text.length <= MAX_CHARS)
|
|
110
|
+
return text;
|
|
111
|
+
const marker = "\n… [truncated]";
|
|
112
|
+
return text.slice(0, MAX_CHARS - marker.length) + marker;
|
|
113
|
+
}
|
|
91
114
|
const ERROR_LINE = /\b(?:error|errors|fail(?:ed|ure|s)?|exception|cannot|can't|could not|not found|no such|denied|refused|invalid|unexpected|missing|undefined|timed? ?out|abort(?:ed)?|panic|fatal|traceback)\b|✗|✖|✘/i;
|
|
92
115
|
/** Runner tallies ("Found 1 error.", "3 failing") say that something failed, not what; an earlier line says what. */
|
|
93
116
|
const SUMMARY_LINE = /^(?:found \d+ errors?\b|\d+ (?:failing|failed|errors?)\b|tests?:\s|ℹ fail \d+|npm (?:err!|error) (?:code|errno|a complete log|command failed|lifecycle)|error: command failed|command failed with exit code)/i;
|
|
@@ -159,5 +182,6 @@ export function buildCompactSnapshot(options) {
|
|
|
159
182
|
verification: options.evidence ? verificationOf(options.evidence) : undefined,
|
|
160
183
|
stuck,
|
|
161
184
|
activeTask,
|
|
185
|
+
...(options.openLoops ? { openLoops: options.openLoops } : {}),
|
|
162
186
|
};
|
|
163
187
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface ActionGuardConfig {
|
|
|
41
41
|
intentMismatch: number;
|
|
42
42
|
/** The same, for a command whose effect is visible outside the working tree (commit, push, merge, publish, launch): less mismatch is enough. */
|
|
43
43
|
visibleMismatch: number;
|
|
44
|
+
/** Which intent mismatches stay in the trace without a steer: "invisible" (default) a call with no visible effect (neither a commit, push, merge, tag, reset, pull request, release, or publish by `isVisibleCommand`, nor judged `visible` at 0.8 or more), "all" every one, "none" none. The steer arrives after the call ran: 275 of 275 recorded steers did. */
|
|
45
|
+
intentTraceOnly: "invisible" | "all" | "none";
|
|
44
46
|
/** Low P(should_proceed) is trace-only unless steer is enabled; hold is the inclusive threshold, not a blocking decision. Calibration: AUC 0.26 against regret, 44% flagged at 0.6 (100 targeted sessions, 2026-09-20). */
|
|
45
47
|
shouldProceed: {
|
|
46
48
|
hold: number;
|
|
@@ -215,6 +217,10 @@ export interface ContextConfig {
|
|
|
215
217
|
formatConfidence: number;
|
|
216
218
|
/** Append a compact evidence appendix to the summary during compaction. */
|
|
217
219
|
compactAppendix: boolean;
|
|
220
|
+
/** A run of at least 20 lines and 1500 characters in a new tool result that repeats text already in context becomes one pointer line (code only). */
|
|
221
|
+
dedupeRuns: boolean;
|
|
222
|
+
/** The same for user and custom messages, when `dedupeRuns` is also on. Off by default: a repeat the user sends can itself carry meaning ("here it is again, still failing"). */
|
|
223
|
+
dedupeMessages: boolean;
|
|
218
224
|
/** Prevention before the call: a bash action request asks whether the command will print far more than the agent needs. Never holds. */
|
|
219
225
|
largeOutput: LargeOutputConfig;
|
|
220
226
|
}
|
|
@@ -267,7 +273,7 @@ export type WardenMode = "steer" | "confirm" | "advise";
|
|
|
267
273
|
export interface PrefsConfig {
|
|
268
274
|
/** Read this project's earlier session files for preferences the user repeated (`/warden prefs`). Local, code only. */
|
|
269
275
|
enabled: boolean;
|
|
270
|
-
/** At session start, send the standing preferences to the agent as one context message.
|
|
276
|
+
/** At session start, send the standing preferences that pass every injection rule to the agent as one context message. */
|
|
271
277
|
inject: boolean;
|
|
272
278
|
}
|
|
273
279
|
export interface LearningConfig {
|
package/dist/config.js
CHANGED
|
@@ -42,6 +42,7 @@ export function defaultConfig() {
|
|
|
42
42
|
offTask: { warn: 0.6, steer: 0.85 },
|
|
43
43
|
intentMismatch: 0.9,
|
|
44
44
|
visibleMismatch: 0.8,
|
|
45
|
+
intentTraceOnly: "invisible",
|
|
45
46
|
shouldProceed: { hold: 0.6, steer: false },
|
|
46
47
|
feedbackLog: true,
|
|
47
48
|
commandRules: [],
|
|
@@ -57,7 +58,7 @@ export function defaultConfig() {
|
|
|
57
58
|
slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
|
|
58
59
|
security: { enabled: true, threshold: 0.7, maskOutput: true },
|
|
59
60
|
rules: { enabled: true, threshold: 0.7, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
|
|
60
|
-
context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7, compactAppendix: true, largeOutput: { enabled: true, threshold: 0.85 } },
|
|
61
|
+
context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7, compactAppendix: true, dedupeRuns: true, dedupeMessages: false, largeOutput: { enabled: true, threshold: 0.85 } },
|
|
61
62
|
runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
|
|
62
63
|
notify: { enabled: false, cooldownMs: 10000, command: [] },
|
|
63
64
|
judge: { cooldownMs: 60000, failuresBeforeCooldown: 3 },
|
|
@@ -82,7 +83,7 @@ export function defaultConfig() {
|
|
|
82
83
|
advanceThreshold: 0.70,
|
|
83
84
|
loadThreshold: 1.0,
|
|
84
85
|
},
|
|
85
|
-
prefs: { enabled: true, inject:
|
|
86
|
+
prefs: { enabled: true, inject: true },
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|
|
@@ -284,6 +285,7 @@ function applyAction(base, raw, timeoutMs, source) {
|
|
|
284
285
|
offTask: offTaskThreshold(raw.offTask, base.offTask),
|
|
285
286
|
intentMismatch: probability(raw.intentMismatch, base.intentMismatch),
|
|
286
287
|
visibleMismatch: Math.min(probability(raw.visibleMismatch, base.visibleMismatch), probability(raw.intentMismatch, base.intentMismatch)),
|
|
288
|
+
intentTraceOnly: raw.intentTraceOnly === "invisible" || raw.intentTraceOnly === "all" || raw.intentTraceOnly === "none" ? raw.intentTraceOnly : base.intentTraceOnly,
|
|
287
289
|
shouldProceed: {
|
|
288
290
|
hold: probability(raw.shouldProceed?.hold, base.shouldProceed.hold),
|
|
289
291
|
steer: boolean(raw.shouldProceed?.steer, base.shouldProceed.steer),
|
|
@@ -464,6 +466,8 @@ function applyGuards(base, raw, timeoutMs, source) {
|
|
|
464
466
|
recallTool: isRecallTool(raw.context.recallTool) ? raw.context.recallTool : base.context.recallTool,
|
|
465
467
|
formatConfidence: probability(raw.context.formatConfidence, base.context.formatConfidence),
|
|
466
468
|
compactAppendix: boolean(raw.context.compactAppendix, base.context.compactAppendix),
|
|
469
|
+
dedupeRuns: boolean(raw.context.dedupeRuns, base.context.dedupeRuns),
|
|
470
|
+
dedupeMessages: boolean(raw.context.dedupeMessages, base.context.dedupeMessages),
|
|
467
471
|
largeOutput: isObject(raw.context.largeOutput) ? {
|
|
468
472
|
enabled: boolean(raw.context.largeOutput.enabled, base.context.largeOutput.enabled),
|
|
469
473
|
threshold: probability(raw.context.largeOutput.threshold, base.context.largeOutput.threshold),
|
package/dist/dedupe.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeated-run detection: a new tool result or message that pastes again a long block the agent already has in context
|
|
3
|
+
* (a relayed report that quotes every earlier turn) keeps only its new part. Exact line matches only, after trimming
|
|
4
|
+
* trailing spaces; near-duplicates are never collapsed. Deterministic; no requests.
|
|
5
|
+
*/
|
|
6
|
+
/** A run shorter than this, in lines or characters, is kept: a short repeat costs less than the pointer that replaces it. */
|
|
7
|
+
export declare const RUN_MIN_LINES = 20;
|
|
8
|
+
export declare const RUN_MIN_CHARS = 1500;
|
|
9
|
+
/** Errors and verdicts usually sit at the end of a result, so the last characters are never replaced. */
|
|
10
|
+
export declare const TAIL_KEEP_CHARS = 2000;
|
|
11
|
+
export interface RepeatedRun {
|
|
12
|
+
/** First line of the run in the new text, 0-based. */
|
|
13
|
+
start: number;
|
|
14
|
+
lines: number;
|
|
15
|
+
chars: number;
|
|
16
|
+
/** Where the earlier copy is, e.g. "an earlier bash result". */
|
|
17
|
+
where: string;
|
|
18
|
+
}
|
|
19
|
+
/** One text the agent has in context, with a stable identity so a sync adds it once. */
|
|
20
|
+
export interface SeenItem {
|
|
21
|
+
key: unknown;
|
|
22
|
+
where: string;
|
|
23
|
+
texts: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare class SeenText {
|
|
26
|
+
/** Interned normalized lines; ids start at 1 so 0 marks a line never seen. */
|
|
27
|
+
private readonly lineIds;
|
|
28
|
+
private readonly docs;
|
|
29
|
+
private readonly windows;
|
|
30
|
+
private readonly keys;
|
|
31
|
+
clear(): void;
|
|
32
|
+
/** Match the index to what is in context now: a compaction or a move to another branch drops texts, so it rebuilds. */
|
|
33
|
+
sync(items: SeenItem[]): void;
|
|
34
|
+
add(text: string, where: string): void;
|
|
35
|
+
/** Runs of `text` that repeat one earlier text exactly, longest match first at each position, outside the kept tail. */
|
|
36
|
+
find(text: string): RepeatedRun[];
|
|
37
|
+
}
|
|
38
|
+
/** Replaces each run with one pointer line; `path` holds the full original text. */
|
|
39
|
+
export declare function collapseRuns(text: string, runs: RepeatedRun[], path: string): string;
|
|
40
|
+
/** The texts a session entry puts in context and where the agent saw them. Thinking, images, and summaries are not indexed. */
|
|
41
|
+
export declare function seenItem(entry: unknown): SeenItem | undefined;
|
package/dist/dedupe.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeated-run detection: a new tool result or message that pastes again a long block the agent already has in context
|
|
3
|
+
* (a relayed report that quotes every earlier turn) keeps only its new part. Exact line matches only, after trimming
|
|
4
|
+
* trailing spaces; near-duplicates are never collapsed. Deterministic; no requests.
|
|
5
|
+
*/
|
|
6
|
+
/** A run shorter than this, in lines or characters, is kept: a short repeat costs less than the pointer that replaces it. */
|
|
7
|
+
export const RUN_MIN_LINES = 20;
|
|
8
|
+
export const RUN_MIN_CHARS = 1500;
|
|
9
|
+
/** Errors and verdicts usually sit at the end of a result, so the last characters are never replaced. */
|
|
10
|
+
export const TAIL_KEEP_CHARS = 2000;
|
|
11
|
+
const BASE = 1_000_003;
|
|
12
|
+
/** BASE^(RUN_MIN_LINES - 1) mod 2^32, for the rolling window hash. */
|
|
13
|
+
const TOP = (() => { let power = 1; for (let index = 1; index < RUN_MIN_LINES; index++)
|
|
14
|
+
power = Math.imul(power, BASE); return power; })();
|
|
15
|
+
/** Several earlier copies of one window are enough to find the longest match; more only cost memory. */
|
|
16
|
+
const MAX_POSITIONS = 4;
|
|
17
|
+
/** Hash of every RUN_MIN_LINES-line window, by start line. */
|
|
18
|
+
function windowHashes(ids) {
|
|
19
|
+
const hashes = [];
|
|
20
|
+
if (ids.length < RUN_MIN_LINES)
|
|
21
|
+
return hashes;
|
|
22
|
+
let hash = 0;
|
|
23
|
+
for (let index = 0; index < RUN_MIN_LINES; index++)
|
|
24
|
+
hash = (Math.imul(hash, BASE) + ids[index]) | 0;
|
|
25
|
+
hashes.push(hash);
|
|
26
|
+
for (let index = RUN_MIN_LINES; index < ids.length; index++) {
|
|
27
|
+
hash = (Math.imul((hash - Math.imul(ids[index - RUN_MIN_LINES], TOP)) | 0, BASE) + ids[index]) | 0;
|
|
28
|
+
hashes.push(hash);
|
|
29
|
+
}
|
|
30
|
+
return hashes;
|
|
31
|
+
}
|
|
32
|
+
export class SeenText {
|
|
33
|
+
/** Interned normalized lines; ids start at 1 so 0 marks a line never seen. */
|
|
34
|
+
lineIds = new Map();
|
|
35
|
+
docs = [];
|
|
36
|
+
windows = new Map();
|
|
37
|
+
keys = new Set();
|
|
38
|
+
clear() {
|
|
39
|
+
this.lineIds.clear();
|
|
40
|
+
this.docs.length = 0;
|
|
41
|
+
this.windows.clear();
|
|
42
|
+
this.keys.clear();
|
|
43
|
+
}
|
|
44
|
+
/** Match the index to what is in context now: a compaction or a move to another branch drops texts, so it rebuilds. */
|
|
45
|
+
sync(items) {
|
|
46
|
+
const current = new Set(items.map(item => item.key));
|
|
47
|
+
for (const key of this.keys)
|
|
48
|
+
if (!current.has(key)) {
|
|
49
|
+
this.clear();
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
if (this.keys.has(item.key))
|
|
54
|
+
continue;
|
|
55
|
+
this.keys.add(item.key);
|
|
56
|
+
for (const text of item.texts)
|
|
57
|
+
this.add(text, item.where);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
add(text, where) {
|
|
61
|
+
const lines = text.split("\n");
|
|
62
|
+
if (lines.length < RUN_MIN_LINES)
|
|
63
|
+
return;
|
|
64
|
+
const ids = lines.map(line => {
|
|
65
|
+
const normalized = line.trimEnd();
|
|
66
|
+
let id = this.lineIds.get(normalized);
|
|
67
|
+
if (id === undefined) {
|
|
68
|
+
id = this.lineIds.size + 1;
|
|
69
|
+
this.lineIds.set(normalized, id);
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
});
|
|
73
|
+
const doc = this.docs.push({ where, ids }) - 1;
|
|
74
|
+
windowHashes(ids).forEach((hash, line) => {
|
|
75
|
+
const positions = this.windows.get(hash);
|
|
76
|
+
if (!positions)
|
|
77
|
+
this.windows.set(hash, [[doc, line]]);
|
|
78
|
+
else if (positions.length < MAX_POSITIONS)
|
|
79
|
+
positions.push([doc, line]);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Runs of `text` that repeat one earlier text exactly, longest match first at each position, outside the kept tail. */
|
|
83
|
+
find(text) {
|
|
84
|
+
const lines = text.split("\n");
|
|
85
|
+
const cutoff = text.length - TAIL_KEEP_CHARS;
|
|
86
|
+
let limit = 0;
|
|
87
|
+
for (let offset = 0; limit < lines.length; limit++) {
|
|
88
|
+
const end = offset + lines[limit].length + 1;
|
|
89
|
+
if (end > cutoff)
|
|
90
|
+
break;
|
|
91
|
+
offset = end;
|
|
92
|
+
}
|
|
93
|
+
if (limit < RUN_MIN_LINES)
|
|
94
|
+
return [];
|
|
95
|
+
const ids = lines.slice(0, limit).map(line => this.lineIds.get(line.trimEnd()) ?? 0);
|
|
96
|
+
const hashes = windowHashes(ids);
|
|
97
|
+
const runs = [];
|
|
98
|
+
let index = 0;
|
|
99
|
+
while (index + RUN_MIN_LINES <= limit) {
|
|
100
|
+
let best;
|
|
101
|
+
for (const [doc, line] of this.windows.get(hashes[index]) ?? []) {
|
|
102
|
+
const seen = this.docs[doc].ids;
|
|
103
|
+
let length = 0;
|
|
104
|
+
while (index + length < limit && line + length < seen.length && ids[index + length] !== 0 && ids[index + length] === seen[line + length])
|
|
105
|
+
length++;
|
|
106
|
+
if (length >= RUN_MIN_LINES && (!best || length > best.length))
|
|
107
|
+
best = { doc, length };
|
|
108
|
+
}
|
|
109
|
+
if (best) {
|
|
110
|
+
let chars = 0;
|
|
111
|
+
for (let line = index; line < index + best.length; line++)
|
|
112
|
+
chars += lines[line].length + 1;
|
|
113
|
+
if (chars >= RUN_MIN_CHARS) {
|
|
114
|
+
runs.push({ start: index, lines: best.length, chars, where: this.docs[best.doc].where });
|
|
115
|
+
index += best.length;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
index++;
|
|
120
|
+
}
|
|
121
|
+
return runs;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Replaces each run with one pointer line; `path` holds the full original text. */
|
|
125
|
+
export function collapseRuns(text, runs, path) {
|
|
126
|
+
if (!runs.length)
|
|
127
|
+
return text;
|
|
128
|
+
const lines = text.split("\n");
|
|
129
|
+
const kept = [];
|
|
130
|
+
let index = 0;
|
|
131
|
+
for (const run of runs) {
|
|
132
|
+
kept.push(...lines.slice(index, run.start), `[pi-warden: the next ${run.lines} lines repeat ${run.where} — omitted; full text: ${path}]`);
|
|
133
|
+
index = run.start + run.lines;
|
|
134
|
+
}
|
|
135
|
+
kept.push(...lines.slice(index));
|
|
136
|
+
return kept.join("\n");
|
|
137
|
+
}
|
|
138
|
+
function textsOf(content) {
|
|
139
|
+
if (typeof content === "string")
|
|
140
|
+
return [content];
|
|
141
|
+
if (!Array.isArray(content))
|
|
142
|
+
return [];
|
|
143
|
+
return content.filter(part => part?.type === "text" && typeof part.text === "string").map(part => part.text);
|
|
144
|
+
}
|
|
145
|
+
/** The texts a session entry puts in context and where the agent saw them. Thinking, images, and summaries are not indexed. */
|
|
146
|
+
export function seenItem(entry) {
|
|
147
|
+
if (!entry || typeof entry !== "object")
|
|
148
|
+
return undefined;
|
|
149
|
+
const record = entry;
|
|
150
|
+
const key = typeof record.id === "string" ? record.id : entry;
|
|
151
|
+
if (record.type === "custom_message")
|
|
152
|
+
return { key, where: `an earlier ${String(record.customType ?? "custom")} message`, texts: textsOf(record.content) };
|
|
153
|
+
if (record.type !== "message" || !record.message)
|
|
154
|
+
return undefined;
|
|
155
|
+
const { role } = record.message;
|
|
156
|
+
const where = role === "toolResult" ? `an earlier ${String(record.message.toolName ?? "tool")} result`
|
|
157
|
+
: role === "user" ? "an earlier user message"
|
|
158
|
+
: role === "assistant" ? "an earlier assistant reply"
|
|
159
|
+
: role === "custom" ? `an earlier ${String(record.message.customType ?? "custom")} message`
|
|
160
|
+
: undefined;
|
|
161
|
+
return where ? { key, where, texts: textsOf(record.message.content) } : undefined;
|
|
162
|
+
}
|
package/dist/extension.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { SlopSymptom, Verdict } from "./guard.js";
|
|
|
5
5
|
import type { ShapeResult } from "./shape.js";
|
|
6
6
|
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), 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 (or a bash command that writes a file with its content in the command) 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), the same task spine (the first request of the thread and up to four redacted earlier requests), 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.";
|
|
7
7
|
/** Which guard spent the user's attention. The status line reports one count per guard. */
|
|
8
|
-
export type SteerGuard = "action" | "rules" | "security" | "stuck" | "repeat" | "done" | "prose" | "runaway" | "subagent" | "conscience";
|
|
8
|
+
export type SteerGuard = "action" | "rules" | "security" | "stuck" | "repeat" | "done" | "prose" | "runaway" | "subagent" | "conscience" | "loops";
|
|
9
9
|
/**
|
|
10
10
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
11
11
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|