From d26d66fd3b7ca6d653473891e68eec7f82917d8e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 30 Apr 2026 01:23:03 -0400 Subject: [PATCH] fix: inject learned skills at session start --- scripts/hooks/session-start.js | 122 ++++++++++++++++++++++++++++++++- tests/hooks/hooks.test.js | 59 ++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index d42f9723..955f9f38 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -30,6 +30,8 @@ const fs = require('fs'); const INSTINCT_CONFIDENCE_THRESHOLD = 0.7; const MAX_INJECTED_INSTINCTS = 6; +const MAX_INJECTED_LEARNED_SKILLS = 6; +const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220; const DEFAULT_SESSION_RETENTION_DAYS = 30; /** @@ -347,6 +349,119 @@ function summarizeActiveInstincts(observerContext) { return `Active instincts:\n${lines.join('\n')}`; } +function stripMarkdownInline(value) { + return String(value || '') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trim(); +} + +function collapseWhitespace(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function truncateSummary(value, maxLength = MAX_LEARNED_SKILL_SUMMARY_CHARS) { + const normalized = collapseWhitespace(stripMarkdownInline(value)); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function extractMarkdownHeading(content) { + const match = String(content || '').match(/^#\s+(.+)$/m); + return match ? stripMarkdownInline(match[1]) : ''; +} + +function extractSection(content, headingPattern) { + const source = String(content || ''); + const match = source.match(new RegExp(`^##\\s+${headingPattern}\\s*\\n+([\\s\\S]+?)(?:\\n##\\s+|$)`, 'im')); + return match ? match[1].trim() : ''; +} + +function extractFirstParagraph(content) { + const withoutHeading = String(content || '').replace(/^#\s+.+$/m, '').trim(); + return withoutHeading + .split(/\n\s*\n/) + .map(paragraph => paragraph.trim()) + .find(Boolean) || ''; +} + +function summarizeLearnedSkillFile(filePath, learnedRoot) { + const content = readFile(filePath); + if (!content) return null; + + const isDirectorySkill = path.basename(filePath).toLowerCase() === 'skill.md'; + const slug = isDirectorySkill + ? path.basename(path.dirname(filePath)) + : path.basename(filePath, path.extname(filePath)); + const title = extractMarkdownHeading(content) || slug; + const summary = truncateSummary( + extractSection(content, 'When to Use') + || extractSection(content, 'Trigger') + || extractSection(content, 'Problem') + || extractFirstParagraph(content) + || title + ); + + if (!summary) return null; + + let mtime = 0; + try { + mtime = fs.statSync(filePath).mtimeMs; + } catch { + // Keep unreadable/deleted files out of recency priority without failing the hook. + } + + const relativePath = path.relative(learnedRoot, filePath); + return { + slug, + title: truncateSummary(title, 80), + summary, + relativePath, + mtime, + }; +} + +function collectLearnedSkillFiles(learnedDir) { + const flatMarkdownFiles = findFiles(learnedDir, '*.md'); + const directorySkillFiles = findFiles(learnedDir, 'SKILL.md', { recursive: true }); + const byPath = new Map(); + + for (const match of [...flatMarkdownFiles, ...directorySkillFiles]) { + byPath.set(match.path, match); + } + + return Array.from(byPath.values()) + .sort((left, right) => right.mtime - left.mtime || left.path.localeCompare(right.path)); +} + +function summarizeLearnedSkills(learnedDir, learnedSkillFiles = collectLearnedSkillFiles(learnedDir)) { + const summaries = learnedSkillFiles + .map(match => summarizeLearnedSkillFile(match.path, learnedDir)) + .filter(Boolean) + .slice(0, MAX_INJECTED_LEARNED_SKILLS); + + if (summaries.length === 0) { + return ''; + } + + log(`[SessionStart] Injecting ${summaries.length} learned skill(s) into session context`); + + const lines = summaries.map(skill => { + const titleSuffix = skill.title && skill.title !== skill.slug ? ` (${skill.title})` : ''; + return `- ${skill.slug}${titleSuffix}: ${skill.summary}`; + }); + + return [ + 'Available learned skills:', + 'Reference only; apply a learned skill only when it is relevant to the current user request.', + ...lines, + ].join('\n'); +} + async function main() { const sessionsDir = getSessionsDir(); const sessionSearchDirs = getSessionSearchDirs(); @@ -428,12 +543,17 @@ async function main() { } // Check for learned skills - const learnedSkills = findFiles(learnedDir, '*.md'); + const learnedSkills = collectLearnedSkillFiles(learnedDir); if (learnedSkills.length > 0) { log(`[SessionStart] ${learnedSkills.length} learned skill(s) available in ${learnedDir}`); } + const learnedSkillSummary = summarizeLearnedSkills(learnedDir, learnedSkills); + if (learnedSkillSummary) { + additionalContextParts.push(learnedSkillSummary); + } + // Check for available session aliases const aliases = listAliases({ limit: 5 }); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 8948057b..9afa55d8 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -545,6 +545,65 @@ async function runTests() { passed++; else failed++; + if ( + await asyncTest('injects learned skills into session-start additional context', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-skills-context-${Date.now()}`); + const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned'); + fs.mkdirSync(learnedDir, { recursive: true }); + fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true }); + + fs.writeFileSync( + path.join(learnedDir, 'testing-patterns.md'), + [ + '# Testing Patterns', + '', + '## When to Use', + 'Use for recurring flaky integration tests that need deterministic setup checks.', + '', + '## Solution', + 'Verify service readiness before running the test body.', + ].join('\n'), + ); + fs.mkdirSync(path.join(learnedDir, 'debugging-pattern'), { recursive: true }); + fs.writeFileSync( + path.join(learnedDir, 'debugging-pattern', 'SKILL.md'), + [ + '# Debugging Pattern', + '', + '## Trigger', + 'Use when a CLI tool silently exits without a result payload.', + ].join('\n'), + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok( + additionalContext.includes('Available learned skills'), + `Should inject learned skills into additionalContext, got: ${additionalContext}` + ); + assert.ok(additionalContext.includes('testing-patterns'), 'Should include the learned skill slug'); + assert.ok( + additionalContext.includes('Use for recurring flaky integration tests'), + 'Should include the learned skill trigger text' + ); + assert.ok(additionalContext.includes('debugging-pattern'), 'Should include directory-style learned skills'); + assert.ok( + additionalContext.includes('CLI tool silently exits'), + 'Should summarize directory-style learned skill trigger text' + ); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + // check-console-log.js tests console.log('\ncheck-console-log.js:');