@geraldmaron/construct 1.0.2 → 1.0.4
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/.env.example +1 -1
- package/README.md +4 -4
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +72 -11
- package/bin/construct-postinstall.mjs +1 -1
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +1 -2
- package/lib/beads-automation.mjs +16 -7
- package/lib/bootstrap/resources.mjs +2 -2
- package/lib/cli-commands.mjs +8 -2
- package/lib/document-ingest.mjs +6 -5
- package/lib/embed/cli.mjs +16 -3
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +38 -18
- package/lib/embed/daemon.mjs +59 -50
- package/lib/embed/inbox.mjs +30 -0
- package/lib/embed/recommendation-store.mjs +214 -15
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/evaluator-optimizer.mjs +0 -2
- package/lib/features.mjs +11 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +3 -5
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +119 -3
- package/lib/init-update.mjs +246 -131
- package/lib/install/first-invocation.mjs +4 -4
- package/lib/intake/queue.mjs +1 -1
- package/lib/integrations/intake-integrations.mjs +4 -5
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/rag.mjs +16 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-cheapest-provider.mjs +231 -0
- package/lib/model-router.mjs +68 -9
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/parity.mjs +124 -21
- package/lib/project-profile.mjs +1 -1
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/roles/catalog.mjs +133 -0
- package/lib/roles/preference.mjs +74 -0
- package/lib/server/index.mjs +109 -47
- package/lib/server/insights.mjs +1 -1
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +32 -24
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +2 -3
- package/lib/setup-prompts.mjs +2 -2
- package/lib/setup.mjs +55 -46
- package/lib/status.mjs +56 -8
- package/lib/storage/backend.mjs +12 -2
- package/lib/storage/postgres-backup.mjs +1 -1
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/worker/trace.mjs +17 -27
- package/package.json +5 -2
- package/rules/common/research.md +44 -12
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/data-analyst.product-intelligence.md +1 -1
- package/skills/roles/researcher.md +28 -15
- package/skills/routing.md +8 -1
- package/templates/docs/construct_guide.md +2 -2
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +7 -7
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* lib/mcp/tools/telemetry.mjs — Telemetry MCP tools: trace/score, session usage, efficiency snapshot.
|
|
3
3
|
*
|
|
4
4
|
* Exposes cxTrace, cxScore, sessionUsage, and efficiencySnapshot.
|
|
5
|
-
* Requires ROOT_DIR injected via opts.
|
|
5
|
+
* Requires ROOT_DIR injected via opts. Remote export uses the shared telemetry
|
|
6
|
+
* adapter when configured; local JSONL remains available by default.
|
|
6
7
|
*/
|
|
7
8
|
import { join, resolve } from 'node:path';
|
|
8
9
|
import { homedir } from 'node:os';
|
|
9
10
|
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
-
import {
|
|
11
|
+
import { createTelemetryClient } from '../../telemetry/client.mjs';
|
|
11
12
|
import { summarizePromptComposition } from '../../prompt-composer.js';
|
|
12
13
|
import { enrichMetadataWithPrompt } from '../../prompt-metadata.mjs';
|
|
13
14
|
import { readCurrentModels, resolveExecutionContractModelMetadata, selectModelTierForWorkCategory } from '../../model-router.mjs';
|
|
@@ -16,7 +17,6 @@ import { loadWorkflow } from '../../workflow-state.mjs';
|
|
|
16
17
|
import { buildStatus } from '../../status.mjs';
|
|
17
18
|
import { readEfficiencyLog, buildCompactEfficiencyDigest } from '../../efficiency.mjs';
|
|
18
19
|
import { addObservation } from '../../observation-store.mjs';
|
|
19
|
-
// Local mock ingest client for fallback when ingestion service is unavailable
|
|
20
20
|
import { loadConstructEnv } from '../../env-config.mjs';
|
|
21
21
|
|
|
22
22
|
// Load config.env once at module init so config.env values win over shell env
|
|
@@ -34,15 +34,6 @@ function readJSON(filePath) {
|
|
|
34
34
|
|
|
35
35
|
import { execSync as _execSync } from 'node:child_process';
|
|
36
36
|
|
|
37
|
-
// Mock ingest client for fallback to direct API when the real client is not available.
|
|
38
|
-
|
|
39
|
-
function getOrCreateIngestClient() {
|
|
40
|
-
return {
|
|
41
|
-
available: false,
|
|
42
|
-
trace: () => {}
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
37
|
function resolveReleaseTag(cwd) {
|
|
47
38
|
try {
|
|
48
39
|
return _execSync('git rev-parse --short HEAD', { stdio: 'pipe', cwd, timeout: 2000 }).toString().trim() || undefined;
|
|
@@ -51,18 +42,8 @@ function resolveReleaseTag(cwd) {
|
|
|
51
42
|
}
|
|
52
43
|
}
|
|
53
44
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
const secret = CONF_ENV.CONSTRUCT_TELEMETRY_SECRET_KEY ?? process.env.CONSTRUCT_TELEMETRY_SECRET_KEY;
|
|
57
|
-
if (!key || !secret) throw new Error('CONSTRUCT_TELEMETRY_PUBLIC_KEY and CONSTRUCT_TELEMETRY_SECRET_KEY must be set.');
|
|
58
|
-
return {
|
|
59
|
-
Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`,
|
|
60
|
-
'Content-Type': 'application/json',
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function telemetryBaseUrl() {
|
|
65
|
-
return (CONF_ENV.CONSTRUCT_TELEMETRY_URL ?? process.env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
|
|
45
|
+
function telemetryEnv() {
|
|
46
|
+
return { ...process.env, ...CONF_ENV };
|
|
66
47
|
}
|
|
67
48
|
|
|
68
49
|
function resolveSessionContext() {
|
|
@@ -128,10 +109,8 @@ export async function cxTrace(args, { ROOT_DIR }) {
|
|
|
128
109
|
}, { rootDir: ROOT_DIR });
|
|
129
110
|
const traceId = args.id ?? crypto.randomUUID();
|
|
130
111
|
try {
|
|
131
|
-
const available = await telemetryAvailable();
|
|
132
|
-
if (!available) return { ok: false, error: 'Telemetry credentials not configured', id: traceId };
|
|
133
112
|
const teamId = args.metadata?.teamId ?? metadata.teamId;
|
|
134
|
-
const workCategoryValue = route?.workCategory ??
|
|
113
|
+
const workCategoryValue = route?.workCategory ?? null;
|
|
135
114
|
const body = {
|
|
136
115
|
id: traceId,
|
|
137
116
|
name: args.name,
|
|
@@ -156,27 +135,17 @@ export async function cxTrace(args, { ROOT_DIR }) {
|
|
|
156
135
|
release: ctx.release,
|
|
157
136
|
};
|
|
158
137
|
|
|
159
|
-
|
|
160
|
-
body.metadata.version = 'v1';
|
|
161
|
-
|
|
162
|
-
// Use ingestion batch for creation (handles large payloads better than direct API)
|
|
163
|
-
const ingestClient = getOrCreateIngestClient();
|
|
164
|
-
if (ingestClient?.available) {
|
|
165
|
-
ingestClient.trace(body);
|
|
166
|
-
return { ok: true, id: traceId };
|
|
167
|
-
}
|
|
138
|
+
body.metadata.version = 'v1';
|
|
168
139
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
return { ok: true, id: traceId };
|
|
140
|
+
const client = createTelemetryClient({ env: telemetryEnv(), rootDir: ROOT_DIR });
|
|
141
|
+
client.trace(body);
|
|
142
|
+
await client.flush();
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
id: traceId,
|
|
146
|
+
backend: client.backend,
|
|
147
|
+
remoteStatus: client.remoteStatus,
|
|
148
|
+
};
|
|
180
149
|
} catch (err) {
|
|
181
150
|
return { ok: false, error: err.message, id: traceId };
|
|
182
151
|
}
|
|
@@ -193,25 +162,16 @@ export async function cxTraceUpdate(args) {
|
|
|
193
162
|
const metadata = args.metadata;
|
|
194
163
|
|
|
195
164
|
try {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
metadata: {
|
|
205
|
-
...(metadata && typeof metadata === 'object' ? metadata : {}),
|
|
206
|
-
traceUpdatedAt: new Date().toISOString(),
|
|
207
|
-
},
|
|
208
|
-
}),
|
|
165
|
+
const client = createTelemetryClient({ env: telemetryEnv(), rootDir: process.cwd() });
|
|
166
|
+
client.traceUpdate({
|
|
167
|
+
id: traceId,
|
|
168
|
+
output,
|
|
169
|
+
metadata: {
|
|
170
|
+
...(metadata && typeof metadata === 'object' ? metadata : {}),
|
|
171
|
+
traceUpdatedAt: new Date().toISOString(),
|
|
172
|
+
},
|
|
209
173
|
});
|
|
210
|
-
|
|
211
|
-
if (!res.ok) {
|
|
212
|
-
const text = await res.text().catch(() => '');
|
|
213
|
-
return { ok: false, error: `Telemetry PATCH error ${res.status}: ${text}` };
|
|
214
|
-
}
|
|
174
|
+
await client.flush();
|
|
215
175
|
|
|
216
176
|
// Also record as observation for local learning
|
|
217
177
|
if (output || metadata) {
|
|
@@ -227,7 +187,7 @@ export async function cxTraceUpdate(args) {
|
|
|
227
187
|
});
|
|
228
188
|
}
|
|
229
189
|
|
|
230
|
-
return { ok: true, traceId };
|
|
190
|
+
return { ok: true, traceId, backend: client.backend, remoteStatus: client.remoteStatus };
|
|
231
191
|
} catch (err) {
|
|
232
192
|
return { ok: false, error: err.message };
|
|
233
193
|
}
|
|
@@ -240,8 +200,6 @@ const SCORE_GOOD_THRESHOLD = 0.85; // at or above this → record positive pat
|
|
|
240
200
|
export async function cxScore(args) {
|
|
241
201
|
const traceId = args.trace_id ?? '';
|
|
242
202
|
try {
|
|
243
|
-
const available = await telemetryAvailable();
|
|
244
|
-
if (!available) return { ok: false, error: 'Telemetry credentials not configured' };
|
|
245
203
|
const body = {
|
|
246
204
|
id: crypto.randomUUID(),
|
|
247
205
|
traceId,
|
|
@@ -250,15 +208,9 @@ export async function cxScore(args) {
|
|
|
250
208
|
dataType: 'NUMERIC',
|
|
251
209
|
comment: args.comment,
|
|
252
210
|
};
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
body: JSON.stringify(body),
|
|
257
|
-
});
|
|
258
|
-
if (!res.ok) {
|
|
259
|
-
const text = await res.text().catch(() => '');
|
|
260
|
-
return { ok: false, error: `Telemetry API error ${res.status}: ${text}` };
|
|
261
|
-
}
|
|
211
|
+
const client = createTelemetryClient({ env: telemetryEnv(), rootDir: process.cwd() });
|
|
212
|
+
client.score(body);
|
|
213
|
+
await client.flush();
|
|
262
214
|
|
|
263
215
|
// Feed score back into the local observation store so future agents learn from it.
|
|
264
216
|
// Low scores generate anti-pattern observations; high scores reinforce positive patterns.
|
|
@@ -291,7 +243,7 @@ export async function cxScore(args) {
|
|
|
291
243
|
}
|
|
292
244
|
}
|
|
293
245
|
|
|
294
|
-
return { ok: true, traceId };
|
|
246
|
+
return { ok: true, traceId, backend: client.backend, remoteStatus: client.remoteStatus };
|
|
295
247
|
} catch (err) {
|
|
296
248
|
return { ok: false, error: err.message };
|
|
297
249
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/model-cheapest-provider.mjs — cheapest configured provider selection.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates all configured providers, looks up their per-tier model pricing,
|
|
5
|
+
* and returns the lowest-cost option. Local providers (Ollama, local) rank
|
|
6
|
+
* first since they are $0.
|
|
7
|
+
*
|
|
8
|
+
* Pricing data comes from getPricingForModels() in model-pricing.mjs, which
|
|
9
|
+
* has a 5-minute cache from the OpenRouter API. Static tables cover
|
|
10
|
+
* Anthropic/OpenAI first-party models.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* selectCheapestProvider('standard') // cheapest for one tier
|
|
14
|
+
* rankConfiguredProvidersByCost('fast') // full ranked list
|
|
15
|
+
* isCheapestProviderEnabled() // check opt-in preference
|
|
16
|
+
* formatCheapestProviderMessage(result) // user-facing output
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { getProviderModelCatalog, PROVIDER_FAMILY_TIERS } from './model-router.mjs';
|
|
23
|
+
import { getPricingForModels, formatPricingLabel } from './model-pricing.mjs';
|
|
24
|
+
|
|
25
|
+
const CHEAPEST_PREF_KEY = 'CHEAPEST_PROVIDER_ENABLED';
|
|
26
|
+
const CHEAPEST_CHECKED_KEY = 'CHEAPEST_PROVIDER_CHECKED';
|
|
27
|
+
const ENV_PATH = path.join(os.homedir(), '.construct', 'config.env');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the cheapest configured provider for a given tier.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} tier - One of "reasoning", "standard", "fast"
|
|
33
|
+
* @param {object} [opts]
|
|
34
|
+
* @param {object} [opts.env] - Environment object (default: process.env)
|
|
35
|
+
* @param {string} [opts.envPath] - Path to config.env (default: ~/.construct/config.env)
|
|
36
|
+
* @param {Function} [opts.getPricingForModels] - Injectable pricing fetcher
|
|
37
|
+
* @returns {Promise<{
|
|
38
|
+
* providerId: string|null,
|
|
39
|
+
* providerLabel: string|null,
|
|
40
|
+
* modelId: string|null,
|
|
41
|
+
* inputPrice: number|null,
|
|
42
|
+
* outputPrice: number|null,
|
|
43
|
+
* isLocal: boolean,
|
|
44
|
+
* configuredProviders: string[],
|
|
45
|
+
* rankedList: Array<{providerId, providerLabel, modelId, inputPrice, outputPrice, totalPer1M, isLocal}>
|
|
46
|
+
* }>}
|
|
47
|
+
*/
|
|
48
|
+
export async function selectCheapestProvider(tier, opts = {}) {
|
|
49
|
+
if (!['reasoning', 'standard', 'fast'].includes(tier)) {
|
|
50
|
+
return { providerId: null, providerLabel: null, modelId: null, configuredProviders: [], rankedList: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const env = opts.env || process.env;
|
|
54
|
+
const catalog = getProviderModelCatalog({ env });
|
|
55
|
+
const configured = catalog.providers.filter((p) => p.configured);
|
|
56
|
+
|
|
57
|
+
if (configured.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
providerId: null, providerLabel: null, modelId: null,
|
|
60
|
+
inputPrice: null, outputPrice: null, isLocal: false,
|
|
61
|
+
configuredProviders: [], rankedList: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Collect tier-specific model IDs from all configured providers
|
|
66
|
+
const modelIds = [];
|
|
67
|
+
for (const p of configured) {
|
|
68
|
+
const modelId = p.tiers[tier];
|
|
69
|
+
if (modelId) modelIds.push(modelId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const needsRemote = modelIds.filter((id) => /^openrouter\//.test(id));
|
|
73
|
+
const hasRemote = needsRemote.length > 0;
|
|
74
|
+
|
|
75
|
+
// Fetch pricing (uses 5-min cache internally)
|
|
76
|
+
let pricingMap = {};
|
|
77
|
+
if (hasRemote) {
|
|
78
|
+
try {
|
|
79
|
+
pricingMap = await getPricingForModels(modelIds, {
|
|
80
|
+
getPricingForModels: opts.getPricingForModels,
|
|
81
|
+
});
|
|
82
|
+
} catch { /* pricing unavailable — fall back to nulls */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Score each provider
|
|
86
|
+
const scored = configured.map((p) => {
|
|
87
|
+
const modelId = p.tiers[tier];
|
|
88
|
+
const pricing = hasRemote ? pricingMap[modelId] : null;
|
|
89
|
+
const inputPrice = pricing ? (Number(pricing.input) || 0) : 0;
|
|
90
|
+
const outputPrice = pricing ? (Number(pricing.output) || 0) : 0;
|
|
91
|
+
return {
|
|
92
|
+
providerId: p.id,
|
|
93
|
+
providerLabel: p.label,
|
|
94
|
+
modelId,
|
|
95
|
+
inputPrice,
|
|
96
|
+
outputPrice,
|
|
97
|
+
totalPer1M: inputPrice + outputPrice,
|
|
98
|
+
isLocal: p.local === true,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Sort ascending by total cost (local/$0 first)
|
|
103
|
+
scored.sort((a, b) => a.totalPer1M - b.totalPer1M);
|
|
104
|
+
|
|
105
|
+
const cheapest = scored[0];
|
|
106
|
+
return {
|
|
107
|
+
providerId: cheapest.providerId,
|
|
108
|
+
providerLabel: cheapest.providerLabel,
|
|
109
|
+
modelId: cheapest.modelId,
|
|
110
|
+
inputPrice: cheapest.inputPrice,
|
|
111
|
+
outputPrice: cheapest.outputPrice,
|
|
112
|
+
isLocal: cheapest.isLocal,
|
|
113
|
+
configuredProviders: configured.map((p) => p.id),
|
|
114
|
+
rankedList: scored,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get all configured providers ranked by cost for a tier.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} tier
|
|
122
|
+
* @param {object} [opts]
|
|
123
|
+
* @returns {Promise<Array<{providerId, providerLabel, modelId, inputPrice, outputPrice, totalPer1M, isLocal}>>}
|
|
124
|
+
*/
|
|
125
|
+
export async function rankConfiguredProvidersByCost(tier, opts = {}) {
|
|
126
|
+
const result = await selectCheapestProvider(tier, opts);
|
|
127
|
+
return result.rankedList;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check if the user has opted into cheapest-provider selection.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} [envPath] - Path to config.env (default: ~/.construct/config.env)
|
|
134
|
+
* @param {object} [opts.env] - Environment override
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function isCheapestProviderEnabled(envPath, opts = {}) {
|
|
138
|
+
const pathToUse = envPath || ENV_PATH;
|
|
139
|
+
const env = opts.env || process.env;
|
|
140
|
+
|
|
141
|
+
// Check env var first
|
|
142
|
+
if (env[CHEAPEST_PREF_KEY]) {
|
|
143
|
+
return env[CHEAPEST_PREF_KEY].toLowerCase() === 'yes';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check config.env file
|
|
147
|
+
try {
|
|
148
|
+
const content = fs.readFileSync(pathToUse, 'utf8');
|
|
149
|
+
const match = content.match(new RegExp(`^${CHEAPEST_PREF_KEY}=(.+)$`, 'm'));
|
|
150
|
+
if (match) {
|
|
151
|
+
return match[1].trim().toLowerCase() === 'yes';
|
|
152
|
+
}
|
|
153
|
+
} catch { /* file doesn't exist */ }
|
|
154
|
+
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Persist the cheapest-provider opt-in preference.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} envPath - Path to config.env
|
|
162
|
+
* @param {boolean} enabled
|
|
163
|
+
*/
|
|
164
|
+
export function setCheapestProviderPreference(envPath, enabled) {
|
|
165
|
+
const pathToUse = envPath || ENV_PATH;
|
|
166
|
+
const existing = fs.existsSync(pathToUse) ? fs.readFileSync(pathToUse, 'utf8') : '';
|
|
167
|
+
const lines = existing.split('\n');
|
|
168
|
+
const keyLine = `${CHEAPEST_PREF_KEY}=${enabled ? 'yes' : 'no'}`;
|
|
169
|
+
|
|
170
|
+
// Replace existing key or append
|
|
171
|
+
const idx = lines.findIndex((l) => l.startsWith(`${CHEAPEST_PREF_KEY}=`));
|
|
172
|
+
if (idx >= 0) {
|
|
173
|
+
lines[idx] = keyLine;
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(keyLine);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fs.mkdirSync(path.dirname(pathToUse), { recursive: true });
|
|
179
|
+
fs.writeFileSync(pathToUse, lines.join('\n'));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Format the cheapest provider selection into a user-facing message.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} result - Output from selectCheapestProvider
|
|
186
|
+
* @param {object} [opts]
|
|
187
|
+
* @param {boolean} [opts.showRanking] - Include full ranked list
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
export function formatCheapestProviderMessage(result, opts = {}) {
|
|
191
|
+
const { providerId, providerLabel, modelId, inputPrice, outputPrice, isLocal, rankedList } = result;
|
|
192
|
+
|
|
193
|
+
if (!providerId) {
|
|
194
|
+
return 'No configured providers found. Set API keys or install Ollama to enable model selection.';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const label = formatPricingLabel({ input: inputPrice, output: outputPrice, source: isLocal ? 'local' : 'openrouter' });
|
|
198
|
+
let msg = `Cheapest provider for this tier:\n`;
|
|
199
|
+
msg += ` Provider: ${providerLabel}\n`;
|
|
200
|
+
msg += ` Model: ${modelId}\n`;
|
|
201
|
+
msg += ` Pricing: ${label}`;
|
|
202
|
+
|
|
203
|
+
if (opts.showRanking && rankedList && rankedList.length > 0) {
|
|
204
|
+
msg += `\n\nConfigured providers ranked by cost:\n`;
|
|
205
|
+
for (let i = 0; i < rankedList.length; i++) {
|
|
206
|
+
const r = rankedList[i];
|
|
207
|
+
const rLabel = formatPricingLabel({
|
|
208
|
+
input: r.inputPrice,
|
|
209
|
+
output: r.outputPrice,
|
|
210
|
+
source: r.isLocal ? 'local' : 'openrouter',
|
|
211
|
+
});
|
|
212
|
+
const marker = i === 0 ? '→' : ' ';
|
|
213
|
+
msg += ` ${marker} ${r.providerLabel.padEnd(28)} ${r.modelId.padEnd(40)} ${rLabel}\n`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return msg;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get all tier-specific cheapest selections.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} [opts]
|
|
224
|
+
* @returns {Promise<{reasoning, standard, fast}>}
|
|
225
|
+
*/
|
|
226
|
+
export async function selectCheapestForAllTiers(opts = {}) {
|
|
227
|
+
const reasoning = await selectCheapestProvider('reasoning', opts);
|
|
228
|
+
const standard = await selectCheapestProvider('standard', opts);
|
|
229
|
+
const fast = await selectCheapestProvider('fast', opts);
|
|
230
|
+
return { reasoning, standard, fast };
|
|
231
|
+
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -45,6 +45,61 @@ export const MODEL_TIER_BY_WORK_CATEGORY = {
|
|
|
45
45
|
analysis: "standard",
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
export const MODEL_OPERATING_PROFILES = Object.freeze({
|
|
49
|
+
balanced: {
|
|
50
|
+
id: 'balanced',
|
|
51
|
+
label: 'Balanced',
|
|
52
|
+
maxPromptTokens: 3000,
|
|
53
|
+
learnedPatternsTokens: 200,
|
|
54
|
+
taskPacketTokens: 150,
|
|
55
|
+
contextDigestTokens: 200,
|
|
56
|
+
hostConstraintsTokens: 75,
|
|
57
|
+
roleFlavorTokens: 600,
|
|
58
|
+
retrievalFirst: false,
|
|
59
|
+
preferCompressedRoleGuidance: false,
|
|
60
|
+
},
|
|
61
|
+
small: {
|
|
62
|
+
id: 'small',
|
|
63
|
+
label: 'Small-model',
|
|
64
|
+
maxPromptTokens: 1800,
|
|
65
|
+
learnedPatternsTokens: 120,
|
|
66
|
+
taskPacketTokens: 110,
|
|
67
|
+
contextDigestTokens: 120,
|
|
68
|
+
hostConstraintsTokens: 40,
|
|
69
|
+
roleFlavorTokens: 280,
|
|
70
|
+
retrievalFirst: true,
|
|
71
|
+
preferCompressedRoleGuidance: true,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function normalizeModelOperatingProfile(value) {
|
|
76
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
77
|
+
if (!normalized) return null;
|
|
78
|
+
if (normalized === 'default') return 'balanced';
|
|
79
|
+
return MODEL_OPERATING_PROFILES[normalized] ? normalized : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inferSmallModelProfile(selectedModel) {
|
|
83
|
+
const model = String(selectedModel || '').toLowerCase();
|
|
84
|
+
if (!model) return false;
|
|
85
|
+
if (/^(ollama|local)\//.test(model) && /(?:[:/-])(3b|7b|8b|13b|14b|32b)\b/.test(model)) return true;
|
|
86
|
+
if (/^(anthropic|openrouter\/anthropic)\/.*haiku/.test(model)) return true;
|
|
87
|
+
if (/gpt-5\.1-mini|gemma-3|gemma-4|phi3:mini/.test(model)) return true;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveModelOperatingProfile({
|
|
92
|
+
envValues = {},
|
|
93
|
+
selectedModel = null,
|
|
94
|
+
} = {}) {
|
|
95
|
+
const explicit = normalizeModelOperatingProfile(
|
|
96
|
+
envValues.CONSTRUCT_MODEL_PROFILE ?? envValues.constructModelProfile
|
|
97
|
+
);
|
|
98
|
+
if (explicit) return MODEL_OPERATING_PROFILES[explicit];
|
|
99
|
+
if (inferSmallModelProfile(selectedModel)) return MODEL_OPERATING_PROFILES.small;
|
|
100
|
+
return MODEL_OPERATING_PROFILES.balanced;
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
/**
|
|
49
104
|
* Provider-family definitions. Each entry contains:
|
|
50
105
|
* - `test`: RegExp that matches provider URLs.
|
|
@@ -217,9 +272,8 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
217
272
|
export { PROVIDER_FAMILY_TIERS };
|
|
218
273
|
|
|
219
274
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* mark which providers are available.
|
|
275
|
+
* Maps provider family IDs to the env var(s) that confirm credentials are present.
|
|
276
|
+
* Consumed by getProviderModelCatalog to mark which providers are available.
|
|
223
277
|
*/
|
|
224
278
|
const PROVIDER_ENV_MAP = {
|
|
225
279
|
'anthropic': ['ANTHROPIC_API_KEY'],
|
|
@@ -230,7 +284,7 @@ const PROVIDER_ENV_MAP = {
|
|
|
230
284
|
'github-copilot': ['GITHUB_TOKEN', 'GH_TOKEN'],
|
|
231
285
|
'openai': ['OPENAI_API_KEY'],
|
|
232
286
|
'openrouter-llama': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
233
|
-
'ollama': ['OLLAMA_BASE_URL'],
|
|
287
|
+
'ollama': ['OLLAMA_BASE_URL', 'OLLAMA_HOST'],
|
|
234
288
|
'local': ['LOCAL_LLM_BASE_URL'],
|
|
235
289
|
};
|
|
236
290
|
|
|
@@ -254,14 +308,14 @@ function isProviderConfigured(familyId, env) {
|
|
|
254
308
|
} catch { /* not available */ }
|
|
255
309
|
}
|
|
256
310
|
|
|
257
|
-
//
|
|
311
|
+
// Check passed env (usually process.env)
|
|
258
312
|
const anyInEnv = varNames.some(name => {
|
|
259
313
|
const val = env?.[name];
|
|
260
314
|
return val && typeof val === 'string' && val.length > 0;
|
|
261
315
|
});
|
|
262
316
|
if (anyInEnv) return true;
|
|
263
317
|
|
|
264
|
-
//
|
|
318
|
+
// Check construct config.env
|
|
265
319
|
try {
|
|
266
320
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
267
321
|
const cfgPath = path.join(homeDir, '.construct', 'config.env');
|
|
@@ -276,7 +330,7 @@ function isProviderConfigured(familyId, env) {
|
|
|
276
330
|
}
|
|
277
331
|
} catch { /* skip */ }
|
|
278
332
|
|
|
279
|
-
//
|
|
333
|
+
// GitHub Copilot: detect via gh CLI auth status
|
|
280
334
|
if (familyId === 'github-copilot') {
|
|
281
335
|
try {
|
|
282
336
|
const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 3000 });
|
|
@@ -284,7 +338,7 @@ function isProviderConfigured(familyId, env) {
|
|
|
284
338
|
} catch { return false; }
|
|
285
339
|
}
|
|
286
340
|
|
|
287
|
-
//
|
|
341
|
+
// Check ~/.env
|
|
288
342
|
try {
|
|
289
343
|
const homeEnv = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.env');
|
|
290
344
|
if (fs.existsSync(homeEnv)) {
|
|
@@ -298,7 +352,7 @@ function isProviderConfigured(familyId, env) {
|
|
|
298
352
|
}
|
|
299
353
|
} catch { /* skip */ }
|
|
300
354
|
|
|
301
|
-
//
|
|
355
|
+
// Check shell rc files for op:// refs (1Password CLI integration)
|
|
302
356
|
try {
|
|
303
357
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
304
358
|
const shellFiles = ['.zshrc', '.bashrc', '.bash_profile', '.profile'].map(f => path.join(homeDir, f));
|
|
@@ -631,6 +685,10 @@ export function resolveExecutionContractModelMetadata({
|
|
|
631
685
|
const tiers = resolveTierAssignments(envValues, registryModels);
|
|
632
686
|
const selectedTier = requestedTier ?? selectModelTierForWorkCategory(workCategory);
|
|
633
687
|
const selected = selectedTier ? tiers[selectedTier] : null;
|
|
688
|
+
const profile = resolveModelOperatingProfile({
|
|
689
|
+
envValues,
|
|
690
|
+
selectedModel: selected?.model ?? null,
|
|
691
|
+
});
|
|
634
692
|
|
|
635
693
|
return {
|
|
636
694
|
version: "v1",
|
|
@@ -639,6 +697,7 @@ export function resolveExecutionContractModelMetadata({
|
|
|
639
697
|
selectedTier: selectedTier ?? null,
|
|
640
698
|
selectedModel: selected?.model ?? null,
|
|
641
699
|
selectedModelSource: selected?.source ?? null,
|
|
700
|
+
profile,
|
|
642
701
|
tiers,
|
|
643
702
|
};
|
|
644
703
|
}
|
package/lib/ollama-manager.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { execSync } from 'node:child_process';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
|
|
13
|
-
const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
13
|
+
const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || process.env.OLLAMA_HOST || 'http://localhost:11434';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Check if Ollama is installed and running
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* trace/generation/span/event observations, and flushes on session.idle.
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { createTelemetryClient } from "./telemetry/client.mjs";
|
|
9
9
|
|
|
10
10
|
const SECRET_PATH_PATTERNS = [
|
|
11
11
|
/(^|\/)\.env(\.|$)/i,
|
|
@@ -24,10 +24,9 @@ let cachedIngest = null;
|
|
|
24
24
|
|
|
25
25
|
export function getIngestClient(env = process.env) {
|
|
26
26
|
if (cachedIngest) return cachedIngest;
|
|
27
|
-
cachedIngest =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
secretKey: env.CONSTRUCT_TELEMETRY_SECRET_KEY,
|
|
27
|
+
cachedIngest = createTelemetryClient({
|
|
28
|
+
env,
|
|
29
|
+
rootDir: env.CX_TOOLKIT_DIR,
|
|
31
30
|
onError: (err) => {
|
|
32
31
|
if (env.CONSTRUCT_TRACE_DEBUG === "1") {
|
|
33
32
|
// eslint-disable-next-line no-console
|
|
@@ -324,6 +324,14 @@ export function classifyDataEngineerFlavor(request = '') {
|
|
|
324
324
|
return null;
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
export function classifyEngineerFlavor(request = '') {
|
|
328
|
+
const text = String(request).toLowerCase();
|
|
329
|
+
if (containsAny(text, ['llm', 'agent', 'prompt', 'eval', 'hallucination', 'rag', 'model behavior', 'model routing'])) return 'ai';
|
|
330
|
+
if (containsAny(text, ['vector', 'embedding', 'retrieval', 'warehouse', 'etl', 'elt', 'pipeline', 'streaming', 'data contract'])) return 'data';
|
|
331
|
+
if (containsAny(text, ['ci', 'build', 'deploy', 'docker', 'kubernetes', 'terraform', 'helm', 'release pipeline', 'platform tooling', 'developer experience'])) return 'platform';
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
327
335
|
export function isDataAnalysisRequest(request = '') {
|
|
328
336
|
return Boolean(classifyDataAnalystFlavor(request));
|
|
329
337
|
}
|
|
@@ -406,6 +414,7 @@ export function isVisualDeliverableRequest(request = '') {
|
|
|
406
414
|
|
|
407
415
|
export function classifyRoleFlavors(request = '') {
|
|
408
416
|
return {
|
|
417
|
+
engineer: classifyEngineerFlavor(request),
|
|
409
418
|
architect: classifyArchitectFlavor(request),
|
|
410
419
|
productManager: isProductIntelligenceRequest(request) ? classifyProductManagerFlavor(request) : null,
|
|
411
420
|
qa: classifyQaFlavor(request),
|