@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.
Files changed (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -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
- if (commandExists('pdftotext')) return extractPdfWithPdftotext(filePath);
164
- if (process.platform === 'darwin' && commandExists('swift')) {
269
+ let extracted;
270
+ if (commandExists('pdftotext')) {
271
+ extracted = extractPdfWithPdftotext(filePath);
272
+ } else if (process.platform === 'darwin' && commandExists('swift')) {
165
273
  try {
166
- return extractPdfWithSwift(filePath);
274
+ extracted = extractPdfWithSwift(filePath);
167
275
  } catch {
168
- return extractPdfWithMdls(filePath);
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
- if (process.platform === 'darwin' && commandExists('mdls')) return extractPdfWithMdls(filePath);
172
- throw new Error('No PDF extraction backend available');
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)) extracted = extractUtf8(resolvedPath, extension);
268
- else if (ZIP_DOCUMENT_EXTS.has(extension)) extracted = extractZipDocument(resolvedPath, extension);
269
- else if (RICH_TEXT_EXTS.has(extension)) extracted = extractRichText(resolvedPath);
270
- else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) extracted = { text: extractWithMdls(resolvedPath), method: 'mdls' };
271
- else if (extension === '.pdf') extracted = extractPdf(resolvedPath);
272
- else throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
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
- return {
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
  }
@@ -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
  }