@geraldmaron/construct 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -1
- package/README.md +4 -4
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +72 -11
- package/bin/construct-postinstall.mjs +1 -1
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +1 -2
- package/lib/beads-automation.mjs +16 -7
- package/lib/bootstrap/resources.mjs +2 -2
- package/lib/cli-commands.mjs +8 -2
- package/lib/document-ingest.mjs +6 -5
- package/lib/embed/cli.mjs +16 -3
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +38 -18
- package/lib/embed/daemon.mjs +59 -50
- package/lib/embed/inbox.mjs +30 -0
- package/lib/embed/recommendation-store.mjs +214 -15
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/evaluator-optimizer.mjs +0 -2
- package/lib/features.mjs +11 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +3 -5
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +119 -3
- package/lib/init-update.mjs +246 -131
- package/lib/install/first-invocation.mjs +4 -4
- package/lib/intake/queue.mjs +1 -1
- package/lib/integrations/intake-integrations.mjs +4 -5
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/rag.mjs +16 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-cheapest-provider.mjs +231 -0
- package/lib/model-router.mjs +68 -9
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/parity.mjs +124 -21
- package/lib/project-profile.mjs +1 -1
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/roles/catalog.mjs +133 -0
- package/lib/roles/preference.mjs +74 -0
- package/lib/server/index.mjs +109 -47
- package/lib/server/insights.mjs +1 -1
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +32 -24
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +2 -3
- package/lib/setup-prompts.mjs +2 -2
- package/lib/setup.mjs +55 -46
- package/lib/status.mjs +56 -8
- package/lib/storage/backend.mjs +12 -2
- package/lib/storage/postgres-backup.mjs +1 -1
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/worker/trace.mjs +17 -27
- package/package.json +5 -2
- package/rules/common/research.md +44 -12
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/data-analyst.product-intelligence.md +1 -1
- package/skills/roles/researcher.md +28 -15
- package/skills/routing.md +8 -1
- package/templates/docs/construct_guide.md +2 -2
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +7 -7
|
@@ -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';
|
|
@@ -1514,6 +1500,82 @@ const server = createServer(async (req, res) => {
|
|
|
1514
1500
|
return;
|
|
1515
1501
|
}
|
|
1516
1502
|
|
|
1503
|
+
// ── Recommendations API ────────────────────────────────────────────────────
|
|
1504
|
+
if (url.pathname === '/api/recommendations') {
|
|
1505
|
+
if (req.method === 'GET') {
|
|
1506
|
+
const { listActiveRecommendations, recommendationStats } = await import('../embed/recommendation-store.mjs');
|
|
1507
|
+
const priority = url.searchParams.get('priority') || undefined;
|
|
1508
|
+
const type = url.searchParams.get('type') || undefined;
|
|
1509
|
+
const limit = Number(url.searchParams.get('limit') || 50);
|
|
1510
|
+
const items = listActiveRecommendations({ priority, type, limit });
|
|
1511
|
+
const stats = recommendationStats();
|
|
1512
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1513
|
+
res.end(JSON.stringify({ items, stats }));
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (req.method === 'PATCH' || req.method === 'POST') {
|
|
1517
|
+
const chunks = [];
|
|
1518
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1519
|
+
let body;
|
|
1520
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1521
|
+
const action = body.action || url.searchParams.get('action');
|
|
1522
|
+
const dedupKey = body.dedupKey || url.searchParams.get('dedupKey');
|
|
1523
|
+
if (!action || !dedupKey) {
|
|
1524
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1525
|
+
res.end(JSON.stringify({ error: 'action and dedupKey are required' }));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const { dismissRecommendation, reviveRecommendation } = await import('../embed/recommendation-store.mjs');
|
|
1529
|
+
try {
|
|
1530
|
+
if (action === 'dismiss') {
|
|
1531
|
+
const result = dismissRecommendation(dedupKey, { reason: body.reason, suppressDays: body.suppressDays });
|
|
1532
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1533
|
+
res.end(JSON.stringify(result));
|
|
1534
|
+
} else if (action === 'revive') {
|
|
1535
|
+
const result = reviveRecommendation(dedupKey, { signalCount: body.signalCount, sourceSignalIds: body.sourceSignalIds });
|
|
1536
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1537
|
+
res.end(JSON.stringify(result));
|
|
1538
|
+
} else {
|
|
1539
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1540
|
+
res.end(JSON.stringify({ error: `unknown action: ${action}` }));
|
|
1541
|
+
}
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1544
|
+
res.end(JSON.stringify({ error: err?.message || 'not found' }));
|
|
1545
|
+
}
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ── Strategy API ───────────────────────────────────────────────────────────
|
|
1551
|
+
if (url.pathname === '/api/strategy') {
|
|
1552
|
+
if (req.method === 'GET') {
|
|
1553
|
+
const { readAllStrategies } = await import('../strategy-store.mjs');
|
|
1554
|
+
const result = await readAllStrategies(process.env);
|
|
1555
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1556
|
+
res.end(JSON.stringify({ scopes: Object.fromEntries(result) }));
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
1560
|
+
const chunks = [];
|
|
1561
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1562
|
+
let body;
|
|
1563
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1564
|
+
const content = (body.content || '').trim();
|
|
1565
|
+
const scope = (body.scope || 'product').trim();
|
|
1566
|
+
if (!content) {
|
|
1567
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1568
|
+
res.end(JSON.stringify({ error: 'content is required' }));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const { writeStrategy } = await import('../strategy-store.mjs');
|
|
1572
|
+
await writeStrategy(content, scope, { updatedBy: 'dashboard', env: process.env });
|
|
1573
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1574
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1517
1579
|
if (url.pathname === '/api/snapshots') {
|
|
1518
1580
|
handleSnapshots(req, res);
|
|
1519
1581
|
return;
|
package/lib/server/insights.mjs
CHANGED
|
@@ -42,7 +42,7 @@ async function fetchTelemetrySummary(env, { limit = 100 } = {}) {
|
|
|
42
42
|
});
|
|
43
43
|
clearTimeout(timer);
|
|
44
44
|
if (res.status === 401 || res.status === 403) {
|
|
45
|
-
return { state: 'misconfigured', message: `Telemetry credentials rejected (HTTP ${res.status}). Run construct
|
|
45
|
+
return { state: 'misconfigured', message: `Telemetry credentials rejected (HTTP ${res.status}). Run construct init.` };
|
|
46
46
|
}
|
|
47
47
|
if (!res.ok) return { state: 'unreachable', message: `Telemetry HTTP ${res.status}` };
|
|
48
48
|
const json = await res.json();
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/server/telemetry-login.mjs — magic-link bridge for a
|
|
2
|
+
* lib/server/telemetry-login.mjs — magic-link bridge for a configured telemetry UI.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the backend, then returns an HTML auto-submit form that POSTs the seeded
|
|
8
|
-
* credentials to NextAuth's callback.
|
|
9
|
-
*
|
|
10
|
-
* The seeded creds are NOT secret — they are deterministic local defaults
|
|
11
|
-
* for zero-touch local development. Not used when CONSTRUCT_TELEMETRY_URL
|
|
12
|
-
* points at a remote backend.
|
|
4
|
+
* CONSTRUCT_TELEMETRY_URL may point at a Langfuse-compatible deployment.
|
|
5
|
+
* The dashboard link can use this bridge for one-click sign-in by fetching a
|
|
6
|
+
* CSRF token and returning an HTML auto-submit form for NextAuth's callback.
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
9
|
const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
16
10
|
|
|
17
11
|
/**
|
|
18
|
-
* Resolve the telemetry
|
|
12
|
+
* Resolve the configured telemetry UI URL from env.
|
|
19
13
|
*/
|
|
20
14
|
function resolveTelemetryUrl(env = process.env) {
|
|
21
15
|
return (
|
|
@@ -45,10 +39,10 @@ function escapeHtml(value) {
|
|
|
45
39
|
*/
|
|
46
40
|
export function renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, email, password }) {
|
|
47
41
|
return `<!doctype html>
|
|
48
|
-
<html><head><meta charset="utf-8"><title>Signing in to telemetry
|
|
42
|
+
<html><head><meta charset="utf-8"><title>Signing in to telemetry UI…</title>
|
|
49
43
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#666;background:#fff}</style>
|
|
50
44
|
</head><body>
|
|
51
|
-
<p>Signing you in to telemetry
|
|
45
|
+
<p>Signing you in to telemetry UI…</p>
|
|
52
46
|
<form id="tf" method="POST" action="${escapeHtml(callbackUrl)}">
|
|
53
47
|
<input type="hidden" name="csrfToken" value="${escapeHtml(csrfToken)}">
|
|
54
48
|
<input type="hidden" name="email" value="${escapeHtml(email)}">
|
|
@@ -63,8 +57,8 @@ export function renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, em
|
|
|
63
57
|
/**
|
|
64
58
|
* HTTP handler: GET /api/services/telemetry/login
|
|
65
59
|
*
|
|
66
|
-
* Returns an HTML page that immediately POSTs credentials to the
|
|
67
|
-
* NextAuth callback. Returns 502 when the
|
|
60
|
+
* Returns an HTML page that immediately POSTs credentials to the configured
|
|
61
|
+
* NextAuth callback. Returns 502 when the endpoint is unreachable.
|
|
68
62
|
*/
|
|
69
63
|
export async function handleTelemetryLogin(req, res, {
|
|
70
64
|
baseUrl,
|
|
@@ -76,8 +70,8 @@ export async function handleTelemetryLogin(req, res, {
|
|
|
76
70
|
|
|
77
71
|
if (!resolvedBaseUrl) {
|
|
78
72
|
res.writeHead(503, HTML_HEADERS);
|
|
79
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry
|
|
80
|
-
`<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a telemetry
|
|
73
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI not configured</title>` +
|
|
74
|
+
`<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a Langfuse-compatible telemetry UI to use this feature.</pre>`);
|
|
81
75
|
return;
|
|
82
76
|
}
|
|
83
77
|
|
|
@@ -87,9 +81,9 @@ export async function handleTelemetryLogin(req, res, {
|
|
|
87
81
|
});
|
|
88
82
|
if (!csrfResponse.ok) {
|
|
89
83
|
res.writeHead(502, HTML_HEADERS);
|
|
90
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry
|
|
91
|
-
`<pre>Could not reach telemetry
|
|
92
|
-
`
|
|
84
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI unreachable</title>` +
|
|
85
|
+
`<pre>Could not reach telemetry UI at ${escapeHtml(resolvedBaseUrl)}. Status: ${csrfResponse.status}. ` +
|
|
86
|
+
`Check CONSTRUCT_TELEMETRY_URL and the configured service.</pre>`);
|
|
93
87
|
return;
|
|
94
88
|
}
|
|
95
89
|
const { csrfToken } = await csrfResponse.json();
|
|
@@ -99,10 +93,8 @@ export async function handleTelemetryLogin(req, res, {
|
|
|
99
93
|
res.end(renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, email, password }));
|
|
100
94
|
} catch (err) {
|
|
101
95
|
res.writeHead(502, HTML_HEADERS);
|
|
102
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry
|
|
103
|
-
`<pre>Could not reach telemetry
|
|
104
|
-
`
|
|
96
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI unreachable</title>` +
|
|
97
|
+
`<pre>Could not reach telemetry UI: ${escapeHtml(err?.message || 'unknown error')}. ` +
|
|
98
|
+
`Check CONSTRUCT_TELEMETRY_URL and the configured service.</pre>`);
|
|
105
99
|
}
|
|
106
100
|
}
|
|
107
|
-
|
|
108
|
-
|
package/lib/service-manager.mjs
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
pruneStashDir,
|
|
21
21
|
verifyTelemetryKeys,
|
|
22
22
|
isRemoteTelemetry,
|
|
23
|
-
|
|
24
|
-
} from './
|
|
23
|
+
} from './services/local-postgres.mjs';
|
|
24
|
+
import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
|
|
25
25
|
|
|
26
26
|
const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
|
|
27
27
|
const CONSTRUCT_PG_CONTAINER = 'construct-postgres';
|
|
@@ -379,7 +379,7 @@ function startConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn =
|
|
|
379
379
|
if (!composeRunner) return { status: 'unavailable', note: 'Docker not available' };
|
|
380
380
|
|
|
381
381
|
const composeFile = constructPgComposePath(rootDir);
|
|
382
|
-
if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct
|
|
382
|
+
if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct init first' };
|
|
383
383
|
|
|
384
384
|
const args = [...composeRunner.argsPrefix, '-p', 'construct-postgres', '-f', composeFile, 'up', '-d'];
|
|
385
385
|
const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
@@ -464,7 +464,7 @@ export async function startServices({
|
|
|
464
464
|
// On macOS, auto-launch Docker Desktop when the CLI is present but the
|
|
465
465
|
// daemon is down. Polls up to 60s so downstream Postgres startup sees a working
|
|
466
466
|
// compose runner instead of "Docker not available".
|
|
467
|
-
await tryStartDockerDaemonFn({ env: process.env });
|
|
467
|
+
await tryStartDockerDaemonFn({ env: { ...process.env, HOME: homeDir }, timeoutMs: 5000 });
|
|
468
468
|
|
|
469
469
|
writeEnvValues(envPath, {
|
|
470
470
|
DASHBOARD_PORT: String(ports.dashboard),
|
|
@@ -475,7 +475,7 @@ export async function startServices({
|
|
|
475
475
|
// Construct Postgres — start if DATABASE_URL points to managed container
|
|
476
476
|
const liveEnv = loadConstructEnvFn({ rootDir, homeDir });
|
|
477
477
|
const results = [];
|
|
478
|
-
const pressureReport = runPressureReleaseFn({ env: liveEnv });
|
|
478
|
+
const pressureReport = runPressureReleaseFn({ env: { ...liveEnv, HOME: homeDir } });
|
|
479
479
|
if (pressureReport?.killed?.length) {
|
|
480
480
|
results.push({
|
|
481
481
|
name: 'Pressure Guard',
|
|
@@ -527,31 +527,39 @@ export async function startServices({
|
|
|
527
527
|
status: dashboard.reused ? 'reused' : 'started',
|
|
528
528
|
});
|
|
529
529
|
|
|
530
|
-
// Telemetry backend — delegated to lib/services/telemetry-backend.mjs.
|
|
531
|
-
// Remote URLs short-circuit inside startManagedServices and return `configured`.
|
|
532
|
-
|
|
533
530
|
const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
|
|
534
|
-
|
|
531
|
+
const traceBackend = resolveTraceBackend(liveEnv);
|
|
532
|
+
if (traceBackend === 'local') {
|
|
533
|
+
results.push({
|
|
534
|
+
name: 'Telemetry',
|
|
535
|
+
url: path.join(rootDir, '.cx', 'traces'),
|
|
536
|
+
status: 'configured',
|
|
537
|
+
note: 'local JSONL traces; remote export not configured',
|
|
538
|
+
});
|
|
539
|
+
} else if (traceBackend === 'none') {
|
|
540
|
+
results.push({
|
|
541
|
+
name: 'Telemetry',
|
|
542
|
+
url: path.join(rootDir, '.cx', 'traces'),
|
|
543
|
+
status: 'configured',
|
|
544
|
+
note: 'remote export disabled; local JSONL traces preserved',
|
|
545
|
+
});
|
|
546
|
+
} else if (traceBackend === 'otel') {
|
|
547
|
+
const endpoint = liveEnv.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT || '';
|
|
548
|
+
results.push({
|
|
549
|
+
name: 'Telemetry',
|
|
550
|
+
url: endpoint || undefined,
|
|
551
|
+
status: endpoint ? 'configured' : 'unavailable',
|
|
552
|
+
note: endpoint ? `OTLP export (${telemetryProviderLabel(liveEnv)})` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
|
|
553
|
+
});
|
|
554
|
+
} else if (telemetryUrl && (isRemoteTelemetry(telemetryUrl) || traceBackend === 'langfuse' || traceBackend === 'http')) {
|
|
535
555
|
results.push({
|
|
536
556
|
name: 'Telemetry',
|
|
537
557
|
url: telemetryUrl,
|
|
538
558
|
status: 'configured',
|
|
539
|
-
note:
|
|
559
|
+
note: `${telemetryProviderLabel(liveEnv)} export — ${telemetryUrl}`,
|
|
540
560
|
});
|
|
541
561
|
} else {
|
|
542
|
-
|
|
543
|
-
if (composeRunner) {
|
|
544
|
-
const svc = await startManagedServices({
|
|
545
|
-
rootDir,
|
|
546
|
-
homeDir,
|
|
547
|
-
env: liveEnv,
|
|
548
|
-
composeRunner,
|
|
549
|
-
spawnDetached: spawnDetachedFn,
|
|
550
|
-
});
|
|
551
|
-
results.push({ name: 'Telemetry', url: svc.url, status: svc.status, note: svc.note });
|
|
552
|
-
} else {
|
|
553
|
-
results.push({ name: 'Telemetry', status: 'unavailable', note: 'Docker not available' });
|
|
554
|
-
}
|
|
562
|
+
results.push({ name: 'Telemetry', status: 'unavailable', note: 'remote export requested but endpoint is not configured' });
|
|
555
563
|
}
|
|
556
564
|
|
|
557
565
|
// Memory (cm)
|
|
@@ -563,7 +571,7 @@ export async function startServices({
|
|
|
563
571
|
results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'started' });
|
|
564
572
|
}
|
|
565
573
|
} else {
|
|
566
|
-
results.push({ name: 'Memory (cm)', status: 'unavailable', note: 'cm not installed — run: construct
|
|
574
|
+
results.push({ name: 'Memory (cm)', status: 'unavailable', note: 'cm not installed — run: construct init or brew install dicklesworthstone/tap/cm' });
|
|
567
575
|
}
|
|
568
576
|
|
|
569
577
|
// OpenCode (optional)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/services/local-postgres.mjs — local Postgres helper exports.
|
|
3
|
+
*
|
|
4
|
+
* Compatibility wrappers live in telemetry-backend.mjs until downstream imports
|
|
5
|
+
* move, but new service-management code should use this name.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
POSTGRES_LOCAL_PORT,
|
|
9
|
+
isRemoteTelemetry,
|
|
10
|
+
servicesComposePath,
|
|
11
|
+
pruneStashDir,
|
|
12
|
+
verifyPostgresHealth,
|
|
13
|
+
verifyTelemetryKeys,
|
|
14
|
+
startManagedServices,
|
|
15
|
+
} from './telemetry-backend.mjs';
|
|
@@ -18,7 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { getUserEnvPath, writeEnvValues } from '../env-config.mjs';
|
|
19
19
|
|
|
20
20
|
/** Local Postgres port (within Construct's 54329-54339 reserved block). */
|
|
21
|
-
export const POSTGRES_LOCAL_PORT =
|
|
21
|
+
export const POSTGRES_LOCAL_PORT = 54329;
|
|
22
22
|
|
|
23
23
|
export function isRemoteTelemetry(url = '') {
|
|
24
24
|
if (!url) return false;
|
|
@@ -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', [
|
|
@@ -175,4 +175,3 @@ export async function startManagedServices({
|
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
package/lib/setup-prompts.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/setup-prompts.mjs — yes/no consent helper for `construct
|
|
2
|
+
* lib/setup-prompts.mjs — yes/no consent helper for `construct init`.
|
|
3
3
|
*
|
|
4
4
|
* Centralises the "should we install this service?" decision so Postgres,
|
|
5
5
|
* telemetry, and any future service share the same prompt + consent-caching
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 5. interactive → prompt [Y/n], cache the answer
|
|
15
15
|
*
|
|
16
16
|
* Consent is stored as BOOTSTRAP_<NAME>=yes|no in ~/.construct/config.env
|
|
17
|
-
* so subsequent runs of `construct
|
|
17
|
+
* so subsequent runs of `construct init` don't re-prompt for the same
|
|
18
18
|
* services. Pass `force = true` to skip the cache and re-prompt.
|
|
19
19
|
*/
|
|
20
20
|
|