@geraldmaron/construct 1.0.24 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/construct +185 -3
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/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/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/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- 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/service-manager.mjs +41 -3
- package/lib/setup.mjs +21 -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/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +1 -1
- package/personas/construct.md +1 -1
- 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 +139 -39
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lib/bridges/copilot-proxy.mjs — Minimal GitHub Copilot to OpenAI-compatible bridge.
|
|
4
|
+
*
|
|
5
|
+
* Translates standard OpenAI /v1/chat/completions requests to the Copilot API
|
|
6
|
+
* using the user's active `gh` CLI session.
|
|
7
|
+
*/
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const PORT = parseInt(process.argv.find(a => a.startsWith('--port='))?.split('=')[1] || '5174', 10);
|
|
12
|
+
|
|
13
|
+
let cachedSession = null;
|
|
14
|
+
|
|
15
|
+
async function getCopilotToken() {
|
|
16
|
+
if (cachedSession && cachedSession.expires_at > (Date.now() / 1000) + 60) {
|
|
17
|
+
return cachedSession.token;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const ghToken = execSync('gh auth token', { encoding: 'utf8' }).trim();
|
|
22
|
+
const res = await fetch('https://api.github.com/copilot_internal/v2/token', {
|
|
23
|
+
headers: {
|
|
24
|
+
'Authorization': `Bearer ${ghToken}`,
|
|
25
|
+
'Accept': 'application/json',
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) throw new Error(`Failed to get session token: ${res.statusText}`);
|
|
30
|
+
|
|
31
|
+
cachedSession = await res.json();
|
|
32
|
+
return cachedSession.token;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('Error fetching Copilot token:', err.message);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const server = http.createServer(async (req, res) => {
|
|
40
|
+
// CORS
|
|
41
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
42
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
43
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
44
|
+
|
|
45
|
+
if (req.method === 'OPTIONS') {
|
|
46
|
+
res.writeHead(204);
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
|
|
52
|
+
const token = await getCopilotToken();
|
|
53
|
+
if (!token) {
|
|
54
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
55
|
+
res.end(JSON.stringify({ error: 'Failed to authenticate with GitHub Copilot via gh CLI.' }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let body = '';
|
|
60
|
+
req.on('data', chunk => { body += chunk; });
|
|
61
|
+
req.on('end', async () => {
|
|
62
|
+
try {
|
|
63
|
+
const payload = JSON.parse(body);
|
|
64
|
+
|
|
65
|
+
// Map common model names to Copilot equivalents if needed
|
|
66
|
+
// For now, we pass them through or default to gpt-4o
|
|
67
|
+
const model = payload.model?.includes('gpt-4o') ? 'gpt-4o' :
|
|
68
|
+
payload.model?.includes('claude-3.5-sonnet') ? 'claude-3.5-sonnet' :
|
|
69
|
+
'gpt-4o';
|
|
70
|
+
|
|
71
|
+
const copilotRes = await fetch('https://api.githubcopilot.com/chat/completions', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Authorization': `Bearer ${token}`,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'Accept': 'application/json',
|
|
77
|
+
'X-Github-Api-Version': '2023-07-07',
|
|
78
|
+
'Editor-Version': 'vscode/1.90.0', // Spoof VS Code for compatibility
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
...payload,
|
|
82
|
+
model
|
|
83
|
+
})
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
res.writeHead(copilotRes.status, {
|
|
87
|
+
'Content-Type': copilotRes.headers.get('Content-Type'),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (payload.stream) {
|
|
91
|
+
const reader = copilotRes.body.getReader();
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
res.write(value);
|
|
96
|
+
}
|
|
97
|
+
res.end();
|
|
98
|
+
} else {
|
|
99
|
+
const data = await copilotRes.text();
|
|
100
|
+
res.end(data);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
104
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end('Not Found');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
115
|
+
console.log(`Copilot Bridge listening on http://127.0.0.1:${PORT}`);
|
|
116
|
+
});
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -61,13 +61,14 @@ export const CLI_COMMANDS = [
|
|
|
61
61
|
category: 'Core',
|
|
62
62
|
core: true,
|
|
63
63
|
description: 'Machine setup (scoped per ADR-0029): --scope=project|user|both, default project',
|
|
64
|
-
usage: 'construct install [--scope=project|user|both] [--yes] [--no-docker] [--no-launch-agent] [--reconfigure]',
|
|
64
|
+
usage: 'construct install [--scope=project|user|both] [--yes] [--no-docker] [--no-launch-agent] [--reconfigure] [--with-docling]',
|
|
65
65
|
options: [
|
|
66
66
|
{ flag: '--scope=<s>', desc: 'project (default, no-op + guidance) | user (writes ~/.construct/, MCP, ~/.claude/* via consent) | both' },
|
|
67
67
|
{ flag: '--yes', desc: 'Apply defaults without prompts (only meaningful with --scope=user|both)' },
|
|
68
68
|
{ flag: '--no-docker', desc: 'Skip Docker-based service setup (local Postgres)' },
|
|
69
69
|
{ flag: '--no-launch-agent', desc: 'Skip background macOS LaunchAgent registration' },
|
|
70
70
|
{ flag: '--reconfigure', desc: 'Re-prompt for service consent, ignoring cached answers' },
|
|
71
|
+
{ flag: '--with-docling', desc: 'Eagerly provision the docling document-extraction venv now (heavy, ~10 min; else lazy on first ingest)' },
|
|
71
72
|
],
|
|
72
73
|
},
|
|
73
74
|
{
|
|
@@ -936,6 +937,9 @@ export const CLI_COMMANDS = [
|
|
|
936
937
|
{ name: 'lint:contracts', category: 'Internal', core: false, internal: true, description: 'Internal lint: specialist contracts', usage: 'construct lint:contracts' },
|
|
937
938
|
{ name: 'lint:research', category: 'Internal', core: false, internal: true, description: 'Internal lint: research artifacts', usage: 'construct lint:research' },
|
|
938
939
|
{ name: 'lint:templates', category: 'Internal', core: false, internal: true, description: 'Internal lint: shipped templates', usage: 'construct lint:templates' },
|
|
940
|
+
{ name: 'lint:prompts', category: 'Internal', core: false, internal: true, description: 'Internal lint: specialist prompt frontmatter + sections', usage: 'construct lint:prompts' },
|
|
941
|
+
{ name: 'specialist', category: 'Internal', core: false, internal: true, description: 'Maintainer tool: scaffold, edit, and lint specialist prompts', usage: 'construct specialist <create|edit|lint>' },
|
|
942
|
+
{ name: 'registry:status', category: 'Internal', core: false, internal: true, description: 'Dev: capability-matrix registry inspector', usage: 'construct registry:status' },
|
|
939
943
|
{ name: 'evaluator:rubrics', category: 'Internal', core: false, internal: true, description: 'Internal: list registered evaluator rubrics', usage: 'construct evaluator:rubrics' },
|
|
940
944
|
{ name: 'activation:status', category: 'Internal', core: false, internal: true, description: 'Internal: agent activation telemetry', usage: 'construct activation:status' },
|
|
941
945
|
{ name: 'prune', category: 'Internal', core: false, internal: true, description: 'Internal: prune ephemeral storage entries', usage: 'construct prune' },
|
package/lib/comment-lint.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function isConstructSelfRepo(rootDir) {
|
|
|
86
86
|
|
|
87
87
|
function requiresHeader(rel) {
|
|
88
88
|
const ext = path.extname(rel);
|
|
89
|
-
if (['.yaml', '.yml', '.json', '.jsonl', '.toml'].includes(ext)) return { required: false, type: null };
|
|
89
|
+
if (['.yaml', '.yml', '.json', '.jsonl', '.toml', '.txt'].includes(ext)) return { required: false, type: null };
|
|
90
90
|
const jsMatch = JS_HEADER_GLOBS.some(r => r.test(rel));
|
|
91
91
|
const mdMatch = MD_HEADER_GLOBS.some(r => r.test(rel));
|
|
92
92
|
// Exclude static assets under lib/server/static from requiring headers
|
package/lib/config/schema.mjs
CHANGED
|
@@ -24,7 +24,7 @@ export const DEFAULT_PROFILE_ID = 'rnd';
|
|
|
24
24
|
|
|
25
25
|
export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cursor'];
|
|
26
26
|
|
|
27
|
-
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
27
|
+
export const INGEST_STRATEGIES = ['adapter', 'provider', 'docling-remote'];
|
|
28
28
|
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
29
29
|
export const INGEST_ORCHESTRATIONS = ['prompt-only', 'orchestrated'];
|
|
30
30
|
|
|
@@ -21,10 +21,11 @@ const SIDECAR_SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url))
|
|
|
21
21
|
const REQUEST_TIMEOUT_MS = 300_000;
|
|
22
22
|
|
|
23
23
|
let activeSidecar = null;
|
|
24
|
+
let sidecarStarting = null;
|
|
24
25
|
let requestSeq = 0;
|
|
25
26
|
|
|
26
|
-
function spawnSidecar({ runtimeDir } = {}) {
|
|
27
|
-
const { pythonBin } = ensureDoclingVenv({ runtimeDir });
|
|
27
|
+
async function spawnSidecar({ runtimeDir } = {}) {
|
|
28
|
+
const { pythonBin } = await ensureDoclingVenv({ runtimeDir });
|
|
28
29
|
const child = spawn(pythonBin, [SIDECAR_SCRIPT], {
|
|
29
30
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
31
|
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
@@ -89,14 +90,23 @@ function spawnSidecar({ runtimeDir } = {}) {
|
|
|
89
90
|
return sidecar;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
// Provisioning is now async, so two concurrent extracts could both spawn a
|
|
94
|
+
// sidecar. Cache the in-flight start promise so the first call wins and the rest
|
|
95
|
+
// await it; once resolved, the running sidecar is reused directly.
|
|
96
|
+
|
|
97
|
+
async function getSidecar() {
|
|
93
98
|
if (activeSidecar && activeSidecar.child.exitCode === null && !activeSidecar.child.killed) return activeSidecar;
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
if (!sidecarStarting) {
|
|
100
|
+
sidecarStarting = spawnSidecar().then(
|
|
101
|
+
(s) => { activeSidecar = s; sidecarStarting = null; return s; },
|
|
102
|
+
(e) => { sidecarStarting = null; throw e; },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return sidecarStarting;
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
export async function extractViaDocling(filePath) {
|
|
99
|
-
const sidecar = getSidecar();
|
|
109
|
+
const sidecar = await getSidecar();
|
|
100
110
|
const result = await sidecar.send('extract', { path: path.resolve(filePath) });
|
|
101
111
|
return result;
|
|
102
112
|
}
|
|
@@ -36,10 +36,40 @@ except ImportError as exc:
|
|
|
36
36
|
_converter = None
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
# By default docling emits a bare `<!-- image -->` marker and discards pixel data,
|
|
40
|
+
# so embedded figures are lost. Enable picture-image generation on the PDF
|
|
41
|
+
# pipeline and export markdown with images embedded as base64 data URIs; the Node
|
|
42
|
+
# side externalizes those into an assets/ directory. The configuration is wrapped
|
|
43
|
+
# so a docling API change degrades to the default converter instead of crashing.
|
|
44
|
+
|
|
45
|
+
def build_converter():
|
|
46
|
+
try:
|
|
47
|
+
from docling.datamodel.base_models import InputFormat
|
|
48
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
49
|
+
from docling.document_converter import PdfFormatOption
|
|
50
|
+
|
|
51
|
+
pdf_options = PdfPipelineOptions()
|
|
52
|
+
pdf_options.generate_picture_images = True
|
|
53
|
+
pdf_options.images_scale = 2.0
|
|
54
|
+
return DocumentConverter(
|
|
55
|
+
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pdf_options)}
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
return DocumentConverter()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def export_markdown(doc):
|
|
62
|
+
try:
|
|
63
|
+
from docling_core.types.doc import ImageRefMode
|
|
64
|
+
return doc.export_to_markdown(image_mode=ImageRefMode.EMBEDDED)
|
|
65
|
+
except Exception:
|
|
66
|
+
return doc.export_to_markdown()
|
|
67
|
+
|
|
68
|
+
|
|
39
69
|
def get_converter():
|
|
40
70
|
global _converter
|
|
41
71
|
if _converter is None:
|
|
42
|
-
_converter =
|
|
72
|
+
_converter = build_converter()
|
|
43
73
|
return _converter
|
|
44
74
|
|
|
45
75
|
|
|
@@ -53,7 +83,7 @@ def extract(params):
|
|
|
53
83
|
|
|
54
84
|
result = get_converter().convert(str(path))
|
|
55
85
|
doc = result.document
|
|
56
|
-
markdown = doc
|
|
86
|
+
markdown = export_markdown(doc)
|
|
57
87
|
|
|
58
88
|
metadata = {
|
|
59
89
|
"format": result.input.format.value if hasattr(result.input, "format") else None,
|
package/lib/document-extract.mjs
CHANGED
|
@@ -107,6 +107,11 @@ function run(command, args) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function commandExists(command) {
|
|
110
|
+
// Test seam: a comma-separated deny-list forces a command to read as absent,
|
|
111
|
+
// so the cross-platform fallbacks (e.g. RTF stripping without macOS textutil)
|
|
112
|
+
// are reachable on any host.
|
|
113
|
+
const denied = (process.env.CONSTRUCT_EXTRACT_NO_COMMANDS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
114
|
+
if (denied.includes(command)) return false;
|
|
110
115
|
const checker = process.platform === 'win32' ? 'where' : 'which';
|
|
111
116
|
return spawnSync(checker, [command], { stdio: 'ignore' }).status === 0;
|
|
112
117
|
}
|
|
@@ -216,16 +221,38 @@ function extractZipDocument(filePath, extension) {
|
|
|
216
221
|
return { text, method: 'zip-xml', droppedInfo, structured: null };
|
|
217
222
|
}
|
|
218
223
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
// Minimal RTF-to-text: strip control words, groups, and escapes. RTF is a
|
|
225
|
+
// text format, so this works everywhere; it is the cross-platform fallback when
|
|
226
|
+
// textutil (macOS only) is absent. Lossy on complex documents, but never throws.
|
|
227
|
+
export function rtfToText(raw) {
|
|
228
|
+
let s = String(raw);
|
|
229
|
+
s = s.replace(/\{(?:\\\*)?\\(?:fonttbl|colortbl|stylesheet|info|pict|object)[\s\S]*?\}/gi, '');
|
|
230
|
+
s = s.replace(/\\u(-?\d+)\s?\??/g, (_, n) => (Number(n) >= 0 ? String.fromCharCode(Number(n)) : ''));
|
|
231
|
+
s = s.replace(/\\'([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
232
|
+
s = s.replace(/\\par[d]?\b/g, '\n').replace(/\\line\b/g, '\n').replace(/\\tab\b/g, '\t');
|
|
233
|
+
s = s.replace(/\\[a-zA-Z]+-?\d* ?/g, '');
|
|
234
|
+
s = s.replace(/\\([{}\\])/g, '$1');
|
|
235
|
+
return s.replace(/[{}]/g, '');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function extractRichText(filePath, extension) {
|
|
239
|
+
if (commandExists('textutil')) {
|
|
240
|
+
return {
|
|
241
|
+
text: normalizeText(run('textutil', ['-convert', 'txt', '-stdout', filePath])),
|
|
242
|
+
method: 'textutil',
|
|
243
|
+
droppedInfo: [],
|
|
244
|
+
structured: null,
|
|
245
|
+
};
|
|
222
246
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
247
|
+
if (extension === '.rtf') {
|
|
248
|
+
return {
|
|
249
|
+
text: normalizeText(rtfToText(readFileSync(filePath, 'utf8'))),
|
|
250
|
+
method: 'rtf-strip',
|
|
251
|
+
droppedInfo: [],
|
|
252
|
+
structured: null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
throw new Error('textutil not available for rich-text extraction');
|
|
229
256
|
}
|
|
230
257
|
|
|
231
258
|
function extractPdfWithPdftotext(filePath) {
|
|
@@ -660,7 +687,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
660
687
|
} else if (ZIP_DOCUMENT_EXTS.has(extension)) {
|
|
661
688
|
extracted = extractZipDocument(resolvedPath, extension);
|
|
662
689
|
} else if (RICH_TEXT_EXTS.has(extension)) {
|
|
663
|
-
extracted = extractRichText(resolvedPath);
|
|
690
|
+
extracted = extractRichText(resolvedPath, extension);
|
|
664
691
|
} else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) {
|
|
665
692
|
extracted = { text: extractWithMdls(resolvedPath), method: 'mdls', droppedInfo: [], structured: null };
|
|
666
693
|
} else if (EMAIL_DOCUMENT_EXTS.has(extension)) {
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and records the selected strategy/model in the returned `ingestion` block.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
-
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
|
11
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
12
12
|
import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
|
|
13
13
|
import { syncFileStateToSql } from './storage/sync.mjs';
|
|
14
14
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
@@ -16,6 +16,7 @@ import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
|
16
16
|
import { loadProjectConfig } from './config/project-config.mjs';
|
|
17
17
|
import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
|
|
18
18
|
import { extractViaProvider } from './ingest/provider-extract.mjs';
|
|
19
|
+
import { extractViaDoclingRemote } from './ingest/docling-remote.mjs';
|
|
19
20
|
|
|
20
21
|
const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
|
|
21
22
|
|
|
@@ -56,6 +57,31 @@ function nextAvailablePath(targetPath) {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// docling embeds figures as base64 `data:` URIs in the markdown so no pixel data
|
|
61
|
+
// is lost in transit. Write each to an assets/ directory beside the output file
|
|
62
|
+
// and rewrite the reference to a relative path, so the markdown stays small and
|
|
63
|
+
// the images are real files an editor or viewer can open — replacing the bare
|
|
64
|
+
// `<!-- image -->` placeholder docling emits by default.
|
|
65
|
+
|
|
66
|
+
export function externalizeEmbeddedImages(markdown, { mdPath }) {
|
|
67
|
+
if (!markdown || !markdown.includes('data:image/')) return { markdown, assets: [] };
|
|
68
|
+
const assetsRel = `assets/${basename(mdPath, '.md')}`;
|
|
69
|
+
const assetsDir = join(dirname(mdPath), assetsRel.split('/').join(sep));
|
|
70
|
+
const assets = [];
|
|
71
|
+
let index = 0;
|
|
72
|
+
const re = /!\[([^\]]*)\]\(\s*data:image\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+?)\s*\)/g;
|
|
73
|
+
const out = markdown.replace(re, (_match, alt, type, b64) => {
|
|
74
|
+
index += 1;
|
|
75
|
+
const ext = type.toLowerCase() === 'jpeg' ? 'jpg' : type.toLowerCase().replace(/[^a-z0-9]/g, '') || 'png';
|
|
76
|
+
const fileName = `image-${index}.${ext}`;
|
|
77
|
+
if (!existsSync(assetsDir)) mkdirSync(assetsDir, { recursive: true });
|
|
78
|
+
writeFileSync(join(assetsDir, fileName), Buffer.from(b64.replace(/\s+/g, ''), 'base64'));
|
|
79
|
+
assets.push(join(assetsDir, fileName));
|
|
80
|
+
return ``;
|
|
81
|
+
});
|
|
82
|
+
return { markdown: out, assets };
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
function renderMarkdown({ sourcePath, extractedAt, title, extractionMethod, characters, truncated, text, outputPath, cwd, metadata }) {
|
|
60
86
|
const relSource = relative(cwd, sourcePath) || basename(sourcePath);
|
|
61
87
|
const relOutput = relative(cwd, outputPath) || basename(outputPath);
|
|
@@ -126,17 +152,74 @@ function resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target }) {
|
|
|
126
152
|
return join(resolvedDir, `${basename(sourcePath)}.md`);
|
|
127
153
|
}
|
|
128
154
|
|
|
155
|
+
// The high-fidelity (docling) path provisions a Python venv and downloads ML
|
|
156
|
+
// models on first use, then runs layout inference on the document — any of which
|
|
157
|
+
// can stall (a large PDF, a slow model download) or fail. Without a bound the
|
|
158
|
+
// caller hangs: the CLI never returns, and the ingest_document MCP tool blocks
|
|
159
|
+
// until the client times out and surfaces an opaque error. Bound the whole
|
|
160
|
+
// docling attempt and fall back to the legacy extractor so ingest always returns
|
|
161
|
+
// a usable result, with the fallback recorded in droppedInfo. Override the bound
|
|
162
|
+
// with CONSTRUCT_DOCLING_TIMEOUT_MS (0 disables the timeout).
|
|
163
|
+
|
|
164
|
+
const DOCLING_TIMEOUT_MS = (() => {
|
|
165
|
+
const raw = Number(process.env.CONSTRUCT_DOCLING_TIMEOUT_MS);
|
|
166
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 600_000;
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
export function withTimeout(promise, ms, onTimeoutMessage) {
|
|
170
|
+
if (!ms) return promise;
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const timer = setTimeout(() => reject(Object.assign(new Error(onTimeoutMessage), { code: 'DOCLING_TIMEOUT' })), ms);
|
|
173
|
+
promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run the high-fidelity (docling) extractor with a bound, falling back to the
|
|
179
|
+
* legacy extractor on timeout or any failure so a caller never hangs. The
|
|
180
|
+
* extractor functions are injectable for testing; production uses the real
|
|
181
|
+
* docling-async and legacy-sync extractors.
|
|
182
|
+
*/
|
|
183
|
+
export async function extractWithDoclingFallback(sourcePath, {
|
|
184
|
+
maxChars = null,
|
|
185
|
+
timeoutMs = DOCLING_TIMEOUT_MS,
|
|
186
|
+
asyncExtract = extractDocumentTextAsync,
|
|
187
|
+
syncExtract = extractDocumentText,
|
|
188
|
+
} = {}) {
|
|
189
|
+
try {
|
|
190
|
+
return await withTimeout(
|
|
191
|
+
asyncExtract(sourcePath, { maxChars }),
|
|
192
|
+
timeoutMs,
|
|
193
|
+
`docling extraction exceeded ${Math.round(timeoutMs / 1000)}s`,
|
|
194
|
+
);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const legacy = syncExtract(sourcePath, { maxChars });
|
|
197
|
+
legacy.droppedInfo = [
|
|
198
|
+
...(legacy.droppedInfo ?? []),
|
|
199
|
+
{
|
|
200
|
+
kind: 'docling-fallback',
|
|
201
|
+
count: 1,
|
|
202
|
+
reason: `High-fidelity (docling) extraction failed or timed out (${err.message}); used the legacy extractor. Re-run after \`construct install --with-docling\`, or pass --legacy-extractor to skip docling.`,
|
|
203
|
+
recoverable: true,
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
return legacy;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
129
210
|
function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
|
|
130
211
|
return highFidelity
|
|
131
|
-
?
|
|
212
|
+
? extractWithDoclingFallback(sourcePath, { maxChars })
|
|
132
213
|
: extractDocumentText(sourcePath, { maxChars });
|
|
133
214
|
}
|
|
134
215
|
|
|
135
216
|
async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {
|
|
136
|
-
const primary = strategy === 'provider'
|
|
217
|
+
const primary = strategy === 'provider' || strategy === 'docling-remote' ? strategy : 'adapter';
|
|
137
218
|
const run = (mode) => (mode === 'provider'
|
|
138
219
|
? extractViaProvider({ filePath: sourcePath, model, provider, maxChars, env })
|
|
139
|
-
:
|
|
220
|
+
: mode === 'docling-remote'
|
|
221
|
+
? extractViaDoclingRemote({ filePath: sourcePath, maxChars, env })
|
|
222
|
+
: extractViaAdapter(sourcePath, { highFidelity, maxChars }));
|
|
140
223
|
|
|
141
224
|
try {
|
|
142
225
|
const extracted = await run(primary);
|
|
@@ -207,6 +290,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
207
290
|
const finalPath = nextAvailablePath(targetPath);
|
|
208
291
|
const extractedAt = new Date().toISOString();
|
|
209
292
|
const title = metadata?.title || formatTitle(sourcePath);
|
|
293
|
+
const { markdown: bodyText, assets: imageAssets } = externalizeEmbeddedImages(extracted.text, { mdPath: finalPath });
|
|
210
294
|
const markdown = renderMarkdown({
|
|
211
295
|
sourcePath,
|
|
212
296
|
extractedAt,
|
|
@@ -214,7 +298,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
214
298
|
extractionMethod: extracted.extractionMethod,
|
|
215
299
|
characters: extracted.characters,
|
|
216
300
|
truncated: extracted.truncated,
|
|
217
|
-
text:
|
|
301
|
+
text: bodyText,
|
|
218
302
|
outputPath: finalPath,
|
|
219
303
|
cwd,
|
|
220
304
|
metadata,
|
|
@@ -229,6 +313,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
229
313
|
characters: extracted.characters,
|
|
230
314
|
droppedInfo: extracted.droppedInfo ?? [],
|
|
231
315
|
structured: extracted.structured ?? null,
|
|
316
|
+
images: imageAssets.map((p) => relative(cwd, p)),
|
|
232
317
|
metadata: { title, authors: metadata?.authors || [], dates: metadata?.dates || {} },
|
|
233
318
|
});
|
|
234
319
|
}
|
package/lib/embed/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
|
|