@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.
Files changed (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +130 -58
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /package/{agents → specialists}/teams.json +0 -0
@@ -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
- if (commandExists('pdftotext')) return extractPdfWithPdftotext(filePath);
171
- 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')) {
172
273
  try {
173
- return extractPdfWithSwift(filePath);
274
+ extracted = extractPdfWithSwift(filePath);
174
275
  } catch {
175
- return extractPdfWithMdls(filePath);
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
- if (process.platform === 'darwin' && commandExists('mdls')) return extractPdfWithMdls(filePath);
179
- throw new Error('No PDF extraction backend available');
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
- // and a flat list of attachment filenames encountered along the way. The
268
- // walker stops at depth 8 so a hand-crafted pathological message cannot
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)) extracted = extractUtf8(resolvedPath, extension);
472
- else if (ZIP_DOCUMENT_EXTS.has(extension)) extracted = extractZipDocument(resolvedPath, extension);
473
- else if (RICH_TEXT_EXTS.has(extension)) extracted = extractRichText(resolvedPath);
474
- else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) extracted = { text: extractWithMdls(resolvedPath), method: 'mdls' };
475
- else if (EMAIL_DOCUMENT_EXTS.has(extension)) extracted = extractEml(resolvedPath);
476
- else if (extension === '.pdf') extracted = extractPdf(resolvedPath);
477
- 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
+ }
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.
@@ -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
  }
@@ -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 { isExtractableDocumentPath } from '../document-extract.mjs';
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
- errors.push({ path: candidate.path, error: err.message });
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, '..', '..', 'agents', 'registry.json');
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 = { agents: [] };
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.agents ?? []).find((a) => a.name === roleName);
38
+ const agent = (registry.specialists ?? []).find((a) => a.name === roleName);
39
39
  return agent?.embedOrientation ?? null;
40
40
  }
41
41
 
@@ -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)}…) shadows ` +
103
- `config.env value (${fileEnv[key].slice(0, 6)}…). ` +
104
- `Unset the shell variable or update ~/.construct/config.env.\n`,
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
  }