@sangyaahub/pi-marg 1.0.5 → 1.0.6
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/.omp-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +6 -0
- package/MODEL-SETUP.md +4 -4
- package/README.md +9 -3
- package/adapters/omp/extensions/model-router.ts +243 -59
- package/adapters/omp/extensions/provider-setup.ts +177 -0
- package/adapters/pi/extensions/model-router.ts +13 -4
- package/adapters/pi/prompts/auto-models.md +2 -2
- package/commands/auto-models.md +2 -2
- package/core/model-routing.ts +133 -1
- package/core/provider-config.ts +142 -0
- package/core/user-route.ts +100 -0
- package/package.json +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/validate.sh +1 -1
- package/skills/auto-mode-router/references/intake-and-state.md +2 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "PiMarg — a governed development path for Pi Agent and Oh My Pi.",
|
|
10
|
-
"version": "1.0.
|
|
10
|
+
"version": "1.0.6"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
14
14
|
"name": "pi-marg",
|
|
15
15
|
"description": "Intent-led development workflow with live A/B/C/D routing, capability detection, verification, and approval gates.",
|
|
16
16
|
"source": "./",
|
|
17
|
-
"version": "1.0.
|
|
17
|
+
"version": "1.0.6",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Sangyaa",
|
|
20
20
|
"email": "contact@sangyaa.co"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.6 — 2026-10-03
|
|
4
|
+
|
|
5
|
+
- Reopening `/pi-marg` highlights the saved boundary, work type, skill, and A/B/C/D selectors. Enter keeps that row, and intake activates the first required stage model before the work message is sent.
|
|
6
|
+
- A saved route now survives a new OMP or Pi process. A selector that disappeared from the live catalog is reported missing and is not remapped or activated.
|
|
7
|
+
- An empty OMP catalog can sign in, add a custom OpenAI-compatible provider, or cancel. Provider files store the env var name, and the secret is written only to the runtime `.env`.
|
|
8
|
+
|
|
3
9
|
## 1.0.5 — 2026-09-26
|
|
4
10
|
|
|
5
11
|
- Documented how to register CheaperInference in Oh My Pi so Kimi and GLM models can be chosen at every A/B/C/D stage.
|
package/MODEL-SETUP.md
CHANGED
|
@@ -16,7 +16,7 @@ LIVE AUTHENTICATED CATALOG
|
|
|
16
16
|
high backup ──► low backup
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
If the live catalog is empty,
|
|
19
|
+
If the live catalog is empty, `/pi-marg` offers three actions: sign in with `/login`, add a custom OpenAI-compatible provider, or cancel. The custom path writes `apiKey: ENV_VAR_NAME` into `~/.omp/agent/models.yml` and the secret only into `~/.omp/agent/.env`. Cancelling writes neither file.
|
|
20
20
|
|
|
21
21
|
## Roles
|
|
22
22
|
|
|
@@ -37,7 +37,7 @@ Use Pi's normal provider configuration or environment variables, then verify ava
|
|
|
37
37
|
pi --list-models
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
The Pi adapter reads `ctx.scopedModels` when the session has a model allowlist; otherwise it reads `ctx.modelRegistry.getAvailable()`.
|
|
40
|
+
The Pi adapter reads `ctx.scopedModels` when the session has a model allowlist; otherwise it reads `ctx.modelRegistry.getAvailable()`. A model can be authenticated and still hidden because that session allowlist is active. This is a scope limit, not a missing login.
|
|
41
41
|
|
|
42
42
|
## Oh My Pi
|
|
43
43
|
|
|
@@ -72,8 +72,8 @@ Any of those four models can be chosen for any stage. Calls spend CheaperInferen
|
|
|
72
72
|
|
|
73
73
|
## Selection workflow
|
|
74
74
|
|
|
75
|
-
1. Run `/pi-marg` for the full native intake, or `/auto-models choose` to change saved models.
|
|
76
|
-
2. Confirm the work type so PiMarg knows which stages are required.
|
|
75
|
+
1. Run `/pi-marg` for the full native intake, or `/auto-models choose` to change saved models. Reopening either one highlights the saved boundary, work type, skill, and each saved A/B/C/D and backup selector. Pressing Enter keeps that highlighted row.
|
|
76
|
+
2. Confirm the work type so PiMarg knows which stages are required. A saved choice that is no longer in the live catalog is reported as missing and is not activated or remapped.
|
|
77
77
|
3. Required stage prompts and catalog sections are always shown in ascending order: **A, then B, then C, then D** (skipping stages that do not apply).
|
|
78
78
|
4. For each required stage, choose a **provider** first (numbered `1. …`), then a **model** from that provider (numbered `1. …`). Exact `provider/id` selectors are the selection keys—not display numbers alone.
|
|
79
79
|
5. Select A and C when discovery/review is required; recommended frontier choices appear first within their lists when recognizable, but every live model remains selectable.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.
|
|
6
|
+
<a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.6" src="https://img.shields.io/badge/version-1.0.6-0451F7"></a>
|
|
7
7
|
<a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-C27B1A"></a>
|
|
8
8
|
<a href="https://pi.dev/packages?name=pi-marg"><img alt="Pi package" src="https://img.shields.io/badge/Pi-package-0338A8"></a>
|
|
9
9
|
<a href="https://omp.sh"><img alt="Oh My Pi plugin" src="https://img.shields.io/badge/OMP-plugin-021E5C"></a>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
# PiMarg
|
|
13
13
|
|
|
14
|
-
**Version 1.0.
|
|
14
|
+
**Version 1.0.6 · MIT licensed · Pi Agent + Oh My Pi · by [Sangyaa](https://sangyaa.co)**
|
|
15
15
|
|
|
16
16
|
PiMarg turns an ordinary prompt into a governed development path. It identifies the work boundary, selects one engineering method, discovers the models and tools the active runtime really provides, separates thinking from execution, records evidence, and stops before protected actions.
|
|
17
17
|
|
|
@@ -122,7 +122,13 @@ Authenticate providers using the active runtime's normal login flow, then run:
|
|
|
122
122
|
/auto-models choose
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
The router shows every model in the live authenticated and enabled registry. Stage-appropriate choices appear first and all other enabled choices remain selectable. It does not promise that a subscription route is free or unlimited. See [MODEL-SETUP.md](MODEL-SETUP.md).
|
|
125
|
+
The router shows every model in the live authenticated and enabled registry. Stage-appropriate choices appear first and all other enabled choices remain selectable. Reopening `/pi-marg` highlights the saved selector; Enter keeps it and activates the first required stage model. It does not promise that a subscription route is free or unlimited. See [MODEL-SETUP.md](MODEL-SETUP.md).
|
|
126
|
+
|
|
127
|
+
### Custom OpenAI-compatible provider
|
|
128
|
+
|
|
129
|
+
When OMP has no models yet, `/pi-marg` can register a custom provider without leaving the intake. It asks for a provider id, display name, base URL, env var name, and model ids. `models.yml` stores `apiKey: YOUR_ENV_VAR`. The secret goes only in `~/.omp/agent/.env`. After the runtime reloads, every model from that provider is offered at A, B, C, and D. A and C must still be different models, and B and D must still be different models. With only one model, PiMarg says so before stage A and lets you add another model or stop before the paired stage.
|
|
130
|
+
|
|
131
|
+
CheaperInference below is one worked example of that same shape, not the only custom provider PiMarg accepts.
|
|
126
132
|
|
|
127
133
|
### CheaperInference (Kimi and GLM)
|
|
128
134
|
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
buildFallbackCatalog,
|
|
6
6
|
buildModelCatalog,
|
|
7
7
|
candidatesForProvider,
|
|
8
|
-
|
|
8
|
+
commitIntakeRoute,
|
|
9
9
|
emptyModelRouteState,
|
|
10
10
|
executeAutomaticFailover,
|
|
11
11
|
executeModelRoute,
|
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
findTerminalUsageLimitFailure,
|
|
14
14
|
formatFailoverRecoveryMessage,
|
|
15
15
|
hasConfiguredFallback,
|
|
16
|
-
matchNumberedOption,
|
|
17
16
|
modelFailureKey,
|
|
18
17
|
modelToolUsageLimitFailure,
|
|
19
18
|
numberedOptionLabel,
|
|
20
19
|
requiredStagesForWorkType,
|
|
21
|
-
|
|
20
|
+
resolveSelectAnswer,
|
|
21
|
+
revalidateSavedRoute,
|
|
22
22
|
uniqueProviders,
|
|
23
23
|
type AutoStage,
|
|
24
24
|
type FallbackLevel,
|
|
@@ -26,8 +26,12 @@ import {
|
|
|
26
26
|
type ModelLike,
|
|
27
27
|
type ModelRouteParams,
|
|
28
28
|
type ModelRouteState,
|
|
29
|
+
type SelectAnswerResolution,
|
|
30
|
+
type WorkBoundary,
|
|
29
31
|
} from "../../../core/model-routing";
|
|
30
32
|
import { normalizeCapabilityNames } from "../../../core/runtime-capabilities";
|
|
33
|
+
import { loadRuntimeRoute, rememberRuntimeRoute } from "../../../core/user-route";
|
|
34
|
+
import { configureEmptyCatalog, resolveSingleModel } from "./provider-setup";
|
|
31
35
|
|
|
32
36
|
const WORK_BOUNDARIES = ["1. Job/client", "2. Personal"] as const;
|
|
33
37
|
|
|
@@ -71,10 +75,68 @@ function commandPrompt(args: string): string {
|
|
|
71
75
|
return trimmed;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
const WORK_BOUNDARY_ITEMS = ["Job/client", "Personal"] as const;
|
|
79
|
+
const WORK_TYPE_ITEMS = [
|
|
80
|
+
"New idea or development from scratch",
|
|
81
|
+
"New feature for an existing repository",
|
|
82
|
+
"Improve an existing feature",
|
|
83
|
+
"Pro-active bug fix",
|
|
84
|
+
"Security review or fixes",
|
|
85
|
+
"Thoughts about an existing repository or features",
|
|
86
|
+
"Business, sales, or other non-technical analysis",
|
|
87
|
+
] as const;
|
|
88
|
+
const SKILL_MODE_ITEMS = [
|
|
89
|
+
"Compound Engineering",
|
|
90
|
+
"Superpowers",
|
|
91
|
+
"GSD Core",
|
|
92
|
+
"PiMarg chooses the best available skill",
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
74
95
|
function optionLabel(candidate: ModelCandidate): string {
|
|
75
96
|
return `${candidate.label} — ${candidate.selector}`;
|
|
76
97
|
}
|
|
77
98
|
|
|
99
|
+
function savedIndex<T>(items: readonly T[], matches: (item: T) => boolean): number | undefined {
|
|
100
|
+
const index = items.findIndex(matches);
|
|
101
|
+
return index >= 0 ? index : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function takeAnswer<T>(ctx: any, resolution: SelectAnswerResolution<T>): T | undefined {
|
|
105
|
+
switch (resolution.status) {
|
|
106
|
+
case "matched":
|
|
107
|
+
return resolution.item;
|
|
108
|
+
case "cancelled":
|
|
109
|
+
ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
|
|
110
|
+
return undefined;
|
|
111
|
+
case "conflict":
|
|
112
|
+
ctx.ui.notify("That answer matches more than one option; no model was saved.", "error");
|
|
113
|
+
return undefined;
|
|
114
|
+
case "unrecognized":
|
|
115
|
+
ctx.ui.notify(`Unrecognized selection ${JSON.stringify(resolution.received)}; no model was saved.`, "error");
|
|
116
|
+
return undefined;
|
|
117
|
+
default: {
|
|
118
|
+
const unreachable: never = resolution;
|
|
119
|
+
return unreachable;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function askChoice<T>(
|
|
125
|
+
ctx: any,
|
|
126
|
+
title: string,
|
|
127
|
+
items: readonly T[],
|
|
128
|
+
labelOf: (item: T) => string,
|
|
129
|
+
options: ReadonlyArray<string | { label: string; description?: string }>,
|
|
130
|
+
settings: { initialIndex?: number; helpText?: string } = {},
|
|
131
|
+
): Promise<T | undefined> {
|
|
132
|
+
const answer = await ctx.ui.select(title, [...options], {
|
|
133
|
+
selectionMarker: "radio",
|
|
134
|
+
...(settings.helpText ? { helpText: settings.helpText } : {}),
|
|
135
|
+
...(typeof settings.initialIndex === "number" ? { initialIndex: settings.initialIndex } : {}),
|
|
136
|
+
});
|
|
137
|
+
return takeAnswer(ctx, resolveSelectAnswer(answer, items, labelOf));
|
|
138
|
+
}
|
|
139
|
+
|
|
78
140
|
function optionDescription(stage: AutoStage, candidate: ModelCandidate): string {
|
|
79
141
|
const preferredTier = stage === "A" || stage === "C" ? "frontier" : "execution";
|
|
80
142
|
const fit = candidate.tier === preferredTier ? "Recommended fit" : "Available";
|
|
@@ -86,6 +148,7 @@ async function chooseModel(
|
|
|
86
148
|
stage: AutoStage,
|
|
87
149
|
candidates: ModelCandidate[],
|
|
88
150
|
selected: Partial<Record<AutoStage, ModelCandidate>>,
|
|
151
|
+
saved?: ModelCandidate,
|
|
89
152
|
): Promise<ModelCandidate | undefined> {
|
|
90
153
|
const opposite: Record<AutoStage, AutoStage> = { A: "C", C: "A", B: "D", D: "B" };
|
|
91
154
|
const other = selected[opposite[stage]];
|
|
@@ -98,13 +161,14 @@ async function chooseModel(
|
|
|
98
161
|
return undefined;
|
|
99
162
|
}
|
|
100
163
|
|
|
164
|
+
const highlighted = saved && eligible.some((candidate) => candidate.selector === saved.selector) ? saved : undefined;
|
|
101
165
|
return chooseCandidateByProvider(ctx, eligible, {
|
|
102
166
|
providerTitle: `Stage ${stage} provider`,
|
|
103
167
|
modelTitle: `Stage ${stage} model`,
|
|
104
168
|
purpose: STAGE_PURPOSE[stage],
|
|
105
169
|
providerDescription: (name) => `All authenticated ${name} models for ${STAGE_PURPOSE[stage]}`,
|
|
106
170
|
modelDescription: (candidate) => optionDescription(stage, candidate),
|
|
107
|
-
});
|
|
171
|
+
}, highlighted);
|
|
108
172
|
}
|
|
109
173
|
|
|
110
174
|
async function chooseFallbackModel(
|
|
@@ -112,6 +176,7 @@ async function chooseFallbackModel(
|
|
|
112
176
|
level: FallbackLevel,
|
|
113
177
|
candidates: ModelCandidate[],
|
|
114
178
|
other?: ModelCandidate,
|
|
179
|
+
saved?: ModelCandidate,
|
|
115
180
|
): Promise<ModelCandidate | undefined> {
|
|
116
181
|
const eligible = candidates.filter((candidate) => (
|
|
117
182
|
candidate.access === "runtime-model" && (!other || candidate.identity !== other.identity)
|
|
@@ -125,6 +190,7 @@ async function chooseFallbackModel(
|
|
|
125
190
|
return undefined;
|
|
126
191
|
}
|
|
127
192
|
|
|
193
|
+
const highlighted = saved && eligible.some((candidate) => candidate.selector === saved.selector) ? saved : undefined;
|
|
128
194
|
return chooseCandidateByProvider(ctx, eligible, {
|
|
129
195
|
providerTitle: `${title} backup provider`,
|
|
130
196
|
modelTitle: `${title} backup model`,
|
|
@@ -133,7 +199,7 @@ async function chooseFallbackModel(
|
|
|
133
199
|
modelDescription: (candidate) => (
|
|
134
200
|
`${FALLBACK_PURPOSE[level]}${candidate.billingNote ? ` · ${candidate.billingNote}` : ""}`
|
|
135
201
|
),
|
|
136
|
-
});
|
|
202
|
+
}, highlighted);
|
|
137
203
|
}
|
|
138
204
|
|
|
139
205
|
async function chooseCandidateByProvider(
|
|
@@ -146,20 +212,23 @@ async function chooseCandidateByProvider(
|
|
|
146
212
|
providerDescription(provider: string): string;
|
|
147
213
|
modelDescription(candidate: ModelCandidate): string;
|
|
148
214
|
},
|
|
215
|
+
saved?: ModelCandidate,
|
|
149
216
|
): Promise<ModelCandidate | undefined> {
|
|
150
217
|
const providers = uniqueProviders(candidates);
|
|
151
218
|
const providerOptions = providers.map((name, index) => ({
|
|
152
219
|
label: numberedOptionLabel(index, `${name} (${candidatesForProvider(candidates, name).length})`),
|
|
153
220
|
description: copy.providerDescription(name),
|
|
154
221
|
}));
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
159
|
-
const provider = matchNumberedOption(
|
|
160
|
-
providerAnswer,
|
|
222
|
+
const provider = await askChoice(
|
|
223
|
+
ctx,
|
|
224
|
+
copy.providerTitle,
|
|
161
225
|
providers,
|
|
162
226
|
(name) => `${name} (${candidatesForProvider(candidates, name).length})`,
|
|
227
|
+
providerOptions,
|
|
228
|
+
{
|
|
229
|
+
initialIndex: saved ? savedIndex(providers, (name) => name === saved.provider) : undefined,
|
|
230
|
+
helpText: `${providers.length} providers · ${candidates.length} models · ${copy.purpose}`,
|
|
231
|
+
},
|
|
163
232
|
);
|
|
164
233
|
if (!provider) return undefined;
|
|
165
234
|
|
|
@@ -168,29 +237,11 @@ async function chooseCandidateByProvider(
|
|
|
168
237
|
label: numberedOptionLabel(index, optionLabel(candidate)),
|
|
169
238
|
description: copy.modelDescription(candidate),
|
|
170
239
|
}));
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
240
|
+
const savedHere = saved?.provider === provider ? saved.selector : undefined;
|
|
241
|
+
return askChoice(ctx, copy.modelTitle, providerModels, optionLabel, modelOptions, {
|
|
242
|
+
initialIndex: savedHere ? savedIndex(providerModels, (candidate) => candidate.selector === savedHere) : undefined,
|
|
243
|
+
helpText: `${providerModels.length} ${provider} choices · ${copy.purpose}${savedHere ? ` · saved ${savedHere}` : ""}`,
|
|
174
244
|
});
|
|
175
|
-
return matchNumberedOption(answer, providerModels, optionLabel);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function selectedState(
|
|
179
|
-
workType: number,
|
|
180
|
-
selected: Partial<Record<AutoStage, ModelCandidate>>,
|
|
181
|
-
fallbacks: Record<FallbackLevel, ModelCandidate>,
|
|
182
|
-
): ModelRouteState {
|
|
183
|
-
const selectedAt = new Date().toISOString();
|
|
184
|
-
return {
|
|
185
|
-
version: 2,
|
|
186
|
-
workType,
|
|
187
|
-
selections: Object.fromEntries(
|
|
188
|
-
Object.entries(selected).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
|
|
189
|
-
) as ModelRouteState["selections"],
|
|
190
|
-
fallbacks: Object.fromEntries(
|
|
191
|
-
Object.entries(fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
|
|
192
|
-
) as ModelRouteState["fallbacks"],
|
|
193
|
-
};
|
|
194
245
|
}
|
|
195
246
|
|
|
196
247
|
function intakeMessage(
|
|
@@ -227,11 +278,20 @@ export default function modelRouter(pi: any) {
|
|
|
227
278
|
let recoveryToken: symbol | undefined;
|
|
228
279
|
let sessionGeneration = 0;
|
|
229
280
|
let pendingToolFailure: { selector: string; key: string } | undefined;
|
|
281
|
+
const routeHome = (ctx: any) => typeof ctx?.agentHome === "string" ? ctx.agentHome : undefined;
|
|
282
|
+
const persistRoute = (nextState: ModelRouteState, ctx?: any) => {
|
|
283
|
+
pi.appendEntry(MODEL_STATE_TYPE, nextState);
|
|
284
|
+
try {
|
|
285
|
+
rememberRuntimeRoute("OMP", nextState, routeHome(ctx));
|
|
286
|
+
} catch {
|
|
287
|
+
ctx?.ui?.notify?.("PiMarg saved the route in this session, but could not update the user route file.", "warning");
|
|
288
|
+
}
|
|
289
|
+
};
|
|
230
290
|
const restore = (_event: unknown, ctx: any) => {
|
|
231
291
|
sessionGeneration += 1;
|
|
232
292
|
recoveryToken = undefined;
|
|
233
293
|
pendingToolFailure = undefined;
|
|
234
|
-
state =
|
|
294
|
+
state = loadRuntimeRoute("OMP", ctx.sessionManager.getBranch(), routeHome(ctx));
|
|
235
295
|
};
|
|
236
296
|
|
|
237
297
|
pi.on("session_start", restore);
|
|
@@ -253,7 +313,7 @@ export default function modelRouter(pi: any) {
|
|
|
253
313
|
activate: (model) => pi.setModel(model),
|
|
254
314
|
persist: (nextState) => {
|
|
255
315
|
if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
|
|
256
|
-
|
|
316
|
+
persistRoute(nextState, ctx);
|
|
257
317
|
},
|
|
258
318
|
}, failure.key);
|
|
259
319
|
if (sessionGeneration !== generation) {
|
|
@@ -348,43 +408,167 @@ export default function modelRouter(pi: any) {
|
|
|
348
408
|
}
|
|
349
409
|
if (!request) return;
|
|
350
410
|
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
411
|
+
const boundary = await askChoice(
|
|
412
|
+
ctx,
|
|
413
|
+
"Work boundary",
|
|
414
|
+
WORK_BOUNDARY_ITEMS,
|
|
415
|
+
(item) => item,
|
|
416
|
+
WORK_BOUNDARIES,
|
|
417
|
+
{ initialIndex: savedIndex(WORK_BOUNDARY_ITEMS, (item) => item === state.boundary) },
|
|
418
|
+
);
|
|
355
419
|
if (!boundary) return;
|
|
356
420
|
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
421
|
+
const workTypeItem = await askChoice(
|
|
422
|
+
ctx,
|
|
423
|
+
"Work type",
|
|
424
|
+
WORK_TYPE_ITEMS,
|
|
425
|
+
(item) => item,
|
|
426
|
+
WORK_TYPES,
|
|
427
|
+
{
|
|
428
|
+
initialIndex: typeof state.workType === "number" && state.workType >= 1 && state.workType <= WORK_TYPE_ITEMS.length
|
|
429
|
+
? state.workType - 1
|
|
430
|
+
: undefined,
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
if (!workTypeItem) return;
|
|
434
|
+
const workType = WORK_TYPE_ITEMS.indexOf(workTypeItem) + 1;
|
|
435
|
+
const workTypeLabel = WORK_TYPES[workType - 1]!;
|
|
436
|
+
|
|
437
|
+
const skillItem = await askChoice(
|
|
438
|
+
ctx,
|
|
439
|
+
"Workflow skill",
|
|
440
|
+
SKILL_MODE_ITEMS,
|
|
441
|
+
(item) => item,
|
|
442
|
+
SKILL_MODES,
|
|
443
|
+
{ initialIndex: savedIndex(SKILL_MODES, (item) => item === state.skillMode) },
|
|
444
|
+
);
|
|
445
|
+
if (!skillItem) return;
|
|
446
|
+
const skillMode = SKILL_MODES[SKILL_MODE_ITEMS.indexOf(skillItem)]!;
|
|
447
|
+
|
|
448
|
+
const home = routeHome(ctx);
|
|
449
|
+
const pauseForRestart = () => {
|
|
450
|
+
rememberRuntimeRoute("OMP", {
|
|
451
|
+
version: 2,
|
|
452
|
+
workType,
|
|
453
|
+
boundary,
|
|
454
|
+
skillMode,
|
|
455
|
+
selections: {},
|
|
456
|
+
fallbacks: {},
|
|
457
|
+
}, home);
|
|
458
|
+
ctx.ui.notify(
|
|
459
|
+
"Restart OMP so it reloads models.yml, then run /pi-marg again. Work type and skill answers are saved and model selection will resume.",
|
|
460
|
+
"warning",
|
|
461
|
+
);
|
|
462
|
+
};
|
|
463
|
+
let liveModels = ctx.models.list() as ModelLike[];
|
|
365
464
|
if (liveModels.length === 0) {
|
|
366
|
-
ctx
|
|
367
|
-
|
|
465
|
+
const setup = await configureEmptyCatalog(ctx, home);
|
|
466
|
+
if (setup.status === "restart") {
|
|
467
|
+
pauseForRestart();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (setup.status !== "ready") return;
|
|
471
|
+
liveModels = setup.models;
|
|
368
472
|
}
|
|
473
|
+
|
|
474
|
+
let stopBeforePair = false;
|
|
475
|
+
if (liveModels.length === 1) {
|
|
476
|
+
const decision = await resolveSingleModel(ctx, home);
|
|
477
|
+
if (decision === "defer") {
|
|
478
|
+
stopBeforePair = true;
|
|
479
|
+
} else if (decision.status === "restart") {
|
|
480
|
+
pauseForRestart();
|
|
481
|
+
return;
|
|
482
|
+
} else if (decision.status !== "ready") {
|
|
483
|
+
return;
|
|
484
|
+
} else {
|
|
485
|
+
liveModels = decision.models;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
369
489
|
const catalog = buildModelCatalog(liveModels, hasExternalDevin(pi));
|
|
370
490
|
const fallbackCatalog = buildFallbackCatalog(catalog);
|
|
491
|
+
const availability = revalidateSavedRoute(state, liveModels, hasExternalDevin(pi));
|
|
371
492
|
const selected: Partial<Record<AutoStage, ModelCandidate>> = {};
|
|
372
|
-
|
|
373
|
-
|
|
493
|
+
const stages = requiredStagesForWorkType(workType);
|
|
494
|
+
const oppositeStage: Record<AutoStage, AutoStage> = { A: "C", C: "A", B: "D", D: "B" };
|
|
495
|
+
for (const stage of stages) {
|
|
496
|
+
if (stopBeforePair && selected[oppositeStage[stage]]) {
|
|
497
|
+
ctx.ui.notify(
|
|
498
|
+
`Stage ${stage} needs a different model from Stage ${oppositeStage[stage]}. Stopping before that paired stage; nothing was saved as a complete route.`,
|
|
499
|
+
"warning",
|
|
500
|
+
);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const savedChoice = availability.selections[stage];
|
|
504
|
+
if (savedChoice?.status === "missing") {
|
|
505
|
+
ctx.ui.notify(
|
|
506
|
+
`Saved Stage ${stage} selector ${savedChoice.selection.selector} is missing from the live catalog. Choose a replacement.`,
|
|
507
|
+
"warning",
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
const choice = await chooseModel(
|
|
511
|
+
ctx,
|
|
512
|
+
stage,
|
|
513
|
+
catalog[stage],
|
|
514
|
+
selected,
|
|
515
|
+
savedChoice?.status === "present" ? savedChoice.selection : undefined,
|
|
516
|
+
);
|
|
374
517
|
if (!choice) return;
|
|
375
518
|
selected[stage] = choice;
|
|
376
519
|
}
|
|
377
520
|
|
|
378
|
-
const
|
|
521
|
+
const savedHigh = availability.fallbacks.high;
|
|
522
|
+
if (savedHigh?.status === "missing") {
|
|
523
|
+
ctx.ui.notify(
|
|
524
|
+
`Saved high backup ${savedHigh.selection.selector} is missing from the live catalog. Choose a replacement.`,
|
|
525
|
+
"warning",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const high = await chooseFallbackModel(
|
|
529
|
+
ctx,
|
|
530
|
+
"high",
|
|
531
|
+
fallbackCatalog.high,
|
|
532
|
+
undefined,
|
|
533
|
+
savedHigh?.status === "present" ? savedHigh.selection : undefined,
|
|
534
|
+
);
|
|
379
535
|
if (!high) return;
|
|
380
|
-
const
|
|
536
|
+
const savedLow = availability.fallbacks.low;
|
|
537
|
+
if (savedLow?.status === "missing") {
|
|
538
|
+
ctx.ui.notify(
|
|
539
|
+
`Saved low backup ${savedLow.selection.selector} is missing from the live catalog. Choose a replacement.`,
|
|
540
|
+
"warning",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const low = await chooseFallbackModel(
|
|
544
|
+
ctx,
|
|
545
|
+
"low",
|
|
546
|
+
fallbackCatalog.low,
|
|
547
|
+
high,
|
|
548
|
+
savedLow?.status === "present" ? savedLow.selection : undefined,
|
|
549
|
+
);
|
|
381
550
|
if (!low) return;
|
|
382
|
-
const fallbacks = { high, low };
|
|
383
551
|
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
552
|
+
const committed = await commitIntakeRoute(state, {
|
|
553
|
+
workType,
|
|
554
|
+
boundary: boundary as WorkBoundary,
|
|
555
|
+
skillMode,
|
|
556
|
+
selections: selected,
|
|
557
|
+
fallbacks: { high, low },
|
|
558
|
+
activateStage: stages[0]!,
|
|
559
|
+
}, {
|
|
560
|
+
runtimeName: "OMP",
|
|
561
|
+
models: liveModels,
|
|
562
|
+
hasExternalDevin: hasExternalDevin(pi),
|
|
563
|
+
activate: (model) => pi.setModel(model),
|
|
564
|
+
persist: (nextState) => persistRoute(nextState, ctx),
|
|
565
|
+
});
|
|
566
|
+
if (committed.error) {
|
|
567
|
+
ctx.ui.notify(committed.error, "error");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
state = committed.state;
|
|
571
|
+
pi.sendUserMessage(intakeMessage(request, boundary, workTypeLabel, skillMode, selected, { high, low }));
|
|
388
572
|
},
|
|
389
573
|
});
|
|
390
574
|
|
|
@@ -407,7 +591,7 @@ export default function modelRouter(pi: any) {
|
|
|
407
591
|
models,
|
|
408
592
|
hasExternalDevin: hasExternalDevin(pi),
|
|
409
593
|
activate: (model) => pi.setModel(model),
|
|
410
|
-
persist: (nextState) =>
|
|
594
|
+
persist: (nextState) => persistRoute(nextState, ctx),
|
|
411
595
|
});
|
|
412
596
|
state = execution.state;
|
|
413
597
|
return execution.response;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
probeModelIds,
|
|
6
|
+
validateCustomProvider,
|
|
7
|
+
writeCustomProvider,
|
|
8
|
+
type CustomProviderInput,
|
|
9
|
+
} from "../../../core/provider-config";
|
|
10
|
+
import {
|
|
11
|
+
numberedOptionLabel,
|
|
12
|
+
resolveSelectAnswer,
|
|
13
|
+
type ModelLike,
|
|
14
|
+
} from "../../../core/model-routing";
|
|
15
|
+
import { agentDirectory } from "../../../core/user-route";
|
|
16
|
+
|
|
17
|
+
const SETUP_ITEMS = [
|
|
18
|
+
"Sign in to a provider",
|
|
19
|
+
"Add a custom OpenAI-compatible provider",
|
|
20
|
+
"Cancel",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
const DISTINCT_ITEMS = [
|
|
24
|
+
"Add another model",
|
|
25
|
+
"Continue and stop before the paired stage",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type CatalogSetup =
|
|
29
|
+
| { status: "ready"; models: ModelLike[] }
|
|
30
|
+
| { status: "cancelled" }
|
|
31
|
+
| { status: "restart" };
|
|
32
|
+
|
|
33
|
+
async function askText(ctx: any, title: string, placeholder: string, masked = false): Promise<string | undefined> {
|
|
34
|
+
const value = await ctx.ui.input(title, placeholder, masked ? { masked: true } : undefined);
|
|
35
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
36
|
+
if (!trimmed) {
|
|
37
|
+
ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return trimmed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function askItems<T extends string>(
|
|
44
|
+
ctx: any,
|
|
45
|
+
title: string,
|
|
46
|
+
items: readonly T[],
|
|
47
|
+
): Promise<T | undefined> {
|
|
48
|
+
const answer = await ctx.ui.select(
|
|
49
|
+
title,
|
|
50
|
+
items.map((item, index) => numberedOptionLabel(index, item)),
|
|
51
|
+
{ selectionMarker: "radio" },
|
|
52
|
+
);
|
|
53
|
+
const resolved = resolveSelectAnswer(answer, items, (item) => item);
|
|
54
|
+
switch (resolved.status) {
|
|
55
|
+
case "matched":
|
|
56
|
+
return resolved.item;
|
|
57
|
+
case "cancelled":
|
|
58
|
+
ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
|
|
59
|
+
return undefined;
|
|
60
|
+
case "conflict":
|
|
61
|
+
ctx.ui.notify("That answer matches more than one option; no model was saved.", "error");
|
|
62
|
+
return undefined;
|
|
63
|
+
case "unrecognized":
|
|
64
|
+
ctx.ui.notify(`Unrecognized selection ${JSON.stringify(resolved.received)}; no model was saved.`, "error");
|
|
65
|
+
return undefined;
|
|
66
|
+
default: {
|
|
67
|
+
const unreachable: never = resolved;
|
|
68
|
+
return unreachable;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function envAlreadySet(home: string | undefined, name: string): boolean {
|
|
74
|
+
if (process.env[name]) return true;
|
|
75
|
+
const envPath = join(agentDirectory("OMP", home), ".env");
|
|
76
|
+
if (!existsSync(envPath)) return false;
|
|
77
|
+
return new RegExp(`^${name}=`, "m").test(readFileSync(envPath, "utf8"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function chooseProbedIds(ctx: any, typed: string[], probed: string[]): Promise<string[] | undefined> {
|
|
81
|
+
const items = ["Keep the model ids I typed", "Use every returned id", ...probed];
|
|
82
|
+
const choice = await askItems(ctx, "Models to register", items);
|
|
83
|
+
if (!choice) return undefined;
|
|
84
|
+
if (choice === "Keep the model ids I typed") return typed;
|
|
85
|
+
if (choice === "Use every returned id") return probed;
|
|
86
|
+
return [choice];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function addCustomProvider(ctx: any, home: string | undefined): Promise<CatalogSetup> {
|
|
90
|
+
const providerId = await askText(ctx, "Custom provider", "Provider id (lowercase)");
|
|
91
|
+
if (!providerId) return { status: "cancelled" };
|
|
92
|
+
const displayName = await askText(ctx, "Custom provider", "Display name");
|
|
93
|
+
if (!displayName) return { status: "cancelled" };
|
|
94
|
+
const baseUrl = await askText(ctx, "Custom provider", "Base URL");
|
|
95
|
+
if (!baseUrl) return { status: "cancelled" };
|
|
96
|
+
let allowLocalHttp = false;
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(baseUrl);
|
|
99
|
+
if (url.protocol === "http:" && url.hostname === "127.0.0.1") {
|
|
100
|
+
const confirm = await askItems(ctx, "Local endpoint", ["Use this local http endpoint", "Cancel"]);
|
|
101
|
+
if (confirm !== "Use this local http endpoint") return { status: "cancelled" };
|
|
102
|
+
allowLocalHttp = true;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
ctx.ui.notify("Base URL must be an https URL.", "error");
|
|
106
|
+
return { status: "cancelled" };
|
|
107
|
+
}
|
|
108
|
+
const envVar = await askText(ctx, "Custom provider", "Env var name");
|
|
109
|
+
if (!envVar) return { status: "cancelled" };
|
|
110
|
+
const modelText = await askText(ctx, "Custom provider", "Model ids, comma-separated");
|
|
111
|
+
if (!modelText) return { status: "cancelled" };
|
|
112
|
+
let modelIds = modelText.split(",").map((id) => id.trim()).filter(Boolean);
|
|
113
|
+
const secret = envAlreadySet(home, envVar) ? undefined : await askText(ctx, "Custom provider", `${envVar} value`, true);
|
|
114
|
+
if (!envAlreadySet(home, envVar) && !secret) return { status: "cancelled" };
|
|
115
|
+
|
|
116
|
+
const draft: CustomProviderInput = {
|
|
117
|
+
providerId,
|
|
118
|
+
displayName,
|
|
119
|
+
baseUrl,
|
|
120
|
+
envVar,
|
|
121
|
+
modelIds,
|
|
122
|
+
allowLocalHttp,
|
|
123
|
+
};
|
|
124
|
+
const problem = validateCustomProvider(draft);
|
|
125
|
+
if (problem) {
|
|
126
|
+
ctx.ui.notify(problem, "error");
|
|
127
|
+
return { status: "cancelled" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const probeKey = secret ?? process.env[envVar];
|
|
131
|
+
if (probeKey) {
|
|
132
|
+
const probed = await probeModelIds(baseUrl, probeKey);
|
|
133
|
+
if (probed) {
|
|
134
|
+
const chosen = await chooseProbedIds(ctx, modelIds, probed);
|
|
135
|
+
if (!chosen) return { status: "cancelled" };
|
|
136
|
+
modelIds = chosen;
|
|
137
|
+
draft.modelIds = chosen;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const directory = agentDirectory("OMP", home);
|
|
143
|
+
writeCustomProvider({
|
|
144
|
+
modelsPath: join(directory, "models.yml"),
|
|
145
|
+
envPath: join(directory, ".env"),
|
|
146
|
+
}, draft, secret);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
ctx.ui.notify(error instanceof Error ? error.message : "Could not write the provider.", "error");
|
|
149
|
+
return { status: "cancelled" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof ctx.models?.reload === "function") {
|
|
153
|
+
await ctx.models.reload();
|
|
154
|
+
}
|
|
155
|
+
const models = (ctx.models.list() ?? []) as ModelLike[];
|
|
156
|
+
if (models.length === 0) return { status: "restart" };
|
|
157
|
+
return { status: "ready", models };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function configureEmptyCatalog(ctx: any, home: string | undefined): Promise<CatalogSetup> {
|
|
161
|
+
const choice = await askItems(ctx, "Model setup", SETUP_ITEMS);
|
|
162
|
+
if (!choice || choice === "Cancel") return { status: "cancelled" };
|
|
163
|
+
if (choice === "Sign in to a provider") {
|
|
164
|
+
if (typeof ctx.openLogin === "function") await ctx.openLogin();
|
|
165
|
+
else ctx.ui.notify("Sign in with /login, verify with `omp models`, then run /pi-marg again.", "warning");
|
|
166
|
+
const models = (ctx.models.list() ?? []) as ModelLike[];
|
|
167
|
+
return models.length > 0 ? { status: "ready", models } : { status: "cancelled" };
|
|
168
|
+
}
|
|
169
|
+
return addCustomProvider(ctx, home);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function resolveSingleModel(ctx: any, home: string | undefined): Promise<CatalogSetup | "defer"> {
|
|
173
|
+
const choice = await askItems(ctx, "Distinct models", DISTINCT_ITEMS);
|
|
174
|
+
if (!choice) return { status: "cancelled" };
|
|
175
|
+
if (choice === "Continue and stop before the paired stage") return "defer";
|
|
176
|
+
return addCustomProvider(ctx, home);
|
|
177
|
+
}
|
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
hasConfiguredFallback,
|
|
13
13
|
modelFailureKey,
|
|
14
14
|
modelToolUsageLimitFailure,
|
|
15
|
-
restoreModelRouteState,
|
|
16
15
|
type ModelLike,
|
|
17
16
|
type ModelRouteParams,
|
|
18
17
|
} from "../../../core/model-routing";
|
|
18
|
+
import { loadRuntimeRoute, rememberRuntimeRoute } from "../../../core/user-route";
|
|
19
19
|
|
|
20
20
|
function toolNames(pi: any): string[] {
|
|
21
21
|
return (pi.getAllTools?.() ?? [])
|
|
@@ -38,12 +38,21 @@ export default function modelRouter(pi: any) {
|
|
|
38
38
|
let sessionGeneration = 0;
|
|
39
39
|
let pendingModelFailure: { selector: string; key: string } | undefined;
|
|
40
40
|
let pendingToolFailure: { selector: string; key: string } | undefined;
|
|
41
|
+
const routeHome = (ctx: any) => typeof ctx?.agentHome === "string" ? ctx.agentHome : undefined;
|
|
42
|
+
const persistRoute = (nextState: Parameters<typeof rememberRuntimeRoute>[1], ctx?: any) => {
|
|
43
|
+
pi.appendEntry(MODEL_STATE_TYPE, nextState);
|
|
44
|
+
try {
|
|
45
|
+
rememberRuntimeRoute("Pi", nextState, routeHome(ctx));
|
|
46
|
+
} catch {
|
|
47
|
+
ctx?.ui?.notify?.("PiMarg saved the route in this session, but could not update the user route file.", "warning");
|
|
48
|
+
}
|
|
49
|
+
};
|
|
41
50
|
const restore = (_event: unknown, ctx: any) => {
|
|
42
51
|
sessionGeneration += 1;
|
|
43
52
|
recoveryToken = undefined;
|
|
44
53
|
pendingModelFailure = undefined;
|
|
45
54
|
pendingToolFailure = undefined;
|
|
46
|
-
state =
|
|
55
|
+
state = loadRuntimeRoute("Pi", ctx.sessionManager.getBranch(), routeHome(ctx));
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
pi.on("session_start", restore);
|
|
@@ -63,7 +72,7 @@ export default function modelRouter(pi: any) {
|
|
|
63
72
|
activate: (model) => pi.setModel(model),
|
|
64
73
|
persist: (nextState) => {
|
|
65
74
|
if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
|
|
66
|
-
|
|
75
|
+
persistRoute(nextState, ctx);
|
|
67
76
|
},
|
|
68
77
|
}, failure.key);
|
|
69
78
|
if (sessionGeneration !== generation) {
|
|
@@ -162,7 +171,7 @@ export default function modelRouter(pi: any) {
|
|
|
162
171
|
models,
|
|
163
172
|
hasExternalDevin: hasExternalDevin(pi),
|
|
164
173
|
activate: (model) => pi.setModel(model),
|
|
165
|
-
persist: (nextState) =>
|
|
174
|
+
persist: (nextState) => persistRoute(nextState, ctx),
|
|
166
175
|
});
|
|
167
176
|
state = execution.state;
|
|
168
177
|
return execution.response;
|
|
@@ -5,8 +5,8 @@ argument-hint: [status | choose]
|
|
|
5
5
|
|
|
6
6
|
Load `auto-mode-router` and read its model-routing reference.
|
|
7
7
|
|
|
8
|
-
- With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover.
|
|
9
|
-
- With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
|
|
8
|
+
- With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover. Show a saved selector as `missing` when status says it is no longer in the live catalog. Do not remap it to a similarly named model.
|
|
9
|
+
- With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Show the saved selectors first and pass each one as the initially selected choice before asking for a replacement. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
|
|
10
10
|
- Do not type or persist a model ID that the live catalog did not return.
|
|
11
11
|
- Treat any model returned by Pi's registry as a normal in-session route. Treat Devin or another external coding-agent session as a capability-gated external route; creating one requires `post-web` approval.
|
|
12
12
|
- C/D are the authoritative independent review selectors. Any detected advisor or watcher is supplemental and must not weaken the A≠C or B≠D checks.
|
package/commands/auto-models.md
CHANGED
|
@@ -5,8 +5,8 @@ argument-hint: [status | choose]
|
|
|
5
5
|
|
|
6
6
|
Load `auto-mode-router` and read its model-routing reference.
|
|
7
7
|
|
|
8
|
-
- With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover.
|
|
9
|
-
- With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
|
|
8
|
+
- With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover. Show a saved selector as `missing` when status says it is no longer in the live catalog. Do not remap it to a similarly named model.
|
|
9
|
+
- With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Show the saved selectors first and pass each one as the initially selected choice before asking for a replacement. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
|
|
10
10
|
- Do not type or persist a model ID that the live catalog did not return.
|
|
11
11
|
- Distinguish native `devin/swe-*` OMP models from the optional external `devin/mcp-session` route. Only creating the external session requires `post-web` approval.
|
|
12
12
|
- C/D are the authoritative independent review selectors. A configured OMP advisor is supplemental and must not weaken the A≠C or B≠D checks.
|
package/core/model-routing.ts
CHANGED
|
@@ -36,9 +36,13 @@ export interface ModelSelection extends ModelCandidate {
|
|
|
36
36
|
selectedAt: string;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export type WorkBoundary = "Job/client" | "Personal";
|
|
40
|
+
|
|
39
41
|
export interface ModelRouteState {
|
|
40
42
|
version: 2;
|
|
41
43
|
workType?: number;
|
|
44
|
+
boundary?: WorkBoundary;
|
|
45
|
+
skillMode?: string;
|
|
42
46
|
selections: Partial<Record<AutoStage, ModelSelection>>;
|
|
43
47
|
fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
|
|
44
48
|
activeStage?: AutoStage;
|
|
@@ -370,6 +374,38 @@ export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]
|
|
|
370
374
|
} satisfies Record<FallbackLevel, ModelCandidate[]>;
|
|
371
375
|
}
|
|
372
376
|
|
|
377
|
+
export interface SavedChoiceAvailability {
|
|
378
|
+
status: "present" | "missing";
|
|
379
|
+
selection: ModelSelection;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function revalidateSavedRoute(
|
|
383
|
+
state: ModelRouteState,
|
|
384
|
+
models: readonly ModelLike[],
|
|
385
|
+
hasExternalDevin: boolean,
|
|
386
|
+
): {
|
|
387
|
+
selections: Partial<Record<AutoStage, SavedChoiceAvailability>>;
|
|
388
|
+
fallbacks: Partial<Record<FallbackLevel, SavedChoiceAvailability>>;
|
|
389
|
+
} {
|
|
390
|
+
const catalog = buildModelCatalog([...models], hasExternalDevin);
|
|
391
|
+
const fallbackCatalog = buildFallbackCatalog(catalog);
|
|
392
|
+
const selections: Partial<Record<AutoStage, SavedChoiceAvailability>> = {};
|
|
393
|
+
for (const stage of STAGES) {
|
|
394
|
+
const selection = state.selections[stage];
|
|
395
|
+
if (!selection) continue;
|
|
396
|
+
const live = catalog[stage].some((item) => item.selector === selection.selector);
|
|
397
|
+
selections[stage] = { status: live ? "present" : "missing", selection };
|
|
398
|
+
}
|
|
399
|
+
const fallbacks: Partial<Record<FallbackLevel, SavedChoiceAvailability>> = {};
|
|
400
|
+
for (const level of FALLBACK_LEVELS) {
|
|
401
|
+
const selection = state.fallbacks[level];
|
|
402
|
+
if (!selection) continue;
|
|
403
|
+
const live = fallbackCatalog[level].some((item) => item.selector === selection.selector);
|
|
404
|
+
fallbacks[level] = { status: live ? "present" : "missing", selection };
|
|
405
|
+
}
|
|
406
|
+
return { selections, fallbacks };
|
|
407
|
+
}
|
|
408
|
+
|
|
373
409
|
export function numberedOptionLabel(index: number, text: string): string {
|
|
374
410
|
return `${index + 1}. ${text}`;
|
|
375
411
|
}
|
|
@@ -383,6 +419,42 @@ export function matchNumberedOption<T>(
|
|
|
383
419
|
return items.find((item, index) => numberedOptionLabel(index, labelOf(item)) === answer);
|
|
384
420
|
}
|
|
385
421
|
|
|
422
|
+
export type SelectAnswerResolution<T> =
|
|
423
|
+
| { status: "cancelled" }
|
|
424
|
+
| { status: "matched"; item: T }
|
|
425
|
+
| { status: "conflict" }
|
|
426
|
+
| { status: "unrecognized"; received: string };
|
|
427
|
+
|
|
428
|
+
const DISAMBIGUATION_SUFFIX = /^(?: \(\d+\)| · .+)$/;
|
|
429
|
+
|
|
430
|
+
export function resolveSelectAnswer<T>(
|
|
431
|
+
answer: string | undefined,
|
|
432
|
+
items: readonly T[],
|
|
433
|
+
labelOf: (item: T) => string,
|
|
434
|
+
): SelectAnswerResolution<T> {
|
|
435
|
+
if (answer === undefined) return { status: "cancelled" };
|
|
436
|
+
const received = answer.trim();
|
|
437
|
+
if (!received) return { status: "cancelled" };
|
|
438
|
+
|
|
439
|
+
const numbered = items.filter((item, index) => numberedOptionLabel(index, labelOf(item)) === received);
|
|
440
|
+
if (numbered.length > 1) return { status: "conflict" };
|
|
441
|
+
if (numbered.length === 1) return { status: "matched", item: numbered[0]! };
|
|
442
|
+
|
|
443
|
+
const exact = items.filter((item) => labelOf(item) === received);
|
|
444
|
+
if (exact.length > 1) return { status: "conflict" };
|
|
445
|
+
if (exact.length === 1) return { status: "matched", item: exact[0]! };
|
|
446
|
+
|
|
447
|
+
const suffixed = items.filter((item) => {
|
|
448
|
+
const label = labelOf(item);
|
|
449
|
+
if (!received.startsWith(label) || received.length === label.length) return false;
|
|
450
|
+
return DISAMBIGUATION_SUFFIX.test(received.slice(label.length));
|
|
451
|
+
});
|
|
452
|
+
if (suffixed.length > 1) return { status: "conflict" };
|
|
453
|
+
if (suffixed.length === 1) return { status: "matched", item: suffixed[0]! };
|
|
454
|
+
|
|
455
|
+
return { status: "unrecognized", received };
|
|
456
|
+
}
|
|
457
|
+
|
|
386
458
|
export function uniqueProviders(candidates: readonly ModelCandidate[]): string[] {
|
|
387
459
|
return [...new Set(candidates.map((candidate) => candidate.provider))].sort((left, right) => left.localeCompare(right));
|
|
388
460
|
}
|
|
@@ -621,6 +693,59 @@ export async function executeAutomaticFailover(
|
|
|
621
693
|
};
|
|
622
694
|
}
|
|
623
695
|
|
|
696
|
+
export interface IntakeRouteDraft {
|
|
697
|
+
workType: number;
|
|
698
|
+
boundary?: WorkBoundary;
|
|
699
|
+
skillMode?: string;
|
|
700
|
+
selections: Partial<Record<AutoStage, ModelCandidate>>;
|
|
701
|
+
fallbacks: Record<FallbackLevel, ModelCandidate>;
|
|
702
|
+
activateStage: AutoStage;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export async function commitIntakeRoute(
|
|
706
|
+
currentState: ModelRouteState,
|
|
707
|
+
intake: IntakeRouteDraft,
|
|
708
|
+
port: ModelRoutePort,
|
|
709
|
+
): Promise<{ state: ModelRouteState; error?: string }> {
|
|
710
|
+
const selected = intake.selections[intake.activateStage];
|
|
711
|
+
if (!selected) {
|
|
712
|
+
return { state: currentState, error: `Stage ${intake.activateStage} has no model to activate.` };
|
|
713
|
+
}
|
|
714
|
+
if (selected.access === "runtime-model") {
|
|
715
|
+
const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
|
|
716
|
+
const failure = `Could not activate ${selected.selector}; verify ${selected.provider} authentication.`;
|
|
717
|
+
if (!model) return { state: currentState, error: failure };
|
|
718
|
+
try {
|
|
719
|
+
const activated = await port.activate(model);
|
|
720
|
+
if (activated === false) return { state: currentState, error: failure };
|
|
721
|
+
} catch {
|
|
722
|
+
return { state: currentState, error: failure };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const selectedAt = port.now?.() ?? new Date().toISOString();
|
|
727
|
+
const selections = Object.fromEntries(
|
|
728
|
+
Object.entries(intake.selections).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
|
|
729
|
+
) as ModelRouteState["selections"];
|
|
730
|
+
const fallbacks = Object.fromEntries(
|
|
731
|
+
Object.entries(intake.fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
|
|
732
|
+
) as ModelRouteState["fallbacks"];
|
|
733
|
+
const nextState: ModelRouteState = {
|
|
734
|
+
version: 2,
|
|
735
|
+
workType: intake.workType,
|
|
736
|
+
boundary: intake.boundary,
|
|
737
|
+
skillMode: intake.skillMode,
|
|
738
|
+
selections,
|
|
739
|
+
fallbacks,
|
|
740
|
+
activeStage: intake.activateStage,
|
|
741
|
+
activeFallback: undefined,
|
|
742
|
+
exhaustedSelectors: [],
|
|
743
|
+
handledFailureKeys: [],
|
|
744
|
+
};
|
|
745
|
+
port.persist(nextState);
|
|
746
|
+
return { state: nextState };
|
|
747
|
+
}
|
|
748
|
+
|
|
624
749
|
export async function executeModelRoute(
|
|
625
750
|
currentState: ModelRouteState,
|
|
626
751
|
params: ModelRouteParams,
|
|
@@ -670,7 +795,14 @@ export async function executeModelRoute(
|
|
|
670
795
|
}
|
|
671
796
|
|
|
672
797
|
if (params.action === "status") {
|
|
673
|
-
|
|
798
|
+
const availability = revalidateSavedRoute(currentState, port.models, port.hasExternalDevin);
|
|
799
|
+
return {
|
|
800
|
+
state: currentState,
|
|
801
|
+
response: toolResult(
|
|
802
|
+
JSON.stringify({ ...currentState, availability }, null, 2),
|
|
803
|
+
{ state: currentState, availability },
|
|
804
|
+
),
|
|
805
|
+
};
|
|
674
806
|
}
|
|
675
807
|
|
|
676
808
|
if (params.action === "select_fallback") {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CustomProviderInput {
|
|
5
|
+
providerId: string;
|
|
6
|
+
displayName: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
envVar: string;
|
|
9
|
+
modelIds: string[];
|
|
10
|
+
allowLocalHttp: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProviderFiles {
|
|
14
|
+
modelsPath: string;
|
|
15
|
+
envPath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PROVIDER_ID = /^[a-z0-9_-]+$/;
|
|
19
|
+
const ENV_VAR = /^[A-Z][A-Z0-9_]*$/;
|
|
20
|
+
const MODEL_ID = /^[A-Za-z0-9._:-]+$/;
|
|
21
|
+
|
|
22
|
+
export function validateCustomProvider(input: CustomProviderInput): string | undefined {
|
|
23
|
+
if (!PROVIDER_ID.test(input.providerId)) {
|
|
24
|
+
return "Provider id must be lowercase letters, numbers, underscores, or hyphens.";
|
|
25
|
+
}
|
|
26
|
+
if (!input.displayName.trim()) return "Display name is required.";
|
|
27
|
+
let url: URL;
|
|
28
|
+
try {
|
|
29
|
+
url = new URL(input.baseUrl);
|
|
30
|
+
} catch {
|
|
31
|
+
return "Base URL must be an https URL.";
|
|
32
|
+
}
|
|
33
|
+
const local = url.protocol === "http:" && url.hostname === "127.0.0.1";
|
|
34
|
+
if (url.protocol !== "https:" && !(local && input.allowLocalHttp)) {
|
|
35
|
+
return "Base URL must use https unless you confirm a local http://127.0.0.1 endpoint.";
|
|
36
|
+
}
|
|
37
|
+
if (!ENV_VAR.test(input.envVar)) {
|
|
38
|
+
return "Env var name must match the runtime convention, such as EXAMPLE_GATEWAY_API_KEY.";
|
|
39
|
+
}
|
|
40
|
+
if (input.modelIds.length === 0) return "At least one model id is required.";
|
|
41
|
+
const unique = new Set(input.modelIds);
|
|
42
|
+
if (unique.size !== input.modelIds.length) return "Model ids must be unique.";
|
|
43
|
+
if (input.modelIds.some((id) => !MODEL_ID.test(id))) return "Model ids must be non-empty and contain no spaces.";
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function yamlString(value: string): string {
|
|
48
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderProviderBlock(input: CustomProviderInput): string {
|
|
53
|
+
const models = input.modelIds.map((id, index) => {
|
|
54
|
+
const name = index === 0 && input.displayName.trim() ? input.displayName.trim() : id;
|
|
55
|
+
return ` - id: ${yamlString(id)}\n name: ${yamlString(name)}`;
|
|
56
|
+
}).join("\n");
|
|
57
|
+
return [
|
|
58
|
+
` ${input.providerId}:`,
|
|
59
|
+
` baseUrl: ${yamlString(input.baseUrl)}`,
|
|
60
|
+
" api: openai-completions",
|
|
61
|
+
` apiKey: ${input.envVar}`,
|
|
62
|
+
" authHeader: true",
|
|
63
|
+
" models:",
|
|
64
|
+
models,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function insertProvider(existing: string, providerId: string, block: string): string {
|
|
69
|
+
if (new RegExp(`^ ${providerId}:\\s*$`, "m").test(existing)) {
|
|
70
|
+
throw new Error(`Provider ${providerId} already exists in models.yml.`);
|
|
71
|
+
}
|
|
72
|
+
const trimmedBlock = block.replace(/\s+$/, "");
|
|
73
|
+
if (!existing.trim()) return `providers:\n${trimmedBlock}\n`;
|
|
74
|
+
const lines = existing.split("\n");
|
|
75
|
+
const providersIndex = lines.findIndex((line) => /^providers:\s*$/.test(line));
|
|
76
|
+
if (providersIndex === -1) {
|
|
77
|
+
const suffix = existing.endsWith("\n") ? "" : "\n";
|
|
78
|
+
return `${existing}${suffix}providers:\n${trimmedBlock}\n`;
|
|
79
|
+
}
|
|
80
|
+
lines.splice(providersIndex + 1, 0, ...trimmedBlock.split("\n"));
|
|
81
|
+
return `${lines.join("\n").replace(/\n*$/, "")}\n`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function upsertEnvAssignment(existing: string, name: string, value: string): string {
|
|
85
|
+
if (new RegExp(`^${name}=`, "m").test(existing)) return existing;
|
|
86
|
+
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
87
|
+
return `${existing}${suffix}${name}=${value}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function atomicWrite(path: string, contents: string): void {
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
92
|
+
const backup = `${path}.bak`;
|
|
93
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
94
|
+
const hadOriginal = existsSync(path);
|
|
95
|
+
if (hadOriginal) copyFileSync(path, backup);
|
|
96
|
+
try {
|
|
97
|
+
writeFileSync(temporary, contents, { mode: 0o600 });
|
|
98
|
+
renameSync(temporary, path);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (hadOriginal && existsSync(backup)) copyFileSync(backup, path);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function writeCustomProvider(files: ProviderFiles, input: CustomProviderInput, secret?: string): void {
|
|
106
|
+
const problem = validateCustomProvider(input);
|
|
107
|
+
if (problem) throw new Error(problem);
|
|
108
|
+
if (secret && (secret.includes("\n") || secret.includes("\r"))) {
|
|
109
|
+
throw new Error("The API key must be a single line.");
|
|
110
|
+
}
|
|
111
|
+
const existing = existsSync(files.modelsPath) ? readFileSync(files.modelsPath, "utf8") : "";
|
|
112
|
+
const next = insertProvider(existing, input.providerId, renderProviderBlock(input));
|
|
113
|
+
if (secret && next.includes(secret)) {
|
|
114
|
+
throw new Error("Refusing to write the API key into models.yml.");
|
|
115
|
+
}
|
|
116
|
+
atomicWrite(files.modelsPath, next);
|
|
117
|
+
if (!secret) return;
|
|
118
|
+
const currentEnv = existsSync(files.envPath) ? readFileSync(files.envPath, "utf8") : "";
|
|
119
|
+
atomicWrite(files.envPath, upsertEnvAssignment(currentEnv, input.envVar, secret));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function probeModelIds(
|
|
123
|
+
baseUrl: string,
|
|
124
|
+
apiKey: string,
|
|
125
|
+
fetchImpl: typeof fetch = fetch,
|
|
126
|
+
): Promise<string[] | undefined> {
|
|
127
|
+
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetchImpl(url, {
|
|
130
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
131
|
+
signal: AbortSignal.timeout(5000),
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) return undefined;
|
|
134
|
+
const body = await response.json() as { data?: Array<{ id?: unknown }> };
|
|
135
|
+
const ids = (body.data ?? [])
|
|
136
|
+
.map((item) => item.id)
|
|
137
|
+
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
138
|
+
return ids.length > 0 ? ids : undefined;
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
emptyModelRouteState,
|
|
7
|
+
isFallbackLevel,
|
|
8
|
+
isStage,
|
|
9
|
+
restoreModelRouteState,
|
|
10
|
+
type ModelRouteState,
|
|
11
|
+
type RuntimeName,
|
|
12
|
+
type WorkBoundary,
|
|
13
|
+
} from "./model-routing";
|
|
14
|
+
|
|
15
|
+
const ROUTE_FILE = "pi-marg-route.json";
|
|
16
|
+
|
|
17
|
+
export function agentDirectory(runtime: RuntimeName, home = process.env.PI_MARG_AGENT_HOME || homedir()): string {
|
|
18
|
+
return join(home, runtime === "OMP" ? ".omp" : ".pi", "agent");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function userRoutePath(runtime: RuntimeName, home?: string): string {
|
|
22
|
+
return join(home ? agentDirectory(runtime, home) : agentDirectory(runtime), ROUTE_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isBoundary(value: unknown): value is WorkBoundary {
|
|
30
|
+
return value === "Job/client" || value === "Personal";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stringList(value: unknown): string[] | undefined {
|
|
34
|
+
if (!Array.isArray(value)) return undefined;
|
|
35
|
+
return value.filter((item): item is string => typeof item === "string");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseUserRoute(raw: string): ModelRouteState | undefined {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(raw) as unknown;
|
|
41
|
+
if (!isRecord(data) || data.version !== 2) return undefined;
|
|
42
|
+
const activeStage = isStage(data.activeStage) ? data.activeStage : undefined;
|
|
43
|
+
const activeFallback = isFallbackLevel(data.activeFallback) ? data.activeFallback : undefined;
|
|
44
|
+
return {
|
|
45
|
+
version: 2,
|
|
46
|
+
workType: typeof data.workType === "number" ? data.workType : undefined,
|
|
47
|
+
boundary: isBoundary(data.boundary) ? data.boundary : undefined,
|
|
48
|
+
skillMode: typeof data.skillMode === "string" ? data.skillMode : undefined,
|
|
49
|
+
selections: isRecord(data.selections) ? data.selections as ModelRouteState["selections"] : {},
|
|
50
|
+
fallbacks: isRecord(data.fallbacks) ? data.fallbacks as ModelRouteState["fallbacks"] : {},
|
|
51
|
+
...(activeStage ? { activeStage } : {}),
|
|
52
|
+
...(activeFallback ? { activeFallback } : {}),
|
|
53
|
+
...(stringList(data.exhaustedSelectors) ? { exhaustedSelectors: stringList(data.exhaustedSelectors) } : {}),
|
|
54
|
+
...(stringList(data.handledFailureKeys) ? { handledFailureKeys: stringList(data.handledFailureKeys) } : {}),
|
|
55
|
+
...(isRecord(data.lastFailover) ? { lastFailover: data.lastFailover as ModelRouteState["lastFailover"] } : {}),
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readUserRoute(path: string): ModelRouteState | undefined {
|
|
63
|
+
try {
|
|
64
|
+
return parseUserRoute(readFileSync(path, "utf8"));
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeUserRoute(path: string, state: ModelRouteState): void {
|
|
71
|
+
const payload = parseUserRoute(JSON.stringify({ ...state, version: 2 })) ?? emptyModelRouteState();
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
74
|
+
writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
75
|
+
renameSync(temporary, path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function routeHasChoices(state: ModelRouteState): boolean {
|
|
79
|
+
return Boolean(
|
|
80
|
+
state.workType
|
|
81
|
+
|| state.boundary
|
|
82
|
+
|| state.skillMode
|
|
83
|
+
|| Object.keys(state.selections).length > 0
|
|
84
|
+
|| Object.keys(state.fallbacks).length > 0,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function loadSavedRoute(entries: readonly unknown[], file: ModelRouteState | undefined): ModelRouteState {
|
|
89
|
+
const branch = restoreModelRouteState(entries);
|
|
90
|
+
if (routeHasChoices(branch)) return branch;
|
|
91
|
+
return file ?? branch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loadRuntimeRoute(runtime: RuntimeName, entries: readonly unknown[], home?: string): ModelRouteState {
|
|
95
|
+
return loadSavedRoute(entries, readUserRoute(userRoutePath(runtime, home)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function rememberRuntimeRoute(runtime: RuntimeName, state: ModelRouteState, home?: string): void {
|
|
99
|
+
writeUserRoute(userRoutePath(runtime, home), state);
|
|
100
|
+
}
|
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -71,4 +71,4 @@ if [[ "${runtime}" == "omp" || "${runtime}" == "both" ]]; then
|
|
|
71
71
|
omp plugin install --scope user pi-marg@pi-marg-marketplace
|
|
72
72
|
fi
|
|
73
73
|
|
|
74
|
-
echo "PiMarg v1.0.
|
|
74
|
+
echo "PiMarg v1.0.6 installed for ${runtime}. Start a fresh runtime session, then run /pi-marg."
|
package/scripts/validate.sh
CHANGED
|
@@ -45,7 +45,7 @@ bun -e '
|
|
|
45
45
|
const packageJson = await Bun.file("package.json").json();
|
|
46
46
|
const marketplace = await Bun.file(".omp-plugin/marketplace.json").json();
|
|
47
47
|
if (packageJson.name !== "@sangyaahub/pi-marg") throw new Error("Unexpected package name");
|
|
48
|
-
if (packageJson.version !== "1.0.
|
|
48
|
+
if (packageJson.version !== "1.0.6") throw new Error("package.json must be v1.0.6");
|
|
49
49
|
if (packageJson.license !== "MIT") throw new Error("package.json must declare MIT");
|
|
50
50
|
if (packageJson.author !== "Sangyaa") throw new Error("Unexpected package author");
|
|
51
51
|
if (!packageJson.keywords?.includes("pi-package")) throw new Error("Pi catalog keyword is missing");
|
|
@@ -125,6 +125,8 @@ ledger: <path>
|
|
|
125
125
|
|
|
126
126
|
Reuse this state for normal follow-ups. Start over only when the user explicitly resets it or introduces a materially different outcome.
|
|
127
127
|
|
|
128
|
+
The v2 model route persists `boundary` (`Job/client` or `Personal`) and `skillMode` with the A/B/C/D selectors and backups. Reopening `/pi-marg` highlights those saved values. The conversational `context:` and `mode:` lines above are the same facts in the ledger's shorthand; they are not a second configuration store.
|
|
129
|
+
|
|
128
130
|
## Availability check
|
|
129
131
|
|
|
130
132
|
Before declaring the task ready:
|