@xynogen/pix-models 0.1.16 → 0.1.18
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/package.json +1 -1
- package/src/models.test.ts +130 -1
- package/src/models.ts +78 -21
- package/src/patch-builtin.test.ts +50 -22
- package/src/patch-builtin.ts +27 -7
package/package.json
CHANGED
package/src/models.test.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
benchStars,
|
|
4
|
+
filterModelItems,
|
|
5
|
+
fmtCost,
|
|
6
|
+
fmtCtx,
|
|
7
|
+
type ModelSearchLookup,
|
|
8
|
+
normalizeModelText,
|
|
9
|
+
sortModels,
|
|
10
|
+
} from "./models.ts";
|
|
3
11
|
|
|
4
12
|
describe("fmtCtx", () => {
|
|
5
13
|
it("formats 0 as 0", () => expect(fmtCtx(0)).toBe("0"));
|
|
@@ -120,3 +128,124 @@ describe("sortModels", () => {
|
|
|
120
128
|
expect(sorted.map((m) => m.name)).toEqual(["Alpha", "Delta", "Beta", "Gamma"]);
|
|
121
129
|
});
|
|
122
130
|
});
|
|
131
|
+
|
|
132
|
+
// ─── normalizeModelText ───────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe("normalizeModelText", () => {
|
|
135
|
+
it("lowercases", () => {
|
|
136
|
+
expect(normalizeModelText("GLM-5.2")).toBe("glm52");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("strips hyphens", () => {
|
|
140
|
+
expect(normalizeModelText("claude-opus-4-8")).toBe("claudeopus48");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("strips dots", () => {
|
|
144
|
+
expect(normalizeModelText("qwen3.7-max")).toBe("qwen37max");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("strips all non-alphanumeric", () => {
|
|
148
|
+
expect(normalizeModelText("a!b@c#d$e%f^g&h")).toBe("abcdefgh");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("handles plain alphanumeric", () => {
|
|
152
|
+
expect(normalizeModelText("abc123")).toBe("abc123");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles empty string", () => {
|
|
156
|
+
expect(normalizeModelText("")).toBe("");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─── filterModelItems ─────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("filterModelItems", () => {
|
|
163
|
+
// Fixture: models with ids matching the acceptance criteria.
|
|
164
|
+
// Ranks are assigned so some overlap with digit-queries.
|
|
165
|
+
const mk = (id: string, rank?: number) => ({
|
|
166
|
+
value: `p/${id}`,
|
|
167
|
+
_rank: rank,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
type Fixture = ReturnType<typeof mk>;
|
|
171
|
+
|
|
172
|
+
/** Build lookup maps exactly like production: haystack = `${id} ${name ?? ""}`. */
|
|
173
|
+
function buildLookup(items: Fixture[]): ModelSearchLookup {
|
|
174
|
+
const rankByValue = new Map<string, number>();
|
|
175
|
+
const searchTextByValue = new Map<string, string>();
|
|
176
|
+
const normalizedByValue = new Map<string, string>();
|
|
177
|
+
for (const it of items) {
|
|
178
|
+
// value is "p/glm-5.2" — extract the id part.
|
|
179
|
+
const id = it.value.split("/")[1] ?? "";
|
|
180
|
+
if (it._rank != null) rankByValue.set(it.value, it._rank);
|
|
181
|
+
const text = `${id} ${""}`; // no name in test fixture
|
|
182
|
+
searchTextByValue.set(it.value, text);
|
|
183
|
+
normalizedByValue.set(it.value, normalizeModelText(text));
|
|
184
|
+
}
|
|
185
|
+
return { rankByValue, searchTextByValue, normalizedByValue };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Full fixture with ranks: glm-5.2→3, minimax-m3→8, claude-opus-4-8→2.
|
|
189
|
+
const allItems: Fixture[] = [
|
|
190
|
+
mk("glm-5.2", 3),
|
|
191
|
+
mk("glm-5.1"),
|
|
192
|
+
mk("minimax-m3", 8),
|
|
193
|
+
mk("claude-opus-4-8", 2),
|
|
194
|
+
mk("claude-sonnet-4-6"),
|
|
195
|
+
mk("qwen3.7-max"),
|
|
196
|
+
];
|
|
197
|
+
const allLookup = buildLookup(allItems);
|
|
198
|
+
|
|
199
|
+
function values(result: Fixture[]): string[] {
|
|
200
|
+
return result.map((it) => it.value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
it("empty query returns all items in original order", () => {
|
|
204
|
+
const result = filterModelItems(allItems, "", allLookup);
|
|
205
|
+
expect(values(result)).toEqual(allItems.map((it) => it.value));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("query '52' includes glm-5.2 via normalized substring", () => {
|
|
209
|
+
const result = filterModelItems(allItems, "52", allLookup);
|
|
210
|
+
expect(values(result)).toContain("p/glm-5.2");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("query 'm3' includes minimax-m3 first (substring priority over fuzzy noise)", () => {
|
|
214
|
+
const result = filterModelItems(allItems, "m3", allLookup);
|
|
215
|
+
expect(values(result)[0]).toBe("p/minimax-m3");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("query '48' includes claude-opus-4-8", () => {
|
|
219
|
+
const result = filterModelItems(allItems, "48", allLookup);
|
|
220
|
+
expect(values(result)).toContain("p/claude-opus-4-8");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("exact id query 'glm-5.2' still matches glm-5.2", () => {
|
|
224
|
+
const result = filterModelItems(allItems, "glm-5.2", allLookup);
|
|
225
|
+
expect(values(result)).toContain("p/glm-5.2");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("digit query matching a rank returns that ranked item first", () => {
|
|
229
|
+
// glm-5.2 has rank 3 — query "3" should put it first.
|
|
230
|
+
const result = filterModelItems(allItems, "3", allLookup);
|
|
231
|
+
expect(values(result)[0]).toBe("p/glm-5.2");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("dedupe: item matching both rank and substring appears once", () => {
|
|
235
|
+
// claude-opus-4-8 has rank 2; query "2" matches by rank AND
|
|
236
|
+
// normalized substring ("2" ⊂ "claudeopus48"). Should appear once.
|
|
237
|
+
const result = filterModelItems(allItems, "2", allLookup);
|
|
238
|
+
const occurrences = values(result).filter((v) => v === "p/claude-opus-4-8");
|
|
239
|
+
expect(occurrences.length).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("digit query '8' (rank 8) returns minimax-m3 as rank match first", () => {
|
|
243
|
+
const result = filterModelItems(allItems, "8", allLookup);
|
|
244
|
+
expect(values(result)[0]).toBe("p/minimax-m3");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("whitespace query is treated as empty", () => {
|
|
248
|
+
const result = filterModelItems(allItems, " ", allLookup);
|
|
249
|
+
expect(values(result)).toEqual(allItems.map((it) => it.value));
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/models.ts
CHANGED
|
@@ -87,6 +87,68 @@ export function sortModels<T extends SortableModel>(models: T[]): T[] {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Lowercase and strip all non-alphanumerics: "glm-5.2" → "glm52". */
|
|
91
|
+
export function normalizeModelText(s: string): string {
|
|
92
|
+
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type ModelSearchLookup = {
|
|
96
|
+
/** benchlm local rank per item value (ranked models only). */
|
|
97
|
+
rankByValue: Map<string, number>;
|
|
98
|
+
/** Clean haystack per value: `${id} ${name ?? ""}` (no ANSI, no rank cell). */
|
|
99
|
+
searchTextByValue: Map<string, string>;
|
|
100
|
+
/** normalizeModelText(haystack) per value — for family+version substring matches. */
|
|
101
|
+
normalizedByValue: Map<string, string>;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Filter+order picker items for a search query.
|
|
106
|
+
* - "" → all items unchanged.
|
|
107
|
+
* - digits-only → items whose benchlm rank equals the number, followed by
|
|
108
|
+
* normalized-substring matches ("52" ⊂ "glm52") rank-sorted.
|
|
109
|
+
* - otherwise → normalized-substring matches first (rank-sorted), then
|
|
110
|
+
* fuzzy matches over the clean haystack (rank-sorted, stable), deduped.
|
|
111
|
+
*/
|
|
112
|
+
export function filterModelItems<T extends { value: string }>(
|
|
113
|
+
items: T[],
|
|
114
|
+
query: string,
|
|
115
|
+
lookup: ModelSearchLookup,
|
|
116
|
+
): T[] {
|
|
117
|
+
const q = query.trim();
|
|
118
|
+
if (q.length === 0) return items;
|
|
119
|
+
|
|
120
|
+
const rankSort = (arr: T[]): T[] => {
|
|
121
|
+
return arr
|
|
122
|
+
.map((it, i) => ({ it, i }))
|
|
123
|
+
.sort((a, b) => {
|
|
124
|
+
const ra = lookup.rankByValue.get(a.it.value) ?? Infinity;
|
|
125
|
+
const rb = lookup.rankByValue.get(b.it.value) ?? Infinity;
|
|
126
|
+
if (ra !== rb) return ra - rb;
|
|
127
|
+
return a.i - b.i;
|
|
128
|
+
})
|
|
129
|
+
.map(({ it }) => it);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const nq = normalizeModelText(q);
|
|
133
|
+
const subMatches =
|
|
134
|
+
nq.length > 0
|
|
135
|
+
? items.filter((it) => (lookup.normalizedByValue.get(it.value) ?? "").includes(nq))
|
|
136
|
+
: [];
|
|
137
|
+
|
|
138
|
+
if (/^\d+$/.test(q)) {
|
|
139
|
+
const wanted = Number(q);
|
|
140
|
+
const rankMatches = items.filter((it) => lookup.rankByValue.get(it.value) === wanted);
|
|
141
|
+
const rankValues = new Set(rankMatches.map((it) => it.value));
|
|
142
|
+
const extraSub = rankSort(subMatches).filter((it) => !rankValues.has(it.value));
|
|
143
|
+
return [...rankMatches, ...extraSub];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const fuzzy = fuzzyFilter(items, q, (it) => lookup.searchTextByValue.get(it.value) ?? "");
|
|
147
|
+
const subValues = new Set(subMatches.map((it) => it.value));
|
|
148
|
+
const extraFuzzy = rankSort(fuzzy).filter((it) => !subValues.has(it.value));
|
|
149
|
+
return [...rankSort(subMatches), ...extraFuzzy];
|
|
150
|
+
}
|
|
151
|
+
|
|
90
152
|
async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
91
153
|
// Mirror the built-in /model selector, which calls refresh() then awaits
|
|
92
154
|
// getAvailable() (see model-selector.js). Without refresh(), this extension
|
|
@@ -184,6 +246,17 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
184
246
|
if (localRank) rankByValue.set(`${m.provider}/${m.id}`, localRank);
|
|
185
247
|
}
|
|
186
248
|
|
|
249
|
+
// Clean search haystacks — labels are ANSI-laden and carry the rank cell,
|
|
250
|
+
// so matching runs against raw id+name instead (see filterModelItems).
|
|
251
|
+
const searchTextByValue = new Map<string, string>();
|
|
252
|
+
const normalizedByValue = new Map<string, string>();
|
|
253
|
+
for (const { m } of dedupedRows) {
|
|
254
|
+
const value = `${m.provider}/${m.id}`;
|
|
255
|
+
const text = `${m.id} ${m.name ?? ""}`;
|
|
256
|
+
searchTextByValue.set(value, text);
|
|
257
|
+
normalizedByValue.set(value, normalizeModelText(text));
|
|
258
|
+
}
|
|
259
|
+
|
|
187
260
|
const items: SelectItem[] = dedupedRows.map(({ m, dev, bench, localRank }) => {
|
|
188
261
|
const isCurrent = current && m.provider === current.provider && m.id === current.id;
|
|
189
262
|
|
|
@@ -282,27 +355,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
282
355
|
selectedIndex: number;
|
|
283
356
|
invalidate(): void;
|
|
284
357
|
};
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
// Pure number → match by benchlm rank, not name.
|
|
291
|
-
const wanted = Number(q);
|
|
292
|
-
next = internal.items.filter((it) => rankByValue.get(it.value) === wanted);
|
|
293
|
-
} else {
|
|
294
|
-
next = fuzzyFilter(internal.items, q, (it) => `${it.label} ${it.description ?? ""}`);
|
|
295
|
-
// Stable sort: ranked models (by rank asc) before unranked.
|
|
296
|
-
next = next
|
|
297
|
-
.map((it, i) => ({ it, i }))
|
|
298
|
-
.sort((a, b) => {
|
|
299
|
-
const ra = rankByValue.get(a.it.value) ?? Infinity;
|
|
300
|
-
const rb = rankByValue.get(b.it.value) ?? Infinity;
|
|
301
|
-
if (ra !== rb) return ra - rb;
|
|
302
|
-
return a.i - b.i;
|
|
303
|
-
})
|
|
304
|
-
.map(({ it }) => it);
|
|
305
|
-
}
|
|
358
|
+
const next = filterModelItems(internal.items, query, {
|
|
359
|
+
rankByValue,
|
|
360
|
+
searchTextByValue,
|
|
361
|
+
normalizedByValue,
|
|
362
|
+
});
|
|
306
363
|
internal.filteredItems = next;
|
|
307
364
|
internal.selectedIndex = 0;
|
|
308
365
|
internal.invalidate();
|
|
@@ -2,19 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
// Pure replacement tested in isolation (the exported fn resolves the host
|
|
7
|
-
// package, which isn't present in the test sandbox).
|
|
8
|
-
const MODEL_COMMAND_LINE = '{ name: "model", description: "Select model (opens selector UI)" },';
|
|
9
|
-
|
|
10
|
-
function escapeRegExp(text: string): string {
|
|
11
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function patchSource(source: string): string {
|
|
15
|
-
if (!source.includes(MODEL_COMMAND_LINE)) return source;
|
|
16
|
-
return source.replace(new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`), "");
|
|
17
|
-
}
|
|
5
|
+
import { stripBuiltinModelCommand } from "./patch-builtin.ts";
|
|
18
6
|
|
|
19
7
|
const UNPATCHED = `export const BUILTIN_SLASH_COMMANDS = [
|
|
20
8
|
{ name: "settings", description: "Open settings menu" },
|
|
@@ -23,40 +11,80 @@ const UNPATCHED = `export const BUILTIN_SLASH_COMMANDS = [
|
|
|
23
11
|
];
|
|
24
12
|
`;
|
|
25
13
|
|
|
14
|
+
const CURRENT_PI = `export const BUILTIN_SLASH_COMMANDS = [
|
|
15
|
+
{ name: "settings", description: "Open settings menu" },
|
|
16
|
+
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
|
|
17
|
+
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
|
|
18
|
+
];
|
|
19
|
+
`;
|
|
20
|
+
|
|
26
21
|
describe("patch-builtin /model removal", () => {
|
|
27
22
|
it("removes the built-in /model line and keeps neighbors", () => {
|
|
28
|
-
const out =
|
|
23
|
+
const out = stripBuiltinModelCommand(UNPATCHED);
|
|
29
24
|
expect(out).not.toContain('name: "model"');
|
|
30
25
|
expect(out).toContain('name: "settings"');
|
|
31
26
|
expect(out).toContain('name: "login"');
|
|
32
27
|
});
|
|
33
28
|
|
|
34
29
|
it("is idempotent — second pass is a no-op", () => {
|
|
35
|
-
const once =
|
|
36
|
-
const twice =
|
|
30
|
+
const once = stripBuiltinModelCommand(UNPATCHED);
|
|
31
|
+
const twice = stripBuiltinModelCommand(once);
|
|
37
32
|
expect(twice).toBe(once);
|
|
38
33
|
});
|
|
39
34
|
|
|
40
35
|
it("leaves an already-clean file untouched", () => {
|
|
41
36
|
const clean = `export const X = [\n { name: "login" },\n];\n`;
|
|
42
|
-
expect(
|
|
37
|
+
expect(stripBuiltinModelCommand(clean)).toBe(clean);
|
|
43
38
|
});
|
|
44
39
|
|
|
45
40
|
it("does not strip the plural /models entry", () => {
|
|
46
|
-
const withPlural = `[
|
|
41
|
+
const withPlural = `export const BUILTIN_SLASH_COMMANDS = [
|
|
47
42
|
{ name: "models", description: "Enhanced picker" },
|
|
48
43
|
{ name: "model", description: "Select model (opens selector UI)" },
|
|
49
|
-
]
|
|
50
|
-
const out =
|
|
44
|
+
];`;
|
|
45
|
+
const out = stripBuiltinModelCommand(withPlural);
|
|
51
46
|
expect(out).toContain('name: "models"');
|
|
52
47
|
expect(out).not.toContain('{ name: "model", description');
|
|
53
48
|
});
|
|
54
49
|
|
|
50
|
+
it("removes Pi's current /model form with an argument hint", () => {
|
|
51
|
+
const out = stripBuiltinModelCommand(CURRENT_PI);
|
|
52
|
+
expect(out).not.toContain('name: "model"');
|
|
53
|
+
expect(out).toContain('name: "settings"');
|
|
54
|
+
expect(out).toContain('name: "scoped-models"');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not alter /model text outside the built-in command array", () => {
|
|
58
|
+
const source = `const source = '{ name: "model" }';
|
|
59
|
+
export const BUILTIN_SLASH_COMMANDS = [
|
|
60
|
+
{ name: "settings", description: "Open settings menu" },
|
|
61
|
+
];
|
|
62
|
+
`;
|
|
63
|
+
expect(stripBuiltinModelCommand(source)).toBe(source);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("removes a multi-line /model command without touching adjacent entries", () => {
|
|
67
|
+
const multiline = `export const BUILTIN_SLASH_COMMANDS = [
|
|
68
|
+
{ name: "settings", description: "Open settings menu" },
|
|
69
|
+
{
|
|
70
|
+
name: "model",
|
|
71
|
+
description: "Select model (opens selector UI)",
|
|
72
|
+
argumentHint: "<provider/model>",
|
|
73
|
+
},
|
|
74
|
+
{ name: "login", description: "Configure provider authentication" },
|
|
75
|
+
];
|
|
76
|
+
`;
|
|
77
|
+
const out = stripBuiltinModelCommand(multiline);
|
|
78
|
+
expect(out).not.toContain('name: "model"');
|
|
79
|
+
expect(out).toContain('name: "settings"');
|
|
80
|
+
expect(out).toContain('name: "login"');
|
|
81
|
+
});
|
|
82
|
+
|
|
55
83
|
it("round-trips through disk", () => {
|
|
56
84
|
const dir = mkdtempSync(join(tmpdir(), "pix-patch-"));
|
|
57
85
|
const file = join(dir, "slash-commands.js");
|
|
58
|
-
writeFileSync(file,
|
|
59
|
-
writeFileSync(file,
|
|
86
|
+
writeFileSync(file, CURRENT_PI, "utf8");
|
|
87
|
+
writeFileSync(file, stripBuiltinModelCommand(readFileSync(file, "utf8")), "utf8");
|
|
60
88
|
expect(readFileSync(file, "utf8")).not.toContain('name: "model"');
|
|
61
89
|
});
|
|
62
90
|
});
|
package/src/patch-builtin.ts
CHANGED
|
@@ -19,7 +19,12 @@ import { createRequire } from "node:module";
|
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Pi has added fields to this object over time (for example, `argumentHint` in
|
|
23
|
+
// v0.80). Match the command entry by its stable `name`, rather than an exact
|
|
24
|
+
// serialized line, while limiting the match to a single non-nested object.
|
|
25
|
+
const BUILTIN_COMMANDS_ARRAY = /export\s+const\s+BUILTIN_SLASH_COMMANDS[^=]*=\s*\[/;
|
|
26
|
+
const BUILTIN_MODEL_COMMAND =
|
|
27
|
+
/^[ \t]*\{(?=[^{}]*\bname\s*:\s*["']model["'])[^{}]*\},?[ \t]*(?:\r?\n|$)/gm;
|
|
23
28
|
|
|
24
29
|
/** Candidate slash-commands.js paths, most-specific first. */
|
|
25
30
|
function candidatePaths(): string[] {
|
|
@@ -89,10 +94,8 @@ export function patchOutBuiltinModelCommand(): void {
|
|
|
89
94
|
return;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const patched = source.replace(new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`), "");
|
|
95
|
-
if (patched === source) return;
|
|
97
|
+
const patched = stripBuiltinModelCommand(source);
|
|
98
|
+
if (patched === source) return; // already patched, or host format is unknown
|
|
96
99
|
|
|
97
100
|
try {
|
|
98
101
|
writeFileSync(file, patched, "utf8");
|
|
@@ -101,8 +104,25 @@ export function patchOutBuiltinModelCommand(): void {
|
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Remove Pi's built-in `/model` entry from compiled slash-command source.
|
|
109
|
+
*
|
|
110
|
+
* The command objects are static, flat literals. Matching the entry's `name`
|
|
111
|
+
* tolerates added properties and line wrapping without touching `/models`.
|
|
112
|
+
*/
|
|
113
|
+
export function stripBuiltinModelCommand(source: string): string {
|
|
114
|
+
const array = BUILTIN_COMMANDS_ARRAY.exec(source);
|
|
115
|
+
if (!array || array.index === undefined) return source;
|
|
116
|
+
|
|
117
|
+
const open = array.index + array[0].lastIndexOf("[");
|
|
118
|
+
const close = source.indexOf("];", open);
|
|
119
|
+
if (close < 0) return source;
|
|
120
|
+
|
|
121
|
+
const entries = source.slice(open + 1, close);
|
|
122
|
+
const patchedEntries = entries.replace(BUILTIN_MODEL_COMMAND, "");
|
|
123
|
+
if (patchedEntries === entries) return source;
|
|
124
|
+
|
|
125
|
+
return `${source.slice(0, open + 1)}${patchedEntries}${source.slice(close)}`;
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
// Export for tests
|