@geraldmaron/construct 1.0.23 → 1.1.0
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 +0 -2
- package/bin/construct +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- 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/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- 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/reconcile/postgres-namespace.mjs +0 -102
- 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/unified-storage.mjs +0 -550
- package/lib/storage/vector-store.mjs +0 -100
package/lib/document-extract.mjs
CHANGED
|
@@ -107,6 +107,11 @@ function run(command, args) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function commandExists(command) {
|
|
110
|
+
// Test seam: a comma-separated deny-list forces a command to read as absent,
|
|
111
|
+
// so the cross-platform fallbacks (e.g. RTF stripping without macOS textutil)
|
|
112
|
+
// are reachable on any host.
|
|
113
|
+
const denied = (process.env.CONSTRUCT_EXTRACT_NO_COMMANDS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
114
|
+
if (denied.includes(command)) return false;
|
|
110
115
|
const checker = process.platform === 'win32' ? 'where' : 'which';
|
|
111
116
|
return spawnSync(checker, [command], { stdio: 'ignore' }).status === 0;
|
|
112
117
|
}
|
|
@@ -216,16 +221,38 @@ function extractZipDocument(filePath, extension) {
|
|
|
216
221
|
return { text, method: 'zip-xml', droppedInfo, structured: null };
|
|
217
222
|
}
|
|
218
223
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
// Minimal RTF-to-text: strip control words, groups, and escapes. RTF is a
|
|
225
|
+
// text format, so this works everywhere; it is the cross-platform fallback when
|
|
226
|
+
// textutil (macOS only) is absent. Lossy on complex documents, but never throws.
|
|
227
|
+
export function rtfToText(raw) {
|
|
228
|
+
let s = String(raw);
|
|
229
|
+
s = s.replace(/\{(?:\\\*)?\\(?:fonttbl|colortbl|stylesheet|info|pict|object)[\s\S]*?\}/gi, '');
|
|
230
|
+
s = s.replace(/\\u(-?\d+)\s?\??/g, (_, n) => (Number(n) >= 0 ? String.fromCharCode(Number(n)) : ''));
|
|
231
|
+
s = s.replace(/\\'([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
232
|
+
s = s.replace(/\\par[d]?\b/g, '\n').replace(/\\line\b/g, '\n').replace(/\\tab\b/g, '\t');
|
|
233
|
+
s = s.replace(/\\[a-zA-Z]+-?\d* ?/g, '');
|
|
234
|
+
s = s.replace(/\\([{}\\])/g, '$1');
|
|
235
|
+
return s.replace(/[{}]/g, '');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function extractRichText(filePath, extension) {
|
|
239
|
+
if (commandExists('textutil')) {
|
|
240
|
+
return {
|
|
241
|
+
text: normalizeText(run('textutil', ['-convert', 'txt', '-stdout', filePath])),
|
|
242
|
+
method: 'textutil',
|
|
243
|
+
droppedInfo: [],
|
|
244
|
+
structured: null,
|
|
245
|
+
};
|
|
222
246
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
247
|
+
if (extension === '.rtf') {
|
|
248
|
+
return {
|
|
249
|
+
text: normalizeText(rtfToText(readFileSync(filePath, 'utf8'))),
|
|
250
|
+
method: 'rtf-strip',
|
|
251
|
+
droppedInfo: [],
|
|
252
|
+
structured: null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
throw new Error('textutil not available for rich-text extraction');
|
|
229
256
|
}
|
|
230
257
|
|
|
231
258
|
function extractPdfWithPdftotext(filePath) {
|
|
@@ -660,7 +687,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
660
687
|
} else if (ZIP_DOCUMENT_EXTS.has(extension)) {
|
|
661
688
|
extracted = extractZipDocument(resolvedPath, extension);
|
|
662
689
|
} else if (RICH_TEXT_EXTS.has(extension)) {
|
|
663
|
-
extracted = extractRichText(resolvedPath);
|
|
690
|
+
extracted = extractRichText(resolvedPath, extension);
|
|
664
691
|
} else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) {
|
|
665
692
|
extracted = { text: extractWithMdls(resolvedPath), method: 'mdls', droppedInfo: [], structured: null };
|
|
666
693
|
} else if (EMAIL_DOCUMENT_EXTS.has(extension)) {
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and records the selected strategy/model in the returned `ingestion` block.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
-
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
|
11
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
12
12
|
import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
|
|
13
13
|
import { syncFileStateToSql } from './storage/sync.mjs';
|
|
14
14
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
@@ -16,6 +16,7 @@ import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
|
16
16
|
import { loadProjectConfig } from './config/project-config.mjs';
|
|
17
17
|
import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
|
|
18
18
|
import { extractViaProvider } from './ingest/provider-extract.mjs';
|
|
19
|
+
import { extractViaDoclingRemote } from './ingest/docling-remote.mjs';
|
|
19
20
|
|
|
20
21
|
const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
|
|
21
22
|
|
|
@@ -56,6 +57,31 @@ function nextAvailablePath(targetPath) {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// docling embeds figures as base64 `data:` URIs in the markdown so no pixel data
|
|
61
|
+
// is lost in transit. Write each to an assets/ directory beside the output file
|
|
62
|
+
// and rewrite the reference to a relative path, so the markdown stays small and
|
|
63
|
+
// the images are real files an editor or viewer can open — replacing the bare
|
|
64
|
+
// `<!-- image -->` placeholder docling emits by default.
|
|
65
|
+
|
|
66
|
+
export function externalizeEmbeddedImages(markdown, { mdPath }) {
|
|
67
|
+
if (!markdown || !markdown.includes('data:image/')) return { markdown, assets: [] };
|
|
68
|
+
const assetsRel = `assets/${basename(mdPath, '.md')}`;
|
|
69
|
+
const assetsDir = join(dirname(mdPath), assetsRel.split('/').join(sep));
|
|
70
|
+
const assets = [];
|
|
71
|
+
let index = 0;
|
|
72
|
+
const re = /!\[([^\]]*)\]\(\s*data:image\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+?)\s*\)/g;
|
|
73
|
+
const out = markdown.replace(re, (_match, alt, type, b64) => {
|
|
74
|
+
index += 1;
|
|
75
|
+
const ext = type.toLowerCase() === 'jpeg' ? 'jpg' : type.toLowerCase().replace(/[^a-z0-9]/g, '') || 'png';
|
|
76
|
+
const fileName = `image-${index}.${ext}`;
|
|
77
|
+
if (!existsSync(assetsDir)) mkdirSync(assetsDir, { recursive: true });
|
|
78
|
+
writeFileSync(join(assetsDir, fileName), Buffer.from(b64.replace(/\s+/g, ''), 'base64'));
|
|
79
|
+
assets.push(join(assetsDir, fileName));
|
|
80
|
+
return ``;
|
|
81
|
+
});
|
|
82
|
+
return { markdown: out, assets };
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
function renderMarkdown({ sourcePath, extractedAt, title, extractionMethod, characters, truncated, text, outputPath, cwd, metadata }) {
|
|
60
86
|
const relSource = relative(cwd, sourcePath) || basename(sourcePath);
|
|
61
87
|
const relOutput = relative(cwd, outputPath) || basename(outputPath);
|
|
@@ -126,17 +152,74 @@ function resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target }) {
|
|
|
126
152
|
return join(resolvedDir, `${basename(sourcePath)}.md`);
|
|
127
153
|
}
|
|
128
154
|
|
|
155
|
+
// The high-fidelity (docling) path provisions a Python venv and downloads ML
|
|
156
|
+
// models on first use, then runs layout inference on the document — any of which
|
|
157
|
+
// can stall (a large PDF, a slow model download) or fail. Without a bound the
|
|
158
|
+
// caller hangs: the CLI never returns, and the ingest_document MCP tool blocks
|
|
159
|
+
// until the client times out and surfaces an opaque error. Bound the whole
|
|
160
|
+
// docling attempt and fall back to the legacy extractor so ingest always returns
|
|
161
|
+
// a usable result, with the fallback recorded in droppedInfo. Override the bound
|
|
162
|
+
// with CONSTRUCT_DOCLING_TIMEOUT_MS (0 disables the timeout).
|
|
163
|
+
|
|
164
|
+
const DOCLING_TIMEOUT_MS = (() => {
|
|
165
|
+
const raw = Number(process.env.CONSTRUCT_DOCLING_TIMEOUT_MS);
|
|
166
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 600_000;
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
export function withTimeout(promise, ms, onTimeoutMessage) {
|
|
170
|
+
if (!ms) return promise;
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const timer = setTimeout(() => reject(Object.assign(new Error(onTimeoutMessage), { code: 'DOCLING_TIMEOUT' })), ms);
|
|
173
|
+
promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run the high-fidelity (docling) extractor with a bound, falling back to the
|
|
179
|
+
* legacy extractor on timeout or any failure so a caller never hangs. The
|
|
180
|
+
* extractor functions are injectable for testing; production uses the real
|
|
181
|
+
* docling-async and legacy-sync extractors.
|
|
182
|
+
*/
|
|
183
|
+
export async function extractWithDoclingFallback(sourcePath, {
|
|
184
|
+
maxChars = null,
|
|
185
|
+
timeoutMs = DOCLING_TIMEOUT_MS,
|
|
186
|
+
asyncExtract = extractDocumentTextAsync,
|
|
187
|
+
syncExtract = extractDocumentText,
|
|
188
|
+
} = {}) {
|
|
189
|
+
try {
|
|
190
|
+
return await withTimeout(
|
|
191
|
+
asyncExtract(sourcePath, { maxChars }),
|
|
192
|
+
timeoutMs,
|
|
193
|
+
`docling extraction exceeded ${Math.round(timeoutMs / 1000)}s`,
|
|
194
|
+
);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const legacy = syncExtract(sourcePath, { maxChars });
|
|
197
|
+
legacy.droppedInfo = [
|
|
198
|
+
...(legacy.droppedInfo ?? []),
|
|
199
|
+
{
|
|
200
|
+
kind: 'docling-fallback',
|
|
201
|
+
count: 1,
|
|
202
|
+
reason: `High-fidelity (docling) extraction failed or timed out (${err.message}); used the legacy extractor. Re-run after \`construct install --with-docling\`, or pass --legacy-extractor to skip docling.`,
|
|
203
|
+
recoverable: true,
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
return legacy;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
129
210
|
function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
|
|
130
211
|
return highFidelity
|
|
131
|
-
?
|
|
212
|
+
? extractWithDoclingFallback(sourcePath, { maxChars })
|
|
132
213
|
: extractDocumentText(sourcePath, { maxChars });
|
|
133
214
|
}
|
|
134
215
|
|
|
135
216
|
async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {
|
|
136
|
-
const primary = strategy === 'provider'
|
|
217
|
+
const primary = strategy === 'provider' || strategy === 'docling-remote' ? strategy : 'adapter';
|
|
137
218
|
const run = (mode) => (mode === 'provider'
|
|
138
219
|
? extractViaProvider({ filePath: sourcePath, model, provider, maxChars, env })
|
|
139
|
-
:
|
|
220
|
+
: mode === 'docling-remote'
|
|
221
|
+
? extractViaDoclingRemote({ filePath: sourcePath, maxChars, env })
|
|
222
|
+
: extractViaAdapter(sourcePath, { highFidelity, maxChars }));
|
|
140
223
|
|
|
141
224
|
try {
|
|
142
225
|
const extracted = await run(primary);
|
|
@@ -207,6 +290,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
207
290
|
const finalPath = nextAvailablePath(targetPath);
|
|
208
291
|
const extractedAt = new Date().toISOString();
|
|
209
292
|
const title = metadata?.title || formatTitle(sourcePath);
|
|
293
|
+
const { markdown: bodyText, assets: imageAssets } = externalizeEmbeddedImages(extracted.text, { mdPath: finalPath });
|
|
210
294
|
const markdown = renderMarkdown({
|
|
211
295
|
sourcePath,
|
|
212
296
|
extractedAt,
|
|
@@ -214,7 +298,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
214
298
|
extractionMethod: extracted.extractionMethod,
|
|
215
299
|
characters: extracted.characters,
|
|
216
300
|
truncated: extracted.truncated,
|
|
217
|
-
text:
|
|
301
|
+
text: bodyText,
|
|
218
302
|
outputPath: finalPath,
|
|
219
303
|
cwd,
|
|
220
304
|
metadata,
|
|
@@ -229,6 +313,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
229
313
|
characters: extracted.characters,
|
|
230
314
|
droppedInfo: extracted.droppedInfo ?? [],
|
|
231
315
|
structured: extracted.structured ?? null,
|
|
316
|
+
images: imageAssets.map((p) => relative(cwd, p)),
|
|
232
317
|
metadata: { title, authors: metadata?.authors || [], dates: metadata?.dates || {} },
|
|
233
318
|
});
|
|
234
319
|
}
|
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,
|
|
@@ -414,7 +417,7 @@ export class InboxWatcher {
|
|
|
414
417
|
const droppedInfo = fileResult.droppedInfo ?? [];
|
|
415
418
|
writeExtractionWarning({ sourcePath: candidate.path, droppedInfo });
|
|
416
419
|
|
|
417
|
-
recordInboxObservation(this.#rootDir, {
|
|
420
|
+
await recordInboxObservation(this.#rootDir, {
|
|
418
421
|
sourcePath: candidate.path,
|
|
419
422
|
outputPath: fileResult.outputPath,
|
|
420
423
|
characters: fileResult.characters,
|
|
@@ -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
|
-
}
|