@geraldmaron/construct 1.0.1 → 1.0.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/bin/construct CHANGED
@@ -160,7 +160,7 @@ async function showInteractiveMenu() {
160
160
 
161
161
  // Show context-aware suggestions
162
162
  if (isConstructProject) {
163
- const { readDashboardState } = await import('./lib/service-manager.mjs');
163
+ const { readDashboardState } = await import('../lib/service-manager.mjs');
164
164
  const dashboard = readDashboardState(HOME);
165
165
 
166
166
  if (!dashboard) {
@@ -288,12 +288,12 @@ async function cmdDoctor() {
288
288
  for (const varName of vars) {
289
289
  const sources = [];
290
290
 
291
- // 1. process.env
291
+ // process.env
292
292
  if (process.env[varName]) {
293
293
  sources.push(`process.env (${process.env[varName].slice(0, 8)}…)`);
294
294
  }
295
295
 
296
- // 2. .env files
296
+ // .env files
297
297
  for (const envPath of [join(process.cwd(), '.env'), join(homedir(), '.env'), join(homedir(), '.construct', 'config.env')]) {
298
298
  if (fs.existsSync(envPath)) {
299
299
  try {
@@ -304,7 +304,7 @@ async function cmdDoctor() {
304
304
  }
305
305
  }
306
306
 
307
- // 3. Shell rc files
307
+ // Shell rc files
308
308
  const shellFiles = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc'), join(homedir(), '.bash_profile'), join(homedir(), '.profile')];
309
309
  for (const rcPath of shellFiles) {
310
310
  if (!fs.existsSync(rcPath)) continue;
@@ -904,9 +904,9 @@ async function cmdUp() {
904
904
  if (embedConfigExists && autoEmbed) {
905
905
  try {
906
906
  const { runEmbedCli } = await import('../lib/embed/cli.mjs');
907
- await runEmbedCli(['start']);
908
- } catch {
909
- // best effort
907
+ await runEmbedCli(['start'], { rootDir: ROOT_DIR });
908
+ } catch (err) {
909
+ warn(`embed start failed: ${err.message} — check ~/.cx/runtime/embed-daemon.log`);
910
910
  }
911
911
  }
912
912
 
@@ -2449,6 +2449,44 @@ async function cmdModels(args) {
2449
2449
  await cmdSync([]);
2450
2450
  return;
2451
2451
  }
2452
+ if (args.includes('--cheapest')) {
2453
+ const { selectCheapestProvider, rankConfiguredProvidersByCost, formatCheapestProviderMessage, isCheapestProviderEnabled } =
2454
+ await import('../lib/model-cheapest-provider.mjs');
2455
+ const cheapestTier = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1] ?? 'standard';
2456
+ const verbose = args.includes('--verbose');
2457
+ const enabled = isCheapestProviderEnabled(envPath, { env: process.env });
2458
+ const result = await selectCheapestProvider(cheapestTier, { env: process.env });
2459
+ const msg = formatCheapestProviderMessage(result, { showRanking: verbose });
2460
+ println(msg);
2461
+ if (enabled) println('\nCheapest provider mode: enabled');
2462
+ return;
2463
+ }
2464
+ if (args.includes('--apply-cheapest')) {
2465
+ const { selectCheapestForAllTiers, setCheapestProviderPreference } =
2466
+ await import('../lib/model-cheapest-provider.mjs');
2467
+ const allTiers = args.includes('--all-tiers') || !args.find((arg) => arg.startsWith('--tier='));
2468
+ const tierArg = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1];
2469
+ const tiers = allTiers ? ['reasoning', 'standard', 'fast'] : (tierArg ? [tierArg] : ['standard']);
2470
+ const selections = {};
2471
+ for (const currentTier of tiers) {
2472
+ const { selectCheapestProvider } = await import('../lib/model-cheapest-provider.mjs');
2473
+ const result = await selectCheapestProvider(currentTier, { env: process.env });
2474
+ if (result.modelId) selections[currentTier] = result.modelId;
2475
+ }
2476
+ if (Object.keys(selections).length === 0) {
2477
+ errorln('No configured providers found. Set API keys or install Ollama first.');
2478
+ process.exit(1);
2479
+ }
2480
+ applyToEnv(envPath, selections);
2481
+ println('Applying cheapest models:');
2482
+ for (const [tier, model] of Object.entries(selections)) {
2483
+ println(` ${tier.padEnd(11)} ${model}`);
2484
+ }
2485
+ println('Written to ~/.construct/config.env. Running construct sync...');
2486
+ setCheapestProviderPreference(envPath, true);
2487
+ await cmdSync([]);
2488
+ return;
2489
+ }
2452
2490
  const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
2453
2491
  const result = readCurrentModels(envPath, registry.models ?? {});
2454
2492
  const nothingSelected = ['reasoning', 'standard', 'fast'].every((t) => !result[t]);
@@ -77,7 +77,7 @@ function renderMarkdown({ sourcePath, extractedAt, title, extractionMethod, char
77
77
  return lines.join('\n');
78
78
  }
79
79
 
80
- function collectInputFiles(inputPath) {
80
+ function collectInputFiles(inputPath, { maxDepth = 10 } = {}) {
81
81
  const resolvedPath = resolve(inputPath);
82
82
  if (!existsSync(resolvedPath)) throw new Error(`Input path not found: ${resolvedPath}`);
83
83
 
@@ -90,13 +90,14 @@ function collectInputFiles(inputPath) {
90
90
  if (!stat.isDirectory()) return [];
91
91
 
92
92
  const files = [];
93
- const stack = [resolvedPath];
93
+ const stack = [{ path: resolvedPath, depth: 0 }];
94
94
  while (stack.length > 0) {
95
- const current = stack.pop();
95
+ const { path: current, depth } = stack.pop();
96
+ if (depth >= maxDepth) continue;
96
97
  for (const entry of readdirSync(current, { withFileTypes: true })) {
97
98
  const full = join(current, entry.name);
98
99
  if (entry.isDirectory()) {
99
- stack.push(full);
100
+ stack.push({ path: full, depth: depth + 1 });
100
101
  continue;
101
102
  }
102
103
  if (entry.isFile() && isExtractableDocumentPath(full)) files.push(full);
@@ -135,7 +136,7 @@ export async function ingestDocuments(inputPaths, {
135
136
  throw new Error('--out can only be used with a single input path');
136
137
  }
137
138
 
138
- const files = inputPaths.flatMap((inputPath) => collectInputFiles(isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath)));
139
+ const files = inputPaths.flatMap((inputPath) => collectInputFiles(isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath), { maxDepth: 10 }));
139
140
  if (files.length === 0) {
140
141
  throw new Error('No supported document files found');
141
142
  }
package/lib/embed/cli.mjs CHANGED
@@ -174,7 +174,7 @@ function parseArgs(args) {
174
174
  // Subcommand: start
175
175
  // ---------------------------------------------------------------------------
176
176
 
177
- async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir } = {}) {
177
+ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPath, _livenessCheckMs = 400 } = {}) {
178
178
  const existing = readRunningState(homeDir);
179
179
  if (existing) {
180
180
  process.stdout.write(`embed daemon already running (pid ${existing.pid})\n`);
@@ -188,7 +188,11 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir } = {}) {
188
188
  ? path.join(os.homedir(), '.construct', 'embed.yaml')
189
189
  : null;
190
190
 
191
- const workerPath = path.join(rootDir, 'lib', 'embed', 'worker.mjs');
191
+ const workerPath = _workerPath ?? path.join(rootDir, 'lib', 'embed', 'worker.mjs');
192
+ if (!fs.existsSync(workerPath)) {
193
+ throw new Error(`embed worker not found at ${workerPath}`);
194
+ }
195
+
192
196
  const workerArgs = configPath ? [workerPath, '--config', configPath] : [workerPath];
193
197
  const log = logPath(homeDir);
194
198
  fs.mkdirSync(path.dirname(log), { recursive: true });
@@ -199,9 +203,18 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir } = {}) {
199
203
  stdio: ['ignore', fd, fd],
200
204
  env: { ...process.env },
201
205
  });
206
+ fs.closeSync(fd);
202
207
  child.unref();
203
208
 
204
209
  writeState({ pid: child.pid, configPath: configPath ?? 'auto', startedAt: new Date().toISOString() }, homeDir);
210
+
211
+ // Brief liveness check — detects immediate crashes (e.g. missing module, bad config path)
212
+ await new Promise(r => setTimeout(r, _livenessCheckMs));
213
+ if (!processExists(child.pid)) {
214
+ clearState(homeDir);
215
+ throw new Error(`embed worker (pid ${child.pid}) exited immediately — check ${log}`);
216
+ }
217
+
205
218
  process.stdout.write(`embed daemon started (pid ${child.pid})\n`);
206
219
  process.stdout.write(`config: ${configPath ?? 'auto-discover from config.env'}\n`);
207
220
  process.stdout.write(`log: ${log}\n`);
@@ -299,7 +312,7 @@ export async function runEmbedCli(args, opts = {}) {
299
312
  const rootDir = opts.rootDir ?? new URL('../..', import.meta.url).pathname;
300
313
 
301
314
  switch (sub) {
302
- case 'start': return cmdEmbedStart(subArgs, { homeDir, rootDir });
315
+ case 'start': return cmdEmbedStart(subArgs, { homeDir, rootDir, _workerPath: opts._workerPath, _livenessCheckMs: opts._livenessCheckMs });
303
316
  case 'stop': return cmdEmbedStop(subArgs, { homeDir });
304
317
  case 'status': return cmdEmbedStatus(subArgs, { homeDir });
305
318
  case 'snapshot': return cmdEmbedSnapshot(subArgs, { homeDir });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Maintains durable customer/account memory in ~/.cx/product-intel/customer-profiles/.
5
5
  * Profiles are additive — history is preserved unless explicitly deleted.
6
- * Used by intake triage to link signals to customers and detect account-level patterns.
6
+ * Links signals to customers and detects account-level patterns during intake triage.
7
7
  *
8
8
  * Storage:
9
9
  * ~/.cx/product-intel/customer-profiles/<customer-id>.md
@@ -58,8 +58,8 @@ function logCatch(source, err) {
58
58
 
59
59
  /**
60
60
  * Resolve an LLM API key from multiple sources: env → config.env → ~/.env → shell rc.
61
- * Used by the telemetry-generation probe so it can find keys stored via 1Password
62
- * op:// refs in .zshrc or other shell config files.
61
+ * Supports 1Password op:// refs in .zshrc or other shell config files for the
62
+ * telemetry-generation probe.
63
63
  */
64
64
  async function resolveLLMKey(varName, env) {
65
65
  if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
@@ -575,7 +575,8 @@ this.#scheduler.register(
575
575
  const ok = result.results.filter(r => r.success).length;
576
576
  const total = result.results.length;
577
577
  process.stderr.write(`[embed] Telemetry setup: ${ok}/${total} resources configured\n`);
578
- } else if (!result.ok) {
578
+ } else if (!result.ok && !result.error?.includes('required')) {
579
+ // Suppress the "keys required" message — normal for solo mode without telemetry configured.
579
580
  process.stderr.write(`[embed] Telemetry setup skipped: ${result.error}\n`);
580
581
  } else if (result.results?.some(r => !r.success)) {
581
582
  const failures = result.results.filter(r => !r.success);
@@ -840,21 +841,23 @@ this.#scheduler.register(
840
841
  meta: { gaps: result.gaps.length, conflicts: result.conflicts?.length || 0, recommendations: result.recommendations?.length || 0, autoFixed, queued },
841
842
  });
842
843
  }
843
- emitTraceEvent({
844
- rootDir: this.#rootDir,
845
- eventType: 'lifecycle.completed',
846
- traceId: this.#daemonTraceId,
847
- spanId: newSpanId(),
848
- env: this.#env,
849
- metadata: {
850
- gaps: result.gaps.length,
851
- conflicts: result.conflicts?.length || 0,
852
- recommendations: result.recommendations?.length || 0,
853
- autoFixed,
854
- queued,
855
- targets: result.targets,
856
- },
857
- }).catch(() => {});
844
+ try {
845
+ emitTraceEvent({
846
+ rootDir: this.#rootDir,
847
+ eventType: 'lifecycle.completed',
848
+ traceId: this.#daemonTraceId,
849
+ spanId: newSpanId(),
850
+ env: this.#env,
851
+ metadata: {
852
+ gaps: result.gaps.length,
853
+ conflicts: result.conflicts?.length || 0,
854
+ recommendations: result.recommendations?.length || 0,
855
+ autoFixed,
856
+ queued,
857
+ targets: result.targets,
858
+ },
859
+ });
860
+ } catch { /* observability must not break the caller */ }
858
861
  } catch (err) {
859
862
  process.stderr.write(`[embed] Docs lifecycle error: ${err.message}\n`);
860
863
  }
@@ -1114,18 +1117,20 @@ this.#scheduler.register(
1114
1117
 
1115
1118
  this.#status = 'running';
1116
1119
  this.#daemonTraceId = newTraceId();
1117
- emitTraceEvent({
1118
- rootDir: this.#rootDir,
1119
- eventType: 'daemon.started',
1120
- traceId: this.#daemonTraceId,
1121
- spanId: newSpanId(),
1122
- env: this.#env,
1123
- metadata: {
1124
- pid: process.pid,
1125
- configSnapshotIntervalMs: this.#config.snapshot.intervalMs,
1126
- version: process.env.CONSTRUCT_VERSION || '0.0.0',
1127
- },
1128
- }).catch(() => {});
1120
+ try {
1121
+ emitTraceEvent({
1122
+ rootDir: this.#rootDir,
1123
+ eventType: 'daemon.started',
1124
+ traceId: this.#daemonTraceId,
1125
+ spanId: newSpanId(),
1126
+ env: this.#env,
1127
+ metadata: {
1128
+ pid: process.pid,
1129
+ configSnapshotIntervalMs: this.#config.snapshot.intervalMs,
1130
+ version: process.env.CONSTRUCT_VERSION || '0.0.0',
1131
+ },
1132
+ });
1133
+ } catch { /* observability must not break the caller */ }
1129
1134
  process.stderr.write(`[embed] Daemon started. Snapshot interval: ${this.#config.snapshot.intervalMs}ms\n`);
1130
1135
  }
1131
1136
 
@@ -1134,14 +1139,16 @@ this.#scheduler.register(
1134
1139
  this.#inboxLiveWatcher = null;
1135
1140
  this.#scheduler?.stop();
1136
1141
  this.#status = 'stopped';
1137
- emitTraceEvent({
1138
- rootDir: this.#rootDir,
1139
- eventType: 'daemon.stopped',
1140
- traceId: this.#daemonTraceId,
1141
- spanId: newSpanId(),
1142
- env: this.#env,
1143
- metadata: { pid: process.pid, uptimeMs: process.uptime() * 1000 },
1144
- }).catch(() => {});
1142
+ try {
1143
+ emitTraceEvent({
1144
+ rootDir: this.#rootDir,
1145
+ eventType: 'daemon.stopped',
1146
+ traceId: this.#daemonTraceId,
1147
+ spanId: newSpanId(),
1148
+ env: this.#env,
1149
+ metadata: { pid: process.pid, uptimeMs: process.uptime() * 1000 },
1150
+ });
1151
+ } catch { /* observability must not break the caller */ }
1145
1152
  process.stderr.write('[embed] Daemon stopped.\n');
1146
1153
  }
1147
1154
 
@@ -1182,10 +1189,10 @@ this.#scheduler.register(
1182
1189
  try {
1183
1190
  // Query strategy/PRDs/RFCs from knowledge base
1184
1191
 
1185
- const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1186
- category: 'knowledge',
1187
- limit: 50,
1188
- });
1192
+ const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1193
+ category: 'insight',
1194
+ limit: 50,
1195
+ });
1189
1196
 
1190
1197
  // Query existing Jira tickets via Atlassian provider
1191
1198
 
@@ -36,6 +36,7 @@ const STATE_FILE = '.cx/runtime/inbox-state.json';
36
36
  const DEFAULT_INBOX_SUBDIR = '.cx/inbox';
37
37
  const DOCS_INTAKE_SUBDIR = 'docs/intake';
38
38
  const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
39
+ const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
39
40
 
40
41
  function nextAvailablePath(targetPath) {
41
42
  if (!existsSync(targetPath)) return targetPath;
@@ -50,6 +51,28 @@ function nextAvailablePath(targetPath) {
50
51
  }
51
52
  }
52
53
 
54
+ // ─── Stale state pruning ─────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Remove state entries for source files absent from disk for more than 7 days.
58
+ * Returns the count of removed entries.
59
+ */
60
+ function pruneStaleState(rootDir, state) {
61
+ const cutoffMs = Date.now() - STALE_STATE_AGE_MS;
62
+ let removed = 0;
63
+ for (const key of Object.keys(state)) {
64
+ const entry = state[key];
65
+ if (!existsSync(key) && entry.processedAt) {
66
+ const processedMs = new Date(entry.processedAt).getTime();
67
+ if (processedMs < cutoffMs) {
68
+ delete state[key];
69
+ removed++;
70
+ }
71
+ }
72
+ }
73
+ return removed;
74
+ }
75
+
53
76
  // ─── State helpers ────────────────────────────────────────────────────────────
54
77
 
55
78
  function statePath(rootDir) {
@@ -327,6 +350,13 @@ export class InboxWatcher {
327
350
  }
328
351
  }
329
352
 
353
+ // Prune stale state entries: remove entries for source files absent from disk
354
+ // for more than 7 days (covers moved/renamed files that linger in state).
355
+ const staleRemoved = pruneStaleState(this.#rootDir, state);
356
+ if (staleRemoved > 0) {
357
+ process.stderr.write(`[inbox] pruned ${staleRemoved} stale state entries\n`);
358
+ }
359
+
330
360
  if (processed.length || errors.length) {
331
361
  writeState(this.#rootDir, state);
332
362
  }
@@ -191,7 +191,7 @@ export function dismissRecommendation(dedupKey, { reason = 'manually dismissed',
191
191
  }
192
192
 
193
193
  /**
194
- * Supersede a recommendation (mark as replaced by a newer one).
194
+ * Supersede a recommendation (mark as superseded by a newer one).
195
195
  *
196
196
  * @param {string} dedupKey - Existing recommendation to supersede
197
197
  * @param {string} supersedingId - ID of the recommendation that replaces it
@@ -26,8 +26,6 @@
26
26
  * }
27
27
  */
28
28
 
29
- import { getTemplate } from './templates.mjs';
30
-
31
29
  const DEFAULT_THRESHOLD = 0.7;
32
30
  const DEFAULT_MAX_ITERATIONS = 3;
33
31
 
package/lib/features.mjs CHANGED
@@ -287,6 +287,17 @@ function buildFeatures(overrides = {}) {
287
287
  if (platforms.length > 0) {
288
288
  return { status: 'configured', message: `Configured in ${platforms.join(', ')}` };
289
289
  }
290
+ // If every host explicitly marks this MCP as unsupported, it is delivered
291
+ // through the built-in construct-mcp stdio server — not a standalone tool.
292
+ const hostSupportEntries = Object.values(mcp.hostSupport ?? {});
293
+ const allUnsupported = hostSupportEntries.length > 0 &&
294
+ hostSupportEntries.every((s) => {
295
+ const mode = typeof s === 'string' ? s : (s?.mode ?? 'managed');
296
+ return mode === 'unsupported';
297
+ });
298
+ if (allUnsupported) {
299
+ return { status: 'configured', message: 'Available via construct-mcp (built-in)' };
300
+ }
290
301
  return { status: 'unavailable', message: 'Not configured in any host' };
291
302
  },
292
303
  }));
@@ -2,10 +2,8 @@
2
2
  /**
3
3
  * lib/health-check.mjs — reusable prerequisite and health checks.
4
4
  *
5
- * Used by:
6
- * - `construct dev` (auto-checks before starting services)
7
- * - `construct doctor` (comprehensive system audit)
8
- * - `construct init` (pre-init validation)
5
+ * Prerequisite and health check runners for `construct dev`, `construct doctor`,
6
+ * and `construct init`.
9
7
  */
10
8
 
11
9
  import { spawnSync } from 'node:child_process';
@@ -1066,10 +1066,10 @@ async function main() {
1066
1066
  if (verbose) console.log('\nStarting services...');
1067
1067
  try {
1068
1068
  const { startServices } = await import('./service-manager.mjs');
1069
- const { os } = await import('node:os');
1069
+ const { homedir } = await import('node:os');
1070
1070
  const { detectDockerCompose } = await import('./setup.mjs');
1071
1071
 
1072
- const homeDir = os.homedir();
1072
+ const homeDir = homedir();
1073
1073
  const composeRunner = detectDockerCompose();
1074
1074
 
1075
1075
  const { results } = await startServices({
@@ -222,7 +222,7 @@ function buildLabels(t) {
222
222
  // ── Jira ─────────────────────────────────────────────────────────────────
223
223
 
224
224
  /**
225
- * Create a Jira ticket from an intake packet.
225
+ * Create a Jira work item from an intake packet.
226
226
  *
227
227
  * @param {object} packet - Intake packet with triage, excerpt
228
228
  * @param {object} [opts]
@@ -276,8 +276,7 @@ export async function createJiraTicket(packet, { host, email, token, project, is
276
276
 
277
277
  const json = await res.json();
278
278
 
279
- // We don't have the browse URL directly from the create response,
280
- // so construct it from the host + issue key
279
+ // Browse URL is absent from the create response construct from host + issue key
281
280
  const issueKey = json.key;
282
281
  const browseUrl = `${resolvedHost.replace(/\/$/, '')}/browse/${issueKey}`;
283
282
 
@@ -474,8 +473,8 @@ async function updateConfluencePage(artifact, { host, email, token, space, fetch
474
473
  }
475
474
 
476
475
  /**
477
- * Minimal markdown to Confluence storage format conversion.
478
- * Handles the subset of markdown used by Construct artifacts.
476
+ * Converts the subset of markdown common to Construct artifacts into
477
+ * Confluence storage format.
479
478
  */
480
479
  function markdownToConfluenceStorage(md) {
481
480
  if (!md) return '';
@@ -155,6 +155,21 @@ function loadSnapshotChunks(rootDir) {
155
155
  return chunks;
156
156
  }
157
157
 
158
+ /**
159
+ * Load ingested knowledge files from .cx/knowledge/ subdirectories.
160
+ */
161
+ function loadKnowledgeChunks(rootDir) {
162
+ const knowledgeRoot = path.resolve(rootDir, '.cx/knowledge');
163
+ const subdirs = ['internal', 'external', 'decisions', 'how-tos', 'reference'];
164
+ const chunks = [];
165
+ for (const subdir of subdirs) {
166
+ const full = path.join(knowledgeRoot, subdir);
167
+ if (!fs.existsSync(full)) continue;
168
+ chunks.push(...loadMarkdownChunks(full, 'knowledge'));
169
+ }
170
+ return chunks;
171
+ }
172
+
158
173
  // ── Index builder ──────────────────────────────────────────────────────────
159
174
 
160
175
  /**
@@ -166,6 +181,7 @@ export function buildCorpus(rootDir = process.cwd()) {
166
181
  ...loadObservationChunks(rootDir),
167
182
  ...loadArtifactChunks(rootDir),
168
183
  ...loadSnapshotChunks(rootDir),
184
+ ...loadKnowledgeChunks(rootDir),
169
185
  ];
170
186
 
171
187
  // Embed each chunk
@@ -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
+ }
@@ -217,9 +217,8 @@ const PROVIDER_FAMILY_TIERS = [
217
217
  export { PROVIDER_FAMILY_TIERS };
218
218
 
219
219
  /**
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.
220
+ * Maps provider family IDs to the env var(s) that confirm credentials are present.
221
+ * Consumed by getProviderModelCatalog to mark which providers are available.
223
222
  */
224
223
  const PROVIDER_ENV_MAP = {
225
224
  'anthropic': ['ANTHROPIC_API_KEY'],
@@ -254,14 +253,14 @@ function isProviderConfigured(familyId, env) {
254
253
  } catch { /* not available */ }
255
254
  }
256
255
 
257
- // 1. Check passed env (usually process.env)
256
+ // Check passed env (usually process.env)
258
257
  const anyInEnv = varNames.some(name => {
259
258
  const val = env?.[name];
260
259
  return val && typeof val === 'string' && val.length > 0;
261
260
  });
262
261
  if (anyInEnv) return true;
263
262
 
264
- // 2. Check construct config.env
263
+ // Check construct config.env
265
264
  try {
266
265
  const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
267
266
  const cfgPath = path.join(homeDir, '.construct', 'config.env');
@@ -276,7 +275,7 @@ function isProviderConfigured(familyId, env) {
276
275
  }
277
276
  } catch { /* skip */ }
278
277
 
279
- // 3. Special case: GitHub Copilot via gh CLI
278
+ // GitHub Copilot: detect via gh CLI auth status
280
279
  if (familyId === 'github-copilot') {
281
280
  try {
282
281
  const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 3000 });
@@ -284,7 +283,7 @@ function isProviderConfigured(familyId, env) {
284
283
  } catch { return false; }
285
284
  }
286
285
 
287
- // 4. Check ~/.env
286
+ // Check ~/.env
288
287
  try {
289
288
  const homeEnv = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.env');
290
289
  if (fs.existsSync(homeEnv)) {
@@ -298,7 +297,7 @@ function isProviderConfigured(familyId, env) {
298
297
  }
299
298
  } catch { /* skip */ }
300
299
 
301
- // 5. Check shell rc files for op:// refs (1Password CLI integration)
300
+ // Check shell rc files for op:// refs (1Password CLI integration)
302
301
  try {
303
302
  const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
304
303
  const shellFiles = ['.zshrc', '.bashrc', '.bash_profile', '.profile'].map(f => path.join(homeDir, f));
@@ -0,0 +1,133 @@
1
+ /**
2
+ * lib/roles/catalog.mjs — role listing and display helpers.
3
+ *
4
+ * Reads agents/registry.json to produce a list of available roles with
5
+ * optional department grouping and consolidated-role views. Consumed by the
6
+ * `roles:list` CLI command.
7
+ */
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const REGISTRY_PATH = join(__dirname, '..', '..', 'agents', 'registry.json');
15
+
16
+ let cached = null;
17
+
18
+ function loadRegistry() {
19
+ if (cached) return cached;
20
+ cached = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
21
+ return cached;
22
+ }
23
+
24
+ /**
25
+ * Return a list of role descriptors.
26
+ *
27
+ * @param {object} opts
28
+ * @param {boolean} opts.departments Group by department when true.
29
+ * @param {boolean} opts.consolidated Return consolidated 12-role view when true.
30
+ * @returns {Array<object>}
31
+ */
32
+ export function listRoles({ departments = false, consolidated = false } = {}) {
33
+ const registry = loadRegistry();
34
+
35
+ if (consolidated) {
36
+ const c = registry.consolidatedRoles || {};
37
+ return (c.roles || []).map((r) => ({
38
+ id: r.id,
39
+ absorbs: r.absorbs || [],
40
+ whenToUse: r.whenToUse || '',
41
+ }));
42
+ }
43
+
44
+ const agents = Array.isArray(registry.agents)
45
+ ? registry.agents
46
+ : Object.values(registry.agents || {});
47
+
48
+ if (!departments) {
49
+ return agents.map((a) => ({
50
+ id: a.name,
51
+ name: `cx-${a.name}`,
52
+ description: a.description || '',
53
+ internal: !!a.internal,
54
+ }));
55
+ }
56
+
57
+ // Group by department based on subDepartment membership in registry.departments
58
+ const depts = registry.departments || {};
59
+ const grouped = {};
60
+ for (const [deptId, dept] of Object.entries(depts)) {
61
+ grouped[deptId] = {
62
+ name: dept.name || deptId,
63
+ roles: [],
64
+ };
65
+ }
66
+ grouped._ungrouped = { name: 'Other', roles: [] };
67
+
68
+ for (const agent of agents) {
69
+ let placed = false;
70
+ for (const [deptId, dept] of Object.entries(depts)) {
71
+ const subs = dept.subDepartments || {};
72
+ for (const sub of Object.values(subs)) {
73
+ if ((sub.roles || []).includes(agent.name) || (sub.roles || []).includes(`cx-${agent.name}`)) {
74
+ grouped[deptId].roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
75
+ placed = true;
76
+ break;
77
+ }
78
+ }
79
+ if (placed) break;
80
+ }
81
+ if (!placed) {
82
+ grouped._ungrouped.roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
83
+ }
84
+ }
85
+
86
+ return Object.entries(grouped)
87
+ .filter(([, g]) => g.roles.length > 0)
88
+ .map(([id, g]) => ({ departmentId: id, departmentName: g.name, roles: g.roles }));
89
+ }
90
+
91
+ /**
92
+ * Format the role list as a human-readable string.
93
+ *
94
+ * @param {object} opts Same options as listRoles.
95
+ * @returns {string}
96
+ */
97
+ export function formatRoleList({ departments = false, consolidated = false } = {}) {
98
+ const roles = listRoles({ departments, consolidated });
99
+ const lines = [];
100
+
101
+ if (consolidated) {
102
+ lines.push('Consolidated Roles (12-role simplified structure)');
103
+ lines.push('=================================================');
104
+ for (const r of roles) {
105
+ lines.push(`\n${r.id}`);
106
+ lines.push(` Absorbs: ${r.absorbs.join(', ')}`);
107
+ lines.push(` Use when: ${r.whenToUse}`);
108
+ }
109
+ lines.push('');
110
+ return lines.join('\n');
111
+ }
112
+
113
+ if (departments) {
114
+ lines.push('Available Roles by Department');
115
+ lines.push('=============================');
116
+ for (const group of roles) {
117
+ lines.push(`\n${group.departmentName}`);
118
+ for (const role of group.roles) {
119
+ lines.push(` ${role.name.padEnd(30)} ${role.description}`);
120
+ }
121
+ }
122
+ lines.push('');
123
+ return lines.join('\n');
124
+ }
125
+
126
+ lines.push('Available Roles');
127
+ lines.push('===============');
128
+ for (const role of roles) {
129
+ lines.push(` ${role.name.padEnd(30)} ${role.description}`);
130
+ }
131
+ lines.push('');
132
+ return lines.join('\n');
133
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * lib/roles/preference.mjs — per-user primary/secondary role preferences.
3
+ *
4
+ * Persists role preferences to ~/.construct/config.env under the keys
5
+ * CONSTRUCT_ROLE_PRIMARY and CONSTRUCT_ROLE_SECONDARY. Consumed by the
6
+ * `roles:set` CLI command and the orchestration layer.
7
+ */
8
+
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { getUserEnvPath, ensureUserConfigDir, writeEnvValues, parseEnvFile } from '../env-config.mjs';
13
+
14
+ const ENV_KEYS = {
15
+ primary: 'CONSTRUCT_ROLE_PRIMARY',
16
+ secondary: 'CONSTRUCT_ROLE_SECONDARY',
17
+ };
18
+
19
+ /**
20
+ * Read the stored role preference for the given slot.
21
+ *
22
+ * @param {'primary'|'secondary'} slot
23
+ * @returns {string|null} The stored role id, or null if unset.
24
+ */
25
+ export function getRolePreference(slot) {
26
+ const envKey = ENV_KEYS[slot];
27
+ if (!envKey) return null;
28
+
29
+ // Env var takes priority (allows per-session override)
30
+ if (process.env[envKey]) return process.env[envKey];
31
+
32
+ const envPath = getUserEnvPath(os.homedir());
33
+ const stored = parseEnvFile(envPath);
34
+ return stored[envKey] || null;
35
+ }
36
+
37
+ /**
38
+ * Persist a role preference for the given slot.
39
+ *
40
+ * @param {'primary'|'secondary'} slot
41
+ * @param {string} role Role id (e.g. "cx-engineer" or "engineer").
42
+ * @returns {{ slot: string, role: string }}
43
+ */
44
+ export function setRolePreference(slot, role) {
45
+ const envKey = ENV_KEYS[slot];
46
+ if (!envKey) throw new Error(`Unknown role slot "${slot}". Use "primary" or "secondary".`);
47
+
48
+ // Normalise: accept both "engineer" and "cx-engineer"
49
+ const normalised = role.startsWith('cx-') ? role : `cx-${role}`;
50
+
51
+ ensureUserConfigDir(os.homedir());
52
+ const envPath = getUserEnvPath(os.homedir());
53
+ writeEnvValues(envPath, { [envKey]: normalised });
54
+
55
+ // Also set in current process so the change is visible immediately
56
+ process.env[envKey] = normalised;
57
+
58
+ return { slot, role: normalised };
59
+ }
60
+
61
+ /**
62
+ * Clear a stored role preference.
63
+ *
64
+ * @param {'primary'|'secondary'} slot
65
+ */
66
+ export function clearRolePreference(slot) {
67
+ const envKey = ENV_KEYS[slot];
68
+ if (!envKey) return;
69
+
70
+ delete process.env[envKey];
71
+
72
+ const envPath = getUserEnvPath(os.homedir());
73
+ writeEnvValues(envPath, { [envKey]: '' });
74
+ }
@@ -250,6 +250,25 @@ const MIME = {
250
250
  '.js': 'text/javascript; charset=utf-8',
251
251
  '.css': 'text/css; charset=utf-8',
252
252
  '.json': 'application/json',
253
+ '.svg': 'image/svg+xml',
254
+ '.ico': 'image/x-icon',
255
+ '.png': 'image/png',
256
+ '.jpg': 'image/jpeg',
257
+ '.jpeg': 'image/jpeg',
258
+ '.gif': 'image/gif',
259
+ '.webp': 'image/webp',
260
+ '.woff': 'font/woff',
261
+ '.woff2': 'font/woff2',
262
+ '.ttf': 'font/ttf',
263
+ '.xml': 'application/xml',
264
+ '.txt': 'text/plain; charset=utf-8',
265
+ '.manifest': 'text/cache-manifest',
266
+ '.map': 'application/json',
267
+ '.eot': 'application/vnd.ms-fontobject',
268
+ '.otf': 'font/otf',
269
+ '.mp4': 'video/mp4',
270
+ '.mp3': 'audio/mpeg',
271
+ '.pdf': 'application/pdf',
253
272
  };
254
273
 
255
274
  function listCommands() {
@@ -1062,56 +1081,10 @@ const server = createServer(async (req, res) => {
1062
1081
  try {
1063
1082
  const env = loadConstructEnv();
1064
1083
  const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1065
-
1066
- // Check if we're running inside another Construct instance
1067
- const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1068
- const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1069
-
1070
- // Determine embedding boundary status
1071
- const isEmbedded = !!parentConstruct;
1072
- const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1073
-
1074
- // Get current embed status
1075
- const embedStatus = resolveEmbedStatus(env);
1076
-
1077
- res.writeHead(200, { 'Content-Type': 'application/json' });
1078
- res.end(JSON.stringify({
1079
- boundaryStatus,
1080
- instanceId,
1081
- parentConstruct,
1082
- parentUrl,
1083
- isEmbedded,
1084
- embedStatus: embedStatus.level,
1085
- embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
1086
- // Boundary capabilities that could be exposed to parent
1087
- capabilities: {
1088
- modeDetection: true,
1089
- snapshotStatus: true,
1090
- approvalQueue: true,
1091
- configManagement: true
1092
- }
1093
- }));
1094
- } catch (err) {
1095
- res.writeHead(500, { 'Content-Type': 'application/json' });
1096
- res.end(JSON.stringify({ error: err.message }));
1097
- }
1098
- return;
1099
- }
1100
-
1101
- if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
1102
- try {
1103
- const env = loadConstructEnv();
1104
- const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
1105
-
1106
- // Check if we're running inside another Construct instance
1107
1084
  const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
1108
1085
  const parentUrl = env.CONSTRUCT_PARENT_URL || null;
1109
-
1110
- // Determine embedding boundary status
1111
1086
  const isEmbedded = !!parentConstruct;
1112
1087
  const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
1113
-
1114
- // Get current embed status
1115
1088
  const embedStatus = resolveEmbedStatus(env);
1116
1089
 
1117
1090
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1123,7 +1096,6 @@ const server = createServer(async (req, res) => {
1123
1096
  isEmbedded,
1124
1097
  embedStatus: embedStatus.level,
1125
1098
  embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
1126
- // Boundary capabilities that could be exposed to parent
1127
1099
  capabilities: {
1128
1100
  modeDetection: true,
1129
1101
  snapshotStatus: true,
@@ -1358,6 +1330,20 @@ const server = createServer(async (req, res) => {
1358
1330
  return;
1359
1331
  }
1360
1332
 
1333
+ if (url.pathname === '/api/intake/list' && req.method === 'GET') {
1334
+ try {
1335
+ const { createIntakeQueue } = await import('../intake/queue.mjs');
1336
+ const queue = createIntakeQueue(ROOT_DIR, process.env);
1337
+ const items = queue.listPending();
1338
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1339
+ res.end(JSON.stringify({ items, total: items.length }));
1340
+ } catch (err) {
1341
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1342
+ res.end(JSON.stringify({ error: err.message }));
1343
+ }
1344
+ return;
1345
+ }
1346
+
1361
1347
  if (url.pathname === '/api/providers' && req.method === 'GET') {
1362
1348
  try {
1363
1349
  const probe = url.searchParams.get('probe') === '1';
@@ -53,7 +53,7 @@ export async function verifyPostgresHealth({
53
53
  maxRetries = 5,
54
54
  intervalMs = 2000,
55
55
  } = {}) {
56
- // We can't use fetch against Postgres (it's not HTTP), so just check pg_isready via docker
56
+ // Postgres is not HTTP health is checked via pg_isready in docker instead
57
57
  for (let attempt = 0; attempt < maxRetries; attempt++) {
58
58
  try {
59
59
  const result = spawnSync('docker', [
package/lib/setup.mjs CHANGED
@@ -585,6 +585,50 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
585
585
  console.log(`Embeddings: warmup skipped (${err?.message || 'unknown error'}) — model will load on first use`);
586
586
  }
587
587
 
588
+ // Cheapest provider selection — opt-out by default. When user consents,
589
+ // evaluate all configured providers, pick the lowest-cost model per tier,
590
+ // and write to config.env. On subsequent runs the preference is persisted
591
+ // so the prompt is skipped.
592
+
593
+ const { isCheapestProviderEnabled, selectCheapestForAllTiers, setCheapestProviderPreference, formatCheapestProviderMessage } =
594
+ await import('./model-cheapest-provider.mjs');
595
+ const cheapestAlreadyEnabled = isCheapestProviderEnabled(envPath, { env: process.env });
596
+ if (!cheapestAlreadyEnabled) {
597
+ const cheapestConsent = await consentToInstall({
598
+ name: 'cheapest-provider',
599
+ isYes,
600
+ alreadyConfigured: false,
601
+ envPath,
602
+ defaultYes: false,
603
+ });
604
+ if (cheapestConsent.decision) {
605
+ try {
606
+ const selections = await selectCheapestForAllTiers({ env: process.env });
607
+ const applied = {};
608
+ for (const tier of ['reasoning', 'standard', 'fast']) {
609
+ if (selections[tier]?.modelId) applied[tier] = selections[tier].modelId;
610
+ }
611
+ if (Object.keys(applied).length > 0) {
612
+ applyToEnv(envPath, applied);
613
+ console.log('\nCheapest providers applied:');
614
+ for (const [tier, model] of Object.entries(applied)) {
615
+ const label = selections[tier]?.providerLabel || '';
616
+ console.log(` ${tier.padEnd(11)} ${model} (${label})`);
617
+ }
618
+ } else {
619
+ console.log('\nCheapest provider: no configured providers found — nothing to apply.');
620
+ }
621
+ setCheapestProviderPreference(envPath, true);
622
+ } catch (err) {
623
+ console.log(`Cheapest provider: skipped (${err?.message || 'unknown error'})`);
624
+ }
625
+ } else {
626
+ console.log(`Cheapest provider: skipped (${cheapestConsent.note})`);
627
+ }
628
+ } else {
629
+ console.log('Cheapest provider: already enabled — skipping prompt.');
630
+ }
631
+
588
632
  const hooksResult = ensureGitHooksPath({ cwd: process.cwd() });
589
633
  if (hooksResult.status === 'set') console.log(`Git hooks: ${hooksResult.message}`);
590
634
  else if (hooksResult.status === 'ok') console.log('Git hooks: wired');
package/lib/status.mjs CHANGED
@@ -491,6 +491,7 @@ function traceBackendDefinition(env) {
491
491
  runtime: 'live',
492
492
  note: 'Trace backend',
493
493
  healthyMessage: 'Reachable',
494
+ impactsOverall: false,
494
495
  };
495
496
  }
496
497
 
@@ -588,6 +589,7 @@ function serviceDefinitions(env, dashboardPort, selfDashboard) {
588
589
  runtime: 'live',
589
590
  note: 'MCP-managed',
590
591
  healthyMessage: 'Reachable',
592
+ impactsOverall: false,
591
593
  },
592
594
  {
593
595
  id: 'opencode',
@@ -772,8 +774,8 @@ export async function buildStatus({
772
774
  }
773
775
 
774
776
  function serviceIcon(status) {
775
- if (status === 'healthy') return '✓';
776
- if (status === 'degraded' || status === 'configured') return '⚠';
777
+ if (status === 'healthy' || status === 'configured') return '✓';
778
+ if (status === 'degraded') return '⚠';
777
779
  return '✗';
778
780
  }
779
781
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
6
6
  "bin": {