@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 +46 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/extensions/index.ts +469 -0
- package/extensions/sort.ts +263 -0
- package/package.json +44 -0
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
|
+
}
|