@geraldmaron/construct 1.0.21 → 1.0.24
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 +23 -7
- package/bin/construct +206 -228
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +31 -10
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +39 -163
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +72 -10
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +169 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +59 -252
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +104 -480
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -43
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +7 -6
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -11
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +427 -107
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +3 -12
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +28 -36
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +15 -15
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/server/static/index.html +0 -1
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/vector-store.mjs +0 -100
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/document-export.mjs — markdown → PDF / DOCX / HTML export via external
|
|
3
|
+
* binaries (Pandoc + Typst), per ADR-0024.
|
|
4
|
+
*
|
|
5
|
+
* Bound to system-binary tooling discovered at runtime, never bundled in core
|
|
6
|
+
* (ADR-0001 zero-npm-core; ADR-0014 optional-capability pattern). Both engines
|
|
7
|
+
* are spawned as separate processes — Pandoc isolates its GPLv2+ licence at
|
|
8
|
+
* the process boundary; Typst is the Apache-2.0 PDF engine driven through
|
|
9
|
+
* Pandoc's `--pdf-engine=typst` flag, so a single Pandoc invocation produces
|
|
10
|
+
* every target format the contract supports.
|
|
11
|
+
*
|
|
12
|
+
* detect(format) reports availability without spawning the engine for real.
|
|
13
|
+
* exportMarkdown({ inputPath, outputPath, format, env }) spawns the engine and
|
|
14
|
+
* returns a structured result. Both surface actionable "install X" guidance
|
|
15
|
+
* when a required binary is absent; neither throws on missing tooling.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
export const EXPORT_FORMATS = ['pdf', 'docx', 'html'];
|
|
23
|
+
|
|
24
|
+
const FORMAT_ENGINES = {
|
|
25
|
+
pdf: { engine: 'pandoc', pdfEngine: 'typst', extraBinaries: ['typst'] },
|
|
26
|
+
docx: { engine: 'pandoc', pdfEngine: null, extraBinaries: [] },
|
|
27
|
+
html: { engine: 'pandoc', pdfEngine: null, extraBinaries: [] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function whichBin(name, env = process.env) {
|
|
31
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
32
|
+
const result = spawnSync(cmd, [name], { encoding: 'utf8', env });
|
|
33
|
+
if (result.status !== 0) return null;
|
|
34
|
+
const first = (result.stdout || '').trim().split('\n')[0];
|
|
35
|
+
return first || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function binVersion(name, args = ['--version'], env = process.env) {
|
|
39
|
+
const r = spawnSync(name, args, { encoding: 'utf8', env });
|
|
40
|
+
if (r.status !== 0) return null;
|
|
41
|
+
const line = (r.stdout || '').trim().split('\n')[0];
|
|
42
|
+
return line || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function installHint(name) {
|
|
46
|
+
if (name === 'pandoc') return 'Install pandoc to enable document export (e.g. `brew install pandoc` on macOS, `apt install pandoc` on Debian/Ubuntu, or https://pandoc.org/installing.html).';
|
|
47
|
+
if (name === 'typst') return 'Install typst to enable PDF export via Pandoc (`brew install typst` on macOS, https://github.com/typst/typst/releases for binaries).';
|
|
48
|
+
return `Install ${name} to enable this export format.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// detect returns the same structure regardless of format so callers can render
|
|
52
|
+
// availability uniformly. `present` is the single boolean a caller should gate
|
|
53
|
+
// on; `missing` enumerates the binaries to install when present is false.
|
|
54
|
+
|
|
55
|
+
export function detect(format, env = process.env) {
|
|
56
|
+
const config = FORMAT_ENGINES[format];
|
|
57
|
+
if (!config) return { ok: false, format, present: false, missing: [], message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
|
|
58
|
+
const required = [config.engine, ...(config.extraBinaries || [])];
|
|
59
|
+
const status = required.map((name) => ({ name, path: whichBin(name, env), version: null }));
|
|
60
|
+
for (const s of status) if (s.path) s.version = binVersion(s.name, ['--version'], env);
|
|
61
|
+
const missing = status.filter((s) => !s.path).map((s) => s.name);
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
format,
|
|
65
|
+
present: missing.length === 0,
|
|
66
|
+
binaries: status,
|
|
67
|
+
missing,
|
|
68
|
+
message: missing.length === 0
|
|
69
|
+
? `Ready: ${status.map((s) => `${s.name} (${s.version?.split(' ').slice(0, 2).join(' ') ?? 'unknown'})`).join(', ')}`
|
|
70
|
+
: missing.map(installHint).join(' '),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultOutputPath(inputPath, format) {
|
|
75
|
+
const parsed = path.parse(inputPath);
|
|
76
|
+
return path.join(parsed.dir, `${parsed.name}.${format}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// exportMarkdown is the single contract callers depend on (CLI / MCP / SDK).
|
|
80
|
+
// Returns { ok, format, inputPath, outputPath, engine, missing?, message }.
|
|
81
|
+
// ok=false with a populated `missing` means a required binary is absent — the
|
|
82
|
+
// caller surfaces `message` (which carries the install hint) instead of
|
|
83
|
+
// retrying or crashing.
|
|
84
|
+
|
|
85
|
+
export function exportMarkdown({ inputPath, outputPath, format, env = process.env, spawnFn = spawnSync } = {}) {
|
|
86
|
+
if (!inputPath) return { ok: false, format, message: 'exportMarkdown: inputPath is required.' };
|
|
87
|
+
if (!EXPORT_FORMATS.includes(format)) return { ok: false, format, inputPath, message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
|
|
88
|
+
if (!fs.existsSync(inputPath)) return { ok: false, format, inputPath, message: `exportMarkdown: input does not exist: ${inputPath}` };
|
|
89
|
+
|
|
90
|
+
const detection = detect(format, env);
|
|
91
|
+
if (!detection.present) {
|
|
92
|
+
return { ok: false, format, inputPath, missing: detection.missing, message: detection.message };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const config = FORMAT_ENGINES[format];
|
|
96
|
+
const target = outputPath || defaultOutputPath(inputPath, format);
|
|
97
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
98
|
+
|
|
99
|
+
const args = ['-f', 'markdown', '-o', target, '--standalone'];
|
|
100
|
+
if (config.pdfEngine) args.push(`--pdf-engine=${config.pdfEngine}`);
|
|
101
|
+
args.push(inputPath);
|
|
102
|
+
|
|
103
|
+
const result = spawnFn(config.engine, args, { encoding: 'utf8', env });
|
|
104
|
+
if (result.status !== 0) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
format,
|
|
108
|
+
inputPath,
|
|
109
|
+
outputPath: target,
|
|
110
|
+
engine: config.engine,
|
|
111
|
+
message: `Export failed (${config.engine} exit ${result.status}): ${(result.stderr || result.stdout || '').trim().slice(0, 400)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
format,
|
|
118
|
+
inputPath,
|
|
119
|
+
outputPath: target,
|
|
120
|
+
engine: config.engine,
|
|
121
|
+
pdfEngine: config.pdfEngine,
|
|
122
|
+
message: `Wrote ${path.relative(process.cwd(), target)}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -1077,7 +1077,7 @@ this.#scheduler.register(
|
|
|
1077
1077
|
method: 'POST',
|
|
1078
1078
|
headers: {
|
|
1079
1079
|
Authorization: `Bearer ${orKey}`, 'content-type': 'application/json',
|
|
1080
|
-
'HTTP-Referer': 'https://github.com/construct',
|
|
1080
|
+
'HTTP-Referer': 'https://github.com/geraldmaron/construct',
|
|
1081
1081
|
},
|
|
1082
1082
|
body: JSON.stringify({
|
|
1083
1083
|
model: modelId.replace(/^openrouter\//, ''),
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -241,7 +241,7 @@ function inferFileClassification(filePath) {
|
|
|
241
241
|
* Summary = first non-empty line of extracted text (≤120 chars).
|
|
242
242
|
* Content = first 1500 chars of extracted text for RAG searchability.
|
|
243
243
|
*/
|
|
244
|
-
function recordInboxObservation(rootDir, { sourcePath, outputPath, characters, extractedText, category, knowledgeSubdir }) {
|
|
244
|
+
async function recordInboxObservation(rootDir, { sourcePath, outputPath, characters, extractedText, category, knowledgeSubdir }) {
|
|
245
245
|
const lines = (extractedText ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
246
246
|
const firstLine = (lines[0] ?? '').slice(0, 120);
|
|
247
247
|
const filename = sourcePath.split('/').pop();
|
|
@@ -251,7 +251,10 @@ function recordInboxObservation(rootDir, { sourcePath, outputPath, characters, e
|
|
|
251
251
|
const content = (extractedText ?? '').slice(0, 1500);
|
|
252
252
|
const tags = ['inbox', 'ingested-doc', category, `knowledge:${knowledgeSubdir}`];
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
// Await the write so callers (poll) know ingestion is complete. Fire-and-
|
|
255
|
+
// forget here let the LanceDB write outlive poll() and race the caller's
|
|
256
|
+
// teardown, surfacing as "ENOTEMPTY: directory not empty" on .cx cleanup.
|
|
257
|
+
await addObservation(rootDir, {
|
|
255
258
|
role: 'construct',
|
|
256
259
|
category,
|
|
257
260
|
summary,
|
|
@@ -296,17 +299,22 @@ export class InboxWatcher {
|
|
|
296
299
|
#rootDir;
|
|
297
300
|
#env;
|
|
298
301
|
#cwd;
|
|
302
|
+
#prepareIntakeFn;
|
|
299
303
|
|
|
300
304
|
/**
|
|
301
305
|
* @param {object} opts
|
|
302
306
|
* @param {string} opts.rootDir - Root dir for observation store + state file
|
|
303
307
|
* @param {object} [opts.env] - Env override (default: process.env)
|
|
304
308
|
* @param {string} [opts.cwd] - Working dir for ingest output (default: rootDir)
|
|
309
|
+
* @param {Function} [opts.prepareIntakeFn] - Override for prepareIntakeForIngestedFile
|
|
310
|
+
* (test seam): receives the same args; returns null/throws to simulate a failed
|
|
311
|
+
* packet creation so tests can assert the manifest-gate behavior (construct-k4bg).
|
|
305
312
|
*/
|
|
306
|
-
constructor({ rootDir, env = process.env, cwd } = {}) {
|
|
313
|
+
constructor({ rootDir, env = process.env, cwd, prepareIntakeFn } = {}) {
|
|
307
314
|
this.#rootDir = rootDir;
|
|
308
315
|
this.#env = env;
|
|
309
316
|
this.#cwd = cwd ?? rootDir;
|
|
317
|
+
this.#prepareIntakeFn = prepareIntakeFn ?? prepareIntakeForIngestedFile;
|
|
310
318
|
}
|
|
311
319
|
|
|
312
320
|
/**
|
|
@@ -409,7 +417,7 @@ export class InboxWatcher {
|
|
|
409
417
|
const droppedInfo = fileResult.droppedInfo ?? [];
|
|
410
418
|
writeExtractionWarning({ sourcePath: candidate.path, droppedInfo });
|
|
411
419
|
|
|
412
|
-
recordInboxObservation(this.#rootDir, {
|
|
420
|
+
await recordInboxObservation(this.#rootDir, {
|
|
413
421
|
sourcePath: candidate.path,
|
|
414
422
|
outputPath: fileResult.outputPath,
|
|
415
423
|
characters: fileResult.characters,
|
|
@@ -423,13 +431,15 @@ export class InboxWatcher {
|
|
|
423
431
|
extractedText,
|
|
424
432
|
});
|
|
425
433
|
|
|
426
|
-
// Queue an intake packet so the agent can triage the new
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
434
|
+
// Queue an intake packet so the agent can triage the new signal
|
|
435
|
+
// against the existing corpus. Failure here is non-fatal — ingest
|
|
436
|
+
// already succeeded; the queue is a best-effort handoff, not the
|
|
437
|
+
// ingestion's source of truth.
|
|
438
|
+
|
|
430
439
|
let intakeId = null;
|
|
440
|
+
let intakeFailed = false;
|
|
431
441
|
try {
|
|
432
|
-
const intakeResult = await
|
|
442
|
+
const intakeResult = await this.#prepareIntakeFn({
|
|
433
443
|
rootDir: this.#rootDir,
|
|
434
444
|
ingestedFile: {
|
|
435
445
|
sourcePath: candidate.path,
|
|
@@ -441,7 +451,9 @@ export class InboxWatcher {
|
|
|
441
451
|
env: this.#env,
|
|
442
452
|
});
|
|
443
453
|
intakeId = intakeResult?.id || null;
|
|
454
|
+
if (!intakeId) intakeFailed = true;
|
|
444
455
|
} catch (err) {
|
|
456
|
+
intakeFailed = true;
|
|
445
457
|
if (this.#env.CONSTRUCT_DEBUG_INTAKE === '1') {
|
|
446
458
|
process.stderr.write(`[inbox] intake queue write failed for ${candidate.path}: ${err.message}\n`);
|
|
447
459
|
}
|
|
@@ -449,7 +461,14 @@ export class InboxWatcher {
|
|
|
449
461
|
|
|
450
462
|
state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), outputPath: fileResult.outputPath };
|
|
451
463
|
|
|
452
|
-
|
|
464
|
+
// The dedup manifest entry is the signal that `construct intake
|
|
465
|
+
// process` uses to skip already-handled files. Recording it when
|
|
466
|
+
// packet creation failed strands the file: explicit `intake process`
|
|
467
|
+
// reports "skipped (unchanged)" while `intake list` is empty, with
|
|
468
|
+
// no path back. Record only on a successful intake id so a failed
|
|
469
|
+
// auto-ingest stays retriable via the explicit path (construct-k4bg).
|
|
470
|
+
|
|
471
|
+
if (manifestEnabled && candidateSha && intakeId && !intakeFailed) {
|
|
453
472
|
try {
|
|
454
473
|
recordFile(this.#rootDir, candidateSha, {
|
|
455
474
|
sourcePath: candidate.path,
|
|
@@ -462,6 +481,8 @@ export class InboxWatcher {
|
|
|
462
481
|
process.stderr.write(`[inbox] manifest record failed for ${candidate.path}: ${err.message}\n`);
|
|
463
482
|
}
|
|
464
483
|
}
|
|
484
|
+
} else if (manifestEnabled && intakeFailed) {
|
|
485
|
+
process.stderr.write(`[inbox] ${candidate.path}: ingest succeeded but intake-packet creation failed; rerun \`construct intake process\` to retry.\n`);
|
|
465
486
|
}
|
|
466
487
|
|
|
467
488
|
processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId, dedupHash: candidateSha });
|
|
@@ -7,25 +7,12 @@
|
|
|
7
7
|
* dismissal, automatic suppression after 7 days, and re-surfacing when
|
|
8
8
|
* new signals arrive.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Construct now uses embedded LanceDB for vectors; recommendations
|
|
11
|
+
* remain filesystem-primary for simplicity and Git-backed collaboration.
|
|
12
|
+
*
|
|
13
|
+
* Storage:
|
|
11
14
|
* ~/.cx/intake/recommendations.jsonl — append-only log
|
|
12
15
|
* ~/.cx/intake/recommendations-index.json — fast lookup by id
|
|
13
|
-
*
|
|
14
|
-
* Storage (team/enterprise mode — DATABASE_URL configured):
|
|
15
|
-
* construct_recommendations table in Postgres (see db/schema/004_recommendations.sql)
|
|
16
|
-
* JSONL store is also written as a local backup.
|
|
17
|
-
*
|
|
18
|
-
* Prioritization formula:
|
|
19
|
-
* score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
|
|
20
|
-
* P0: score >= 10, P1: >= 7, P2: >= 4, P3: < 4
|
|
21
|
-
*
|
|
22
|
-
* Enrichment state machine: every new record starts with state: 'raw'. The
|
|
23
|
-
* enrichment loop (subscriber to recommendation.generated, lifecycle event
|
|
24
|
-
* emitted from createRecommendation on the new-record path only) transitions
|
|
25
|
-
* raw → enriching → enriched | enrichment_failed and writes enrichedAt /
|
|
26
|
-
* enrichedBy / framingNotes back to the index. Dedup-update path does not
|
|
27
|
-
* re-emit so a steady drip of signals against the same recommendation does
|
|
28
|
-
* not retrigger enrichment.
|
|
29
16
|
*/
|
|
30
17
|
|
|
31
18
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
@@ -33,8 +20,6 @@ import { join } from 'node:path';
|
|
|
33
20
|
import { randomUUID } from 'node:crypto';
|
|
34
21
|
import { emitBestEffort } from '../roles/event-bus.mjs';
|
|
35
22
|
import { cxDir } from '../paths.mjs';
|
|
36
|
-
import { hasSqlStore } from '../storage/sql-store.mjs';
|
|
37
|
-
import { createSqlClient } from '../storage/backend.mjs';
|
|
38
23
|
|
|
39
24
|
const DEFAULT_SUPPRESS_DAYS = 7;
|
|
40
25
|
const SUPERSEDE_WITHIN_HOURS = 72;
|
|
@@ -90,7 +75,6 @@ function priorityTier(score) {
|
|
|
90
75
|
|
|
91
76
|
/**
|
|
92
77
|
* Compute a dedup key from recommendation properties.
|
|
93
|
-
* Two recommendations with the same dedup key are considered identical.
|
|
94
78
|
*/
|
|
95
79
|
function dedupKey(type, title) {
|
|
96
80
|
return `${type}::${title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60)}`;
|
|
@@ -98,20 +82,6 @@ function dedupKey(type, title) {
|
|
|
98
82
|
|
|
99
83
|
/**
|
|
100
84
|
* Create a recommendation.
|
|
101
|
-
*
|
|
102
|
-
* @param {object} opts
|
|
103
|
-
* @param {string} opts.type - 'prd' | 'adr' | 'rfc' | 'runbook' | 'postmortem'
|
|
104
|
-
* @param {string} opts.title - Human-readable title
|
|
105
|
-
* @param {string} opts.reason - Why this is being recommended
|
|
106
|
-
* @param {number} [opts.signalCount=1] - Number of signals triggering this
|
|
107
|
-
* @param {number} [opts.customerImpact=0] - 0-3: how many customers affected
|
|
108
|
-
* @param {number} [opts.recencyBonus=0] - 0-3: how recent the signals are
|
|
109
|
-
* @param {number} [opts.strategicBonus=0] - 0-3: strategic alignment
|
|
110
|
-
* @param {string[]} [opts.sourceSignalIds] - IDs of intake signals that triggered this
|
|
111
|
-
* @param {string} [opts.lane] - Docs lane this belongs to
|
|
112
|
-
* @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
|
|
113
|
-
* @param {object} [opts.env] - Environment (defaults to process.env)
|
|
114
|
-
* @returns {{ id: string, dedupKey: string, priority: string, score: number }}
|
|
115
85
|
*/
|
|
116
86
|
export function createRecommendation({ type, title, reason, signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0, sourceSignalIds = [], lane, project, env }) {
|
|
117
87
|
if (!type || !title) throw new Error('type and title are required');
|
|
@@ -120,11 +90,9 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
|
|
|
120
90
|
const key = dedupKey(type, title);
|
|
121
91
|
const index = readIndex();
|
|
122
92
|
|
|
123
|
-
// Check if already exists (dedup)
|
|
124
93
|
if (index[key]) {
|
|
125
94
|
const existing = index[key];
|
|
126
95
|
if (!existing.supersededAt) {
|
|
127
|
-
// Update existing — increment signal count, extend dates
|
|
128
96
|
const updated = {
|
|
129
97
|
...existing,
|
|
130
98
|
lastSeen: new Date().toISOString(),
|
|
@@ -140,12 +108,8 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
|
|
|
140
108
|
index[key] = updated;
|
|
141
109
|
writeIndex(index);
|
|
142
110
|
|
|
143
|
-
|
|
144
|
-
createRecommendationPgBestEffort(updated, project, env);
|
|
145
|
-
return result;
|
|
111
|
+
return { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
|
|
146
112
|
}
|
|
147
|
-
// Existing was dismissed — create new instance with new signals
|
|
148
|
-
// (superseded recommendations can be revived with fresh signals)
|
|
149
113
|
}
|
|
150
114
|
|
|
151
115
|
const id = `rec-${randomUUID().slice(0, 8)}`;
|
|
@@ -175,19 +139,11 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
|
|
|
175
139
|
enrichedBy: null,
|
|
176
140
|
};
|
|
177
141
|
|
|
178
|
-
// Append to log
|
|
179
142
|
appendFileSync(recommendationPaths().logFile, JSON.stringify(rec) + '\n', 'utf8');
|
|
180
143
|
|
|
181
|
-
// Update index
|
|
182
144
|
index[key] = rec;
|
|
183
145
|
writeIndex(index);
|
|
184
146
|
|
|
185
|
-
createRecommendationPgBestEffort(rec, project, env);
|
|
186
|
-
|
|
187
|
-
// Surfaces the new recommendation as a lifecycle event so the enrichment
|
|
188
|
-
// loop (and any project-bound subscribers in .cx/specialists/) can route
|
|
189
|
-
// the matched specialist's contract chain against it.
|
|
190
|
-
|
|
191
147
|
emitBestEffort('recommendation.generated', {
|
|
192
148
|
project: project || '',
|
|
193
149
|
summary: `${rec.type}: ${rec.title}`,
|
|
@@ -203,23 +159,10 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
|
|
|
203
159
|
return { id, dedupKey: key, priority: rec.priority, score, existing: false };
|
|
204
160
|
}
|
|
205
161
|
|
|
206
|
-
/**
|
|
207
|
-
* Compute prioritization score.
|
|
208
|
-
* score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
|
|
209
|
-
*/
|
|
210
162
|
function computeScore({ signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0 }) {
|
|
211
163
|
return (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus);
|
|
212
164
|
}
|
|
213
165
|
|
|
214
|
-
/**
|
|
215
|
-
* Dismiss a recommendation (suppress permanently or for N days).
|
|
216
|
-
*
|
|
217
|
-
* @param {string} dedupKey - Dedup key
|
|
218
|
-
* @param {object} [opts]
|
|
219
|
-
* @param {string} [opts.reason] - Why dismissed
|
|
220
|
-
* @param {number} [opts.suppressDays] - Days to suppress (default: forever)
|
|
221
|
-
* @returns {{ success: boolean }}
|
|
222
|
-
*/
|
|
223
166
|
export function dismissRecommendation(dedupKey, { reason = 'manually dismissed', suppressDays } = {}) {
|
|
224
167
|
const index = readIndex();
|
|
225
168
|
if (!index[dedupKey]) {
|
|
@@ -239,13 +182,6 @@ export function dismissRecommendation(dedupKey, { reason = 'manually dismissed',
|
|
|
239
182
|
return { success: true };
|
|
240
183
|
}
|
|
241
184
|
|
|
242
|
-
/**
|
|
243
|
-
* Supersede a recommendation (mark as superseded by a newer one).
|
|
244
|
-
*
|
|
245
|
-
* @param {string} dedupKey - Existing recommendation to supersede
|
|
246
|
-
* @param {string} supersedingId - ID of the recommendation that replaces it
|
|
247
|
-
* @returns {{ success: boolean }}
|
|
248
|
-
*/
|
|
249
185
|
export function supersedeRecommendation(dedupKey, supersedingId) {
|
|
250
186
|
const index = readIndex();
|
|
251
187
|
if (!index[dedupKey]) {
|
|
@@ -263,41 +199,17 @@ export function supersedeRecommendation(dedupKey, supersedingId) {
|
|
|
263
199
|
}
|
|
264
200
|
|
|
265
201
|
/**
|
|
266
|
-
* List active (
|
|
267
|
-
*
|
|
268
|
-
* In team/enterprise mode (DATABASE_URL configured), reads from Postgres when
|
|
269
|
-
* the project has rows. Falls back to JSONL index.
|
|
270
|
-
*
|
|
271
|
-
* @param {object} [opts]
|
|
272
|
-
* @param {string} [opts.type] - Filter by type
|
|
273
|
-
* @param {string} [opts.priority] - Filter by priority (P0, P1, etc.)
|
|
274
|
-
* @param {number} [opts.limit=20]
|
|
275
|
-
* @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
|
|
276
|
-
* @param {object} [opts.env] - Environment (defaults to process.env)
|
|
277
|
-
* @returns {Array<object>}
|
|
202
|
+
* List active recommendations (filesystem-only).
|
|
278
203
|
*/
|
|
279
204
|
export function listActiveRecommendations({ type, priority, limit = 20, project, env } = {}) {
|
|
280
|
-
if (hasSqlStore(env)) {
|
|
281
|
-
return listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env });
|
|
282
|
-
}
|
|
283
|
-
return listActiveRecommendationsFile({ type, priority, limit });
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* File-based active recommendations list.
|
|
288
|
-
*/
|
|
289
|
-
function listActiveRecommendationsFile({ type, priority, limit = 20 } = {}) {
|
|
290
205
|
const index = readIndex();
|
|
291
206
|
const now = new Date();
|
|
292
207
|
let results = Object.values(index).filter(rec => {
|
|
293
|
-
// Skip dismissed (unless temporary suppress and still active)
|
|
294
208
|
if (rec.dismissedAt) {
|
|
295
209
|
if (rec.suppressedUntil && new Date(rec.suppressedUntil) > now) return false;
|
|
296
210
|
if (!rec.suppressedUntil) return false;
|
|
297
211
|
}
|
|
298
|
-
// Skip superseded
|
|
299
212
|
if (rec.supersededAt) return false;
|
|
300
|
-
// Skip suppressed
|
|
301
213
|
if (rec.suppressedUntil && new Date(rec.suppressedUntil) > now) return false;
|
|
302
214
|
return true;
|
|
303
215
|
});
|
|
@@ -310,34 +222,6 @@ function listActiveRecommendationsFile({ type, priority, limit = 20 } = {}) {
|
|
|
310
222
|
.slice(0, limit);
|
|
311
223
|
}
|
|
312
224
|
|
|
313
|
-
/**
|
|
314
|
-
* Attempt Postgres read, fall back to file synchronously.
|
|
315
|
-
* Returns a Promise so callers using await get Postgres data; callers not
|
|
316
|
-
* using await get the file-based results via the sync fallback.
|
|
317
|
-
*/
|
|
318
|
-
function listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env }) {
|
|
319
|
-
const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
|
|
320
|
-
const client = createSqlClient(env);
|
|
321
|
-
|
|
322
|
-
return listActiveRecommendationsPg(resolvedProject, { type, priority, limit }, client)
|
|
323
|
-
.then(rows => {
|
|
324
|
-
client.end({ timeout: 5 }).catch(() => {});
|
|
325
|
-
if (rows.length > 0) return rows;
|
|
326
|
-
return listActiveRecommendationsFile({ type, priority, limit });
|
|
327
|
-
})
|
|
328
|
-
.catch(() => {
|
|
329
|
-
client.end({ timeout: 5 }).catch(() => {});
|
|
330
|
-
return listActiveRecommendationsFile({ type, priority, limit });
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Auto-suppress recommendations that have been active for DEFAULT_SUPPRESS_DAYS
|
|
336
|
-
* without new signals, or that were superseded more than SUPERSEDE_WITHIN_HOURS ago.
|
|
337
|
-
* Call from docs-lifecycle or a scheduled daemon job.
|
|
338
|
-
*
|
|
339
|
-
* @returns {number} Number of recommendations suppressed
|
|
340
|
-
*/
|
|
341
225
|
export function autoSuppressStale() {
|
|
342
226
|
const index = readIndex();
|
|
343
227
|
const now = Date.now();
|
|
@@ -346,7 +230,6 @@ export function autoSuppressStale() {
|
|
|
346
230
|
for (const [key, rec] of Object.entries(index)) {
|
|
347
231
|
if (rec.dismissedAt || rec.supersededAt) continue;
|
|
348
232
|
|
|
349
|
-
// Suppress if lastSeen is older than DEFAULT_SUPPRESS_DAYS
|
|
350
233
|
const lastSeen = new Date(rec.lastSeen || rec.firstSeen).getTime();
|
|
351
234
|
const ageDays = (now - lastSeen) / (24 * 60 * 60 * 1000);
|
|
352
235
|
if (ageDays > DEFAULT_SUPPRESS_DAYS) {
|
|
@@ -359,7 +242,6 @@ export function autoSuppressStale() {
|
|
|
359
242
|
continue;
|
|
360
243
|
}
|
|
361
244
|
|
|
362
|
-
// Suppress superseded older than threshold
|
|
363
245
|
if (rec.supersededAt) {
|
|
364
246
|
const supersededAt = new Date(rec.supersededAt).getTime();
|
|
365
247
|
if ((now - supersededAt) > SUPERSEDE_WITHIN_HOURS * 60 * 60 * 1000) {
|
|
@@ -377,15 +259,6 @@ export function autoSuppressStale() {
|
|
|
377
259
|
return suppressed;
|
|
378
260
|
}
|
|
379
261
|
|
|
380
|
-
/**
|
|
381
|
-
* Revive a recommendation from dismissal with new signals.
|
|
382
|
-
*
|
|
383
|
-
* @param {string} dedupKey
|
|
384
|
-
* @param {object} newSignals
|
|
385
|
-
* @param {number} [newSignals.signalCount=1]
|
|
386
|
-
* @param {string[]} [newSignals.sourceSignalIds]
|
|
387
|
-
* @returns {{ success: boolean }}
|
|
388
|
-
*/
|
|
389
262
|
export function reviveRecommendation(dedupKey, { signalCount = 1, sourceSignalIds = [] } = {}) {
|
|
390
263
|
const index = readIndex();
|
|
391
264
|
if (!index[dedupKey]) {
|
|
@@ -397,7 +270,7 @@ export function reviveRecommendation(dedupKey, { signalCount = 1, sourceSignalId
|
|
|
397
270
|
const score = computeScore({
|
|
398
271
|
signalCount: updatedSignalCount,
|
|
399
272
|
customerImpact: existing.customerImpact || 0,
|
|
400
|
-
recencyBonus: 3,
|
|
273
|
+
recencyBonus: 3,
|
|
401
274
|
strategicBonus: existing.strategicBonus || 0,
|
|
402
275
|
});
|
|
403
276
|
|
|
@@ -418,11 +291,6 @@ export function reviveRecommendation(dedupKey, { signalCount = 1, sourceSignalId
|
|
|
418
291
|
return { success: true };
|
|
419
292
|
}
|
|
420
293
|
|
|
421
|
-
/**
|
|
422
|
-
* Get store statistics.
|
|
423
|
-
*
|
|
424
|
-
* @returns {{ total: number, active: number, dismissed: number, superseded: number, byPriority: object }}
|
|
425
|
-
*/
|
|
426
294
|
export function recommendationStats() {
|
|
427
295
|
const index = readIndex();
|
|
428
296
|
const entries = Object.values(index);
|
|
@@ -446,13 +314,6 @@ export function recommendationStats() {
|
|
|
446
314
|
};
|
|
447
315
|
}
|
|
448
316
|
|
|
449
|
-
/**
|
|
450
|
-
* Check if a recommendation with the given dedup key is active.
|
|
451
|
-
*
|
|
452
|
-
* @param {string} type
|
|
453
|
-
* @param {string} title
|
|
454
|
-
* @returns {{ active: boolean, existing: object|null }}
|
|
455
|
-
*/
|
|
456
317
|
export function isRecommendationActive(type, title) {
|
|
457
318
|
const key = dedupKey(type, title);
|
|
458
319
|
const index = readIndex();
|
|
@@ -461,146 +322,3 @@ export function isRecommendationActive(type, title) {
|
|
|
461
322
|
if (rec.dismissedAt || rec.supersededAt) return { active: false, existing: rec };
|
|
462
323
|
return { active: true, existing: rec };
|
|
463
324
|
}
|
|
464
|
-
|
|
465
|
-
// ---------------------------------------------------------------------------
|
|
466
|
-
// Postgres backend — team/enterprise mode only
|
|
467
|
-
// ---------------------------------------------------------------------------
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Upsert a recommendation into construct_recommendations.
|
|
471
|
-
* On conflict (project, dedup_key) update all mutable fields.
|
|
472
|
-
*
|
|
473
|
-
* @param {object} rec - Recommendation object (camelCase)
|
|
474
|
-
* @param {string} project
|
|
475
|
-
* @param {object} client - postgres.js SQL client
|
|
476
|
-
*/
|
|
477
|
-
export async function createRecommendationPg(rec, project, client) {
|
|
478
|
-
await client`
|
|
479
|
-
insert into construct_recommendations (
|
|
480
|
-
id, project, dedup_key, type, title, reason, lane,
|
|
481
|
-
signal_count, total_signal_count,
|
|
482
|
-
customer_impact, recency_bonus, strategic_bonus,
|
|
483
|
-
score, priority, source_signal_ids,
|
|
484
|
-
first_seen, last_seen,
|
|
485
|
-
dismissed_at, dismiss_reason,
|
|
486
|
-
superseded_at, superseded_by_id,
|
|
487
|
-
suppressed_until, suppress_reason,
|
|
488
|
-
updated_at
|
|
489
|
-
) values (
|
|
490
|
-
${rec.id}, ${project}, ${rec.dedupKey || dedupKey(rec.type, rec.title)}, ${rec.type}, ${rec.title},
|
|
491
|
-
${rec.reason ?? null}, ${rec.lane ?? null},
|
|
492
|
-
${rec.signalCount ?? 1}, ${rec.totalSignalCount ?? 1},
|
|
493
|
-
${rec.customerImpact ?? 0}, ${rec.recencyBonus ?? 0}, ${rec.strategicBonus ?? 0},
|
|
494
|
-
${rec.score ?? 0}, ${rec.priority ?? 'P3'},
|
|
495
|
-
${JSON.stringify(rec.sourceSignalIds ?? [])}::jsonb,
|
|
496
|
-
${rec.firstSeen ?? new Date().toISOString()}, ${rec.lastSeen ?? new Date().toISOString()},
|
|
497
|
-
${rec.dismissedAt ?? null}, ${rec.dismissReason ?? null},
|
|
498
|
-
${rec.supersededAt ?? null}, ${rec.supersededById ?? null},
|
|
499
|
-
${rec.suppressedUntil ?? null}, ${rec.suppressReason ?? null},
|
|
500
|
-
now()
|
|
501
|
-
)
|
|
502
|
-
on conflict (project, dedup_key) do update set
|
|
503
|
-
signal_count = excluded.signal_count,
|
|
504
|
-
total_signal_count = excluded.total_signal_count,
|
|
505
|
-
customer_impact = excluded.customer_impact,
|
|
506
|
-
recency_bonus = excluded.recency_bonus,
|
|
507
|
-
strategic_bonus = excluded.strategic_bonus,
|
|
508
|
-
score = excluded.score,
|
|
509
|
-
priority = excluded.priority,
|
|
510
|
-
source_signal_ids = excluded.source_signal_ids,
|
|
511
|
-
last_seen = excluded.last_seen,
|
|
512
|
-
dismissed_at = excluded.dismissed_at,
|
|
513
|
-
dismiss_reason = excluded.dismiss_reason,
|
|
514
|
-
superseded_at = excluded.superseded_at,
|
|
515
|
-
superseded_by_id = excluded.superseded_by_id,
|
|
516
|
-
suppressed_until = excluded.suppressed_until,
|
|
517
|
-
suppress_reason = excluded.suppress_reason,
|
|
518
|
-
updated_at = now()
|
|
519
|
-
`;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Query active recommendations from Postgres for a project.
|
|
524
|
-
*
|
|
525
|
-
* @param {string} project
|
|
526
|
-
* @param {object} [opts]
|
|
527
|
-
* @param {string} [opts.type]
|
|
528
|
-
* @param {string} [opts.priority]
|
|
529
|
-
* @param {number} [opts.limit=20]
|
|
530
|
-
* @param {object} client - postgres.js SQL client
|
|
531
|
-
* @returns {Promise<Array<object>>}
|
|
532
|
-
*/
|
|
533
|
-
export async function listActiveRecommendationsPg(project, { type, priority, limit = 20 } = {}, client) {
|
|
534
|
-
const rows = await client`
|
|
535
|
-
select *
|
|
536
|
-
from construct_recommendations
|
|
537
|
-
where project = ${project}
|
|
538
|
-
and dismissed_at is null
|
|
539
|
-
and superseded_at is null
|
|
540
|
-
and (suppressed_until is null or suppressed_until <= now())
|
|
541
|
-
${type ? client`and type = ${type}` : client``}
|
|
542
|
-
${priority ? client`and priority = ${priority}` : client``}
|
|
543
|
-
order by score desc
|
|
544
|
-
limit ${limit}
|
|
545
|
-
`;
|
|
546
|
-
|
|
547
|
-
return rows.map(row => ({
|
|
548
|
-
id: row.id,
|
|
549
|
-
type: row.type,
|
|
550
|
-
title: row.title,
|
|
551
|
-
reason: row.reason,
|
|
552
|
-
lane: row.lane,
|
|
553
|
-
signalCount: row.signal_count,
|
|
554
|
-
totalSignalCount: row.total_signal_count,
|
|
555
|
-
customerImpact: row.customer_impact,
|
|
556
|
-
recencyBonus: row.recency_bonus,
|
|
557
|
-
strategicBonus: row.strategic_bonus,
|
|
558
|
-
score: row.score,
|
|
559
|
-
priority: row.priority,
|
|
560
|
-
sourceSignalIds: row.source_signal_ids ?? [],
|
|
561
|
-
firstSeen: row.first_seen instanceof Date ? row.first_seen.toISOString() : row.first_seen,
|
|
562
|
-
lastSeen: row.last_seen instanceof Date ? row.last_seen.toISOString() : row.last_seen,
|
|
563
|
-
dismissedAt: row.dismissed_at,
|
|
564
|
-
supersededAt: row.superseded_at,
|
|
565
|
-
suppressedUntil: row.suppressed_until,
|
|
566
|
-
}));
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Dismiss a recommendation in Postgres.
|
|
571
|
-
*
|
|
572
|
-
* @param {string} dedupKeyValue
|
|
573
|
-
* @param {string} project
|
|
574
|
-
* @param {object} [opts]
|
|
575
|
-
* @param {string} [opts.reason]
|
|
576
|
-
* @param {number} [opts.suppressDays]
|
|
577
|
-
* @param {object} client - postgres.js SQL client
|
|
578
|
-
*/
|
|
579
|
-
export async function dismissRecommendationPg(dedupKeyValue, project, { reason = 'manually dismissed', suppressDays } = {}, client) {
|
|
580
|
-
const suppressedUntil = suppressDays
|
|
581
|
-
? new Date(Date.now() + suppressDays * 24 * 60 * 60 * 1000).toISOString()
|
|
582
|
-
: null;
|
|
583
|
-
|
|
584
|
-
await client`
|
|
585
|
-
update construct_recommendations
|
|
586
|
-
set
|
|
587
|
-
dismissed_at = now(),
|
|
588
|
-
dismiss_reason = ${reason},
|
|
589
|
-
suppressed_until = ${suppressedUntil},
|
|
590
|
-
updated_at = now()
|
|
591
|
-
where project = ${project}
|
|
592
|
-
and dedup_key = ${dedupKeyValue}
|
|
593
|
-
`;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Fire-and-forget Postgres upsert — never throws, never blocks the caller.
|
|
598
|
-
*/
|
|
599
|
-
function createRecommendationPgBestEffort(rec, project, env) {
|
|
600
|
-
if (!hasSqlStore(env)) return;
|
|
601
|
-
const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
|
|
602
|
-
const client = createSqlClient(env);
|
|
603
|
-
createRecommendationPg(rec, resolvedProject, client)
|
|
604
|
-
.catch(() => {})
|
|
605
|
-
.finally(() => client.end({ timeout: 5 }).catch(() => {}));
|
|
606
|
-
}
|