@geraldmaron/construct 1.0.7 → 1.0.9
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 +20 -17
- package/bin/construct +1199 -69
- package/commands/work/optimize-prompts.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +2 -2
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +5 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +65 -17
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/doctor/watchers/consistency.mjs +62 -12
- package/lib/document-extract.mjs +221 -28
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/inbox.mjs +48 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/env-config.mjs +9 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +12 -0
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +18 -0
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +222 -55
- package/lib/intake/filesystem-queue.mjs +25 -5
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -0
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/mcp/server.mjs +9 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/migrations/index.mjs +1 -1
- package/lib/model-registry.mjs +1 -1
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +7 -1
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +13 -3
- package/personas/construct.md +2 -2
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/no-fabrication.md +1 -1
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +130 -58
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/{agents → specialists}/contracts.schema.json +1 -1
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-architect.md +1 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-debugger.md +1 -1
- package/{agents → specialists}/prompts/cx-designer.md +1 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
- package/{agents → specialists}/prompts/cx-engineer.md +2 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
- package/{agents → specialists}/prompts/cx-explorer.md +1 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
- package/{agents → specialists}/prompts/cx-operations.md +1 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-qa.md +1 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-researcher.md +1 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
- package/{agents → specialists}/prompts/cx-security.md +1 -1
- package/{agents → specialists}/prompts/cx-sre.md +2 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
- package/{agents → specialists}/registry.json +651 -423
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- /package/{agents → specialists}/contracts.json +0 -0
- /package/{agents → specialists}/teams.json +0 -0
package/lib/document-extract.mjs
CHANGED
|
@@ -5,10 +5,18 @@
|
|
|
5
5
|
* Office zip containers via unzip, and PDFs via pdftotext or macOS-native fallbacks.
|
|
6
6
|
* Also provides extractDocumentMetadata() for structured metadata (title, authors,
|
|
7
7
|
* dates, links) from source document frontmatter and content.
|
|
8
|
+
*
|
|
9
|
+
* All paths now return { text, structured, droppedInfo, method, ... } where
|
|
10
|
+
* droppedInfo is an array of { kind, count, reason, recoverable } objects
|
|
11
|
+
* describing information that was silently dropped during extraction.
|
|
12
|
+
* Audio/video files throw a typed { code: 'ASR_REQUIRED' } error.
|
|
8
13
|
*/
|
|
9
14
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
15
|
import { basename, extname, resolve } from 'node:path';
|
|
11
16
|
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { makeDropInfo } from './extractors/shared/drop-info.mjs';
|
|
18
|
+
import { extractTranscript } from './extractors/transcript.mjs';
|
|
19
|
+
import { extractCalendar } from './extractors/calendar.mjs';
|
|
12
20
|
|
|
13
21
|
// 50 MB hard cap mirrors lib/embed/inbox.mjs and lib/observation-store.mjs.
|
|
14
22
|
// Anything larger is intentionally not extracted to keep the daemon path
|
|
@@ -20,15 +28,27 @@ export const UTF8_TEXT_EXTS = new Set([
|
|
|
20
28
|
'.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.sh', '.bash',
|
|
21
29
|
'.html', '.css', '.csv', '.tsv', '.xml', '.env', '.env.example', '.conf', '.ini', '.tf', '.hcl',
|
|
22
30
|
'.sql', '.log',
|
|
23
|
-
'.vtt', '.srt', '.lrc', '.transcript',
|
|
24
31
|
]);
|
|
25
32
|
|
|
33
|
+
// Transcript formats — routed to lib/extractors/transcript.mjs for structured extraction.
|
|
34
|
+
export const TRANSCRIPT_EXTS = new Set(['.vtt', '.srt', '.lrc', '.transcript']);
|
|
35
|
+
|
|
36
|
+
// Calendar formats — routed to lib/extractors/calendar.mjs.
|
|
37
|
+
export const CALENDAR_EXTS = new Set(['.ics']);
|
|
38
|
+
|
|
39
|
+
// Audio/video formats — inbox-compatible but require ASR; extractDocumentText
|
|
40
|
+
// throws { code: 'ASR_REQUIRED' } so callers can route to needs-asr/ queue.
|
|
41
|
+
export const AUDIO_VIDEO_EXTS = new Set(['.mp3', '.wav', '.m4a', '.mp4', '.mov', '.avi', '.mkv', '.flac', '.ogg', '.webm', '.m4v']);
|
|
42
|
+
|
|
26
43
|
export const ZIP_DOCUMENT_EXTS = new Set(['.docx', '.xlsx', '.pptx', '.odt', '.ods']);
|
|
27
44
|
export const RICH_TEXT_EXTS = new Set(['.doc', '.rtf']);
|
|
28
45
|
export const MDLS_DOCUMENT_EXTS = new Set(['.xls', '.ppt', '.pages', '.numbers', '.key']);
|
|
29
|
-
export const EMAIL_DOCUMENT_EXTS = new Set(['.eml']);
|
|
46
|
+
export const EMAIL_DOCUMENT_EXTS = new Set(['.eml', '.msg']);
|
|
30
47
|
export const EXTRACTABLE_DOCUMENT_EXTS = new Set([
|
|
31
48
|
...UTF8_TEXT_EXTS,
|
|
49
|
+
...TRANSCRIPT_EXTS,
|
|
50
|
+
...CALENDAR_EXTS,
|
|
51
|
+
...AUDIO_VIDEO_EXTS,
|
|
32
52
|
...ZIP_DOCUMENT_EXTS,
|
|
33
53
|
...RICH_TEXT_EXTS,
|
|
34
54
|
...MDLS_DOCUMENT_EXTS,
|
|
@@ -36,6 +56,10 @@ export const EXTRACTABLE_DOCUMENT_EXTS = new Set([
|
|
|
36
56
|
'.pdf',
|
|
37
57
|
]);
|
|
38
58
|
|
|
59
|
+
// Minimum extracted characters per page that distinguishes text-bearing PDFs
|
|
60
|
+
// from scanned images. Below this threshold droppedInfo is emitted.
|
|
61
|
+
const PDF_MIN_TEXT_THRESHOLD = 50;
|
|
62
|
+
|
|
39
63
|
function normalizeText(value) {
|
|
40
64
|
return String(value)
|
|
41
65
|
.replace(/\r\n/g, '\n')
|
|
@@ -88,7 +112,7 @@ function extractUtf8(filePath, extension) {
|
|
|
88
112
|
const text = extension === '.html' || extension === '.xml'
|
|
89
113
|
? stripMarkup(raw)
|
|
90
114
|
: normalizeText(raw);
|
|
91
|
-
return { text, method: 'utf8' };
|
|
115
|
+
return { text, method: 'utf8', droppedInfo: [], structured: null };
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
function zipEntries(filePath) {
|
|
@@ -102,13 +126,82 @@ function unzipEntry(filePath, entryPath) {
|
|
|
102
126
|
function extractZipDocument(filePath, extension) {
|
|
103
127
|
const entries = zipEntries(filePath);
|
|
104
128
|
let targets = [];
|
|
129
|
+
const droppedInfo = [];
|
|
105
130
|
|
|
106
131
|
if (extension === '.docx') {
|
|
107
132
|
targets = entries.filter((entry) => entry === 'word/document.xml' || entry.startsWith('word/header') || entry.startsWith('word/footer'));
|
|
133
|
+
|
|
134
|
+
// Count embedded images (word/media/ folder entries)
|
|
135
|
+
const imageCount = entries.filter((e) => e.startsWith('word/media/')).length;
|
|
136
|
+
if (imageCount > 0) {
|
|
137
|
+
droppedInfo.push(makeDropInfo({ kind: 'inline-image', count: imageCount, reason: 'Embedded images not extracted from DOCX', recoverable: true }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Count tables in the main document XML
|
|
141
|
+
try {
|
|
142
|
+
const docXml = unzipEntry(filePath, 'word/document.xml');
|
|
143
|
+
const tableCount = (docXml.match(/<w:tbl[\s>]/g) || []).length;
|
|
144
|
+
if (tableCount > 0) {
|
|
145
|
+
droppedInfo.push(makeDropInfo({ kind: 'table', count: tableCount, reason: 'Table structure flattened; cell boundaries and column headers not preserved', recoverable: true }));
|
|
146
|
+
}
|
|
147
|
+
} catch { /* ignore — main extraction still proceeds */ }
|
|
148
|
+
|
|
149
|
+
// Count comments
|
|
150
|
+
if (entries.includes('word/comments.xml')) {
|
|
151
|
+
try {
|
|
152
|
+
const commentsXml = unzipEntry(filePath, 'word/comments.xml');
|
|
153
|
+
const commentCount = (commentsXml.match(/<w:comment\s/g) || []).length;
|
|
154
|
+
if (commentCount > 0) {
|
|
155
|
+
droppedInfo.push(makeDropInfo({ kind: 'comment', count: commentCount, reason: 'Document review comments not included in extracted text', recoverable: true }));
|
|
156
|
+
}
|
|
157
|
+
} catch { /* ignore */ }
|
|
158
|
+
}
|
|
108
159
|
} else if (extension === '.xlsx') {
|
|
109
160
|
targets = entries.filter((entry) => entry === 'xl/sharedStrings.xml' || /^xl\/worksheets\/sheet\d+\.xml$/.test(entry));
|
|
161
|
+
|
|
162
|
+
// Count formula cells across all worksheets
|
|
163
|
+
let formulaCount = 0;
|
|
164
|
+
for (const sheetEntry of entries.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e))) {
|
|
165
|
+
try {
|
|
166
|
+
const sheetXml = unzipEntry(filePath, sheetEntry);
|
|
167
|
+
formulaCount += (sheetXml.match(/<f\b/g) || []).length;
|
|
168
|
+
} catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
if (formulaCount > 0) {
|
|
171
|
+
droppedInfo.push(makeDropInfo({ kind: 'formula', count: formulaCount, reason: 'Spreadsheet formulas not evaluated; only cell string values extracted', recoverable: true }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Count embedded images
|
|
175
|
+
const imageCount = entries.filter((e) => e.startsWith('xl/media/')).length;
|
|
176
|
+
if (imageCount > 0) {
|
|
177
|
+
droppedInfo.push(makeDropInfo({ kind: 'inline-image', count: imageCount, reason: 'Embedded images not extracted from XLSX', recoverable: true }));
|
|
178
|
+
}
|
|
110
179
|
} else if (extension === '.pptx') {
|
|
111
180
|
targets = entries.filter((entry) => /^ppt\/slides\/slide\d+\.xml$/.test(entry));
|
|
181
|
+
|
|
182
|
+
// Count speaker notes slides
|
|
183
|
+
const notesCount = entries.filter((e) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(e)).length;
|
|
184
|
+
if (notesCount > 0) {
|
|
185
|
+
droppedInfo.push(makeDropInfo({ kind: 'speaker-notes', count: notesCount, reason: 'Speaker notes slides not included in extracted text', recoverable: true }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Count slides with animation sequences
|
|
189
|
+
let animationCount = 0;
|
|
190
|
+
for (const slideEntry of targets) {
|
|
191
|
+
try {
|
|
192
|
+
const slideXml = unzipEntry(filePath, slideEntry);
|
|
193
|
+
if (/<p:timing\b/.test(slideXml)) animationCount++;
|
|
194
|
+
} catch { /* ignore */ }
|
|
195
|
+
}
|
|
196
|
+
if (animationCount > 0) {
|
|
197
|
+
droppedInfo.push(makeDropInfo({ kind: 'animation', count: animationCount, reason: 'Slide animation timing sequences not preserved in extracted text', recoverable: false }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Count embedded images
|
|
201
|
+
const imageCount = entries.filter((e) => e.startsWith('ppt/media/')).length;
|
|
202
|
+
if (imageCount > 0) {
|
|
203
|
+
droppedInfo.push(makeDropInfo({ kind: 'inline-image', count: imageCount, reason: 'Embedded images not extracted from PPTX', recoverable: true }));
|
|
204
|
+
}
|
|
112
205
|
} else if (extension === '.odt' || extension === '.ods') {
|
|
113
206
|
targets = entries.filter((entry) => entry === 'content.xml');
|
|
114
207
|
}
|
|
@@ -116,7 +209,7 @@ function extractZipDocument(filePath, extension) {
|
|
|
116
209
|
if (targets.length === 0) throw new Error(`No readable document entries found for ${extension}`);
|
|
117
210
|
|
|
118
211
|
const text = normalizeText(targets.map((entry) => stripMarkup(unzipEntry(filePath, entry))).join('\n\n'));
|
|
119
|
-
return { text, method: 'zip-xml' };
|
|
212
|
+
return { text, method: 'zip-xml', droppedInfo, structured: null };
|
|
120
213
|
}
|
|
121
214
|
|
|
122
215
|
function extractRichText(filePath) {
|
|
@@ -126,6 +219,8 @@ function extractRichText(filePath) {
|
|
|
126
219
|
return {
|
|
127
220
|
text: normalizeText(run('textutil', ['-convert', 'txt', '-stdout', filePath])),
|
|
128
221
|
method: 'textutil',
|
|
222
|
+
droppedInfo: [],
|
|
223
|
+
structured: null,
|
|
129
224
|
};
|
|
130
225
|
}
|
|
131
226
|
|
|
@@ -133,6 +228,8 @@ function extractPdfWithPdftotext(filePath) {
|
|
|
133
228
|
return {
|
|
134
229
|
text: normalizeText(run('pdftotext', ['-layout', '-nopgbrk', filePath, '-'])),
|
|
135
230
|
method: 'pdftotext',
|
|
231
|
+
droppedInfo: [],
|
|
232
|
+
structured: null,
|
|
136
233
|
};
|
|
137
234
|
}
|
|
138
235
|
|
|
@@ -152,12 +249,14 @@ print(document.string ?? "")
|
|
|
152
249
|
return {
|
|
153
250
|
text: normalizeText(run('swift', ['-e', script, filePath])),
|
|
154
251
|
method: 'swift-pdfkit',
|
|
252
|
+
droppedInfo: [],
|
|
253
|
+
structured: null,
|
|
155
254
|
};
|
|
156
255
|
}
|
|
157
256
|
|
|
158
257
|
function extractPdfWithMdls(filePath) {
|
|
159
258
|
const text = extractWithMdls(filePath);
|
|
160
|
-
return { text, method: 'mdls' };
|
|
259
|
+
return { text, method: 'mdls', droppedInfo: [], structured: null };
|
|
161
260
|
}
|
|
162
261
|
|
|
163
262
|
function extractWithMdls(filePath) {
|
|
@@ -167,16 +266,40 @@ function extractWithMdls(filePath) {
|
|
|
167
266
|
}
|
|
168
267
|
|
|
169
268
|
function extractPdf(filePath) {
|
|
170
|
-
|
|
171
|
-
if (
|
|
269
|
+
let extracted;
|
|
270
|
+
if (commandExists('pdftotext')) {
|
|
271
|
+
extracted = extractPdfWithPdftotext(filePath);
|
|
272
|
+
} else if (process.platform === 'darwin' && commandExists('swift')) {
|
|
172
273
|
try {
|
|
173
|
-
|
|
274
|
+
extracted = extractPdfWithSwift(filePath);
|
|
174
275
|
} catch {
|
|
175
|
-
|
|
276
|
+
extracted = extractPdfWithMdls(filePath);
|
|
176
277
|
}
|
|
278
|
+
} else if (process.platform === 'darwin' && commandExists('mdls')) {
|
|
279
|
+
extracted = extractPdfWithMdls(filePath);
|
|
280
|
+
} else {
|
|
281
|
+
throw new Error('No PDF extraction backend available');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Scanned-PDF heuristic: if extracted text is very short relative to file size,
|
|
285
|
+
// the PDF probably contains only images and needs OCR to be useful.
|
|
286
|
+
const stat = statSync(filePath);
|
|
287
|
+
const pages = Math.max(1, Math.ceil(stat.size / 50_000));
|
|
288
|
+
const threshold = PDF_MIN_TEXT_THRESHOLD * pages;
|
|
289
|
+
if (extracted.text.length < threshold) {
|
|
290
|
+
const ocrHint = process.env.CONSTRUCT_PDF_OCR_HINT || 'none';
|
|
291
|
+
const hintMsg = ocrHint !== 'none'
|
|
292
|
+
? `OCR backend hint: ${ocrHint}. See docs/intake/scanned-pdfs.md.`
|
|
293
|
+
: 'Set CONSTRUCT_PDF_OCR_HINT=tesseract|pdfocr to enable OCR. See docs/intake/scanned-pdfs.md.';
|
|
294
|
+
extracted.droppedInfo.push(makeDropInfo({
|
|
295
|
+
kind: 'scanned-pdf',
|
|
296
|
+
count: pages,
|
|
297
|
+
reason: `Extracted text (${extracted.text.length} chars) is below minimum threshold for ${pages} page(s); document may be a scanned image. ${hintMsg}`,
|
|
298
|
+
recoverable: true,
|
|
299
|
+
}));
|
|
177
300
|
}
|
|
178
|
-
|
|
179
|
-
|
|
301
|
+
|
|
302
|
+
return extracted;
|
|
180
303
|
}
|
|
181
304
|
|
|
182
305
|
export function isExtractableDocumentPath(filePath) {
|
|
@@ -263,16 +386,16 @@ function decodeTransferEncoded(text, encoding) {
|
|
|
263
386
|
return text;
|
|
264
387
|
}
|
|
265
388
|
|
|
266
|
-
// Walk a parsed MIME entity, returning the first text/plain body it finds
|
|
267
|
-
//
|
|
268
|
-
// walker stops at depth 8 so a hand-crafted pathological
|
|
269
|
-
// exhaust the stack.
|
|
389
|
+
// Walk a parsed MIME entity, returning the first text/plain body it finds,
|
|
390
|
+
// a flat list of attachment filenames, and counts of dropped HTML parts and
|
|
391
|
+
// inline images. The walker stops at depth 8 so a hand-crafted pathological
|
|
392
|
+
// message cannot exhaust the stack.
|
|
270
393
|
function walkMimeForPlainText(rawEntity, depth = 0) {
|
|
271
|
-
if (depth > 8) return { text: '', attachments: [] };
|
|
394
|
+
if (depth > 8) return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 0 };
|
|
272
395
|
const sepIdx = rawEntity.indexOf('\n\n') !== -1
|
|
273
396
|
? rawEntity.indexOf('\n\n')
|
|
274
397
|
: rawEntity.indexOf('\r\n\r\n');
|
|
275
|
-
if (sepIdx === -1) return { text: '', attachments: [] };
|
|
398
|
+
if (sepIdx === -1) return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 0 };
|
|
276
399
|
const headerBlock = rawEntity.slice(0, sepIdx);
|
|
277
400
|
const body = rawEntity.slice(sepIdx).replace(/^\r?\n\r?\n/, '');
|
|
278
401
|
const headers = parseRfc5322Headers(headerBlock);
|
|
@@ -283,28 +406,44 @@ function walkMimeForPlainText(rawEntity, depth = 0) {
|
|
|
283
406
|
if (type.startsWith('multipart/')) {
|
|
284
407
|
const attachments = [];
|
|
285
408
|
let firstPlain = '';
|
|
409
|
+
let droppedHtmlParts = 0;
|
|
410
|
+
let droppedImages = 0;
|
|
286
411
|
for (const seg of splitMultipart(body, params.boundary)) {
|
|
287
412
|
const child = walkMimeForPlainText(seg, depth + 1);
|
|
288
413
|
attachments.push(...child.attachments);
|
|
414
|
+
droppedHtmlParts += child.droppedHtmlParts;
|
|
415
|
+
droppedImages += child.droppedImages;
|
|
289
416
|
if (!firstPlain && child.text) firstPlain = child.text;
|
|
290
417
|
}
|
|
291
|
-
return { text: firstPlain, attachments };
|
|
418
|
+
return { text: firstPlain, attachments, droppedHtmlParts, droppedImages };
|
|
292
419
|
}
|
|
293
420
|
|
|
294
421
|
if (disposition.type === 'attachment' && filename) {
|
|
295
|
-
return { text: '', attachments: [filename] };
|
|
422
|
+
return { text: '', attachments: [filename], droppedHtmlParts: 0, droppedImages: 0 };
|
|
296
423
|
}
|
|
297
424
|
|
|
298
425
|
if (type === '' || type === 'text/plain') {
|
|
299
426
|
return {
|
|
300
427
|
text: decodeTransferEncoded(body, headers['content-transfer-encoding']),
|
|
301
428
|
attachments: [],
|
|
429
|
+
droppedHtmlParts: 0,
|
|
430
|
+
droppedImages: 0,
|
|
302
431
|
};
|
|
303
432
|
}
|
|
304
433
|
|
|
434
|
+
// HTML body part that would be useful if plain/text was absent
|
|
435
|
+
if (type === 'text/html') {
|
|
436
|
+
return { text: '', attachments: [], droppedHtmlParts: 1, droppedImages: 0 };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Inline images (image/* with no attachment disposition)
|
|
440
|
+
if (type.startsWith('image/') && disposition.type !== 'attachment') {
|
|
441
|
+
return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 1 };
|
|
442
|
+
}
|
|
443
|
+
|
|
305
444
|
// Non-plain leaves with an explicit filename are treated as attachments.
|
|
306
|
-
if (filename) return { text: '', attachments: [filename] };
|
|
307
|
-
return { text: '', attachments: [] };
|
|
445
|
+
if (filename) return { text: '', attachments: [filename], droppedHtmlParts: 0, droppedImages: 0 };
|
|
446
|
+
return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 0 };
|
|
308
447
|
}
|
|
309
448
|
|
|
310
449
|
/**
|
|
@@ -346,14 +485,19 @@ export function extractEmlMessage(filePath) {
|
|
|
346
485
|
|
|
347
486
|
let bodyText = '';
|
|
348
487
|
const attachments = [];
|
|
488
|
+
let droppedHtmlParts = 0;
|
|
489
|
+
let droppedImages = 0;
|
|
349
490
|
if (type.startsWith('multipart/')) {
|
|
350
491
|
for (const seg of splitMultipart(body, params.boundary)) {
|
|
351
492
|
const child = walkMimeForPlainText(seg);
|
|
352
493
|
attachments.push(...child.attachments);
|
|
494
|
+
droppedHtmlParts += child.droppedHtmlParts;
|
|
495
|
+
droppedImages += child.droppedImages;
|
|
353
496
|
if (!bodyText && child.text) bodyText = child.text;
|
|
354
497
|
}
|
|
355
498
|
} else {
|
|
356
499
|
bodyText = decodeTransferEncoded(body, headers['content-transfer-encoding']);
|
|
500
|
+
if (type === 'text/html') droppedHtmlParts++;
|
|
357
501
|
}
|
|
358
502
|
|
|
359
503
|
const subject = headers.subject || '';
|
|
@@ -367,16 +511,37 @@ export function extractEmlMessage(filePath) {
|
|
|
367
511
|
text,
|
|
368
512
|
headers: { subject, from, date, to },
|
|
369
513
|
attachments,
|
|
514
|
+
droppedHtmlParts,
|
|
515
|
+
droppedImages,
|
|
516
|
+
structured: {
|
|
517
|
+
subject,
|
|
518
|
+
from,
|
|
519
|
+
date,
|
|
520
|
+
to,
|
|
521
|
+
attachments: attachments.map((name) => ({ filename: name })),
|
|
522
|
+
},
|
|
370
523
|
};
|
|
371
524
|
}
|
|
372
525
|
|
|
373
526
|
function extractEml(filePath) {
|
|
374
527
|
const message = extractEmlMessage(filePath);
|
|
528
|
+
const droppedInfo = [];
|
|
529
|
+
if ((message.droppedHtmlParts || 0) > 0) {
|
|
530
|
+
droppedInfo.push(makeDropInfo({ kind: 'html-part', count: message.droppedHtmlParts, reason: 'HTML email body parts not included (plain-text preferred)', recoverable: true }));
|
|
531
|
+
}
|
|
532
|
+
if ((message.droppedImages || 0) > 0) {
|
|
533
|
+
droppedInfo.push(makeDropInfo({ kind: 'inline-image', count: message.droppedImages, reason: 'Inline images from multipart/related not extracted', recoverable: true }));
|
|
534
|
+
}
|
|
535
|
+
if ((message.attachments || []).length > 0) {
|
|
536
|
+
droppedInfo.push(makeDropInfo({ kind: 'attachment', count: message.attachments.length, reason: `Attachment bodies not extracted (filenames preserved): ${message.attachments.join(', ')}`, recoverable: true }));
|
|
537
|
+
}
|
|
375
538
|
return {
|
|
376
539
|
text: message.text,
|
|
377
540
|
method: message.skipped ? 'eml-skipped' : 'eml',
|
|
378
541
|
skipped: message.skipped || null,
|
|
379
542
|
attachments: message.attachments,
|
|
543
|
+
droppedInfo,
|
|
544
|
+
structured: message.structured || null,
|
|
380
545
|
};
|
|
381
546
|
}
|
|
382
547
|
|
|
@@ -467,14 +632,40 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
467
632
|
throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
|
|
468
633
|
}
|
|
469
634
|
|
|
635
|
+
// Audio and video files cannot be text-extracted without ASR.
|
|
636
|
+
// Throw a typed error so callers can route to .cx/intake/needs-asr/.
|
|
637
|
+
if (AUDIO_VIDEO_EXTS.has(extension)) {
|
|
638
|
+
const err = new Error(
|
|
639
|
+
`Audio/video extraction requires ASR. Set CONSTRUCT_ASR_BACKEND=whisper|assemblyai to enable. See docs/intake/audio-video.md.`,
|
|
640
|
+
);
|
|
641
|
+
err.code = 'ASR_REQUIRED';
|
|
642
|
+
err.extension = extension;
|
|
643
|
+
throw err;
|
|
644
|
+
}
|
|
645
|
+
|
|
470
646
|
let extracted;
|
|
471
|
-
if (UTF8_TEXT_EXTS.has(extension))
|
|
472
|
-
|
|
473
|
-
else if (
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
else
|
|
647
|
+
if (UTF8_TEXT_EXTS.has(extension)) {
|
|
648
|
+
extracted = extractUtf8(resolvedPath, extension);
|
|
649
|
+
} else if (TRANSCRIPT_EXTS.has(extension)) {
|
|
650
|
+
// Transcript formats return the full { text, structured, droppedInfo } envelope.
|
|
651
|
+
const envelope = extractTranscript(resolvedPath);
|
|
652
|
+
extracted = { text: envelope.text, method: 'transcript', droppedInfo: envelope.droppedInfo, structured: envelope.structured };
|
|
653
|
+
} else if (CALENDAR_EXTS.has(extension)) {
|
|
654
|
+
const envelope = extractCalendar(resolvedPath);
|
|
655
|
+
extracted = { text: envelope.text, method: 'ics', droppedInfo: envelope.droppedInfo, structured: envelope.structured };
|
|
656
|
+
} else if (ZIP_DOCUMENT_EXTS.has(extension)) {
|
|
657
|
+
extracted = extractZipDocument(resolvedPath, extension);
|
|
658
|
+
} else if (RICH_TEXT_EXTS.has(extension)) {
|
|
659
|
+
extracted = extractRichText(resolvedPath);
|
|
660
|
+
} else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) {
|
|
661
|
+
extracted = { text: extractWithMdls(resolvedPath), method: 'mdls', droppedInfo: [], structured: null };
|
|
662
|
+
} else if (EMAIL_DOCUMENT_EXTS.has(extension)) {
|
|
663
|
+
extracted = extractEml(resolvedPath);
|
|
664
|
+
} else if (extension === '.pdf') {
|
|
665
|
+
extracted = extractPdf(resolvedPath);
|
|
666
|
+
} else {
|
|
667
|
+
throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
|
|
668
|
+
}
|
|
478
669
|
|
|
479
670
|
const text = extracted.text;
|
|
480
671
|
const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
|
|
@@ -489,6 +680,8 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
489
680
|
text: truncated ? `${text.slice(0, limit)}\n` : text,
|
|
490
681
|
truncated,
|
|
491
682
|
characters: text.length,
|
|
683
|
+
droppedInfo: extracted.droppedInfo ?? [],
|
|
684
|
+
structured: extracted.structured ?? null,
|
|
492
685
|
};
|
|
493
686
|
// Surface eml-specific metadata so callers (intake, ingest) can route
|
|
494
687
|
// attachment filenames and oversize-skip markers without re-parsing.
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -170,6 +170,8 @@ export async function ingestDocuments(inputPaths, {
|
|
|
170
170
|
extractionMethod: extracted.extractionMethod,
|
|
171
171
|
truncated: extracted.truncated,
|
|
172
172
|
characters: extracted.characters,
|
|
173
|
+
droppedInfo: extracted.droppedInfo ?? [],
|
|
174
|
+
structured: extracted.structured ?? null,
|
|
173
175
|
metadata: { title, authors: metadata?.authors || [], dates: metadata?.dates || {} },
|
|
174
176
|
});
|
|
175
177
|
}
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* // result: { processed: [...], skipped: number, errors: [...] }
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
25
25
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
26
|
-
import {
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { isExtractableDocumentPath, AUDIO_VIDEO_EXTS } from '../document-extract.mjs';
|
|
27
28
|
import { ingestDocuments } from '../document-ingest.mjs';
|
|
28
29
|
import { addObservation } from '../observation-store.mjs';
|
|
29
30
|
import { inferKnowledgeTarget, knowledgeDirForCategory } from '../knowledge/layout.mjs';
|
|
@@ -39,6 +40,37 @@ const DOCS_INTAKE_SUBDIR = 'docs/intake';
|
|
|
39
40
|
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
|
|
40
41
|
const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
41
42
|
|
|
43
|
+
// ─── Extraction-warning helpers ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function extractionWarningsLog() {
|
|
46
|
+
return join(homedir(), '.cx', 'intake', 'extraction-warnings.jsonl');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function needsAsrDir() {
|
|
50
|
+
return join(homedir(), '.cx', 'intake', 'needs-asr');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeExtractionWarning({ sourcePath, droppedInfo }) {
|
|
54
|
+
if (!droppedInfo?.length) return;
|
|
55
|
+
const logPath = extractionWarningsLog();
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(join(homedir(), '.cx', 'intake'), { recursive: true });
|
|
58
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), sourcePath, droppedInfo }) + '\n';
|
|
59
|
+
appendFileSync(logPath, line);
|
|
60
|
+
} catch { /* non-fatal */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeAsrPacket({ sourcePath, extension, errorMessage }) {
|
|
64
|
+
try {
|
|
65
|
+
const dir = needsAsrDir();
|
|
66
|
+
mkdirSync(dir, { recursive: true });
|
|
67
|
+
const id = `asr-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
68
|
+
const packet = { id, ts: new Date().toISOString(), sourcePath, extension, error: errorMessage };
|
|
69
|
+
writeFileSync(join(dir, `${id}.json`), JSON.stringify(packet, null, 2));
|
|
70
|
+
return id;
|
|
71
|
+
} catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
42
74
|
function nextAvailablePath(targetPath) {
|
|
43
75
|
if (!existsSync(targetPath)) return targetPath;
|
|
44
76
|
const dot = targetPath.lastIndexOf('.');
|
|
@@ -306,6 +338,10 @@ export class InboxWatcher {
|
|
|
306
338
|
if (contentIdx !== -1) extractedText = md.slice(contentIdx + 20).trim();
|
|
307
339
|
} catch (err) { process.stderr.write('[inbox.mjs] read-extracted-text: ' + (err?.message ?? String(err)) + '\n'); }
|
|
308
340
|
|
|
341
|
+
// Write extraction warnings JSONL for any dropped structured content.
|
|
342
|
+
const droppedInfo = fileResult.droppedInfo ?? [];
|
|
343
|
+
writeExtractionWarning({ sourcePath: candidate.path, droppedInfo });
|
|
344
|
+
|
|
309
345
|
recordInboxObservation(this.#rootDir, {
|
|
310
346
|
sourcePath: candidate.path,
|
|
311
347
|
outputPath: fileResult.outputPath,
|
|
@@ -333,6 +369,7 @@ export class InboxWatcher {
|
|
|
333
369
|
outputPath: fileResult.outputPath,
|
|
334
370
|
characters: fileResult.characters,
|
|
335
371
|
knowledgeSubdir,
|
|
372
|
+
droppedInfo,
|
|
336
373
|
},
|
|
337
374
|
env: this.#env,
|
|
338
375
|
});
|
|
@@ -347,7 +384,15 @@ export class InboxWatcher {
|
|
|
347
384
|
processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId });
|
|
348
385
|
}
|
|
349
386
|
} catch (err) {
|
|
350
|
-
|
|
387
|
+
// Audio/video files cannot be text-extracted without ASR. Route to
|
|
388
|
+
// the needs-asr queue so the signal is preserved for later processing.
|
|
389
|
+
if (err.code === 'ASR_REQUIRED') {
|
|
390
|
+
const asrId = writeAsrPacket({ sourcePath: candidate.path, extension: err.extension, errorMessage: err.message });
|
|
391
|
+
state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), needsAsr: true, asrId };
|
|
392
|
+
processed.push({ path: candidate.path, needsAsr: true, asrId });
|
|
393
|
+
} else {
|
|
394
|
+
errors.push({ path: candidate.path, error: err.message });
|
|
395
|
+
}
|
|
351
396
|
}
|
|
352
397
|
}
|
|
353
398
|
}
|
|
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const REGISTRY_PATH = join(__dirname, '..', '..', '
|
|
15
|
+
const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
|
|
16
16
|
|
|
17
17
|
let _registryCache = null;
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ function loadRegistry() {
|
|
|
22
22
|
_registryCache = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
|
|
23
23
|
} catch (err) {
|
|
24
24
|
process.stderr.write('[role-framing.mjs] loadRegistry: ' + (err?.message ?? String(err)) + '\n');
|
|
25
|
-
_registryCache = {
|
|
25
|
+
_registryCache = { specialists: [] };
|
|
26
26
|
}
|
|
27
27
|
return _registryCache;
|
|
28
28
|
}
|
|
@@ -35,7 +35,7 @@ function loadRegistry() {
|
|
|
35
35
|
export function getOrientation(roleName) {
|
|
36
36
|
if (!roleName) return null;
|
|
37
37
|
const registry = loadRegistry();
|
|
38
|
-
const agent = (registry.
|
|
38
|
+
const agent = (registry.specialists ?? []).find((a) => a.name === roleName);
|
|
39
39
|
return agent?.embedOrientation ?? null;
|
|
40
40
|
}
|
|
41
41
|
|
package/lib/env-config.mjs
CHANGED
|
@@ -97,11 +97,17 @@ export function loadConstructEnv({ rootDir, homeDir = os.homedir(), env = proces
|
|
|
97
97
|
for (const key of Object.keys(env)) {
|
|
98
98
|
if (ENV_SHADOW_IGNORE.has(key)) continue;
|
|
99
99
|
if (!ENV_SHADOW_WARN.has(key)) continue;
|
|
100
|
+
// Only warn when process.env has a non-empty competing value.
|
|
101
|
+
// An empty-string env var (e.g. Claude for Desktop clears ANTHROPIC_API_KEY
|
|
102
|
+
// so its internal auth is used instead) is not a real conflict — config.env wins
|
|
103
|
+
// silently and no user action is needed.
|
|
104
|
+
if (!env[key]) continue;
|
|
100
105
|
if (key in fileEnv && fileEnv[key] !== env[key]) {
|
|
101
106
|
process.stderr.write(
|
|
102
|
-
`[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…)
|
|
103
|
-
`config.env value (${fileEnv[key].slice(0, 6)}…). ` +
|
|
104
|
-
`
|
|
107
|
+
`[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…) ` +
|
|
108
|
+
`differs from config.env value (${fileEnv[key].slice(0, 6)}…). ` +
|
|
109
|
+
`config.env will be used. To silence: unset the shell variable or ` +
|
|
110
|
+
`update ~/.construct/config.env to match.\n`,
|
|
105
111
|
);
|
|
106
112
|
}
|
|
107
113
|
}
|