pi-goala 0.2.0 → 0.3.1

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-07-26)
4
+
5
+ - Make each phase default to keeping its current model configuration.
6
+ - Separate reasoning-effort changes from provider/model changes.
7
+ - Select provider, then a filtered model, while skipping provider selection
8
+ when only one authenticated provider is available.
9
+ - Review, edit, save, or cancel the complete configuration atomically.
10
+
11
+ ## 0.3.0 (2026-07-26)
12
+
13
+ - Add interactive per-phase provider, model, and reasoning selection from Pi's
14
+ authenticated available-model registry.
15
+ - Support mixed-provider planner, executor, step-verifier, final-verifier, and
16
+ repeated-repair roles.
17
+ - Migrate version 1 single-provider configuration to the version 2 per-role
18
+ schema without retaining the obsolete top-level provider.
19
+
3
20
  ## 0.2.0 (2026-07-26)
4
21
 
5
22
  - Rename the package, repository, extension namespace, storage, environment
package/README.md CHANGED
@@ -39,7 +39,7 @@ pi install npm:pi-goala
39
39
  Pin a release for reproducible team installations:
40
40
 
41
41
  ```text
42
- pi install npm:pi-goala@0.2.0
42
+ pi install npm:pi-goala@0.3.1
43
43
  ```
44
44
 
45
45
  Pi's package command both downloads Goala and registers its extension. A plain
@@ -49,7 +49,7 @@ supported installation path.
49
49
  From GitHub or a local checkout:
50
50
 
51
51
  ```text
52
- pi install git:github.com/barryking/pi-goala@v0.2.0
52
+ pi install git:github.com/barryking/pi-goala@v0.3.1
53
53
  pi install /absolute/path/to/pi-goala
54
54
  ```
55
55
 
@@ -115,6 +115,7 @@ Useful commands:
115
115
  /memory retire <id> Exclude an obsolete episode from recall
116
116
  /memory restore <id> Restore a retired episode
117
117
  /goala-setup Choose a model preset
118
+ /goala-setup custom Review or change each role's model and reasoning
118
119
  /goala-setup status Show effective configuration
119
120
  ```
120
121
 
@@ -379,7 +380,16 @@ The recommended OpenAI Codex preset is:
379
380
 
380
381
  If those models are unavailable, Goala can use the model that was active
381
382
  when Pi started. Run `/goala-setup current` to persist that portable
382
- single-model configuration. Advanced users can configure each role in
383
+ single-model configuration. Run `/goala-setup custom` to choose an
384
+ authenticated provider/model and reasoning level separately for planning,
385
+ execution, step verification, final verification, and repeated repair. The
386
+ interactive wizard defaults to keeping each current role. It can change
387
+ reasoning alone or select a provider followed by one of that provider's
388
+ available models. Provider selection is skipped when only one is authenticated.
389
+ A final summary can be edited, saved, or cancelled; nothing is written before
390
+ save.
391
+
392
+ Advanced users can edit the same per-role profiles in
383
393
  `~/.pi/agent/pi-goala/config.json`.
384
394
 
385
395
  The package does not overwrite Pi's `settings.json`, model list, skills,
@@ -23,6 +23,7 @@ own one concern:
23
23
  | `workflow.ts` | Goal-state types, normalization, and invariants |
24
24
  | `memory.ts` | Verified episodic storage and retrieval |
25
25
  | `recovery.ts` | Discovery of unfinished goals in saved Pi sessions |
26
+ | `setup.ts` | Interactive available-model selection for each lifecycle role |
26
27
  | `config.ts` | Namespaced configuration and model-role presets |
27
28
 
28
29
  Simple concern names are intentional: the directory already supplies the
@@ -122,7 +123,11 @@ planning and final verification, a faster model for implementation and routine
122
123
  step verification, and a balanced fallback after repeated repair. Independent
123
124
  step context and the final strong verifier preserve the trust boundary without
124
125
  paying the strongest-model cost at every human checkpoint. A portable
125
- current-model preset is available for other providers.
126
+ current-model preset is available for other providers. The advanced setup
127
+ defaults to retaining each role, supports reasoning-only changes, and filters
128
+ models after provider selection. It stores provider, model, and reasoning
129
+ independently for every role only after final review; mixed-provider workflows
130
+ therefore use the same phase-routing path as the bundled preset.
126
131
 
127
132
  If a configured model cannot be resolved and current-model fallback is
128
133
  enabled, Goala uses the model that was active when the extension session
@@ -46,35 +46,46 @@ and restore the backup to `~/.pi/agent/`.
46
46
  /goala-setup status
47
47
  /goala-setup openai
48
48
  /goala-setup current
49
+ /goala-setup custom
49
50
  ```
50
51
 
51
52
  `openai` selects the tested Sol/Luna/Terra model split when those models are
52
53
  available. `current` assigns the model active at Pi startup to every role.
54
+ `custom` reviews each role and defaults to keeping its current configuration.
55
+ For a change, it can adjust reasoning alone or select a provider followed by
56
+ one of that provider's authenticated, available models. Provider selection is
57
+ skipped when only one provider is available. The final summary can be edited,
58
+ saved, or cancelled. Cancelling any prompt leaves the existing configuration
59
+ unchanged.
53
60
 
54
61
  ## Full schema
55
62
 
56
63
  ```json
57
64
  {
58
- "configVersion": 1,
59
- "provider": "openai-codex",
65
+ "configVersion": 2,
60
66
  "planner": {
67
+ "provider": "openai-codex",
61
68
  "model": "gpt-5.6-sol",
62
69
  "thinkingLevel": "medium"
63
70
  },
64
71
  "executor": {
72
+ "provider": "openai-codex",
65
73
  "model": "gpt-5.6-luna",
66
74
  "thinkingLevel": "medium"
67
75
  },
68
76
  "fallbackExecutor": {
77
+ "provider": "openai-codex",
69
78
  "model": "gpt-5.6-terra",
70
79
  "thinkingLevel": "medium",
71
80
  "afterRepairCycle": 2
72
81
  },
73
82
  "stepVerifier": {
83
+ "provider": "openai-codex",
74
84
  "model": "gpt-5.6-luna",
75
85
  "thinkingLevel": "medium"
76
86
  },
77
87
  "verifier": {
88
+ "provider": "openai-codex",
78
89
  "model": "gpt-5.6-sol",
79
90
  "thinkingLevel": "medium"
80
91
  },
@@ -96,6 +107,10 @@ available. `current` assigns the model active at Pi startup to every role.
96
107
 
97
108
  Restart Pi or use `/reload` after manually editing the file.
98
109
 
110
+ Version 1 configuration used one top-level `provider`. Goala reads that schema,
111
+ applies the provider to every role, and writes only version 2 when the
112
+ configuration is next saved.
113
+
99
114
  `reviewPolicy` accepts:
100
115
 
101
116
  - `final`: run the full approved plan and review the final result;
@@ -12,6 +12,7 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi
12
12
  export type ReviewPolicy = "final" | "per-step";
13
13
 
14
14
  export interface ModelProfile {
15
+ provider: string;
15
16
  model: string;
16
17
  thinkingLevel: ThinkingLevel;
17
18
  }
@@ -28,8 +29,7 @@ export interface GoalaSetupPreset {
28
29
  }
29
30
 
30
31
  export interface GoalaConfig {
31
- configVersion: 1;
32
- provider: string;
32
+ configVersion: 2;
33
33
  planner: ModelProfile;
34
34
  executor: ModelProfile;
35
35
  fallbackExecutor: ModelProfile & { afterRepairCycle: number };
@@ -44,13 +44,33 @@ export interface GoalaConfig {
44
44
  }
45
45
 
46
46
  export const OPENAI_CODEX_PRESET: GoalaConfig = {
47
- configVersion: 1,
48
- provider: "openai-codex",
49
- planner: { model: "gpt-5.6-sol", thinkingLevel: "medium" },
50
- executor: { model: "gpt-5.6-luna", thinkingLevel: "medium" },
51
- fallbackExecutor: { model: "gpt-5.6-terra", thinkingLevel: "medium", afterRepairCycle: 2 },
52
- stepVerifier: { model: "gpt-5.6-luna", thinkingLevel: "medium" },
53
- verifier: { model: "gpt-5.6-sol", thinkingLevel: "medium" },
47
+ configVersion: 2,
48
+ planner: {
49
+ provider: "openai-codex",
50
+ model: "gpt-5.6-sol",
51
+ thinkingLevel: "medium",
52
+ },
53
+ executor: {
54
+ provider: "openai-codex",
55
+ model: "gpt-5.6-luna",
56
+ thinkingLevel: "medium",
57
+ },
58
+ fallbackExecutor: {
59
+ provider: "openai-codex",
60
+ model: "gpt-5.6-terra",
61
+ thinkingLevel: "medium",
62
+ afterRepairCycle: 2,
63
+ },
64
+ stepVerifier: {
65
+ provider: "openai-codex",
66
+ model: "gpt-5.6-luna",
67
+ thinkingLevel: "medium",
68
+ },
69
+ verifier: {
70
+ provider: "openai-codex",
71
+ model: "gpt-5.6-sol",
72
+ thinkingLevel: "medium",
73
+ },
54
74
  reviewPolicy: "final",
55
75
  autoVerify: true,
56
76
  maxRepairCycles: 3,
@@ -74,20 +94,69 @@ export function configPath(): string {
74
94
  return join(goalaHome(), "config.json");
75
95
  }
76
96
 
77
- function mergeConfig(parsed: Partial<GoalaConfig>): GoalaConfig {
97
+ type PersistedConfig = Partial<GoalaConfig> & {
98
+ provider?: unknown;
99
+ };
100
+
101
+ function profileProvider(
102
+ profile: Partial<ModelProfile> | undefined,
103
+ legacyProvider: unknown,
104
+ fallback: ModelProfile,
105
+ ): string {
106
+ if (typeof profile?.provider === "string" && profile.provider) return profile.provider;
107
+ if (typeof legacyProvider === "string" && legacyProvider) return legacyProvider;
108
+ return fallback.provider;
109
+ }
110
+
111
+ function mergeProfile(
112
+ profile: Partial<ModelProfile> | undefined,
113
+ legacyProvider: unknown,
114
+ fallback: ModelProfile,
115
+ ): ModelProfile {
116
+ return {
117
+ ...fallback,
118
+ ...profile,
119
+ provider: profileProvider(profile, legacyProvider, fallback),
120
+ };
121
+ }
122
+
123
+ function mergeConfig(parsed: PersistedConfig): GoalaConfig {
124
+ const { provider: legacyProvider, ...current } = parsed;
78
125
  return {
79
126
  ...OPENAI_CODEX_PRESET,
80
- ...parsed,
81
- configVersion: 1,
127
+ ...current,
128
+ configVersion: 2,
82
129
  reviewPolicy: parsed.reviewPolicy === "per-step" ? "per-step" : "final",
83
- planner: { ...OPENAI_CODEX_PRESET.planner, ...parsed.planner },
84
- executor: { ...OPENAI_CODEX_PRESET.executor, ...parsed.executor },
130
+ planner: mergeProfile(
131
+ parsed.planner,
132
+ legacyProvider,
133
+ OPENAI_CODEX_PRESET.planner,
134
+ ),
135
+ executor: mergeProfile(
136
+ parsed.executor,
137
+ legacyProvider,
138
+ OPENAI_CODEX_PRESET.executor,
139
+ ),
85
140
  fallbackExecutor: {
86
- ...OPENAI_CODEX_PRESET.fallbackExecutor,
87
- ...parsed.fallbackExecutor,
141
+ ...mergeProfile(
142
+ parsed.fallbackExecutor,
143
+ legacyProvider,
144
+ OPENAI_CODEX_PRESET.fallbackExecutor,
145
+ ),
146
+ afterRepairCycle:
147
+ parsed.fallbackExecutor?.afterRepairCycle ??
148
+ OPENAI_CODEX_PRESET.fallbackExecutor.afterRepairCycle,
88
149
  },
89
- stepVerifier: { ...OPENAI_CODEX_PRESET.stepVerifier, ...parsed.stepVerifier },
90
- verifier: { ...OPENAI_CODEX_PRESET.verifier, ...parsed.verifier },
150
+ stepVerifier: mergeProfile(
151
+ parsed.stepVerifier,
152
+ legacyProvider,
153
+ OPENAI_CODEX_PRESET.stepVerifier,
154
+ ),
155
+ verifier: mergeProfile(
156
+ parsed.verifier,
157
+ legacyProvider,
158
+ OPENAI_CODEX_PRESET.verifier,
159
+ ),
91
160
  memory: { ...OPENAI_CODEX_PRESET.memory, ...parsed.memory },
92
161
  };
93
162
  }
@@ -95,7 +164,7 @@ function mergeConfig(parsed: Partial<GoalaConfig>): GoalaConfig {
95
164
  export function loadConfig(): GoalaConfig {
96
165
  let config = OPENAI_CODEX_PRESET;
97
166
  try {
98
- const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as Partial<GoalaConfig>;
167
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as PersistedConfig;
99
168
  config = mergeConfig(parsed);
100
169
  } catch {
101
170
  config = mergeConfig({});
@@ -129,12 +198,11 @@ export function currentModelConfig(
129
198
  ): GoalaConfig {
130
199
  return {
131
200
  ...OPENAI_CODEX_PRESET,
132
- provider,
133
- planner: { model, thinkingLevel },
134
- executor: { model, thinkingLevel },
135
- fallbackExecutor: { model, thinkingLevel, afterRepairCycle: 2 },
136
- stepVerifier: { model, thinkingLevel },
137
- verifier: { model, thinkingLevel },
201
+ planner: { provider, model, thinkingLevel },
202
+ executor: { provider, model, thinkingLevel },
203
+ fallbackExecutor: { provider, model, thinkingLevel, afterRepairCycle: 2 },
204
+ stepVerifier: { provider, model, thinkingLevel },
205
+ verifier: { provider, model, thinkingLevel },
138
206
  };
139
207
  }
140
208
 
@@ -165,7 +233,7 @@ export function configuredModels(config: GoalaConfig): ModelIdentity[] {
165
233
  config.fallbackExecutor,
166
234
  config.stepVerifier,
167
235
  config.verifier,
168
- ].map((profile) => ({ provider: config.provider, id: profile.model }));
236
+ ].map((profile) => ({ provider: profile.provider, id: profile.model }));
169
237
  return identities.filter(
170
238
  (identity, index) =>
171
239
  identities.findIndex(
@@ -178,13 +246,17 @@ export function configuredModels(config: GoalaConfig): ModelIdentity[] {
178
246
  export function formatConfig(config: GoalaConfig): string {
179
247
  return [
180
248
  `Config: ${configPath()}`,
181
- `Planner: ${config.provider}/${config.planner.model}:${config.planner.thinkingLevel}`,
182
- `Executor: ${config.provider}/${config.executor.model}:${config.executor.thinkingLevel}`,
183
- `Step verifier: ${config.provider}/${config.stepVerifier.model}:${config.stepVerifier.thinkingLevel}`,
184
- `Verifier: ${config.provider}/${config.verifier.model}:${config.verifier.thinkingLevel}`,
185
- `Fallback: ${config.provider}/${config.fallbackExecutor.model}:${config.fallbackExecutor.thinkingLevel} from repair ${config.fallbackExecutor.afterRepairCycle}`,
249
+ `Planner: ${formatProfile(config.planner)}`,
250
+ `Executor: ${formatProfile(config.executor)}`,
251
+ `Step verifier: ${formatProfile(config.stepVerifier)}`,
252
+ `Verifier: ${formatProfile(config.verifier)}`,
253
+ `Fallback: ${formatProfile(config.fallbackExecutor)} from repair ${config.fallbackExecutor.afterRepairCycle}`,
186
254
  `Review policy: ${config.reviewPolicy}`,
187
255
  `Memory: ${config.memory.enabled ? "enabled" : "disabled"}; auto-recall ${config.memory.autoRecall ? "on" : "off"}`,
188
256
  `Current-model fallback: ${config.allowCurrentModelFallback ? "allowed" : "disabled"}`,
189
257
  ].join("\n");
190
258
  }
259
+
260
+ function formatProfile(profile: ModelProfile): string {
261
+ return `${profile.provider}/${profile.model}:${profile.thinkingLevel}`;
262
+ }
@@ -9,6 +9,7 @@ import {
9
9
  GOALA_SETUP_PRESETS,
10
10
  loadConfig,
11
11
  writeConfig,
12
+ type GoalaConfig,
12
13
  type ModelProfile,
13
14
  type ReviewPolicy,
14
15
  type ThinkingLevel,
@@ -40,6 +41,7 @@ import {
40
41
  moveToFreshSession,
41
42
  slicePhaseContext,
42
43
  } from "./session.ts";
44
+ import { selectModelRoles } from "./setup.ts";
43
45
  import {
44
46
  formatSourceDrift,
45
47
  inspectGoalSources,
@@ -142,7 +144,7 @@ export default function goala(pi: ExtensionAPI): void {
142
144
  }
143
145
 
144
146
  const profile = profileForPhase();
145
- let model = ctx.modelRegistry.find(config.provider, profile.model);
147
+ let model = ctx.modelRegistry.find(profile.provider, profile.model);
146
148
  if (!model && config.allowCurrentModelFallback) {
147
149
  const fallback = sessionDefaultModel ?? (ctx.model
148
150
  ? { provider: ctx.model.provider, id: ctx.model.id }
@@ -151,7 +153,7 @@ export default function goala(pi: ExtensionAPI): void {
151
153
  model = ctx.modelRegistry.find(fallback.provider, fallback.id);
152
154
  if (model && !fallbackNoticeShown) {
153
155
  ctx.ui.notify(
154
- `Goala: ${config.provider}/${profile.model} is unavailable; using ${fallback.provider}/${fallback.id}. Run /goala-setup to configure model roles.`,
156
+ `Goala: ${profile.provider}/${profile.model} is unavailable; using ${fallback.provider}/${fallback.id}. Run /goala-setup to configure model roles.`,
155
157
  "warning",
156
158
  );
157
159
  fallbackNoticeShown = true;
@@ -160,7 +162,7 @@ export default function goala(pi: ExtensionAPI): void {
160
162
  }
161
163
  if (!model) {
162
164
  ctx.ui.notify(
163
- `Goala: model not found: ${config.provider}/${profile.model}. Run /goala-setup current or edit the namespaced config.`,
165
+ `Goala: model not found: ${profile.provider}/${profile.model}. Run /goala-setup current or edit the namespaced config.`,
164
166
  "error",
165
167
  );
166
168
  updateGoalUi(ctx, state);
@@ -169,7 +171,10 @@ export default function goala(pi: ExtensionAPI): void {
169
171
 
170
172
  const selected = await pi.setModel(model);
171
173
  if (!selected) {
172
- ctx.ui.notify(`Goala: authenticate ${config.provider} before using ${profile.model}`, "warning");
174
+ ctx.ui.notify(
175
+ `Goala: authenticate ${profile.provider} before using ${profile.model}`,
176
+ "warning",
177
+ );
173
178
  updateGoalUi(ctx, state);
174
179
  return false;
175
180
  }
@@ -558,10 +563,13 @@ Do not edit files or rely on executor claims. Finish with submit_step_verificati
558
563
  if (!choice && ctx.hasUI) {
559
564
  const selected = await ctx.ui.select("Goala setup", [
560
565
  ...GOALA_SETUP_PRESETS.map((preset) => preset.label),
566
+ "Configure each phase",
561
567
  "Show current configuration",
562
568
  ]);
563
569
  choice = selected === "Show current configuration"
564
570
  ? "status"
571
+ : selected === "Configure each phase"
572
+ ? "custom"
565
573
  : GOALA_SETUP_PRESETS.find((preset) => preset.label === selected)?.id ?? "";
566
574
  }
567
575
 
@@ -570,28 +578,34 @@ Do not edit files or rely on executor claims. Finish with submit_step_verificati
570
578
  return;
571
579
  }
572
580
 
573
- const preset = GOALA_SETUP_PRESETS.find((candidate) => candidate.id === choice);
574
- if (!preset) {
575
- ctx.ui.notify(
576
- `Usage: /goala-setup [status | ${GOALA_SETUP_PRESETS.map((candidate) => candidate.id).join(" | ")}]`,
577
- "warning",
578
- );
579
- return;
580
- }
581
- const current = ctx.model
582
- ? { provider: ctx.model.provider, id: ctx.model.id }
583
- : sessionDefaultModel;
584
- const proposed = preset.create(current);
585
- if (!proposed) {
586
- ctx.ui.notify(`Preset "${preset.label}" requires a current model.`, "warning");
587
- return;
581
+ let proposed: GoalaConfig | undefined;
582
+ if (choice === "custom") {
583
+ proposed = await selectModelRoles(ctx, config);
584
+ if (!proposed) return;
585
+ } else {
586
+ const preset = GOALA_SETUP_PRESETS.find((candidate) => candidate.id === choice);
587
+ if (!preset) {
588
+ ctx.ui.notify(
589
+ `Usage: /goala-setup [status | custom | ${GOALA_SETUP_PRESETS.map((candidate) => candidate.id).join(" | ")}]`,
590
+ "warning",
591
+ );
592
+ return;
593
+ }
594
+ const current = ctx.model
595
+ ? { provider: ctx.model.provider, id: ctx.model.id }
596
+ : sessionDefaultModel;
597
+ proposed = preset.create(current);
598
+ if (!proposed) {
599
+ ctx.ui.notify(`Preset "${preset.label}" requires a current model.`, "warning");
600
+ return;
601
+ }
588
602
  }
589
603
  const missing = configuredModels(proposed).filter(
590
604
  (model) => !ctx.modelRegistry.find(model.provider, model.id),
591
605
  );
592
606
  if (missing.length > 0) {
593
607
  ctx.ui.notify(
594
- `Preset not saved; unavailable model(s): ${missing.map((model) => `${model.provider}/${model.id}`).join(", ")}.`,
608
+ `Configuration not saved; unavailable model(s): ${missing.map((model) => `${model.provider}/${model.id}`).join(", ")}.`,
595
609
  "warning",
596
610
  );
597
611
  return;
@@ -605,7 +619,7 @@ Do not edit files or rely on executor claims. Finish with submit_step_verificati
605
619
  }
606
620
 
607
621
  pi.registerCommand("goala-setup", {
608
- description: `Configure model roles: /goala-setup [status | ${GOALA_SETUP_PRESETS.map((preset) => preset.id).join(" | ")}]`,
622
+ description: `Configure model roles: /goala-setup [status | custom | ${GOALA_SETUP_PRESETS.map((preset) => preset.id).join(" | ")}]`,
609
623
  handler: configureGoala,
610
624
  });
611
625
 
@@ -0,0 +1,305 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ GoalaConfig,
4
+ ModelProfile,
5
+ ThinkingLevel,
6
+ } from "./config.ts";
7
+
8
+ type ModelRole =
9
+ | "planner"
10
+ | "executor"
11
+ | "stepVerifier"
12
+ | "verifier"
13
+ | "fallbackExecutor";
14
+
15
+ type AvailableModel = ReturnType<
16
+ ExtensionCommandContext["modelRegistry"]["getAvailable"]
17
+ >[number];
18
+
19
+ const MODEL_ROLES: ReadonlyArray<{ key: ModelRole; label: string }> = [
20
+ { key: "planner", label: "Planner" },
21
+ { key: "executor", label: "Executor" },
22
+ { key: "stepVerifier", label: "Step verifier" },
23
+ { key: "verifier", label: "Final verifier" },
24
+ { key: "fallbackExecutor", label: "Repair fallback" },
25
+ ];
26
+
27
+ const THINKING_LEVELS: readonly ThinkingLevel[] = [
28
+ "off",
29
+ "minimal",
30
+ "low",
31
+ "medium",
32
+ "high",
33
+ "xhigh",
34
+ "max",
35
+ ];
36
+
37
+ const KEEP_CURRENT = "Keep current";
38
+ const CHANGE_REASONING = "Change reasoning effort";
39
+ const CHANGE_MODEL = "Change provider or model";
40
+ const SAVE_CONFIGURATION = "Save configuration";
41
+ const EDIT_PHASE = "Edit a phase";
42
+ const CANCEL = "Cancel";
43
+
44
+ function titleCase(value: string): string {
45
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
46
+ }
47
+
48
+ function thinkingLabel(level: ThinkingLevel): string {
49
+ if (level === "off") return "Off";
50
+ if (level === "xhigh") return "Extra high";
51
+ if (level === "max") return "Maximum";
52
+ return titleCase(level);
53
+ }
54
+
55
+ function providerLabel(
56
+ ctx: ExtensionCommandContext,
57
+ provider: string,
58
+ ): string {
59
+ const displayName = ctx.modelRegistry.getProviderDisplayName(provider);
60
+ return displayName === provider ? provider : `${displayName} (${provider})`;
61
+ }
62
+
63
+ function modelLabel(model: AvailableModel): string {
64
+ return model.name && model.name !== model.id
65
+ ? `${model.name} (${model.id})`
66
+ : model.id;
67
+ }
68
+
69
+ function profileLabel(
70
+ ctx: ExtensionCommandContext,
71
+ available: AvailableModel[],
72
+ profile: ModelProfile,
73
+ ): string {
74
+ const model = available.find(
75
+ (candidate) =>
76
+ candidate.provider === profile.provider && candidate.id === profile.model,
77
+ );
78
+ const reasoning =
79
+ profile.thinkingLevel === "off"
80
+ ? "Reasoning off"
81
+ : `${thinkingLabel(profile.thinkingLevel)} reasoning`;
82
+ return [
83
+ providerLabel(ctx, profile.provider),
84
+ model ? modelLabel(model) : `${profile.model} (unavailable)`,
85
+ reasoning,
86
+ ].join(" / ");
87
+ }
88
+
89
+ function setProfile(
90
+ config: GoalaConfig,
91
+ role: ModelRole,
92
+ profile: ModelProfile,
93
+ ): void {
94
+ if (role === "fallbackExecutor") {
95
+ config.fallbackExecutor = {
96
+ ...profile,
97
+ afterRepairCycle: config.fallbackExecutor.afterRepairCycle,
98
+ };
99
+ return;
100
+ }
101
+ config[role] = profile;
102
+ }
103
+
104
+ async function selectThinkingLevel(
105
+ ctx: ExtensionCommandContext,
106
+ roleLabel: string,
107
+ profile: ModelProfile,
108
+ ): Promise<ThinkingLevel | undefined> {
109
+ const ordered = [
110
+ profile.thinkingLevel,
111
+ ...THINKING_LEVELS.filter((level) => level !== profile.thinkingLevel),
112
+ ];
113
+ const choices = ordered.map((level, index) =>
114
+ index === 0 ? `${thinkingLabel(level)} (current)` : thinkingLabel(level),
115
+ );
116
+ const selected = await ctx.ui.select(
117
+ `${roleLabel} reasoning effort\nCurrent: ${thinkingLabel(profile.thinkingLevel)}`,
118
+ choices,
119
+ );
120
+ if (!selected) return undefined;
121
+ return ordered[choices.indexOf(selected)];
122
+ }
123
+
124
+ async function selectProvider(
125
+ ctx: ExtensionCommandContext,
126
+ available: AvailableModel[],
127
+ roleLabel: string,
128
+ currentProvider: string,
129
+ ): Promise<string | undefined> {
130
+ const providers = [...new Set(available.map((model) => model.provider))].sort(
131
+ (left, right) => {
132
+ if (left === currentProvider) return -1;
133
+ if (right === currentProvider) return 1;
134
+ return providerLabel(ctx, left).localeCompare(providerLabel(ctx, right));
135
+ },
136
+ );
137
+ if (providers.length === 1) return providers[0];
138
+
139
+ const choices = providers.map((provider, index) =>
140
+ index === 0 && provider === currentProvider
141
+ ? `${providerLabel(ctx, provider)} (current)`
142
+ : providerLabel(ctx, provider),
143
+ );
144
+ const selected = await ctx.ui.select(
145
+ `${roleLabel} provider\nCurrent: ${providerLabel(ctx, currentProvider)}`,
146
+ choices,
147
+ );
148
+ if (!selected) return undefined;
149
+ return providers[choices.indexOf(selected)];
150
+ }
151
+
152
+ async function selectModel(
153
+ ctx: ExtensionCommandContext,
154
+ available: AvailableModel[],
155
+ roleLabel: string,
156
+ provider: string,
157
+ current: ModelProfile,
158
+ ): Promise<AvailableModel | undefined> {
159
+ const models = available
160
+ .filter((model) => model.provider === provider)
161
+ .sort((left, right) => {
162
+ const leftCurrent =
163
+ left.provider === current.provider && left.id === current.model;
164
+ const rightCurrent =
165
+ right.provider === current.provider && right.id === current.model;
166
+ if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1;
167
+ return modelLabel(left).localeCompare(modelLabel(right));
168
+ });
169
+ const choices = models.map((model, index) =>
170
+ index === 0 &&
171
+ model.provider === current.provider &&
172
+ model.id === current.model
173
+ ? `${modelLabel(model)} (current)`
174
+ : modelLabel(model),
175
+ );
176
+ const selected = await ctx.ui.select(
177
+ `${roleLabel} model\nProvider: ${providerLabel(ctx, provider)}`,
178
+ choices,
179
+ );
180
+ if (!selected) return undefined;
181
+ return models[choices.indexOf(selected)];
182
+ }
183
+
184
+ async function changeModel(
185
+ ctx: ExtensionCommandContext,
186
+ available: AvailableModel[],
187
+ roleLabel: string,
188
+ current: ModelProfile,
189
+ ): Promise<ModelProfile | undefined> {
190
+ const provider = await selectProvider(
191
+ ctx,
192
+ available,
193
+ roleLabel,
194
+ current.provider,
195
+ );
196
+ if (!provider) return undefined;
197
+ const model = await selectModel(ctx, available, roleLabel, provider, current);
198
+ if (!model) return undefined;
199
+
200
+ const profile: ModelProfile = {
201
+ provider: model.provider,
202
+ model: model.id,
203
+ thinkingLevel: model.reasoning ? current.thinkingLevel : "off",
204
+ };
205
+ if (!model.reasoning) return profile;
206
+
207
+ const thinkingLevel = await selectThinkingLevel(ctx, roleLabel, profile);
208
+ if (!thinkingLevel) return undefined;
209
+ return { ...profile, thinkingLevel };
210
+ }
211
+
212
+ async function configureRole(
213
+ ctx: ExtensionCommandContext,
214
+ available: AvailableModel[],
215
+ config: GoalaConfig,
216
+ role: { key: ModelRole; label: string },
217
+ ): Promise<boolean> {
218
+ const current = config[role.key];
219
+ const currentModel = available.find(
220
+ (model) =>
221
+ model.provider === current.provider && model.id === current.model,
222
+ );
223
+ const actions = [
224
+ KEEP_CURRENT,
225
+ ...(currentModel?.reasoning ? [CHANGE_REASONING] : []),
226
+ CHANGE_MODEL,
227
+ ];
228
+ const action = await ctx.ui.select(
229
+ `${role.label}\nCurrent: ${profileLabel(ctx, available, current)}`,
230
+ actions,
231
+ );
232
+ if (!action) return false;
233
+ if (action === KEEP_CURRENT) return true;
234
+
235
+ if (action === CHANGE_REASONING) {
236
+ const thinkingLevel = await selectThinkingLevel(ctx, role.label, current);
237
+ if (!thinkingLevel) return false;
238
+ setProfile(config, role.key, { ...current, thinkingLevel });
239
+ return true;
240
+ }
241
+
242
+ const profile = await changeModel(ctx, available, role.label, current);
243
+ if (!profile) return false;
244
+ setProfile(config, role.key, profile);
245
+ return true;
246
+ }
247
+
248
+ function reviewSummary(
249
+ ctx: ExtensionCommandContext,
250
+ available: AvailableModel[],
251
+ config: GoalaConfig,
252
+ ): string {
253
+ return MODEL_ROLES.map(
254
+ (role) =>
255
+ `${role.label}: ${profileLabel(ctx, available, config[role.key])}`,
256
+ ).join("\n");
257
+ }
258
+
259
+ export async function selectModelRoles(
260
+ ctx: ExtensionCommandContext,
261
+ current: GoalaConfig,
262
+ ): Promise<GoalaConfig | undefined> {
263
+ if (!ctx.hasUI) {
264
+ ctx.ui.notify("Per-phase model setup requires interactive Pi.", "warning");
265
+ return undefined;
266
+ }
267
+
268
+ try {
269
+ await ctx.modelRegistry.refresh();
270
+ } catch {
271
+ // The current registry can still contain usable cached and built-in models.
272
+ }
273
+ const available = ctx.modelRegistry.getAvailable();
274
+ if (available.length === 0) {
275
+ ctx.ui.notify(
276
+ "No authenticated models are available. Run /login, then retry /goala-setup.",
277
+ "warning",
278
+ );
279
+ return undefined;
280
+ }
281
+
282
+ const proposed = structuredClone(current);
283
+ for (const role of MODEL_ROLES) {
284
+ if (!(await configureRole(ctx, available, proposed, role))) return undefined;
285
+ }
286
+
287
+ while (true) {
288
+ const action = await ctx.ui.select(
289
+ `Review Goala configuration\n${reviewSummary(ctx, available, proposed)}`,
290
+ [SAVE_CONFIGURATION, EDIT_PHASE, CANCEL],
291
+ );
292
+ if (!action || action === CANCEL) return undefined;
293
+ if (action === SAVE_CONFIGURATION) return proposed;
294
+
295
+ const selectedRole = await ctx.ui.select(
296
+ "Choose a phase to edit",
297
+ MODEL_ROLES.map((role) => role.label),
298
+ );
299
+ if (!selectedRole) return undefined;
300
+ const role = MODEL_ROLES.find((candidate) => candidate.label === selectedRole);
301
+ if (!role || !(await configureRole(ctx, available, proposed, role))) {
302
+ return undefined;
303
+ }
304
+ }
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goala",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Goal-oriented agent lifecycle, evidence, and verified memory for Pi.",
5
5
  "type": "module",
6
6
  "author": "Barry King",