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/README.md +33 -7
- package/package.json +33 -30
- package/src/commands.ts +34 -4
- package/src/config.ts +102 -5
- package/src/conversation.ts +17 -0
- package/src/git.ts +190 -0
- package/src/outcomes.ts +85 -0
- package/src/preferences.ts +50 -0
- package/src/session-state.ts +36 -1
- package/src/tools.ts +276 -18
- package/src/ui.ts +104 -24
- package/src/untracked.ts +98 -0
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
};
|
package/src/outcomes.ts
ADDED
|
@@ -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
|
+
};
|
package/src/session-state.ts
CHANGED
|
@@ -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`,
|