@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 +45 -7
- package/lib/document-ingest.mjs +6 -5
- package/lib/embed/cli.mjs +16 -3
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/daemon.mjs +49 -42
- package/lib/embed/inbox.mjs +30 -0
- package/lib/embed/recommendation-store.mjs +1 -1
- package/lib/evaluator-optimizer.mjs +0 -2
- package/lib/features.mjs +11 -0
- package/lib/health-check.mjs +2 -4
- package/lib/init-unified.mjs +2 -2
- package/lib/integrations/intake-integrations.mjs +4 -5
- package/lib/knowledge/rag.mjs +16 -0
- package/lib/model-cheapest-provider.mjs +231 -0
- package/lib/model-router.mjs +7 -8
- package/lib/roles/catalog.mjs +133 -0
- package/lib/roles/preference.mjs +74 -0
- package/lib/server/index.mjs +33 -47
- package/lib/services/telemetry-backend.mjs +1 -1
- package/lib/setup.mjs +44 -0
- package/lib/status.mjs +4 -2
- package/package.json +1 -1
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('
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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]);
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -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
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -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
|
|
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
|
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
|
}));
|
package/lib/health-check.mjs
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lib/health-check.mjs — reusable prerequisite and health checks.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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';
|
package/lib/init-unified.mjs
CHANGED
|
@@ -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 {
|
|
1069
|
+
const { homedir } = await import('node:os');
|
|
1070
1070
|
const { detectDockerCompose } = await import('./setup.mjs');
|
|
1071
1071
|
|
|
1072
|
-
const 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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
478
|
-
*
|
|
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 '';
|
package/lib/knowledge/rag.mjs
CHANGED
|
@@ -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
|
+
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -217,9 +217,8 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
217
217
|
export { PROVIDER_FAMILY_TIERS };
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|
package/lib/server/index.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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'
|
|
777
|
+
if (status === 'healthy' || status === 'configured') return '✓';
|
|
778
|
+
if (status === 'degraded') return '⚠';
|
|
777
779
|
return '✗';
|
|
778
780
|
}
|
|
779
781
|
|