@geraldmaron/construct 1.5.1 → 1.5.3
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 +7 -1
- package/bin/construct +354 -19
- package/lib/adapters-sync.mjs +1 -1
- 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/auto-docs.mjs +5 -1
- package/lib/cli-commands.mjs +51 -6
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- package/lib/decisions/golden.mjs +17 -18
- 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/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/review-pr.mjs +102 -0
- 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/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -5
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/scripts/sync-specialists.mjs +12 -3
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- package/templates/docs/strategy-comparison.md +19 -0
|
@@ -5,23 +5,48 @@
|
|
|
5
5
|
* the drafted typed-artifact markdown,
|
|
6
6
|
* which gets materialized to its canonical path and run through the release gate.
|
|
7
7
|
* Returns the path and a structured verdict so the agent can fix and re-call.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* Type resolution respects the manifest gate. A registered class (builtin,
|
|
10
|
+
* user/project overlay, or the sanctioned `adhoc`) proceeds; an unknown
|
|
11
|
+
* non-adhoc class returns a classification/registration result instead of
|
|
12
|
+
* silently becoming a PRD. `adhoc` needs an explicit title + instructions and
|
|
13
|
+
* is not a bypass for a known class — naming a registered type through adhoc
|
|
14
|
+
* is redirected. allowScaffold stays off for a real author pass (a real draft
|
|
15
|
+
* is required); `dry_run` flips it on to preview the resolved template or the
|
|
16
|
+
* adhoc scaffold through the same gate without a live model.
|
|
9
17
|
*/
|
|
10
18
|
import { runConstructArtifactLoop } from '../../artifact-loop-core.mjs';
|
|
19
|
+
import { resolveArtifactType } from '../../artifact-manifest.mjs';
|
|
20
|
+
import { ADHOC_TYPE } from '../../artifact-manifest-overlay.mjs';
|
|
21
|
+
import { loadProjectConfig } from '../../config/project-config.mjs';
|
|
22
|
+
import { resolveContextBindings } from '../../orchestration/context-bindings.mjs';
|
|
23
|
+
import { synthesize } from '../../knowledge/synthesis.mjs';
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
// context_targets binds an author pass to registered source projects (B3): the
|
|
26
|
+
// ids are validated the same way orchestration runs validate theirs (unknown id
|
|
27
|
+
// → hard error before any authoring), and the multi-project synthesis context is
|
|
28
|
+
// assembled deterministically (dry-run map) and woven into the authoring input so
|
|
29
|
+
// the artifact draws on — and cites — every named project.
|
|
30
|
+
async function resolveContextBlock(args, cwd) {
|
|
31
|
+
const contextTargets = args.context_targets;
|
|
32
|
+
if (contextTargets == null || (Array.isArray(contextTargets) && contextTargets.length === 0)) {
|
|
33
|
+
return { ok: true, block: '', bindings: [] };
|
|
34
|
+
}
|
|
35
|
+
const { config } = loadProjectConfig(cwd);
|
|
36
|
+
let bindings;
|
|
37
|
+
try {
|
|
38
|
+
bindings = resolveContextBindings(contextTargets, { config, cwd });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { ok: false, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
const ask = String(args.subject || args.text || args.instructions || args.title || 'synthesize across the bound projects').trim();
|
|
43
|
+
const projects = bindings.map((b) => b.id).join(',');
|
|
44
|
+
const synth = await synthesize({ projects, ask, cwd, dryRun: true });
|
|
45
|
+
const block = synth.ok ? `\n\n## Cross-project context (cite as project:path)\n\n${synth.context}` : '';
|
|
46
|
+
return { ok: true, block, bindings };
|
|
47
|
+
}
|
|
24
48
|
|
|
49
|
+
function toResult(result) {
|
|
25
50
|
return {
|
|
26
51
|
ok: Boolean(result.ok),
|
|
27
52
|
artifact_type: result.artifactType,
|
|
@@ -34,3 +59,91 @@ export async function authorArtifact(args = {}, { ROOT_DIR } = {}) {
|
|
|
34
59
|
summary: result.summary,
|
|
35
60
|
};
|
|
36
61
|
}
|
|
62
|
+
|
|
63
|
+
async function authorAdhoc(args, { ROOT_DIR, cwd, dryRun, contextBlock = '' }) {
|
|
64
|
+
const title = String(args.title || '').trim();
|
|
65
|
+
const instructions = String(args.instructions || '').trim();
|
|
66
|
+
if (!title || !instructions) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
artifact_type: ADHOC_TYPE,
|
|
70
|
+
status: 'invalid-request',
|
|
71
|
+
errors: ['adhoc requires an explicit title and instructions'],
|
|
72
|
+
guidance: 'Call author_artifact with {type:"adhoc", title, instructions}. For a registered class, pass its type instead.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Guard R3: adhoc is for genuinely unstructured one-offs. When the caller
|
|
77
|
+
// names a registered class (via `for_type` or an exact-match title), redirect
|
|
78
|
+
// to that class so adhoc never becomes a bypass for a gated type.
|
|
79
|
+
const namedType = String(args.for_type || '').trim().toLowerCase() || title.toLowerCase();
|
|
80
|
+
const named = resolveArtifactType(namedType, { rootDir: ROOT_DIR, cwd });
|
|
81
|
+
if (named.status === 'registered' && named.type !== ADHOC_TYPE) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
artifact_type: ADHOC_TYPE,
|
|
85
|
+
status: 'redirect',
|
|
86
|
+
redirect_to: named.type,
|
|
87
|
+
warnings: [`'${named.type}' is a registered class; author it directly instead of via adhoc.`],
|
|
88
|
+
guidance: `Call author_artifact with {type:"${named.type}", ...} to use its template and release gate.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const draftMarkdown = args.draft_markdown || args.draft || '';
|
|
93
|
+
const result = await runConstructArtifactLoop({
|
|
94
|
+
draftMarkdown,
|
|
95
|
+
artifactType: ADHOC_TYPE,
|
|
96
|
+
titleOverride: title,
|
|
97
|
+
instructions,
|
|
98
|
+
text: `${args.subject || args.text || instructions}${contextBlock}`,
|
|
99
|
+
cwd,
|
|
100
|
+
rootDir: ROOT_DIR,
|
|
101
|
+
explicit: true,
|
|
102
|
+
allowScaffold: dryRun || !draftMarkdown,
|
|
103
|
+
});
|
|
104
|
+
return toResult(result);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function authorArtifact(args = {}, { ROOT_DIR } = {}) {
|
|
108
|
+
const cwd = args.cwd || process.cwd();
|
|
109
|
+
const dryRun = args.dry_run === true || args.scaffold === true;
|
|
110
|
+
const requestedType = String(args.artifact_type || '').trim().toLowerCase();
|
|
111
|
+
|
|
112
|
+
const ctx = await resolveContextBlock(args, cwd);
|
|
113
|
+
if (!ctx.ok) {
|
|
114
|
+
return { ok: false, artifact_type: requestedType || null, status: 'invalid-context-target', errors: [ctx.error] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (requestedType === ADHOC_TYPE || requestedType === 'ad-hoc' || requestedType === 'free-form' || requestedType === 'freeform') {
|
|
118
|
+
return authorAdhoc(args, { ROOT_DIR, cwd, dryRun, contextBlock: ctx.block });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Gate intact (R4): an explicitly requested class that resolves to nothing
|
|
122
|
+
// registered gets the classification/registration answer, not a PRD.
|
|
123
|
+
if (requestedType) {
|
|
124
|
+
const resolved = resolveArtifactType(requestedType, { rootDir: ROOT_DIR, cwd });
|
|
125
|
+
if (resolved.status !== 'registered') {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
artifact_type: requestedType,
|
|
129
|
+
status: 'unrecognized',
|
|
130
|
+
classification_required: true,
|
|
131
|
+
errors: [`Document class '${requestedType}' is not registered.`],
|
|
132
|
+
guidance: `${resolved.guidance} Register it with \`construct templates register ${requestedType}\`, or author a one-off with {type:"adhoc", title, instructions}.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const draftMarkdown = args.draft_markdown || args.draft || '';
|
|
138
|
+
const result = await runConstructArtifactLoop({
|
|
139
|
+
draftMarkdown,
|
|
140
|
+
artifactType: args.artifact_type,
|
|
141
|
+
text: `${args.subject || args.text || ''}${ctx.block}`,
|
|
142
|
+
cwd,
|
|
143
|
+
rootDir: ROOT_DIR,
|
|
144
|
+
explicit: true,
|
|
145
|
+
allowScaffold: dryRun,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return toResult(result);
|
|
149
|
+
}
|
|
@@ -73,6 +73,7 @@ export function shapeRun(run) {
|
|
|
73
73
|
suggestedWorkflowType: run.plan?.suggestedWorkflowType ?? null,
|
|
74
74
|
researchExecutionPolicy: run.plan?.researchExecutionPolicy ?? null,
|
|
75
75
|
specialists: run.plan?.specialists ?? [],
|
|
76
|
+
contextBindings: run.contextBindings ?? [],
|
|
76
77
|
tasks: (run.tasks || []).map((t) => ({
|
|
77
78
|
id: t.id, role: t.role, status: t.status, executor: t.executor,
|
|
78
79
|
output: t.output ?? null, reasoning: t.reasoning ?? null, error: t.error ?? null,
|
|
@@ -85,6 +86,7 @@ export function shapeRun(run) {
|
|
|
85
86
|
// never be confused with a construct-verified provider execution.
|
|
86
87
|
...(t.hostPrompt ? { system: t.hostPrompt.system, user: t.hostPrompt.user } : {}),
|
|
87
88
|
...(t.provenanceSource ? { provenanceSource: t.provenanceSource } : {}),
|
|
89
|
+
...(t.evidenceGate ? { evidenceGate: t.evidenceGate } : {}),
|
|
88
90
|
})),
|
|
89
91
|
};
|
|
90
92
|
}
|
|
@@ -118,6 +120,7 @@ function toRequest(args) {
|
|
|
118
120
|
hostProvider: args.host_provider,
|
|
119
121
|
fileCount: args.file_count,
|
|
120
122
|
moduleCount: args.module_count,
|
|
123
|
+
contextTargets: args.context_targets,
|
|
121
124
|
};
|
|
122
125
|
}
|
|
123
126
|
|
package/lib/mcp/tools/skills.mjs
CHANGED
|
@@ -290,11 +290,28 @@ export async function orchestrationPolicy(args) {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// A plan is not a tool. Weak models otherwise infer a tool name from
|
|
294
|
+
// suggestedWorkflowType (e.g. research-synthesis → an invented
|
|
295
|
+
// `research_synthesis` tool) or freelance raw host tools, bypassing the
|
|
296
|
+
// governed specialist chain and its anti-fabrication persona. Hand back an
|
|
297
|
+
// explicit, machine-actionable next step so the caller executes the plan
|
|
298
|
+
// through orchestration_run instead of guessing. Omitted for the immediate
|
|
299
|
+
// track, which needs no orchestration. worker_backend is left unset so the
|
|
300
|
+
// project's configured backend (free/host/inline) is honored — never forced.
|
|
301
|
+
const nextAction = route.track !== 'immediate'
|
|
302
|
+
? {
|
|
303
|
+
tool: 'orchestration_run',
|
|
304
|
+
arguments: { request: args?.request || '' },
|
|
305
|
+
instruction: 'Execute this plan by calling the orchestration_run tool with the request above. suggestedWorkflowType is a plan label, not a tool — do not call it, and do not freelance raw web/file tools in its place.',
|
|
306
|
+
}
|
|
307
|
+
: null;
|
|
308
|
+
|
|
293
309
|
return {
|
|
294
310
|
...route,
|
|
295
311
|
specialistCatalog: buildSpecialistCatalog(),
|
|
296
312
|
approvalRequired,
|
|
297
313
|
terminalStates: TERMINAL_STATES,
|
|
314
|
+
nextAction,
|
|
298
315
|
draftTask,
|
|
299
316
|
handoffPacket,
|
|
300
317
|
...(contextPackets ? { contextPackets } : {}),
|
|
@@ -22,6 +22,7 @@ import path from 'node:path';
|
|
|
22
22
|
import { getProviderModelCatalog, PROVIDER_FAMILY_TIERS } from './model-router.mjs';
|
|
23
23
|
import { getPricingForModels, formatPricingLabel } from './model-pricing.mjs';
|
|
24
24
|
import { configDir } from './config/xdg.mjs';
|
|
25
|
+
import { isModelTier } from './model-tiers.mjs';
|
|
25
26
|
|
|
26
27
|
const CHEAPEST_PREF_KEY = 'CHEAPEST_PROVIDER_ENABLED';
|
|
27
28
|
const CHEAPEST_CHECKED_KEY = 'CHEAPEST_PROVIDER_CHECKED';
|
|
@@ -47,7 +48,7 @@ const ENV_PATH = path.join(configDir(), 'config.env');
|
|
|
47
48
|
* }>}
|
|
48
49
|
*/
|
|
49
50
|
export async function selectCheapestProvider(tier, opts = {}) {
|
|
50
|
-
if (!
|
|
51
|
+
if (!isModelTier(tier)) {
|
|
51
52
|
return { providerId: null, providerLabel: null, modelId: null, configuredProviders: [], rankedList: [] };
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -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
|
+
}
|