@geraldmaron/construct 1.0.6 → 1.0.8
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 +39 -34
- package/bin/construct +1403 -96
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +2 -2
- package/commands/work/parallel-review.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 +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -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/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- 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 +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- 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/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- 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/reflect.mjs +49 -12
- 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 +29 -29
- 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/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- 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/client.mjs +5 -1
- 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/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
- 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/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- 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/contracts.json +0 -387
- /package/{agents → specialists}/teams.json +0 -0
package/lib/document-extract.mjs
CHANGED
|
@@ -5,30 +5,61 @@
|
|
|
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';
|
|
20
|
+
|
|
21
|
+
// 50 MB hard cap mirrors lib/embed/inbox.mjs and lib/observation-store.mjs.
|
|
22
|
+
// Anything larger is intentionally not extracted to keep the daemon path
|
|
23
|
+
// bounded; callers receive an empty text plus a `skipped` marker.
|
|
24
|
+
const MAX_EML_BYTES = 50 * 1024 * 1024;
|
|
12
25
|
|
|
13
26
|
export const UTF8_TEXT_EXTS = new Set([
|
|
14
27
|
'.md', '.txt', '.rst', '.adoc', '.json', '.yaml', '.yml', '.toml',
|
|
15
28
|
'.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.sh', '.bash',
|
|
16
29
|
'.html', '.css', '.csv', '.tsv', '.xml', '.env', '.env.example', '.conf', '.ini', '.tf', '.hcl',
|
|
17
30
|
'.sql', '.log',
|
|
18
|
-
'.vtt', '.srt', '.lrc', '.transcript',
|
|
19
31
|
]);
|
|
20
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
|
+
|
|
21
43
|
export const ZIP_DOCUMENT_EXTS = new Set(['.docx', '.xlsx', '.pptx', '.odt', '.ods']);
|
|
22
44
|
export const RICH_TEXT_EXTS = new Set(['.doc', '.rtf']);
|
|
23
45
|
export const MDLS_DOCUMENT_EXTS = new Set(['.xls', '.ppt', '.pages', '.numbers', '.key']);
|
|
46
|
+
export const EMAIL_DOCUMENT_EXTS = new Set(['.eml', '.msg']);
|
|
24
47
|
export const EXTRACTABLE_DOCUMENT_EXTS = new Set([
|
|
25
48
|
...UTF8_TEXT_EXTS,
|
|
49
|
+
...TRANSCRIPT_EXTS,
|
|
50
|
+
...CALENDAR_EXTS,
|
|
51
|
+
...AUDIO_VIDEO_EXTS,
|
|
26
52
|
...ZIP_DOCUMENT_EXTS,
|
|
27
53
|
...RICH_TEXT_EXTS,
|
|
28
54
|
...MDLS_DOCUMENT_EXTS,
|
|
55
|
+
...EMAIL_DOCUMENT_EXTS,
|
|
29
56
|
'.pdf',
|
|
30
57
|
]);
|
|
31
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
|
+
|
|
32
63
|
function normalizeText(value) {
|
|
33
64
|
return String(value)
|
|
34
65
|
.replace(/\r\n/g, '\n')
|
|
@@ -81,7 +112,7 @@ function extractUtf8(filePath, extension) {
|
|
|
81
112
|
const text = extension === '.html' || extension === '.xml'
|
|
82
113
|
? stripMarkup(raw)
|
|
83
114
|
: normalizeText(raw);
|
|
84
|
-
return { text, method: 'utf8' };
|
|
115
|
+
return { text, method: 'utf8', droppedInfo: [], structured: null };
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
function zipEntries(filePath) {
|
|
@@ -95,13 +126,82 @@ function unzipEntry(filePath, entryPath) {
|
|
|
95
126
|
function extractZipDocument(filePath, extension) {
|
|
96
127
|
const entries = zipEntries(filePath);
|
|
97
128
|
let targets = [];
|
|
129
|
+
const droppedInfo = [];
|
|
98
130
|
|
|
99
131
|
if (extension === '.docx') {
|
|
100
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
|
+
}
|
|
101
159
|
} else if (extension === '.xlsx') {
|
|
102
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
|
+
}
|
|
103
179
|
} else if (extension === '.pptx') {
|
|
104
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
|
+
}
|
|
105
205
|
} else if (extension === '.odt' || extension === '.ods') {
|
|
106
206
|
targets = entries.filter((entry) => entry === 'content.xml');
|
|
107
207
|
}
|
|
@@ -109,7 +209,7 @@ function extractZipDocument(filePath, extension) {
|
|
|
109
209
|
if (targets.length === 0) throw new Error(`No readable document entries found for ${extension}`);
|
|
110
210
|
|
|
111
211
|
const text = normalizeText(targets.map((entry) => stripMarkup(unzipEntry(filePath, entry))).join('\n\n'));
|
|
112
|
-
return { text, method: 'zip-xml' };
|
|
212
|
+
return { text, method: 'zip-xml', droppedInfo, structured: null };
|
|
113
213
|
}
|
|
114
214
|
|
|
115
215
|
function extractRichText(filePath) {
|
|
@@ -119,6 +219,8 @@ function extractRichText(filePath) {
|
|
|
119
219
|
return {
|
|
120
220
|
text: normalizeText(run('textutil', ['-convert', 'txt', '-stdout', filePath])),
|
|
121
221
|
method: 'textutil',
|
|
222
|
+
droppedInfo: [],
|
|
223
|
+
structured: null,
|
|
122
224
|
};
|
|
123
225
|
}
|
|
124
226
|
|
|
@@ -126,6 +228,8 @@ function extractPdfWithPdftotext(filePath) {
|
|
|
126
228
|
return {
|
|
127
229
|
text: normalizeText(run('pdftotext', ['-layout', '-nopgbrk', filePath, '-'])),
|
|
128
230
|
method: 'pdftotext',
|
|
231
|
+
droppedInfo: [],
|
|
232
|
+
structured: null,
|
|
129
233
|
};
|
|
130
234
|
}
|
|
131
235
|
|
|
@@ -145,12 +249,14 @@ print(document.string ?? "")
|
|
|
145
249
|
return {
|
|
146
250
|
text: normalizeText(run('swift', ['-e', script, filePath])),
|
|
147
251
|
method: 'swift-pdfkit',
|
|
252
|
+
droppedInfo: [],
|
|
253
|
+
structured: null,
|
|
148
254
|
};
|
|
149
255
|
}
|
|
150
256
|
|
|
151
257
|
function extractPdfWithMdls(filePath) {
|
|
152
258
|
const text = extractWithMdls(filePath);
|
|
153
|
-
return { text, method: 'mdls' };
|
|
259
|
+
return { text, method: 'mdls', droppedInfo: [], structured: null };
|
|
154
260
|
}
|
|
155
261
|
|
|
156
262
|
function extractWithMdls(filePath) {
|
|
@@ -160,22 +266,285 @@ function extractWithMdls(filePath) {
|
|
|
160
266
|
}
|
|
161
267
|
|
|
162
268
|
function extractPdf(filePath) {
|
|
163
|
-
|
|
164
|
-
if (
|
|
269
|
+
let extracted;
|
|
270
|
+
if (commandExists('pdftotext')) {
|
|
271
|
+
extracted = extractPdfWithPdftotext(filePath);
|
|
272
|
+
} else if (process.platform === 'darwin' && commandExists('swift')) {
|
|
165
273
|
try {
|
|
166
|
-
|
|
274
|
+
extracted = extractPdfWithSwift(filePath);
|
|
167
275
|
} catch {
|
|
168
|
-
|
|
276
|
+
extracted = extractPdfWithMdls(filePath);
|
|
169
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
|
+
}));
|
|
170
300
|
}
|
|
171
|
-
|
|
172
|
-
|
|
301
|
+
|
|
302
|
+
return extracted;
|
|
173
303
|
}
|
|
174
304
|
|
|
175
305
|
export function isExtractableDocumentPath(filePath) {
|
|
176
306
|
return EXTRACTABLE_DOCUMENT_EXTS.has(extname(filePath).toLowerCase());
|
|
177
307
|
}
|
|
178
308
|
|
|
309
|
+
// Minimal RFC 5322 header decoder. Unfolds continuation lines (a line that
|
|
310
|
+
// begins with whitespace continues the previous header) and splits each
|
|
311
|
+
// header into (name, value). Stops at the first blank line, which RFC 5322
|
|
312
|
+
// defines as the header/body separator.
|
|
313
|
+
function parseRfc5322Headers(headerBlock) {
|
|
314
|
+
const lines = headerBlock.split('\n');
|
|
315
|
+
const unfolded = [];
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
if (/^[ \t]/.test(line) && unfolded.length > 0) {
|
|
318
|
+
unfolded[unfolded.length - 1] += ' ' + line.trim();
|
|
319
|
+
} else {
|
|
320
|
+
unfolded.push(line);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const headers = {};
|
|
324
|
+
for (const line of unfolded) {
|
|
325
|
+
const idx = line.indexOf(':');
|
|
326
|
+
if (idx === -1) continue;
|
|
327
|
+
const name = line.slice(0, idx).trim().toLowerCase();
|
|
328
|
+
const value = line.slice(idx + 1).trim();
|
|
329
|
+
if (!name) continue;
|
|
330
|
+
// Multiple Received headers are common; keep the first occurrence of
|
|
331
|
+
// any header so From/Subject/Date pick the outermost value.
|
|
332
|
+
if (!(name in headers)) headers[name] = value;
|
|
333
|
+
}
|
|
334
|
+
return headers;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Split a Content-Type header value into the bare type and a parameter map.
|
|
338
|
+
// Returns { type, params } where type is lowercased and stripped, params is
|
|
339
|
+
// keyed by lowercased name with values stripped of surrounding quotes.
|
|
340
|
+
function parseContentType(value) {
|
|
341
|
+
if (!value) return { type: '', params: {} };
|
|
342
|
+
const parts = value.split(';').map((s) => s.trim()).filter(Boolean);
|
|
343
|
+
const type = (parts.shift() || '').toLowerCase();
|
|
344
|
+
const params = {};
|
|
345
|
+
for (const part of parts) {
|
|
346
|
+
const idx = part.indexOf('=');
|
|
347
|
+
if (idx === -1) continue;
|
|
348
|
+
const k = part.slice(0, idx).trim().toLowerCase();
|
|
349
|
+
let v = part.slice(idx + 1).trim();
|
|
350
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
351
|
+
params[k] = v;
|
|
352
|
+
}
|
|
353
|
+
return { type, params };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Split a multipart body on the boundary marker. Each returned segment is
|
|
357
|
+
// itself a mini MIME entity (its own header block, blank line, body). The
|
|
358
|
+
// closing `--boundary--` terminator is honored by stripping anything past
|
|
359
|
+
// it. Boundaries that do not appear in the body produce a single segment.
|
|
360
|
+
function splitMultipart(body, boundary) {
|
|
361
|
+
if (!boundary) return [body];
|
|
362
|
+
const marker = '--' + boundary;
|
|
363
|
+
const closer = marker + '--';
|
|
364
|
+
const closeIdx = body.indexOf(closer);
|
|
365
|
+
const scoped = closeIdx === -1 ? body : body.slice(0, closeIdx);
|
|
366
|
+
const segments = scoped.split(marker);
|
|
367
|
+
// The piece before the first boundary is the preamble; discard it.
|
|
368
|
+
return segments.slice(1).map((seg) => seg.replace(/^\r?\n/, '').replace(/\r?\n$/, ''));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Decode a quoted-printable body. Tolerant: malformed `=XX` sequences are
|
|
372
|
+
// passed through verbatim rather than throwing, which matches real-world
|
|
373
|
+
// mailers more closely than a strict decoder.
|
|
374
|
+
function decodeQuotedPrintable(text) {
|
|
375
|
+
return text
|
|
376
|
+
.replace(/=\r?\n/g, '')
|
|
377
|
+
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function decodeTransferEncoded(text, encoding) {
|
|
381
|
+
const enc = String(encoding || '').toLowerCase();
|
|
382
|
+
if (enc === 'quoted-printable') return decodeQuotedPrintable(text);
|
|
383
|
+
if (enc === 'base64') {
|
|
384
|
+
try { return Buffer.from(text.replace(/\s+/g, ''), 'base64').toString('utf8'); } catch { return text; }
|
|
385
|
+
}
|
|
386
|
+
return text;
|
|
387
|
+
}
|
|
388
|
+
|
|
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.
|
|
393
|
+
function walkMimeForPlainText(rawEntity, depth = 0) {
|
|
394
|
+
if (depth > 8) return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 0 };
|
|
395
|
+
const sepIdx = rawEntity.indexOf('\n\n') !== -1
|
|
396
|
+
? rawEntity.indexOf('\n\n')
|
|
397
|
+
: rawEntity.indexOf('\r\n\r\n');
|
|
398
|
+
if (sepIdx === -1) return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 0 };
|
|
399
|
+
const headerBlock = rawEntity.slice(0, sepIdx);
|
|
400
|
+
const body = rawEntity.slice(sepIdx).replace(/^\r?\n\r?\n/, '');
|
|
401
|
+
const headers = parseRfc5322Headers(headerBlock);
|
|
402
|
+
const { type, params } = parseContentType(headers['content-type'] || 'text/plain');
|
|
403
|
+
const disposition = parseContentType(headers['content-disposition'] || '');
|
|
404
|
+
const filename = disposition.params.filename || params.name || null;
|
|
405
|
+
|
|
406
|
+
if (type.startsWith('multipart/')) {
|
|
407
|
+
const attachments = [];
|
|
408
|
+
let firstPlain = '';
|
|
409
|
+
let droppedHtmlParts = 0;
|
|
410
|
+
let droppedImages = 0;
|
|
411
|
+
for (const seg of splitMultipart(body, params.boundary)) {
|
|
412
|
+
const child = walkMimeForPlainText(seg, depth + 1);
|
|
413
|
+
attachments.push(...child.attachments);
|
|
414
|
+
droppedHtmlParts += child.droppedHtmlParts;
|
|
415
|
+
droppedImages += child.droppedImages;
|
|
416
|
+
if (!firstPlain && child.text) firstPlain = child.text;
|
|
417
|
+
}
|
|
418
|
+
return { text: firstPlain, attachments, droppedHtmlParts, droppedImages };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (disposition.type === 'attachment' && filename) {
|
|
422
|
+
return { text: '', attachments: [filename], droppedHtmlParts: 0, droppedImages: 0 };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (type === '' || type === 'text/plain') {
|
|
426
|
+
return {
|
|
427
|
+
text: decodeTransferEncoded(body, headers['content-transfer-encoding']),
|
|
428
|
+
attachments: [],
|
|
429
|
+
droppedHtmlParts: 0,
|
|
430
|
+
droppedImages: 0,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
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
|
+
|
|
444
|
+
// Non-plain leaves with an explicit filename are treated as attachments.
|
|
445
|
+
if (filename) return { text: '', attachments: [filename], droppedHtmlParts: 0, droppedImages: 0 };
|
|
446
|
+
return { text: '', attachments: [], droppedHtmlParts: 0, droppedImages: 0 };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Extract a normalized representation of an .eml message.
|
|
451
|
+
*
|
|
452
|
+
* Returns { text, headers, attachments, skipped } where:
|
|
453
|
+
* text concatenates Subject, From, Date and the first text/plain
|
|
454
|
+
* body, suitable for direct hand-off to the classifier.
|
|
455
|
+
* headers decoded From, Subject, Date, To (raw strings).
|
|
456
|
+
* attachments filenames of every non-plain part with a Content-Disposition
|
|
457
|
+
* of `attachment` or a filename parameter. Bodies are skipped.
|
|
458
|
+
* skipped set to `'too large'` if the message exceeded MAX_EML_BYTES;
|
|
459
|
+
* in that case text is empty and no parsing was attempted.
|
|
460
|
+
*
|
|
461
|
+
* The parser is intentionally minimal: it understands header folding, the
|
|
462
|
+
* `--boundary` framing of multipart messages, and quoted-printable / base64
|
|
463
|
+
* transfer encodings. It does not decode RFC 2047 encoded-word headers, does
|
|
464
|
+
* not chase nested message/rfc822 envelopes, and does not validate signatures.
|
|
465
|
+
*/
|
|
466
|
+
export function extractEmlMessage(filePath) {
|
|
467
|
+
const stat = statSync(filePath);
|
|
468
|
+
if (stat.size > MAX_EML_BYTES) {
|
|
469
|
+
return { text: '', headers: {}, attachments: [], skipped: 'too large' };
|
|
470
|
+
}
|
|
471
|
+
const raw = readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
472
|
+
const sepIdx = raw.indexOf('\n\n');
|
|
473
|
+
if (sepIdx === -1) {
|
|
474
|
+
// No header/body separator. Treat the whole file as the body.
|
|
475
|
+
return {
|
|
476
|
+
text: normalizeText(raw),
|
|
477
|
+
headers: {},
|
|
478
|
+
attachments: [],
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const headerBlock = raw.slice(0, sepIdx);
|
|
482
|
+
const body = raw.slice(sepIdx + 2);
|
|
483
|
+
const headers = parseRfc5322Headers(headerBlock);
|
|
484
|
+
const { type, params } = parseContentType(headers['content-type'] || 'text/plain');
|
|
485
|
+
|
|
486
|
+
let bodyText = '';
|
|
487
|
+
const attachments = [];
|
|
488
|
+
let droppedHtmlParts = 0;
|
|
489
|
+
let droppedImages = 0;
|
|
490
|
+
if (type.startsWith('multipart/')) {
|
|
491
|
+
for (const seg of splitMultipart(body, params.boundary)) {
|
|
492
|
+
const child = walkMimeForPlainText(seg);
|
|
493
|
+
attachments.push(...child.attachments);
|
|
494
|
+
droppedHtmlParts += child.droppedHtmlParts;
|
|
495
|
+
droppedImages += child.droppedImages;
|
|
496
|
+
if (!bodyText && child.text) bodyText = child.text;
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
bodyText = decodeTransferEncoded(body, headers['content-transfer-encoding']);
|
|
500
|
+
if (type === 'text/html') droppedHtmlParts++;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const subject = headers.subject || '';
|
|
504
|
+
const from = headers.from || '';
|
|
505
|
+
const date = headers.date || '';
|
|
506
|
+
const to = headers.to || '';
|
|
507
|
+
const text = normalizeText(
|
|
508
|
+
`Subject: ${subject}\nFrom: ${from}\nDate: ${date}\n\n${bodyText}`,
|
|
509
|
+
);
|
|
510
|
+
return {
|
|
511
|
+
text,
|
|
512
|
+
headers: { subject, from, date, to },
|
|
513
|
+
attachments,
|
|
514
|
+
droppedHtmlParts,
|
|
515
|
+
droppedImages,
|
|
516
|
+
structured: {
|
|
517
|
+
subject,
|
|
518
|
+
from,
|
|
519
|
+
date,
|
|
520
|
+
to,
|
|
521
|
+
attachments: attachments.map((name) => ({ filename: name })),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function extractEml(filePath) {
|
|
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
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
text: message.text,
|
|
540
|
+
method: message.skipped ? 'eml-skipped' : 'eml',
|
|
541
|
+
skipped: message.skipped || null,
|
|
542
|
+
attachments: message.attachments,
|
|
543
|
+
droppedInfo,
|
|
544
|
+
structured: message.structured || null,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
179
548
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
180
549
|
const URL_RE = /https?:\/\/[^\s)>\]"'`]+/g;
|
|
181
550
|
const DATE_KEYS = ['date', 'created', 'created_at', 'updated', 'updated_at', 'published', 'last_modified'];
|
|
@@ -263,13 +632,40 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
263
632
|
throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
|
|
264
633
|
}
|
|
265
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
|
+
|
|
266
646
|
let extracted;
|
|
267
|
-
if (UTF8_TEXT_EXTS.has(extension))
|
|
268
|
-
|
|
269
|
-
else if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
669
|
|
|
274
670
|
const text = extracted.text;
|
|
275
671
|
const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
|
|
@@ -277,12 +673,19 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
277
673
|
: null;
|
|
278
674
|
const truncated = limit !== null && text.length > limit;
|
|
279
675
|
|
|
280
|
-
|
|
676
|
+
const result = {
|
|
281
677
|
filePath: resolvedPath,
|
|
282
678
|
extension,
|
|
283
679
|
extractionMethod: extracted.method,
|
|
284
680
|
text: truncated ? `${text.slice(0, limit)}\n` : text,
|
|
285
681
|
truncated,
|
|
286
682
|
characters: text.length,
|
|
683
|
+
droppedInfo: extracted.droppedInfo ?? [],
|
|
684
|
+
structured: extracted.structured ?? null,
|
|
287
685
|
};
|
|
686
|
+
// Surface eml-specific metadata so callers (intake, ingest) can route
|
|
687
|
+
// attachment filenames and oversize-skip markers without re-parsing.
|
|
688
|
+
if (extracted.attachments) result.attachments = extracted.attachments;
|
|
689
|
+
if (extracted.skipped) result.skipped = extracted.skipped;
|
|
690
|
+
return result;
|
|
288
691
|
}
|
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
|
}
|