@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.
Files changed (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
@@ -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
- function extractRichText(filePath) {
220
- if (!commandExists('textutil')) {
221
- throw new Error('textutil not available for rich-text extraction');
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
- return {
224
- text: normalizeText(run('textutil', ['-convert', 'txt', '-stdout', filePath])),
225
- method: 'textutil',
226
- droppedInfo: [],
227
- structured: null,
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)) {
@@ -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 `![${alt || `image ${index}`}](${assetsRel}/${fileName})`;
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
- ? extractDocumentTextAsync(sourcePath, { maxChars })
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' ? 'provider' : 'adapter';
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
- : extractViaAdapter(sourcePath, { highFidelity, maxChars }));
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: extracted.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
  }
@@ -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
- addObservation(rootDir, {
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
- * Storage (solo mode):
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
- const result = { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
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 (non-dismissed, non-superseded) recommendations.
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, // recent signals get max recency bonus
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
- }