pi-bro 0.25.1 → 0.27.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.
@@ -0,0 +1,22 @@
1
+ /** Read-only agent interface for the user-approved personal memory store. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { readMemoryStore, renderUserMemory } from "./remember-store.ts";
5
+
6
+ export function registerAboutUserTool(pi: ExtensionAPI): void {
7
+ pi.registerTool({
8
+ name: "about_user",
9
+ label: "About user",
10
+ description: "Read user-approved personal memories in bounded pages. Continue with offset, query by topic, or expand IDs for source and exceptions. Current requests override standing preferences. No write access.",
11
+ parameters: Type.Object({
12
+ query: Type.Optional(Type.String({ description: "Topic to find relevant user memories" })),
13
+ expand: Type.Optional(Type.Array(Type.String(), { description: "Stable memory IDs to unfold", maxItems: 12 })),
14
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based start for overview or search" })),
15
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 30, description: "Page size" })),
16
+ }),
17
+ async execute(_id, params) {
18
+ const store = await readMemoryStore();
19
+ return { content: [{ type: "text" as const, text: renderUserMemory(store, params) }], details: {} };
20
+ },
21
+ });
22
+ }
@@ -0,0 +1,294 @@
1
+ /** Private, user-controlled workflow for approved personal memories. No durable unfinished drafts. */
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { execute as executeBackend } from "./backend.ts";
4
+ import { BtwModal, formatBtwTranscript } from "./btw.ts";
5
+ import { openBroOverlay } from "./modal-kit.ts";
6
+ import { captureShowTranscript } from "./show.ts";
7
+ import { readPreferences, readSettings, selectionForCapability, selectionLabel } from "./settings.ts";
8
+ import {
9
+ changeMemory, readMemoryStore, renderUserMemory,
10
+ type MemoryEntry,
11
+ } from "./remember-store.ts";
12
+ import {
13
+ buildRememberPrompt, makeReviewedProposal, parseRememberResult, staleProposalReview,
14
+ markProposalClarifying, proposalReadyToSave, rememberBackendNotice,
15
+ type RememberProposal, type RememberTurn,
16
+ } from "./remember-engine.ts";
17
+ import { errorMessage } from "./util.ts";
18
+
19
+ const HELP = [
20
+ "## Remember Me", "",
21
+ "Talk naturally; Bro may ask a focused question or propose a qualified memory. /save approves **exactly** what is displayed.",
22
+ "/save — approve proposal, or show changes in the store that need new approval",
23
+ "/confirm-revision — after examining stale changes, approve the reviewed proposal against that revision",
24
+ "/list [page|all] — page through current search (or all memories); /list all clears search",
25
+ "/search <words> — search approved memory fields",
26
+ "/expand <id> — unfold a memory; /edit <id> — start an update; /delete <id> — confirm deletion",
27
+ "/context show|on|off — inspect, include or omit captured Pi context from model calls",
28
+ "/new — intentionally discard current unfinished work; /help — show these commands",
29
+ "",
30
+ "Drafts last while this window stays open. Approved entries survive restarts. Legacy /bro preferences remain a separate style file.",
31
+ ].join("\n");
32
+
33
+ function memoryDetail(m?: MemoryEntry): string {
34
+ if (!m) return "(entry absent)";
35
+ return m.title+": "+m.summary+
36
+ (m.scope ? "\nScope: "+m.scope : "")+
37
+ (m.exceptions ? "\nExceptions: "+m.exceptions : "")+
38
+ (m.uncertainty ? "\nUNRESOLVED: "+m.uncertainty : "")+
39
+ "\nBackground: "+m.background+
40
+ (m.sourceContext ? "\nSource context: "+m.sourceContext : "")+
41
+ "\nBasis: "+(m.basis??"user-stated");
42
+ }
43
+
44
+ export async function openRememberModal(ctx: ExtensionCommandContext, initial = ""): Promise<void> {
45
+ let store = await readMemoryStore();
46
+ const capturedContext = captureShowTranscript(ctx, 8, 12000)?.text ?? "";
47
+ const openingSettings = await readSettings();
48
+ const openingSelection = selectionForCapability(openingSettings, "btw");
49
+ await openBroOverlay<void>(ctx, (tui, theme, _keys, done) => {
50
+ let closed = false;
51
+ let controller: AbortController | undefined;
52
+ let busy = false;
53
+ let proposal: RememberProposal | undefined;
54
+ let transcript: RememberTurn[] = [];
55
+ let relatedQuestionIds: string[] = [];
56
+ let confirmingDelete: string | undefined;
57
+ let contextEnabled = true;
58
+ let page = 0;
59
+ let query = "";
60
+ let browserText = "";
61
+ let selectedBackend = openingSelection.backend ?? "agy";
62
+ let selectedModel = selectionLabel(openingSelection);
63
+ const close = () => { if (closed) return; closed = true; controller?.abort(); done(undefined); };
64
+ const modal = new BtwModal(tui, theme, close, submit, () => { closed = true; controller?.abort(); }, {
65
+ title:"Bro · remember-me", commands:"", inputLabel:"Your memory or correction",
66
+ idleHint:"Enter discuss · /save approve · /context show · /help",
67
+ });
68
+ const header = () => "## Writing a memory\n\n**Model:** "+selectedModel+" ("+selectedBackend+
69
+ "). **Recent Pi context:** "+(contextEnabled ? "ON ("+capturedContext.length+" characters, at most 8 turns)" : "OFF")+
70
+ ".\n\nDrafting also shares all saved short-memory statements (and relevant expanded entries) plus the legacy Bro preferences with the selected provider."+
71
+ " /context show displays the captured Pi text; /context off omits it. Current user requests outrank stored guidance.\n\n**Execution boundary:** "+rememberBackendNotice(selectedBackend)+
72
+ "\n\n/save controls only writes to Bro’s memory store; it cannot prevent every backend-side action.";
73
+ const proposalText = () => {
74
+ if (!proposal) return "";
75
+ const d = proposal.draft;
76
+ return "## Reviewed proposal — "+(proposal.action==="update" ? "UPDATE "+proposal.targetId : "CREATE new memory")+
77
+ "\n\n**"+d.title+": "+d.summary+"**"+
78
+ (d.scope ? "\n\nScope: "+d.scope : "")+
79
+ (d.exceptions ? "\n\nExceptions: "+d.exceptions : "")+
80
+ (d.uncertainty ? "\n\n**UNRESOLVED:** "+d.uncertainty : "")+
81
+ "\n\nBackground: "+d.background+
82
+ (d.sourceContext ? "\n\nSource context: "+d.sourceContext : "")+
83
+ "\n\nBasis: "+(d.basis??"user-stated")+
84
+ (proposal.relatedIds.length ? "\n\nRelated IDs: "+proposal.relatedIds.join(", ") : "")+
85
+ "\n\nReviewed against store revision: "+proposal.reviewedRevision+
86
+ (proposal.approvalState==="clarifying"
87
+ ? "\n\n**Awaiting clarification.** This earlier draft is retained for reference, but /save and /confirm-revision are blocked until Bro proposes revised wording. "+
88
+ (transcript.at(-1)?.bro && !transcript.at(-1)?.bro.startsWith("{") ? "\n\n**Latest question:** "+transcript.at(-1)!.bro : "")
89
+ : "\n\n**/save** approves this exact content; reply to correct it.");
90
+ };
91
+ const staleText = () => {
92
+ const review = proposal?.staleReview;
93
+ if (!review) return "";
94
+ return "## Store changed since proposal review\n\nOriginal revision: "+proposal!.reviewedRevision+
95
+ " · Current revision: "+review.revision+
96
+ "\n\n**Original target:**\n\n"+memoryDetail(review.previousTarget)+
97
+ "\n\n**Current target:**\n\n"+memoryDetail(review.currentTarget)+
98
+ (review.related.length ? "\n\n**Current related entries:**\n\n"+review.related.map(memoryDetail).join("\n\n") : "")+
99
+ "\n\nReview the differences and the proposed replacement above. "+
100
+ (proposal!.action==="update" && !review.currentTarget
101
+ ? "The update target was deleted; this proposal cannot overwrite it. Discuss a new create action."
102
+ : "Type **/confirm-revision** to approve against the current revision, or reply to revise. "+
103
+ "If another write occurs, you must review again.");
104
+ };
105
+ const show = (message = "", focusMessage = false) => {
106
+ const conversation = transcript.length
107
+ ? formatBtwTranscript(transcript.map(t=>({question:t.user,
108
+ answer:t.bro.startsWith('{"kind":"proposal"') ? "Bro proposed memory wording; review the current draft below." : t.bro}))) : "";
109
+ // Put proposal last: scrolling to end exposes the exact text being approved.
110
+ const questionLinks = relatedQuestionIds.length ? "Related memory IDs: "+relatedQuestionIds.join(", ") : "";
111
+ const relevant = message||browserText;
112
+ const content = (focusMessage
113
+ ? [header(), conversation, questionLinks, proposalText(), staleText(), relevant]
114
+ : proposal?.staleReview
115
+ ? [header(), conversation, questionLinks, relevant, proposalText(), staleText()]
116
+ : [header(), conversation, questionLinks, relevant, proposalText()]
117
+ ).filter(Boolean).join("\n\n---\n\n");
118
+ modal.setText(content);
119
+ modal.scrollToEnd();
120
+ };
121
+ const refresh = async (offset=0, search="") => {
122
+ const latest = await readMemoryStore();
123
+ store=latest; page=Math.max(0,offset); query=search;
124
+ // No proposal mutation on list/search/refresh. A stale proposal retains its reviewedRevision.
125
+ browserText=renderUserMemory(store,{query,offset:page,limit:20});
126
+ show("", true);
127
+ };
128
+ const save = async (reapproved = false) => {
129
+ if (!proposal || busy) { modal.setNotice("No proposal to save.", "warning"); return; }
130
+ if (!proposalReadyToSave(proposal)) {
131
+ show();
132
+ modal.setNotice("Bro is awaiting clarification. The old draft may be disputed; answer the question and review a revised proposal before saving.", "warning");
133
+ return;
134
+ }
135
+ busy = true; modal.setRunning(true);
136
+ try {
137
+ const latest=await readMemoryStore();
138
+ store=latest;
139
+ if (reapproved) {
140
+ const pending=proposal.staleReview;
141
+ if (!pending) { modal.setNotice("No changed revision under review. Use /save.", "warning"); return; }
142
+ if (pending.revision!==latest.revision) {
143
+ proposal.staleReview=staleProposalReview(proposal,latest);
144
+ browserText=""; show();
145
+ modal.setNotice("Store changed again. Compare and type /confirm-revision again.", "warning");
146
+ return;
147
+ }
148
+ } else if (latest.revision!==proposal.reviewedRevision) {
149
+ proposal.staleReview=staleProposalReview(proposal,latest);
150
+ browserText=""; show();
151
+ modal.setNotice("Store changed. Review original and current entries before /confirm-revision.", "warning");
152
+ return;
153
+ }
154
+ const expectedRevision=reapproved ? proposal.staleReview!.revision : proposal.reviewedRevision;
155
+ if (proposal.action==="update" && !latest.entries.some(m=>m.id===proposal!.targetId)) {
156
+ modal.setNotice("Update target was deleted. No write occurred; discuss creating a new entry.", "error");
157
+ return;
158
+ }
159
+ const id=proposal.targetId;
160
+ store=await changeMemory({kind:"save",draft:proposal.draft,...(id ? {id} : {})},expectedRevision);
161
+ const savedId=id??store.entries.at(-1)?.id??"";
162
+ proposal=undefined; transcript=[]; relatedQuestionIds=[]; browserText=renderUserMemory(store);
163
+ show(); modal.setNotice("Saved "+savedId+" exactly as approved.", "success");
164
+ } catch(e) { modal.setNotice(errorMessage(e), "error"); }
165
+ finally { busy=false; if(!closed)modal.setRunning(false); }
166
+ };
167
+ const ask = async (input:string) => {
168
+ if (busy||closed) return;
169
+ // A correction immediately invalidates approval, even if this model request fails.
170
+ markProposalClarifying(proposal);
171
+ busy=true;
172
+ const attempt=new AbortController();
173
+ controller=attempt; modal.setRunning(true);
174
+ try {
175
+ store=await readMemoryStore();
176
+ const [settings,preferences]=await Promise.all([readSettings(),readPreferences()]);
177
+ const selection=selectionForCapability(settings,"btw");
178
+ selectedBackend=selection.backend??"agy";
179
+ selectedModel=selectionLabel(selection);
180
+ const prompt=buildRememberPrompt({
181
+ statement:input, context:contextEnabled?capturedContext:undefined,
182
+ history:transcript, memories:store.entries, proposal, preferences
183
+ });
184
+ const pending:RememberTurn={user:input,bro:"Drafting…"};
185
+ transcript.push(pending); show();
186
+ const result=await executeBackend({
187
+ feature:"explain",access:"restricted",cwd:ctx.cwd,prompt
188
+ },selection,attempt.signal);
189
+ if(closed||attempt.signal.aborted)return;
190
+ if(result.status!=="success")throw new Error(result.message);
191
+ const parsed=parseRememberResult(result.text);
192
+ if(parsed.kind==="question") {
193
+ relatedQuestionIds=parsed.relatedIds;
194
+ pending.bro=parsed.message;
195
+ // Keep the disputed proposal for reference, but its approvalState remains clarifying.
196
+ } else {
197
+ proposal=makeReviewedProposal(parsed,store.revision,store.entries);
198
+ relatedQuestionIds=[];
199
+ // Retain exact substantive model proposal as part of the conversation history.
200
+ pending.bro=JSON.stringify(parsed);
201
+ }
202
+ browserText="";
203
+ show();
204
+ modal.setNotice(parsed.kind==="question"
205
+ ? "Answer the question; your prior proposal remains available."
206
+ : "Review the full proposal at the bottom. /save approves, or reply to refine.", "info");
207
+ } catch(e) {
208
+ if(!closed&&!attempt.signal.aborted) {
209
+ if(transcript.at(-1)?.user===input&&transcript.at(-1)?.bro==="Drafting…") transcript.pop();
210
+ modal.setNotice(errorMessage(e), "error");
211
+ if(!modal.getComposerText().trim())modal.setComposerText(input);
212
+ show();
213
+ }
214
+ } finally {
215
+ if(controller===attempt)controller=undefined;
216
+ busy=false; if(!closed)modal.setRunning(false);
217
+ }
218
+ };
219
+ function submit(raw:string):void {
220
+ const value=raw.trim();
221
+ if(!value||closed||busy)return;
222
+ modal.clearComposer();
223
+ if(confirmingDelete) {
224
+ const id=confirmingDelete; confirmingDelete=undefined;
225
+ if(value.toLowerCase()!=="yes"){modal.setNotice("Deletion cancelled.");return;}
226
+ busy=true;modal.setRunning(true);
227
+ void changeMemory({kind:"delete",id},store.revision).then(next=>{
228
+ store=next; browserText=renderUserMemory(store);show();modal.setNotice("Deleted "+id,"success");
229
+ }).catch(e=>modal.setNotice(errorMessage(e),"error")).finally(()=>{
230
+ busy=false;if(!closed)modal.setRunning(false);
231
+ });
232
+ return;
233
+ }
234
+ if(value==="/save"){void save();return;}
235
+ if(value==="/confirm-revision"){void save(true);return;}
236
+ if(value==="/help"){show(HELP, true);return;}
237
+ if(value==="/context show"){
238
+ show(capturedContext ? "## Captured Pi context (not transmitted by inspection)\n\n"+capturedContext : "No Pi context captured.", true);
239
+ return;
240
+ }
241
+ if(value==="/context off"||value==="/context on"){
242
+ contextEnabled=value.endsWith("on");show();modal.setNotice("Recent Pi context "+(contextEnabled?"included":"omitted")+" on future requests.");return;
243
+ }
244
+ if(value==="/new"){
245
+ proposal=undefined;transcript=[];relatedQuestionIds=[];browserText="";show();
246
+ modal.setNotice("Discarded unfinished draft. Saved memories are unchanged.");return;
247
+ }
248
+ if(value==="/list"||value.startsWith("/list ")){
249
+ const token=value.slice(5).trim();
250
+ if(token==="all") { void refresh(0,"").catch(e=>modal.setNotice(errorMessage(e),"error"));return; }
251
+ const pageNumber=token?Number(token):1;
252
+ if(!Number.isSafeInteger(pageNumber)||pageNumber<1){modal.setNotice("Use /list [page number] or /list all.", "warning");return;}
253
+ void refresh((pageNumber-1)*20,query).catch(e=>modal.setNotice(errorMessage(e),"error"));return;
254
+ }
255
+ if(value.startsWith("/search ")){
256
+ void refresh(0,value.slice(8).trim()).catch(e=>modal.setNotice(errorMessage(e),"error"));return;
257
+ }
258
+ if(value.startsWith("/expand ")){
259
+ const id=value.slice(8).trim();
260
+ show(renderUserMemory(store,{expand:[id]}), true);return;
261
+ }
262
+ if(value.startsWith("/edit ")){
263
+ if(proposal){modal.setNotice("Finish or /new the current proposal before starting a different edit.","warning");return;}
264
+ const id=value.slice(6).trim();
265
+ const target=store.entries.find(m=>m.id===id);
266
+ if(!target){modal.setNotice("Unknown memory ID. Use /list.","warning");return;}
267
+ proposal={
268
+ action:"update",targetId:id,reviewedRevision:store.revision,approvalState:"ready",reviewedTarget:target,
269
+ draft:{title:target.title,summary:target.summary,background:target.background,
270
+ scope:target.scope,exceptions:target.exceptions,sourceContext:target.sourceContext,
271
+ uncertainty:target.uncertainty,basis:target.basis},relatedIds:[id]
272
+ };
273
+ transcript=[];browserText="";show("Editing "+target.title+". Correct it in plain words, or /save the exact existing text.");return;
274
+ }
275
+ if(value.startsWith("/delete ")){
276
+ const id=value.slice(8).trim();
277
+ if(!store.entries.some(m=>m.id===id)){modal.setNotice("Unknown ID. Use /list.","warning");return;}
278
+ confirmingDelete=id;
279
+ modal.setNotice("Delete "+id+"? Type yes; any other answer cancels.","warning");return;
280
+ }
281
+ if(value.startsWith("/")){modal.setNotice("Unknown command. /help shows the available controls.","warning");return;}
282
+ void ask(value);
283
+ }
284
+ // Disclosure happens before any prompt. An initial command thought only pre-fills the composer.
285
+ modal.setModel(selectedModel);modal.setCustomMode("private draft · explicit save");
286
+ browserText=renderUserMemory(store);
287
+ show();
288
+ if(initial.trim()){
289
+ modal.setComposerText(initial.trim());
290
+ modal.setNotice("Review the backend and /context controls above, then press Enter to start.", "info");
291
+ }
292
+ return modal;
293
+ },{mouse:true});
294
+ }
package/src/settings.ts CHANGED
@@ -9,20 +9,22 @@ import {
9
9
  GROK_EFFORTS,
10
10
  CODEX_EFFORTS,
11
11
  MUSE_EFFORTS,
12
+ PI_EFFORTS,
12
13
  } from "./backend.ts";
13
14
  import { type BroMode, DEFAULT_BRO_MODE, MAX_PREFERENCES_CHARS, parseBroMode } from "./prompt.ts";
14
15
  import { errorMessage, isRecord } from "./util.ts";
15
16
 
16
17
  export const EFFORTS = ["default", "low", "medium", "high"] as const;
17
- export const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
18
+ export const BACKENDS = ["agy", "claude", "grok", "codex", "muse", "pi"] as const;
18
19
  export type BackendName = (typeof BACKENDS)[number];
19
20
  export type BroEffort = "default" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
20
21
  export type AgyEffort = Exclude<BroEffort, "default" | "minimal" | "xhigh" | "max">;
21
22
  export type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
22
- export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS };
23
+ export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS, PI_EFFORTS };
23
24
  export type GrokEffort = (typeof GROK_EFFORTS)[number];
24
25
  export type CodexEffort = (typeof CODEX_EFFORTS)[number];
25
26
  export type MuseEffort = (typeof MUSE_EFFORTS)[number];
27
+ export type PiEffort = (typeof PI_EFFORTS)[number];
26
28
 
27
29
  export const CAPABILITIES = ["explain", "show", "btw", "advisor", "review"] as const;
28
30
  export type Capability = (typeof CAPABILITIES)[number];
@@ -75,7 +77,7 @@ export const DEFAULT_SHOW_TURNS = 1;
75
77
 
76
78
  export function parseBackend(value: unknown, context: string): BackendName {
77
79
  if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
78
- throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", or "muse".`);
80
+ throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", "muse", or "pi".`);
79
81
  }
80
82
  return value as BackendName;
81
83
  }
@@ -100,6 +102,12 @@ export function isMuseEffort(effort: unknown): effort is MuseEffort | "default"
100
102
  return effort === "default" || MUSE_EFFORTS.some((item) => item === effort);
101
103
  }
102
104
 
105
+ // The in-process Pi backend accepts the whole BroEffort range; createAgentSession clamps it to
106
+ // what the chosen physical model actually supports.
107
+ export function isPiEffort(effort: unknown): effort is BroEffort {
108
+ return effort === "default" || PI_EFFORTS.some((item) => item === effort);
109
+ }
110
+
103
111
  // Sonnet/opus aliases resolve case-insensitively; any other non-empty string passes
104
112
  // through untouched as an explicit user-entered model ID.
105
113
  export function resolveClaudeModel(input: string): string {
@@ -133,7 +141,18 @@ export function resolveMuseModel(input: string): string {
133
141
  return trimmed;
134
142
  }
135
143
 
136
- export type ExternalBackend = "claude" | "grok" | "codex" | "muse";
144
+ // Pi models are physical registry entries addressed as a non-empty "<provider>/<id>" pair;
145
+ // only the first "/" separates the two so model IDs containing slashes survive intact.
146
+ export function resolvePiModel(input: string): string {
147
+ const trimmed = input.trim();
148
+ const separator = trimmed.indexOf("/");
149
+ if (separator <= 0 || separator === trimmed.length - 1) {
150
+ throw new Error("Pi model must be a non-empty <provider>/<id> pair from your Pi model catalog.");
151
+ }
152
+ return trimmed;
153
+ }
154
+
155
+ export type ExternalBackend = "claude" | "grok" | "codex" | "muse" | "pi";
137
156
 
138
157
  export interface ExternalBackendMeta {
139
158
  readonly name: ExternalBackend;
@@ -144,6 +163,8 @@ export interface ExternalBackendMeta {
144
163
  readonly effortHelp: string;
145
164
  isEffort(effort: unknown): effort is BroEffort;
146
165
  resolveModel(input: string): string;
166
+ /** How this backend's stored model reads in the config list. Defaults to `prefix + model`. */
167
+ displayModel?(model: string): string;
147
168
  }
148
169
 
149
170
  export const EXTERNAL_BACKENDS: Record<ExternalBackend, ExternalBackendMeta> = {
@@ -187,8 +208,35 @@ export const EXTERNAL_BACKENDS: Record<ExternalBackend, ExternalBackendMeta> = {
187
208
  isEffort: isMuseEffort,
188
209
  resolveModel: resolveMuseModel,
189
210
  },
211
+ // Pi models are addressed by "<provider>/<id>" and resolved live from the host registry,
212
+ // so there are no fixed seed models and no prefix form. The `pi:` prefix is only the transport
213
+ // tag the picker uses to carry the choice back into applyModelChange; the stored and displayed
214
+ // value is the bare "<provider>/<id>".
215
+ pi: {
216
+ name: "pi",
217
+ label: "Pi",
218
+ prefix: "pi:",
219
+ efforts: PI_EFFORTS,
220
+ models: [],
221
+ effortHelp: '"default", "minimal", "low", "medium", "high", "xhigh", or "max"',
222
+ isEffort: isPiEffort,
223
+ resolveModel: resolvePiModel,
224
+ displayModel: (model) => model,
225
+ },
190
226
  };
191
227
 
228
+ /** The config-list text for a backend's stored model, using the backend's own display rule. */
229
+ export function externalModelDisplay(backend: ExternalBackend, model: string): string {
230
+ const meta = EXTERNAL_BACKENDS[backend];
231
+ return meta.displayModel ? meta.displayModel(model) : `${meta.prefix}${model}`;
232
+ }
233
+
234
+ // Narrows a BackendName to one that has an EXTERNAL_BACKENDS catalog entry. Agy is the only
235
+ // backend without one, so this is exactly "not Agy" and keeps index lookups type-safe.
236
+ export function isExternalBackend(backend: BackendName): backend is ExternalBackend {
237
+ return backend !== "agy";
238
+ }
239
+
192
240
  export function parseBackendOption(value: string): { backend: ExternalBackend; id: string } | undefined {
193
241
  for (const meta of Object.values(EXTERNAL_BACKENDS)) {
194
242
  if (value.startsWith(meta.prefix) && value.length > meta.prefix.length) {
@@ -198,6 +246,37 @@ export function parseBackendOption(value: string): { backend: ExternalBackend; i
198
246
  return undefined;
199
247
  }
200
248
 
249
+ // A single selectable Pi model: a physical, deduplicated "<provider>/<id>" entry.
250
+ export interface PiModelChoice {
251
+ /** Canonical "<provider>/<id>" value stored in settings. */
252
+ value: string;
253
+ /** Human label for the picker. */
254
+ label: string;
255
+ }
256
+
257
+ /** Builds the `value` for a Pi model, splitting on the first "/" only. */
258
+ export function piModelValue(providerId: string, modelId: string): string {
259
+ return `${providerId}/${modelId}`;
260
+ }
261
+
262
+ // Turns resolved scoped-model entries (or registry models) into deduplicated, physical-only choices.
263
+ // Models reported as "pi-virtual" are dropped: they route elsewhere at request time and cannot be
264
+ // driven as a standalone in-process session.
265
+ export function toPiModelChoices(
266
+ entries: readonly { model: { provider: string; id: string; name?: string; api?: string } }[],
267
+ ): PiModelChoice[] {
268
+ const seen = new Set<string>();
269
+ const choices: PiModelChoice[] = [];
270
+ for (const { model } of entries) {
271
+ if (model.api === "pi-virtual") continue;
272
+ const value = piModelValue(model.provider, model.id);
273
+ if (seen.has(value)) continue;
274
+ seen.add(value);
275
+ choices.push({ value, label: model.name ? `${model.name} · ${value}` : value });
276
+ }
277
+ return choices;
278
+ }
279
+
201
280
  export function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
202
281
  if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
203
282
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
@@ -373,8 +452,8 @@ export function capabilityBackend(settings: BroSettings, capability: Capability)
373
452
  export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
374
453
  const pair = capabilityPair(settings, capability);
375
454
  const backend = capabilityBackend(settings, capability);
376
- if (backend in EXTERNAL_BACKENDS) {
377
- const meta = EXTERNAL_BACKENDS[backend as ExternalBackend];
455
+ if (isExternalBackend(backend)) {
456
+ const meta = EXTERNAL_BACKENDS[backend];
378
457
  if (!meta.isEffort(pair.effort)) {
379
458
  throw new Error(`\`${pair.effort}\` is not supported on the ${meta.label} backend. Run \`/bro config\` to fix this.`);
380
459
  }