@benvargas/pi-model-sort 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2026-08-28
11
+
12
+ ### Added
13
+ - Initial `@benvargas/pi-model-sort` package, forked from
14
+ [monotykamary/pi-model-sort](https://github.com/monotykamary/pi-model-sort)
15
+ v0.3.2 (MIT): last-usage sorting for the `/model` picker (all and scoped
16
+ views, fuzzy-search results) and scoped Ctrl+P cycling; MRU model selection
17
+ on fresh starts; per-model thinking level memory; persistent usage history
18
+ in `~/.pi/agent/extensions/pi-model-sort.json`.
19
+ - Continued sessions (`pi -c`, `--session`, `/resume`, context-bearing
20
+ forks) record their restored model as last-used — pi 0.84.3 restores without
21
+ emitting `model_select`, so the extension timestamps it at `session_start`
22
+ (fresh `new` sessions and `reload` are excluded).
23
+ - Unit tests for continuation detection (including pi's new-session entry
24
+ seeding, context-only summarized branches, and compaction-only branches),
25
+ MRU auth fallback, restore-timestamp gating, and config sanitization.
26
+
27
+ ### Changed
28
+ - Fork change from upstream: the MRU startup override skips continued
29
+ sessions. Upstream applies the override on every `startup` session start,
30
+ which replaces the model restored by `pi -c` (or `--session`) with the
31
+ global most-recently-used model. Continuation is detected by projecting
32
+ the branch through pi's context-message rules (`message`, `custom_message`,
33
+ non-empty `branch_summary`, `compaction` — the same predicate
34
+ `buildSessionContext()` uses) — pi seeds every new session with
35
+ `model_change` + `thinking_level_change` entries before `session_start`
36
+ fires, so raw branch length would disable the override everywhere, and
37
+ literal-message-only detection would miss context-only summarized
38
+ branches.
39
+ - Registry cleanup on `session_shutdown` (`unpatchRegistry` is defined but
40
+ never called upstream).
41
+ - Documentation narrowed to the surfaces actually affected on pi 0.84.x:
42
+ `--list-models`, `/scoped-models`, and unscoped Ctrl+P cycling read
43
+ `ModelRuntime` directly and are not re-sorted (upstream README claims
44
+ otherwise for older pi versions).
45
+ - Peer dependency floor set to the tested-with release
46
+ (`@earendil-works/pi-coding-agent >=0.84.0`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Vargas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @benvargas/pi-model-sort
2
+
3
+ Sorts pi's model picker by last usage and starts fresh sessions on your most recently used model.
4
+
5
+ - `/model` picker — both "Scope: all" and "Scope: scoped" views, including fuzzy-search results — is sorted by recency: current model first → most recently used → provider/id alphabetical
6
+ - Ctrl+P / Ctrl+Shift+P **scoped** cycling follows last-used order (scoped models come from `enabledModels` or `--models`)
7
+ - Fresh starts and `/new` begin on your most recently used model instead of `enabledModels[0]` or the hardcoded provider default
8
+ - Continued sessions (`pi -c`, `--session`, `/resume`, forks) keep the model saved in the session file, and that restored model is recorded as last-used (fresh `/new` sessions and `/reload` are excluded from that recording)
9
+ - Remembers the thinking level you last used on each model and restores it on every switch, clamped to what each model supports
10
+ - No configuration needed — tracking starts on first use and degrades to the default alphabetical order with no history
11
+
12
+ > Forked from [monotykamary/pi-model-sort](https://github.com/monotykamary/pi-model-sort) (MIT, v0.3.2). See `THIRD_PARTY_NOTICES.md` for the full list of fork changes and attribution.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:@benvargas/pi-model-sort
18
+ ```
19
+
20
+ Or try without installing:
21
+
22
+ ```bash
23
+ pi -e npm:@benvargas/pi-model-sort
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ The extension works automatically — there are no commands to learn.
29
+
30
+ ```
31
+ /model # Most recently used models appear at the top
32
+ Ctrl+P / Ctrl+Shift+P # Cycle through scoped models in last-used order
33
+ pi # Fresh starts use MRU
34
+ pi -c # Continuations keep the session's model
35
+ ```
36
+
37
+ ## How It Works
38
+
39
+ - Tracking uses pi's documented extension events: `/model` switches (`model_select`) and thinking-level changes (`thinking_level_select`) are timestamped into `~/.pi/agent/extensions/pi-model-sort.json`. Continued sessions restore their model during construction without emitting `model_select` (pi 0.84.3), so the extension records the restored model at `session_start` itself.
40
+ - Sorting has no SDK hook, so the extension wraps (monkey-patches) internal methods: `ModelSelectorComponent.sortModels`, its scoped loader and `filterModels`, and `AgentSession._cycleScopedModel` for scoped cycling. All original methods are preserved and restored on shutdown/reload; the patches survive `modelRegistry.refresh()`.
41
+ - The MRU startup override calls `pi.setModel()` on `session_start` for fresh starts and `/new` only. A continued session is detected by projecting its branch through pi's own context-message rules (`message`, `custom_message`, non-empty `branch_summary`, and `compaction` entries — exactly what `buildSessionContext()` counts) — pi seeds every new session with `model_change` + `thinking_level_change` entries before `session_start` fires, so raw branch length cannot distinguish fresh from continued.
42
+
43
+ ### Known limitations on pi 0.84.x
44
+
45
+ - `--list-models` output is **not** sorted: the CLI lists models before extensions load and re-sorts by provider/id internally.
46
+ - `/scoped-models` and unscoped Ctrl+P cycling are **not** re-sorted: they read `ModelRuntime` snapshots directly, not the extension-facing `ModelRegistry` facade this extension wraps. (Scoped cycling — the common case with `enabledModels` set — is sorted via the `_cycleScopedModel` wrapper.)
47
+
48
+ ## Configuration
49
+
50
+ Usage history lives in `~/.pi/agent/extensions/pi-model-sort.json`:
51
+
52
+ ```json
53
+ {
54
+ "lastUsed": {
55
+ "provider/modelId": 1717000000000
56
+ },
57
+ "thinking": {
58
+ "provider/modelId": "high"
59
+ }
60
+ }
61
+ ```
62
+
63
+ No manual editing is needed. To clear usage history, delete the file and `/reload`.
64
+
65
+ ## Notes
66
+
67
+ - Ctrl+P cycling does not update last-used timestamps — doing so would create a sort feedback loop (each cycle step re-sorts the selected model to the top, making the cycle toggle forever between the top two). Manual selections and session restores still update it.
68
+ - Patches are coupled to pi internals; this package is maintained against current pi releases (peer requirement `>=0.84.0`, the version it is tested with).
69
+
70
+ ## Uninstall
71
+
72
+ ```bash
73
+ pi remove npm:@benvargas/pi-model-sort
74
+ ```
@@ -0,0 +1,50 @@
1
+ # Third-Party Notices
2
+
3
+ ## pi-model-sort
4
+
5
+ This package is adapted from [monotykamary/pi-model-sort](https://github.com/monotykamary/pi-model-sort),
6
+ version 0.3.2, by Tom X Nguyen. The upstream project is MIT-licensed (see its
7
+ `package.json`); it did not ship a standalone `LICENSE` file at the time of
8
+ forking.
9
+
10
+ Modifications in this fork:
11
+
12
+ - The MRU startup override skips continued sessions (`pi -c`, `--session`)
13
+ so the model restored from the session file is preserved; continuation is
14
+ detected by projecting the branch through pi's context-message rules
15
+ (message, custom_message, non-empty branch_summary, compaction entries)
16
+ because pi seeds new sessions with model/thinking entries before
17
+ `session_start`.
18
+ - Continued sessions record their restored model as last-used (pi 0.84.3
19
+ does not emit `model_select` for construction-time restoration).
20
+ - `unpatchRegistry()` (defined but never called upstream) is invoked on
21
+ `session_shutdown` so registry cleanup is symmetric with the other patches.
22
+ - Upstream non-null assertions in the scoped-loader, filter, and cycle
23
+ wrappers were replaced with runtime null guards.
24
+ - `findMruModel` moved into the shared helpers module and is exported for
25
+ testing.
26
+ - Code reformatted to this repository's Biome configuration and restructured
27
+ to the `extensions/` package layout; documentation corrected for the
28
+ surfaces actually affected on pi 0.84.x.
29
+
30
+ MIT License
31
+
32
+ Copyright (c) 2026 Tom X Nguyen
33
+
34
+ Permission is hereby granted, free of charge, to any person obtaining a copy
35
+ of this software and associated documentation files (the "Software"), to deal
36
+ in the Software without restriction, including without limitation the rights
37
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
38
+ copies of the Software, and to permit persons to whom the Software is
39
+ furnished to do so, subject to the following conditions:
40
+
41
+ The above copyright notice and this permission notice shall be included in all
42
+ copies or substantial portions of the Software.
43
+
44
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
45
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
46
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
47
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
48
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
49
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
50
+ SOFTWARE.
@@ -0,0 +1,469 @@
1
+ /**
2
+ * pi-model-sort — sort models in pi by last usage (descending) and start
3
+ * fresh sessions on the most recently used model.
4
+ *
5
+ * Adapted from monotykamary/pi-model-sort (MIT). See THIRD_PARTY_NOTICES.md.
6
+ * Fork change: the MRU startup override skips continued sessions (`pi -c`,
7
+ * `--session`) so the model restored from the session file is preserved.
8
+ *
9
+ * Strategy: monkey-patches three areas:
10
+ * ModelSelectorComponent.prototype.sortModels, filterModels, and the scoped
11
+ * loader (loadModelsFromSnapshot on pi 0.80.8+, loadModels on older pi) —
12
+ * sorts both "Scope: all" and "Scope: scoped" views in the /model TUI
13
+ * picker, including fuzzy-search results.
14
+ * AgentSession.prototype._cycleScopedModel — sorts the Ctrl+P / Ctrl+Shift+P
15
+ * scoped cycling order (non-destructively — the configured order is
16
+ * preserved).
17
+ * ModelRegistry getAvailable/getAll — sorts the extension-facing registry
18
+ * facade for any extension consumer. NOT affected on pi 0.84.x:
19
+ * --list-models (the CLI lists before extensions load), /scoped-models, and
20
+ * unscoped Ctrl+P cycling, which read ModelRuntime snapshots directly.
21
+ *
22
+ * Usage tracking: manual /model selections and switches via pi's model_select
23
+ * event (Ctrl+P cycles are deliberately excluded to avoid a sort feedback
24
+ * loop); continued-session restores are timestamped at session_start because
25
+ * pi 0.84.3 restores them without emitting model_select. Data persists to
26
+ * ~/.pi/agent/extensions/pi-model-sort.json.
27
+ *
28
+ * It also remembers the thinking level last used on each model and restores
29
+ * it on every switch (including Ctrl+P cycling), clamped to what the model
30
+ * supports — deepseek stays on max, claude on high, without manual
31
+ * re-adjustment after every switch.
32
+ *
33
+ * With no recorded usage, the sort degrades gracefully to the default
34
+ * provider/model-id alphabetical order.
35
+ */
36
+
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
40
+ import { AgentSession, getAgentDir, ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
41
+ import {
42
+ buildModelKey,
43
+ CONFIG_FILENAME,
44
+ createThinkingTracker,
45
+ findMruModel,
46
+ handleModelSelect,
47
+ hasContextMessages,
48
+ type ModelSortConfig,
49
+ parseConfig,
50
+ recordThinkingSelect,
51
+ shouldApplyMruOverride,
52
+ shouldTimestampRestoredModel,
53
+ sortByLastUsed,
54
+ } from "./sort.js";
55
+
56
+ const CONFIG_PATH = join(getAgentDir(), "extensions", CONFIG_FILENAME);
57
+
58
+ // Config I/O
59
+
60
+ function readConfig(): ModelSortConfig {
61
+ if (!existsSync(CONFIG_PATH)) {
62
+ return { lastUsed: {}, thinking: {} };
63
+ }
64
+ try {
65
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
66
+ return parseConfig(JSON.parse(raw));
67
+ } catch {
68
+ return { lastUsed: {}, thinking: {} };
69
+ }
70
+ }
71
+
72
+ function writeConfig(config: ModelSortConfig): void {
73
+ const dir = join(getAgentDir(), "extensions");
74
+ if (!existsSync(dir)) {
75
+ mkdirSync(dir, { recursive: true });
76
+ }
77
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
78
+ }
79
+
80
+ // ModelSelectorComponent sortModels patch
81
+
82
+ let origSortModels:
83
+ | ((models: Array<{ provider: string; id: string; model: unknown }>) => Array<{
84
+ provider: string;
85
+ id: string;
86
+ model: unknown;
87
+ }>)
88
+ | null = null;
89
+
90
+ function buildCurrentModelKey(instance: Record<string, unknown>): string | null {
91
+ const cm = instance.currentModel as { provider?: string; id?: string } | undefined;
92
+ if (cm?.provider && cm?.id) {
93
+ return buildModelKey(cm.provider, cm.id);
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function patchSortModels(getLastUsed: () => Record<string, number>): void {
99
+ if (origSortModels !== null) return;
100
+
101
+ const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
102
+ origSortModels = proto.sortModels as typeof origSortModels;
103
+
104
+ proto.sortModels = function (
105
+ this: Record<string, unknown>,
106
+ models: Array<{ provider: string; id: string; model: unknown }>,
107
+ ) {
108
+ const lastUsed = getLastUsed();
109
+ return sortByLastUsed(models, lastUsed, buildCurrentModelKey(this));
110
+ };
111
+ }
112
+
113
+ function unpatchSortModels(): void {
114
+ if (origSortModels === null) return;
115
+ (ModelSelectorComponent.prototype as unknown as Record<string, unknown>).sortModels = origSortModels;
116
+ origSortModels = null;
117
+ }
118
+
119
+ // ModelSelectorComponent scoped-loader patch — sorts scopedModelItems for
120
+ // the "Scope: scoped" toggle in the /model picker.
121
+ //
122
+ // pi 0.80.8 split the old loadModels() into a synchronous
123
+ // loadModelsFromSnapshot() (used for both the initial render and after each
124
+ // catalog refresh) plus a new async refreshModels(). The scoped items are built
125
+ // directly inside the snapshot loader and never pass through sortModels, so
126
+ // this patch re-sorts them there. We hook whichever method the running pi
127
+ // exposes — new (loadModelsFromSnapshot, sync) or old (loadModels, async) — so
128
+ // the sort applies on initial render, after a refresh, and (because
129
+ // scopedModelItems is re-sorted even when the active scope is "all") survives
130
+ // the Tab toggle to "scoped".
131
+
132
+ let origScopedLoader: ((this: unknown) => unknown) | null = null;
133
+ let scopedLoaderName: string | null = null;
134
+
135
+ function sortScopedItems(instance: Record<string, unknown>, getLastUsed: () => Record<string, number>): void {
136
+ const scopedItems = instance.scopedModelItems as Array<{ provider: string; id: string; model: unknown }> | undefined;
137
+ if (!scopedItems || scopedItems.length === 0) return;
138
+
139
+ const lastUsed = getLastUsed();
140
+ instance.scopedModelItems = sortByLastUsed(scopedItems, lastUsed, buildCurrentModelKey(instance));
141
+
142
+ if (instance.scope === "scoped") {
143
+ // Sync activeModels/filteredModels — the loader set them to the unsorted
144
+ // scopedModelItems before our patch had a chance to sort.
145
+ instance.activeModels = instance.scopedModelItems;
146
+ instance.filteredModels = instance.scopedModelItems;
147
+
148
+ // Recalculate selectedIndex — the loader computed it from the unsorted
149
+ // array, so the cursor is at the old position.
150
+ const currentKey = buildCurrentModelKey(instance);
151
+ if (currentKey) {
152
+ const filtered = instance.filteredModels as Array<{ provider: string; id: string }>;
153
+ const newIndex = filtered.findIndex((item) => buildModelKey(item.provider, item.id) === currentKey);
154
+ if (newIndex >= 0) {
155
+ instance.selectedIndex = newIndex;
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ function patchScopedLoader(getLastUsed: () => Record<string, number>): void {
162
+ if (origScopedLoader !== null) return;
163
+
164
+ const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
165
+
166
+ // pi 0.80.8+ — synchronous snapshot loader (initial render + after refresh).
167
+ if (typeof proto.loadModelsFromSnapshot === "function") {
168
+ origScopedLoader = proto.loadModelsFromSnapshot as (this: unknown) => unknown;
169
+ scopedLoaderName = "loadModelsFromSnapshot";
170
+ proto.loadModelsFromSnapshot = function (this: Record<string, unknown>) {
171
+ const orig = origScopedLoader;
172
+ if (!orig) return;
173
+ orig.call(this);
174
+ sortScopedItems(this, getLastUsed);
175
+ };
176
+ return;
177
+ }
178
+
179
+ // pi <= 0.80.3 — async loadModels.
180
+ if (typeof proto.loadModels === "function") {
181
+ origScopedLoader = proto.loadModels as (this: unknown) => unknown;
182
+ scopedLoaderName = "loadModels";
183
+ proto.loadModels = async function (this: Record<string, unknown>) {
184
+ const orig = origScopedLoader;
185
+ if (!orig) return;
186
+ await orig.call(this);
187
+ sortScopedItems(this, getLastUsed);
188
+ };
189
+ }
190
+ }
191
+
192
+ function unpatchScopedLoader(): void {
193
+ if (origScopedLoader === null || scopedLoaderName === null) return;
194
+ (ModelSelectorComponent.prototype as unknown as Record<string, unknown>)[scopedLoaderName] = origScopedLoader;
195
+ origScopedLoader = null;
196
+ scopedLoaderName = null;
197
+ }
198
+
199
+ // ModelSelectorComponent filterModels patch — re-applies last-used sort after
200
+ // fuzzyFilter re-orders results by match quality. Without this, typing in the
201
+ // /model picker search box discards the last-used order.
202
+
203
+ let origFilterModels: ((query: string) => void) | null = null;
204
+
205
+ function patchFilterModels(getLastUsed: () => Record<string, number>): void {
206
+ if (origFilterModels !== null) return;
207
+
208
+ const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
209
+ origFilterModels = proto.filterModels as (query: string) => void;
210
+
211
+ proto.filterModels = function (this: Record<string, unknown>, query: string) {
212
+ const orig = origFilterModels;
213
+ if (!orig) return;
214
+
215
+ // Suppress the original's updateList() call — we'll call it once after
216
+ // re-sorting to avoid a double-render.
217
+ const origUpdateList = this.updateList as () => void;
218
+ this.updateList = () => {};
219
+
220
+ try {
221
+ orig.call(this, query);
222
+ } finally {
223
+ this.updateList = origUpdateList;
224
+ }
225
+
226
+ // Empty query: nothing to re-sort (activeModels is already sorted by our
227
+ // sortModels/scoped-loader patches), just render the original result.
228
+ if (!query) {
229
+ origUpdateList.call(this);
230
+ return;
231
+ }
232
+
233
+ const filtered = this.filteredModels as Array<{ provider: string; id: string; model: unknown }> | undefined;
234
+ if (!filtered || filtered.length <= 1) {
235
+ origUpdateList.call(this);
236
+ return;
237
+ }
238
+
239
+ const lastUsed = getLastUsed();
240
+ this.filteredModels = sortByLastUsed(filtered, lastUsed, buildCurrentModelKey(this));
241
+
242
+ // Re-sync selectedIndex — fuzzyFilter may have moved the current model.
243
+ const currentKey = buildCurrentModelKey(this);
244
+ if (currentKey) {
245
+ const newFiltered = this.filteredModels as Array<{ provider: string; id: string }>;
246
+ const newIndex = newFiltered.findIndex((item) => buildModelKey(item.provider, item.id) === currentKey);
247
+ if (newIndex >= 0) {
248
+ this.selectedIndex = newIndex;
249
+ }
250
+ }
251
+
252
+ // Render once with the final sorted list.
253
+ origUpdateList.call(this);
254
+ };
255
+ }
256
+
257
+ function unpatchFilterModels(): void {
258
+ if (origFilterModels === null) return;
259
+ (ModelSelectorComponent.prototype as unknown as Record<string, unknown>).filterModels = origFilterModels;
260
+ origFilterModels = null;
261
+ }
262
+
263
+ // ModelRegistry getAvailable / getAll patch
264
+
265
+ const REGISTRY_PATCH_KEY = "__model_sort_registry_patched";
266
+
267
+ interface PatchedRegistry {
268
+ [REGISTRY_PATCH_KEY]: boolean;
269
+ getAvailable(): unknown[];
270
+ getAll(): unknown[];
271
+ __model_sort_get_last_used: () => Record<string, number>;
272
+ __model_sort_orig_getAvailable: () => unknown[];
273
+ __model_sort_orig_getAll: () => unknown[];
274
+ }
275
+
276
+ function patchRegistry(registry: PatchedRegistry, getLastUsed: () => Record<string, number>): void {
277
+ if (registry[REGISTRY_PATCH_KEY]) {
278
+ registry.__model_sort_get_last_used = getLastUsed;
279
+ return;
280
+ }
281
+
282
+ registry[REGISTRY_PATCH_KEY] = true;
283
+ registry.__model_sort_get_last_used = getLastUsed;
284
+
285
+ registry.__model_sort_orig_getAvailable = registry.getAvailable.bind(registry);
286
+ registry.__model_sort_orig_getAll = registry.getAll.bind(registry);
287
+
288
+ registry.getAvailable = function (this: PatchedRegistry) {
289
+ const lastUsed = this.__model_sort_get_last_used();
290
+ const all = this.__model_sort_orig_getAvailable() as Array<{ provider: string; id: string }>;
291
+ return sortByLastUsed(all, lastUsed, null);
292
+ };
293
+
294
+ registry.getAll = function (this: PatchedRegistry) {
295
+ const lastUsed = this.__model_sort_get_last_used();
296
+ const all = this.__model_sort_orig_getAll() as Array<{ provider: string; id: string }>;
297
+ return sortByLastUsed(all, lastUsed, null);
298
+ };
299
+ }
300
+
301
+ function unpatchRegistry(registry: PatchedRegistry): void {
302
+ if (!registry[REGISTRY_PATCH_KEY]) return;
303
+
304
+ registry.getAvailable = registry.__model_sort_orig_getAvailable;
305
+ registry.getAll = registry.__model_sort_orig_getAll;
306
+
307
+ const raw = registry as unknown as Record<string, unknown>;
308
+ delete raw[REGISTRY_PATCH_KEY];
309
+ delete raw.__model_sort_get_last_used;
310
+ delete raw.__model_sort_orig_getAvailable;
311
+ delete raw.__model_sort_orig_getAll;
312
+ }
313
+
314
+ // AgentSession _cycleScopedModel patch — sorts the scoped models list
315
+ // before cycling so Ctrl+P / Ctrl+Shift+P follows last-used order instead
316
+ // of the configured order. Non-destructive: the session's stored order is
317
+ // temporarily swapped and restored after the cycle lookup.
318
+
319
+ type ScopedModelEntry = { model: { provider: string; id: string }; thinkingLevel?: string };
320
+
321
+ let origCycleScopedModel: ((direction: string) => Promise<unknown>) | null = null;
322
+
323
+ function patchCycleScopedModel(getLastUsed: () => Record<string, number>): void {
324
+ if (origCycleScopedModel !== null) return;
325
+
326
+ const proto = AgentSession.prototype as unknown as Record<string, unknown>;
327
+ origCycleScopedModel = proto._cycleScopedModel as (direction: string) => Promise<unknown>;
328
+
329
+ proto._cycleScopedModel = async function (this: Record<string, unknown>, direction: string) {
330
+ const orig = origCycleScopedModel;
331
+ if (!orig) return undefined;
332
+
333
+ const lastUsed = getLastUsed();
334
+ const origScoped = this._scopedModels as ScopedModelEntry[] | undefined;
335
+
336
+ if (!origScoped || origScoped.length <= 1) {
337
+ return orig.call(this, direction);
338
+ }
339
+
340
+ // Sort by last-used without mutating the session's stored order.
341
+ const sorted = [...origScoped].sort((a, b) => {
342
+ const aKey = buildModelKey(a.model.provider, a.model.id);
343
+ const bKey = buildModelKey(b.model.provider, b.model.id);
344
+ const aLast = lastUsed[aKey] ?? 0;
345
+ const bLast = lastUsed[bKey] ?? 0;
346
+ if (aLast !== bLast) return bLast - aLast;
347
+ return a.model.provider.localeCompare(b.model.provider) || a.model.id.localeCompare(b.model.id);
348
+ });
349
+
350
+ // Temporarily swap for the cycle lookup, restore afterward.
351
+ this._scopedModels = sorted;
352
+ try {
353
+ return await orig.call(this, direction);
354
+ } finally {
355
+ this._scopedModels = origScoped;
356
+ }
357
+ };
358
+ }
359
+
360
+ function unpatchCycleScopedModel(): void {
361
+ if (origCycleScopedModel === null) return;
362
+ (AgentSession.prototype as unknown as Record<string, unknown>)._cycleScopedModel = origCycleScopedModel;
363
+ origCycleScopedModel = null;
364
+ }
365
+
366
+ // Extension
367
+
368
+ export default function (pi: ExtensionAPI) {
369
+ let lastUsed: Record<string, number> = {};
370
+ const tracker = createThinkingTracker();
371
+
372
+ pi.on("session_start", async (event, ctx) => {
373
+ const config = readConfig();
374
+ lastUsed = config.lastUsed;
375
+ tracker.thinking = config.thinking;
376
+ tracker.activeKey = ctx.model ? buildModelKey(ctx.model.provider, ctx.model.id) : null;
377
+ tracker.sawSwitchClamp = false;
378
+
379
+ patchRegistry(ctx.modelRegistry as unknown as PatchedRegistry, () => lastUsed);
380
+ patchSortModels(() => lastUsed);
381
+ patchScopedLoader(() => lastUsed);
382
+ patchFilterModels(() => lastUsed);
383
+ patchCycleScopedModel(() => lastUsed);
384
+
385
+ // Override the initial model to MRU on fresh starts.
386
+ // Pi core picks the saved default if in scope, otherwise scopedModels[0].
387
+ // This override switches to the most recently used model instead, so your
388
+ // actual usage history determines the default — not scope order.
389
+ //
390
+ // Continued sessions (pi -c, --session, /resume, forks) are skipped: pi
391
+ // has already restored the model saved in the session file, and global
392
+ // MRU should not clobber it.
393
+ //
394
+ // NOTE: pi seeds every new session with model_change +
395
+ // thinking_level_change entries before session_start fires, so
396
+ // continuation is derived by projecting the branch through pi's own
397
+ // context-message rules (message, custom_message, non-empty
398
+ // branch_summary, compaction entries) — the same predicate pi core uses
399
+ // for its continuation check, not raw branch length and not literal
400
+ // message entries alone.
401
+ const hasSessionMessages = hasContextMessages(ctx.sessionManager.buildContextEntries());
402
+ if (shouldApplyMruOverride(event.reason, hasSessionMessages) && Object.keys(lastUsed).length > 0) {
403
+ const mruModel = findMruModel(lastUsed, ctx.modelRegistry);
404
+ const currentModel = ctx.model as { provider: string; id: string } | undefined;
405
+ if (
406
+ mruModel &&
407
+ (!currentModel ||
408
+ currentModel.provider !== (mruModel as { provider: string }).provider ||
409
+ currentModel.id !== (mruModel as { id: string }).id)
410
+ ) {
411
+ await pi.setModel(mruModel as Parameters<typeof pi.setModel>[0]);
412
+ }
413
+ } else if (shouldTimestampRestoredModel(event.reason, hasSessionMessages) && ctx.model) {
414
+ // Continued session (continued startup, resume, or context-bearing
415
+ // fork): pi 0.84.3 restores the session's model during construction
416
+ // without emitting model_select, so recency would never update for
417
+ // continuations. Record the restored model here so "last used"
418
+ // stays accurate. Fresh "new" sessions and "reload" are excluded —
419
+ // they perform no construction-time restore worth recording.
420
+ lastUsed[buildModelKey(ctx.model.provider, ctx.model.id)] = Date.now();
421
+ writeConfig({ lastUsed, thinking: tracker.thinking });
422
+ }
423
+ });
424
+
425
+ // Record thinking levels per model. Pi emits this only when the effective
426
+ // level changes — for manual changes (Ctrl+T, /thinking) and for the
427
+ // re-clamp inside setModel/cycle, which runs before model_select fires.
428
+ pi.on("thinking_level_select", (event, ctx) => {
429
+ const currentKey = ctx.model ? buildModelKey(ctx.model.provider, ctx.model.id) : null;
430
+ if (recordThinkingSelect(tracker, currentKey, event.level, event.previousLevel)) {
431
+ writeConfig({ lastUsed, thinking: tracker.thinking });
432
+ }
433
+ });
434
+
435
+ // Track model selections (manual, session restore).
436
+ // Skip lastUsed updates for "cycle" events — updating lastUsed during
437
+ // Ctrl+P cycling creates a feedback loop: each cycle step makes the
438
+ // selected model most-recent, re-sorts it to position 0, then
439
+ // (currentIndex + 1) % len always hits position 1 — toggling forever
440
+ // between the top 2. Thinking restore still applies to cycle selections.
441
+ pi.on("model_select", async (event, _ctx) => {
442
+ const newKey = buildModelKey(event.model.provider, event.model.id);
443
+ if (event.source !== "cycle") {
444
+ lastUsed[newKey] = Date.now();
445
+ }
446
+
447
+ const previousKey = event.previousModel
448
+ ? buildModelKey(event.previousModel.provider, event.previousModel.id)
449
+ : null;
450
+ const restoreLevel = handleModelSelect(tracker, newKey, previousKey, pi.getThinkingLevel());
451
+ writeConfig({ lastUsed, thinking: tracker.thinking });
452
+
453
+ // Restore the model's remembered thinking level. setThinkingLevel clamps
454
+ // to the model's capabilities; if clamping changes the level, the
455
+ // resulting thinking_level_select records the effective level instead.
456
+ if (restoreLevel !== null) {
457
+ pi.setThinkingLevel(restoreLevel);
458
+ }
459
+ });
460
+
461
+ // Cleanup on shutdown / reload
462
+ pi.on("session_shutdown", (_event, ctx) => {
463
+ unpatchSortModels();
464
+ unpatchScopedLoader();
465
+ unpatchFilterModels();
466
+ unpatchCycleScopedModel();
467
+ unpatchRegistry(ctx.modelRegistry as unknown as PatchedRegistry);
468
+ });
469
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Shared constants, types, and utilities for pi-model-sort.
3
+ */
4
+
5
+ import { type SessionEntry, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
6
+
7
+ /** Default config file name (placed in ~/.pi/agent/extensions/). */
8
+ export const CONFIG_FILENAME = "pi-model-sort.json";
9
+
10
+ /** Thinking levels supported by pi. Mirrors ThinkingLevel from pi-agent-core. */
11
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
12
+
13
+ /** All valid thinking levels, ascending. */
14
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
15
+
16
+ /** Type guard for ThinkingLevel. */
17
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
18
+ return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value);
19
+ }
20
+
21
+ export interface ModelSortConfig {
22
+ /** Map of "provider/modelId" → last-used Unix timestamp (ms). */
23
+ lastUsed: Record<string, number>;
24
+ /** Map of "provider/modelId" → last-used thinking level. */
25
+ thinking: Record<string, ThinkingLevel>;
26
+ }
27
+
28
+ /**
29
+ * Parse a raw config file payload into a ModelSortConfig, dropping malformed
30
+ * entries. Unknown fields are ignored; missing fields default to empty maps.
31
+ */
32
+ export function parseConfig(raw: unknown): ModelSortConfig {
33
+ const config: ModelSortConfig = { lastUsed: {}, thinking: {} };
34
+ if (typeof raw !== "object" || raw === null) return config;
35
+
36
+ const obj = raw as Record<string, unknown>;
37
+ if (typeof obj.lastUsed === "object" && obj.lastUsed !== null) {
38
+ for (const [key, value] of Object.entries(obj.lastUsed)) {
39
+ if (typeof value === "number" && Number.isFinite(value)) {
40
+ config.lastUsed[key] = value;
41
+ }
42
+ }
43
+ }
44
+ if (typeof obj.thinking === "object" && obj.thinking !== null) {
45
+ for (const [key, value] of Object.entries(obj.thinking)) {
46
+ if (isThinkingLevel(value)) {
47
+ config.thinking[key] = value;
48
+ }
49
+ }
50
+ }
51
+ return config;
52
+ }
53
+
54
+ /** Parse a model key into [provider, modelId]. Returns undefined if malformed. */
55
+ export function parseModelKey(key: string): [provider: string, modelId: string] | undefined {
56
+ const idx = key.indexOf("/");
57
+ if (idx === -1) return undefined;
58
+ return [key.substring(0, idx), key.substring(idx + 1)];
59
+ }
60
+
61
+ /** Build a stable model key from provider and model id. */
62
+ export function buildModelKey(provider: string, modelId: string): string {
63
+ return `${provider}/${modelId}`;
64
+ }
65
+
66
+ /** Reasons pi reports for session_start events. */
67
+ export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
68
+
69
+ /**
70
+ * Whether the session branch produces context messages — pi core's own
71
+ * continuation predicate (`buildSessionContext().messages.length > 0`).
72
+ *
73
+ * pi seeds every NEW session with `model_change` and `thinking_level_change`
74
+ * entries during `createAgentSession` — BEFORE extensions are bound and
75
+ * `session_start` fires (installed pi 0.84.3, dist/core/sdk.js:241-252) — so
76
+ * raw branch length is always > 0 by the time extensions observe it. Context
77
+ * messages come from more than literal `message` entries: pi's
78
+ * `sessionEntryToContextMessages` also projects `custom_message`, non-empty
79
+ * `branch_summary`, and `compaction` entries (dist/core/session-manager.js:
80
+ * 162-188), so a summarized branch with no literal message still counts as
81
+ * continued for core — and must for us too.
82
+ */
83
+ export function hasContextMessages(entries: readonly SessionEntry[]): boolean {
84
+ return entries.flatMap(sessionEntryToContextMessages).length > 0;
85
+ }
86
+
87
+ /**
88
+ * Whether the MRU (most recently used) startup override should apply for a
89
+ * session start.
90
+ *
91
+ * Fresh starts and `/new` switch to the most recently used model. A continued
92
+ * session (`pi -c`, `--session`) has already restored the model saved in its
93
+ * session file — the override is skipped so global MRU does not clobber it.
94
+ * `/resume` and forked sessions keep the session's own model as well.
95
+ *
96
+ * @param reason session_start reason reported by pi
97
+ * @param hasSessionMessages whether the session branch produces context messages
98
+ */
99
+ export function shouldApplyMruOverride(reason: SessionStartReason, hasSessionMessages: boolean): boolean {
100
+ if (reason === "startup" && hasSessionMessages) return false;
101
+ return reason === "startup" || reason === "new";
102
+ }
103
+
104
+ /**
105
+ * Whether the model pi restored for this session start should be recorded as
106
+ * last-used. Pi 0.84.3 restores a continued session's model during
107
+ * construction without emitting `model_select`, so recency would otherwise
108
+ * never update for continuations.
109
+ *
110
+ * Only starts of a session that already carries context count (continued
111
+ * `startup`, `resume`, `fork`). "new" is excluded — a fresh session has no
112
+ * restored model even when a `setup` hook appended messages before
113
+ * `session_start`. "reload" is excluded — it rebuilds the extension runner
114
+ * around the same live session without a construction-time restore, and the
115
+ * active model was already recorded when it became active.
116
+ *
117
+ * @param reason session_start reason reported by pi
118
+ * @param hasSessionMessages whether the session branch produces context messages
119
+ */
120
+ export function shouldTimestampRestoredModel(reason: SessionStartReason, hasSessionMessages: boolean): boolean {
121
+ if (!hasSessionMessages) return false;
122
+ return reason === "startup" || reason === "resume" || reason === "fork";
123
+ }
124
+
125
+ /**
126
+ * Sort an array of models (or model-like objects) by last-usage recency.
127
+ *
128
+ * Sort order:
129
+ * 1. Current model first (if currentModelKey is provided)
130
+ * 2. Most recently used (highest timestamp) first
131
+ * 3. Provider name alphabetically
132
+ * 4. Model id alphabetically
133
+ *
134
+ * Models with no recorded usage get timestamp 0 (sorted last).
135
+ */
136
+ export function sortByLastUsed<T extends { provider: string; id: string }>(
137
+ items: T[],
138
+ lastUsed: Record<string, number>,
139
+ currentModelKey: string | null,
140
+ ): T[] {
141
+ const sorted = [...items];
142
+ sorted.sort((a, b) => {
143
+ const aKey = buildModelKey(a.provider, a.id);
144
+ const bKey = buildModelKey(b.provider, b.id);
145
+
146
+ if (currentModelKey !== null) {
147
+ const aIsCurrent = aKey === currentModelKey;
148
+ const bIsCurrent = bKey === currentModelKey;
149
+ if (aIsCurrent && !bIsCurrent) return -1;
150
+ if (!aIsCurrent && bIsCurrent) return 1;
151
+ }
152
+
153
+ const aLast = lastUsed[aKey] ?? 0;
154
+ const bLast = lastUsed[bKey] ?? 0;
155
+ if (aLast !== bLast) return bLast - aLast;
156
+
157
+ return a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
158
+ });
159
+ return sorted;
160
+ }
161
+
162
+ /**
163
+ * MRU model lookup — finds the most recently used model that exists in the
164
+ * registry and has auth configured. Returns undefined if no usable model is
165
+ * found. `find`/`hasConfiguredAuth` match the pi 0.84.3 ModelRegistry
166
+ * extension facade surface.
167
+ */
168
+ export function findMruModel(
169
+ lastUsed: Record<string, number>,
170
+ registry: { find(provider: string, modelId: string): unknown; hasConfiguredAuth(model: unknown): boolean },
171
+ ): unknown | undefined {
172
+ const sorted = Object.entries(lastUsed).sort(([, a], [, b]) => b - a);
173
+ for (const [key] of sorted) {
174
+ const parsed = parseModelKey(key);
175
+ if (!parsed) continue;
176
+ const [provider, modelId] = parsed;
177
+ const model = registry.find(provider, modelId);
178
+ if (model && registry.hasConfiguredAuth(model)) {
179
+ return model;
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ /**
186
+ * Per-model thinking-level memory.
187
+ *
188
+ * Pi keeps one global thinking level: on every model switch it carries the
189
+ * current level over and clamps it to the new model's capabilities, so each
190
+ * model needs manual re-adjustment. This tracker records the last level used
191
+ * per model and computes which level to restore on switch.
192
+ *
193
+ * Attribution relies on pi's event ordering: setModel/cycle swap the model
194
+ * and re-clamp the level (emitting thinking_level_select) *before* emitting
195
+ * model_select. So when a thinking_level_select arrives whose current model
196
+ * no longer matches the tracked active model, the change belongs to a switch:
197
+ * previousLevel is the final level of the model being left (recorded), while
198
+ * the new level is inherited rather than chosen (not recorded).
199
+ */
200
+ export interface ThinkingTrackerState {
201
+ /** Persisted map of "provider/modelId" → last-used thinking level. */
202
+ thinking: Record<string, ThinkingLevel>;
203
+ /** Key of the model last accounted for (session start or last model_select). */
204
+ activeKey: string | null;
205
+ /** Whether a switch-time re-clamp fired since the last model_select. */
206
+ sawSwitchClamp: boolean;
207
+ }
208
+
209
+ export function createThinkingTracker(): ThinkingTrackerState {
210
+ return { thinking: {}, activeKey: null, sawSwitchClamp: false };
211
+ }
212
+
213
+ function recordLevel(state: ThinkingTrackerState, key: string, level: ThinkingLevel): boolean {
214
+ if (state.thinking[key] === level) return false;
215
+ state.thinking[key] = level;
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Record a thinking_level_select event. currentKey is the model pi reports as
221
+ * active at event time. Returns true when the thinking map changed.
222
+ */
223
+ export function recordThinkingSelect(
224
+ state: ThinkingTrackerState,
225
+ currentKey: string | null,
226
+ level: ThinkingLevel,
227
+ previousLevel: ThinkingLevel,
228
+ ): boolean {
229
+ if (currentKey !== null && state.activeKey !== null && currentKey !== state.activeKey) {
230
+ state.sawSwitchClamp = true;
231
+ return recordLevel(state, state.activeKey, previousLevel);
232
+ }
233
+ const key = currentKey ?? state.activeKey;
234
+ if (key === null) return false;
235
+ return recordLevel(state, key, level);
236
+ }
237
+
238
+ /**
239
+ * Account for a model_select event and decide which level to restore, if any.
240
+ * currentLevel is the session's thinking level after pi's switch-time clamp.
241
+ * Sets activeKey to newKey — callers must apply the returned level afterwards
242
+ * (via pi.setThinkingLevel) so the emitted thinking_level_select attributes
243
+ * the effective, possibly further-clamped level to the new model.
244
+ */
245
+ export function handleModelSelect(
246
+ state: ThinkingTrackerState,
247
+ newKey: string,
248
+ previousKey: string | null,
249
+ currentLevel: ThinkingLevel,
250
+ ): ThinkingLevel | null {
251
+ // No re-clamp during the switch means the level carried over unchanged, so
252
+ // currentLevel is also the previous model's final level. When a re-clamp did
253
+ // fire, recordThinkingSelect already stored the previous model's level.
254
+ if (previousKey !== null && !state.sawSwitchClamp) {
255
+ recordLevel(state, previousKey, currentLevel);
256
+ }
257
+ state.sawSwitchClamp = false;
258
+ state.activeKey = newKey;
259
+
260
+ const remembered = state.thinking[newKey];
261
+ if (remembered === undefined || remembered === currentLevel) return null;
262
+ return remembered;
263
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@benvargas/pi-model-sort",
3
+ "version": "1.0.0",
4
+ "description": "Sort models in pi by last usage and start fresh sessions on the most recently used model",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-coding-agent",
10
+ "model",
11
+ "sort",
12
+ "mru",
13
+ "last-used",
14
+ "model-selector",
15
+ "thinking-level"
16
+ ],
17
+ "type": "module",
18
+ "files": [
19
+ "extensions/",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE",
23
+ "THIRD_PARTY_NOTICES.md"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./extensions/index.ts"
28
+ ]
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": ">=0.84.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/ben-vargas/pi-packages.git",
36
+ "directory": "packages/pi-model-sort"
37
+ },
38
+ "author": "Ben Vargas",
39
+ "license": "MIT",
40
+ "bugs": {
41
+ "url": "https://github.com/ben-vargas/pi-packages/issues"
42
+ },
43
+ "homepage": "https://github.com/ben-vargas/pi-packages/tree/main/packages/pi-model-sort#readme"
44
+ }