@geraldmaron/construct 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/construct +336 -17
- package/lib/artifact-loop-core.mjs +26 -5
- package/lib/artifact-manifest-overlay.mjs +273 -0
- package/lib/artifact-manifest.mjs +13 -10
- package/lib/cli-commands.mjs +44 -4
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- package/lib/doc-stamp.mjs +12 -0
- package/lib/doctor/index.mjs +2 -1
- package/lib/doctor/source-target-health.mjs +101 -0
- package/lib/doctor/watchers/source-targets.mjs +44 -0
- package/lib/document-ingest.mjs +25 -3
- package/lib/embed/demand-fetch.mjs +30 -36
- package/lib/embed/providers/directory.mjs +117 -0
- package/lib/embed/providers/registry.mjs +7 -0
- package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
- package/lib/extensions/manifests/directory.manifest.json +24 -1
- package/lib/extensions/manifests/github.manifest.json +10 -0
- package/lib/extensions/manifests/linear.manifest.json +1 -1
- package/lib/hooks/session-start.mjs +18 -11
- package/lib/init-unified.mjs +2 -7
- package/lib/knowledge/rag.mjs +52 -11
- package/lib/knowledge/search.mjs +69 -6
- package/lib/knowledge/synthesis.mjs +157 -0
- package/lib/mcp/server.mjs +2 -2
- package/lib/mcp/tool-definitions-workflow.mjs +35 -7
- package/lib/mcp/tools/artifact-author.mjs +126 -13
- package/lib/mcp/tools/orchestration-run.mjs +3 -0
- package/lib/mcp/tools/skills.mjs +17 -0
- package/lib/model-cheapest-provider.mjs +2 -1
- package/lib/model-policy.mjs +329 -0
- package/lib/model-router.mjs +10 -9
- package/lib/model-tiers.mjs +27 -0
- package/lib/models/catalog.mjs +5 -4
- package/lib/orchestration/classification.mjs +11 -0
- package/lib/orchestration/context-bindings.mjs +85 -0
- package/lib/orchestration/readiness.mjs +2 -1
- package/lib/orchestration/research-evidence-gate.mjs +104 -0
- package/lib/orchestration/runtime.mjs +35 -1
- package/lib/orchestration/worker.mjs +9 -0
- package/lib/providers/directory/index.mjs +19 -7
- package/lib/setup.mjs +2 -1
- package/lib/sources/content-roots.mjs +147 -0
- package/lib/sources/repo-cache.mjs +142 -0
- package/lib/template-registry.mjs +2 -0
- package/lib/test-corpus-inventory.mjs +1 -0
- package/lib/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -1
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- package/templates/docs/strategy-comparison.md +19 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/model-policy.mjs — user-facing model policy: presets, the effective-policy
|
|
3
|
+
* view, and per-specialist resolution traces, layered on the existing resolution
|
|
4
|
+
* chain (lib/model-router.mjs + lib/embedded-contract/model-resolve.mjs).
|
|
5
|
+
*
|
|
6
|
+
* Design constraint (construct-760c.7): the single edit surface for model
|
|
7
|
+
* assignment is specialists/org/models.json. config.schema is deliberately not a
|
|
8
|
+
* second surface. Presets are COMPUTED here from the cost/free selectors
|
|
9
|
+
* (model-cheapest-provider / model-free-selector / model-pricing) and then
|
|
10
|
+
* PERSISTED to models.json, which already sits below env pins in the chain — so
|
|
11
|
+
* CX_MODEL_<TIER> pins keep overriding and nothing new enters the chain.
|
|
12
|
+
*
|
|
13
|
+
* Budget invariant: computeBudgetTiers never treats a missing price as $0. An
|
|
14
|
+
* unknown or unreachable price ranks a candidate LAST, and flagship ids are
|
|
15
|
+
* hard-excluded — so with only an OpenRouter credential no frontier model can be
|
|
16
|
+
* selected for any tier, even when live pricing is unreachable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import {
|
|
22
|
+
defaultModelRegistryPath,
|
|
23
|
+
getProviderModelCatalog,
|
|
24
|
+
resolveModelTiers,
|
|
25
|
+
MODEL_TIER_BY_WORK_CATEGORY,
|
|
26
|
+
} from './model-router.mjs';
|
|
27
|
+
import { getPricingForModels } from './model-pricing.mjs';
|
|
28
|
+
import { pollFreeModels, selectForTier, isFreeModel } from './model-free-selector.mjs';
|
|
29
|
+
import { getSpecialist } from './registry/loader.mjs';
|
|
30
|
+
import { MODEL_TIERS } from './model-tiers.mjs';
|
|
31
|
+
|
|
32
|
+
export const POLICY_PRESETS = ['budget', 'free', 'frontier', 'local'];
|
|
33
|
+
export const POLICY_TIERS = MODEL_TIERS;
|
|
34
|
+
|
|
35
|
+
// Flagship, high-cost models a budget/free policy must never resolve to. The set
|
|
36
|
+
// mirrors the reasoning/standard flagships declared in PROVIDER_FAMILY_TIERS so a
|
|
37
|
+
// preset can hard-exclude them regardless of whether live pricing was reachable;
|
|
38
|
+
// the regex catches the same families' newer point releases.
|
|
39
|
+
|
|
40
|
+
const FRONTIER_MODEL_IDS = new Set([
|
|
41
|
+
'anthropic/claude-opus-4-6',
|
|
42
|
+
'anthropic/claude-sonnet-4-6',
|
|
43
|
+
'openrouter/anthropic/claude-opus-4-6',
|
|
44
|
+
'openrouter/anthropic/claude-sonnet-4-6',
|
|
45
|
+
'openai/gpt-5.4',
|
|
46
|
+
'openai/gpt-5.1',
|
|
47
|
+
'openrouter/openai/gpt-5.4',
|
|
48
|
+
'openrouter/openai/gpt-5.1',
|
|
49
|
+
'openrouter/google/gemini-2.5-pro',
|
|
50
|
+
'openrouter/meta-llama/llama-3.1-405b-instruct',
|
|
51
|
+
'github-copilot/gpt-5.5',
|
|
52
|
+
'github-copilot/gpt-5.4',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const FRONTIER_PATTERN = /(claude-opus|claude-sonnet|gpt-5\.(4|5)|gemini-2\.5-pro|llama-3\.1-405b|405b-instruct)/i;
|
|
56
|
+
|
|
57
|
+
export function isFrontierModel(modelId) {
|
|
58
|
+
if (!modelId || typeof modelId !== 'string') return false;
|
|
59
|
+
if (isFreeModel(modelId)) return false;
|
|
60
|
+
return FRONTIER_MODEL_IDS.has(modelId) || FRONTIER_PATTERN.test(modelId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uniq(values) {
|
|
64
|
+
return [...new Set(values.filter((v) => typeof v === 'string' && v.trim()))];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function configuredProviders(env) {
|
|
68
|
+
const catalog = getProviderModelCatalog({ env });
|
|
69
|
+
return catalog.providers.filter((p) => p.configured);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tierDefinition(primary, fallback = []) {
|
|
73
|
+
return { primary, fallback: uniq(fallback.filter((id) => id !== primary)) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Candidate models for a tier from every configured provider: the tier pin plus
|
|
77
|
+
// the shipped option list. Frontier ids are dropped so a budget/free preset can
|
|
78
|
+
// never rank one, whatever the pricing outcome.
|
|
79
|
+
|
|
80
|
+
function budgetCandidates(providers, tier) {
|
|
81
|
+
const ids = [];
|
|
82
|
+
const local = new Set();
|
|
83
|
+
for (const p of providers) {
|
|
84
|
+
const list = uniq([p.tiers?.[tier], ...(p.options?.[tier] ?? [])]);
|
|
85
|
+
for (const id of list) {
|
|
86
|
+
ids.push(id);
|
|
87
|
+
if (p.local === true) local.add(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ids: uniq(ids).filter((id) => !isFrontierModel(id)), local };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compute the budget tier assignments: the cheapest eligible model per tier from
|
|
95
|
+
* configured providers. Unknown/unreachable prices rank LAST (never $0), so the
|
|
96
|
+
* budget invariant holds even when the pricing fetch fails.
|
|
97
|
+
*/
|
|
98
|
+
export async function computeBudgetTiers({ env = process.env, getPricing = getPricingForModels } = {}) {
|
|
99
|
+
const providers = configuredProviders(env);
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const models = {};
|
|
102
|
+
let pricingDegraded = false;
|
|
103
|
+
|
|
104
|
+
for (const tier of POLICY_TIERS) {
|
|
105
|
+
const { ids, local } = budgetCandidates(providers, tier);
|
|
106
|
+
if (ids.length === 0) {
|
|
107
|
+
models[tier] = tierDefinition(null, []);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let pricing = {};
|
|
112
|
+
try {
|
|
113
|
+
pricing = await getPricing(ids);
|
|
114
|
+
} catch {
|
|
115
|
+
pricing = {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const scored = ids.map((id) => {
|
|
119
|
+
if (local.has(id)) return { id, cost: 0, priced: true };
|
|
120
|
+
const entry = pricing[id];
|
|
121
|
+
if (entry && Number.isFinite(Number(entry.input)) && Number.isFinite(Number(entry.output))) {
|
|
122
|
+
return { id, cost: Number(entry.input) + Number(entry.output), priced: true };
|
|
123
|
+
}
|
|
124
|
+
return { id, cost: Number.POSITIVE_INFINITY, priced: false };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const priced = scored.filter((s) => s.priced);
|
|
128
|
+
if (priced.length === 0) {
|
|
129
|
+
// No candidate had a reachable price. Fall back to a deterministic static
|
|
130
|
+
// ordering — a :free slug first, else the first non-frontier candidate —
|
|
131
|
+
// and say so rather than silently ranking on missing data.
|
|
132
|
+
pricingDegraded = true;
|
|
133
|
+
const free = ids.find((id) => isFreeModel(id));
|
|
134
|
+
const primary = free ?? ids[0];
|
|
135
|
+
models[tier] = tierDefinition(primary, ids.filter((id) => id !== primary).slice(0, 2));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
scored.sort((a, b) => a.cost - b.cost);
|
|
140
|
+
const primary = scored[0].id;
|
|
141
|
+
const fallback = scored.slice(1).filter((s) => Number.isFinite(s.cost)).map((s) => s.id).slice(0, 2);
|
|
142
|
+
models[tier] = tierDefinition(primary, fallback);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (pricingDegraded) {
|
|
146
|
+
warnings.push('Live pricing was unreachable for at least one tier; used static ordering (a :free slug, else the first eligible candidate).');
|
|
147
|
+
}
|
|
148
|
+
return { models, warnings };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Compute the free tier assignments: :free OpenRouter slugs only. A tier with no
|
|
153
|
+
* available free model is left null and reported, never silently substituted.
|
|
154
|
+
*/
|
|
155
|
+
export async function computeFreeTiers({ env = process.env, apiKey = null, poll = pollFreeModels } = {}) {
|
|
156
|
+
const warnings = [];
|
|
157
|
+
const models = {};
|
|
158
|
+
const key = apiKey || env.OPENROUTER_API_KEY || env.OPEN_ROUTER_API_KEY || null;
|
|
159
|
+
const freeModels = key ? await poll(key) : [];
|
|
160
|
+
|
|
161
|
+
for (const tier of POLICY_TIERS) {
|
|
162
|
+
const id = selectForTier(freeModels, tier, []);
|
|
163
|
+
if (id && isFreeModel(id)) {
|
|
164
|
+
models[tier] = tierDefinition(id, []);
|
|
165
|
+
} else {
|
|
166
|
+
models[tier] = tierDefinition(null, []);
|
|
167
|
+
warnings.push(`No free model available for the ${tier} tier; left unset (run \`construct models free\` to inspect the live catalog).`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { models, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Best-available (flagship) per tier from configured providers, ranked by a fixed
|
|
174
|
+
// capability order. This is the deliberately-expensive preset, so frontier ids are
|
|
175
|
+
// not excluded here.
|
|
176
|
+
|
|
177
|
+
const FRONTIER_PROVIDER_ORDER = [
|
|
178
|
+
'anthropic',
|
|
179
|
+
'openai',
|
|
180
|
+
'openrouter-anthropic',
|
|
181
|
+
'openrouter-openai',
|
|
182
|
+
'github-copilot',
|
|
183
|
+
'openrouter-google',
|
|
184
|
+
'openrouter-deepseek',
|
|
185
|
+
'openrouter-llama',
|
|
186
|
+
'openrouter-qwen',
|
|
187
|
+
'openrouter',
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
export function computeFrontierTiers({ env = process.env } = {}) {
|
|
191
|
+
const providers = configuredProviders(env);
|
|
192
|
+
const ranked = [...providers].sort((a, b) => {
|
|
193
|
+
const ia = FRONTIER_PROVIDER_ORDER.indexOf(a.id);
|
|
194
|
+
const ib = FRONTIER_PROVIDER_ORDER.indexOf(b.id);
|
|
195
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
196
|
+
});
|
|
197
|
+
const models = {};
|
|
198
|
+
for (const tier of POLICY_TIERS) {
|
|
199
|
+
const provider = ranked.find((p) => p.tiers?.[tier]);
|
|
200
|
+
const primary = provider?.tiers?.[tier] ?? null;
|
|
201
|
+
const fallback = ranked.filter((p) => p !== provider).map((p) => p.tiers?.[tier]).filter(Boolean);
|
|
202
|
+
models[tier] = tierDefinition(primary, fallback.slice(0, 2));
|
|
203
|
+
}
|
|
204
|
+
return { models, warnings: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function computeLocalTiers({ env = process.env } = {}) {
|
|
208
|
+
const providers = configuredProviders(env).filter((p) => p.local === true);
|
|
209
|
+
const models = {};
|
|
210
|
+
for (const tier of POLICY_TIERS) {
|
|
211
|
+
const provider = providers.find((p) => p.tiers?.[tier]);
|
|
212
|
+
const primary = provider?.tiers?.[tier] ?? null;
|
|
213
|
+
const fallback = providers.filter((p) => p !== provider).map((p) => p.tiers?.[tier]).filter(Boolean);
|
|
214
|
+
models[tier] = tierDefinition(primary, fallback.slice(0, 2));
|
|
215
|
+
}
|
|
216
|
+
return { models, warnings: [] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Compute a preset's per-tier assignments. Refuses (ok:false) with a clear
|
|
221
|
+
* message when no provider credential can back the preset, so a preset never
|
|
222
|
+
* silently writes an unbacked frontier default.
|
|
223
|
+
*/
|
|
224
|
+
export async function computePolicyPreset(preset, { env = process.env, apiKey = null, getPricing, poll } = {}) {
|
|
225
|
+
if (!POLICY_PRESETS.includes(preset)) {
|
|
226
|
+
return { ok: false, refusal: `Unknown preset '${preset}'. Choose one of: ${POLICY_PRESETS.join(', ')}.` };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const providers = configuredProviders(env);
|
|
230
|
+
const localProviders = providers.filter((p) => p.local === true);
|
|
231
|
+
const hasOpenRouter = Boolean(env.OPENROUTER_API_KEY || env.OPEN_ROUTER_API_KEY);
|
|
232
|
+
|
|
233
|
+
if (preset === 'free' && !hasOpenRouter) {
|
|
234
|
+
return { ok: false, refusal: 'The `free` preset needs an OpenRouter credential (OPENROUTER_API_KEY). Set one, then rerun.' };
|
|
235
|
+
}
|
|
236
|
+
if (preset === 'local' && localProviders.length === 0) {
|
|
237
|
+
return { ok: false, refusal: 'The `local` preset needs a local provider (Ollama or a local OpenAI-compatible server). Start one, then rerun.' };
|
|
238
|
+
}
|
|
239
|
+
if ((preset === 'budget' || preset === 'frontier') && providers.length === 0) {
|
|
240
|
+
return { ok: false, refusal: `The \`${preset}\` preset needs at least one configured provider credential. Set an API key (e.g. OPENROUTER_API_KEY / ANTHROPIC_API_KEY), then rerun.` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let result;
|
|
244
|
+
if (preset === 'budget') result = await computeBudgetTiers({ env, ...(getPricing ? { getPricing } : {}) });
|
|
245
|
+
else if (preset === 'free') result = await computeFreeTiers({ env, apiKey, ...(poll ? { poll } : {}) });
|
|
246
|
+
else if (preset === 'frontier') result = computeFrontierTiers({ env });
|
|
247
|
+
else result = computeLocalTiers({ env });
|
|
248
|
+
|
|
249
|
+
const anyResolved = POLICY_TIERS.some((tier) => result.models[tier]?.primary);
|
|
250
|
+
if (!anyResolved) {
|
|
251
|
+
return { ok: false, refusal: `No model could be computed for the \`${preset}\` preset with the current credentials.`, warnings: result.warnings };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { ok: true, preset, models: result.models, warnings: result.warnings ?? [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Persist computed tier assignments to the models.json registry — the single
|
|
259
|
+
* edit surface. Writes only `models`; env pins keep overriding at resolution.
|
|
260
|
+
*/
|
|
261
|
+
export function writeModelRegistry(registryPath, models, { preset = null } = {}) {
|
|
262
|
+
const payload = {
|
|
263
|
+
_comment: `Written by \`construct models policy set${preset ? ` ${preset}` : ''}\` (construct-760c.7). Tier defaults below fill only tiers no CX_MODEL_<TIER> env pin sets; env pins always win. Edit via \`construct models policy set <preset>\` or \`construct models set\`.`,
|
|
264
|
+
models: {},
|
|
265
|
+
};
|
|
266
|
+
for (const tier of POLICY_TIERS) {
|
|
267
|
+
const def = models[tier];
|
|
268
|
+
if (def?.primary) payload.models[tier] = { primary: def.primary, fallback: def.fallback ?? [] };
|
|
269
|
+
}
|
|
270
|
+
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
271
|
+
fs.writeFileSync(registryPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
272
|
+
return payload;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Effective-policy view: per-tier resolved model and the winning source, plus the
|
|
277
|
+
* work-category → tier map. Reads through resolveModelTiers so an env pin is
|
|
278
|
+
* attributed as the winning source over a registry default.
|
|
279
|
+
*/
|
|
280
|
+
export function readPolicyView({ env = process.env, registryPath = null } = {}) {
|
|
281
|
+
const resolvedRegistryPath = registryPath ?? defaultModelRegistryPath(env);
|
|
282
|
+
const resolution = resolveModelTiers({ env, registryPath: resolvedRegistryPath });
|
|
283
|
+
const tiers = POLICY_TIERS.map((tier) => ({
|
|
284
|
+
tier,
|
|
285
|
+
model: resolution.models[tier],
|
|
286
|
+
source: resolution.sources[tier],
|
|
287
|
+
envPin: env[`CX_MODEL_${tier.toUpperCase()}`] || env[`CONSTRUCT_MODEL_${tier.toUpperCase()}`] || null,
|
|
288
|
+
}));
|
|
289
|
+
return {
|
|
290
|
+
registryPath: resolvedRegistryPath,
|
|
291
|
+
tiers,
|
|
292
|
+
workCategoryMap: { ...MODEL_TIER_BY_WORK_CATEGORY },
|
|
293
|
+
configured: resolution.configured,
|
|
294
|
+
complete: resolution.complete,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Per-specialist resolution trace: the specialist's declared modelTier/model, the
|
|
300
|
+
* tier it maps to, and the winning rule for that tier. Mirrors what
|
|
301
|
+
* `construct models resolve --json --tier <tier>` reports for the same tier.
|
|
302
|
+
*/
|
|
303
|
+
export function explainRole(roleId, { env = process.env, registryPath = null, rootDir = null } = {}) {
|
|
304
|
+
const specialist = getSpecialist(roleId, rootDir ? { rootDir } : {});
|
|
305
|
+
if (!specialist) {
|
|
306
|
+
return { ok: false, error: `Unknown specialist '${roleId}'.` };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const declaredModel = specialist.model || null;
|
|
310
|
+
const declaredTier = specialist.modelTier || null;
|
|
311
|
+
const tier = declaredTier || 'standard';
|
|
312
|
+
|
|
313
|
+
const resolvedRegistryPath = registryPath ?? defaultModelRegistryPath(env);
|
|
314
|
+
const resolution = resolveModelTiers({ env, registryPath: resolvedRegistryPath });
|
|
315
|
+
|
|
316
|
+
const resolvedModel = declaredModel || resolution.models[tier];
|
|
317
|
+
const source = declaredModel ? 'specialist model pin' : resolution.sources[tier];
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
role: specialist.name ? `cx-${specialist.name}` : roleId,
|
|
322
|
+
declaredModel,
|
|
323
|
+
declaredTier,
|
|
324
|
+
tier,
|
|
325
|
+
resolvedModel,
|
|
326
|
+
source,
|
|
327
|
+
registryPath: resolvedRegistryPath,
|
|
328
|
+
};
|
|
329
|
+
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import path from "node:path";
|
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
27
|
import { spawnSync } from "child_process";
|
|
28
28
|
import { hasAnySecret } from "./providers/secret-resolver.mjs";
|
|
29
|
+
import { MODEL_TIERS } from "./model-tiers.mjs";
|
|
29
30
|
import { isOllamaModelInstalled, toOllamaNativeModelId } from "./ollama/installed-models.mjs";
|
|
30
31
|
import { readRawFromOpenCodeProvider } from "./providers/credential-sources.mjs";
|
|
31
32
|
import { findOpenCodeConfigPath } from "./opencode-config.mjs";
|
|
@@ -535,9 +536,9 @@ export function getPickerModelAllowlist({
|
|
|
535
536
|
if (!providerFamilyEnabled(family.id, vis)) continue;
|
|
536
537
|
|
|
537
538
|
const tiers = family.resolve({});
|
|
538
|
-
const pins = uniqueStrings(
|
|
539
|
+
const pins = uniqueStrings(MODEL_TIERS.map((tier) => tiers[tier]));
|
|
539
540
|
const shipped = uniqueStrings(
|
|
540
|
-
|
|
541
|
+
MODEL_TIERS.flatMap((tier) => family.options?.[tier] || []),
|
|
541
542
|
);
|
|
542
543
|
|
|
543
544
|
if (vis.mode === 'explicit') {
|
|
@@ -675,7 +676,7 @@ export function listAvailableModels({ env = process.env, cwd = process.cwd(), ac
|
|
|
675
676
|
const models = [];
|
|
676
677
|
const seen = new Set();
|
|
677
678
|
for (const provider of providers) {
|
|
678
|
-
for (const tier of
|
|
679
|
+
for (const tier of MODEL_TIERS) {
|
|
679
680
|
for (const id of provider.options?.[tier] || []) {
|
|
680
681
|
if (seen.has(id)) continue;
|
|
681
682
|
seen.add(id);
|
|
@@ -1135,7 +1136,7 @@ function resolveTierAssignments(envValues = {}, registryModels = {}) {
|
|
|
1135
1136
|
const defaults = getRegistryDefaults(registryModels);
|
|
1136
1137
|
const tiers = {};
|
|
1137
1138
|
|
|
1138
|
-
for (const tier of
|
|
1139
|
+
for (const tier of MODEL_TIERS) {
|
|
1139
1140
|
if (explicitSources[tier]) {
|
|
1140
1141
|
tiers[tier] = { model: normalizedEnv[tier] ?? defaults[tier], source: explicitSources[tier] };
|
|
1141
1142
|
} else if (normalizedEnv[tier]) {
|
|
@@ -1224,7 +1225,7 @@ export function applyFreeSameFamilyPreferenceToTierSet(tierSet, selectedModel) {
|
|
|
1224
1225
|
|
|
1225
1226
|
const sameFamily = family.resolve({ reasoning: null, standard: null, fast: null });
|
|
1226
1227
|
const next = { ...tierSet };
|
|
1227
|
-
for (const tier of
|
|
1228
|
+
for (const tier of MODEL_TIERS) {
|
|
1228
1229
|
if (tierSet[tier] === selectedModel) continue;
|
|
1229
1230
|
const candidate = sameFamily[tier];
|
|
1230
1231
|
if (candidate && isFreeModel(candidate)) next[tier] = candidate;
|
|
@@ -1341,13 +1342,13 @@ export function resolveModelTiers(options = {}) {
|
|
|
1341
1342
|
const tiers = resolveTierAssignments(normalizeEnvAssignments(env), registryModels);
|
|
1342
1343
|
const models = {};
|
|
1343
1344
|
const sources = {};
|
|
1344
|
-
for (const tier of
|
|
1345
|
+
for (const tier of MODEL_TIERS) {
|
|
1345
1346
|
models[tier] = tiers[tier].model;
|
|
1346
1347
|
sources[tier] = tiers[tier].source;
|
|
1347
1348
|
}
|
|
1348
1349
|
const configured = Object.values(models).filter(Boolean).length;
|
|
1349
1350
|
const errors = strict && configured < 3
|
|
1350
|
-
? [`Missing configuration for tiers: ${
|
|
1351
|
+
? [`Missing configuration for tiers: ${MODEL_TIERS.filter((t) => !models[t]).join(', ')}`]
|
|
1351
1352
|
: null;
|
|
1352
1353
|
|
|
1353
1354
|
return {
|
|
@@ -1386,7 +1387,7 @@ export function validateModelTiers(options = {}) {
|
|
|
1386
1387
|
export function formatModelStatus(options = {}) {
|
|
1387
1388
|
const resolved = resolveModelTiers(options);
|
|
1388
1389
|
let output = 'Model Configuration:\n\n';
|
|
1389
|
-
for (const tier of
|
|
1390
|
+
for (const tier of MODEL_TIERS) {
|
|
1390
1391
|
const model = resolved.models[tier];
|
|
1391
1392
|
const source = resolved.sources[tier];
|
|
1392
1393
|
const icon = source === 'not configured' ? '!' : 'ok';
|
|
@@ -1407,7 +1408,7 @@ export function readCurrentModels(envPath, registryModels = {}) {
|
|
|
1407
1408
|
};
|
|
1408
1409
|
const tiers = resolveTierAssignments(mergedAssignments, registryModels);
|
|
1409
1410
|
const result = { sources: {} };
|
|
1410
|
-
for (const tier of
|
|
1411
|
+
for (const tier of MODEL_TIERS) {
|
|
1411
1412
|
result[tier] = tiers[tier].model;
|
|
1412
1413
|
result.sources[tier] = tiers[tier].source;
|
|
1413
1414
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/model-tiers.mjs — the canonical model-tier vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* `reasoning`, `standard`, and `fast` are the three work tiers every
|
|
5
|
+
* model-resolution surface keys off: the router's family tables, the registry
|
|
6
|
+
* validator's accept-list, cost selection, policy presets, the CLI, and the
|
|
7
|
+
* MCP tool schemas. They lived as a `['reasoning', 'standard', 'fast']` literal
|
|
8
|
+
* re-typed in ~30 places, so adding or renaming a tier meant the accept-lists
|
|
9
|
+
* could silently diverge from what the router actually resolves (construct-v1wk).
|
|
10
|
+
*
|
|
11
|
+
* Single source of truth for that vocabulary — deliberately zero-dependency so
|
|
12
|
+
* the lowest-level consumers (the registry validator, setup) can import it
|
|
13
|
+
* without pulling the router's provider probes or model-policy's
|
|
14
|
+
* pricing/registry imports. A JSON-schema `enum` or any caller that needs a
|
|
15
|
+
* fresh mutable array spreads it: `[...MODEL_TIERS]`.
|
|
16
|
+
*
|
|
17
|
+
* The intentionally self-contained `lib/embedded-contract/` bundle keeps its
|
|
18
|
+
* own copy on purpose — it must not import `lib/` internals.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const MODEL_TIERS = Object.freeze(['reasoning', 'standard', 'fast']);
|
|
22
|
+
|
|
23
|
+
export const MODEL_TIER_SET = Object.freeze(new Set(MODEL_TIERS));
|
|
24
|
+
|
|
25
|
+
export function isModelTier(value) {
|
|
26
|
+
return MODEL_TIER_SET.has(value);
|
|
27
|
+
}
|
package/lib/models/catalog.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { loadRegistry } from '../registry/loader.mjs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import { pollFreeModels } from '../model-free-selector.mjs';
|
|
14
|
+
import { MODEL_TIERS } from '../model-tiers.mjs';
|
|
14
15
|
import { resolveFirstSecret, hasAnySecret } from '../providers/secret-resolver.mjs';
|
|
15
16
|
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
16
17
|
import { doctorRoot } from '../config/xdg.mjs';
|
|
@@ -105,7 +106,7 @@ export function providerFamilyEnabled(providerId, visibility) {
|
|
|
105
106
|
|
|
106
107
|
export function collectTierDefaultIds(registryModels = {}) {
|
|
107
108
|
const ids = [];
|
|
108
|
-
for (const tier of
|
|
109
|
+
for (const tier of MODEL_TIERS) {
|
|
109
110
|
const def = registryModels[tier];
|
|
110
111
|
if (typeof def === 'string') ids.push(def);
|
|
111
112
|
else if (def && typeof def === 'object') {
|
|
@@ -176,11 +177,11 @@ export function applyModelVisibilityFilter(catalog, {
|
|
|
176
177
|
.filter((provider) => providerFamilyEnabled(provider.id, visibility))
|
|
177
178
|
.map((provider) => {
|
|
178
179
|
const options = {};
|
|
179
|
-
for (const tier of
|
|
180
|
+
for (const tier of MODEL_TIERS) {
|
|
180
181
|
options[tier] = (provider.options?.[tier] ?? []).filter((id) => modelAllowed(id, provider.id));
|
|
181
182
|
}
|
|
182
183
|
const tiers = { ...provider.tiers };
|
|
183
|
-
for (const tier of
|
|
184
|
+
for (const tier of MODEL_TIERS) {
|
|
184
185
|
if (tiers[tier] && !modelAllowed(tiers[tier], provider.id)) {
|
|
185
186
|
tiers[tier] = options[tier]?.[0] ?? null;
|
|
186
187
|
}
|
|
@@ -260,6 +261,6 @@ export function isModelVisible(modelId, {
|
|
|
260
261
|
{ visibility, registryModels, activeModelId },
|
|
261
262
|
);
|
|
262
263
|
return filtered.providers.some((p) =>
|
|
263
|
-
|
|
264
|
+
MODEL_TIERS.some((tier) => (p.options[tier] ?? []).includes(modelId)),
|
|
264
265
|
);
|
|
265
266
|
}
|
|
@@ -49,6 +49,17 @@ const DOC_AUTHORING_PATTERNS = [
|
|
|
49
49
|
{ pattern: /\bchangelog/i, docType: 'changelog' },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
+
// The docType each pattern maps to must exist in the routing registry's
|
|
53
|
+
// knownDocTypes(), or detectDocAuthoringIntent returns a docType with a null
|
|
54
|
+
// owner and the request silently loses its canonical author. A drift-guard test
|
|
55
|
+
// (tests/orchestration-doc-authoring-patterns.test.mjs) asserts the subset,
|
|
56
|
+
// since the mapping cannot be generated from the registry (the regex is
|
|
57
|
+
// natural-language, only the docType string is registry-owned) — construct-v1wk.
|
|
58
|
+
|
|
59
|
+
export function docAuthoringDocTypes() {
|
|
60
|
+
return DOC_AUTHORING_PATTERNS.map(({ docType }) => docType);
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
const AUTHORING_VERBS = /\b(write|writing|draft|drafting|create|creating|author|authoring|produce|producing|compose|composing|prepare|preparing|generate|generating)\b/i;
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/context-bindings.mjs — resolve per-run context-target
|
|
3
|
+
* bindings for orchestration runs (bead construct-760c.4).
|
|
4
|
+
*
|
|
5
|
+
* An orchestration run can name which registered source targets it should draw
|
|
6
|
+
* context from — `contextTargets: [{id, role?}]`. This module validates those
|
|
7
|
+
* ids against the project's effective `sources.targets[]` at PLAN time and
|
|
8
|
+
* resolves each to a binding record persisted on the run for audit and picked up
|
|
9
|
+
* by the run's retrieval path. An unknown id is a hard error before any task is
|
|
10
|
+
* built (never a silent skip), so a run never plans against context it can't
|
|
11
|
+
* reach.
|
|
12
|
+
*
|
|
13
|
+
* `role` is an optional free-form hint (e.g. "tracker", "reference") threaded
|
|
14
|
+
* verbatim onto the binding — there is no role enum in core. Content-capable
|
|
15
|
+
* targets (directory / synced corpus, via lib/sources/content-roots.mjs) also
|
|
16
|
+
* carry a resolved `contentRoot` so a directory-bound run can read the docs
|
|
17
|
+
* directly; targets with no local content (a tracker like jira) resolve to a
|
|
18
|
+
* binding with `contentRoot: null` and provider identity only.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
|
|
22
|
+
import { resolveContentRoots } from '../sources/content-roots.mjs';
|
|
23
|
+
|
|
24
|
+
export class ContextTargetError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'ContextTargetError';
|
|
28
|
+
this.code = 'CONTEXT_TARGET_UNKNOWN';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeRequests(contextTargets) {
|
|
33
|
+
if (contextTargets == null) return [];
|
|
34
|
+
const list = Array.isArray(contextTargets) ? contextTargets : [contextTargets];
|
|
35
|
+
return list
|
|
36
|
+
.map((entry) => (typeof entry === 'string' ? { id: entry } : entry))
|
|
37
|
+
.filter((entry) => entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim())
|
|
38
|
+
.map((entry) => ({ id: entry.id.trim(), role: entry.role != null ? String(entry.role) : null }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve requested context targets into binding records, or throw
|
|
43
|
+
* ContextTargetError naming the offending id and the known ids. Returns an empty
|
|
44
|
+
* array when nothing is requested — the caller then falls back to today's
|
|
45
|
+
* implicit source resolution, unchanged.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{id: string, role?: string}>|string[]} contextTargets
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {object} opts.config loaded project config
|
|
50
|
+
* @param {object} [opts.env]
|
|
51
|
+
* @param {string} [opts.cwd] project root, for content-root resolution
|
|
52
|
+
* @returns {{id, provider, role, resolution, contentRoot, ref}[]}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveContextBindings(contextTargets, { config, env = process.env, cwd = process.cwd() } = {}) {
|
|
55
|
+
const requests = normalizeRequests(contextTargets);
|
|
56
|
+
if (!requests.length) return [];
|
|
57
|
+
|
|
58
|
+
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
59
|
+
const byId = new Map(targets.map((t) => [t.id, t]));
|
|
60
|
+
const contentRoots = resolveContentRoots(targets, { projectRoot: cwd });
|
|
61
|
+
const rootByTargetId = new Map(contentRoots.map((r) => [r.origin.targetId, r]));
|
|
62
|
+
|
|
63
|
+
const bindings = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const req of requests) {
|
|
66
|
+
if (seen.has(req.id)) continue;
|
|
67
|
+
seen.add(req.id);
|
|
68
|
+
|
|
69
|
+
const target = byId.get(req.id);
|
|
70
|
+
if (!target) {
|
|
71
|
+
const known = [...byId.keys()].join(', ') || '(none registered)';
|
|
72
|
+
throw new ContextTargetError(`unknown context target "${req.id}" — known targets: ${known}`);
|
|
73
|
+
}
|
|
74
|
+
const root = rootByTargetId.get(req.id) || null;
|
|
75
|
+
bindings.push({
|
|
76
|
+
id: target.id,
|
|
77
|
+
provider: target.provider,
|
|
78
|
+
role: req.role,
|
|
79
|
+
resolution: 'resolved',
|
|
80
|
+
contentRoot: root ? root.dir : null,
|
|
81
|
+
ref: root ? root.origin.ref : null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return bindings;
|
|
85
|
+
}
|
|
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path';
|
|
|
12
12
|
import { getInstalledVersion } from '../version.mjs';
|
|
13
13
|
import { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION } from '../embedded-contract/contract-version.mjs';
|
|
14
14
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
15
|
+
import { MODEL_TIERS } from '../model-tiers.mjs';
|
|
15
16
|
import { doctorRoot } from '../config/xdg.mjs';
|
|
16
17
|
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
17
18
|
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
@@ -165,7 +166,7 @@ export function buildOrchestrationReadiness(input = {}, { env = process.env, cwd
|
|
|
165
166
|
const workerBackend = resolveWorkerBackend({ config });
|
|
166
167
|
const modelResolved = {};
|
|
167
168
|
let firstUnresolvedTier = null;
|
|
168
|
-
for (const tier of
|
|
169
|
+
for (const tier of MODEL_TIERS) {
|
|
169
170
|
const resolved = resolveEmbeddedModel({ requestedTier: tier }, { env });
|
|
170
171
|
modelResolved[tier] = resolved.resolutionSource !== 'config-error';
|
|
171
172
|
if (!modelResolved[tier] && !firstUnresolvedTier) firstUnresolvedTier = tier;
|