@andares/pi-toolkits 0.3.1 → 0.4.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,137 @@
1
+ /**
2
+ * compact-model feature — persisted compact-model config store.
3
+ *
4
+ * State lives in `<agent-dir>/pi-toolkits-compact.json` (agent-dir from
5
+ * getAgentDir(), same convention as the review store). The file holds a
6
+ * single model key:
7
+ *
8
+ * { "model": "google/gemini-2.5-flash" }
9
+ *
10
+ * Unset = feature disabled: every compaction (manual /compact, auto-compact
11
+ * threshold, overflow recovery) uses pi's default behavior with the current
12
+ * session model, zero overhead.
13
+ *
14
+ * Robustness mirrors the review store: corrupted/unreadable files fall back
15
+ * to "no model configured" with a console.warn; writes are atomic
16
+ * (tmp + rename) and never throw.
17
+ */
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
27
+ import { COMPACT_FILE } from "./constants.js";
28
+
29
+ export class CompactStore {
30
+ private model: string | undefined;
31
+ private readonly file: string;
32
+ private loaded = false;
33
+ private readonly listeners = new Set<() => void>();
34
+
35
+ constructor(file: string = join(getAgentDir(), COMPACT_FILE)) {
36
+ this.file = file;
37
+ }
38
+
39
+ private ensureLoaded(): void {
40
+ if (this.loaded) return;
41
+ this.loaded = true;
42
+ try {
43
+ if (!existsSync(this.file)) return;
44
+ const raw = readFileSync(this.file, "utf8");
45
+ const parsed = JSON.parse(raw) as { model?: unknown };
46
+ if (
47
+ parsed &&
48
+ typeof parsed.model === "string" &&
49
+ parsed.model.includes("/")
50
+ ) {
51
+ this.model = parsed.model;
52
+ }
53
+ } catch (error) {
54
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
55
+ console.warn(
56
+ "[pi-toolkits/compact] Failed to load compact-model config, starting unset:",
57
+ error instanceof Error ? error.message : String(error),
58
+ );
59
+ this.model = undefined;
60
+ }
61
+ }
62
+
63
+ /** Atomically persist (tmp + rename). Never throws. */
64
+ save(): void {
65
+ try {
66
+ const dir = dirname(this.file);
67
+ if (!existsSync(dir)) {
68
+ mkdirSync(dir, { recursive: true });
69
+ }
70
+ const tmp = `${this.file}.tmp`;
71
+ writeFileSync(
72
+ tmp,
73
+ `${JSON.stringify({ model: this.model ?? null }, null, 2)}\n`,
74
+ "utf8",
75
+ );
76
+ renameSync(tmp, this.file);
77
+ } catch (error) {
78
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
79
+ console.warn(
80
+ "[pi-toolkits/compact] Failed to save compact-model config:",
81
+ error instanceof Error ? error.message : String(error),
82
+ );
83
+ }
84
+ }
85
+
86
+ /** The configured compact model key ("provider/id"), or undefined. */
87
+ getModel(): string | undefined {
88
+ this.ensureLoaded();
89
+ return this.model;
90
+ }
91
+
92
+ /** Set (or clear with undefined) the compact model key and persist. */
93
+ setModel(key: string | undefined): void {
94
+ this.ensureLoaded();
95
+ this.model = key;
96
+ this.save();
97
+ this.notify();
98
+ }
99
+
100
+ subscribe(listener: () => void): () => void {
101
+ this.listeners.add(listener);
102
+ return () => {
103
+ this.listeners.delete(listener);
104
+ };
105
+ }
106
+
107
+ private notify(): void {
108
+ for (const listener of [...this.listeners]) {
109
+ try {
110
+ listener();
111
+ } catch {
112
+ // A broken listener must not break compact config handling.
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Process-wide store shared by the session_before_compact handler and the
120
+ * /compact-model command.
121
+ *
122
+ * Accessed via getCompactStore() (lazy) so tests can swap in an isolated
123
+ * store via setSharedCompactStore().
124
+ */
125
+ let sharedStore: CompactStore | undefined;
126
+
127
+ export function getCompactStore(): CompactStore {
128
+ if (!sharedStore) {
129
+ sharedStore = new CompactStore();
130
+ }
131
+ return sharedStore;
132
+ }
133
+
134
+ /** Test hook — replace the shared store (e.g. with one backed by a temp file). */
135
+ export function setSharedCompactStore(store: CompactStore): void {
136
+ sharedStore = store;
137
+ }
@@ -27,6 +27,8 @@
27
27
  import type {
28
28
  ExtensionAPI,
29
29
  ExtensionContext,
30
+ ModelSelectEvent,
31
+ SessionStartEvent,
30
32
  Theme,
31
33
  } from "@earendil-works/pi-coding-agent";
32
34
  import { FAVORITES_STATUS_KEY } from "./constants.js";
@@ -68,13 +70,13 @@ export function registerFavorites(pi: ExtensionAPI): void {
68
70
  // selector. setStatus is idempotent and re-synced on every session_start.
69
71
  let latestCtx: ExtensionContext | undefined;
70
72
 
71
- pi.on("session_start", (_event, ctx) => {
73
+ pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
72
74
  latestCtx = ctx;
73
75
  captureTheme(ctx);
74
76
  updateFooterStatus(ctx);
75
77
  });
76
78
 
77
- pi.on("model_select", (_event, ctx) => {
79
+ pi.on("model_select", (_event: ModelSelectEvent, ctx: ExtensionContext) => {
78
80
  captureTheme(ctx);
79
81
  updateFooterStatus(ctx);
80
82
  });
@@ -90,7 +92,7 @@ export function registerFavorites(pi: ExtensionAPI): void {
90
92
 
91
93
  pi.registerCommand("favorites", {
92
94
  description: "List favorited models (add/remove with ctrl+F in /model)",
93
- handler: async (_args, ctx) => {
95
+ handler: async (_args: string, ctx: ExtensionContext) => {
94
96
  captureTheme(ctx);
95
97
  const list = getFavoritesStore().list();
96
98
  if (list.length === 0) {
@@ -7,14 +7,16 @@
7
7
  * one favorite exists, cycling is restricted to favorited models:
8
8
  *
9
9
  * - candidates come from the same source stock cycling would use (scoped
10
- * models if any, else the available-model snapshot), filtered to favorites
10
+ * models if any — filtered to currently available ones like stock —
11
+ * else the available-model snapshot), filtered to favorites
11
12
  * - if the current model is not a favorite: forward jumps to the first
12
13
  * favorite, backward to the last (user-confirmed)
13
14
  * - with exactly one favorite, already on it → undefined (stock "only one
14
15
  * model available" feedback); otherwise jump to it
15
- * - the apply logic mirrors _cycleAvailableModel (state swap, session +
16
- * settings persistence, thinking-level re-clamp, model_select emit with
17
- * source "cycle")
16
+ * - the apply logic mirrors pi 0.87's _cycleScopedModel/_cycleAvailableModel
17
+ * (state swap, session persistence, default-model persistence only when
18
+ * `options.persist` is set, thinking-level re-clamp, model_select emit
19
+ * with source "cycle")
18
20
  *
19
21
  * Version guard: missing cycleModel → warn and skip; the patch is idempotent
20
22
  * across /reload.
@@ -23,9 +25,17 @@ import {
23
25
  AgentSession,
24
26
  type ModelCycleResult,
25
27
  } from "@earendil-works/pi-coding-agent";
26
- import type { Model } from "@earendil-works/pi-ai";
27
28
  import { getFavoritesStore, modelKey } from "./store.js";
28
29
 
30
+ /**
31
+ * pi's `Model<any>` — the exact type of `ModelCycleResult.model` in
32
+ * pi-coding-agent 0.87. Derived here instead of importing `Model` from
33
+ * @earendil-works/pi-ai, whose 0.87 index.d.ts re-exports it via an
34
+ * extensioned `"./types.ts"` specifier that not every TS resolver substitutes
35
+ * to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
36
+ */
37
+ type AnyModel = NonNullable<ModelCycleResult>["model"];
38
+
29
39
  /**
30
40
  * Structural view of the AgentSession internals the patch touches.
31
41
  * AgentSession's members are TS-private (compile-time only), so this view is
@@ -33,16 +43,21 @@ import { getFavoritesStore, modelKey } from "./store.js";
33
43
  */
34
44
  interface CycleSessionInternals {
35
45
  // pi-lens-ignore: no-any-type, — pi's Model generic requires `any` (constraint is Api); pi itself uses Model<any> everywhere
36
- _scopedModels: ReadonlyArray<{ model: Model<any>; thinkingLevel?: string }>;
46
+ _scopedModels: ReadonlyArray<{ model: AnyModel; thinkingLevel?: string }>;
37
47
  // pi-lens-ignore: no-any-type, — same as above
38
- _modelRuntime: { getAvailableSnapshot(): Model<any>[] };
48
+ _modelRuntime: { getAvailableSnapshot(): AnyModel[] };
39
49
  // pi-lens-ignore: no-any-type, — same as above
40
- model: Model<any>;
50
+ model: AnyModel;
41
51
  agent: { state: { model: unknown } };
42
52
  sessionManager: { appendModelChange(provider: string, id: string): void };
43
53
  settingsManager: {
44
54
  setDefaultModelAndProvider(provider: string, id: string): void;
45
55
  };
56
+ /**
57
+ * pi 0.87: keeps a persisted default usable when cycling inside a
58
+ * non-empty --models scope.
59
+ */
60
+ _addPersistedDefaultToNonEmptyScope(model: unknown): void;
46
61
  setThinkingLevel(level: unknown): void;
47
62
  _getThinkingLevelForModelSwitch(
48
63
  explicitLevel?: string,
@@ -56,8 +71,7 @@ interface CycleSessionInternals {
56
71
  }
57
72
 
58
73
  interface CycleCandidate {
59
- // pi-lens-ignore: no-any-type, — matches pi's Model<any> API
60
- model: Model<any>;
74
+ model: AnyModel;
61
75
  thinkingLevel?: string;
62
76
  }
63
77
 
@@ -81,15 +95,24 @@ function nextCycleIndex(
81
95
  : (currentIndex - 1 + len) % len;
82
96
  }
83
97
 
98
+ /** Options accepted by pi 0.87's cycleModel (persist gates default-model persistence). */
99
+ interface CycleOptions {
100
+ persist?: boolean;
101
+ }
102
+
84
103
  let patched = false;
85
104
 
86
105
  /** Restrict ctrl+p cycling to favorites. Idempotent; returns false if skipped. */
87
106
  export function applyCyclePatch(): boolean {
88
107
  if (patched) return true;
108
+ // SAFETY: AgentSession's members are TS-private (compile-time only), so a
109
+ // structural view of the runtime prototype is safe to cast to; guarded by
110
+ // the typeof check below before any patching happens.
89
111
  const proto = AgentSession.prototype as unknown as {
90
112
  cycleModel(
91
113
  this: CycleSessionInternals,
92
114
  direction?: "forward" | "backward",
115
+ options?: CycleOptions,
93
116
  ): Promise<ModelCycleResult | undefined>;
94
117
  };
95
118
  if (typeof proto.cycleModel !== "function") {
@@ -105,23 +128,37 @@ export function applyCyclePatch(): boolean {
105
128
  proto.cycleModel = async function (
106
129
  this: CycleSessionInternals,
107
130
  direction: "forward" | "backward" = "forward",
131
+ options: CycleOptions = {},
108
132
  ): Promise<ModelCycleResult | undefined> {
109
133
  if (
110
134
  !getFavoritesStore().getConfig().cycleOnlyFavorites ||
111
135
  !getFavoritesStore().hasAny()
112
136
  ) {
113
- return original.call(this, direction);
137
+ return original.call(this, direction, options);
114
138
  }
115
139
 
140
+ // Same source and availability filtering as pi 0.87's stock cycling.
116
141
  const scoped = this._scopedModels.length > 0;
117
- const candidates: CycleCandidate[] = scoped
118
- ? this._scopedModels.map((sm) => ({
142
+ let candidates: CycleCandidate[];
143
+ if (scoped) {
144
+ const availableIds = new Set(
145
+ this._modelRuntime
146
+ .getAvailableSnapshot()
147
+ .map((model) => `${model.provider}\0${model.id}`),
148
+ );
149
+ candidates = this._scopedModels
150
+ .filter(
151
+ (sm) => availableIds.has(`${sm.model.provider}\0${sm.model.id}`),
152
+ )
153
+ .map((sm) => ({
119
154
  model: sm.model,
120
155
  thinkingLevel: sm.thinkingLevel,
121
- }))
122
- : this._modelRuntime
123
- .getAvailableSnapshot()
124
- .map((model) => ({ model, thinkingLevel: undefined }));
156
+ }));
157
+ } else {
158
+ candidates = this._modelRuntime
159
+ .getAvailableSnapshot()
160
+ .map((model) => ({ model, thinkingLevel: undefined }));
161
+ }
125
162
  const favorites = candidates.filter((candidate) =>
126
163
  getFavoritesStore().has(candidate.model),
127
164
  );
@@ -148,10 +185,14 @@ export function applyCyclePatch(): boolean {
148
185
  : this._getThinkingLevelForModelSwitch();
149
186
  this.agent.state.model = next.model;
150
187
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
151
- this.settingsManager.setDefaultModelAndProvider(
152
- next.model.provider,
153
- next.model.id,
154
- );
188
+ // pi 0.87: only persist the default model when the caller asked for it.
189
+ if (options.persist) {
190
+ this.settingsManager.setDefaultModelAndProvider(
191
+ next.model.provider,
192
+ next.model.id,
193
+ );
194
+ this._addPersistedDefaultToNonEmptyScope(next.model);
195
+ }
155
196
  this.setThinkingLevel(thinkingLevel);
156
197
  await this._emitModelSelect(next.model, current, "cycle");
157
198
  return {
@@ -58,6 +58,8 @@ interface SelectorPatchTarget {
58
58
  handleInput(keyData: string): void;
59
59
  updateList(): void;
60
60
  filterModels(query: string): void;
61
+ /** pi 0.87: default-model badge source. */
62
+ isDefaultModel(model: ModelRef): boolean;
61
63
  listContainer: ListContainerLike;
62
64
  searchInput: SearchInputLike;
63
65
  filteredModels: ModelItem[];
@@ -110,6 +112,9 @@ let patched = false;
110
112
  /** Apply the three selector patches. Idempotent; returns false if skipped. */
111
113
  export function applyModelSelectorPatches(): boolean {
112
114
  if (patched) return true;
115
+ // SAFETY: ModelSelectorComponent's members are TS-private (compile-time
116
+ // only); the structural view matches pi 0.87's prototype shape and every
117
+ // touched method is existence-checked below before any patching happens.
113
118
  const proto =
114
119
  ModelSelectorComponent.prototype as unknown as SelectorPatchTarget;
115
120
  if (
@@ -146,9 +151,10 @@ export function applyModelSelectorPatches(): boolean {
146
151
  };
147
152
 
148
153
  // ── 2. List rendering: hint row + bold bright yellow favorites ────────
149
- // Replicates the built-in updateList layout (current-model check, selected
150
- // row, scroll indicator, empty/error states, refresh status) with one
151
- // change: favorited model ids render in the favorite style.
154
+ // Replicates the built-in updateList layout (pi 0.87: fixed-width cursor
155
+ // and ✓ current-marker columns, provider badge, `· default` badge, scroll
156
+ // indicator, empty/error states, refresh status, Model Name footer) with
157
+ // one change: favorited model ids render in the favorite style.
152
158
  proto.updateList = function (this: SelectorPatchTarget): void {
153
159
  const theme = themeProvider();
154
160
  if (!theme) {
@@ -178,24 +184,20 @@ export function applyModelSelectorPatches(): boolean {
178
184
  const isCurrent =
179
185
  this.currentModel !== undefined &&
180
186
  sameModel(item.model, this.currentModel);
187
+ const isDefault = this.isDefaultModel(item.model);
181
188
  const isFavorite = getFavoritesStore().has(item.model);
182
- let line: string;
183
- if (isSelected) {
184
- const prefix = theme.fg("accent", "→ ");
185
- const modelText = isFavorite
186
- ? styleFavorite(item.id)
187
- : theme.fg("accent", item.id);
188
- const providerBadge = theme.fg("muted", `[${item.provider}]`);
189
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
190
- line = `${prefix + modelText} ${providerBadge}${checkmark}`;
191
- } else {
192
- const modelText = isFavorite
193
- ? ` ${styleFavorite(item.id)}`
194
- : ` ${item.id}`;
195
- const providerBadge = theme.fg("muted", `[${item.provider}]`);
196
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
197
- line = `${modelText} ${providerBadge}${checkmark}`;
198
- }
189
+ const defaultBadge = isDefault
190
+ ? theme.fg("muted", " · default")
191
+ : "";
192
+ const cursor = isSelected ? theme.fg("accent", "→ ") : " ";
193
+ const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " ";
194
+ const modelText = isFavorite
195
+ ? styleFavorite(item.id)
196
+ : isSelected
197
+ ? theme.fg("accent", item.id)
198
+ : item.id;
199
+ const providerBadge = theme.fg("muted", `[${item.provider}]`);
200
+ const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`;
199
201
  list.addChild(new Text(line, 0, 0));
200
202
  }
201
203
  if (startIndex > 0 || endIndex < this.filteredModels.length) {
@@ -23,27 +23,17 @@
23
23
  import type {
24
24
  ExtensionAPI,
25
25
  ExtensionContext,
26
+ SessionStartEvent,
26
27
  Theme,
27
28
  } from "@earendil-works/pi-coding-agent";
28
- import type { Api, Model } from "@earendil-works/pi-ai";
29
+ import {
30
+ modelKey,
31
+ resolveConfiguredModel,
32
+ type AvailableModel,
33
+ } from "../../lib/model-config.js";
29
34
  import { REVIEW_STATUS_KEY } from "./constants.js";
30
35
  import { REVIEW_PROMPT } from "./prompt.js";
31
- import { getReviewStore, modelKey, type ReviewStore } from "./store.js";
32
-
33
- /** Resolve the configured review model against the available model list. */
34
- export function resolveConfiguredModel(
35
- ctx: ExtensionContext,
36
- key: string | undefined,
37
- ): Model<Api> | undefined {
38
- if (!key) return undefined;
39
- const slash = key.indexOf("/");
40
- if (slash <= 0 || slash === key.length - 1) return undefined;
41
- const provider = key.slice(0, slash);
42
- const id = key.slice(slash + 1);
43
- return ctx.modelRegistry
44
- .getAvailable()
45
- .find((m) => m.provider === provider && m.id === id);
46
- }
36
+ import { getReviewStore, type ReviewStore } from "./store.js";
47
37
 
48
38
  /**
49
39
  * Let the user pick a review model from the available list and persist the
@@ -55,7 +45,7 @@ export function resolveConfiguredModel(
55
45
  export async function pickReviewModel(
56
46
  ctx: ExtensionContext,
57
47
  store: ReviewStore,
58
- ): Promise<Model<Api> | undefined> {
48
+ ): Promise<AvailableModel | undefined> {
59
49
  // Dialog-capable UI exists in TUI and RPC modes (hasUI); json/print and
60
50
  // headless contexts have no dialogs, so fall back to a manual-config hint.
61
51
  if (!ctx.hasUI) {
@@ -75,7 +65,7 @@ export async function pickReviewModel(
75
65
  }
76
66
  const configured = store.getModel();
77
67
  // Same display source as the /model selector: name + [provider]/id.
78
- const options = available.map((m) => `${m.name} (${m.provider}/${m.id})`);
68
+ const options = available.map((m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`);
79
69
  const choice = await ctx.ui.select(
80
70
  configured
81
71
  ? `选择评审模型 (当前: ${configured})`
@@ -159,7 +149,7 @@ export function registerReview(pi: ExtensionAPI): void {
159
149
  pi.registerCommand("third-review", {
160
150
  description:
161
151
  "Summon the configured review model to review the just-completed work (check + fix)",
162
- handler: async (_args, ctx) => {
152
+ handler: async (_args: string, ctx: ExtensionContext) => {
163
153
  await runReview(ctx);
164
154
  },
165
155
  });
@@ -170,7 +160,7 @@ export function registerReview(pi: ExtensionAPI): void {
170
160
  pi.registerCommand("review-model", {
171
161
  description:
172
162
  "Set or change the review model used by third-review (picker shows the current one)",
173
- handler: async (_args, ctx) => {
163
+ handler: async (_args: string, ctx: ExtensionContext) => {
174
164
  await pickReviewModel(ctx, getReviewStore());
175
165
  },
176
166
  });
@@ -188,7 +178,7 @@ export function registerReview(pi: ExtensionAPI): void {
188
178
  );
189
179
  };
190
180
 
191
- pi.on("session_start", (_event, ctx) => {
181
+ pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
192
182
  latestCtx = ctx;
193
183
  try {
194
184
  const t = ctx.ui.theme;
@@ -34,13 +34,15 @@ import {
34
34
  import {
35
35
  createReviewRunner,
36
36
  pickReviewModel,
37
- resolveConfiguredModel,
38
37
  } from "./index.js";
38
+ import {
39
+ modelKey,
40
+ resolveConfiguredModel,
41
+ } from "../../lib/model-config.js";
39
42
  import { REVIEW_PROMPT } from "./prompt.js";
40
43
  import {
41
44
  ReviewStore,
42
45
  getReviewStore,
43
- modelKey,
44
46
  setSharedReviewStore,
45
47
  } from "./store.js";
46
48
 
@@ -22,11 +22,6 @@ import { dirname, join } from "node:path";
22
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
23
  import { REVIEW_FILE } from "./constants.js";
24
24
 
25
- /** Model identity used as the review-model key: "provider/id". */
26
- export function modelKey(model: { provider: string; id: string }): string {
27
- return `${model.provider}/${model.id}`;
28
- }
29
-
30
25
  export class ReviewStore {
31
26
  private model: string | undefined;
32
27
  private readonly file: string;
package/src/index.ts CHANGED
@@ -9,15 +9,18 @@
9
9
  * - favorites — model favorites (ctrl+F/ctrl+J in /model, favorites-only cycling)
10
10
  * - stash — prompt stash (ctrl+alt+y swap / double-press stash-and-clear)
11
11
  * - review — third-party code review with a configured review model (third-review)
12
+ * - compact — dedicated compact model for /compact + auto-compact summaries
12
13
  */
13
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
  import { registerAsk } from "./features/ask/index.js";
16
+ import { registerCompact } from "./features/compact/index.js";
15
17
  import { registerFavorites } from "./features/favorites/index.js";
16
18
  import { registerReview } from "./features/review/index.js";
17
19
  import { registerStash } from "./features/stash/index.js";
18
20
 
19
21
  export default function piToolkits(pi: ExtensionAPI): void {
20
22
  registerAsk(pi);
23
+ registerCompact(pi);
21
24
  registerFavorites(pi);
22
25
  registerReview(pi);
23
26
  registerStash(pi);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared model-config helpers for features that persist a "provider/id"
3
+ * model key and resolve it against the currently available model list
4
+ * (review, compact-model — favorites keeps its own store-local key logic).
5
+ *
6
+ * Types are derived from pi-coding-agent's own exports (ModelRegistry)
7
+ * rather than imported from @earendil-works/pi-ai: pi-ai 0.87's index.d.ts
8
+ * re-exports `Model`/`Api` via extensioned `./types.ts` specifiers, which
9
+ * tsc 5.9 bundler resolution substitutes to `.d.ts` but some tsserver
10
+ * builds do not. Deriving from ModelRegistry keeps every resolver in
11
+ * agreement with the exact type `ctx.modelRegistry.getAvailable()` returns.
12
+ */
13
+ import type {
14
+ ExtensionContext,
15
+ ModelRegistry,
16
+ } from "@earendil-works/pi-coding-agent";
17
+
18
+ /** The model type pi's ModelRegistry exposes (`Model<Api>`). */
19
+ export type AvailableModel = ReturnType<ModelRegistry["getAvailable"]>[number];
20
+
21
+ /** Model identity used as a persisted config key: "provider/id". */
22
+ export function modelKey(model: { provider: string; id: string }): string {
23
+ return `${model.provider}/${model.id}`;
24
+ }
25
+
26
+ /**
27
+ * Resolve a persisted "provider/id" key against the available model list.
28
+ * Ids may themselves contain slashes (e.g. openrouter/openai/gpt-5), so only
29
+ * the first slash splits provider from id. Returns undefined for missing,
30
+ * malformed, or currently-unavailable keys.
31
+ */
32
+ export function resolveConfiguredModel(
33
+ ctx: ExtensionContext,
34
+ key: string | undefined,
35
+ ): AvailableModel | undefined {
36
+ if (!key) return undefined;
37
+ const slash = key.indexOf("/");
38
+ if (slash <= 0 || slash === key.length - 1) return undefined;
39
+ const provider = key.slice(0, slash);
40
+ const id = key.slice(slash + 1);
41
+ return ctx.modelRegistry
42
+ .getAvailable()
43
+ .find((m) => m.provider === provider && m.id === id);
44
+ }