@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/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: '
|
|
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
|
|
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:
|
|
538
|
+
note: `${provider} trace export`,
|
|
493
539
|
healthyMessage: 'Reachable',
|
|
494
540
|
impactsOverall: false,
|
|
495
541
|
};
|
package/lib/storage/backend.mjs
CHANGED
|
@@ -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),
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
const lines = readFileSync(join(dir, file), 'utf8')
|
|
40
42
|
.split('\n')
|
|
41
43
|
.filter(Boolean);
|
|
42
44
|
for (const line of lines) {
|