@lunchbox-labs/opencode-model-profiles 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +90 -2
- package/dist/apply.d.ts +18 -0
- package/dist/apply.js +71 -0
- package/dist/fallback.d.ts +49 -0
- package/dist/fallback.js +169 -0
- package/dist/model-config-view.d.ts +36 -0
- package/dist/model-config-view.js +131 -0
- package/dist/models-file.d.ts +22 -0
- package/dist/models-file.js +155 -0
- package/dist/report.d.ts +17 -0
- package/dist/report.js +73 -0
- package/dist/resolution.d.ts +46 -0
- package/dist/resolution.js +179 -0
- package/dist/selection.d.ts +17 -0
- package/dist/selection.js +95 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +280 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +204 -0
- package/package.json +73 -4
- package/schema.json +97 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { modelsFilePath, resolveModelProfiles, stripModelRefs, } from "./apply.js";
|
|
2
|
+
import { assignmentsAreStale, availabilityKey, availableModels, fallbackModel, knownAvailableModels, knownRefModels, readConnectedProviders, recentModels, rememberAvailableModels, } from "./fallback.js";
|
|
3
|
+
import { isRecord } from "./models-file.js";
|
|
4
|
+
import { publishReport } from "./report.js";
|
|
5
|
+
import { selectionFilePath, stateDirectory, worktreeRoot, } from "./selection.js";
|
|
6
|
+
const logService = "model-profiles";
|
|
7
|
+
/** Availability each directory was last reloaded for, so a reload that changes nothing is never repeated. */
|
|
8
|
+
const reloadedFor = new Map();
|
|
9
|
+
function modelName(value) {
|
|
10
|
+
if (isRecord(value) &&
|
|
11
|
+
typeof value.providerID === "string" &&
|
|
12
|
+
typeof value.modelID === "string") {
|
|
13
|
+
return `${value.providerID}/${value.modelID}`;
|
|
14
|
+
}
|
|
15
|
+
// OpenCode stores a session's model as { providerID, id }.
|
|
16
|
+
if (isRecord(value) &&
|
|
17
|
+
typeof value.providerID === "string" &&
|
|
18
|
+
typeof value.id === "string") {
|
|
19
|
+
return `${value.providerID}/${value.id}`;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Keeps agents usable when a ref names a model no connected provider offers. The config hook runs before
|
|
25
|
+
* providers load, so it cannot check models itself: it uses the availability saved by an earlier check, and until
|
|
26
|
+
* there is one, a prompt to such a model is moved to the model OpenCode would use without the ref. When the
|
|
27
|
+
* applied refs no longer match what is available, the project reloads once nothing is running, so the config hook
|
|
28
|
+
* leaves those refs unapplied (which also covers built-in agents such as compaction that never pass through a
|
|
29
|
+
* prompt).
|
|
30
|
+
*/
|
|
31
|
+
function createFallbacks(input, getApplied) {
|
|
32
|
+
const { client, directory } = input;
|
|
33
|
+
/** Each session's model after this hook, for subagents that inherit their parent's. */
|
|
34
|
+
const sessionModels = new Map();
|
|
35
|
+
let reloadWanted = false;
|
|
36
|
+
let reloading = false;
|
|
37
|
+
let checkedOnce = false;
|
|
38
|
+
let lastAvailable;
|
|
39
|
+
const log = (message) => client.app
|
|
40
|
+
.log({
|
|
41
|
+
body: { service: logService, level: "warn", message },
|
|
42
|
+
query: { directory },
|
|
43
|
+
})
|
|
44
|
+
.catch(() => undefined);
|
|
45
|
+
const check = async () => {
|
|
46
|
+
const response = await client.config.providers({ query: { directory } });
|
|
47
|
+
const connected = response.error === undefined
|
|
48
|
+
? readConnectedProviders(response.data)
|
|
49
|
+
: undefined;
|
|
50
|
+
if (connected === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const available = availableModels(connected);
|
|
54
|
+
lastAvailable = available;
|
|
55
|
+
await rememberAvailableModels(stateDirectory(), directory, available, getApplied().assignments.map((assignment) => assignment.model));
|
|
56
|
+
if (assignmentsAreStale(getApplied().assignments, available) &&
|
|
57
|
+
reloadedFor.get(directory) !== availabilityKey(available)) {
|
|
58
|
+
reloadWanted = true;
|
|
59
|
+
}
|
|
60
|
+
return { connected, available };
|
|
61
|
+
};
|
|
62
|
+
const parentModel = async (sessionID) => {
|
|
63
|
+
const session = await client.session.get({
|
|
64
|
+
path: { id: sessionID },
|
|
65
|
+
query: { directory },
|
|
66
|
+
});
|
|
67
|
+
const parentID = isRecord(session.data) ? session.data.parentID : undefined;
|
|
68
|
+
if (typeof parentID !== "string") {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const remembered = sessionModels.get(parentID);
|
|
72
|
+
if (remembered !== undefined) {
|
|
73
|
+
return remembered;
|
|
74
|
+
}
|
|
75
|
+
const parent = await client.session.get({
|
|
76
|
+
path: { id: parentID },
|
|
77
|
+
query: { directory },
|
|
78
|
+
});
|
|
79
|
+
// OpenCode 1.19 includes the session's model, which the SDK's type does not list yet.
|
|
80
|
+
const data = parent.data;
|
|
81
|
+
return isRecord(data) ? modelName(data.model) : undefined;
|
|
82
|
+
};
|
|
83
|
+
const checkMessage = async (hookInput, message) => {
|
|
84
|
+
const applied = getApplied();
|
|
85
|
+
const requested = modelName(message.model);
|
|
86
|
+
const sessionID = typeof hookInput.sessionID === "string" ? hookInput.sessionID : undefined;
|
|
87
|
+
const result = await check();
|
|
88
|
+
if (result === undefined || requested === undefined) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { connected, available } = result;
|
|
92
|
+
const remember = (model) => {
|
|
93
|
+
if (sessionID !== undefined) {
|
|
94
|
+
sessionModels.set(sessionID, model);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
if (available.has(requested)) {
|
|
98
|
+
remember(requested);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// A model the caller chose (as the task tool does for a subagent) is moved only when a ref put it there. One
|
|
102
|
+
// OpenCode took from the agent or the session is moved either way, because OpenCode stores a session's model
|
|
103
|
+
// before this hook runs, so a session keeps asking for it after the ref is fixed or left out.
|
|
104
|
+
const assignment = applied.assignments.find((candidate) => candidate.agent === message.agent && candidate.model === requested) ??
|
|
105
|
+
applied.assignments.find((candidate) => candidate.model === requested);
|
|
106
|
+
if (assignment === undefined &&
|
|
107
|
+
(hookInput.model !== undefined ||
|
|
108
|
+
!knownRefModels(directory).has(requested))) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const own = assignment !== undefined &&
|
|
112
|
+
assignment.agent === message.agent &&
|
|
113
|
+
assignment.ownModel !== undefined
|
|
114
|
+
? {
|
|
115
|
+
model: assignment.ownModel,
|
|
116
|
+
...(assignment.ownVariant === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { variant: assignment.ownVariant }),
|
|
119
|
+
}
|
|
120
|
+
: undefined;
|
|
121
|
+
const inherited = sessionID === undefined
|
|
122
|
+
? undefined
|
|
123
|
+
: await parentModel(sessionID).catch(() => undefined);
|
|
124
|
+
const fallback = fallbackModel({
|
|
125
|
+
...(own === undefined ? {} : { own }),
|
|
126
|
+
...(inherited === undefined ? {} : { parentModel: inherited }),
|
|
127
|
+
available,
|
|
128
|
+
connected,
|
|
129
|
+
...(applied.configuredModel === undefined
|
|
130
|
+
? {}
|
|
131
|
+
: { configuredModel: applied.configuredModel }),
|
|
132
|
+
configuredProviders: applied.configuredProviders,
|
|
133
|
+
recent: await recentModels(stateDirectory()),
|
|
134
|
+
});
|
|
135
|
+
if (fallback === undefined) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
message.model = fallback;
|
|
139
|
+
remember(`${fallback.providerID}/${fallback.modelID}`);
|
|
140
|
+
const agent = typeof message.agent === "string" ? message.agent : "the agent";
|
|
141
|
+
await log(assignment === undefined
|
|
142
|
+
? `${agent} asked for ${requested}, which no connected provider offers; it uses ${fallback.providerID}/${fallback.modelID} instead.`
|
|
143
|
+
: `Ref "${assignment.ref}" uses ${requested}, which no connected provider offers; ${agent} uses ${fallback.providerID}/${fallback.modelID} instead.`);
|
|
144
|
+
};
|
|
145
|
+
/** Reloads only while no session is busy and no terminal is open, since a reload ends running terminals. */
|
|
146
|
+
const reloadWhenIdle = async () => {
|
|
147
|
+
const status = await client.session.status({ query: { directory } });
|
|
148
|
+
if (status.error !== undefined ||
|
|
149
|
+
!isRecord(status.data) ||
|
|
150
|
+
Object.values(status.data).some((session) => !isRecord(session) || session.type !== "idle")) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const terminals = await client.pty.list({ query: { directory } });
|
|
154
|
+
if (terminals.error !== undefined ||
|
|
155
|
+
!Array.isArray(terminals.data) ||
|
|
156
|
+
terminals.data.length > 0) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const available = lastAvailable;
|
|
160
|
+
const disposed = await client.instance.dispose({ query: { directory } });
|
|
161
|
+
if (disposed.error === undefined) {
|
|
162
|
+
reloadWanted = false;
|
|
163
|
+
if (available !== undefined) {
|
|
164
|
+
reloadedFor.set(directory, availabilityKey(available));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const scheduleReload = () => {
|
|
169
|
+
if (!reloadWanted || reloading) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
reloading = true;
|
|
173
|
+
// Disposing from inside the event that reports idleness would wait on this very hook.
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
reloadWhenIdle()
|
|
176
|
+
.catch(() => undefined)
|
|
177
|
+
.finally(() => {
|
|
178
|
+
reloading = false;
|
|
179
|
+
});
|
|
180
|
+
}, 0);
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
message: async (hookInput, message) => {
|
|
184
|
+
if (isRecord(message)) {
|
|
185
|
+
await checkMessage(hookInput, message);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
forget: (sessionID) => {
|
|
189
|
+
sessionModels.delete(sessionID);
|
|
190
|
+
},
|
|
191
|
+
event: (type) => {
|
|
192
|
+
if (!checkedOnce) {
|
|
193
|
+
// Saves availability before the first prompt too, so the next start leaves unavailable refs out. Reloading
|
|
194
|
+
// waits for an idle session, since the project may still be starting.
|
|
195
|
+
checkedOnce = true;
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
check().catch(() => undefined);
|
|
198
|
+
}, 0);
|
|
199
|
+
}
|
|
200
|
+
if (type === "session.idle") {
|
|
201
|
+
scheduleReload();
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const ModelProfilesServer = (input, options) => {
|
|
207
|
+
const worktree = worktreeRoot(input.worktree, input.directory);
|
|
208
|
+
let applied = { assignments: [], configuredProviders: [] };
|
|
209
|
+
const fallbacks = createFallbacks(input, () => applied);
|
|
210
|
+
return Promise.resolve({
|
|
211
|
+
config: async (config) => {
|
|
212
|
+
const target = config;
|
|
213
|
+
const configFile = modelsFilePath(options);
|
|
214
|
+
try {
|
|
215
|
+
const available = await knownAvailableModels(stateDirectory(), input.directory);
|
|
216
|
+
const resolved = await resolveModelProfiles(target, {
|
|
217
|
+
configFile,
|
|
218
|
+
worktree,
|
|
219
|
+
...(available === undefined ? {} : { availableModels: available }),
|
|
220
|
+
});
|
|
221
|
+
applied = {
|
|
222
|
+
assignments: resolved.assignments,
|
|
223
|
+
...(typeof target.model === "string"
|
|
224
|
+
? { configuredModel: target.model }
|
|
225
|
+
: {}),
|
|
226
|
+
configuredProviders: isRecord(target.provider)
|
|
227
|
+
? Object.keys(target.provider)
|
|
228
|
+
: [],
|
|
229
|
+
};
|
|
230
|
+
publishReport(target, options, resolved.report);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
try {
|
|
234
|
+
applied = { assignments: [], configuredProviders: [] };
|
|
235
|
+
stripModelRefs(target);
|
|
236
|
+
publishReport(target, options, {
|
|
237
|
+
version: 1,
|
|
238
|
+
configFile,
|
|
239
|
+
selectionFile: selectionFilePath(stateDirectory(), worktree),
|
|
240
|
+
worktree,
|
|
241
|
+
profiles: [],
|
|
242
|
+
profile: null,
|
|
243
|
+
profileSource: "none",
|
|
244
|
+
refs: [],
|
|
245
|
+
notices: [
|
|
246
|
+
`Model refs were not applied (${error instanceof Error ? error.message : String(error)}); agents keep OpenCode's models.`,
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// A failing config hook must never stop OpenCode; agents then keep OpenCode's models.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
"chat.message": async (hookInput, output) => {
|
|
256
|
+
try {
|
|
257
|
+
await fallbacks.message(hookInput, output.message);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// A failed check leaves the prompt as OpenCode built it.
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
event: ({ event }) => {
|
|
264
|
+
try {
|
|
265
|
+
fallbacks.event(event.type);
|
|
266
|
+
if (event.type === "session.deleted") {
|
|
267
|
+
fallbacks.forget(event.properties.info.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Fallback checks never stop OpenCode's events.
|
|
272
|
+
}
|
|
273
|
+
return Promise.resolve();
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
export default {
|
|
278
|
+
id: "model-profiles",
|
|
279
|
+
server: ModelProfilesServer,
|
|
280
|
+
};
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { fallbackToastLines, modelConfigOptions, profileLabel, selectedChoice, selectedNotice, unavailableModelNotices, } from "./model-config-view.js";
|
|
2
|
+
import { loadModelsDocument } from "./models-file.js";
|
|
3
|
+
import { readReport } from "./report.js";
|
|
4
|
+
import { clearSelection, processProfileVariable, readSavedSelection, saveSelection, } from "./selection.js";
|
|
5
|
+
const toastTitle = "Model Profiles";
|
|
6
|
+
function errorText(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error);
|
|
8
|
+
}
|
|
9
|
+
function registerModelConfig(api) {
|
|
10
|
+
const directory = () => api.state.path.directory;
|
|
11
|
+
let shownNotices = new Set();
|
|
12
|
+
let reading = Promise.resolve();
|
|
13
|
+
let reloadsInFlight = 0;
|
|
14
|
+
const toast = (variant, lines) => {
|
|
15
|
+
api.ui.toast({
|
|
16
|
+
variant,
|
|
17
|
+
title: toastTitle,
|
|
18
|
+
message: lines.join("\n"),
|
|
19
|
+
duration: variant === "warning" ? 10_000 : 5_000,
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
/** Reads what the server applied; a fallback is new until it disappears from a later report. */
|
|
23
|
+
const readApplied = () => {
|
|
24
|
+
const next = reading.then(async () => {
|
|
25
|
+
const response = await api.client.config.get({ directory: directory() });
|
|
26
|
+
const report = response.error === undefined ? readReport(response.data) : undefined;
|
|
27
|
+
if (report === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const notices = [
|
|
31
|
+
...unavailableModelNotices(report.refs, api.state.provider),
|
|
32
|
+
...report.notices,
|
|
33
|
+
];
|
|
34
|
+
const newNotices = notices.filter((notice) => !shownNotices.has(notice));
|
|
35
|
+
shownNotices = new Set(notices);
|
|
36
|
+
return { report, notices, newNotices };
|
|
37
|
+
});
|
|
38
|
+
reading = next.catch(() => undefined);
|
|
39
|
+
return next;
|
|
40
|
+
};
|
|
41
|
+
const announceFallbacks = async () => {
|
|
42
|
+
const applied = await readApplied();
|
|
43
|
+
if (applied !== undefined && applied.newNotices.length > 0) {
|
|
44
|
+
toast("warning", fallbackToastLines(applied.newNotices));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const reload = async (label) => {
|
|
48
|
+
const status = await api.client.session.status({ directory: directory() });
|
|
49
|
+
if (status.error !== undefined) {
|
|
50
|
+
toast("warning", [
|
|
51
|
+
`Saved ${label} for this worktree, but OpenCode could not report whether a session is busy, so it did not reload. It applies on the next reload.`,
|
|
52
|
+
]);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Object.values(status.data).some((session) => session.type !== "idle")) {
|
|
56
|
+
toast("info", [
|
|
57
|
+
`Saved ${label} for this worktree. A session is busy, so it applies the next time OpenCode reloads this project, or pick it again in /model-config once the session is idle.`,
|
|
58
|
+
]);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
reloadsInFlight += 1;
|
|
62
|
+
const disposed = await api.client.instance
|
|
63
|
+
.dispose({ directory: directory() })
|
|
64
|
+
.finally(() => {
|
|
65
|
+
reloadsInFlight -= 1;
|
|
66
|
+
});
|
|
67
|
+
if (disposed.error !== undefined) {
|
|
68
|
+
toast("warning", [
|
|
69
|
+
`Saved ${label}, but OpenCode did not reload. It applies on the next reload.`,
|
|
70
|
+
]);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const applied = await readApplied();
|
|
74
|
+
if (applied === undefined) {
|
|
75
|
+
toast("success", [`Saved ${label}.`]);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
toast(applied.newNotices.length > 0 ? "warning" : "success", [
|
|
79
|
+
`Using ${profileLabel(applied.report.profile)}.`,
|
|
80
|
+
...fallbackToastLines(applied.newNotices),
|
|
81
|
+
]);
|
|
82
|
+
};
|
|
83
|
+
const choose = async (report, choice, defaultProfile) => {
|
|
84
|
+
const label = choice.kind === "save"
|
|
85
|
+
? profileLabel(choice.profile)
|
|
86
|
+
: `defaultProfile (${defaultProfile ?? "none"})`;
|
|
87
|
+
try {
|
|
88
|
+
await (choice.kind === "save"
|
|
89
|
+
? saveSelection(report.selectionFile, report.worktree, choice.profile)
|
|
90
|
+
: clearSelection(report.selectionFile));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
toast("warning", [`Could not save ${label}: ${errorText(error)}`]);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (report.environmentProfile !== undefined) {
|
|
97
|
+
toast("info", [
|
|
98
|
+
`Saved ${label} for this worktree. ${processProfileVariable}=${report.environmentProfile} still applies to this OpenCode process.`,
|
|
99
|
+
]);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await reload(label);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
toast("warning", [
|
|
107
|
+
`Saved ${label}, but could not reload OpenCode (${errorText(error)}). It applies on the next reload.`,
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const open = async () => {
|
|
112
|
+
const applied = await readApplied().catch(() => undefined);
|
|
113
|
+
if (applied === undefined) {
|
|
114
|
+
toast("warning", [
|
|
115
|
+
"The server plugin has not reported model refs. Add @lunchbox-labs/opencode-model-profiles to plugin in opencode.jsonc.",
|
|
116
|
+
]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { report, notices } = applied;
|
|
120
|
+
const [current, saved] = await Promise.all([
|
|
121
|
+
loadModelsDocument(report.configFile),
|
|
122
|
+
readSavedSelection(report.selectionFile),
|
|
123
|
+
]);
|
|
124
|
+
const loaded = current.status === "loaded";
|
|
125
|
+
const defaultProfile = loaded ? current.document.defaultProfile : undefined;
|
|
126
|
+
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
127
|
+
title: "Model config",
|
|
128
|
+
options: modelConfigOptions({
|
|
129
|
+
report,
|
|
130
|
+
profiles: loaded
|
|
131
|
+
? Object.keys(current.document.profiles)
|
|
132
|
+
: report.profiles,
|
|
133
|
+
savedProfile: saved.profile,
|
|
134
|
+
defaultProfile,
|
|
135
|
+
notices,
|
|
136
|
+
}),
|
|
137
|
+
current: report.profile === null ? "defaults" : `profile:${report.profile}`,
|
|
138
|
+
onSelect: (option) => {
|
|
139
|
+
const notice = selectedNotice(option.value, notices);
|
|
140
|
+
if (notice !== undefined) {
|
|
141
|
+
toast("warning", [notice]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const choice = selectedChoice(option.value);
|
|
145
|
+
if (choice === undefined) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
api.ui.dialog.clear();
|
|
149
|
+
choose(report, choice, defaultProfile).catch(() => undefined);
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
152
|
+
api.ui.dialog.setSize("large");
|
|
153
|
+
};
|
|
154
|
+
const unregister = api.keymap.registerLayer({
|
|
155
|
+
commands: [
|
|
156
|
+
{
|
|
157
|
+
name: "model-profiles.config",
|
|
158
|
+
title: "Model config",
|
|
159
|
+
category: "Model",
|
|
160
|
+
namespace: "palette",
|
|
161
|
+
slashName: "model-config",
|
|
162
|
+
run: () => {
|
|
163
|
+
open().catch(() => undefined);
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
const unsubscribeInstance = api.event.on("server.instance.disposed", (event) => {
|
|
169
|
+
if (reloadsInFlight === 0 && event.properties.directory === directory()) {
|
|
170
|
+
announceFallbacks().catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const unsubscribeGlobal = api.event.on("global.disposed", () => {
|
|
174
|
+
announceFallbacks().catch(() => undefined);
|
|
175
|
+
});
|
|
176
|
+
/** Models are checked against connected providers, which may not have loaded when the TUI starts. */
|
|
177
|
+
let providerChecks = 0;
|
|
178
|
+
const providerTimer = setInterval(() => {
|
|
179
|
+
providerChecks += 1;
|
|
180
|
+
if (api.state.provider.length > 0) {
|
|
181
|
+
clearInterval(providerTimer);
|
|
182
|
+
announceFallbacks().catch(() => undefined);
|
|
183
|
+
}
|
|
184
|
+
else if (providerChecks >= 60) {
|
|
185
|
+
clearInterval(providerTimer);
|
|
186
|
+
}
|
|
187
|
+
}, 500);
|
|
188
|
+
providerTimer.unref();
|
|
189
|
+
api.lifecycle.onDispose(() => {
|
|
190
|
+
clearInterval(providerTimer);
|
|
191
|
+
unregister();
|
|
192
|
+
unsubscribeInstance();
|
|
193
|
+
unsubscribeGlobal();
|
|
194
|
+
});
|
|
195
|
+
announceFallbacks().catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
const ModelProfilesTui = (api) => {
|
|
198
|
+
registerModelConfig(api);
|
|
199
|
+
return Promise.resolve();
|
|
200
|
+
};
|
|
201
|
+
export default {
|
|
202
|
+
id: "model-profiles",
|
|
203
|
+
tui: ModelProfilesTui,
|
|
204
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunchbox-labs/opencode-model-profiles",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pick agent models by name, with defaults and per-worktree profiles",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lunchbox-labs/opencode-model-profiles.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.18.0"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/server.d.ts",
|
|
17
|
+
"default": "./dist/server.js"
|
|
18
|
+
},
|
|
19
|
+
"./server": {
|
|
20
|
+
"types": "./dist/server.d.ts",
|
|
21
|
+
"default": "./dist/server.js"
|
|
22
|
+
},
|
|
23
|
+
"./tui": {
|
|
24
|
+
"types": "./dist/tui.d.ts",
|
|
25
|
+
"default": "./dist/tui.js"
|
|
26
|
+
},
|
|
27
|
+
"./schema.json": "./schema.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"schema.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
38
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs",
|
|
39
|
+
"lint": "eslint . --max-warnings 0",
|
|
40
|
+
"format": "prettier --write src tests scripts package.json tsconfig*.json *.config.mjs schema.json",
|
|
41
|
+
"format:check": "prettier --check src tests scripts package.json tsconfig*.json *.config.mjs schema.json",
|
|
42
|
+
"check": "npm run check:static && npm test",
|
|
43
|
+
"prepack": "npm run build",
|
|
44
|
+
"test:package": "npm run build && node --experimental-strip-types --test tests/package.integration.ts",
|
|
45
|
+
"check:static": "concurrently --names typecheck,lint,format --kill-others-on-fail \"npm run typecheck\" \"npm run lint\" \"npm run format:check\"",
|
|
46
|
+
"prebuild": "node --input-type=module -e \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\""
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
|
50
|
+
"@eslint/js": "10.0.1",
|
|
51
|
+
"@opencode-ai/plugin": "1.19.5",
|
|
52
|
+
"@opencode-ai/sdk": "1.19.5",
|
|
53
|
+
"@opentui/core": "0.4.5",
|
|
54
|
+
"@opentui/keymap": "0.4.5",
|
|
55
|
+
"@opentui/solid": "0.4.5",
|
|
56
|
+
"@types/node": "24.19.2",
|
|
57
|
+
"concurrently": "10.0.6",
|
|
58
|
+
"eslint": "10.14.0",
|
|
59
|
+
"eslint-config-prettier": "10.1.8",
|
|
60
|
+
"eslint-plugin-regexp": "3.3.3",
|
|
61
|
+
"globals": "17.14.0",
|
|
62
|
+
"prettier": "3.9.10",
|
|
63
|
+
"solid-js": "1.9.12",
|
|
64
|
+
"typescript": "6.0.3",
|
|
65
|
+
"typescript-eslint": "8.72.1",
|
|
66
|
+
"yaml": "2.9.1"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public",
|
|
70
|
+
"registry": "https://registry.npmjs.org"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"jsonc-parser": "3.3.1"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/schema.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://cdn.jsdelivr.net/npm/@lunchbox-labs/opencode-model-profiles/schema.json",
|
|
4
|
+
"title": "OpenCode Model Profiles",
|
|
5
|
+
"description": "Named model refs for OpenCode agents: defaults, per-worktree profiles and built-in agent refs.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string"
|
|
11
|
+
},
|
|
12
|
+
"defaultProfile": {
|
|
13
|
+
"description": "Profile used when no profile is saved for the worktree and OPENCODE_MODEL_PROFILE is unset.",
|
|
14
|
+
"type": "string",
|
|
15
|
+
"minLength": 1
|
|
16
|
+
},
|
|
17
|
+
"defaults": {
|
|
18
|
+
"description": "Model for each ref when the selected profile does not define it.",
|
|
19
|
+
"$ref": "#/$defs/refs"
|
|
20
|
+
},
|
|
21
|
+
"profiles": {
|
|
22
|
+
"description": "Profiles by name, each mapping ref names to models.",
|
|
23
|
+
"type": "object",
|
|
24
|
+
"propertyNames": {
|
|
25
|
+
"minLength": 1
|
|
26
|
+
},
|
|
27
|
+
"additionalProperties": {
|
|
28
|
+
"$ref": "#/$defs/refs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"builtins": {
|
|
32
|
+
"description": "Ref for each built-in OpenCode agent. Built-in agents without a ref keep OpenCode's default model.",
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"build": {
|
|
37
|
+
"$ref": "#/$defs/refName"
|
|
38
|
+
},
|
|
39
|
+
"plan": {
|
|
40
|
+
"$ref": "#/$defs/refName"
|
|
41
|
+
},
|
|
42
|
+
"general": {
|
|
43
|
+
"$ref": "#/$defs/refName"
|
|
44
|
+
},
|
|
45
|
+
"explore": {
|
|
46
|
+
"$ref": "#/$defs/refName"
|
|
47
|
+
},
|
|
48
|
+
"compaction": {
|
|
49
|
+
"$ref": "#/$defs/refName"
|
|
50
|
+
},
|
|
51
|
+
"title": {
|
|
52
|
+
"$ref": "#/$defs/refName"
|
|
53
|
+
},
|
|
54
|
+
"summary": {
|
|
55
|
+
"$ref": "#/$defs/refName"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"$defs": {
|
|
61
|
+
"refName": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1
|
|
64
|
+
},
|
|
65
|
+
"modelIdentifier": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"pattern": "^[^\\s/]+/\\S+$"
|
|
68
|
+
},
|
|
69
|
+
"refs": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"propertyNames": {
|
|
72
|
+
"minLength": 1
|
|
73
|
+
},
|
|
74
|
+
"additionalProperties": {
|
|
75
|
+
"oneOf": [
|
|
76
|
+
{
|
|
77
|
+
"$ref": "#/$defs/modelIdentifier"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"type": "object",
|
|
81
|
+
"additionalProperties": false,
|
|
82
|
+
"required": ["model"],
|
|
83
|
+
"properties": {
|
|
84
|
+
"model": {
|
|
85
|
+
"$ref": "#/$defs/modelIdentifier"
|
|
86
|
+
},
|
|
87
|
+
"variant": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"minLength": 1
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|