@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.
- package/README.md +2 -0
- package/bin/construct +266 -13
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/audit-trail.mjs +77 -10
- 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 +112 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/demo.mjs +245 -0
- package/lib/diagram.mjs +300 -0
- package/lib/doctor/index.mjs +1 -1
- package/lib/doctor/watchers/process-pressure.mjs +1 -1
- package/lib/doctor/watchers/service-health.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 +85 -10
- package/lib/document-ingest.mjs +97 -7
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/embed/semantic.mjs +5 -2
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- 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/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp-catalog.json +1 -1
- package/lib/model-router.mjs +52 -1
- 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-policy.mjs +41 -6
- package/lib/persona-sections.mjs +66 -0
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +14 -9
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- 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/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +42 -4
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +58 -2
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +4 -2
- package/personas/construct.md +12 -12
- package/platforms/capabilities.json +76 -0
- 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 +277 -63
- package/skills/docs/init-project.md +1 -1
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/templates/docs/construct_guide.md +2 -2
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
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)) {
|
|
@@ -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
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -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 ``;
|
|
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
|
-
?
|
|
132
|
-
:
|
|
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'
|
|
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
|
-
:
|
|
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:
|
|
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
|
}
|
package/lib/embed/roadmap.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
package/lib/embed/semantic.mjs
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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 };
|