@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.
Files changed (97) hide show
  1. package/.env.example +1 -1
  2. package/README.md +4 -4
  3. package/agents/prompts/cx-ai-engineer.md +6 -26
  4. package/agents/prompts/cx-architect.md +1 -0
  5. package/agents/prompts/cx-business-strategist.md +2 -0
  6. package/agents/prompts/cx-data-analyst.md +6 -26
  7. package/agents/prompts/cx-docs-keeper.md +1 -31
  8. package/agents/prompts/cx-explorer.md +1 -0
  9. package/agents/prompts/cx-orchestrator.md +40 -112
  10. package/agents/prompts/cx-platform-engineer.md +2 -22
  11. package/agents/prompts/cx-product-manager.md +2 -1
  12. package/agents/prompts/cx-qa.md +0 -20
  13. package/agents/prompts/cx-rd-lead.md +2 -0
  14. package/agents/prompts/cx-researcher.md +77 -31
  15. package/agents/prompts/cx-security.md +11 -49
  16. package/agents/prompts/cx-sre.md +9 -43
  17. package/agents/prompts/cx-ux-researcher.md +1 -0
  18. package/agents/role-manifests.json +4 -4
  19. package/bin/construct +72 -11
  20. package/bin/construct-postinstall.mjs +1 -1
  21. package/db/schema/004_recommendations.sql +46 -0
  22. package/db/schema/005_strategy.sql +21 -0
  23. package/lib/auto-docs.mjs +1 -2
  24. package/lib/beads-automation.mjs +16 -7
  25. package/lib/bootstrap/resources.mjs +2 -2
  26. package/lib/cli-commands.mjs +8 -2
  27. package/lib/document-ingest.mjs +6 -5
  28. package/lib/embed/cli.mjs +16 -3
  29. package/lib/embed/conflict-detection.mjs +26 -9
  30. package/lib/embed/customer-profiles.mjs +38 -18
  31. package/lib/embed/daemon.mjs +59 -50
  32. package/lib/embed/inbox.mjs +30 -0
  33. package/lib/embed/recommendation-store.mjs +214 -15
  34. package/lib/embed/workspaces.mjs +53 -18
  35. package/lib/evaluator-optimizer.mjs +0 -2
  36. package/lib/features.mjs +11 -0
  37. package/lib/gates-audit.mjs +3 -3
  38. package/lib/health-check.mjs +3 -5
  39. package/lib/hooks/pre-compact.mjs +3 -0
  40. package/lib/hooks/read-tracker.mjs +10 -101
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +119 -3
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/install/first-invocation.mjs +4 -4
  45. package/lib/intake/queue.mjs +1 -1
  46. package/lib/integrations/intake-integrations.mjs +4 -5
  47. package/lib/intent-classifier.mjs +1 -0
  48. package/lib/knowledge/layout.mjs +10 -0
  49. package/lib/knowledge/rag.mjs +16 -0
  50. package/lib/mcp/tools/telemetry.mjs +30 -78
  51. package/lib/model-cheapest-provider.mjs +231 -0
  52. package/lib/model-router.mjs +68 -9
  53. package/lib/ollama-manager.mjs +1 -1
  54. package/lib/opencode-telemetry.mjs +4 -5
  55. package/lib/orchestration-policy.mjs +9 -0
  56. package/lib/parity.mjs +124 -21
  57. package/lib/project-profile.mjs +1 -1
  58. package/lib/prompt-composer.js +106 -29
  59. package/lib/read-tracker-store.mjs +149 -0
  60. package/lib/roles/catalog.mjs +133 -0
  61. package/lib/roles/preference.mjs +74 -0
  62. package/lib/server/index.mjs +109 -47
  63. package/lib/server/insights.mjs +1 -1
  64. package/lib/server/telemetry-login.mjs +17 -25
  65. package/lib/service-manager.mjs +32 -24
  66. package/lib/services/local-postgres.mjs +15 -0
  67. package/lib/services/telemetry-backend.mjs +2 -3
  68. package/lib/setup-prompts.mjs +2 -2
  69. package/lib/setup.mjs +55 -46
  70. package/lib/status.mjs +56 -8
  71. package/lib/storage/backend.mjs +12 -2
  72. package/lib/storage/postgres-backup.mjs +1 -1
  73. package/lib/strategy-store.mjs +371 -0
  74. package/lib/telemetry/backends/local.mjs +6 -4
  75. package/lib/telemetry/client.mjs +185 -0
  76. package/lib/telemetry/ingest.mjs +13 -5
  77. package/lib/telemetry/team-rollup.mjs +9 -2
  78. package/lib/uninstall/uninstall.mjs +1 -1
  79. package/lib/worker/trace.mjs +17 -27
  80. package/package.json +5 -2
  81. package/rules/common/research.md +44 -12
  82. package/skills/docs/backlog-proposal-workflow.md +2 -2
  83. package/skills/docs/customer-profile-workflow.md +1 -1
  84. package/skills/docs/evidence-ingest-workflow.md +5 -5
  85. package/skills/docs/prfaq-workflow.md +1 -1
  86. package/skills/docs/product-intelligence-review.md +1 -1
  87. package/skills/docs/product-intelligence-workflow.md +3 -3
  88. package/skills/docs/product-signal-workflow.md +48 -18
  89. package/skills/docs/research-workflow.md +26 -14
  90. package/skills/docs/strategy-workflow.md +36 -0
  91. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  92. package/skills/roles/researcher.md +28 -15
  93. package/skills/routing.md +8 -1
  94. package/templates/docs/construct_guide.md +2 -2
  95. package/templates/docs/research-brief.md +63 -9
  96. package/templates/docs/strategy.md +36 -0
  97. 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. Telemetry credentials must be in env.
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 { isAvailable as telemetryAvailable } from '../../telemetry/backends/remote.mjs';
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 telemetryHeaders() {
55
- const key = CONF_ENV.CONSTRUCT_TELEMETRY_PUBLIC_KEY ?? process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
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 ?? (typeof args.input === 'string' ? classifyWorkCategory({ request: args.input }) : null);
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
- // Add version to metadata for test compatibility
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
- // Fallback: direct API call
170
- const res = await fetch(`${telemetryBaseUrl()}/api/public/traces`, {
171
- method: 'POST',
172
- headers: telemetryHeaders(),
173
- body: JSON.stringify(body),
174
- });
175
- if (!res.ok) {
176
- const text = await res.text().catch(() => '');
177
- return { ok: false, error: `Telemetry API error ${res.status}: ${text}`, id: traceId };
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 available = await telemetryAvailable();
197
- if (!available) return { ok: false, error: 'Telemetry credentials not configured' };
198
-
199
- const res = await fetch(`${telemetryBaseUrl()}/api/public/traces/${traceId}`, {
200
- method: 'PATCH',
201
- headers: telemetryHeaders(),
202
- body: JSON.stringify({
203
- output,
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 res = await fetch(`${telemetryBaseUrl()}/api/public/scores`, {
254
- method: 'POST',
255
- headers: telemetryHeaders(),
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
+ }
@@ -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
- * Map provider family IDs to the env var(s) that indicate the provider
221
- * is configured (has credentials). Used by getProviderModelCatalog to
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
- // 1. Check passed env (usually process.env)
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
- // 2. Check construct config.env
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
- // 3. Special case: GitHub Copilot via gh CLI
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
- // 4. Check ~/.env
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
- // 5. Check shell rc files for op:// refs (1Password CLI integration)
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
  }
@@ -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 { createIngestClient } from "./telemetry/ingest.mjs";
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 = createIngestClient({
28
- baseUrl: env.CONSTRUCT_TELEMETRY_URL,
29
- publicKey: env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
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),