@geraldmaron/construct 1.0.24 → 1.1.1

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 (87) hide show
  1. package/README.md +2 -0
  2. package/bin/construct +266 -13
  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/audit-trail.mjs +77 -10
  7. package/lib/beads-client.mjs +9 -0
  8. package/lib/beads-optimistic.mjs +23 -71
  9. package/lib/bridges/copilot-proxy.mjs +116 -0
  10. package/lib/cli-commands.mjs +112 -1
  11. package/lib/comment-lint.mjs +1 -1
  12. package/lib/config/schema.mjs +1 -1
  13. package/lib/demo.mjs +245 -0
  14. package/lib/diagram.mjs +300 -0
  15. package/lib/doctor/index.mjs +1 -1
  16. package/lib/doctor/watchers/process-pressure.mjs +1 -1
  17. package/lib/doctor/watchers/service-health.mjs +1 -1
  18. package/lib/document-extract/docling-client.mjs +16 -6
  19. package/lib/document-extract/docling-sidecar.py +32 -2
  20. package/lib/document-extract.mjs +85 -10
  21. package/lib/document-ingest.mjs +97 -7
  22. package/lib/embed/roadmap.mjs +16 -1
  23. package/lib/embed/semantic.mjs +5 -2
  24. package/lib/engine/consolidate.mjs +160 -3
  25. package/lib/engine/contradiction-judge.mjs +71 -0
  26. package/lib/engine/contradiction.mjs +74 -0
  27. package/lib/handoffs/cleanup.mjs +1 -1
  28. package/lib/hooks/audit-trail.mjs +14 -28
  29. package/lib/host-capabilities.mjs +30 -0
  30. package/lib/ingest/docling-remote.mjs +90 -0
  31. package/lib/ingest/strategy.mjs +1 -1
  32. package/lib/init-unified.mjs +9 -13
  33. package/lib/logging/rotate.mjs +18 -0
  34. package/lib/mcp/server.mjs +124 -12
  35. package/lib/mcp/tool-budget.mjs +53 -0
  36. package/lib/mcp-catalog.json +1 -1
  37. package/lib/model-router.mjs +52 -1
  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-policy.mjs +41 -6
  43. package/lib/persona-sections.mjs +66 -0
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +14 -9
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reflect/extractor.mjs +14 -1
  48. package/lib/reflect/salience.mjs +65 -0
  49. package/lib/rules-delivery.mjs +122 -0
  50. package/lib/runtime/uv-bootstrap.mjs +32 -17
  51. package/lib/server/index.mjs +1 -1
  52. package/lib/server/langfuse-login.mjs +3 -3
  53. package/lib/service-manager.mjs +42 -4
  54. package/lib/session-store.mjs +1 -1
  55. package/lib/setup.mjs +58 -2
  56. package/lib/specialists/prompt-schema.mjs +162 -0
  57. package/lib/specialists/scaffold.mjs +109 -0
  58. package/lib/storage/embeddings-engine.mjs +19 -5
  59. package/lib/storage/embeddings-local.mjs +6 -3
  60. package/lib/storage/file-lock.mjs +18 -11
  61. package/lib/telemetry/beads-fallback.mjs +40 -0
  62. package/lib/telemetry/hook-calls.mjs +138 -0
  63. package/package.json +4 -2
  64. package/personas/construct.md +12 -12
  65. package/platforms/capabilities.json +76 -0
  66. package/platforms/opencode/sync-config.mjs +121 -25
  67. package/rules/common/neurodivergent-output.md +1 -1
  68. package/rules/web/coding-style.md +8 -0
  69. package/rules/web/design-quality.md +8 -0
  70. package/rules/web/hooks.md +8 -0
  71. package/rules/web/patterns.md +8 -0
  72. package/rules/web/performance.md +8 -0
  73. package/rules/web/security.md +8 -0
  74. package/rules/web/testing.md +8 -0
  75. package/scripts/sync-specialists.mjs +277 -63
  76. package/skills/docs/init-project.md +1 -1
  77. package/specialists/prompts/cx-architect.md +20 -0
  78. package/specialists/prompts/cx-test-automation.md +12 -0
  79. package/templates/docs/construct_guide.md +2 -2
  80. package/lib/ingest/chunker.mjs +0 -94
  81. package/lib/ingest/pipeline.mjs +0 -53
  82. package/lib/ingest/store.mjs +0 -82
  83. package/lib/mode-commands.mjs +0 -122
  84. package/lib/policy/unified-gates.mjs +0 -96
  85. package/lib/profiles/validate-custom.mjs +0 -114
  86. package/lib/services/telemetry-backend.mjs +0 -177
  87. package/lib/storage/fusion.mjs +0 -95
@@ -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)) {
@@ -671,6 +698,13 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
671
698
  throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
672
699
  }
673
700
 
701
+ return buildExtractionResult(resolvedPath, extension, extracted, maxChars);
702
+ }
703
+
704
+ // Shared envelope construction so the sync, async, and Node-native paths emit
705
+ // an identical result shape (callers route on extractionMethod and droppedInfo).
706
+
707
+ function buildExtractionResult(resolvedPath, extension, extracted, maxChars) {
674
708
  const text = extracted.text;
675
709
  const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
676
710
  ? Math.min(Number(maxChars), 200_000)
@@ -687,13 +721,54 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
687
721
  droppedInfo: extracted.droppedInfo ?? [],
688
722
  structured: extracted.structured ?? null,
689
723
  };
724
+
690
725
  // Surface eml-specific metadata so callers (intake, ingest) can route
691
726
  // attachment filenames and oversize-skip markers without re-parsing.
727
+
692
728
  if (extracted.attachments) result.attachments = extracted.attachments;
693
729
  if (extracted.skipped) result.skipped = extracted.skipped;
694
730
  return result;
695
731
  }
696
732
 
733
+ // Node-native extraction for the everyday adapter path: unpdf (PDF) and mammoth
734
+ // (DOCX) are optional pure-JS deps, so plain documents ingest with no Python venv
735
+ // and no system binary. Missing dep, an empty parse, or any other type delegates
736
+ // to the sync system-binary extractor — the choice degrades, never fails hard.
737
+
738
+ async function extractWithUnpdf(filePath) {
739
+ const { extractText, getDocumentProxy } = await import('unpdf');
740
+ const proxy = await getDocumentProxy(new Uint8Array(readFileSync(filePath)));
741
+ const { text } = await extractText(proxy, { mergePages: true });
742
+ return normalizeText(typeof text === 'string' ? text : (Array.isArray(text) ? text.join('\n') : ''));
743
+ }
744
+
745
+ async function extractWithMammoth(filePath) {
746
+ const mammoth = (await import('mammoth')).default;
747
+ const { value } = await mammoth.extractRawText({ path: filePath });
748
+ return normalizeText(value || '');
749
+ }
750
+
751
+ export async function extractDocumentTextNodeNative(filePath, { maxChars = null } = {}) {
752
+ const resolvedPath = resolve(filePath);
753
+ if (!existsSync(resolvedPath)) throw new Error(`File not found: ${resolvedPath}`);
754
+ const extension = extname(resolvedPath).toLowerCase();
755
+
756
+ const nodeBackend = extension === '.pdf' ? { fn: extractWithUnpdf, method: 'unpdf' }
757
+ : extension === '.docx' ? { fn: extractWithMammoth, method: 'mammoth' }
758
+ : null;
759
+
760
+ if (nodeBackend) {
761
+ try {
762
+ const text = await nodeBackend.fn(resolvedPath);
763
+ if (text) {
764
+ return buildExtractionResult(resolvedPath, extension, { text, method: nodeBackend.method, droppedInfo: [], structured: null }, maxChars);
765
+ }
766
+ } catch { /* optional dep absent or parse failure — fall through to the sync extractor */ }
767
+ }
768
+
769
+ return extractDocumentText(resolvedPath, { maxChars });
770
+ }
771
+
697
772
  // Async high-fidelity extraction path. Routes PDF/Office/HTML through the
698
773
  // docling Python sidecar (layout-aware markdown) and audio/video through
699
774
  // whisper.cpp (Metal-accelerated on macOS). Text/transcript/calendar/email
@@ -8,14 +8,15 @@
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';
12
- import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
11
+ import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
12
+ import { extractDocumentText, extractDocumentTextAsync, extractDocumentTextNodeNative, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
13
13
  import { syncFileStateToSql } from './storage/sync.mjs';
14
14
  import { stampFrontmatter } from './doc-stamp.mjs';
15
15
  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,79 @@ 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
+
210
+ // The default adapter path prefers the Node-native extractor (unpdf/mammoth) so
211
+ // plain PDF/DOCX ingest needs no Python venv; it self-degrades to the sync
212
+ // system-binary extractor when the optional dep is absent. High-fidelity stays
213
+ // on docling, reserved for scanned/layout-critical and audio/video.
214
+
129
215
  function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
130
216
  return highFidelity
131
- ? extractDocumentTextAsync(sourcePath, { maxChars })
132
- : extractDocumentText(sourcePath, { maxChars });
217
+ ? extractWithDoclingFallback(sourcePath, { maxChars })
218
+ : extractDocumentTextNodeNative(sourcePath, { maxChars });
133
219
  }
134
220
 
135
221
  async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {
136
- const primary = strategy === 'provider' ? 'provider' : 'adapter';
222
+ const primary = strategy === 'provider' || strategy === 'docling-remote' ? strategy : 'adapter';
137
223
  const run = (mode) => (mode === 'provider'
138
224
  ? extractViaProvider({ filePath: sourcePath, model, provider, maxChars, env })
139
- : extractViaAdapter(sourcePath, { highFidelity, maxChars }));
225
+ : mode === 'docling-remote'
226
+ ? extractViaDoclingRemote({ filePath: sourcePath, maxChars, env })
227
+ : extractViaAdapter(sourcePath, { highFidelity, maxChars }));
140
228
 
141
229
  try {
142
230
  const extracted = await run(primary);
@@ -207,6 +295,7 @@ export async function ingestDocuments(inputPaths, {
207
295
  const finalPath = nextAvailablePath(targetPath);
208
296
  const extractedAt = new Date().toISOString();
209
297
  const title = metadata?.title || formatTitle(sourcePath);
298
+ const { markdown: bodyText, assets: imageAssets } = externalizeEmbeddedImages(extracted.text, { mdPath: finalPath });
210
299
  const markdown = renderMarkdown({
211
300
  sourcePath,
212
301
  extractedAt,
@@ -214,7 +303,7 @@ export async function ingestDocuments(inputPaths, {
214
303
  extractionMethod: extracted.extractionMethod,
215
304
  characters: extracted.characters,
216
305
  truncated: extracted.truncated,
217
- text: extracted.text,
306
+ text: bodyText,
218
307
  outputPath: finalPath,
219
308
  cwd,
220
309
  metadata,
@@ -229,6 +318,7 @@ export async function ingestDocuments(inputPaths, {
229
318
  characters: extracted.characters,
230
319
  droppedInfo: extracted.droppedInfo ?? [],
231
320
  structured: extracted.structured ?? null,
321
+ images: imageAssets.map((p) => relative(cwd, p)),
232
322
  metadata: { title, authors: metadata?.authors || [], dates: metadata?.dates || {} },
233
323
  });
234
324
  }
@@ -202,6 +202,12 @@ function normalizeStatus(raw) {
202
202
  return 'planned';
203
203
  }
204
204
 
205
+ // Normalize the volatile "Last updated" stamp so two renders can be compared on
206
+ // content alone.
207
+ function stripTimestamp(markdown) {
208
+ return String(markdown || '').replace(/^> Last updated: .*$/m, '> Last updated:');
209
+ }
210
+
205
211
  /**
206
212
  * Render entries back to roadmap markdown.
207
213
  */
@@ -324,7 +330,16 @@ export async function generateRoadmap({ targetPath, snapshot, roles }) {
324
330
  // Render
325
331
  const markdown = renderRoadmap(entries, { roles, sources });
326
332
 
327
- // Write
333
+ // Skip the write when only the generated "Last updated" stamp would change —
334
+ // otherwise every render rewrites a tracked file with no semantic diff and
335
+ // leaves the working tree perpetually dirty. The timestamp then reflects when
336
+ // the roadmap content last actually moved, not when generation last ran.
337
+ let existingRaw = null;
338
+ try { existingRaw = readFileSync(roadmapPath, 'utf8'); } catch { existingRaw = null; }
339
+ if (existingRaw !== null && stripTimestamp(existingRaw) === stripTimestamp(markdown)) {
340
+ return { path: roadmapPath, updatedAt, itemCount: entries.length, isNew: false, unchanged: true };
341
+ }
342
+
328
343
  mkdirSync(join(targetPath, 'docs'), { recursive: true });
329
344
  writeFileSync(roadmapPath, markdown, 'utf8');
330
345
 
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * Generates embeddings via @huggingface/transformers, caches them to disk,
5
5
  * computes cosine similarity, and clusters related signals by topic.
6
- * All local no external API calls. Default model is all-MiniLM-L6-v2
7
- * (384-dimensional vectors, ~50MB on disk, fast inference).
6
+ * Inference is local and offline (allowRemoteModels=false): the model loads
7
+ * from local cache only and returns null when no cached weights are present,
8
+ * never reaching the network. Default model is all-MiniLM-L6-v2 (384-dimensional
9
+ * vectors, ~50MB on disk, fast inference).
8
10
  *
9
11
  * Storage:
10
12
  * ~/.cx/cache/embeddings/<sha256>.json — single embedding vector
@@ -37,6 +39,7 @@ async function getEmbedder() {
37
39
  try {
38
40
  const { pipeline, env: hfEnv } = await import('@huggingface/transformers');
39
41
  hfEnv.allowLocalModels = true;
42
+ hfEnv.allowRemoteModels = false;
40
43
  hfEnv.useBrowserCache = false;
41
44
  embedder = await pipeline('feature-extraction', MODEL_NAME, {
42
45
  quantized: true,
@@ -8,9 +8,18 @@
8
8
  * wires in) — when no summariser is provided, the representative observation's
9
9
  * own summary is kept verbatim.
10
10
  *
11
+ * Supersede (the mem0/Letta decision layer): clustering alone left every
12
+ * near-duplicate live, so search returned N restatements of the same fact. The
13
+ * supersede pass keeps only the highest-salience member of a tight cluster live
14
+ * and archives the rest behind a `supersededBy` pointer — salience (the
15
+ * observation's confidence) decides the winner, the newest breaks a tie. The
16
+ * winner becomes the cluster representative, so the kept insight points at the
17
+ * most valuable member rather than the lexically-first one.
18
+ *
11
19
  * Outputs:
12
20
  * .cx/observations/consolidated.json list of insight clusters
13
21
  * .cx/observations/archive/<id>.json observations demoted to cold archive
22
+ * (superseded members carry supersededBy)
14
23
  *
15
24
  * The pass is idempotent: re-running it on a stable corpus produces the same
16
25
  * cluster set. It is safe to schedule as a cron / launchd job, run in the
@@ -24,6 +33,7 @@
24
33
  import fs from 'node:fs';
25
34
  import path from 'node:path';
26
35
  import { cosineSimilarity } from '../storage/embeddings.mjs';
36
+ import { detectContradiction } from './contradiction.mjs';
27
37
 
28
38
  const OBS_DIR = '.cx/observations';
29
39
  const ARCHIVE_DIR = '.cx/observations/archive';
@@ -35,6 +45,19 @@ const DEFAULTS = {
35
45
  similarityThreshold: 0.95,
36
46
  archiveAfterDays: 60,
37
47
  archiveBelowConfidence: 0.5,
48
+ // Supersede only collapses a member that is a true restatement of the winner,
49
+ // a stricter bar than the cluster-forming threshold: cluster-adjacent is not
50
+ // the same as duplicate.
51
+ supersedeDuplicates: true,
52
+ supersedeThreshold: 0.97,
53
+ // Contradiction sits in the band between same-subject and duplicate: low
54
+ // enough that the flipped claim drops cosine out of the duplicate range, high
55
+ // enough that the two are still about the same thing. The O(n²) scan is
56
+ // bounded by contradictionScanMax so a large store degrades to a no-op rather
57
+ // than a stall.
58
+ detectContradictions: true,
59
+ contradictionMinSimilarity: 0.75,
60
+ contradictionScanMax: 1500,
38
61
  maxStored: 5000,
39
62
  // Archive retention: keep at most this many archived files, and delete
40
63
  // anything older than archiveRetainDays. Both bounds apply.
@@ -56,12 +79,27 @@ function readObservation(rootDir, id) {
56
79
  return readJsonOrEmpty(filePath, null);
57
80
  }
58
81
 
59
- function archiveObservation(rootDir, id) {
82
+ function archiveObservation(rootDir, id, supersededBy = null, reason = null) {
60
83
  const src = path.join(rootDir, OBS_DIR, `${id}.json`);
61
84
  if (!fs.existsSync(src)) return false;
62
85
  const archiveDir = path.join(rootDir, ARCHIVE_DIR);
63
86
  fs.mkdirSync(archiveDir, { recursive: true });
64
87
  const dest = path.join(archiveDir, `${id}.json`);
88
+
89
+ // A superseded member records what replaced it and why before leaving the
90
+ // live store, so the archive stays auditable and a future undo knows both the
91
+ // winner and whether it was a restatement or a contradiction.
92
+ if (supersededBy) {
93
+ const record = readJsonOrEmpty(src, null);
94
+ if (record) {
95
+ record.supersededBy = supersededBy;
96
+ record.supersededAt = new Date().toISOString();
97
+ if (reason) record.supersededReason = reason;
98
+ fs.writeFileSync(dest, JSON.stringify(record, null, 2) + '\n');
99
+ fs.unlinkSync(src);
100
+ return true;
101
+ }
102
+ }
65
103
  fs.renameSync(src, dest);
66
104
  return true;
67
105
  }
@@ -71,6 +109,77 @@ function daysAgo(dateStr) {
71
109
  return (Date.now() - new Date(dateStr).getTime()) / (24 * 60 * 60 * 1000);
72
110
  }
73
111
 
112
+ // The winner of a near-duplicate cluster is the member worth keeping live:
113
+ // highest salience (confidence) first, then the most recent restatement, then a
114
+ // stable id tiebreak so the choice is deterministic across runs.
115
+ function rankForSupersede(a, b) {
116
+ const conf = (b.confidence || 0) - (a.confidence || 0);
117
+ if (conf !== 0) return conf;
118
+ const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
119
+ const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
120
+ if (bt !== at) return bt - at;
121
+ return String(a.id).localeCompare(String(b.id));
122
+ }
123
+
124
+ // A contradiction resolves the opposite way from a restatement: the world
125
+ // changed, so the most recent claim wins regardless of salience; salience and a
126
+ // stable id only break a same-timestamp tie.
127
+ function rankByRecency(a, b) {
128
+ const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
129
+ const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
130
+ if (bt !== at) return bt - at;
131
+ const conf = (b.confidence || 0) - (a.confidence || 0);
132
+ if (conf !== 0) return conf;
133
+ return String(a.id).localeCompare(String(b.id));
134
+ }
135
+
136
+ /**
137
+ * Scan a record set for same-subject contradictions and archive the older of
138
+ * each contradicting pair behind a supersededBy pointer with reason
139
+ * 'contradiction'. Mutates nothing; returns the loser ids and the supersede
140
+ * entries. Bounded by scanMax — above it the O(n²) scan is skipped wholesale so
141
+ * a large store never stalls consolidation.
142
+ */
143
+ async function scanContradictions(rootDir, records, settings) {
144
+ const superseded = [];
145
+ const losers = new Set();
146
+ if (records.length > settings.contradictionScanMax) {
147
+ return { superseded, losers, skipped: true };
148
+ }
149
+
150
+ const ordered = [...records].sort(rankByRecency);
151
+ for (let i = 0; i < ordered.length; i++) {
152
+ const newer = ordered[i];
153
+ if (losers.has(newer.id)) continue;
154
+ for (let j = i + 1; j < ordered.length; j++) {
155
+ const older = ordered[j];
156
+ if (losers.has(older.id)) continue;
157
+ if (!newer.embedding?.length || !older.embedding?.length) continue;
158
+
159
+ const sim = cosineSimilarity(newer.embedding, older.embedding);
160
+ const sameSubject = sim >= settings.contradictionMinSimilarity && sim < settings.supersedeThreshold;
161
+ if (!sameSubject) continue;
162
+
163
+ // The heuristic runs first and for free; the optional judge is consulted
164
+ // only on its misses (the value-swap case it abstains on). Awaiting covers
165
+ // both a sync local judge and a future async one.
166
+ const heuristic = detectContradiction(newer.summary, older.summary);
167
+ let contradicts = heuristic.contradicts;
168
+ if (!contradicts && settings.contradictionJudge?.judge) {
169
+ try { contradicts = !!((await settings.contradictionJudge.judge(newer, older))?.contradicts); }
170
+ catch { contradicts = false; }
171
+ }
172
+ if (!contradicts) continue;
173
+
174
+ if (archiveObservation(rootDir, older.id, newer.id, 'contradiction')) {
175
+ losers.add(older.id);
176
+ superseded.push({ id: older.id, supersededBy: newer.id, reason: 'contradiction' });
177
+ }
178
+ }
179
+ }
180
+ return { superseded, losers, skipped: false };
181
+ }
182
+
74
183
  /**
75
184
  * Greedy single-pass clustering. Each observation is added to the existing
76
185
  * cluster whose centroid has highest cosine similarity, provided that
@@ -145,9 +254,15 @@ async function buildSummary(cluster, summariser) {
145
254
  * @param {number} [opts.similarityThreshold=0.95]
146
255
  * @param {number} [opts.archiveAfterDays=60]
147
256
  * @param {number} [opts.archiveBelowConfidence=0.5]
257
+ * @param {boolean} [opts.supersedeDuplicates=true] - archive tight restatements, keeping the highest-salience member live
258
+ * @param {number} [opts.supersedeThreshold=0.97] - cosine bar a member must clear against the winner to be superseded
259
+ * @param {boolean} [opts.detectContradictions=true] - archive the older of a same-subject contradicting pair (newest wins)
260
+ * @param {number} [opts.contradictionMinSimilarity=0.75] - cosine floor for two observations to count as same-subject
261
+ * @param {number} [opts.contradictionScanMax=1500] - skip the O(n²) contradiction scan above this many live records
262
+ * @param {object} [opts.contradictionJudge] - optional plugin with `judge(a, b) -> { contradicts }` for the value-swap case the heuristic misses
148
263
  * @param {number} [opts.maxStored=5000]
149
264
  * @param {object} [opts.summariser] - Compressor-shaped plugin
150
- * @returns {Promise<{ clustersBefore: number, clusters: number, archived: string[], stored: number }>}
265
+ * @returns {Promise<{ clustersBefore: number, clusters: number, archived: string[], superseded: Array<{id: string, supersededBy: string, reason: string}>, contradictionScanSkipped: boolean, stored: number }>}
151
266
  */
152
267
  export async function consolidate(rootDir, opts = {}) {
153
268
  const settings = { ...DEFAULTS, ...opts };
@@ -177,7 +292,7 @@ export async function consolidate(rootDir, opts = {}) {
177
292
  const clustersBefore = records.length;
178
293
 
179
294
  const archived = [];
180
- const liveRecords = records.filter((r) => {
295
+ let liveRecords = records.filter((r) => {
181
296
  const old = daysAgo(r.createdAt) > settings.archiveAfterDays;
182
297
  const lowConfidence = r.confidence < settings.archiveBelowConfidence;
183
298
  if (old && lowConfidence) {
@@ -187,8 +302,47 @@ export async function consolidate(rootDir, opts = {}) {
187
302
  return true;
188
303
  });
189
304
 
305
+ const superseded = [];
306
+
307
+ // Contradiction runs before clustering: a flipped claim sits below the
308
+ // duplicate threshold, so it would never cluster with what it contradicts and
309
+ // both stale and current statements would stay live. The older loser leaves
310
+ // the set so it is not clustered or returned by search.
311
+ let contradictionScanSkipped = false;
312
+ if (settings.detectContradictions) {
313
+ const result = await scanContradictions(rootDir, liveRecords, settings);
314
+ contradictionScanSkipped = result.skipped;
315
+ for (const id of result.losers) archived.push(id);
316
+ superseded.push(...result.superseded);
317
+ if (result.losers.size > 0) {
318
+ liveRecords = liveRecords.filter((r) => !result.losers.has(r.id));
319
+ }
320
+ }
321
+
190
322
  const clusters = greedyCluster(liveRecords, settings.similarityThreshold);
191
323
 
324
+ // Supersede tight restatements: rank each multi-member cluster by salience so
325
+ // the winner leads (it becomes the representative), then archive any member
326
+ // that duplicates the winner closely enough to be the same fact.
327
+ if (settings.supersedeDuplicates) {
328
+ for (const cluster of clusters) {
329
+ if (cluster.members.length < 2) continue;
330
+ cluster.members.sort(rankForSupersede);
331
+ const winner = cluster.members[0];
332
+ cluster.supersededIds = [];
333
+ for (const loser of cluster.members.slice(1)) {
334
+ const tight = winner.embedding?.length && loser.embedding?.length &&
335
+ cosineSimilarity(winner.embedding, loser.embedding) >= settings.supersedeThreshold;
336
+ if (!tight) continue;
337
+ if (archiveObservation(rootDir, loser.id, winner.id, 'restatement')) {
338
+ archived.push(loser.id);
339
+ superseded.push({ id: loser.id, supersededBy: winner.id, reason: 'restatement' });
340
+ cluster.supersededIds.push(loser.id);
341
+ }
342
+ }
343
+ }
344
+ }
345
+
192
346
  const consolidated = [];
193
347
  for (const cluster of clusters) {
194
348
  const representative = cluster.members[0];
@@ -206,6 +360,7 @@ export async function consolidate(rootDir, opts = {}) {
206
360
  category: representative.category,
207
361
  tags: [...new Set(cluster.members.flatMap((m) => m.tags || []))].slice(0, 10),
208
362
  memberIds: cluster.members.map((m) => m.id),
363
+ supersededIds: cluster.supersededIds || [],
209
364
  avgConfidence:
210
365
  cluster.members.reduce((acc, m) => acc + (m.confidence || 0), 0) /
211
366
  Math.max(cluster.members.length, 1),
@@ -247,6 +402,8 @@ export async function consolidate(rootDir, opts = {}) {
247
402
  clustersBefore,
248
403
  clusters: consolidated.length,
249
404
  archived,
405
+ superseded,
406
+ contradictionScanSkipped,
250
407
  archivePruned,
251
408
  stored: consolidated.length,
252
409
  };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * lib/engine/contradiction-judge.mjs — optional LLM second opinion for the
3
+ * consolidation contradiction pass.
4
+ *
5
+ * The negation-polarity heuristic (lib/engine/contradiction.mjs) catches a
6
+ * flipped claim but abstains on a value swap with no negation cue — "auth uses
7
+ * RS256" vs "auth uses HS256" share every word yet disagree. Resolving that
8
+ * needs to know the two values are mutually exclusive, which is semantic
9
+ * judgment. This factory builds the judge consolidation consults only when the
10
+ * heuristic says "no".
11
+ *
12
+ * Offline-first: the judge is backed by a local Ollama model and the factory
13
+ * returns null when Ollama is not running or has no model, so consolidation
14
+ * degrades to heuristic-only with no provider and no key required. The status
15
+ * probe and the runner are injectable so the parse logic is testable without a
16
+ * live model.
17
+ */
18
+
19
+ import { checkOllamaStatus, testModel } from '../ollama-manager.mjs';
20
+
21
+ function buildPrompt(a, b) {
22
+ const textA = (a?.summary || a?.content || '').slice(0, 400);
23
+ const textB = (b?.summary || b?.content || '').slice(0, 400);
24
+ return [
25
+ 'Two short notes describe the same subject. Decide whether they directly',
26
+ 'contradict — assert mutually exclusive facts (e.g. different algorithms,',
27
+ 'opposite states, incompatible values). A restatement or an added detail is',
28
+ 'NOT a contradiction. Answer with only YES or NO.',
29
+ '',
30
+ `Note A: ${textA}`,
31
+ `Note B: ${textB}`,
32
+ 'Answer:',
33
+ ].join('\n');
34
+ }
35
+
36
+ function parseVerdict(response) {
37
+ const match = String(response || '').toLowerCase().match(/\b(yes|no)\b/);
38
+ return { contradicts: !!match && match[1] === 'yes' };
39
+ }
40
+
41
+ /**
42
+ * Build a contradiction judge, or null when no local model is available.
43
+ *
44
+ * @param {object} [opts]
45
+ * @param {() => {running?: boolean, models?: Array<{name?: string, model?: string}>}} [opts.statusFn]
46
+ * @param {(model: string, prompt: string) => {success?: boolean, response?: string}} [opts.runFn]
47
+ * @param {string} [opts.model] explicit model override (else env, else first available)
48
+ * @returns {{judge: (a: object, b: object) => {contradicts: boolean}} | null}
49
+ */
50
+ export function createContradictionJudge({ statusFn = checkOllamaStatus, runFn = testModel, model } = {}) {
51
+ const status = statusFn();
52
+ if (!status?.running || !status.models?.length) return null;
53
+
54
+ const chosen = model || process.env.CONSTRUCT_CONTRADICTION_JUDGE_MODEL ||
55
+ status.models[0]?.name || status.models[0]?.model;
56
+ if (!chosen) return null;
57
+
58
+ return {
59
+ judge(a, b) {
60
+ try {
61
+ const result = runFn(chosen, buildPrompt(a, b));
62
+ if (!result?.success) return { contradicts: false };
63
+ return parseVerdict(result.response);
64
+ } catch {
65
+ return { contradicts: false };
66
+ }
67
+ },
68
+ };
69
+ }
70
+
71
+ export const __testing = { buildPrompt, parseVerdict };