(content: string, skillName: string)
| 424 | * JSON aggregation at the end of the run). |
| 425 | */ |
| 426 | export function applyCatalogTrim(content: string, skillName: string): { content: string; parts: CatalogParts } | null { |
| 427 | // Locate description block in frontmatter |
| 428 | if (!content.startsWith('---\n')) return null; |
| 429 | const fmEnd = content.indexOf('\n---', 4); |
| 430 | if (fmEnd === -1) return null; |
| 431 | const frontmatter = content.slice(4, fmEnd); |
| 432 | |
| 433 | // Match `description: |` block + indented body lines |
| 434 | const descMatch = frontmatter.match(/^description:\s*\|?\s*\n((?:\s{2,}.*(?:\n|$))+)/m) |
| 435 | || frontmatter.match(/^description:\s+(.+)$/m); |
| 436 | if (!descMatch) return null; |
| 437 | |
| 438 | // Extract full description text |
| 439 | let descText: string; |
| 440 | if (descMatch[0].startsWith('description: |') || /^description:\s*\|/.test(descMatch[0])) { |
| 441 | descText = descMatch[1].split('\n').map(l => l.replace(/^\s{2}/, '')).join('\n').trim(); |
| 442 | } else { |
| 443 | descText = descMatch[1].trim(); |
| 444 | } |
| 445 | |
| 446 | // Skip skills with very short descriptions (already trimmed or no routing prose). |
| 447 | // Below ~120 chars, splitting adds no value. |
| 448 | if (descText.length < 120) return null; |
| 449 | |
| 450 | const parts = splitCatalogDescription(descText); |
| 451 | // If lead + (gstack) is already most of the text, no trim needed. |
| 452 | const trimmedLen = buildTrimmedDescription(parts).length; |
| 453 | if (trimmedLen >= descText.length - 20) return null; |
| 454 | |
| 455 | // Replace description in frontmatter — keep trailing newline so the next |
| 456 | // YAML field doesn't collide on the same line as the description value. |
| 457 | // Quote the value when it would be an invalid YAML plain scalar (the common |
| 458 | // case: an interior ": " like "Ship workflow: detect..." which a strict YAML |
| 459 | // parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar |
| 460 | // only quotes when needed, so descriptions without special chars stay plain. |
| 461 | const newDesc = buildTrimmedDescription(parts); |
| 462 | // Function replacer (not a string) so a `$` in the description — e.g. a future |
| 463 | // skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement |
| 464 | // pattern and silently corrupt the frontmatter. |
| 465 | const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`; |
| 466 | const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine); |
| 467 | let newContent = '---\n' + newFrontmatter + content.slice(fmEnd); |
| 468 | |
| 469 | // Insert body section after frontmatter (after the closing ---\n and any |
| 470 | // existing GENERATED header). We insert before the first non-comment line. |
| 471 | const bodyStart = newContent.indexOf('\n---\n') + 5; |
| 472 | const whenToInvoke = '\n' + buildWhenToInvokeSection(parts).trim() + '\n'; |
| 473 | // Skip past the generated header if present (it lives after frontmatter close) |
| 474 | const headerMatch = newContent.slice(bodyStart).match(/^(<!--[^>]*-->\s*\n)+/); |
| 475 | const insertAt = bodyStart + (headerMatch ? headerMatch[0].length : 0); |
| 476 | newContent = newContent.slice(0, insertAt) + whenToInvoke + '\n' + newContent.slice(insertAt); |
| 477 | |
| 478 | return { content: newContent, parts }; |
| 479 | } |
| 480 | |
| 481 | const OPENAI_SHORT_DESCRIPTION_LIMIT = 120; |
| 482 |
no test coverage detected