@dtlabs/ai-toolkit 0.19.1 → 0.19.2

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/CHANGELOG.md CHANGED
@@ -1,4 +1,12 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.19.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.1...v0.19.2) (2026-10-19)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **workflow-control, tech-spec-assembler:** unwrap whole response fences in Markdown documents to prevent content loss ([fe88582](https://github.com/dtlab-tech/ai-toolkit/commit/fe885821bb7b813237a1d53ed827c70e09b6c9d0))
8
+ * **workflow-control, tech-spec-assembler:** unwrap whole response fences in Markdown documents to prevent content loss ([358e484](https://github.com/dtlab-tech/ai-toolkit/commit/358e4847fe74c314c3c1158dbeda08669530c1f8))
9
+
2
10
  ## [0.19.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.0...v0.19.1) (2026-10-19)
3
11
 
4
12
 
@@ -238,6 +238,13 @@ function createControl(options) {
238
238
  const candidate = extractDelimitedStructuredOutput(result.result.result, opts.schema);
239
239
  if (candidate != null) { payload = candidate; usedTextFallback = true; }
240
240
  }
241
+ // A non-schema worker's payload IS the document — unwrap a whole-response fence/preamble
242
+ // before anything downstream (the caller's own write, or this function's own outputTarget
243
+ // write below) ever sees it. See extractMarkdownDocument's own comment for the full story.
244
+ if (!opts.schema && typeof payload === 'string') {
245
+ const cleaned = extractMarkdownDocument(payload);
246
+ if (cleaned !== payload) { payload = cleaned; usedTextFallback = true; }
247
+ }
241
248
  if (payload == null || (typeof payload === 'string' && !payload.trim())) {
242
249
  if (opts.schema) {
243
250
  const fields = (opts.schema.required || []).join(', ') || '(see requested schema)';
@@ -476,6 +483,65 @@ function extractJsonDocument(text) {
476
483
  return text;
477
484
  }
478
485
 
486
+ // Same problem extractJsonDocument fixes, for a Markdown document deliverable (a component/
487
+ // use-case/task detail file, or a Requirements/Tech-Spec skeleton) instead of a JSON one: a
488
+ // worker's entire response becomes the file verbatim (these agents have no Write tool, see
489
+ // worker()'s own comment on why — the host writes whatever text they return), and a model that
490
+ // wraps its deliverable in conversational prose and/or an outer code fence silently corrupts the
491
+ // saved file. Found empirically in a real spec/components/*.md file: the agent's own real
492
+ // "## Change"/"## Tests" headings ended up INSIDE an outer ```markdown fence and were (correctly)
493
+ // treated as fenced, non-heading content by tech-spec-assembler.js's own fence-aware parser
494
+ // (FTR-025) — the file wrote to disk with its real content unreachable, surfacing only much later
495
+ // as an opaque "component file is missing its ## Change section" error during final assembly, with
496
+ // no filename attached to say which one (see loadTechSpec's own fix for that half of the bug).
497
+ //
498
+ // Same "try as-is first, re-validate the candidate, never a blind substring guess, never throws"
499
+ // discipline as extractJsonDocument: "valid" here means "has at least one unfenced Markdown
500
+ // heading" — the same fence-length-aware scan tech-spec-assembler.js's own
501
+ // _nonFencedHeadingIndexes uses, reimplemented here as a presence check rather than a line-index
502
+ // list, for the identical "sandboxed runtime has no require()"-shaped reason these two files are
503
+ // already siblings, not shared, elsewhere in this codebase. A document that legitimately ends in
504
+ // its own small fenced code example (a real Tests section's last line) is never mistaken for a
505
+ // whole-response wrap: its real headings already appear BEFORE that trailing fence, unfenced, so
506
+ // the as-is candidate already satisfies this check and no unwrapping is even attempted.
507
+ function _hasUnfencedHeading(text) {
508
+ let fenceLen = 0;
509
+ for (const line of text.split('\n')) {
510
+ const m = line.match(/^(`{3,})/);
511
+ if (m) { const n = m[1].length; fenceLen = fenceLen === 0 ? n : (n >= fenceLen ? 0 : fenceLen); continue; }
512
+ if (fenceLen === 0 && /^#{1,6}\s/.test(line)) return true;
513
+ }
514
+ return false;
515
+ }
516
+ // Line-range based, not regex-based: a regex scan for the "first ``` ... last ```" span would
517
+ // wrongly stop at the first NESTED closing fence instead of the real outer one (exactly the shape
518
+ // a Markdown document's own "## Data Model" section legitimately needs — a fenced JSON example
519
+ // nested inside the model's outer ```markdown wrap). Only the LAST non-blank line being a bare
520
+ // fence-close, matched back to the first fence-open of the SAME backtick-run length, correctly
521
+ // identifies the true outer boundary regardless of what's nested between them.
522
+ function _unwrapWholeResponseFence(text) {
523
+ const lines = text.split('\n');
524
+ let end = lines.length;
525
+ while (end > 0 && lines[end - 1].trim() === '') end--;
526
+ if (end === 0) return null;
527
+ const closeMatch = lines[end - 1].match(/^(`{3,})\s*$/);
528
+ if (!closeMatch) return null;
529
+ const fenceLen = closeMatch[1].length;
530
+ let start = -1;
531
+ for (let i = 0; i < end - 1; i++) {
532
+ const openMatch = lines[i].match(/^(`{3,})\S*\s*$/);
533
+ if (openMatch && openMatch[1].length === fenceLen) { start = i; break; }
534
+ }
535
+ if (start === -1) return null;
536
+ return lines.slice(start + 1, end - 1).join('\n').trim() + '\n';
537
+ }
538
+ function extractMarkdownDocument(text) {
539
+ if (_hasUnfencedHeading(text)) return text;
540
+ const unwrapped = _unwrapWholeResponseFence(text);
541
+ if (unwrapped != null && _hasUnfencedHeading(unwrapped)) return unwrapped;
542
+ return text;
543
+ }
544
+
479
545
  function validateSchema(value, schema) {
480
546
  if (schema.type === 'object') {
481
547
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid structured object');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -148,7 +148,18 @@ function loadTechSpec(inputPath) {
148
148
  for (const c of components) {
149
149
  const compPath = path.join(inputPath, 'components', c.fileSlug)
150
150
  if (!fs.existsSync(compPath)) throw new AssemblyError(`component "${c.filePath}" has no detail file at ${compPath}`)
151
- detailByPath[c.filePath] = parseComponentDetail(fs.readFileSync(compPath, 'utf8'))
151
+ try {
152
+ detailByPath[c.filePath] = parseComponentDetail(fs.readFileSync(compPath, 'utf8'))
153
+ } catch (err) {
154
+ // parseComponentDetail's own error names only the symptom ("missing its ## Change
155
+ // section"), never which file — a real diagnosability bug, found when exactly this error
156
+ // surfaced during final assembly with nothing identifying which of ~60 component files was
157
+ // actually malformed (the real culprit here was a whole-response-wrapped-in-a-fence worker
158
+ // reply — see lib/workflow-control.js's extractMarkdownDocument for the fix to that other
159
+ // half of this same incident). Re-thrown with the file identified, never silently swallowed.
160
+ if (err instanceof AssemblyError) throw new AssemblyError(`${err.message} (component "${c.filePath}" at ${compPath})`)
161
+ throw err
162
+ }
152
163
  }
153
164
 
154
165
  let doc = rewriteSection9(indexContent, components)