@geraldmaron/construct 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +4 -4
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +23 -0
  19. package/db/schema/004_recommendations.sql +46 -0
  20. package/db/schema/005_strategy.sql +21 -0
  21. package/lib/auto-docs.mjs +1 -2
  22. package/lib/beads-automation.mjs +16 -7
  23. package/lib/cli-commands.mjs +8 -2
  24. package/lib/embed/conflict-detection.mjs +26 -9
  25. package/lib/embed/customer-profiles.mjs +37 -17
  26. package/lib/embed/daemon.mjs +10 -8
  27. package/lib/embed/recommendation-store.mjs +213 -14
  28. package/lib/embed/workspaces.mjs +53 -18
  29. package/lib/gates-audit.mjs +3 -3
  30. package/lib/health-check.mjs +1 -1
  31. package/lib/hooks/pre-compact.mjs +3 -0
  32. package/lib/hooks/read-tracker.mjs +10 -101
  33. package/lib/host-capabilities.mjs +90 -1
  34. package/lib/init-update.mjs +246 -131
  35. package/lib/intent-classifier.mjs +1 -0
  36. package/lib/knowledge/layout.mjs +10 -0
  37. package/lib/mcp/tools/telemetry.mjs +30 -78
  38. package/lib/model-router.mjs +61 -1
  39. package/lib/ollama-manager.mjs +1 -1
  40. package/lib/opencode-telemetry.mjs +4 -5
  41. package/lib/orchestration-policy.mjs +9 -0
  42. package/lib/parity.mjs +124 -21
  43. package/lib/prompt-composer.js +106 -29
  44. package/lib/read-tracker-store.mjs +149 -0
  45. package/lib/server/index.mjs +76 -0
  46. package/lib/server/telemetry-login.mjs +17 -25
  47. package/lib/service-manager.mjs +30 -22
  48. package/lib/services/local-postgres.mjs +15 -0
  49. package/lib/services/telemetry-backend.mjs +1 -2
  50. package/lib/setup.mjs +8 -43
  51. package/lib/status.mjs +51 -5
  52. package/lib/storage/backend.mjs +12 -2
  53. package/lib/strategy-store.mjs +371 -0
  54. package/lib/telemetry/backends/local.mjs +6 -4
  55. package/lib/telemetry/client.mjs +185 -0
  56. package/lib/telemetry/ingest.mjs +13 -5
  57. package/lib/telemetry/team-rollup.mjs +9 -2
  58. package/lib/worker/trace.mjs +17 -27
  59. package/package.json +5 -2
  60. package/rules/common/research.md +44 -12
  61. package/skills/docs/backlog-proposal-workflow.md +2 -2
  62. package/skills/docs/customer-profile-workflow.md +1 -1
  63. package/skills/docs/evidence-ingest-workflow.md +5 -5
  64. package/skills/docs/prfaq-workflow.md +1 -1
  65. package/skills/docs/product-intelligence-review.md +1 -1
  66. package/skills/docs/product-intelligence-workflow.md +3 -3
  67. package/skills/docs/product-signal-workflow.md +48 -18
  68. package/skills/docs/research-workflow.md +26 -14
  69. package/skills/docs/strategy-workflow.md +36 -0
  70. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  71. package/skills/roles/researcher.md +28 -15
  72. package/skills/routing.md +8 -1
  73. package/templates/docs/research-brief.md +63 -9
  74. package/templates/docs/strategy.md +36 -0
  75. package/templates/homebrew/construct.rb +6 -6
@@ -0,0 +1,149 @@
1
+ /**
2
+ * lib/hooks/read-tracker-store.mjs — batched persistence for read-efficiency tracking.
3
+ *
4
+ * The Read hook runs in a fresh process for every file read. Persisting the
5
+ * full session-efficiency store on every invocation is correct but noisy and
6
+ * expensive. This module appends compact JSONL deltas per read, then folds
7
+ * them into the durable session summary when the delta log is flushed.
8
+ */
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ const SESSION_IDLE_RESET_MS = 2 * 60 * 60 * 1000;
14
+ const REPEATED_READ_WARNING_THRESHOLD = 5;
15
+ const REPEATED_READ_TIER2_THRESHOLD = 8;
16
+ const LARGE_READ_WARNING_THRESHOLD = 3;
17
+ const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
18
+ const FILES_LRU_CAP = 200;
19
+ const EFFICIENCY_LARGE_READ_LIMIT = 400;
20
+
21
+ export function readTrackerPaths(env = process.env) {
22
+ const home = env.CX_HOME_OVERRIDE || env.HOME || homedir();
23
+ const cxDir = join(home, '.cx');
24
+ return {
25
+ cxDir,
26
+ efficiencyStore: join(cxDir, 'session-efficiency.json'),
27
+ deltaLog: join(cxDir, 'session-efficiency.reads.jsonl'),
28
+ warnFlags: join(cxDir, 'warn-flags.txt'),
29
+ };
30
+ }
31
+
32
+ export function freshEfficiencyStats(nowIso) {
33
+ return {
34
+ sessionStartedAt: nowIso,
35
+ lastUpdatedAt: nowIso,
36
+ readCount: 0,
37
+ uniqueFileCount: 0,
38
+ repeatedReadCount: 0,
39
+ largeReadCount: 0,
40
+ totalBytesRead: 0,
41
+ warnings: {},
42
+ files: {},
43
+ };
44
+ }
45
+
46
+ export function loadEfficiencyStats(nowIso, env = process.env) {
47
+ const { efficiencyStore } = readTrackerPaths(env);
48
+ const fresh = freshEfficiencyStats(nowIso);
49
+ try {
50
+ const existing = JSON.parse(readFileSync(efficiencyStore, 'utf8'));
51
+ const lastUpdated = new Date(existing.lastUpdatedAt || 0).getTime();
52
+ if (!lastUpdated || Date.now() - lastUpdated > SESSION_IDLE_RESET_MS) return fresh;
53
+ return { ...fresh, ...existing, warnings: existing.warnings || {}, files: existing.files || {} };
54
+ } catch {
55
+ return fresh;
56
+ }
57
+ }
58
+
59
+ function appendWarning(message, env = process.env) {
60
+ try {
61
+ const { warnFlags } = readTrackerPaths(env);
62
+ appendFileSync(warnFlags, `${message}\n`);
63
+ } catch { /* best effort */ }
64
+ }
65
+
66
+ function topRepeatedPath(files) {
67
+ return Object.entries(files || {})
68
+ .map(([filePath, value]) => ({ filePath, count: Number(value?.count || 0) }))
69
+ .filter((entry) => entry.count > 1)
70
+ .sort((a, b) => b.count - a.count || a.filePath.localeCompare(b.filePath))[0];
71
+ }
72
+
73
+ export function applyReadDelta(stats, delta, env = process.env) {
74
+ const existingFile = stats.files[delta.path];
75
+ const isLargeRead = Number(delta.limit || 0) > EFFICIENCY_LARGE_READ_LIMIT;
76
+
77
+ stats.readCount += 1;
78
+ stats.totalBytesRead += Number(delta.size || 0);
79
+ if (isLargeRead) stats.largeReadCount += 1;
80
+ if (existingFile) stats.repeatedReadCount += 1;
81
+ else stats.uniqueFileCount += 1;
82
+
83
+ stats.files[delta.path] = {
84
+ count: Number(existingFile?.count || 0) + 1,
85
+ size: Number(delta.size || 0),
86
+ lastReadAt: delta.ts,
87
+ lastRequestedLimit: Number(delta.limit || 0),
88
+ };
89
+
90
+ const fileEntries = Object.entries(stats.files);
91
+ if (fileEntries.length > FILES_LRU_CAP) {
92
+ fileEntries.sort((a, b) => (a[1]?.lastReadAt || '').localeCompare(b[1]?.lastReadAt || ''));
93
+ const drop = fileEntries.length - FILES_LRU_CAP;
94
+ for (let i = 0; i < drop; i++) delete stats.files[fileEntries[i][0]];
95
+ }
96
+
97
+ if (stats.repeatedReadCount >= REPEATED_READ_WARNING_THRESHOLD && !stats.warnings.repeatedReads) {
98
+ const top = topRepeatedPath(stats.files);
99
+ const topNote = top ? ` Top repeat: ${top.filePath} (${top.count}x).` : '';
100
+ appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session.${topNote} Use rg or construct distill before re-reading more files.`, env);
101
+ stats.warnings.repeatedReads = delta.ts;
102
+ if (stats.repeatedReadCount >= REPEATED_READ_TIER2_THRESHOLD && !stats.warnings.repeatedReadsTier2) {
103
+ appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session — consider using construct distill or rg before re-reading entire files.`, env);
104
+ stats.warnings.repeatedReadsTier2 = delta.ts;
105
+ }
106
+ }
107
+
108
+ if (stats.largeReadCount >= LARGE_READ_WARNING_THRESHOLD && !stats.warnings.largeReads) {
109
+ appendWarning(`Efficiency: ${stats.largeReadCount} large reads this session — prefer rg/glob plus targeted reads under 400 lines.`, env);
110
+ stats.warnings.largeReads = delta.ts;
111
+ }
112
+
113
+ if (stats.totalBytesRead >= TOTAL_BYTES_WARNING_THRESHOLD && !stats.warnings.totalBytes) {
114
+ appendWarning(`Efficiency: ${Math.round(stats.totalBytesRead / 1024)} KB read this session — consider distill/query-focused retrieval or compact context before continuing.`, env);
115
+ stats.warnings.totalBytes = delta.ts;
116
+ }
117
+
118
+ stats.lastUpdatedAt = delta.ts;
119
+ return stats;
120
+ }
121
+
122
+ export function recordReadDelta(delta, env = process.env) {
123
+ const { cxDir, deltaLog } = readTrackerPaths(env);
124
+ mkdirSync(cxDir, { recursive: true });
125
+ appendFileSync(deltaLog, `${JSON.stringify(delta)}\n`, 'utf8');
126
+ }
127
+
128
+ export function flushReadTrackerDeltas({ nowIso = new Date().toISOString(), env = process.env } = {}) {
129
+ const { cxDir, deltaLog, efficiencyStore } = readTrackerPaths(env);
130
+ mkdirSync(cxDir, { recursive: true });
131
+ const stats = loadEfficiencyStats(nowIso, env);
132
+ if (!existsSync(deltaLog)) return stats;
133
+
134
+ const lines = readFileSync(deltaLog, 'utf8')
135
+ .split('\n')
136
+ .map((line) => line.trim())
137
+ .filter(Boolean);
138
+
139
+ for (const line of lines) {
140
+ try {
141
+ const delta = JSON.parse(line);
142
+ applyReadDelta(stats, delta, env);
143
+ } catch { /* best effort */ }
144
+ }
145
+
146
+ writeFileSync(efficiencyStore, `${JSON.stringify(stats, null, 2)}\n`);
147
+ rmSync(deltaLog, { force: true });
148
+ return stats;
149
+ }
@@ -1500,6 +1500,82 @@ const server = createServer(async (req, res) => {
1500
1500
  return;
1501
1501
  }
1502
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
+
1503
1579
  if (url.pathname === '/api/snapshots') {
1504
1580
  handleSnapshots(req, res);
1505
1581
  return;
@@ -1,21 +1,15 @@
1
1
  /**
2
- * lib/server/telemetry-login.mjs — magic-link bridge for a local telemetry backend.
2
+ * lib/server/telemetry-login.mjs — magic-link bridge for a configured telemetry UI.
3
3
  *
4
- * When a self-hosted telemetry backend (e.g. a self-hosted or cloud telemetry backend)
5
- * is running locally, this bridge turns the "Open Telemetry" link in the
6
- * dashboard into a one-click sign-in. The server fetches a CSRF token from
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 backend URL from env with backward compat.
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 backend…</title>
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 backend…</p>
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 backend's
67
- * NextAuth callback. Returns 502 when the backend is unreachable.
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 backend not configured</title>` +
80
- `<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a telemetry backend to use this feature.</pre>`);
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 backend unreachable</title>` +
91
- `<pre>Could not reach telemetry backend at ${escapeHtml(resolvedBaseUrl)}. Status: ${csrfResponse.status}. ` +
92
- `Try \`construct up\` first.</pre>`);
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 backend unreachable</title>` +
103
- `<pre>Could not reach telemetry backend: ${escapeHtml(err?.message || 'unknown error')}. ` +
104
- `Try \`construct up\` first.</pre>`);
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
-
@@ -20,8 +20,8 @@ import {
20
20
  pruneStashDir,
21
21
  verifyTelemetryKeys,
22
22
  isRemoteTelemetry,
23
- startManagedServices,
24
- } from './services/telemetry-backend.mjs';
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';
@@ -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
- if (telemetryUrl && isRemoteTelemetry(telemetryUrl)) {
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: `remote — ${telemetryUrl}`,
559
+ note: `${telemetryProviderLabel(liveEnv)} export — ${telemetryUrl}`,
540
560
  });
541
561
  } else {
542
- const composeRunner = detectDockerComposeFn();
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)
@@ -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 = 54332;
21
+ export const POSTGRES_LOCAL_PORT = 54329;
22
22
 
23
23
  export function isRemoteTelemetry(url = '') {
24
24
  if (!url) return false;
@@ -175,4 +175,3 @@ export async function startManagedServices({
175
175
  };
176
176
  }
177
177
 
178
-
package/lib/setup.mjs CHANGED
@@ -25,10 +25,6 @@ import { createSqlClient, closeSqlClient } from './storage/backend.mjs';
25
25
  import { getEmbeddingModelInfo, warmupEmbeddingModel } from './storage/embeddings-engine.mjs';
26
26
  import { restoreConstructDb } from './storage/postgres-backup.mjs';
27
27
  import { buildPressureGuardValues, installPressureGuardLaunchAgent, loadPressureGuardLaunchAgent } from './runtime-pressure.mjs';
28
- import {
29
- isRemoteTelemetry,
30
- startManagedServices,
31
- } from './services/telemetry-backend.mjs';
32
28
  import { consentToInstall } from './setup-prompts.mjs';
33
29
 
34
30
  const ROOT_DIR = path.resolve(import.meta.dirname, '..');
@@ -86,9 +82,8 @@ export function defaultVectorIndexPath(homeDir = HOME) {
86
82
  export async function buildManagedSetupValues({ homeDir = HOME, env = process.env, databaseUrl = '' } = {}) {
87
83
  const modelInfo = await getEmbeddingModelInfo({ env });
88
84
 
89
- // Local telemetry backend
90
85
  const values = {
91
- CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || 'remote',
86
+ CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || 'local',
92
87
  CONSTRUCT_VECTOR_INDEX_PATH: env.CONSTRUCT_VECTOR_INDEX_PATH || defaultVectorIndexPath(homeDir),
93
88
  CONSTRUCT_VECTOR_MODEL: env.CONSTRUCT_VECTOR_MODEL || modelInfo.model,
94
89
  ...buildPressureGuardValues({ env }),
@@ -533,37 +528,10 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
533
528
  console.log(` ${dockerInstallHint()}`);
534
529
  }
535
530
 
536
- // Local telemetry backend — starts Postgres via services/docker-compose.yml.
537
- // Skipped automatically when CONSTRUCT_TELEMETRY_URL points to a remote instance.
538
-
539
- let telemetryResult = { status: 'skipped', note: 'Docker not available' };
540
- if (dockerAvailable) {
541
- const svcConsent = await consentToInstall({
542
- name: 'telemetry',
543
- isYes,
544
- alreadyConfigured: isRemoteTelemetry(process.env.CONSTRUCT_TELEMETRY_URL || ''),
545
- alreadyConfiguredNote: 'CONSTRUCT_TELEMETRY_URL points to a remote instance — local stack not started.',
546
- envPath,
547
- });
548
- if (svcConsent.decision) {
549
- try {
550
- telemetryResult = await startManagedServices({
551
- rootDir,
552
- homeDir,
553
- env: process.env,
554
- composeRunner: dockerRunner,
555
- spawnDetached: spawnDetachedForSetup,
556
- });
557
- console.log(`Telemetry: ${telemetryResult.status} — ${telemetryResult.note || ''}`);
558
- } catch (err) {
559
- telemetryResult = { status: 'error', note: err?.message || 'spin-up failed' };
560
- console.log(`Telemetry: error — ${telemetryResult.note}`);
561
- }
562
- } else {
563
- telemetryResult = { status: 'skipped', note: svcConsent.note };
564
- console.log(`Telemetry: skipped (${svcConsent.note})`);
565
- }
566
- }
531
+ const telemetryResult = process.env.CONSTRUCT_TELEMETRY_URL
532
+ ? { status: 'configured', note: `remote export configured (${process.env.CONSTRUCT_TELEMETRY_URL})` }
533
+ : { status: 'local', note: 'local JSONL traces in .cx/traces; remote export optional' };
534
+ console.log(`Telemetry: ${telemetryResult.note}`);
567
535
 
568
536
  fs.mkdirSync(path.dirname(defaultVectorIndexPath(homeDir)), { recursive: true });
569
537
 
@@ -790,18 +758,15 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
790
758
  // Local-service summary — surfaces running local services. Same pattern
791
759
  // as `supabase start`, which prints all local URLs + keys after spin-up.
792
760
 
793
- const hasLocalTelemetry = telemetryResult?.status === 'started' || telemetryResult?.status === 'degraded';
794
761
  const hasLocalPostgres = serviceResult?.status === 'ok' && serviceResult?.databaseUrl?.includes('127.0.0.1');
795
762
  const vectorBackend = hasLocalPostgres
796
763
  ? { label: 'Postgres + pgvector', detail: `${serviceResult.databaseUrl} (384d embeddings, ${managedValues.CONSTRUCT_VECTOR_MODEL})` }
797
764
  : { label: 'JSON fallback', detail: `${managedValues.CONSTRUCT_VECTOR_INDEX_PATH} (${managedValues.CONSTRUCT_VECTOR_MODEL})` };
798
765
 
799
766
  console.log('\nLocal services:');
800
- if (hasLocalTelemetry) {
801
- console.log(` Telemetry: ${telemetryResult.note || 'started'}`);
802
- } else {
803
- console.log(` Telemetry: not started`);
804
- }
767
+ console.log(' Traces: local JSONL (.cx/traces)');
768
+ if (process.env.CONSTRUCT_TELEMETRY_URL) console.log(` Telemetry: remote export (${process.env.CONSTRUCT_TELEMETRY_URL})`);
769
+ else console.log(' Telemetry: remote export not configured');
805
770
  if (hasLocalPostgres) {
806
771
  console.log(` Postgres: ${serviceResult.databaseUrl}`);
807
772
  } else if (process.env.DATABASE_URL) {
package/lib/status.mjs CHANGED
@@ -24,6 +24,7 @@ import { describeSqlStore, sqlStoreHealth } from './storage/sql-store.mjs';
24
24
  import { describeVectorStore } from './storage/vector-store.mjs';
25
25
  import { describeSqlStoreHealth } from './storage/sql-store.mjs';
26
26
  import { triggerAutoBackfillIfSparse } from './telemetry/backfill.mjs';
27
+ import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
27
28
  const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
28
29
 
29
30
  function readJSON(path) {
@@ -317,18 +318,27 @@ async function defaultProbeService(service) {
317
318
  }
318
319
 
319
320
  async function fetchTelemetryStatus(env, { timeout = 2500 } = {}) {
321
+ const backend = resolveTraceBackend(env);
322
+ if (backend === 'local') return { status: 'healthy', summary: 'Local JSONL tracing enabled · remote export not configured', backend, provider: telemetryProviderLabel(env) };
323
+ if (backend === 'none') return { status: 'disabled', summary: 'Remote telemetry disabled · local JSONL tracing preserved', backend, provider: telemetryProviderLabel(env) };
324
+ if (backend === 'otel') {
325
+ const endpoint = (env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT ?? '').replace(/\/$/, '');
326
+ if (!endpoint) return { status: 'unavailable', summary: 'OTLP endpoint not configured', backend, provider: telemetryProviderLabel(env) };
327
+ return { status: 'configured', summary: `OTLP export configured · ${endpoint}`, backend, provider: telemetryProviderLabel(env) };
328
+ }
320
329
  const baseUrl = (env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
321
330
  const key = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
322
331
  const secret = env.CONSTRUCT_TELEMETRY_SECRET_KEY;
323
- if (!key || !secret) return { status: 'unavailable', summary: 'Telemetry credentials not configured' };
332
+ if (backend === 'langfuse' && (!key || !secret)) return { status: 'unavailable', summary: 'Langfuse credentials not configured', backend, provider: telemetryProviderLabel(env) };
324
333
  if (!baseUrl) return { status: 'unavailable', summary: 'CONSTRUCT_TELEMETRY_URL not set' };
325
334
  const headers = {
326
- Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`,
335
+ ...(key && secret ? { Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}` } : {}),
327
336
  };
328
337
  const controller = new AbortController();
329
338
  const timer = setTimeout(() => controller.abort(), timeout);
330
339
  try {
331
- const res = await fetch(`${baseUrl}/api/public/traces?limit=25`, { headers, signal: controller.signal });
340
+ const tracesPath = backend === 'http' ? '/traces?limit=25' : '/api/public/traces?limit=25';
341
+ const res = await fetch(`${baseUrl}${tracesPath}`, { headers, signal: controller.signal });
332
342
  if (res.status === 401 || res.status === 403) {
333
343
  return { status: 'credentials-invalid', summary: `Telemetry credentials rejected (HTTP ${res.status}) — run: construct init` };
334
344
  }
@@ -359,6 +369,8 @@ async function fetchTelemetryStatus(env, { timeout = 2500 } = {}) {
359
369
 
360
370
  return {
361
371
  status,
372
+ backend,
373
+ provider: telemetryProviderLabel(env),
362
374
  total,
363
375
  rich: counts.rich,
364
376
  partial: counts.partial,
@@ -478,6 +490,40 @@ export function buildPublicHealthSurface({
478
490
  }
479
491
 
480
492
  function traceBackendDefinition(env) {
493
+ const backend = resolveTraceBackend(env);
494
+ const provider = telemetryProviderLabel(env);
495
+ if (backend === 'local' || backend === 'none') {
496
+ return {
497
+ id: 'telemetry',
498
+ name: 'Telemetry',
499
+ url: 'local://.cx/traces',
500
+ runtime: 'local',
501
+ note: backend === 'none' ? 'Remote disabled; local JSONL preserved' : 'Local JSONL traces',
502
+ healthyMessage: 'Local trace capture enabled',
503
+ impactsOverall: false,
504
+ selfCheck: {
505
+ status: backend === 'none' ? 'disabled' : 'healthy',
506
+ message: backend === 'none' ? 'Remote telemetry disabled' : 'Writing .cx/traces/*.jsonl',
507
+ },
508
+ };
509
+ }
510
+ if (backend === 'otel') {
511
+ const endpoint = (env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT ?? '').replace(/\/$/, '');
512
+ return {
513
+ id: 'telemetry',
514
+ name: 'Telemetry',
515
+ url: endpoint || '(not configured)',
516
+ probeUrl: '',
517
+ runtime: 'remote',
518
+ note: `${provider} trace export`,
519
+ healthyMessage: 'Configured',
520
+ impactsOverall: false,
521
+ selfCheck: {
522
+ status: endpoint ? 'configured' : 'unavailable',
523
+ message: endpoint ? `OTLP export configured: ${endpoint}` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
524
+ },
525
+ };
526
+ }
481
527
  const url = (env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
482
528
  const pubKey = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY || '';
483
529
  const secKey = env.CONSTRUCT_TELEMETRY_SECRET_KEY || '';
@@ -486,10 +532,10 @@ function traceBackendDefinition(env) {
486
532
  id: 'telemetry',
487
533
  name: 'Telemetry',
488
534
  url: url || '(not configured)',
489
- probeUrl: url ? `${url}/api/public/traces?limit=1` : '',
535
+ probeUrl: url ? `${url}${backend === 'http' ? '/traces?limit=1' : '/api/public/traces?limit=1'}` : '',
490
536
  probeHeaders: auth ? { Authorization: auth } : undefined,
491
537
  runtime: 'live',
492
- note: 'Trace backend',
538
+ note: `${provider} trace export`,
493
539
  healthyMessage: 'Reachable',
494
540
  impactsOverall: false,
495
541
  };
@@ -1,17 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * lib/storage/backend.mjs — shared storage backend helpers for SQL/vector wiring.
4
+ *
5
+ * postgres is an optional peer dep. Top-level await catches the case where it is not
6
+ * installed (fresh installs, test temp dirs) so the module loads cleanly everywhere.
4
7
  */
5
- import postgres from 'postgres';
6
8
  import { resolveDatabaseUrl } from '../env-config.mjs';
7
9
 
10
+ let postgres = null;
11
+ try {
12
+ const mod = await import('postgres');
13
+ postgres = mod.default ?? mod;
14
+ } catch {
15
+ // postgres not installed — SQL features unavailable; createSqlClient returns null
16
+ }
17
+
8
18
  function cleanUrl(url) {
9
19
  return String(url || '').trim();
10
20
  }
11
21
 
12
22
  export function createSqlClient(env = process.env) {
13
23
  const databaseUrl = cleanUrl(resolveDatabaseUrl(env));
14
- if (!databaseUrl) return null;
24
+ if (!databaseUrl || !postgres) return null;
15
25
  return postgres(databaseUrl, {
16
26
  max: Number.parseInt(env.CONSTRUCT_DB_POOL_SIZE || '5', 10),
17
27
  idle_timeout: Number.parseInt(env.CONSTRUCT_DB_IDLE_TIMEOUT_MS || '30000', 10),