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/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
- this.contextIndex = Math.max(
311
- 0,
312
- options.presets.findIndex(
313
- (preset) => preset.value === this.settings.contextMaxChars
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 values = [undefined, 0, 1, 2, 3, 5, 10, 25, 50];
812
- const index = Math.max(
813
- 0,
814
- values.indexOf(this.settings.maxCallsPerSession)
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
- const values = [0, 5000, 10_000, 20_000, 50_000, 100_000];
869
- const index = Math.max(
870
- 0,
871
- values.indexOf(this.settings.gitContextMaxChars ?? 20_000)
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
- const values = [0, 500, 1000, 2000, 5000, 10_000];
879
- const index = Math.max(
880
- 0,
881
- values.indexOf(this.settings.toolResultMaxLines ?? 2000)
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
- const values = [0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024];
889
- const index = Math.max(
890
- 0,
891
- values.indexOf(this.settings.toolResultMaxBytes ?? 50 * 1024)
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 UNTRACKED_FILE_MAX_BYTES = 8 * 1024;
7
- export const UNTRACKED_TOTAL_MAX_BYTES = 24 * 1024;
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 = execFileSync(
37
- "git",
38
- ["ls-files", "--others", "--exclude-standard", "-z", "--", path],
39
- {
40
- cwd,
41
- encoding: "utf8",
42
- maxBuffer: 16 * 1024 * 1024,
43
- shell: false,
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
- /** Returns only exact, permitted untracked regular-file bodies. Refusals are silent. */
57
- export const readUntrackedFiles = async (
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 realpath(cwd).catch(() => undefined);
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
- const absolute = resolve(root, normalizedName);
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 stats = await lstat(absolute);
87
- if (stats.isSymbolicLink() || !stats.isFile()) {
88
- continue;
89
- }
90
- const resolved = await realpath(absolute);
91
- if (!within(root, resolved)) {
92
- continue;
93
- }
94
- const file = await open(resolved, "r");
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);