UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

229 lines 8.87 kB
const DEFAULT_KIND_BUDGETS = { footgun: { maxBytes: 1_100, maxEntries: 3 }, lesson: { maxBytes: 700, maxEntries: 2 }, pattern: { maxBytes: 420, maxEntries: 1 }, decision: { maxBytes: 420, maxEntries: 1 }, }; const KIND_RANK = { footgun: 0, lesson: 1, pattern: 2, decision: 3, }; const OVERSIZED_BUCKET_BYTES = 40_000; /** Measure prompt budget in UTF-8 bytes so caps match the rendered block. */ function byteLength(content) { return Buffer.byteLength(content, "utf8"); } /** Truncate excerpts without splitting multibyte characters. */ function truncateBytes(content, maxBytes) { if (byteLength(content) <= maxBytes) return content; let out = ""; for (const char of content) { const next = out + char; if (byteLength(next + "...") > maxBytes) break; out = next; } return `${out.trimEnd()}...`; } /** Use updated dates first so recently revised incidents outrank old original dates. */ function entryDate(entry) { return entry.updated ?? entry.created ?? ""; } /** Treat stale references and invalid line refs as maintenance-only warning context. */ function isStaleOrInvalid(entry) { return entry.staleRefs.length > 0 || entry.invalidLineRefs.length > 0; } function mergedKindBudgets(overrides) { return { footgun: { ...DEFAULT_KIND_BUDGETS.footgun, ...overrides?.footgun }, lesson: { ...DEFAULT_KIND_BUDGETS.lesson, ...overrides?.lesson }, pattern: { ...DEFAULT_KIND_BUDGETS.pattern, ...overrides?.pattern }, decision: { ...DEFAULT_KIND_BUDGETS.decision, ...overrides?.decision }, }; } /** Tune total context budget by prompt surface; maintenance prompts need more evidence. */ function defaultMaxBytes(surface) { if (surface === "maintenance") return 3_200; if (surface === "quality-process") return 2_600; return 2_200; } /** Include ADRs by default because setup and quality prompts both need policy context. */ function includeDecisionEntries() { return true; } /** Permit oversized buckets only for surfaces that explicitly evaluate learning-loop quality. */ function allowOversizedBuckets(surface) { return surface.startsWith("quality-") || surface === "maintenance"; } function resolveLearningLoopOptions(options) { const surface = options.surface ?? "quality-agent-setup"; return { includeStale: options.includeStale ?? surface === "maintenance", includeDecisions: options.includeDecisions ?? includeDecisionEntries(), includeOversized: options.includeOversized ?? allowOversizedBuckets(surface), budgetMax: options.maxBytes ?? defaultMaxBytes(surface), perEntryMaxBytes: options.perEntryMaxBytes ?? 360, kindBudgets: mergedKindBudgets(options.perKind), }; } /** Explain why a selected excerpt is relevant to the receiving prompt. */ function reasonFor(entry) { if (isStaleOrInvalid(entry)) { return "surfaced for learning-loop maintenance despite stale or invalid refs"; } if (entry.kind === "footgun" && entry.hasValidAnchor) { return "active footgun with valid semantic anchor"; } if (entry.kind === "footgun") return "active footgun"; if (entry.kind === "lesson") return "recent lesson"; if (entry.kind === "pattern") return "reusable pattern within cap"; return "decision included for setup or policy context"; } /** Rank durable warnings before softer context so scarce prompt budget favours footguns. */ function entryRank(entry) { const staleOffset = isStaleOrInvalid(entry) ? 10 : 0; const anchorBoost = entry.kind === "footgun" && entry.hasValidAnchor ? 0 : 1; return staleOffset + KIND_RANK[entry.kind] * 2 + anchorBoost; } function compareEntries(left, right) { const rankDiff = entryRank(left) - entryRank(right); if (rankDiff !== 0) return rankDiff; const dateDiff = entryDate(right).localeCompare(entryDate(left)); if (dateDiff !== 0) return dateDiff; const pathDiff = left.sourcePath.localeCompare(right.sourcePath); if (pathDiff !== 0) return pathDiff; return left.order - right.order; } function allowedEntry(entry, options) { if (entry.sourcePath.endsWith("/README.md")) return false; if (entry.kind === "decision" && !options.includeDecisions) return false; if (entry.kind === "footgun" && entry.status !== "active") return false; if (!options.includeStale && isStaleOrInvalid(entry)) return false; if (!options.includeOversized && entry.bucketSizeBytes > OVERSIZED_BUCKET_BYTES) { return false; } return true; } /** Render stale-reference counts compactly without inlining every broken path. */ function flagText(entry) { const flags = [ entry.staleRefs.length > 0 ? `stale refs: ${entry.staleRefs.length}` : "", entry.invalidLineRefs.length > 0 ? `invalid refs: ${entry.invalidLineRefs.length}` : "", ].filter(Boolean); return flags.length === 0 ? "" : ` Flags: ${flags.join(", ")}.`; } /** Render one selected excerpt as a single prompt bullet. */ function renderEntry(entry) { return `- [${entry.kind}] ${entry.title} (\`${entry.sourcePath}\`) - ${entry.reasonSelected}.${flagText(entry)} ${entry.excerpt}`; } function selectedFromEntry(entry, maxExcerptBytes) { return { sourcePath: entry.sourcePath, kind: entry.kind, title: entry.title, reasonSelected: reasonFor(entry), excerpt: truncateBytes(entry.excerpt, maxExcerptBytes), staleRefs: [...entry.staleRefs], invalidLineRefs: [...entry.invalidLineRefs], }; } function finalizeSelection(entries, budgetMax, omittedCount, zeroHit) { let selection = { entries, budgetUsed: 0, budgetMax, selectedCount: entries.length, omittedCount, zeroHit, }; for (let i = 0; i < 3; i++) { selection = { ...selection, budgetUsed: byteLength(renderLearningLoopContext(selection)), }; } while (selection.entries.length > 0 && byteLength(renderLearningLoopContext(selection)) > budgetMax) { selection = finalizeSelection(selection.entries.slice(0, -1), budgetMax, omittedCount + 1, zeroHit); } return selection; } /** * Select deterministic, size-bounded learning-loop context from shared facts. * * @param sharedFacts - Extracted project facts containing learning-loop entries. * @param options - Surface-specific caps and inclusion overrides. * @returns Prompt-ready selection and budget accounting. */ export function selectLearningLoopContext(sharedFacts, options = {}) { const resolved = resolveLearningLoopOptions(options); const sourceEntries = sharedFacts.learningLoopEntries; const candidates = sourceEntries .filter((entry) => allowedEntry(entry, { includeStale: resolved.includeStale, includeDecisions: resolved.includeDecisions, includeOversized: resolved.includeOversized, })) .sort(compareEntries); const kindBytes = { footgun: 0, lesson: 0, pattern: 0, decision: 0, }; const kindCounts = { footgun: 0, lesson: 0, pattern: 0, decision: 0, }; const selected = []; for (const candidate of candidates) { const budget = resolved.kindBudgets[candidate.kind]; if (kindCounts[candidate.kind] >= budget.maxEntries) continue; const next = selectedFromEntry(candidate, resolved.perEntryMaxBytes); const nextBytes = byteLength(renderEntry(next)); if (kindBytes[candidate.kind] + nextBytes > budget.maxBytes) continue; selected.push(next); kindCounts[candidate.kind]++; kindBytes[candidate.kind] += nextBytes; } return finalizeSelection(selected, resolved.budgetMax, sourceEntries.length - selected.length, candidates.length === 0); } /** * Render the selected entries as a compact prompt block. * * @param selection - Selection returned by `selectLearningLoopContext`. * @returns XML-like prompt block, or an empty string when no entries were selected. */ export function renderLearningLoopContext(selection) { if (selection.entries.length === 0) return ""; return [ `<goat-learning-loop budget="${selection.budgetMax} bytes" used="${selection.budgetUsed} bytes" selected="${selection.selectedCount}" omitted="${selection.omittedCount}">`, "Curated learning-loop context only. Full freshness/stale-ref enforcement remains owned by `goat-flow stats --check`.", ...selection.entries.map(renderEntry), "</goat-learning-loop>", ].join("\n"); } //# sourceMappingURL=learning-loop-context.js.map