pi-advisor-flow 0.5.4 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Advanced Executor/Advisor flow for Pi, fully configurable and extendable.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -18,9 +18,10 @@
18
18
  "license": "MIT",
19
19
  "author": "Philip Brembeck",
20
20
  "type": "module",
21
- "main": "extensions/index.ts",
21
+ "main": "dist/index.js",
22
22
  "types": "extensions/index.ts",
23
23
  "files": [
24
+ "dist",
24
25
  "extensions",
25
26
  "src",
26
27
  "CHANGELOG.md",
@@ -32,15 +33,18 @@
32
33
  "bench:evaluate": "bun bench/src/cli.ts evaluate",
33
34
  "bench:replay": "bun bench/src/cli.ts replay",
34
35
  "bench:screen": "bun bench/src/cli.ts screen",
36
+ "build": "bun build ./extensions/index.ts --outfile dist/index.js --target node --format esm --external @earendil-works/pi-ai --external @earendil-works/pi-coding-agent --external @earendil-works/pi-tui --external typebox",
35
37
  "check:boundaries": "node scripts/check-module-boundaries.mjs",
36
38
  "format": "bunx ultracite fix --linter-enabled=false",
37
39
  "lint": "bunx ultracite check",
38
40
  "lint:fix": "bunx ultracite fix",
41
+ "prepack": "bun run build >/dev/null",
39
42
  "package:check": "node scripts/check-package.mjs",
40
43
  "prepare": "husky",
41
44
  "socket": "bunx socket optimize",
42
- "test": "bun test",
45
+ "test": "bun test && bun run test:node-loader",
43
46
  "test:bench": "bun --config=./bunfig.bench.toml test ./bench/test/",
47
+ "test:node-loader": "node --test test/default-resource-loader.node.mjs",
44
48
  "typecheck": "tsc --noEmit"
45
49
  },
46
50
  "lint-staged": {
@@ -58,9 +62,9 @@
58
62
  },
59
63
  "devDependencies": {
60
64
  "@biomejs/biome": "2.5.12",
61
- "@earendil-works/pi-ai": "^0.84.4",
62
- "@earendil-works/pi-coding-agent": "^0.84.4",
63
- "@earendil-works/pi-tui": "^0.84.4",
65
+ "@earendil-works/pi-ai": "^0.85.1",
66
+ "@earendil-works/pi-coding-agent": "^0.85.1",
67
+ "@earendil-works/pi-tui": "^0.85.1",
64
68
  "@types/node": "^26.4.1",
65
69
  "bun-types": "1.4.1",
66
70
  "husky": "^9.1.7",
@@ -70,9 +74,9 @@
70
74
  "ultracite": "7.10.8"
71
75
  },
72
76
  "peerDependencies": {
73
- "@earendil-works/pi-ai": "^0.84.1",
74
- "@earendil-works/pi-coding-agent": "^0.84.1",
75
- "@earendil-works/pi-tui": "^0.84.1",
77
+ "@earendil-works/pi-ai": "^0.84.1 || ^0.85.1",
78
+ "@earendil-works/pi-coding-agent": "^0.84.1 || ^0.85.1",
79
+ "@earendil-works/pi-tui": "^0.84.1 || ^0.85.1",
76
80
  "typebox": "^1.1.38"
77
81
  },
78
82
  "peerDependenciesMeta": {
@@ -91,7 +95,7 @@
91
95
  },
92
96
  "pi": {
93
97
  "extensions": [
94
- "./extensions/index.ts"
98
+ "./dist/index.js"
95
99
  ],
96
100
  "image": "https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/hero.png"
97
101
  }
@@ -53,6 +53,125 @@ export const configPaths = (ctx: ExtensionContext) => [
53
53
  const configuredModelRef = (value: string | undefined): string | undefined =>
54
54
  value?.trim() || undefined;
55
55
 
56
+ const SAVED_CONFIG_KEYS = [
57
+ "advisor",
58
+ "advisorAutoLoopGate",
59
+ "advisorBlockOnBlocked",
60
+ "advisorCollapseResponses",
61
+ "advisorCompletionGate",
62
+ "advisorCustomInvocation",
63
+ "advisorEffort",
64
+ "advisorFailureGate",
65
+ "advisorGitContext",
66
+ "advisorGitContextMaxChars",
67
+ "advisorHerdrIntegration",
68
+ "advisorLoopThreshold",
69
+ "advisorMaxCallsPerSession",
70
+ "advisorPlanGate",
71
+ "advisorRedactSecrets",
72
+ "advisorScoutEnabled",
73
+ "advisorSessionSummary",
74
+ "advisorToolPolicies",
75
+ "advisorToolResultMaxBytes",
76
+ "advisorToolResultMaxLines",
77
+ "advisorTrackedFileContent",
78
+ "advisorUntrackedContent",
79
+ "alwaysOn",
80
+ "contextMaxChars",
81
+ "executor",
82
+ "executorEffort",
83
+ "gateFailureMode",
84
+ "showUsageDetails",
85
+ "showUsageFooter",
86
+ "simpleMode",
87
+ ] as const satisfies readonly (keyof AdvisorConfig)[];
88
+ type SavedConfigKey = (typeof SAVED_CONFIG_KEYS)[number];
89
+ type ConfigState = {
90
+ [Key in SavedConfigKey]: AdvisorConfig[Key];
91
+ };
92
+
93
+ const currentConfigState = (): ConfigState => ({
94
+ advisor: configuredModelRef(advisorRef),
95
+ advisorAutoLoopGate: advisorAutoLoopGateRef,
96
+ advisorBlockOnBlocked: advisorBlockOnBlockedRef,
97
+ advisorCollapseResponses: advisorCollapseResponsesRef,
98
+ advisorCompletionGate: advisorCompletionGateRef,
99
+ advisorCustomInvocation: advisorCustomInvocationRef,
100
+ advisorEffort: advisorEffortRef,
101
+ advisorFailureGate: advisorFailureGateRef,
102
+ advisorGitContext: advisorGitContextRef,
103
+ advisorGitContextMaxChars: advisorGitContextMaxCharsRef,
104
+ advisorHerdrIntegration: advisorHerdrIntegrationRef,
105
+ advisorLoopThreshold: advisorLoopThresholdRef,
106
+ advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef,
107
+ advisorPlanGate: advisorPlanGateRef,
108
+ advisorRedactSecrets: advisorRedactSecretsRef,
109
+ advisorScoutEnabled: advisorScoutEnabledRef,
110
+ advisorSessionSummary: advisorSessionSummaryRef,
111
+ advisorToolPolicies: { ...advisorToolPoliciesRef },
112
+ advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
113
+ advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
114
+ advisorTrackedFileContent: advisorTrackedFileContentRef,
115
+ advisorUntrackedContent: advisorUntrackedContentRef,
116
+ alwaysOn: alwaysOnRef,
117
+ contextMaxChars: contextMaxCharsRef,
118
+ executor: configuredModelRef(executorRef),
119
+ executorEffort: executorEffortRef,
120
+ gateFailureMode: advisorFailureModeRef,
121
+ showUsageDetails: showUsageDetailsRef,
122
+ showUsageFooter: showUsageFooterRef,
123
+ simpleMode: simpleModeRef,
124
+ });
125
+
126
+ const sameConfigValue = <Value>(left: Value, right: Value) =>
127
+ JSON.stringify(left) === JSON.stringify(right);
128
+
129
+ const readExistingConfig = (path: string): Record<string, unknown> => {
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
132
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
133
+ ? (parsed as Record<string, unknown>)
134
+ : {};
135
+ } catch {
136
+ return {};
137
+ }
138
+ };
139
+
140
+ const shouldPersistConfigKey = (
141
+ key: SavedConfigKey,
142
+ persistAdvisor: boolean,
143
+ persistExecutor: boolean
144
+ ) =>
145
+ (key !== "advisor" || persistAdvisor) &&
146
+ (key !== "executor" || persistExecutor);
147
+
148
+ const applyChangedConfigValues = (
149
+ data: Record<string, unknown>,
150
+ current: ConfigState,
151
+ changedKeys: readonly SavedConfigKey[],
152
+ persistAdvisor: boolean,
153
+ persistExecutor: boolean
154
+ ) => {
155
+ for (const key of changedKeys) {
156
+ if (!shouldPersistConfigKey(key, persistAdvisor, persistExecutor)) {
157
+ continue;
158
+ }
159
+ const value = current[key];
160
+ const isEmptyModelRef = (key === "advisor" || key === "executor") && !value;
161
+ if (value === undefined || isEmptyModelRef) {
162
+ delete data[key];
163
+ } else {
164
+ data[key] = value;
165
+ }
166
+ }
167
+ };
168
+
169
+ // Tracks the runtime state represented by the last successful load/save. A
170
+ // save can then merge fresh disk contents without treating untouched in-memory
171
+ // values as an instruction to overwrite an external edit.
172
+ let loadedConfigState: ConfigState | undefined;
173
+ let loadedConfigPath: string | undefined;
174
+
56
175
  const readConfig = (path: string): AdvisorConfig => {
57
176
  try {
58
177
  const config = JSON.parse(readFileSync(path, "utf8"));
@@ -135,10 +254,17 @@ export const loadConfig = (_ctx: ExtensionContext) => {
135
254
  // prompts, gates, budgets, disclosure, redaction, integrations, and consent
136
255
  // remain under the user's global Pi configuration.
137
256
  setAdvisorOutcomeLoggingRef(globalConfig?.advisorOutcomeLogging === true);
257
+ loadedConfigState = currentConfigState();
258
+ loadedConfigPath = global;
138
259
  return existsSync(global) ? global : null;
139
260
  };
140
261
 
141
- /** Saves user-controlled configuration globally without outcome consent. */
262
+ /**
263
+ * Saves user-controlled configuration globally without outcome consent.
264
+ *
265
+ * Only values changed since the last load/save are written. This lets an
266
+ * external edit to advisor.json coexist with a later settings or model save.
267
+ */
142
268
  export interface SaveConfigOptions {
143
269
  persistAdvisor?: boolean;
144
270
  persistExecutor?: boolean;
@@ -151,70 +277,51 @@ export const saveConfig = (
151
277
  const path = join(getAgentDir(), "advisor.json");
152
278
  const persistAdvisor = options.persistAdvisor ?? true;
153
279
  const persistExecutor = options.persistExecutor ?? true;
154
- let existing: Record<string, unknown> = {};
155
- try {
156
- const parsed = JSON.parse(readFileSync(path, "utf8"));
157
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
158
- existing = parsed as Record<string, unknown>;
159
- }
160
- } catch {
161
- /* replace a missing or malformed file */
162
- }
163
- if (advisorMaxCallsPerSessionRef === undefined) {
164
- existing.advisorMaxCallsPerSession = undefined;
280
+ const existing = readExistingConfig(path);
281
+ const current = currentConfigState();
282
+ const baseline = loadedConfigPath === path ? loadedConfigState : undefined;
283
+ const changedKeys = baseline
284
+ ? SAVED_CONFIG_KEYS.filter(
285
+ (key) => !sameConfigValue(current[key], baseline[key])
286
+ )
287
+ : [...SAVED_CONFIG_KEYS];
288
+ if (changedKeys.length === 0) {
289
+ return path;
165
290
  }
166
- const data = {
167
- ...existing,
168
- ...(persistAdvisor && advisorRef ? { advisor: advisorRef } : {}),
169
- advisorAutoLoopGate: advisorAutoLoopGateRef,
170
- advisorBlockOnBlocked: advisorBlockOnBlockedRef,
171
- advisorCollapseResponses: advisorCollapseResponsesRef,
172
- advisorCompletionGate: advisorCompletionGateRef,
173
- advisorCustomInvocation: advisorCustomInvocationRef,
174
- advisorEffort: advisorEffortRef,
175
- advisorFailureGate: advisorFailureGateRef,
176
- advisorLoopThreshold: advisorLoopThresholdRef,
177
- advisorPlanGate: advisorPlanGateRef,
178
- contextMaxChars: contextMaxCharsRef,
179
- ...(persistExecutor && executorRef ? { executor: executorRef } : {}),
180
- executorEffort: executorEffortRef,
181
- ...(advisorMaxCallsPerSessionRef === undefined
182
- ? {}
183
- : { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
184
- advisorGitContext: advisorGitContextRef,
185
- advisorGitContextMaxChars: advisorGitContextMaxCharsRef,
186
- advisorHerdrIntegration: advisorHerdrIntegrationRef,
187
- advisorRedactSecrets: advisorRedactSecretsRef,
188
- advisorScoutEnabled: advisorScoutEnabledRef,
189
- advisorSessionSummary: advisorSessionSummaryRef,
190
- advisorToolPolicies: advisorToolPoliciesRef,
191
- advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
192
- advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
193
- advisorTrackedFileContent: advisorTrackedFileContentRef,
194
- advisorUntrackedContent: advisorUntrackedContentRef,
195
- alwaysOn: alwaysOnRef,
196
- gateFailureMode: advisorFailureModeRef,
197
- showUsageDetails: showUsageDetailsRef,
198
- showUsageFooter: showUsageFooterRef,
199
- simpleMode: simpleModeRef,
200
- };
291
+ const data = { ...existing };
292
+ applyChangedConfigValues(
293
+ data,
294
+ current,
295
+ changedKeys,
296
+ persistAdvisor,
297
+ persistExecutor
298
+ );
201
299
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
202
300
  resetConfigCache();
301
+ const nextLoadedState = { ...current };
302
+ if (!persistAdvisor) {
303
+ nextLoadedState.advisor = baseline
304
+ ? baseline.advisor
305
+ : configuredModelRef(
306
+ typeof existing.advisor === "string" ? existing.advisor : undefined
307
+ );
308
+ }
309
+ if (!persistExecutor) {
310
+ nextLoadedState.executor = baseline
311
+ ? baseline.executor
312
+ : configuredModelRef(
313
+ typeof existing.executor === "string" ? existing.executor : undefined
314
+ );
315
+ }
316
+ loadedConfigState = nextLoadedState;
317
+ loadedConfigPath = path;
203
318
  return path;
204
319
  };
205
320
 
206
321
  /** Outcome logging is deliberately written only to the global Pi configuration. */
207
322
  export const saveGlobalOutcomeLogging = (enabled: boolean) => {
208
323
  const path = join(getAgentDir(), "advisor.json");
209
- let existing: Record<string, unknown> = {};
210
- try {
211
- const parsed = JSON.parse(readFileSync(path, "utf8"));
212
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
213
- existing = parsed as Record<string, unknown>;
214
- }
215
- } catch {
216
- /* create it */
217
- }
324
+ const existing = readExistingConfig(path);
218
325
  writeFileSync(
219
326
  path,
220
327
  `${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}\n`
@@ -135,8 +135,15 @@ export const renderManualAdvisorDialog = (
135
135
  const cancelLabel = cancel
136
136
  ? theme.fg("accent", "[Cancel]")
137
137
  : theme.fg("text", "[Cancel]");
138
+ const actionButtons = `${submitLabel} ${cancelLabel}`;
139
+ // Center the buttons in the content area; the 2-column focus marker sits
140
+ // left of the computed offset.
141
+ const buttonOffset = Math.max(
142
+ 0,
143
+ Math.floor((contentWidth - visibleWidth(actionButtons)) / 2) - 2
144
+ );
138
145
  addLine(
139
- `${focusMarker("actions")} ${submitLabel} ${cancelLabel}`
146
+ `${focusMarker("actions")}${" ".repeat(buttonOffset)}${actionButtons}`
140
147
  );
141
148
  addWrapped(interactionHint, "dim");
142
149
  addLine("");
@@ -6,6 +6,7 @@ import {
6
6
  Input,
7
7
  type Keybindings,
8
8
  type KeybindingsManager,
9
+ truncateToWidth,
9
10
  } from "@earendil-works/pi-tui";
10
11
  import type { SearchableModelSelectorOptions } from "./types.js";
11
12
 
@@ -58,9 +59,9 @@ export class SearchableModelSelector implements Component, Focusable {
58
59
  }
59
60
 
60
61
  render(width: number): string[] {
61
- const lines: string[] = ["═".repeat(width)];
62
+ const lines: string[] = [this.theme.fg("border", "─".repeat(width))];
62
63
  lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
63
- const inputLines = this.searchInput.render(width - 12);
64
+ const inputLines = this.searchInput.render(Math.max(1, width - 10));
64
65
  lines.push(
65
66
  ` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`
66
67
  );
@@ -108,10 +109,10 @@ export class SearchableModelSelector implements Component, Focusable {
108
109
  }
109
110
  lines.push("");
110
111
  lines.push(
111
- ` ${this.theme.fg("muted", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`
112
+ ` ${this.theme.fg("dim", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`
112
113
  );
113
- lines.push("═".repeat(width));
114
- return lines;
114
+ lines.push(this.theme.fg("border", "─".repeat(width)));
115
+ return lines.map((line) => truncateToWidth(line, width));
115
116
  }
116
117
 
117
118
  handleInput(keyData: string): void {
@@ -61,6 +61,11 @@ export class TextSettingSubmenu implements Component, Focusable {
61
61
  }
62
62
 
63
63
  handleInput(keyData: string): void {
64
+ const before = this.input.getValue();
64
65
  this.input.handleInput(keyData);
66
+ // A failed submit leaves the error visible; the first value change clears it.
67
+ if (this.error && this.input.getValue() !== before) {
68
+ this.error = undefined;
69
+ }
65
70
  }
66
71
  }