@geraldmaron/construct 1.0.3 → 1.0.5
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/README.md +51 -42
- 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 +279 -4
- package/commands/understand/research.md +5 -3
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +3 -4
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +52 -2
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/flavors/loader.mjs +136 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +25 -2
- package/lib/init-update.mjs +246 -131
- package/lib/intake/classify.mjs +61 -183
- package/lib/intake/prepare.mjs +7 -0
- package/lib/intake/tables/creative.mjs +94 -0
- package/lib/intake/tables/operations.mjs +85 -0
- package/lib/intake/tables/research.mjs +85 -0
- package/lib/intake/tables/rnd.mjs +175 -0
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/research-store.mjs +109 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/observation-store.mjs +19 -0
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +121 -21
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- 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/worker/trace.mjs +17 -27
- package/package.json +10 -2
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/rules/common/research.md +44 -12
- package/scripts/sync-agents.mjs +11 -0
- 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/architect.ai-systems.md +4 -2
- package/skills/roles/architect.data.md +4 -2
- package/skills/roles/architect.enterprise.md +4 -2
- package/skills/roles/architect.integration.md +4 -2
- package/skills/roles/architect.md +7 -5
- package/skills/roles/architect.platform.md +4 -2
- package/skills/roles/data-analyst.experiment.md +4 -2
- package/skills/roles/data-analyst.md +9 -7
- package/skills/roles/data-analyst.product-intelligence.md +5 -3
- package/skills/roles/data-analyst.product.md +4 -2
- package/skills/roles/data-analyst.telemetry.md +4 -2
- package/skills/roles/data-engineer.pipeline.md +4 -2
- package/skills/roles/data-engineer.vector-retrieval.md +4 -2
- package/skills/roles/data-engineer.warehouse.md +4 -2
- package/skills/roles/debugger.md +7 -5
- package/skills/roles/designer.accessibility.md +4 -2
- package/skills/roles/designer.md +10 -8
- package/skills/roles/engineer.ai.md +4 -2
- package/skills/roles/engineer.data.md +5 -3
- package/skills/roles/engineer.md +14 -12
- package/skills/roles/engineer.platform.md +5 -3
- package/skills/roles/operator.docs.md +6 -4
- package/skills/roles/operator.md +6 -4
- package/skills/roles/operator.release.md +4 -2
- package/skills/roles/operator.sre.md +5 -3
- package/skills/roles/orchestrator.md +5 -3
- package/skills/roles/product-manager.ai-product.md +4 -2
- package/skills/roles/product-manager.business-strategy.md +4 -2
- package/skills/roles/product-manager.enterprise.md +4 -2
- package/skills/roles/product-manager.growth.md +4 -2
- package/skills/roles/product-manager.md +6 -4
- package/skills/roles/product-manager.platform.md +4 -2
- package/skills/roles/product-manager.product.md +4 -2
- package/skills/roles/qa.ai-eval.md +4 -2
- package/skills/roles/qa.api-contract.md +4 -2
- package/skills/roles/qa.data-pipeline.md +4 -2
- package/skills/roles/qa.md +7 -5
- package/skills/roles/qa.test-automation.md +5 -3
- package/skills/roles/qa.web-ui.md +4 -2
- package/skills/roles/researcher.explorer.md +4 -2
- package/skills/roles/researcher.md +35 -20
- package/skills/roles/researcher.ux.md +4 -2
- package/skills/roles/reviewer.devil-advocate.md +4 -2
- package/skills/roles/reviewer.evaluator.md +4 -2
- package/skills/roles/reviewer.md +14 -12
- package/skills/roles/reviewer.trace.md +4 -2
- package/skills/roles/security.ai.md +4 -2
- package/skills/roles/security.appsec.md +4 -2
- package/skills/roles/security.cloud.md +4 -2
- package/skills/roles/security.legal-compliance.md +4 -2
- package/skills/roles/security.md +7 -5
- package/skills/roles/security.privacy.md +4 -2
- package/skills/roles/security.supply-chain.md +4 -2
- package/skills/routing.md +8 -1
- package/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +6 -6
package/lib/reflect.mjs
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* knowledge base with actionable insights for continuous self-improvement.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { join, resolve } from 'node:path';
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join, resolve, basename } from 'node:path';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
10
11
|
import { addObservation } from './observation-store.mjs';
|
|
11
12
|
import { loadConstructEnv } from './env-config.mjs';
|
|
12
13
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS, inferKnowledgeTarget } from './knowledge/layout.mjs';
|
|
14
|
+
import { extractSessionObservation } from './reflect/extractor.mjs';
|
|
13
15
|
|
|
14
16
|
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
15
17
|
const USER_ENV_PATH = join(HOME, '.construct', 'config.env');
|
|
@@ -125,6 +127,91 @@ export async function runReflectCli(args) {
|
|
|
125
127
|
console.log(` Use 'construct ingest --target=${target} <file>' to manually add similar feedback in the future`);
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Auto-reflect from a Claude Code Stop hook payload.
|
|
132
|
+
*
|
|
133
|
+
* Reads the transcript JSONL, extracts a deterministic session summary, and
|
|
134
|
+
* persists it via the standard observation store. Contract: exit fast, fail
|
|
135
|
+
* silent, never throw — every error path returns null so the hook always
|
|
136
|
+
* exits 0.
|
|
137
|
+
*
|
|
138
|
+
* Returns the recorded observation id on success, null on no-op.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} args
|
|
141
|
+
* @param {string} args.transcriptPath — path to the Stop hook's transcript_path
|
|
142
|
+
* @param {string} args.cwd — session cwd (the project root)
|
|
143
|
+
* @param {string} [args.sessionId]
|
|
144
|
+
* @param {number} [args.durationMs]
|
|
145
|
+
* @returns {Promise<string|null>}
|
|
146
|
+
*/
|
|
147
|
+
export async function runReflectAuto({ transcriptPath, cwd, sessionId, durationMs }) {
|
|
148
|
+
if (!transcriptPath || !cwd) return null;
|
|
149
|
+
if (!existsSync(transcriptPath)) return null;
|
|
150
|
+
|
|
151
|
+
const entries = parseTranscript(transcriptPath);
|
|
152
|
+
if (entries.length === 0) return null;
|
|
153
|
+
|
|
154
|
+
const observation = extractSessionObservation({ entries, cwd, sessionId, durationMs });
|
|
155
|
+
if (!observation) return null;
|
|
156
|
+
|
|
157
|
+
// Skip trivial sessions: no tool calls AND no substantive final text. Avoids
|
|
158
|
+
// polluting the observation store with one-line acknowledgements.
|
|
159
|
+
const trivial =
|
|
160
|
+
observation.extras?.toolCallCount === 0 &&
|
|
161
|
+
(observation.content?.length || 0) < 120;
|
|
162
|
+
if (trivial) return null;
|
|
163
|
+
|
|
164
|
+
const project = safeProject(cwd);
|
|
165
|
+
const gitSha = safeGitSha(cwd);
|
|
166
|
+
|
|
167
|
+
// Session-id stays in tags so the index can filter by it without parsing.
|
|
168
|
+
// Structured metadata goes into the new extras field, not into content.
|
|
169
|
+
const sessionTag = sessionId ? `session:${sessionId.slice(0, 24)}` : null;
|
|
170
|
+
const tags = [...observation.tags];
|
|
171
|
+
if (sessionTag) tags.push(sessionTag);
|
|
172
|
+
|
|
173
|
+
const result = await addObservation(cwd, {
|
|
174
|
+
role: observation.role,
|
|
175
|
+
category: observation.category,
|
|
176
|
+
summary: observation.summary,
|
|
177
|
+
content: observation.content,
|
|
178
|
+
tags,
|
|
179
|
+
project,
|
|
180
|
+
gitSha,
|
|
181
|
+
confidence: observation.confidence,
|
|
182
|
+
source: observation.source,
|
|
183
|
+
extras: observation.extras ?? null,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return result?.id ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function safeProject(cwd) {
|
|
190
|
+
try { return basename(cwd) || null; } catch { return null; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function safeGitSha(cwd) {
|
|
194
|
+
try {
|
|
195
|
+
return execSync('git rev-parse HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 200 })
|
|
196
|
+
.toString().trim().slice(0, 40) || null;
|
|
197
|
+
} catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Transcripts are JSONL; one JSON object per line. Be permissive — a single
|
|
201
|
+
// malformed line shouldn't lose the rest of the session.
|
|
202
|
+
|
|
203
|
+
function parseTranscript(path) {
|
|
204
|
+
let raw = '';
|
|
205
|
+
try { raw = readFileSync(path, 'utf8'); } catch { return []; }
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const line of raw.split('\n')) {
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (!trimmed) continue;
|
|
210
|
+
try { out.push(JSON.parse(trimmed)); } catch { /* skip malformed */ }
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
128
215
|
/**
|
|
129
216
|
* CLI entry point for direct execution.
|
|
130
217
|
*/
|
package/lib/sandbox.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sandbox.mjs — Lightweight isolated environment for QA validation.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a fresh tmpdir with a minimal `.cx/` so a specialist (or operator)
|
|
5
|
+
* can run end-to-end checks against a candidate change without touching the
|
|
6
|
+
* working repo. Designed to be the cheapest possible alternative to a full
|
|
7
|
+
* Docker container: when Docker is unavailable, an isolated tmpdir is enough
|
|
8
|
+
* for almost every validation Construct needs.
|
|
9
|
+
*
|
|
10
|
+
* Layout created:
|
|
11
|
+
* <sandbox>/.cx/
|
|
12
|
+
* context.md stub
|
|
13
|
+
* observations/ empty
|
|
14
|
+
* intake/pending/ empty
|
|
15
|
+
* <sandbox>/.git/ empty repo init, so git-aware tools work
|
|
16
|
+
*
|
|
17
|
+
* Tear-down is opt-in via `construct sandbox prune` or by hand. Each sandbox
|
|
18
|
+
* lives under `~/.cx/sandboxes/<id>/` so they survive across CLI sessions.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.join(os.homedir(), '.cx', 'sandboxes');
|
|
26
|
+
|
|
27
|
+
function ensureRoot() {
|
|
28
|
+
fs.mkdirSync(ROOT, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function newSandboxId() {
|
|
32
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
33
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
34
|
+
return `${stamp}-${rand}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a new sandbox. Returns its absolute path.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} [opts]
|
|
41
|
+
* @param {string} [opts.profile] - profile id to write into construct.config.json
|
|
42
|
+
* @param {string} [opts.seedContext] - text to drop into .cx/context.md
|
|
43
|
+
* @returns {{ id: string, path: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function createSandbox({ profile = null, seedContext = null } = {}) {
|
|
46
|
+
ensureRoot();
|
|
47
|
+
const id = newSandboxId();
|
|
48
|
+
const root = path.join(ROOT, id);
|
|
49
|
+
fs.mkdirSync(root, { recursive: true });
|
|
50
|
+
fs.mkdirSync(path.join(root, '.cx', 'observations'), { recursive: true });
|
|
51
|
+
fs.mkdirSync(path.join(root, '.cx', 'intake', 'pending'), { recursive: true });
|
|
52
|
+
|
|
53
|
+
const ctx = seedContext || `# Sandbox ${id}\n\nIsolated environment for QA validation. Created ${new Date().toISOString()}.\n`;
|
|
54
|
+
fs.writeFileSync(path.join(root, '.cx', 'context.md'), ctx);
|
|
55
|
+
|
|
56
|
+
const cfg = { version: 1, alias: 'Construct', deployment: { mode: 'solo' } };
|
|
57
|
+
if (profile) cfg.profile = profile;
|
|
58
|
+
fs.writeFileSync(path.join(root, 'construct.config.json'), JSON.stringify(cfg, null, 2) + '\n');
|
|
59
|
+
|
|
60
|
+
spawnSync('git', ['init', '--quiet'], { cwd: root, stdio: 'ignore' });
|
|
61
|
+
|
|
62
|
+
return { id, path: root };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* List existing sandboxes, newest first.
|
|
67
|
+
*/
|
|
68
|
+
export function listSandboxes() {
|
|
69
|
+
if (!fs.existsSync(ROOT)) return [];
|
|
70
|
+
const entries = fs.readdirSync(ROOT, { withFileTypes: true })
|
|
71
|
+
.filter((d) => d.isDirectory())
|
|
72
|
+
.map((d) => {
|
|
73
|
+
const full = path.join(ROOT, d.name);
|
|
74
|
+
const stat = fs.statSync(full);
|
|
75
|
+
return { id: d.name, path: full, createdAt: stat.birthtime.toISOString() };
|
|
76
|
+
});
|
|
77
|
+
return entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Delete a sandbox by id. Returns true if removed, false if not found.
|
|
82
|
+
*/
|
|
83
|
+
export function deleteSandbox(id) {
|
|
84
|
+
const full = path.join(ROOT, id);
|
|
85
|
+
if (!fs.existsSync(full)) return false;
|
|
86
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prune sandboxes older than N days. Returns the count removed.
|
|
92
|
+
*/
|
|
93
|
+
export function pruneSandboxes({ olderThanDays = 7 } = {}) {
|
|
94
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
95
|
+
let removed = 0;
|
|
96
|
+
for (const s of listSandboxes()) {
|
|
97
|
+
if (Date.parse(s.createdAt) < cutoff) {
|
|
98
|
+
if (deleteSandbox(s.id)) removed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
package/lib/server/index.mjs
CHANGED
|
@@ -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
|
|
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';
|
|
@@ -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)
|
|
@@ -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;
|
|
@@ -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 || '
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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) {
|