pi-advisor-flow 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +43 -170
- package/extensions/index.ts +4 -2
- package/package.json +3 -2
- package/src/commands.ts +49 -11
- package/src/config.ts +32 -9
- package/src/conversation.ts +25 -12
- package/src/git.ts +5 -1
- package/src/outcomes.ts +93 -23
- package/src/session-state.ts +22 -0
- package/src/tools.ts +91 -22
- package/src/ui.ts +62 -29
- package/src/untracked.ts +138 -50
package/src/ui.ts
CHANGED
|
@@ -24,6 +24,18 @@ interface SearchableModelSelectorOptions {
|
|
|
24
24
|
title: string;
|
|
25
25
|
tui: RenderRequester;
|
|
26
26
|
}
|
|
27
|
+
const stepNumericPreset = (
|
|
28
|
+
current: number,
|
|
29
|
+
presets: number[],
|
|
30
|
+
direction: number
|
|
31
|
+
) => {
|
|
32
|
+
const values = presets.includes(current)
|
|
33
|
+
? presets
|
|
34
|
+
: [...presets, current].sort((a, b) => a - b);
|
|
35
|
+
const index = values.indexOf(current);
|
|
36
|
+
return values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
37
|
+
};
|
|
38
|
+
|
|
27
39
|
interface AdvisorSettingsSelectorOptions {
|
|
28
40
|
effortLevels: string[];
|
|
29
41
|
initial: AdvisorSettings;
|
|
@@ -208,6 +220,7 @@ export interface AdvisorSettings {
|
|
|
208
220
|
toolPolicies?: Record<string, "full" | "summary" | "exclude">;
|
|
209
221
|
toolResultMaxBytes?: number;
|
|
210
222
|
toolResultMaxLines?: number;
|
|
223
|
+
trackedFileContent?: boolean;
|
|
211
224
|
untrackedContent?: boolean;
|
|
212
225
|
}
|
|
213
226
|
|
|
@@ -235,6 +248,7 @@ type AdvisorSettingsRow =
|
|
|
235
248
|
| "gitContextMaxChars"
|
|
236
249
|
| "toolPolicies"
|
|
237
250
|
| "outcomeLogging"
|
|
251
|
+
| "trackedFileContent"
|
|
238
252
|
| "untrackedContent"
|
|
239
253
|
| "save";
|
|
240
254
|
|
|
@@ -259,6 +273,7 @@ const ADVANCED_ROWS: AdvisorSettingsRow[] = [
|
|
|
259
273
|
"gitContext",
|
|
260
274
|
"gitContextMaxChars",
|
|
261
275
|
"toolPolicies",
|
|
276
|
+
"trackedFileContent",
|
|
262
277
|
"untrackedContent",
|
|
263
278
|
"outcomeLogging",
|
|
264
279
|
];
|
|
@@ -304,14 +319,24 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
304
319
|
this.editingCustom = false;
|
|
305
320
|
this.editingPolicies = false;
|
|
306
321
|
this.policiesError = undefined;
|
|
307
|
-
this.options = options;
|
|
308
322
|
// Retain every advanced value even when Simple mode hides its controls.
|
|
309
323
|
this.settings = { ...options.initial };
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
324
|
+
const configuredContext = this.settings.contextMaxChars;
|
|
325
|
+
const presets = options.presets.some(
|
|
326
|
+
(preset) => preset.value === configuredContext
|
|
327
|
+
)
|
|
328
|
+
? [...options.presets]
|
|
329
|
+
: [
|
|
330
|
+
...options.presets,
|
|
331
|
+
{
|
|
332
|
+
description: "Custom configured value",
|
|
333
|
+
label: String(configuredContext),
|
|
334
|
+
value: configuredContext,
|
|
335
|
+
},
|
|
336
|
+
].sort((a, b) => a.value - b.value);
|
|
337
|
+
this.options = { ...options, presets };
|
|
338
|
+
this.contextIndex = presets.findIndex(
|
|
339
|
+
(preset) => preset.value === configuredContext
|
|
315
340
|
);
|
|
316
341
|
this.effortIndex = Math.max(
|
|
317
342
|
0,
|
|
@@ -519,6 +544,11 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
519
544
|
: "All tools: full",
|
|
520
545
|
"toolPolicies"
|
|
521
546
|
),
|
|
547
|
+
this.row(
|
|
548
|
+
"Tracked file content",
|
|
549
|
+
onOff(this.settings.trackedFileContent ?? false),
|
|
550
|
+
"trackedFileContent"
|
|
551
|
+
),
|
|
522
552
|
this.row(
|
|
523
553
|
"Untracked file content",
|
|
524
554
|
onOff(this.settings.untrackedContent ?? false),
|
|
@@ -746,6 +776,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
746
776
|
tui.requestRender();
|
|
747
777
|
}
|
|
748
778
|
|
|
779
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one keyboard dispatcher maps each settings row to its bounded adjustment.
|
|
749
780
|
private adjust(direction: number): void {
|
|
750
781
|
switch (this.selectedRowId()) {
|
|
751
782
|
case "simpleMode":
|
|
@@ -808,11 +839,14 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
808
839
|
);
|
|
809
840
|
break;
|
|
810
841
|
case "maxCallsPerSession": {
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
842
|
+
const current = this.settings.maxCallsPerSession;
|
|
843
|
+
const numeric = [0, 1, 2, 3, 5, 10, 25, 50];
|
|
844
|
+
const sorted =
|
|
845
|
+
current === undefined || numeric.includes(current)
|
|
846
|
+
? numeric
|
|
847
|
+
: [...numeric, current].sort((a, b) => a - b);
|
|
848
|
+
const values: (number | undefined)[] = [undefined, ...sorted];
|
|
849
|
+
const index = values.indexOf(current);
|
|
816
850
|
this.settings.maxCallsPerSession =
|
|
817
851
|
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
818
852
|
break;
|
|
@@ -842,6 +876,11 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
842
876
|
case "redactSecrets":
|
|
843
877
|
this.settings.redactSecrets = !(this.settings.redactSecrets ?? false);
|
|
844
878
|
break;
|
|
879
|
+
case "trackedFileContent":
|
|
880
|
+
this.settings.trackedFileContent = !(
|
|
881
|
+
this.settings.trackedFileContent ?? false
|
|
882
|
+
);
|
|
883
|
+
break;
|
|
845
884
|
case "untrackedContent":
|
|
846
885
|
this.settings.untrackedContent = !(
|
|
847
886
|
this.settings.untrackedContent ?? false
|
|
@@ -865,33 +904,27 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
865
904
|
break;
|
|
866
905
|
}
|
|
867
906
|
case "gitContextMaxChars": {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
0,
|
|
871
|
-
|
|
907
|
+
this.settings.gitContextMaxChars = stepNumericPreset(
|
|
908
|
+
this.settings.gitContextMaxChars ?? 20_000,
|
|
909
|
+
[0, 5000, 10_000, 20_000, 50_000, 100_000],
|
|
910
|
+
direction
|
|
872
911
|
);
|
|
873
|
-
this.settings.gitContextMaxChars =
|
|
874
|
-
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
875
912
|
break;
|
|
876
913
|
}
|
|
877
914
|
case "toolResultMaxLines": {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
0,
|
|
881
|
-
|
|
915
|
+
this.settings.toolResultMaxLines = stepNumericPreset(
|
|
916
|
+
this.settings.toolResultMaxLines ?? 2000,
|
|
917
|
+
[0, 500, 1000, 2000, 5000, 10_000],
|
|
918
|
+
direction
|
|
882
919
|
);
|
|
883
|
-
this.settings.toolResultMaxLines =
|
|
884
|
-
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
885
920
|
break;
|
|
886
921
|
}
|
|
887
922
|
case "toolResultMaxBytes": {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
0,
|
|
891
|
-
|
|
923
|
+
this.settings.toolResultMaxBytes = stepNumericPreset(
|
|
924
|
+
this.settings.toolResultMaxBytes ?? 50 * 1024,
|
|
925
|
+
[0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024],
|
|
926
|
+
direction
|
|
892
927
|
);
|
|
893
|
-
this.settings.toolResultMaxBytes =
|
|
894
|
-
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
895
928
|
break;
|
|
896
929
|
}
|
|
897
930
|
default:
|
package/src/untracked.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
2
3
|
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
4
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
5
|
import { redactAndCapText } from "./conversation.js";
|
|
5
6
|
|
|
6
|
-
export const
|
|
7
|
-
export const
|
|
7
|
+
export const ADVISOR_FILE_MAX_BYTES = 8 * 1024;
|
|
8
|
+
export const ADVISOR_FILES_TOTAL_MAX_BYTES = 24 * 1024;
|
|
9
|
+
export const UNTRACKED_FILE_MAX_BYTES = ADVISOR_FILE_MAX_BYTES;
|
|
10
|
+
export const UNTRACKED_TOTAL_MAX_BYTES = ADVISOR_FILES_TOTAL_MAX_BYTES;
|
|
8
11
|
export interface UntrackedAttachment {
|
|
9
12
|
bytes: number;
|
|
10
13
|
path: string;
|
|
11
14
|
text: string;
|
|
12
15
|
}
|
|
16
|
+
export type TrackedAttachment = UntrackedAttachment;
|
|
13
17
|
|
|
14
18
|
const PATH_SEGMENTS = /[\\/]/;
|
|
15
19
|
|
|
@@ -32,20 +36,34 @@ const normalizeRequestedPath = (root: string, value: unknown) => {
|
|
|
32
36
|
return normalizeRelativePath(root, value);
|
|
33
37
|
};
|
|
34
38
|
|
|
39
|
+
const git = (cwd: string, args: string[]) =>
|
|
40
|
+
execFileSync("git", args, {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
44
|
+
shell: false,
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
timeout: 5000,
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const repositoryRoot = (cwd: string) => {
|
|
51
|
+
try {
|
|
52
|
+
return realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
53
|
+
} catch {
|
|
54
|
+
return Promise.resolve(undefined);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
35
58
|
const untracked = (cwd: string, path: string) => {
|
|
36
|
-
const output =
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
-
timeout: 5000,
|
|
46
|
-
windowsHide: true,
|
|
47
|
-
}
|
|
48
|
-
);
|
|
59
|
+
const output = git(cwd, [
|
|
60
|
+
"ls-files",
|
|
61
|
+
"--others",
|
|
62
|
+
"--exclude-standard",
|
|
63
|
+
"-z",
|
|
64
|
+
"--",
|
|
65
|
+
path,
|
|
66
|
+
]);
|
|
49
67
|
const expected = normalizeRelativePath(cwd, path);
|
|
50
68
|
return output
|
|
51
69
|
.split("\0")
|
|
@@ -53,17 +71,86 @@ const untracked = (cwd: string, path: string) => {
|
|
|
53
71
|
.some((entry) => normalizeRelativePath(cwd, entry) === expected);
|
|
54
72
|
};
|
|
55
73
|
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
const tracked = (cwd: string, path: string) => {
|
|
75
|
+
const output = git(cwd, ["ls-files", "--stage", "-z", "--", path]);
|
|
76
|
+
const expected = normalizeRelativePath(cwd, path);
|
|
77
|
+
return output.split("\0").some((entry) => {
|
|
78
|
+
if (!entry) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const [metadata, name] = entry.split("\t");
|
|
82
|
+
return (
|
|
83
|
+
name &&
|
|
84
|
+
normalizeRelativePath(cwd, name) === expected &&
|
|
85
|
+
!metadata.startsWith("160000 ")
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isPermitted = (
|
|
91
|
+
root: string,
|
|
92
|
+
path: string,
|
|
93
|
+
kind: "tracked" | "untracked"
|
|
94
|
+
) => (kind === "tracked" ? tracked(root, path) : untracked(root, path));
|
|
95
|
+
|
|
96
|
+
const readAttachment = async (
|
|
97
|
+
root: string,
|
|
98
|
+
normalizedName: string,
|
|
99
|
+
redact: boolean,
|
|
100
|
+
available: number
|
|
101
|
+
): Promise<UntrackedAttachment | undefined> => {
|
|
102
|
+
const absolute = resolve(root, normalizedName);
|
|
103
|
+
if (!(within(root, absolute) && available > 0)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const stats = await lstat(absolute);
|
|
107
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const resolved = await realpath(absolute);
|
|
111
|
+
if (!within(root, resolved)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const flags = constants.O_NOFOLLOW
|
|
115
|
+
? // biome-ignore lint/suspicious/noBitwiseOperators: fs.open requires numeric flags for O_NOFOLLOW.
|
|
116
|
+
constants.O_RDONLY | constants.O_NOFOLLOW
|
|
117
|
+
: constants.O_RDONLY;
|
|
118
|
+
const file = await open(resolved, flags);
|
|
119
|
+
try {
|
|
120
|
+
const openedStats = await file.stat();
|
|
121
|
+
if (!openedStats.isFile()) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const buffer = Buffer.alloc(available + 1);
|
|
125
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
126
|
+
const raw = buffer.subarray(0, bytesRead).toString("utf8");
|
|
127
|
+
if (raw.includes("\0")) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const text = redactAndCapText(raw, available, redact);
|
|
131
|
+
return {
|
|
132
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
133
|
+
path: normalizedName,
|
|
134
|
+
text,
|
|
135
|
+
};
|
|
136
|
+
} finally {
|
|
137
|
+
await file.close();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Reads exact, permitted regular-file bodies. Refusals are silent. */
|
|
142
|
+
const readFiles = async (
|
|
58
143
|
cwd: string,
|
|
59
144
|
requested: string[],
|
|
60
145
|
enabled: boolean,
|
|
61
|
-
redact: boolean
|
|
146
|
+
redact: boolean,
|
|
147
|
+
kind: "tracked" | "untracked",
|
|
148
|
+
totalLimit = ADVISOR_FILES_TOTAL_MAX_BYTES
|
|
62
149
|
): Promise<UntrackedAttachment[]> => {
|
|
63
150
|
if (!(enabled && Array.isArray(requested))) {
|
|
64
151
|
return [];
|
|
65
152
|
}
|
|
66
|
-
const root = await
|
|
153
|
+
const root = await repositoryRoot(cwd);
|
|
67
154
|
if (!root) {
|
|
68
155
|
return [];
|
|
69
156
|
}
|
|
@@ -77,41 +164,24 @@ export const readUntrackedFiles = async (
|
|
|
77
164
|
}
|
|
78
165
|
unique.add(normalizedName);
|
|
79
166
|
try {
|
|
80
|
-
|
|
81
|
-
if (!(within(root, absolute) && untracked(root, normalizedName))) {
|
|
167
|
+
if (!isPermitted(root, normalizedName, kind)) {
|
|
82
168
|
continue;
|
|
83
169
|
}
|
|
170
|
+
const available = Math.min(ADVISOR_FILE_MAX_BYTES, totalLimit - total);
|
|
171
|
+
if (available <= 0) {
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
84
174
|
// Sequentially enforce the aggregate disclosure budget.
|
|
85
175
|
// biome-ignore lint/performance/noAwaitInLoops: each accepted file consumes the remaining budget.
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const available = Math.min(
|
|
97
|
-
UNTRACKED_FILE_MAX_BYTES,
|
|
98
|
-
UNTRACKED_TOTAL_MAX_BYTES - total
|
|
99
|
-
);
|
|
100
|
-
if (available <= 0) {
|
|
101
|
-
break;
|
|
102
|
-
}
|
|
103
|
-
const buffer = Buffer.alloc(available + 1);
|
|
104
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
105
|
-
const text = redactAndCapText(
|
|
106
|
-
buffer.subarray(0, bytesRead).toString("utf8"),
|
|
107
|
-
available,
|
|
108
|
-
redact
|
|
109
|
-
);
|
|
110
|
-
const bytes = Buffer.byteLength(text, "utf8");
|
|
111
|
-
attachments.push({ bytes, path: normalizedName, text });
|
|
112
|
-
total += bytes;
|
|
113
|
-
} finally {
|
|
114
|
-
await file.close();
|
|
176
|
+
const attachment = await readAttachment(
|
|
177
|
+
root,
|
|
178
|
+
normalizedName,
|
|
179
|
+
redact,
|
|
180
|
+
available
|
|
181
|
+
);
|
|
182
|
+
if (attachment) {
|
|
183
|
+
attachments.push(attachment);
|
|
184
|
+
total += attachment.bytes;
|
|
115
185
|
}
|
|
116
186
|
} catch {
|
|
117
187
|
/* refuse unreadable or non-git paths */
|
|
@@ -119,3 +189,21 @@ export const readUntrackedFiles = async (
|
|
|
119
189
|
}
|
|
120
190
|
return attachments;
|
|
121
191
|
};
|
|
192
|
+
|
|
193
|
+
/** Reads exact tracked working-tree files only when explicitly enabled. */
|
|
194
|
+
export const readTrackedFiles = (
|
|
195
|
+
cwd: string,
|
|
196
|
+
requested: string[],
|
|
197
|
+
enabled: boolean,
|
|
198
|
+
redact: boolean,
|
|
199
|
+
totalLimit = ADVISOR_FILES_TOTAL_MAX_BYTES
|
|
200
|
+
) => readFiles(cwd, requested, enabled, redact, "tracked", totalLimit);
|
|
201
|
+
|
|
202
|
+
/** Returns exact, permitted untracked regular-file bodies. Refusals are silent. */
|
|
203
|
+
export const readUntrackedFiles = (
|
|
204
|
+
cwd: string,
|
|
205
|
+
requested: string[],
|
|
206
|
+
enabled: boolean,
|
|
207
|
+
redact: boolean,
|
|
208
|
+
totalLimit = ADVISOR_FILES_TOTAL_MAX_BYTES
|
|
209
|
+
) => readFiles(cwd, requested, enabled, redact, "untracked", totalLimit);
|