@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,371 @@
1
+ /**
2
+ * lib/strategy-store.mjs — Multi-scope product strategy store.
3
+ *
4
+ * Strategy is organised by scope: product, technical, gtm, platform.
5
+ * Each scope lives in its own file under .cx/knowledge/decisions/strategy/{scope}.md.
6
+ * Strategy documents are NEVER auto-updated from ingested documents — they require
7
+ * an explicit writeStrategy() call from a privileged caller.
8
+ *
9
+ * In team/enterprise mode Postgres is the secondary store (best-effort). Each scope
10
+ * is stored as a separate row whose content is prefixed with `scope:{name}\n` so we
11
+ * can store and retrieve scopes without adding a new column to the existing schema.
12
+ *
13
+ * getStrategyDigest() / getStrategyDigestSync() return a compact block (≤ 500 tokens)
14
+ * suitable for injection into agent prompts. getStrategyDigestSync() is the file-only
15
+ * synchronous path required where await is not available (e.g. prompt assembly).
16
+ */
17
+
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
19
+ import { join, basename } from 'node:path';
20
+ import { cxDir } from './paths.mjs';
21
+ import { hasSqlStore } from './storage/sql-store.mjs';
22
+ import { createSqlClient } from './storage/backend.mjs';
23
+
24
+ const VALID_SCOPES = ['product', 'technical', 'gtm', 'platform'];
25
+ const SCOPE_PREFIX = 'scope:';
26
+
27
+ // ── Path helpers ──────────────────────────────────────────────────────────────
28
+
29
+ export function strategyDir(env = process.env) {
30
+ return join(cxDir(), 'knowledge', 'decisions', 'strategy');
31
+ }
32
+
33
+ export function strategyFilePath(scope = 'product', env = process.env) {
34
+ return join(strategyDir(env), `${scope}.md`);
35
+ }
36
+
37
+ // ── Scope discovery ───────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Return scope names that have corresponding files in the strategy directory.
41
+ *
42
+ * @param {object} [env]
43
+ * @returns {string[]}
44
+ */
45
+ export function listStrategyScopes(env = process.env) {
46
+ const dir = strategyDir(env);
47
+ if (!existsSync(dir)) return [];
48
+ try {
49
+ return readdirSync(dir)
50
+ .filter((f) => f.endsWith('.md'))
51
+ .map((f) => basename(f, '.md'));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ // ── Read helpers ──────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Read one strategy scope from Postgres (primary) or file (fallback).
61
+ *
62
+ * @param {string} [scope]
63
+ * @param {object} [env]
64
+ * @returns {Promise<{ content: string, version: number, updatedAt: string, source: 'postgres'|'file'|'none', scope: string }>}
65
+ */
66
+ export async function readStrategy(scope = 'product', env = process.env) {
67
+ if (hasSqlStore(env)) {
68
+ const client = createSqlClient(env);
69
+ try {
70
+ const project = env.CX_PROJECT || 'default';
71
+ const prefix = `${SCOPE_PREFIX}${scope}\n`;
72
+
73
+ // Rows stored with the scope prefix — match on content prefix
74
+ const rows = await client`
75
+ select content, version, updated_at
76
+ from construct_strategy
77
+ where project = ${project}
78
+ and content like ${prefix + '%'}
79
+ order by version desc
80
+ limit 1
81
+ `;
82
+
83
+ if (rows.length > 0) {
84
+ const row = rows[0];
85
+ return {
86
+ content: row.content.startsWith(prefix) ? row.content.slice(prefix.length) : row.content,
87
+ version: row.version,
88
+ updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
89
+ source: 'postgres',
90
+ scope,
91
+ };
92
+ }
93
+ } catch {
94
+ // Postgres unavailable — fall through to file
95
+ } finally {
96
+ if (client) await client.end({ timeout: 5 }).catch(() => {});
97
+ }
98
+ }
99
+
100
+ const filePath = strategyFilePath(scope, env);
101
+ if (existsSync(filePath)) {
102
+ const content = readFileSync(filePath, 'utf8');
103
+ return { content, version: 1, updatedAt: new Date().toISOString(), source: 'file', scope };
104
+ }
105
+
106
+ return { content: '', version: 0, updatedAt: new Date().toISOString(), source: 'none', scope };
107
+ }
108
+
109
+ /**
110
+ * Read all present strategy scopes.
111
+ *
112
+ * When Postgres is available it reads all rows for the project and parses scope
113
+ * from the prefix. Files are used as the fallback or supplement for scopes that
114
+ * have no Postgres row.
115
+ *
116
+ * @param {object} [env]
117
+ * @returns {Promise<Map<string, { content: string, version: number, updatedAt: string, source: string }>>}
118
+ */
119
+ export async function readAllStrategies(env = process.env) {
120
+ const result = new Map();
121
+
122
+ if (hasSqlStore(env)) {
123
+ const client = createSqlClient(env);
124
+ try {
125
+ const project = env.CX_PROJECT || 'default';
126
+
127
+ // Fetch all rows — latest version per scope
128
+ const rows = await client`
129
+ select distinct on (
130
+ substring(content from 1 for position(E'\n' in content))
131
+ ) content, version, updated_at
132
+ from construct_strategy
133
+ where project = ${project}
134
+ and content like ${SCOPE_PREFIX + '%'}
135
+ order by
136
+ substring(content from 1 for position(E'\n' in content)),
137
+ version desc
138
+ `;
139
+
140
+ for (const row of rows) {
141
+ const firstNewline = row.content.indexOf('\n');
142
+ if (firstNewline === -1) continue;
143
+ const header = row.content.slice(0, firstNewline);
144
+ if (!header.startsWith(SCOPE_PREFIX)) continue;
145
+ const rowScope = header.slice(SCOPE_PREFIX.length);
146
+ const content = row.content.slice(firstNewline + 1);
147
+ result.set(rowScope, {
148
+ content,
149
+ version: row.version,
150
+ updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
151
+ source: 'postgres',
152
+ });
153
+ }
154
+ } catch {
155
+ // Fall through to file-based reads
156
+ } finally {
157
+ if (client) await client.end({ timeout: 5 }).catch(() => {});
158
+ }
159
+ }
160
+
161
+ // Supplement / replace with file-based reads for any scope present on disk
162
+ const fileScopes = listStrategyScopes(env);
163
+ for (const fileScope of fileScopes) {
164
+ if (result.has(fileScope)) continue;
165
+ const filePath = strategyFilePath(fileScope, env);
166
+ try {
167
+ const content = readFileSync(filePath, 'utf8');
168
+ result.set(fileScope, {
169
+ content,
170
+ version: 1,
171
+ updatedAt: new Date().toISOString(),
172
+ source: 'file',
173
+ });
174
+ } catch {
175
+ // Skip unreadable files
176
+ }
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ // ── Digest helpers ────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Parse markdown into sections split on ## headers.
186
+ *
187
+ * @param {string} content
188
+ * @returns {Array<{ header: string, body: string }>}
189
+ */
190
+ function parseSections(content) {
191
+ const lines = content.split('\n');
192
+ const sections = [];
193
+ let current = null;
194
+
195
+ for (const line of lines) {
196
+ if (line.startsWith('## ')) {
197
+ if (current) sections.push(current);
198
+ current = { header: line.slice(3).trim(), body: '' };
199
+ } else if (current) {
200
+ current.body += line + '\n';
201
+ }
202
+ }
203
+ if (current) sections.push(current);
204
+ return sections;
205
+ }
206
+
207
+ /**
208
+ * Truncate a body to the first N sentences.
209
+ *
210
+ * @param {string} body
211
+ * @param {number} maxSentences
212
+ * @returns {string}
213
+ */
214
+ function firstSentences(body, maxSentences = 2) {
215
+ const trimmed = body.trim();
216
+ if (!trimmed) return '';
217
+ const sentences = trimmed.match(/[^.!?\n]+[.!?\n]+/g) || [trimmed];
218
+ return sentences.slice(0, maxSentences).join(' ').trim();
219
+ }
220
+
221
+ /**
222
+ * Build a compact markdown block from a single scope's content.
223
+ *
224
+ * @param {string} scopeName
225
+ * @param {string} content
226
+ * @param {number} charBudget — remaining character budget; mutated by reference via returned delta
227
+ * @returns {{ text: string, charsUsed: number }}
228
+ */
229
+ function buildScopeBlock(scopeName, content, charBudget) {
230
+ const sections = parseSections(content);
231
+ if (!sections.length) return { text: '', charsUsed: 0 };
232
+
233
+ const lines = [`### ${scopeName}`];
234
+ let used = scopeName.length + 5;
235
+
236
+ for (const { header, body } of sections) {
237
+ if (used >= charBudget) break;
238
+ const excerpt = firstSentences(body, 2);
239
+ const line = excerpt ? `**${header}:** ${excerpt}` : `**${header}**`;
240
+ lines.push(line);
241
+ used += line.length + 1;
242
+ }
243
+
244
+ const text = lines.join('\n');
245
+ return { text, charsUsed: used };
246
+ }
247
+
248
+ /**
249
+ * Return a compact strategy digest (≤ 500 tokens) for agent prompt injection.
250
+ *
251
+ * Reads all present scopes asynchronously. Returns empty string when no scopes exist.
252
+ *
253
+ * @param {object} [env]
254
+ * @returns {Promise<string>}
255
+ */
256
+ export async function getStrategyDigest(env = process.env) {
257
+ try {
258
+ const all = await readAllStrategies(env);
259
+ if (!all.size) return '';
260
+
261
+ const TOKEN_LIMIT = 500;
262
+ let charBudget = TOKEN_LIMIT * 4;
263
+ const parts = ['## Active strategy'];
264
+
265
+ for (const [scopeName, { content }] of all) {
266
+ if (!content) continue;
267
+ const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
268
+ if (text) {
269
+ parts.push(text);
270
+ charBudget -= charsUsed;
271
+ }
272
+ if (charBudget <= 0) break;
273
+ }
274
+
275
+ return parts.length > 1 ? parts.join('\n') : '';
276
+ } catch {
277
+ return '';
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Synchronous file-only strategy digest for prompt-composer.js.
283
+ *
284
+ * Reads all .md files present in the strategy directory directly. Never reads
285
+ * Postgres. Returns empty string when the directory is absent or empty.
286
+ *
287
+ * @param {object} [env]
288
+ * @returns {string}
289
+ */
290
+ export function getStrategyDigestSync(env = process.env) {
291
+ try {
292
+ const dir = strategyDir(env);
293
+ if (!existsSync(dir)) return '';
294
+
295
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
296
+ if (!files.length) return '';
297
+
298
+ const TOKEN_LIMIT = 500;
299
+ let charBudget = TOKEN_LIMIT * 4;
300
+ const parts = ['## Active strategy'];
301
+
302
+ for (const file of files) {
303
+ if (charBudget <= 0) break;
304
+ const scopeName = basename(file, '.md');
305
+ let content;
306
+ try {
307
+ content = readFileSync(join(dir, file), 'utf8');
308
+ } catch {
309
+ continue;
310
+ }
311
+ if (!content) continue;
312
+ const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
313
+ if (text) {
314
+ parts.push(text);
315
+ charBudget -= charsUsed;
316
+ }
317
+ }
318
+
319
+ return parts.length > 1 ? parts.join('\n') : '';
320
+ } catch {
321
+ return '';
322
+ }
323
+ }
324
+
325
+ // ── Write ─────────────────────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Write a strategy scope to file (always) and Postgres (best-effort if available).
329
+ *
330
+ * The scope parameter defaults to 'product'. When writing to Postgres the content
331
+ * is prefixed with `scope:{name}\n` to allow per-scope storage without a schema change.
332
+ *
333
+ * @param {string} content
334
+ * @param {string} [scope]
335
+ * @param {object} [opts]
336
+ * @param {string} [opts.updatedBy]
337
+ * @param {object} [opts.env]
338
+ */
339
+ export async function writeStrategy(content, scope = 'product', { updatedBy, env = process.env } = {}) {
340
+ const filePath = strategyFilePath(scope, env);
341
+ const dir = strategyDir(env);
342
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
343
+ writeFileSync(filePath, content, 'utf8');
344
+
345
+ if (!hasSqlStore(env)) return;
346
+
347
+ const client = createSqlClient(env);
348
+ try {
349
+ const project = env.CX_PROJECT || 'default';
350
+ const scopedContent = `${SCOPE_PREFIX}${scope}\n${content}`;
351
+ const prefix = `${SCOPE_PREFIX}${scope}\n`;
352
+
353
+ const existing = await client`
354
+ select version from construct_strategy
355
+ where project = ${project}
356
+ and content like ${prefix + '%'}
357
+ order by version desc
358
+ limit 1
359
+ `;
360
+ const nextVersion = existing.length > 0 ? existing[0].version + 1 : 1;
361
+
362
+ await client`
363
+ insert into construct_strategy (project, content, version, updated_by)
364
+ values (${project}, ${scopedContent}, ${nextVersion}, ${updatedBy ?? null})
365
+ `;
366
+ } catch {
367
+ // Best-effort Postgres write — file write already succeeded
368
+ } finally {
369
+ if (client) await client.end({ timeout: 5 }).catch(() => {});
370
+ }
371
+ }
@@ -7,11 +7,12 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import { homedir } from 'node:os';
11
10
 
12
11
  export const name = 'local';
13
12
 
14
- const TRACES_DIR = join(homedir(), '.cx', 'traces');
13
+ function tracesDir(env = process.env) {
14
+ return join(env.CX_TOOLKIT_DIR || process.cwd(), '.cx', 'traces');
15
+ }
15
16
 
16
17
  export async function isAvailable() {
17
18
  return true;
@@ -25,7 +26,8 @@ export async function isAvailable() {
25
26
  * @returns {Promise<object[]>}
26
27
  */
27
28
  export async function listTraces(teamId, windowMs) {
28
- if (!existsSync(TRACES_DIR)) return [];
29
+ const dir = tracesDir();
30
+ if (!existsSync(dir)) return [];
29
31
 
30
32
  const since = Date.now() - windowMs;
31
33
  const files = readdirSync(TRACES_DIR)
@@ -36,7 +38,7 @@ export async function listTraces(teamId, windowMs) {
36
38
 
37
39
  const traces = [];
38
40
  for (const file of files) {
39
- const lines = readFileSync(join(TRACES_DIR, file), 'utf8')
41
+ const lines = readFileSync(join(dir, file), 'utf8')
40
42
  .split('\n')
41
43
  .filter(Boolean);
42
44
  for (const line of lines) {
@@ -0,0 +1,185 @@
1
+ /**
2
+ * lib/telemetry/client.mjs — shared local-first telemetry adapter.
3
+ *
4
+ * Local JSONL trace capture is the default. Remote export is opt-in through
5
+ * CONSTRUCT_TRACE_BACKEND and never throws into callers.
6
+ */
7
+ import { existsSync, mkdirSync, appendFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { randomUUID } from 'node:crypto';
11
+
12
+ import { createIngestClient } from './ingest.mjs';
13
+
14
+ const DEFAULT_MAX_BATCH = 50;
15
+
16
+ export const TRACE_BACKENDS = new Set(['local', 'langfuse', 'http', 'otel', 'none']);
17
+
18
+ function cleanUrl(value = '') {
19
+ return String(value || '').replace(/\/$/, '');
20
+ }
21
+
22
+ function inferRemoteBackend(env) {
23
+ const url = cleanUrl(env.CONSTRUCT_TELEMETRY_URL);
24
+ const hasKeys = Boolean(env.CONSTRUCT_TELEMETRY_PUBLIC_KEY && env.CONSTRUCT_TELEMETRY_SECRET_KEY);
25
+ if (!url) return 'local';
26
+ return hasKeys ? 'langfuse' : 'http';
27
+ }
28
+
29
+ export function resolveTraceBackend(env = process.env) {
30
+ const raw = String(env.CONSTRUCT_TRACE_BACKEND || '').trim().toLowerCase();
31
+ if (!raw) return inferRemoteBackend(env);
32
+ if (raw === 'remote' || raw === 'telemetry') return inferRemoteBackend(env);
33
+ if (raw === 'off') return 'none';
34
+ return TRACE_BACKENDS.has(raw) ? raw : 'local';
35
+ }
36
+
37
+ export function telemetryProviderLabel(env = process.env) {
38
+ return env.CONSTRUCT_TELEMETRY_PROVIDER || resolveTraceBackend(env);
39
+ }
40
+
41
+ function resolveProjectRoot({ rootDir, env = process.env } = {}) {
42
+ return rootDir || env.CX_TOOLKIT_DIR || env.PWD || process.cwd();
43
+ }
44
+
45
+ function localTraceEnabled(env = process.env) {
46
+ return env.CONSTRUCT_TRACE_LOCAL_ENABLED !== '0';
47
+ }
48
+
49
+ function traceShard() {
50
+ return new Date().toISOString().slice(0, 10);
51
+ }
52
+
53
+ function appendLocalTelemetry(type, body, { rootDir, env = process.env, onError = () => {} } = {}) {
54
+ if (!localTraceEnabled(env)) return;
55
+ const projectRoot = resolveProjectRoot({ rootDir, env });
56
+ const dir = join(projectRoot, '.cx', 'traces');
57
+ try {
58
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
59
+ appendFileSync(join(dir, `${traceShard()}.jsonl`), `${JSON.stringify({
60
+ traceId: body?.traceId || body?.id || randomUUID(),
61
+ spanId: body?.id || randomUUID(),
62
+ eventType: `telemetry.${type}`,
63
+ telemetryType: type,
64
+ project: body?.metadata?.project || body?.project || null,
65
+ role: body?.metadata?.agent || body?.metadata?.agentName || body?.name || null,
66
+ metadata: body?.metadata || {},
67
+ input: body?.input,
68
+ output: body?.output,
69
+ createdAt: body?.timestamp || body?.startTime || new Date().toISOString(),
70
+ })}\n`, 'utf8');
71
+ } catch (err) {
72
+ onError(err);
73
+ }
74
+ }
75
+
76
+ function buildOtlpPayload(batch) {
77
+ return {
78
+ resourceSpans: [{
79
+ resource: {
80
+ attributes: [
81
+ { key: 'service.name', value: { stringValue: 'construct' } },
82
+ ],
83
+ },
84
+ scopeSpans: [{
85
+ scope: { name: 'construct.telemetry' },
86
+ spans: batch.map((item) => {
87
+ const body = item.body || {};
88
+ const startedAt = Date.parse(body.startTime || body.timestamp || item.timestamp || new Date().toISOString());
89
+ const endedAt = Date.parse(body.endTime || body.startTime || body.timestamp || item.timestamp || new Date().toISOString());
90
+ return {
91
+ traceId: String(body.traceId || body.id || item.id).replace(/[^a-fA-F0-9]/g, '').padEnd(32, '0').slice(0, 32),
92
+ spanId: String(body.id || item.id).replace(/[^a-fA-F0-9]/g, '').padEnd(16, '0').slice(0, 16),
93
+ name: body.name || item.type,
94
+ kind: 1,
95
+ startTimeUnixNano: String(Math.max(0, startedAt) * 1_000_000),
96
+ endTimeUnixNano: String(Math.max(0, endedAt) * 1_000_000),
97
+ attributes: [
98
+ { key: 'construct.telemetry.type', value: { stringValue: item.type } },
99
+ ...(body.model ? [{ key: 'llm.model_name', value: { stringValue: String(body.model) } }] : []),
100
+ ],
101
+ };
102
+ }),
103
+ }],
104
+ }],
105
+ };
106
+ }
107
+
108
+ function createRemoteClient({ backend, env, fetchImpl, onError }) {
109
+ if (backend === 'local' || backend === 'none') return null;
110
+ if (backend === 'otel') {
111
+ const endpoint = cleanUrl(env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT);
112
+ return createIngestClient({
113
+ baseUrl: endpoint,
114
+ endpointPath: endpoint.endsWith('/v1/traces') ? '' : '/v1/traces',
115
+ authMode: 'none',
116
+ payloadBuilder: buildOtlpPayload,
117
+ fetchImpl,
118
+ onError,
119
+ maxBatch: DEFAULT_MAX_BATCH,
120
+ });
121
+ }
122
+ if (backend === 'http') {
123
+ return createIngestClient({
124
+ baseUrl: cleanUrl(env.CONSTRUCT_TELEMETRY_URL),
125
+ endpointPath: '/ingest',
126
+ authMode: 'none',
127
+ fetchImpl,
128
+ onError,
129
+ maxBatch: DEFAULT_MAX_BATCH,
130
+ });
131
+ }
132
+ return createIngestClient({
133
+ baseUrl: cleanUrl(env.CONSTRUCT_TELEMETRY_URL),
134
+ publicKey: env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
135
+ secretKey: env.CONSTRUCT_TELEMETRY_SECRET_KEY,
136
+ fetchImpl,
137
+ onError,
138
+ maxBatch: DEFAULT_MAX_BATCH,
139
+ });
140
+ }
141
+
142
+ export function createTelemetryClient({
143
+ env = process.env,
144
+ rootDir,
145
+ fetchImpl = globalThis.fetch,
146
+ onError = () => {},
147
+ localWrites = true,
148
+ } = {}) {
149
+ const backend = resolveTraceBackend(env);
150
+ const remote = createRemoteClient({ backend, env, fetchImpl, onError });
151
+ const localAvailable = localWrites && localTraceEnabled(env);
152
+ const remoteAvailable = Boolean(remote?.available);
153
+
154
+ function emit(type, body) {
155
+ if (!body) return;
156
+ if (localWrites) appendLocalTelemetry(type, body, { rootDir, env, onError });
157
+ if (!remoteAvailable) return;
158
+ try {
159
+ remote[type]?.(body);
160
+ } catch (err) {
161
+ onError(err);
162
+ }
163
+ }
164
+
165
+ return {
166
+ backend,
167
+ provider: telemetryProviderLabel(env),
168
+ available: localAvailable || remoteAvailable,
169
+ localAvailable,
170
+ remoteAvailable,
171
+ remoteStatus: remoteAvailable ? 'configured' : backend === 'local' || backend === 'none' ? backend : 'unconfigured',
172
+ trace: (body) => emit('trace', body),
173
+ traceUpdate: (body) => emit('traceUpdate', body),
174
+ generation: (body) => emit('generation', body),
175
+ generationUpdate: (body) => emit('generationUpdate', body),
176
+ span: (body) => emit('span', body),
177
+ spanUpdate: (body) => emit('spanUpdate', body),
178
+ event: (body) => emit('event', body),
179
+ score: (body) => emit('score', body),
180
+ async flush() {
181
+ if (remote?.flush) await remote.flush();
182
+ },
183
+ queueSize: () => remote?.queueSize?.() ?? 0,
184
+ };
185
+ }
@@ -35,17 +35,21 @@ export function createIngestClient({
35
35
  ).replace(/\/$/, ''),
36
36
  publicKey = process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
37
37
  secretKey = process.env.CONSTRUCT_TELEMETRY_SECRET_KEY,
38
+ endpointPath = '/api/public/ingestion',
39
+ authMode = 'basic',
40
+ payloadBuilder = (batch) => ({ batch }),
38
41
  flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS,
39
42
  maxBatch = DEFAULT_MAX_BATCH,
40
43
  onError = () => {},
41
44
  fetchImpl = globalThis.fetch,
42
45
  } = {}) {
43
- const available = Boolean(publicKey && secretKey && fetchImpl);
46
+ const requiresBasicAuth = authMode === 'basic';
47
+ const available = Boolean(baseUrl && fetchImpl && (!requiresBasicAuth || (publicKey && secretKey)));
44
48
  const queue = [];
45
49
  let flushTimer = null;
46
50
  let inflight = Promise.resolve();
47
51
 
48
- const authHeader = available
52
+ const authHeader = available && requiresBasicAuth
49
53
  ? `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`
50
54
  : '';
51
55
 
@@ -61,14 +65,17 @@ export function createIngestClient({
61
65
  async function flushBatch() {
62
66
  if (!available || queue.length === 0) return;
63
67
  const batch = queue.splice(0, maxBatch);
64
- const payload = JSON.stringify({ batch });
68
+ const payload = JSON.stringify(payloadBuilder(batch));
65
69
  const prev = inflight;
66
70
  inflight = (async () => {
67
71
  try { await prev; } catch { /* ignore prior */ }
68
72
  try {
69
- const res = await fetchImpl(`${baseUrl}/api/public/ingestion`, {
73
+ const res = await fetchImpl(`${baseUrl}${endpointPath}`, {
70
74
  method: 'POST',
71
- headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
75
+ headers: {
76
+ ...(authHeader ? { Authorization: authHeader } : {}),
77
+ 'Content-Type': 'application/json',
78
+ },
72
79
  body: payload,
73
80
  });
74
81
  if (!res.ok && res.status !== 207) {
@@ -104,6 +111,7 @@ export function createIngestClient({
104
111
  span: (body) => push('span-create', body),
105
112
  spanUpdate: (body) => push('span-update', body),
106
113
  event: (body) => push('event-create', body),
114
+ score: (body) => push('score-create', body),
107
115
  async flush() {
108
116
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
109
117
  while (queue.length > 0) await flushBatch();
@@ -3,22 +3,29 @@
3
3
  *
4
4
  * Groups agent traces by teamId (stamped by construct headhunt at overlay creation),
5
5
  * computes per-team success rates, latency, handoff counts, and common blockers.
6
- * Backend is selected via CONSTRUCT_TRACE_BACKEND env var (default: remote).
6
+ * Backend is selected via CONSTRUCT_TRACE_BACKEND env var (default: local).
7
7
  * Called by 'construct team review'.
8
8
  */
9
9
 
10
10
  import * as remoteBackend from './backends/remote.mjs';
11
+ import * as localBackend from './backends/local.mjs';
11
12
  import * as noopBackend from './backends/noop.mjs';
13
+ import { resolveTraceBackend } from './client.mjs';
12
14
 
13
15
  const BACKENDS = {
16
+ local: localBackend,
17
+ langfuse: remoteBackend,
18
+ http: remoteBackend,
19
+ otel: noopBackend,
14
20
  remote: remoteBackend,
21
+ none: noopBackend,
15
22
  noop: noopBackend,
16
23
  };
17
24
 
18
25
  const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
19
26
 
20
27
  function resolveBackend() {
21
- const name = process.env.CONSTRUCT_TRACE_BACKEND ?? 'remote';
28
+ const name = resolveTraceBackend(process.env);
22
29
  const backend = BACKENDS[name];
23
30
  if (!backend) throw new Error(`Unknown trace backend: ${name}. Available: ${Object.keys(BACKENDS).join(', ')}`);
24
31
  return backend;