pi-model-sort 0.1.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/README.md +159 -0
- package/model-sort.ts +227 -0
- package/package.json +52 -0
- package/src/index.ts +53 -0
- package/vitest.config.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🔄 pi-model-sort
|
|
4
|
+
|
|
5
|
+
**Sort models by last usage in [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
|
+
|
|
7
|
+
_Your most-used models appear first — no more scrolling past providers you never touch._
|
|
8
|
+
|
|
9
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The Problem
|
|
17
|
+
|
|
18
|
+
Pi's `/model` selector sorts models alphabetically by provider. If you have Anthropic + OpenAI + Google + Ollama all configured, your most-used model might be buried behind twenty other models. Every time you open the picker, you scroll past providers you haven't touched in weeks. There's no built-in way to say *"show me what I actually use."*
|
|
19
|
+
|
|
20
|
+
## The Solution
|
|
21
|
+
|
|
22
|
+
`pi-model-sort` tracks every model selection and reorders the `/model` picker so your most recently used models appear at the top.
|
|
23
|
+
|
|
24
|
+
- **Automatic tracking** — every `/model` switch, `Ctrl+P` cycle, and session restore is recorded with a Unix timestamp
|
|
25
|
+
- **Sort order** — current model first → most recently used descending → provider/id alphabetical fallback
|
|
26
|
+
- **Persistent** — usage data lives in `~/.pi/agent/extensions/pi-model-sort.json`, survives restarts
|
|
27
|
+
- **No config needed** — install and forget; the extension starts tracking on first use
|
|
28
|
+
- **Zero setup** — with no recorded usage, models fall back to the default alphabetical order
|
|
29
|
+
- **Everywhere** — the sort applies to `/model` (`Ctrl+L`), both "Scope: all" and "Scope: scoped" views, `--list-models` CLI, and the `/scoped-models` config selector
|
|
30
|
+
|
|
31
|
+
No `settings.json` modifications. No manual maintenance. No database.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
The extension works automatically — there are no commands to learn.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Install, then just use pi normally
|
|
39
|
+
/model # Most recently used models now appear at the top
|
|
40
|
+
pi --list-models # CLI output is also sorted by last usage
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open `/model` and press `Tab` to switch between "Scope: all" and "Scope: scoped" — both views are sorted by recency.
|
|
44
|
+
|
|
45
|
+
### Config File
|
|
46
|
+
|
|
47
|
+
The extension creates `~/.pi/agent/extensions/pi-model-sort.json` automatically on first model switch:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"lastUsed": {
|
|
52
|
+
"anthropic/claude-sonnet-4-20250514": 1717000000000,
|
|
53
|
+
"openai/gpt-4o": 1716995000000,
|
|
54
|
+
"google/gemini-2.5-pro": 1716000000000
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
No manual editing needed. To clear usage history, delete the file and `/reload`.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
**With `pi install`** (recommended):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pi install https://github.com/monotykamary/pi-model-sort
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**With npm**:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install pi-model-sort
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or in `~/.pi/agent/settings.json`:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"packages": [
|
|
80
|
+
"git:github.com/monotykamary/pi-model-sort"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then `/reload` or restart pi.
|
|
86
|
+
|
|
87
|
+
For quick one-off tests:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pi -e ./model-sort.ts
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## How It Works
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
model_select event fires
|
|
97
|
+
→ Extension records timestamp
|
|
98
|
+
→ Writes to pi-model-sort.json
|
|
99
|
+
→ Next /model opens with updated sort
|
|
100
|
+
|
|
101
|
+
Session starts
|
|
102
|
+
→ Extension reads pi-model-sort.json
|
|
103
|
+
→ Monkey-patches ModelSelectorComponent.prototype:
|
|
104
|
+
sortModels — sorts "Scope: all" view
|
|
105
|
+
loadModels — sorts "Scope: scoped" scopedModelItems after load
|
|
106
|
+
→ Monkey-patches ModelRegistry.prototype.getAvailable/getAll
|
|
107
|
+
→ Sort order: current model first → most recent → provider/id alphabetical
|
|
108
|
+
→ Patches survive modelRegistry.refresh()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Four patches, full coverage:**
|
|
112
|
+
|
|
113
|
+
| Patch | What it affects |
|
|
114
|
+
|-------|----------------|
|
|
115
|
+
| `ModelSelectorComponent.prototype.sortModels` | `/model` TUI picker — "Scope: all" view |
|
|
116
|
+
| `ModelSelectorComponent.prototype.loadModels` | `/model` TUI picker — "Scope: scoped" view (configured cycling models) |
|
|
117
|
+
| `ModelRegistry.prototype.getAvailable()` | `/scoped-models` config selector, model resolution |
|
|
118
|
+
| `ModelRegistry.prototype.getAll()` | `--list-models` CLI output |
|
|
119
|
+
|
|
120
|
+
The SDK doesn't expose a sort order for model lists. Monkey-patching the component and registry methods is the only way to control ordering without rebuilding the entire picker UI.
|
|
121
|
+
|
|
122
|
+
The patches survive `modelRegistry.refresh()` because they wrap the original methods. On reload, the extension detects the prototypes are already patched and just updates the last-used data source.
|
|
123
|
+
|
|
124
|
+
## Comparison with Alternatives
|
|
125
|
+
|
|
126
|
+
| Approach | Pros | Cons |
|
|
127
|
+
|----------|------|------|
|
|
128
|
+
| **pi-model-sort** (this) | Automatic, zero-config, persistent, applies everywhere | Monkey-patches internal prototypes |
|
|
129
|
+
| `enabledModels` in `settings.json` (manual) | Built-in, no extension needed | Allowlist — must list models manually; doesn't sort, just scopes |
|
|
130
|
+
| Custom `/model` replacement extension | Full control over UI | Rebuilds the entire picker component from scratch (~400 lines of TUI code) |
|
|
131
|
+
| Manually ordering `models.json` | Controls `--list-models` output | Static, doesn't react to actual usage; doesn't affect `/model` picker |
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm install
|
|
137
|
+
npm test # Vitest unit tests
|
|
138
|
+
npm run typecheck # TypeScript validation
|
|
139
|
+
npm run lint:dead # Dead code detection (knip)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Structure
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
.
|
|
146
|
+
├── model-sort.ts # Main extension
|
|
147
|
+
├── src/
|
|
148
|
+
│ └── index.ts # Sort logic, types, and utilities
|
|
149
|
+
├── __tests__/
|
|
150
|
+
│ └── sort.test.ts # Unit tests for sortByLastUsed
|
|
151
|
+
├── package.json
|
|
152
|
+
├── tsconfig.json
|
|
153
|
+
├── vitest.config.ts
|
|
154
|
+
└── knip.json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/model-sort.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-model-sort — sort models in pi by last usage (descending).
|
|
3
|
+
*
|
|
4
|
+
* Strategy: monkey-patches ModelSelectorComponent.prototype.sortModels and
|
|
5
|
+
* loadModels so the /model picker sorts by recency instead of alphabetically
|
|
6
|
+
* by provider — including the "Scope: scoped" view for Ctrl+P cycling.
|
|
7
|
+
* Also patches ModelRegistry.getAvailable() and getAll() so --list-models
|
|
8
|
+
* and the scoped-models config selector benefit from the same ordering.
|
|
9
|
+
*
|
|
10
|
+
* Usage tracking is automatic — every model selection (manual, Ctrl+P cycle,
|
|
11
|
+
* or session restore) updates the last-used timestamp. Data persists to
|
|
12
|
+
* ~/.pi/agent/extensions/pi-model-sort.json.
|
|
13
|
+
*
|
|
14
|
+
* With no recorded usage, the sort degrades gracefully to the default
|
|
15
|
+
* provider/model-id alphabetical order.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { ModelRegistry, ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
buildModelKey,
|
|
25
|
+
CONFIG_FILENAME,
|
|
26
|
+
type ModelSortConfig,
|
|
27
|
+
sortByLastUsed,
|
|
28
|
+
} from "./src/index.js";
|
|
29
|
+
|
|
30
|
+
const HOME = homedir();
|
|
31
|
+
const CONFIG_PATH = join(HOME, ".pi", "agent", "extensions", CONFIG_FILENAME);
|
|
32
|
+
|
|
33
|
+
// Config I/O
|
|
34
|
+
|
|
35
|
+
function readConfig(): ModelSortConfig {
|
|
36
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
37
|
+
return { lastUsed: {} };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
41
|
+
const parsed = JSON.parse(raw) as ModelSortConfig;
|
|
42
|
+
return { lastUsed: parsed.lastUsed ?? {} };
|
|
43
|
+
} catch {
|
|
44
|
+
return { lastUsed: {} };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeConfig(config: ModelSortConfig): void {
|
|
49
|
+
const dir = join(HOME, ".pi", "agent", "extensions");
|
|
50
|
+
if (!existsSync(dir)) {
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ModelSelectorComponent sortModels patch
|
|
57
|
+
|
|
58
|
+
let origSortModels: ((models: Array<{ provider: string; id: string; model: unknown }>) => Array<{ provider: string; id: string; model: unknown }>) | null = null;
|
|
59
|
+
|
|
60
|
+
function buildCurrentModelKey(instance: Record<string, unknown>): string | null {
|
|
61
|
+
const cm = instance.currentModel as { provider?: string; id?: string } | undefined;
|
|
62
|
+
if (cm?.provider && cm?.id) {
|
|
63
|
+
return buildModelKey(cm.provider, cm.id);
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function patchSortModels(getLastUsed: () => Record<string, number>): void {
|
|
69
|
+
if (origSortModels !== null) return;
|
|
70
|
+
|
|
71
|
+
const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
|
|
72
|
+
origSortModels = proto.sortModels as typeof origSortModels;
|
|
73
|
+
|
|
74
|
+
proto.sortModels = function (
|
|
75
|
+
this: Record<string, unknown>,
|
|
76
|
+
models: Array<{ provider: string; id: string; model: unknown }>,
|
|
77
|
+
) {
|
|
78
|
+
const lastUsed = getLastUsed();
|
|
79
|
+
return sortByLastUsed(models, lastUsed, buildCurrentModelKey(this));
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function unpatchSortModels(): void {
|
|
84
|
+
if (origSortModels === null) return;
|
|
85
|
+
(ModelSelectorComponent.prototype as unknown as Record<string, unknown>).sortModels = origSortModels;
|
|
86
|
+
origSortModels = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ModelSelectorComponent loadModels patch — sorts scopedModelItems for the
|
|
90
|
+
// "Scope: scoped" toggle in the /model picker.
|
|
91
|
+
|
|
92
|
+
let origLoadModels: (() => Promise<void>) | null = null;
|
|
93
|
+
|
|
94
|
+
function patchLoadModels(getLastUsed: () => Record<string, number>): void {
|
|
95
|
+
if (origLoadModels !== null) return;
|
|
96
|
+
|
|
97
|
+
const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
|
|
98
|
+
origLoadModels = proto.loadModels as () => Promise<void>;
|
|
99
|
+
|
|
100
|
+
proto.loadModels = async function (this: Record<string, unknown>) {
|
|
101
|
+
await origLoadModels!.call(this);
|
|
102
|
+
|
|
103
|
+
const scopedItems = this.scopedModelItems as Array<{ provider: string; id: string; model: unknown }> | undefined;
|
|
104
|
+
if (!scopedItems || scopedItems.length === 0) return;
|
|
105
|
+
|
|
106
|
+
const lastUsed = getLastUsed();
|
|
107
|
+
this.scopedModelItems = sortByLastUsed(scopedItems, lastUsed, buildCurrentModelKey(this));
|
|
108
|
+
|
|
109
|
+
if (this.scope === "scoped") {
|
|
110
|
+
this.activeModels = this.scopedModelItems;
|
|
111
|
+
// Sync filteredModels — the original loadModels set it to the
|
|
112
|
+
// unsorted scopedModelItems before our patch had a chance to sort.
|
|
113
|
+
this.filteredModels = this.scopedModelItems;
|
|
114
|
+
|
|
115
|
+
// Recalculate selectedIndex — the original loadModels computed it
|
|
116
|
+
// from the unsorted array, so the cursor is at the old position.
|
|
117
|
+
const currentKey = buildCurrentModelKey(this);
|
|
118
|
+
if (currentKey) {
|
|
119
|
+
const filtered = this.filteredModels as Array<{ provider: string; id: string }>;
|
|
120
|
+
const newIndex = filtered.findIndex(
|
|
121
|
+
(item) => buildModelKey(item.provider, item.id) === currentKey,
|
|
122
|
+
);
|
|
123
|
+
if (newIndex >= 0) {
|
|
124
|
+
this.selectedIndex = newIndex;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function unpatchLoadModels(): void {
|
|
132
|
+
if (origLoadModels === null) return;
|
|
133
|
+
(ModelSelectorComponent.prototype as unknown as Record<string, unknown>).loadModels = origLoadModels;
|
|
134
|
+
origLoadModels = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ModelRegistry getAvailable / getAll patch
|
|
138
|
+
|
|
139
|
+
const REGISTRY_PATCH_KEY = "__model_sort_registry_patched";
|
|
140
|
+
|
|
141
|
+
interface PatchedRegistry {
|
|
142
|
+
[REGISTRY_PATCH_KEY]: boolean;
|
|
143
|
+
getAvailable(): unknown[];
|
|
144
|
+
getAll(): unknown[];
|
|
145
|
+
__model_sort_get_last_used: () => Record<string, number>;
|
|
146
|
+
__model_sort_orig_getAvailable: () => unknown[];
|
|
147
|
+
__model_sort_orig_getAll: () => unknown[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function patchRegistry(
|
|
151
|
+
registry: PatchedRegistry,
|
|
152
|
+
getLastUsed: () => Record<string, number>,
|
|
153
|
+
): void {
|
|
154
|
+
if (registry[REGISTRY_PATCH_KEY]) {
|
|
155
|
+
registry.__model_sort_get_last_used = getLastUsed;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
registry[REGISTRY_PATCH_KEY] = true;
|
|
160
|
+
registry.__model_sort_get_last_used = getLastUsed;
|
|
161
|
+
|
|
162
|
+
registry.__model_sort_orig_getAvailable = registry.getAvailable.bind(registry);
|
|
163
|
+
registry.__model_sort_orig_getAll = registry.getAll.bind(registry);
|
|
164
|
+
|
|
165
|
+
registry.getAvailable = function (this: PatchedRegistry) {
|
|
166
|
+
const lastUsed = this.__model_sort_get_last_used();
|
|
167
|
+
const all = this.__model_sort_orig_getAvailable() as Array<{ provider: string; id: string }>;
|
|
168
|
+
return sortByLastUsed(all, lastUsed, null);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
registry.getAll = function (this: PatchedRegistry) {
|
|
172
|
+
const lastUsed = this.__model_sort_get_last_used();
|
|
173
|
+
const all = this.__model_sort_orig_getAll() as Array<{ provider: string; id: string }>;
|
|
174
|
+
return sortByLastUsed(all, lastUsed, null);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function unpatchRegistry(registry: PatchedRegistry): void {
|
|
179
|
+
if (!registry[REGISTRY_PATCH_KEY]) return;
|
|
180
|
+
|
|
181
|
+
registry.getAvailable = registry.__model_sort_orig_getAvailable;
|
|
182
|
+
registry.getAll = registry.__model_sort_orig_getAll;
|
|
183
|
+
|
|
184
|
+
const raw = registry as unknown as Record<string, unknown>;
|
|
185
|
+
delete raw[REGISTRY_PATCH_KEY];
|
|
186
|
+
delete raw.__model_sort_get_last_used;
|
|
187
|
+
delete raw.__model_sort_orig_getAvailable;
|
|
188
|
+
delete raw.__model_sort_orig_getAll;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Extension
|
|
192
|
+
|
|
193
|
+
export default function (pi: ExtensionAPI) {
|
|
194
|
+
let lastUsed: Record<string, number> = {};
|
|
195
|
+
|
|
196
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
197
|
+
const config = readConfig();
|
|
198
|
+
lastUsed = config.lastUsed;
|
|
199
|
+
|
|
200
|
+
patchRegistry(ctx.modelRegistry as unknown as PatchedRegistry, () => lastUsed);
|
|
201
|
+
patchSortModels(() => lastUsed);
|
|
202
|
+
patchLoadModels(() => lastUsed);
|
|
203
|
+
|
|
204
|
+
if (ctx.hasUI) {
|
|
205
|
+
const count = Object.keys(lastUsed).length;
|
|
206
|
+
ctx.ui.notify(
|
|
207
|
+
count > 0
|
|
208
|
+
? `pi-model-sort: ${count} model(s) tracked — sorting by last usage`
|
|
209
|
+
: "pi-model-sort: tracking started — models will sort by recency after first use",
|
|
210
|
+
"info",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Track every model selection (manual, Ctrl+P cycle, session restore)
|
|
216
|
+
pi.on("model_select", async (event, _ctx) => {
|
|
217
|
+
const key = buildModelKey(event.model.provider, event.model.id);
|
|
218
|
+
lastUsed[key] = Date.now();
|
|
219
|
+
writeConfig({ lastUsed });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Cleanup on shutdown / reload
|
|
223
|
+
pi.on("session_shutdown", () => {
|
|
224
|
+
unpatchSortModels();
|
|
225
|
+
unpatchLoadModels();
|
|
226
|
+
});
|
|
227
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-model-sort",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sort models in pi's /model selector by last usage — most recently used models appear first",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Tom X Nguyen",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/monotykamary/pi-model-sort.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/monotykamary/pi-model-sort#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/monotykamary/pi-model-sort/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"extension",
|
|
21
|
+
"model",
|
|
22
|
+
"sort",
|
|
23
|
+
"recent",
|
|
24
|
+
"last-used",
|
|
25
|
+
"model-selector"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"*.ts",
|
|
29
|
+
"src/",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"test:coverage": "vitest run --coverage",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"lint:dead": "knip --no-gitignore"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
41
|
+
"@types/node": "25.9.1",
|
|
42
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
43
|
+
"knip": "6.14.1",
|
|
44
|
+
"typescript": "6.0.3",
|
|
45
|
+
"vitest": "4.1.7"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./model-sort.ts"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants, types, and utilities for pi-model-sort.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Default config file name (placed in ~/.pi/agent/extensions/). */
|
|
6
|
+
export const CONFIG_FILENAME = "pi-model-sort.json";
|
|
7
|
+
|
|
8
|
+
export interface ModelSortConfig {
|
|
9
|
+
/** Map of "provider/modelId" → last-used Unix timestamp (ms). */
|
|
10
|
+
lastUsed: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Build a stable model key from provider and model id. */
|
|
14
|
+
export function buildModelKey(provider: string, modelId: string): string {
|
|
15
|
+
return `${provider}/${modelId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sort an array of models (or model-like objects) by last-usage recency.
|
|
20
|
+
*
|
|
21
|
+
* Sort order:
|
|
22
|
+
* 1. Current model first (if currentModelKey is provided)
|
|
23
|
+
* 2. Most recently used (highest timestamp) first
|
|
24
|
+
* 3. Provider name alphabetically
|
|
25
|
+
* 4. Model id alphabetically
|
|
26
|
+
*
|
|
27
|
+
* Models with no recorded usage get timestamp 0 (sorted last).
|
|
28
|
+
*/
|
|
29
|
+
export function sortByLastUsed<T extends { provider: string; id: string }>(
|
|
30
|
+
items: T[],
|
|
31
|
+
lastUsed: Record<string, number>,
|
|
32
|
+
currentModelKey: string | null,
|
|
33
|
+
): T[] {
|
|
34
|
+
const sorted = [...items];
|
|
35
|
+
sorted.sort((a, b) => {
|
|
36
|
+
const aKey = buildModelKey(a.provider, a.id);
|
|
37
|
+
const bKey = buildModelKey(b.provider, b.id);
|
|
38
|
+
|
|
39
|
+
if (currentModelKey !== null) {
|
|
40
|
+
const aIsCurrent = aKey === currentModelKey;
|
|
41
|
+
const bIsCurrent = bKey === currentModelKey;
|
|
42
|
+
if (aIsCurrent && !bIsCurrent) return -1;
|
|
43
|
+
if (!aIsCurrent && bIsCurrent) return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const aLast = lastUsed[aKey] ?? 0;
|
|
47
|
+
const bLast = lastUsed[bKey] ?? 0;
|
|
48
|
+
if (aLast !== bLast) return bLast - aLast;
|
|
49
|
+
|
|
50
|
+
return a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
|
|
51
|
+
});
|
|
52
|
+
return sorted;
|
|
53
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: "node",
|
|
7
|
+
include: ["__tests__/**/*.test.ts"],
|
|
8
|
+
exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: "v8",
|
|
11
|
+
reporter: ["text", "json", "html"],
|
|
12
|
+
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|