pi-warden 0.40.2 → 0.47.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.
@@ -0,0 +1,92 @@
1
+ /** Classify a judge error into a safe category (spec §6: never exception bodies). */
2
+ export function classifyJudgeError(err) {
3
+ if (err && typeof err === "object" && "code" in err) {
4
+ const code = err.code;
5
+ if (code === "timeout")
6
+ return "timeout";
7
+ if (code === "http" || code === "connection" || code === "response")
8
+ return "network";
9
+ if (code === "configuration" || code === "validation")
10
+ return "configuration";
11
+ if (code === "budget" || code === "aborted")
12
+ return "other";
13
+ }
14
+ const msg = err instanceof Error ? err.message : String(err);
15
+ if (/timeout|timed out/i.test(msg))
16
+ return "timeout";
17
+ if (/auth|key|credential|401|403/i.test(msg))
18
+ return "auth";
19
+ if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
20
+ return "network";
21
+ return "other";
22
+ }
23
+ /**
24
+ * What a failure says about the backend, or `undefined` when it says nothing.
25
+ *
26
+ * `budget` already stops judgments for the session on its own. `validation` is thrown locally for one malformed
27
+ * request (too many questions, input over the byte limit) and says nothing about the backend. `aborted` is ambiguous: pi-warden's own per-request
28
+ * deadline reaches the SDK as an abort, so a hung backend surfaces as `aborted`, not `timeout`. The signal's reason
29
+ * tells the two apart; only a user's cancel is left out.
30
+ */
31
+ export function cooldownFailureKind(err, signal) {
32
+ const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
33
+ if (code === "budget" || code === "validation")
34
+ return undefined;
35
+ if (code === "aborted")
36
+ return signal?.reason?.name === "TimeoutError" ? "timeout" : undefined;
37
+ const status = err && typeof err === "object" && "status" in err ? err.status : undefined;
38
+ if (status === 401 || status === 403)
39
+ return "auth";
40
+ return classifyJudgeError(err);
41
+ }
42
+ /**
43
+ * Session-scoped pause on a failing judge, so a dead backend costs one notice instead of one timeout per action.
44
+ * Nothing is persisted; a new session starts with the judge trusted again.
45
+ */
46
+ export class JudgeCooldown {
47
+ now;
48
+ failures = 0;
49
+ until = 0;
50
+ /** A window has started and no judgment has succeeded since, so the user was told and not yet told it recovered. */
51
+ cooled = false;
52
+ constructor(now = Date.now) {
53
+ this.now = now;
54
+ }
55
+ /** True while judgments are paused. */
56
+ active() {
57
+ return this.now() < this.until;
58
+ }
59
+ failure(kind, settings) {
60
+ // A request that was already in flight when the window opened adds nothing the user has not been told.
61
+ if (this.active())
62
+ return undefined;
63
+ // The probe after a window failed too: the judge is still down, which the open notice already says.
64
+ if (this.cooled) {
65
+ this.open(settings);
66
+ return undefined;
67
+ }
68
+ this.failures++;
69
+ const persistent = kind === "auth" || kind === "configuration";
70
+ if (!persistent && this.failures < settings.failuresBeforeCooldown)
71
+ return undefined;
72
+ this.open(settings);
73
+ return { type: "started", kind, ms: settings.cooldownMs };
74
+ }
75
+ success() {
76
+ this.failures = 0;
77
+ if (!this.cooled)
78
+ return undefined;
79
+ this.cooled = false;
80
+ return { type: "recovered" };
81
+ }
82
+ reset() {
83
+ this.failures = 0;
84
+ this.until = 0;
85
+ this.cooled = false;
86
+ }
87
+ open(settings) {
88
+ this.failures = 0;
89
+ this.cooled = true;
90
+ this.until = this.now() + settings.cooldownMs;
91
+ }
92
+ }
package/dist/panel.d.ts CHANGED
@@ -56,11 +56,14 @@ export interface PanelController {
56
56
  }
57
57
  /**
58
58
  * Open the trace as a right-hand sidebar. Pi's public UI API offers floating overlays but no side dock that narrows the
59
- * transcript, so the sidebar covers the right part of the screen; `nonCapturing` keeps the editor focused.
59
+ * transcript, so the sidebar covers the right part of the screen; `nonCapturing` keeps the editor focused. `built()` says
60
+ * whether the host ever asked for the component: a host without a terminal UI (RPC mode) settles `closed` without asking.
60
61
  */
61
62
  export declare function openTracePanel(ui: PanelUi, trace: Trace, options?: {
62
63
  width?: string | number;
63
- }): PanelController;
64
+ }): PanelController & {
65
+ built(): boolean;
66
+ };
64
67
  export declare class ConfigPanel implements Component {
65
68
  private readonly config;
66
69
  private readonly theme;
package/dist/panel.js CHANGED
@@ -151,12 +151,15 @@ function theme_fg(theme, color, text) {
151
151
  }
152
152
  /**
153
153
  * Open the trace as a right-hand sidebar. Pi's public UI API offers floating overlays but no side dock that narrows the
154
- * transcript, so the sidebar covers the right part of the screen; `nonCapturing` keeps the editor focused.
154
+ * transcript, so the sidebar covers the right part of the screen; `nonCapturing` keeps the editor focused. `built()` says
155
+ * whether the host ever asked for the component: a host without a terminal UI (RPC mode) settles `closed` without asking.
155
156
  */
156
157
  export function openTracePanel(ui, trace, options = {}) {
157
158
  let close = () => { };
158
159
  let unfocus = () => { };
160
+ let built = false;
159
161
  const closed = ui.custom((tui, theme, _keybindings, done) => {
162
+ built = true;
160
163
  close = () => done();
161
164
  const panel = new TracePanel(trace, theme, { close, unfocus: () => unfocus() }, () => tui.requestRender());
162
165
  const rows = tui.terminal?.rows;
@@ -168,7 +171,7 @@ export function openTracePanel(ui, trace, options = {}) {
168
171
  overlayOptions: { anchor: "right-center", width: options.width ?? "40%", minWidth: 44, maxHeight: "100%", nonCapturing: true },
169
172
  onHandle: (handle) => { unfocus = () => handle.unfocus(); },
170
173
  });
171
- return { closed, close: () => close() };
174
+ return { closed, close: () => close(), built: () => built };
172
175
  }
173
176
  function flattenConfig(obj, prefix = "") {
174
177
  const entries = [];
package/dist/redact.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export declare function redact(text: string): string;
2
2
  /**
3
- * A value shape, not a name: at least 8 characters, not a type word or placeholder, not a reference to somewhere the
4
- * value lives (`$VAR`, `${...}`, `process.env.X`, `<your key>`, `[redacted]`, `***`), not an all-caps identifier, and
5
- * with the mixture of character classes that keys have (digits with letters, or both cases with punctuation).
3
+ * A value shape, not a name: at least 8 characters, not a type word or placeholder (`<redacted>`, `***`, `REDACTED`,
4
+ * `xxxx`), not a plain number (`19415506`, `19,415,506`), not an identifier or expression in code (`findSecrets(text)`,
5
+ * `output.secretIds`, a type name), not a reference to somewhere the value lives (`$VAR`, `${...}`, `process.env.X`,
6
+ * `<your key>`, `[redacted]`), not an all-caps identifier, and with the mixture of character classes that keys have
7
+ * (digits with letters, or both cases with punctuation).
6
8
  */
7
9
  export declare function looksLikeSecretValue(value: string): boolean;
8
10
  /**
package/dist/redact.js CHANGED
@@ -51,12 +51,17 @@ const ASSIGNMENTS = [
51
51
  /** Words that sit after `secret:` in code and docs. */
52
52
  const NOT_VALUES = new Set(["boolean", "string", "number", "object", "any", "unknown", "null", "undefined", "true", "false", "none", "nil", "void", "never", "required", "optional", "redacted", "hidden", "masked", "omitted", "unset", "missing", "empty", "changeme", "example", "placeholder", "password", "secret", "token", "value", "text", "str", "int", "bytes", "yes", "no", "on", "off", "auto", "default", "bearer", "basic", "env", "process", "os", "environ", "config", "settings", "input", "output", "prompt"]);
53
53
  /**
54
- * A value shape, not a name: at least 8 characters, not a type word or placeholder, not a reference to somewhere the
55
- * value lives (`$VAR`, `${...}`, `process.env.X`, `<your key>`, `[redacted]`, `***`), not an all-caps identifier, and
56
- * with the mixture of character classes that keys have (digits with letters, or both cases with punctuation).
54
+ * A value shape, not a name: at least 8 characters, not a type word or placeholder (`<redacted>`, `***`, `REDACTED`,
55
+ * `xxxx`), not a plain number (`19415506`, `19,415,506`), not an identifier or expression in code (`findSecrets(text)`,
56
+ * `output.secretIds`, a type name), not a reference to somewhere the value lives (`$VAR`, `${...}`, `process.env.X`,
57
+ * `<your key>`, `[redacted]`), not an all-caps identifier, and with the mixture of character classes that keys have
58
+ * (digits with letters, or both cases with punctuation).
57
59
  */
58
60
  export function looksLikeSecretValue(value) {
59
- const text = value.trim().replace(/^[`"'([{<]+|[`"')\]}>.,;]+$/g, "");
61
+ const raw = value.trim();
62
+ if (/^<[^<>]*>$/.test(raw))
63
+ return false; // an angle-bracket placeholder: <redacted>, <...>, <sk-live-token>
64
+ const text = raw.replace(/^[`"'([{<]+|[`"')\]}>.,;]+$/g, "");
60
65
  if (text.length < 8 || text.length > 512)
61
66
  return false;
62
67
  const lower = text.toLowerCase();
@@ -64,8 +69,16 @@ export function looksLikeSecretValue(value) {
64
69
  return false;
65
70
  if (/^[$%<\[{*]|^(?:process|os|env|settings|config|secrets?|vault|keychain|import\.meta)\.|^\$?\{|^\*+$|^x+$|^(?:your|my|the|a|an)[-_ ]/i.test(text))
66
71
  return false;
72
+ if (/^\d+(?:[,_]\d+)*$/.test(text))
73
+ return false; // a plain number, separators allowed: 19415506, 797330_123, 19,415,506
74
+ if (/^(?:[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+|[a-z]+(?:[A-Z][\w$]*)+|[A-Z][a-z]+(?:[A-Z][\w$]*)*)\s*[(\[]/.test(text))
75
+ return false; // a call or index expression: a bracket straight after a code identifier or property path (`findSecrets(text)`, `secretValues.filter((id`); a bracket inside mixed characters is a password, not code
76
+ if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(text))
77
+ return false; // a property access: output.secretIds
67
78
  if (/^[A-Z][A-Z0-9_]{6,}$/.test(text))
68
79
  return false; // an environment variable name
80
+ if (/^[A-Z][a-z]+(?:[A-Z][a-z0-9]*)*$/.test(text) && !/\d{3,}/.test(text))
81
+ return false; // a class or type name in code
69
82
  if (/^[a-z]+(?:[A-Z][a-z0-9]*)+$/.test(text) && !/\d{3,}/.test(text))
70
83
  return false; // a camelCase identifier
71
84
  if (/^[a-z]+(?:[_-][a-z]+)+$/.test(text))
package/dist/rules.d.ts CHANGED
@@ -145,6 +145,8 @@ export interface RulesOptions {
145
145
  timeoutMs: number;
146
146
  signal?: AbortSignal | undefined;
147
147
  }
148
+ /** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd`. Returns false when git is unavailable or the path cannot be checked. */
149
+ export declare function gitIgnored(projectRel: string, cwd: string): boolean;
148
150
  /** Why a write or edit is not judged against the rules: nothing to judge, no rules, or a path the config keeps out. */
149
151
  export declare function skipReason(target: RulesTarget | undefined, set: RuleSet | undefined, config: RulesConfig): string | undefined;
150
152
  export declare function evaluateRules(tool: string, input: Record<string, unknown>, options: RulesOptions): Promise<RulesVerdict>;
package/dist/rules.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
3
4
  import { ask, choice } from "pi-typesafe";
4
5
  import { redact } from "./redact.js";
@@ -372,6 +373,16 @@ export function buildRulesRequest(target, set) {
372
373
  applicable,
373
374
  };
374
375
  }
376
+ /** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd`. Returns false when git is unavailable or the path cannot be checked. */
377
+ export function gitIgnored(projectRel, cwd) {
378
+ try {
379
+ execFileSync("git", ["check-ignore", "-q", projectRel], { cwd, timeout: 2000, stdio: "pipe" });
380
+ return true;
381
+ }
382
+ catch {
383
+ return false;
384
+ }
385
+ }
375
386
  /** Why a write or edit is not judged against the rules: nothing to judge, no rules, or a path the config keeps out. */
376
387
  export function skipReason(target, set, config) {
377
388
  if (!target)
@@ -487,6 +498,18 @@ export class RulesGuard {
487
498
  noted = new Set();
488
499
  inspect(call, siblings, options) {
489
500
  const set = this.store.load(options.cwd, options.config);
501
+ // A write or edit to a gitignored path is not project code; the rules in pi-warden.md do not apply.
502
+ // Only check when a rule set exists — no set means no rules file and skipReason already handles that.
503
+ if (set) {
504
+ const rawPath = typeof call.input.path === "string" ? call.input.path : undefined;
505
+ if (rawPath) {
506
+ const rel = projectPath(rawPath, options.cwd);
507
+ if (rel && gitIgnored(rel, options.cwd)) {
508
+ const verdict = { source: "skipped", tool: call.tool, path: rel, sources: set.sources, asked: 0, aggregate: false, findings: [], skippedReason: "gitignored by the project" };
509
+ return Promise.resolve(verdict);
510
+ }
511
+ }
512
+ }
490
513
  const judgeCall = (tool, input) => evaluateRules(tool, input, { ...options, set });
491
514
  if (options.judge) {
492
515
  for (const sibling of siblings) {
package/dist/shape.d.ts CHANGED
@@ -13,3 +13,49 @@ export interface ShapeResult {
13
13
  */
14
14
  export declare function completeConfig(loaded: Partial<WardenConfig> | undefined): ShapeResult;
15
15
  export declare function shapeWarning(missing: readonly string[], loadedSchema: number | undefined): string;
16
+ /**
17
+ * The task spine the action guard and the conscience both judge with: the thread's first user turn (`goal`),
18
+ * the latest user turn (`task`), and up to four earlier user turns (`history`, newest first). A follow-up
19
+ * like "now the tests" is judged against the goal it belongs to, not on its words alone.
20
+ */
21
+ export interface TaskSpine {
22
+ /** The first user turn of the thread, earlier than `task`; the goal the latest turn belongs to. */
23
+ goal: string;
24
+ /** The latest user turn, raw and unclipped: the request state already carries it as `task` (redacted, bounded) and approval reads only that field. */
25
+ task: string;
26
+ /** Earlier user turns between goal and task, newest first; empty when the goal is the turn just before task. */
27
+ history: string[];
28
+ }
29
+ /** Whole-spine character budget. `task_history` is clipped first, then `goal`; `task` is never clipped here. */
30
+ export declare const SPINE_CAP = 1200;
31
+ /** Earlier turns the spine keeps, between goal and task. */
32
+ export declare const SPINE_HISTORY_TURNS = 4;
33
+ /** Per-field limits the request paths apply to a spine they receive, whoever built it. */
34
+ export declare const SPINE_GOAL_LIMIT = 1200;
35
+ export declare const SPINE_HISTORY_LIMIT = 750;
36
+ /** The slice of a Pi session entry the spine reads; structural, so shape.ts needs no host types. */
37
+ export interface BranchMessageEntry {
38
+ type: string;
39
+ message?: {
40
+ role: string;
41
+ content?: string | ReadonlyArray<{
42
+ type: string;
43
+ text?: string;
44
+ }>;
45
+ } | undefined;
46
+ }
47
+ /** User-turn texts in branch order: text parts joined with newlines, non-text parts skipped, blank turns dropped. */
48
+ export declare function userTurnTexts(entries: readonly (BranchMessageEntry | undefined | null)[] | undefined): string[];
49
+ /**
50
+ * The task spine over the branch entries: goal, task, and up to four earlier turns, newest first. Pure.
51
+ * The whole spine fits SPINE_CAP characters — `history` is clipped first (newest turns keep their text,
52
+ * the oldest give way), then `goal`; `task` is never clipped, because approval is judged from the request's
53
+ * `task` field and a clipped task would judge a request the user did not write. `goal` and `history` are
54
+ * redacted here; `task` stays raw because both request paths redact and bound it themselves. `latest`
55
+ * supplies the latest turn when the branch does not carry it yet (the conscience passes the prompt it is
56
+ * about to send); when the branch already carries it, that copy is dropped from `history`. No user turn at
57
+ * all, or the latest turn is the first: no spine, so the budget is not spent on a copy of `task`. Only
58
+ * `message` entries count, so a compaction entry and its summary never become the goal; Pi's getBranch
59
+ * still returns the entries a compaction summarized. Scope context only — the spine never authorizes an action.
60
+ */
61
+ export declare function taskSpine(entries: readonly (BranchMessageEntry | undefined | null)[] | undefined, latest?: string): TaskSpine | undefined;
package/dist/shape.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isMode } from "./config.js";
2
2
  import { resolveBackend } from "./backend.js";
3
+ import { redact } from "./redact.js";
3
4
  import { DEFAULT_TEMPLATES } from "./widget.js";
4
5
  /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
5
6
  export const EXPECTED_SCHEMA = 7;
@@ -36,9 +37,11 @@ export function completeConfig(loaded) {
36
37
  slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
37
38
  security: section("security", { ...off, threshold: 1 }),
38
39
  rules: section("rules", { ...off, threshold: 1, files: [], fallback: false, maxChars: 500, exclude: [], skip: [], sensitivePaths: {} }),
39
- context: section("context", { ...off, tailMinChars: 1, confidence: 1, duplicateMinChars: Number.MAX_SAFE_INTEGER, recallTool: "none", formatConfidence: 1, compactAppendix: true }),
40
+ context: section("context", { ...off, tailMinChars: 1, confidence: 1, duplicateMinChars: Number.MAX_SAFE_INTEGER, recallTool: "none", formatConfidence: 1, compactAppendix: true, largeOutput: { ...off, threshold: 1 } }),
40
41
  runaway: section("runaway", { ...off, repeats: Number.MAX_SAFE_INTEGER, thinkingRepeats: Number.MAX_SAFE_INTEGER, minChars: Number.MAX_SAFE_INTEGER, recover: false }),
41
42
  notify: section("notify", { ...off, cooldownMs: 0, command: [] }),
43
+ // A stale config module leaves the judge trusted: never pausing is today's behaviour, not a new failure mode.
44
+ judge: section("judge", { failuresBeforeCooldown: Number.MAX_SAFE_INTEGER, cooldownMs: 0 }),
42
45
  subagent: section("subagent", { ...off, wake: false, threshold: 1, cooldownMs: 0 }),
43
46
  widget: section("widget", { ...off, placement: "aboveEditor", barMode: "live", shortcut: "", panelWidth: "40%", action: "", stuck: "", done: "", prose: "", security: "", context: "", runaway: "", rules: "", subagent: "" }),
44
47
  learning: section("learning", { adaptiveThresholds: true, patternAnalysis: true, minHoldsForAdaptive: 20, adaptationRate: 0.1, retentionDays: 365 }),
@@ -64,6 +67,9 @@ export function completeConfig(loaded) {
64
67
  missing.push("context.saver");
65
68
  config.context = { ...config.context, duplicateMinChars: Number.MAX_SAFE_INTEGER, recallTool: "none", formatConfidence: 1 };
66
69
  }
70
+ // The large-output question was added inside the context section later than the section itself; an older config module leaves it undefined and the question is not asked.
71
+ if (typeof config.context.largeOutput !== "object" || config.context.largeOutput === null)
72
+ config.context = { ...config.context, largeOutput: { ...off, threshold: 1 } };
67
73
  if (typeof config.widget.panelWidth !== "string" && typeof config.widget.panelWidth !== "number")
68
74
  config.widget = { ...config.widget, panelWidth: "40%" };
69
75
  // The feedback log flag was added inside the action section later than the section itself; an older config module leaves it undefined and the log stays on.
@@ -107,3 +113,72 @@ export function completeConfig(loaded) {
107
113
  export function shapeWarning(missing, loadedSchema) {
108
114
  return `warden: config sections ${missing.join(", ")} are missing (config module schema ${loadedSchema ?? "pre-3"}, extension expects ${EXPECTED_SCHEMA}); those guards are off. This happens when pi-warden was updated while Pi was running: restart Pi (a /reload is not enough).`;
109
115
  }
116
+ /** Whole-spine character budget. `task_history` is clipped first, then `goal`; `task` is never clipped here. */
117
+ export const SPINE_CAP = 1200;
118
+ /** Earlier turns the spine keeps, between goal and task. */
119
+ export const SPINE_HISTORY_TURNS = 4;
120
+ /** Per-field limits the request paths apply to a spine they receive, whoever built it. */
121
+ export const SPINE_GOAL_LIMIT = 1200;
122
+ export const SPINE_HISTORY_LIMIT = 750;
123
+ /** User-turn texts in branch order: text parts joined with newlines, non-text parts skipped, blank turns dropped. */
124
+ export function userTurnTexts(entries) {
125
+ const turns = [];
126
+ for (const entry of entries ?? []) {
127
+ if (!entry || entry.type !== "message")
128
+ continue;
129
+ const message = entry.message;
130
+ if (!message || message.role !== "user")
131
+ continue;
132
+ const content = message.content;
133
+ if (content === undefined)
134
+ continue;
135
+ const text = typeof content === "string"
136
+ ? content
137
+ : content.filter(part => part.type === "text").map(part => part.text ?? "").join("\n");
138
+ if (text.trim())
139
+ turns.push(text.trim());
140
+ }
141
+ return turns;
142
+ }
143
+ /**
144
+ * The task spine over the branch entries: goal, task, and up to four earlier turns, newest first. Pure.
145
+ * The whole spine fits SPINE_CAP characters — `history` is clipped first (newest turns keep their text,
146
+ * the oldest give way), then `goal`; `task` is never clipped, because approval is judged from the request's
147
+ * `task` field and a clipped task would judge a request the user did not write. `goal` and `history` are
148
+ * redacted here; `task` stays raw because both request paths redact and bound it themselves. `latest`
149
+ * supplies the latest turn when the branch does not carry it yet (the conscience passes the prompt it is
150
+ * about to send); when the branch already carries it, that copy is dropped from `history`. No user turn at
151
+ * all, or the latest turn is the first: no spine, so the budget is not spent on a copy of `task`. Only
152
+ * `message` entries count, so a compaction entry and its summary never become the goal; Pi's getBranch
153
+ * still returns the entries a compaction summarized. Scope context only — the spine never authorizes an action.
154
+ */
155
+ export function taskSpine(entries, latest) {
156
+ const turns = userTurnTexts(entries);
157
+ const supplied = latest?.trim();
158
+ const last = turns.at(-1);
159
+ const task = supplied || last || "";
160
+ if (!task)
161
+ return undefined;
162
+ const earlier = supplied
163
+ ? (last === task ? turns.slice(0, -1) : turns)
164
+ : turns.slice(0, -1);
165
+ if (!earlier.length)
166
+ return undefined;
167
+ let goal = redact(earlier[0]);
168
+ let history = earlier.slice(1).slice(-SPINE_HISTORY_TURNS).reverse().map(redact);
169
+ let budget = SPINE_CAP - goal.length - task.length;
170
+ const kept = [];
171
+ for (const turn of history) { // newest first: the oldest give way first
172
+ if (budget <= 0)
173
+ break;
174
+ const take = turn.slice(0, Math.max(0, budget));
175
+ kept.push(take);
176
+ budget -= take.length;
177
+ }
178
+ history = kept;
179
+ const historyLength = history.reduce((n, turn) => n + turn.length, 0);
180
+ if (goal.length + task.length + historyLength > SPINE_CAP) {
181
+ goal = goal.slice(0, Math.max(0, SPINE_CAP - task.length - historyLength));
182
+ }
183
+ return { goal, task, history };
184
+ }
@@ -0,0 +1,50 @@
1
+ import type { JudgmentsOffReason } from "./backend.js";
2
+ import type { TraceEvent } from "./trace.js";
3
+ /**
4
+ * Trace file for hosts that run Pi without the terminal UI. The widget and the sidebar never reach an RPC host, so with
5
+ * `PI_WARDEN_TRACE_DIR` set the trace is also appended to `<dir>/<session>.jsonl`, one JSON object per line:
6
+ *
7
+ * - `{"v":1,"kind":"session","sessionId","cwd","wardenVersion","mode","judgments","at"}` when the file is opened for a
8
+ * session; `judgments` is `"on"` or `"off:<reason>"`, reason one of `no_consent`, `no_key`, `key_rejected`, `budget`;
9
+ * - `{"v":1,"kind":"judgments","judgments","at"}` when a guard finds the judgment state changed from the last line;
10
+ * - `{"v":1,"kind":"entry","id","at","guard","line","details","tokens"?}` for every trace entry, as the trace stores it;
11
+ * - `{"v":1,"kind":"amend","id","line","at"}` for an outcome line added to an entry still in the trace.
12
+ *
13
+ * The file keeps every entry; the in-memory limit does not apply. A write error is reported once and ends the file for
14
+ * the session; it never reaches a guard or a tool call.
15
+ */
16
+ export declare const TRACE_DIR_ENV = "PI_WARDEN_TRACE_DIR";
17
+ /** The trace directory, or undefined when the feature is off. A relative path is not resolved against a cwd that moves. */
18
+ export declare function traceDir(env?: NodeJS.ProcessEnv): string | undefined;
19
+ export declare function traceFilePath(dir: string, sessionId: string): string;
20
+ export interface TraceFileSession {
21
+ sessionId: string;
22
+ cwd: string;
23
+ mode: string;
24
+ /** Whether Jev judgments can run as the session opens. */
25
+ judgments: JudgmentsState;
26
+ }
27
+ /** The trace file's judgment state: `"on"`, or `"off:<reason>"`. */
28
+ export type JudgmentsState = "on" | `off:${JudgmentsOffReason}`;
29
+ export declare function judgmentsState(reason: JudgmentsOffReason | undefined): JudgmentsState;
30
+ /** Appends the session's trace events in trace order, one write at a time. */
31
+ export declare class TraceFile {
32
+ readonly path: string;
33
+ private readonly onFailure;
34
+ private queue;
35
+ private base;
36
+ private sequence;
37
+ private readonly ids;
38
+ private stopped;
39
+ private judgmentsNow;
40
+ constructor(path: string, dir: string, session: TraceFileSession, onFailure: (message: string) => void);
41
+ /** Trace listener. The record is built now, so a later amend does not show up in the entry line. */
42
+ readonly listener: (event: TraceEvent) => void;
43
+ /** Adds a `judgments` line when the state differs from the last one written. */
44
+ judgments(state: JudgmentsState): void;
45
+ /** Resolves when every event so far is on disk or the file has stopped. */
46
+ flush(): Promise<void>;
47
+ private append;
48
+ private guarded;
49
+ private run;
50
+ }
@@ -0,0 +1,141 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, sep } from "node:path";
5
+ import { sessionFileId } from "./holds.js";
6
+ import { redact } from "./redact.js";
7
+ /**
8
+ * Trace file for hosts that run Pi without the terminal UI. The widget and the sidebar never reach an RPC host, so with
9
+ * `PI_WARDEN_TRACE_DIR` set the trace is also appended to `<dir>/<session>.jsonl`, one JSON object per line:
10
+ *
11
+ * - `{"v":1,"kind":"session","sessionId","cwd","wardenVersion","mode","judgments","at"}` when the file is opened for a
12
+ * session; `judgments` is `"on"` or `"off:<reason>"`, reason one of `no_consent`, `no_key`, `key_rejected`, `budget`;
13
+ * - `{"v":1,"kind":"judgments","judgments","at"}` when a guard finds the judgment state changed from the last line;
14
+ * - `{"v":1,"kind":"entry","id","at","guard","line","details","tokens"?}` for every trace entry, as the trace stores it;
15
+ * - `{"v":1,"kind":"amend","id","line","at"}` for an outcome line added to an entry still in the trace.
16
+ *
17
+ * The file keeps every entry; the in-memory limit does not apply. A write error is reported once and ends the file for
18
+ * the session; it never reaches a guard or a tool call.
19
+ */
20
+ export const TRACE_DIR_ENV = "PI_WARDEN_TRACE_DIR";
21
+ /** The trace directory, or undefined when the feature is off. A relative path is not resolved against a cwd that moves. */
22
+ export function traceDir(env = process.env) {
23
+ const dir = env[TRACE_DIR_ENV];
24
+ return dir && isAbsolute(dir) ? dir : undefined;
25
+ }
26
+ export function traceFilePath(dir, sessionId) {
27
+ return join(dir, `${sessionFileId(sessionId)}.jsonl`);
28
+ }
29
+ let version;
30
+ function wardenVersion() {
31
+ if (version !== undefined)
32
+ return version;
33
+ try {
34
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
35
+ version = typeof manifest.version === "string" ? manifest.version : "unknown";
36
+ }
37
+ catch (error) {
38
+ console.warn("pi-warden: package version unreadable for the trace file:", error);
39
+ version = "unknown";
40
+ }
41
+ return version;
42
+ }
43
+ /** The home directory as `~`, the way action summaries show a path outside the project. */
44
+ function shownPath(path) {
45
+ const home = homedir();
46
+ return redact(path === home || path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path);
47
+ }
48
+ /** Highest entry id already in the file, so a reload or a resumed session keeps ids unique in the session's file. */
49
+ async function lastEntryId(path) {
50
+ let text;
51
+ try {
52
+ text = await readFile(path, "utf8");
53
+ }
54
+ catch (error) {
55
+ if (error.code === "ENOENT")
56
+ return 0;
57
+ throw error;
58
+ }
59
+ let last = 0;
60
+ for (const match of text.matchAll(/^\{"v":1,"kind":"entry","id":(\d+)/gm))
61
+ last = Math.max(last, Number(match[1]));
62
+ return last;
63
+ }
64
+ export function judgmentsState(reason) {
65
+ return reason === undefined ? "on" : `off:${reason}`;
66
+ }
67
+ /** Appends the session's trace events in trace order, one write at a time. */
68
+ export class TraceFile {
69
+ path;
70
+ onFailure;
71
+ queue = Promise.resolve();
72
+ base = 0;
73
+ sequence = 0;
74
+ ids = new WeakMap();
75
+ stopped = false;
76
+ judgmentsNow;
77
+ constructor(path, dir, session, onFailure) {
78
+ this.path = path;
79
+ this.onFailure = onFailure;
80
+ const header = { v: 1, kind: "session", sessionId: session.sessionId, cwd: shownPath(session.cwd), wardenVersion: wardenVersion(), mode: session.mode, judgments: session.judgments, at: new Date().toISOString() };
81
+ this.judgmentsNow = session.judgments;
82
+ this.queue = this.guarded(async () => {
83
+ await mkdir(dir, { recursive: true, mode: 0o700 });
84
+ this.base = await lastEntryId(path);
85
+ await appendFile(path, `${JSON.stringify(header)}\n`, { mode: 0o600 });
86
+ });
87
+ }
88
+ /** Trace listener. The record is built now, so a later amend does not show up in the entry line. */
89
+ listener = (event) => {
90
+ if (this.stopped || event.kind === "clear")
91
+ return;
92
+ const at = new Date().toISOString();
93
+ if (event.kind === "push") {
94
+ const local = ++this.sequence;
95
+ this.ids.set(event.entry, local);
96
+ const { at: stamp, guard, line, details, tokens } = event.entry;
97
+ const fields = { at: new Date(stamp).toISOString(), guard, line, details: [...details], ...(tokens ? { tokens: { ...tokens } } : {}) };
98
+ this.append(() => ({ v: 1, kind: "entry", id: this.base + local, ...fields }));
99
+ return;
100
+ }
101
+ const local = this.ids.get(event.entry);
102
+ if (local === undefined)
103
+ return;
104
+ const { line } = event;
105
+ this.append(() => ({ v: 1, kind: "amend", id: this.base + local, line, at }));
106
+ };
107
+ /** Adds a `judgments` line when the state differs from the last one written. */
108
+ judgments(state) {
109
+ if (this.stopped || state === this.judgmentsNow)
110
+ return;
111
+ this.judgmentsNow = state;
112
+ const at = new Date().toISOString();
113
+ this.append(() => ({ v: 1, kind: "judgments", judgments: state, at }));
114
+ }
115
+ /** Resolves when every event so far is on disk or the file has stopped. */
116
+ flush() {
117
+ return this.queue;
118
+ }
119
+ append(record) {
120
+ this.queue = this.guarded(() => appendFile(this.path, `${JSON.stringify(record())}\n`, { mode: 0o600 }));
121
+ }
122
+ guarded(write) {
123
+ return this.queue.then(() => this.run(write));
124
+ }
125
+ async run(write) {
126
+ if (this.stopped)
127
+ return;
128
+ try {
129
+ await write();
130
+ }
131
+ catch (error) {
132
+ this.stopped = true;
133
+ try {
134
+ this.onFailure(`warden: trace file ${this.path} could not be written (${error instanceof Error ? error.message : String(error)}); no more trace lines go to it this session.`);
135
+ }
136
+ catch (reportError) {
137
+ console.warn("pi-warden: trace file failure could not be reported:", reportError);
138
+ }
139
+ }
140
+ }
141
+ }
package/dist/trace.d.ts CHANGED
@@ -15,6 +15,17 @@ export interface TraceEntry {
15
15
  /** Raw tokens for re-rendering in live mode with sentence templates. */
16
16
  tokens?: Record<string, string | undefined> | undefined;
17
17
  }
18
+ /** What changed, for a listener that keeps its own record (the trace file); the widget and the panel re-read entries(). */
19
+ export type TraceEvent = {
20
+ kind: "push";
21
+ entry: TraceEntry;
22
+ } | {
23
+ kind: "amend";
24
+ entry: TraceEntry;
25
+ line: string;
26
+ } | {
27
+ kind: "clear";
28
+ };
18
29
  /** Session memory of guard decisions; the widget shows the latest line per guard, the panel shows the history. */
19
30
  export declare class Trace {
20
31
  private readonly limit;
@@ -26,7 +37,7 @@ export declare class Trace {
26
37
  amend(entry: TraceEntry, line: string): boolean;
27
38
  entries(): readonly TraceEntry[];
28
39
  clear(): void;
29
- subscribe(listener: () => void): () => void;
40
+ subscribe(listener: (event: TraceEvent) => void): () => void;
30
41
  }
31
42
  export declare function actionDetails(verdict: Verdict, extra?: {
32
43
  mode?: string;
package/dist/trace.js CHANGED
@@ -11,7 +11,7 @@ export class Trace {
11
11
  if (this.items.length > this.limit)
12
12
  this.items.splice(0, this.items.length - this.limit);
13
13
  for (const listener of this.listeners)
14
- listener();
14
+ listener({ kind: "push", entry });
15
15
  }
16
16
  /** Appends a detail line to an entry that is still in the trace, for an outcome that lands after the event. */
17
17
  amend(entry, line) {
@@ -19,7 +19,7 @@ export class Trace {
19
19
  return false;
20
20
  entry.details.push(line);
21
21
  for (const listener of this.listeners)
22
- listener();
22
+ listener({ kind: "amend", entry, line });
23
23
  return true;
24
24
  }
25
25
  entries() {
@@ -28,7 +28,7 @@ export class Trace {
28
28
  clear() {
29
29
  this.items.length = 0;
30
30
  for (const listener of this.listeners)
31
- listener();
31
+ listener({ kind: "clear" });
32
32
  }
33
33
  subscribe(listener) {
34
34
  this.listeners.add(listener);