* chore: add v1.31.0 npm known-issue notice to issue template config Adds a top-priority contact link to the issue template chooser so users are redirected to the Discussions announcement before opening a duplicate issue about v1.31.0 not being on npm. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sdk): reduce context prompt sizes with truncation and cache-friendly ordering (#1614) - Reorder prompt assembly in PromptFactory to place stable content (role, workflow, phase instructions) before variable content (.planning/ files), enabling Anthropic prompt caching at 0.1x input cost on cache hits - Add markdown-aware truncation for oversized context files (headings + first paragraphs preserved, rest omitted with line counts) - Add ROADMAP.md milestone extraction to inject only the current milestone instead of the full roadmap - Export truncation utilities from SDK public API - 60 new + updated tests covering truncation, milestone extraction, cache-friendly ordering, and ContextEngine integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
6.8 KiB
TypeScript
234 lines
6.8 KiB
TypeScript
/**
|
|
* Context truncation — reduces large .planning/ files to cache-friendly sizes.
|
|
*
|
|
* Two strategies:
|
|
* 1. Markdown-aware truncation: keeps headings + first paragraph per section,
|
|
* replaces the rest with a pointer to the full file.
|
|
* 2. Milestone extraction: pulls only the current milestone from ROADMAP.md.
|
|
*
|
|
* All functions are pure — no I/O, no side effects.
|
|
*/
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
export interface TruncationOptions {
|
|
/** Max content length in characters before truncation kicks in. Default: 8192 */
|
|
maxContentLength: number;
|
|
}
|
|
|
|
export const DEFAULT_TRUNCATION_OPTIONS: TruncationOptions = {
|
|
maxContentLength: 8192,
|
|
};
|
|
|
|
// ─── Markdown-aware truncation ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Truncate markdown content while preserving structure.
|
|
*
|
|
* Strategy: keep YAML frontmatter, all headings, and the first paragraph under
|
|
* each heading. Collapse everything else with a line count summary.
|
|
*
|
|
* Returns the original content unchanged if below maxContentLength.
|
|
*/
|
|
export function truncateMarkdown(
|
|
content: string,
|
|
filename: string,
|
|
options: TruncationOptions = DEFAULT_TRUNCATION_OPTIONS,
|
|
): string {
|
|
if (content.length <= options.maxContentLength) return content;
|
|
|
|
const lines = content.split('\n');
|
|
const kept: string[] = [];
|
|
let inFrontmatter = false;
|
|
let frontmatterDone = false;
|
|
let currentSectionLines = 0;
|
|
let paragraphKept = false;
|
|
let omittedLines = 0;
|
|
let inParagraph = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// Handle YAML frontmatter (preserve entirely)
|
|
if (i === 0 && line.trim() === '---') {
|
|
inFrontmatter = true;
|
|
kept.push(line);
|
|
continue;
|
|
}
|
|
if (inFrontmatter) {
|
|
kept.push(line);
|
|
if (line.trim() === '---') {
|
|
inFrontmatter = false;
|
|
frontmatterDone = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Heading — always keep, reset paragraph tracking
|
|
if (/^#{1,6}\s/.test(line)) {
|
|
if (omittedLines > 0) {
|
|
kept.push(`[... ${omittedLines} lines omitted]`);
|
|
omittedLines = 0;
|
|
}
|
|
kept.push(line);
|
|
currentSectionLines = 0;
|
|
paragraphKept = false;
|
|
inParagraph = false;
|
|
continue;
|
|
}
|
|
|
|
// Empty line — paragraph boundary
|
|
if (line.trim() === '') {
|
|
if (inParagraph && !paragraphKept) {
|
|
// End of first paragraph — mark it kept
|
|
paragraphKept = true;
|
|
}
|
|
if (!paragraphKept || currentSectionLines === 0) {
|
|
kept.push(line);
|
|
} else {
|
|
omittedLines++;
|
|
}
|
|
inParagraph = false;
|
|
continue;
|
|
}
|
|
|
|
// Content line
|
|
currentSectionLines++;
|
|
if (!paragraphKept) {
|
|
// Still in the first paragraph — keep it
|
|
kept.push(line);
|
|
inParagraph = true;
|
|
} else {
|
|
omittedLines++;
|
|
}
|
|
}
|
|
|
|
if (omittedLines > 0) {
|
|
kept.push(`[... ${omittedLines} lines omitted]`);
|
|
}
|
|
|
|
const totalOmitted = lines.length - kept.length;
|
|
if (totalOmitted > 0) {
|
|
kept.push('');
|
|
kept.push(`[Truncated: read .planning/${filename} for full content]`);
|
|
}
|
|
|
|
return kept.join('\n');
|
|
}
|
|
|
|
// ─── Milestone extraction ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract the current milestone section from a ROADMAP.md.
|
|
*
|
|
* Parses STATE.md to find the current milestone name, then extracts only
|
|
* that milestone's section from the roadmap. Falls back to full content
|
|
* if the milestone can't be identified or found.
|
|
*/
|
|
export function extractCurrentMilestone(
|
|
roadmapContent: string,
|
|
stateContent?: string,
|
|
): string {
|
|
if (!stateContent) return roadmapContent;
|
|
|
|
// Find current milestone from STATE.md
|
|
// Patterns: "Current Milestone: X", "milestone: X", "## Current Position" block
|
|
const milestonePatterns = [
|
|
/current\s*milestone\s*:\s*(.+)/i,
|
|
/^milestone\s*:\s*(.+)/im,
|
|
/##\s*current\s*position[\s\S]*?milestone\s*:\s*(.+)/i,
|
|
];
|
|
|
|
let milestoneName: string | undefined;
|
|
for (const pattern of milestonePatterns) {
|
|
const match = stateContent.match(pattern);
|
|
if (match) {
|
|
milestoneName = match[1].trim();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!milestoneName) return roadmapContent;
|
|
|
|
// Find the milestone section in roadmap
|
|
// Look for heading containing the milestone name
|
|
const lines = roadmapContent.split('\n');
|
|
let sectionStart = -1;
|
|
let sectionEnd = lines.length;
|
|
let sectionHeadingLevel = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)/);
|
|
if (!headingMatch) continue;
|
|
|
|
const level = headingMatch[1].length;
|
|
const title = headingMatch[2];
|
|
|
|
if (sectionStart === -1) {
|
|
// Looking for the milestone heading
|
|
if (title.toLowerCase().includes(milestoneName.toLowerCase())) {
|
|
sectionStart = i;
|
|
sectionHeadingLevel = level;
|
|
}
|
|
} else {
|
|
// Found start — look for next heading at same or higher level
|
|
if (level <= sectionHeadingLevel) {
|
|
sectionEnd = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sectionStart === -1) return roadmapContent;
|
|
|
|
// Extract preamble (everything before first milestone heading at the same level)
|
|
const preamble: string[] = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const headingMatch = lines[i].match(/^(#{1,6})\s/);
|
|
if (headingMatch && headingMatch[1].length === sectionHeadingLevel && i !== sectionStart) {
|
|
// Hit another milestone-level heading before our section
|
|
if (i < sectionStart) {
|
|
break; // preamble ends at first milestone heading
|
|
}
|
|
}
|
|
if (i < sectionStart) {
|
|
// Keep top-level title and intro
|
|
if (i === 0 || lines[i].match(/^#\s/) || !lines[i].match(/^#{1,6}\s/)) {
|
|
preamble.push(lines[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const milestoneSection = lines.slice(sectionStart, sectionEnd).join('\n');
|
|
const otherMilestones = countOtherMilestones(lines, sectionHeadingLevel, sectionStart);
|
|
|
|
const result = [
|
|
...preamble,
|
|
'',
|
|
milestoneSection,
|
|
];
|
|
|
|
if (otherMilestones > 0) {
|
|
result.push('');
|
|
result.push(`[${otherMilestones} other milestone(s) omitted — read .planning/ROADMAP.md for full roadmap]`);
|
|
}
|
|
|
|
return result.join('\n').trim();
|
|
}
|
|
|
|
function countOtherMilestones(
|
|
lines: string[],
|
|
headingLevel: number,
|
|
excludeIndex: number,
|
|
): number {
|
|
let count = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (i === excludeIndex) continue;
|
|
const match = lines[i].match(/^(#{1,6})\s/);
|
|
if (match && match[1].length === headingLevel) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|