pi-advisor-flow 0.2.4 → 0.2.6

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/src/git.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /** How much repository change context may leave the machine. */
4
+ export type GitContextLevel = "off" | "summary" | "full";
5
+
6
+ export const GIT_CONTEXT_LEVELS: GitContextLevel[] = ["off", "summary", "full"];
7
+
8
+ export const isValidGitContextLevel = (
9
+ value: unknown
10
+ ): value is GitContextLevel =>
11
+ GIT_CONTEXT_LEVELS.includes(value as GitContextLevel);
12
+
13
+ const LEVEL_RANK: Record<GitContextLevel, number> = {
14
+ full: 2,
15
+ off: 0,
16
+ summary: 1,
17
+ };
18
+
19
+ /**
20
+ * The Executor may request no more repository context than the user configured.
21
+ * A model cannot widen its own disclosure allowance.
22
+ */
23
+ export const clampGitContextLevel = (
24
+ requested: GitContextLevel,
25
+ allowed: GitContextLevel
26
+ ): GitContextLevel =>
27
+ LEVEL_RANK[requested] <= LEVEL_RANK[allowed] ? requested : allowed;
28
+
29
+ export type GitContextStatus =
30
+ | "disabled"
31
+ | "no-changes"
32
+ | "collected"
33
+ | "not-a-repository"
34
+ | "failed";
35
+
36
+ export interface GitContextResult {
37
+ /** Why collection produced nothing, for diagnostics. Never sent verbatim. */
38
+ detail?: string;
39
+ /** The level actually collected after clamping. */
40
+ level: GitContextLevel;
41
+ status: GitContextStatus;
42
+ /** Collected context, not yet escaped or capped. Empty when nothing is disclosed. */
43
+ text: string;
44
+ }
45
+
46
+ /**
47
+ * Repository text is attacker-influenced and is embedded in a tagged region.
48
+ * A path can reproduce a closing tag verbatim: a directory named `x<` holding a
49
+ * file named `repository_changes>` prints as `x</repository_changes>`, which
50
+ * would end the region early and promote the remainder to trusted instructions.
51
+ */
52
+ export const escapeRepositoryText = (value: string) =>
53
+ value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
54
+
55
+ const TRUNCATION_NOTICE =
56
+ "\n[Repository context truncated: it exceeded the configured limit.]";
57
+
58
+ /** Caps the fully rendered region and states plainly that content was dropped. */
59
+ export const capRepositoryContext = (value: string, maxChars: number) => {
60
+ if (value.length <= maxChars) {
61
+ return { text: value, truncated: false };
62
+ }
63
+ return {
64
+ text: `${value.slice(0, Math.max(0, maxChars - TRUNCATION_NOTICE.length))}${TRUNCATION_NOTICE}`,
65
+ truncated: true,
66
+ };
67
+ };
68
+
69
+ // git's own empty-tree object, used to diff a repository with no commits yet.
70
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
71
+ const GIT_TOTAL_TIMEOUT_MS = 5000;
72
+ const GIT_MAX_BUFFER = 16 * 1024 * 1024;
73
+
74
+ export type GitRunner = (args: string[], cwd: string) => string;
75
+
76
+ /**
77
+ * Collection runs several git commands. A per-command timeout alone would let a
78
+ * pathological repository block for the sum of them, so the budget is shared:
79
+ * each command may use only the time remaining before the overall deadline.
80
+ */
81
+ const deadlineRunner = (): GitRunner => {
82
+ const expiresAt = Date.now() + GIT_TOTAL_TIMEOUT_MS;
83
+ return (args, cwd) => {
84
+ const remaining = expiresAt - Date.now();
85
+ if (remaining <= 0) {
86
+ throw new Error("Git context collection exceeded its time budget.");
87
+ }
88
+ return execFileSync("git", args, {
89
+ cwd,
90
+ encoding: "utf8",
91
+ maxBuffer: GIT_MAX_BUFFER,
92
+ // Never use a shell: arguments are fixed and must not be re-parsed.
93
+ shell: false,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ timeout: remaining,
96
+ windowsHide: true,
97
+ });
98
+ };
99
+ };
100
+
101
+ /** Resolves the base revision, falling back to the empty tree before any commit. */
102
+ const diffBase = (run: GitRunner, cwd: string): string => {
103
+ try {
104
+ run(["rev-parse", "--verify", "--quiet", "HEAD"], cwd);
105
+ return "HEAD";
106
+ } catch {
107
+ return EMPTY_TREE;
108
+ }
109
+ };
110
+
111
+ /**
112
+ * Collects working-tree changes relative to HEAD, covering staged and unstaged
113
+ * work. Untracked files are reported by name only and never by content.
114
+ *
115
+ * `summary` discloses file names, change status, and line counts. It must not
116
+ * include diff hunk headers: git derives those from surrounding file content,
117
+ * so they can reproduce a secret from a line the change never touched.
118
+ */
119
+ export const collectGitContext = (
120
+ cwd: string,
121
+ level: GitContextLevel,
122
+ maxChars: number,
123
+ redact: (value: string) => string = (value) => value,
124
+ run: GitRunner = deadlineRunner()
125
+ ): GitContextResult => {
126
+ if (level === "off" || maxChars <= 0) {
127
+ return { level: "off", status: "disabled", text: "" };
128
+ }
129
+ try {
130
+ run(["rev-parse", "--is-inside-work-tree"], cwd);
131
+ } catch (error) {
132
+ return {
133
+ detail: error instanceof Error ? error.message : String(error),
134
+ level,
135
+ status: "not-a-repository",
136
+ text: "",
137
+ };
138
+ }
139
+
140
+ try {
141
+ const base = diffBase(run, cwd);
142
+ const nameStatus = run(["diff", "--name-status", base], cwd).trim();
143
+ const shortstat = run(["diff", "--shortstat", base], cwd).trim();
144
+ const untracked = run(
145
+ ["ls-files", "--others", "--exclude-standard"],
146
+ cwd
147
+ ).trim();
148
+
149
+ if (!(nameStatus || untracked)) {
150
+ return { level, status: "no-changes", text: "" };
151
+ }
152
+
153
+ const sections = [
154
+ "Working-tree changes against the last commit (staged and unstaged).",
155
+ nameStatus ? `Changed files:\n${nameStatus}` : "",
156
+ shortstat ? `Totals: ${shortstat}` : "",
157
+ untracked
158
+ ? `Untracked files (names only, contents withheld):\n${untracked}`
159
+ : "",
160
+ ];
161
+
162
+ if (level === "full") {
163
+ const patch = run(["diff", base], cwd);
164
+ sections.push(
165
+ patch.trim()
166
+ ? `Patch:\n${patch}`
167
+ : "Patch: (no tracked-file content changes)"
168
+ );
169
+ } else {
170
+ sections.push(
171
+ "Full patch withheld by configuration; file contents were not disclosed."
172
+ );
173
+ }
174
+
175
+ // Redaction runs before the caller caps the region, so a limit can never
176
+ // split a secret and leave a readable fragment behind.
177
+ return {
178
+ level,
179
+ status: "collected",
180
+ text: redact(sections.filter(Boolean).join("\n\n")),
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ detail: error instanceof Error ? error.message : String(error),
185
+ level,
186
+ status: "failed",
187
+ text: "",
188
+ };
189
+ }
190
+ };
@@ -0,0 +1,85 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
7
+ export type OutcomeValidation = "passed" | "failed" | "not-run" | "unknown";
8
+ export type OutcomeTrigger =
9
+ | "manual"
10
+ | "executor-requested"
11
+ | "repeated-tool-call";
12
+ export const ADOPTIONS: OutcomeAdoption[] = [
13
+ "followed",
14
+ "not-followed",
15
+ "unknown",
16
+ ];
17
+ export const VALIDATIONS: OutcomeValidation[] = [
18
+ "passed",
19
+ "failed",
20
+ "not-run",
21
+ "unknown",
22
+ ];
23
+ export interface OutcomeRecord {
24
+ adoption: OutcomeAdoption;
25
+ adviceHash: string;
26
+ timestamp: string;
27
+ trigger: OutcomeTrigger;
28
+ v: 1;
29
+ validationStatus: OutcomeValidation;
30
+ }
31
+ const MAX_LOG_BYTES = 1024 * 1024;
32
+ const statePath = () => join(getAgentDir(), "advisor-outcomes-salt");
33
+ export const outcomeLogPath = () =>
34
+ join(getAgentDir(), "advisor-outcomes.jsonl");
35
+
36
+ const salt = async () => {
37
+ const path = statePath();
38
+ try {
39
+ return await readFile(path);
40
+ } catch {
41
+ await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
42
+ const value = randomBytes(32);
43
+ const temporary = `${path}.${process.pid}.${Date.now()}`;
44
+ await writeFile(temporary, value, { mode: 0o600 });
45
+ await rename(temporary, path);
46
+ await chmod(path, 0o600);
47
+ return value;
48
+ }
49
+ };
50
+ export const adviceDigest = (advice: string, key: Buffer) =>
51
+ createHmac("sha256", key).update(advice).digest("hex").slice(0, 16);
52
+
53
+ /** Best-effort, global-only, minimal persistent telemetry. */
54
+ export const appendOutcome = async (
55
+ record: Omit<OutcomeRecord, "adviceHash" | "timestamp" | "v"> & {
56
+ advice: string;
57
+ }
58
+ ) => {
59
+ const path = outcomeLogPath();
60
+ await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
61
+ let previous = "";
62
+ try {
63
+ previous = await readFile(path, "utf8");
64
+ } catch {
65
+ /* new log */
66
+ }
67
+ const next: OutcomeRecord = {
68
+ adoption: record.adoption,
69
+ adviceHash: adviceDigest(record.advice, await salt()),
70
+ timestamp: new Date().toISOString(),
71
+ trigger: record.trigger,
72
+ v: 1,
73
+ validationStatus: record.validationStatus,
74
+ };
75
+ const line = `${JSON.stringify(next)}\n`;
76
+ const retained =
77
+ Buffer.byteLength(previous) + Buffer.byteLength(line) > MAX_LOG_BYTES
78
+ ? ""
79
+ : previous;
80
+ const temporary = `${path}.${process.pid}.${Date.now()}`;
81
+ await writeFile(temporary, retained + line, { mode: 0o600 });
82
+ await rename(temporary, path);
83
+ await chmod(path, 0o600);
84
+ return next;
85
+ };
@@ -0,0 +1,50 @@
1
+ import { lstat, open, realpath } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { redactAndCapText } from "./conversation.js";
5
+
6
+ export const PREFERENCES_MAX_BYTES = 8 * 1024;
7
+ export interface TextAttachment {
8
+ bytes: number;
9
+ text: string;
10
+ }
11
+
12
+ const inside = (root: string, candidate: string) => {
13
+ const path = relative(root, candidate);
14
+ return path === "" || !(path.startsWith("..") || path.includes("../"));
15
+ };
16
+
17
+ /** Reads trusted project preferences without following a file or root escape. */
18
+ export const readProjectPreferences = async (
19
+ ctx: ExtensionContext,
20
+ maxBytes = PREFERENCES_MAX_BYTES,
21
+ redact = true
22
+ ): Promise<TextAttachment | undefined> => {
23
+ if (!ctx.isProjectTrusted()) {
24
+ return;
25
+ }
26
+ try {
27
+ const root = await realpath(ctx.cwd);
28
+ const candidate = join(ctx.cwd, ".pi", "advisor-preferences.md");
29
+ const stats = await lstat(candidate);
30
+ if (stats.isSymbolicLink() || !stats.isFile()) {
31
+ return;
32
+ }
33
+ const resolved = await realpath(candidate);
34
+ if (!inside(root, resolved)) {
35
+ return;
36
+ }
37
+ const file = await open(resolved, "r");
38
+ try {
39
+ const buffer = Buffer.alloc(maxBytes + 1);
40
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
41
+ const source = buffer.subarray(0, bytesRead).toString("utf8");
42
+ const capped = redactAndCapText(source, maxBytes, redact);
43
+ return { bytes: Buffer.byteLength(capped, "utf8"), text: capped };
44
+ } finally {
45
+ await file.close();
46
+ }
47
+ } catch {
48
+ // Missing, unreadable, or unsafe preferences are intentionally withheld.
49
+ }
50
+ };
@@ -112,6 +112,13 @@ export class AdvisorSessionState {
112
112
  #invocations: AdvisorInvocationRecord[] = [];
113
113
  #loopInterventions = 0;
114
114
  #consumedCalls = 0;
115
+ readonly #issuedAdvice = new Map<
116
+ string,
117
+ { advice: string; trigger: ConsultationTrigger }
118
+ >();
119
+ readonly #reportedAdvice = new Set<string>();
120
+ #draftConsultations = 0;
121
+ #outcomes = 0;
115
122
 
116
123
  resetTask() {
117
124
  this.#previousSignature = undefined;
@@ -120,6 +127,10 @@ export class AdvisorSessionState {
120
127
  this.#invocations = [];
121
128
  this.#loopInterventions = 0;
122
129
  this.#consumedCalls = 0;
130
+ this.#issuedAdvice.clear();
131
+ this.#reportedAdvice.clear();
132
+ this.#draftConsultations = 0;
133
+ this.#outcomes = 0;
123
134
  }
124
135
 
125
136
  clearBlocked() {
@@ -172,6 +183,29 @@ export class AdvisorSessionState {
172
183
  recordInvocation(record: AdvisorInvocationRecord) {
173
184
  this.#invocations.push(record);
174
185
  }
186
+ issueAdvice(
187
+ id: string,
188
+ advice: string,
189
+ trigger: ConsultationTrigger,
190
+ draft = false
191
+ ) {
192
+ this.#issuedAdvice.set(id, { advice, trigger });
193
+ if (draft) {
194
+ this.#draftConsultations += 1;
195
+ }
196
+ }
197
+ claimAdvice(id: string) {
198
+ if (this.#reportedAdvice.has(id)) {
199
+ return;
200
+ }
201
+ const advice = this.#issuedAdvice.get(id);
202
+ if (!advice) {
203
+ return;
204
+ }
205
+ this.#reportedAdvice.add(id);
206
+ this.#outcomes += 1;
207
+ return advice;
208
+ }
175
209
 
176
210
  summary(limit: number | undefined) {
177
211
  if (this.#invocations.length === 0 && this.#loopInterventions === 0) {
@@ -215,7 +249,8 @@ export class AdvisorSessionState {
215
249
  `Triggers: ${["manual", "executor-requested", "repeated-tool-call", "completion-review", "custom-rule"].filter((trigger) => countTrigger(trigger as AdvisorTrigger) > 0).join(", ") || "none"}`,
216
250
  `Models: ${models}`,
217
251
  `Budget: ${budget}`,
218
- `Markdown advice: ${markdown.length} responses`,
252
+ `Markdown advice: ${markdown.length} responses (${this.#draftConsultations} with drafts)`,
253
+ `Outcome reports: ${this.#outcomes}`,
219
254
  `Gate decisions: ${decisions}`,
220
255
  `Loop matching: normalized tool signatures; ${this.#loopInterventions} gate intervention${this.#loopInterventions === 1 ? "" : "s"}`,
221
256
  `Execution effects: ${effects("tool-blocked")} tool blocked, ${effects("session-blocked")} sessions blocked, ${effects("continued")} continued`,