From 6f6527ea950e39a6a63b8f6d11c7d192af9f9aa3 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:51:30 +0000 Subject: [PATCH 1/3] feat(core): add PageIndex-inspired patterns for skill discovery Implements patterns from anthropics/knowledge-work-plugins: Tree Module (packages/core/src/tree/): - Hierarchical skill taxonomy with 12 categories - Tree generator from skill tags and metadata - Tree serializer for JSON/Markdown export - Related skills graph with 4 relation types Reasoning Engine (packages/core/src/reasoning/): - LLM-based tree traversal for skill discovery - Explainable recommendations with reasoning chains - Search planning with category relevance scoring - Result caching with 5-minute TTL Connectors (packages/core/src/connectors/): - Tool-agnostic ~~ placeholder system - 13 connector categories (crm, chat, email, etc.) - Auto-suggest mappings from MCP config - Placeholder detection and replacement utilities Execution Flow (packages/core/src/execution/): - Step-by-step execution tracking with metrics - Automatic retry with exponential backoff - Standalone vs Enhanced mode detection - Capability-based feature gating CLI Integration: - New `skillkit tree` command with --generate, --stats flags - Enhanced `skillkit recommend` with --explain, --reasoning flags TUI Integration: - Tree view mode in Marketplace (toggle with 'v') - Arrow key navigation for tree hierarchy --- apps/skillkit/src/cli.ts | 3 + packages/cli/src/commands/index.ts | 3 + packages/cli/src/commands/recommend.ts | 170 +++++ packages/cli/src/commands/tree.ts | 259 ++++++++ packages/core/src/connectors/index.ts | 22 + packages/core/src/connectors/types.ts | 162 +++++ packages/core/src/connectors/utils.ts | 268 ++++++++ packages/core/src/execution/index.ts | 23 + packages/core/src/execution/manager.ts | 306 +++++++++ packages/core/src/execution/mode.ts | 256 ++++++++ packages/core/src/execution/types.ts | 106 +++ packages/core/src/index.ts | 12 + packages/core/src/reasoning/engine.ts | 614 ++++++++++++++++++ packages/core/src/reasoning/index.ts | 14 + packages/core/src/reasoning/prompts.ts | 224 +++++++ packages/core/src/reasoning/types.ts | 115 ++++ packages/core/src/recommend/engine.ts | 133 ++++ packages/core/src/recommend/index.ts | 7 +- packages/core/src/recommend/types.ts | 42 ++ packages/core/src/tree/generator.ts | 358 ++++++++++ packages/core/src/tree/graph.ts | 322 +++++++++ packages/core/src/tree/index.ts | 25 + packages/core/src/tree/serializer.ts | 191 ++++++ packages/core/src/tree/types.ts | 142 ++++ packages/tui/src/screens/Marketplace.tsx | 384 ++++++++--- packages/tui/src/services/index.ts | 11 + .../tui/src/services/recommend.service.ts | 6 + packages/tui/src/services/tree.service.ts | 160 +++++ 28 files changed, 4250 insertions(+), 88 deletions(-) create mode 100644 packages/cli/src/commands/tree.ts create mode 100644 packages/core/src/connectors/index.ts create mode 100644 packages/core/src/connectors/types.ts create mode 100644 packages/core/src/connectors/utils.ts create mode 100644 packages/core/src/execution/index.ts create mode 100644 packages/core/src/execution/manager.ts create mode 100644 packages/core/src/execution/mode.ts create mode 100644 packages/core/src/execution/types.ts create mode 100644 packages/core/src/reasoning/engine.ts create mode 100644 packages/core/src/reasoning/index.ts create mode 100644 packages/core/src/reasoning/prompts.ts create mode 100644 packages/core/src/reasoning/types.ts create mode 100644 packages/core/src/tree/generator.ts create mode 100644 packages/core/src/tree/graph.ts create mode 100644 packages/core/src/tree/index.ts create mode 100644 packages/core/src/tree/serializer.ts create mode 100644 packages/core/src/tree/types.ts create mode 100644 packages/tui/src/services/tree.service.ts diff --git a/apps/skillkit/src/cli.ts b/apps/skillkit/src/cli.ts index a5896437..a0b070b9 100644 --- a/apps/skillkit/src/cli.ts +++ b/apps/skillkit/src/cli.ts @@ -97,6 +97,7 @@ import { GuidelineDisableCommand, GuidelineCreateCommand, GuidelineRemoveCommand, + TreeCommand, } from '@skillkit/cli'; const __filename = fileURLToPath(import.meta.url); @@ -217,4 +218,6 @@ cli.register(GuidelineDisableCommand); cli.register(GuidelineCreateCommand); cli.register(GuidelineRemoveCommand); +cli.register(TreeCommand); + cli.runExit(process.argv.slice(2)); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ab55eb9a..295ee6b0 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -105,3 +105,6 @@ export { GuidelineCreateCommand, GuidelineRemoveCommand, } from './guideline.js'; + +// Tree command for hierarchical skill browsing (Phase 21) +export { TreeCommand } from './tree.js'; diff --git a/packages/cli/src/commands/recommend.ts b/packages/cli/src/commands/recommend.ts index 198e9661..041ff3ae 100644 --- a/packages/cli/src/commands/recommend.ts +++ b/packages/cli/src/commands/recommend.ts @@ -3,8 +3,10 @@ import { resolve } from 'node:path'; import { type ProjectProfile, type ScoredSkill, + type ExplainedScoredSkill, ContextManager, RecommendationEngine, + ReasoningRecommendationEngine, buildSkillIndex, saveIndex, loadIndex as loadIndexFromCache, @@ -108,6 +110,21 @@ export class RecommendCommand extends Command { description: 'Minimal output', }); + // Explain mode (reasoning-based recommendations) + explain = Option.Boolean('--explain,-e', false, { + description: 'Show detailed explanations for recommendations (uses reasoning engine)', + }); + + // Reasoning mode + reasoning = Option.Boolean('--reasoning,-r', false, { + description: 'Use LLM-based reasoning for recommendations', + }); + + // Show tree path + showPath = Option.Boolean('--show-path', false, { + description: 'Show category path for each recommendation', + }); + async execute(): Promise { const targetPath = resolve(this.projectPath || process.cwd()); @@ -139,6 +156,11 @@ export class RecommendCommand extends Command { return 0; } + // Use reasoning engine if explain/reasoning flags are set + if (this.explain || this.reasoning || this.showPath) { + return await this.handleReasoningRecommendations(profile, index); + } + // Create recommendation engine const engine = new RecommendationEngine(); engine.loadIndex(index); @@ -168,6 +190,63 @@ export class RecommendCommand extends Command { return 0; } + private async handleReasoningRecommendations( + profile: ProjectProfile, + index: { skills: ScoredSkill['skill'][]; sources: { name: string; url: string; lastFetched: string; skillCount: number }[]; version: number; lastUpdated: string } + ): Promise { + const s = !this.quiet && !this.json ? spinner() : null; + s?.start('Analyzing with reasoning engine...'); + + try { + const engine = new ReasoningRecommendationEngine(); + engine.loadIndex(index); + await engine.initReasoning(); + + const result = await engine.recommendWithReasoning(profile, { + limit: this.limit ? parseInt(this.limit, 10) : 10, + minScore: this.minScore ? parseInt(this.minScore, 10) : 30, + categories: this.category, + excludeInstalled: !this.includeInstalled, + includeReasons: this.verbose, + reasoning: this.reasoning, + explainResults: this.explain, + useTree: true, + }); + + s?.stop('Analysis complete'); + + if (this.json) { + console.log(JSON.stringify(result, null, 2)); + return 0; + } + + this.displayExplainedRecommendations( + result.recommendations, + profile, + result.totalSkillsScanned, + result.reasoningSummary + ); + return 0; + } catch (err) { + s?.stop(colors.error('Reasoning analysis failed')); + console.log(colors.muted(err instanceof Error ? err.message : String(err))); + console.log(colors.muted('Falling back to standard recommendations...')); + console.log(''); + + const engine = new RecommendationEngine(); + engine.loadIndex(index); + const result = engine.recommend(profile, { + limit: this.limit ? parseInt(this.limit, 10) : 10, + minScore: this.minScore ? parseInt(this.minScore, 10) : 30, + categories: this.category, + excludeInstalled: !this.includeInstalled, + includeReasons: this.verbose, + }); + this.displayRecommendations(result.recommendations, profile, result.totalSkillsScanned); + return 0; + } + } + private async getProjectProfile(projectPath: string): Promise { const manager = new ContextManager(projectPath); let context = manager.get(); @@ -276,6 +355,97 @@ export class RecommendCommand extends Command { console.log(colors.muted('Install with: skillkit install ')); } + private displayExplainedRecommendations( + recommendations: ExplainedScoredSkill[], + profile: ProjectProfile, + totalScanned: number, + reasoningSummary?: string + ): void { + this.showProjectProfile(profile); + + if (reasoningSummary && !this.quiet) { + console.log(colors.dim('Reasoning: ') + colors.muted(reasoningSummary)); + console.log(''); + } + + if (recommendations.length === 0) { + warn('No matching skills found.'); + console.log(colors.muted('Try lowering the minimum score with --min-score')); + return; + } + + console.log(colors.bold(`Explained Recommendations (${recommendations.length} of ${totalScanned} scanned):`)); + console.log(''); + + for (const rec of recommendations) { + let scoreColor: (text: string) => string; + if (rec.score >= 70) { + scoreColor = colors.success; + } else if (rec.score >= 50) { + scoreColor = colors.warning; + } else { + scoreColor = colors.muted; + } + const scoreBar = progressBar(rec.score, 100, 10); + const qualityScore = rec.skill.quality ?? null; + const qualityDisplay = qualityScore !== null && qualityScore !== undefined + ? ` ${formatQualityBadge(qualityScore)}` + : ''; + + console.log(` ${scoreColor(`${rec.score}%`)} ${colors.dim(scoreBar)} ${colors.bold(rec.skill.name)}${qualityDisplay}`); + + if (this.showPath && rec.treePath && rec.treePath.length > 0) { + console.log(` ${colors.accent('Path:')} ${rec.treePath.join(' > ')}`); + } + + if (rec.skill.description) { + console.log(` ${colors.muted(truncate(rec.skill.description, 70))}`); + } + + if (rec.skill.source) { + console.log(` ${colors.dim('Source:')} ${rec.skill.source}`); + } + + if (this.explain && rec.explanation) { + console.log(colors.dim(' Why this skill:')); + if (rec.explanation.matchedBecause.length > 0) { + console.log(` ${colors.success('├─')} Matched: ${rec.explanation.matchedBecause.join(', ')}`); + } + if (rec.explanation.relevantFor.length > 0) { + console.log(` ${colors.accent('├─')} Relevant for: ${rec.explanation.relevantFor.join(', ')}`); + } + if (rec.explanation.confidence) { + const confidenceColor = rec.explanation.confidence === 'high' ? colors.success : + rec.explanation.confidence === 'medium' ? colors.warning : + colors.muted; + console.log(` ${colors.dim('└─')} Confidence: ${confidenceColor(rec.explanation.confidence)}`); + } + } + + if (this.verbose && rec.reasons.length > 0) { + console.log(colors.dim(' Score breakdown:')); + for (const reason of rec.reasons.filter(r => r.weight > 0)) { + console.log(` ${colors.muted(symbols.stepActive)} ${reason.description} (+${reason.weight})`); + } + if (qualityScore !== null && qualityScore !== undefined) { + const grade = getQualityGradeFromScore(qualityScore); + console.log(` ${colors.muted(symbols.stepActive)} Quality: ${qualityScore}/100 (${grade})`); + } + } + + if (rec.warnings.length > 0) { + for (const warning of rec.warnings) { + console.log(` ${colors.warning(symbols.warning)} ${warning}`); + } + } + + console.log(''); + } + + console.log(colors.muted('Install with: skillkit install ')); + console.log(colors.muted('More details: skillkit recommend --explain --verbose')); + } + private handleSearch(engine: RecommendationEngine, query: string): number { if (!this.quiet && !this.json) { header(`Search: "${query}"`); diff --git a/packages/cli/src/commands/tree.ts b/packages/cli/src/commands/tree.ts new file mode 100644 index 00000000..48dfdf7e --- /dev/null +++ b/packages/cli/src/commands/tree.ts @@ -0,0 +1,259 @@ +import { Command, Option } from 'clipanion'; +import { join } from 'node:path'; +import { + loadIndex as loadIndexFromCache, + generateSkillTree, + saveTree, + loadTree, + type SkillTree, + type TreeNode, +} from '@skillkit/core'; +import { + header, + colors, + symbols, + spinner, + warn, +} from '../onboarding/index.js'; + +const TREE_PATH = join(process.env.HOME || '~', '.skillkit', 'skill-tree.json'); + +export class TreeCommand extends Command { + static override paths = [['tree']]; + + static override usage = Command.Usage({ + description: 'Browse skills in a hierarchical tree structure', + details: ` + The tree command displays skills organized in a hierarchical taxonomy. + Navigate through categories like Development, Testing, DevOps, AI/ML, etc. + + Features: + - Visual tree structure of all skills + - Filter by category path (e.g., "Frontend > React") + - Generate tree from skill index + - Export to markdown format + `, + examples: [ + ['Show full tree', '$0 tree'], + ['Show specific category', '$0 tree Frontend'], + ['Show subcategory', '$0 tree "Frontend > React"'], + ['Limit depth', '$0 tree --depth 2'], + ['Generate/update tree', '$0 tree --generate'], + ['Export to markdown', '$0 tree --markdown'], + ['Show tree stats', '$0 tree --stats'], + ], + }); + + treePath = Option.String({ required: false }); + + depth = Option.String('--depth,-d', { + description: 'Maximum depth to display', + }); + + generate = Option.Boolean('--generate,-g', false, { + description: 'Generate/update tree from skill index', + }); + + markdown = Option.Boolean('--markdown,-m', false, { + description: 'Output in markdown format', + }); + + stats = Option.Boolean('--stats,-s', false, { + description: 'Show tree statistics', + }); + + json = Option.Boolean('--json,-j', false, { + description: 'Output in JSON format', + }); + + quiet = Option.Boolean('--quiet,-q', false, { + description: 'Minimal output', + }); + + async execute(): Promise { + if (this.generate) { + return await this.generateTree(); + } + + const tree = this.loadOrGenerateTree(); + if (!tree) { + warn('No skill tree found. Run "skillkit tree --generate" first.'); + return 1; + } + + if (this.stats) { + return this.showStats(tree); + } + + if (this.json) { + console.log(JSON.stringify(tree, null, 2)); + return 0; + } + + if (this.markdown) { + const { treeToMarkdown } = await import('@skillkit/core'); + console.log(treeToMarkdown(tree)); + return 0; + } + + return this.displayTree(tree); + } + + private async generateTree(): Promise { + if (!this.quiet) { + header('Generate Skill Tree'); + } + + const index = loadIndexFromCache(); + if (!index || index.skills.length === 0) { + warn('No skill index found. Run "skillkit recommend --update" first.'); + return 1; + } + + const s = spinner(); + s.start('Generating skill tree...'); + + try { + const tree = generateSkillTree(index.skills); + saveTree(tree, TREE_PATH); + + s.stop(`Generated tree with ${tree.totalCategories} categories`); + + console.log(''); + console.log(colors.success(`${symbols.success} Tree generated successfully`)); + console.log(colors.muted(` Total skills: ${tree.totalSkills}`)); + console.log(colors.muted(` Categories: ${tree.totalCategories}`)); + console.log(colors.muted(` Max depth: ${tree.maxDepth}`)); + console.log(colors.muted(` Saved to: ${TREE_PATH}`)); + console.log(''); + + return 0; + } catch (err) { + s.stop(colors.error('Failed to generate tree')); + console.log(colors.muted(err instanceof Error ? err.message : String(err))); + return 1; + } + } + + private loadOrGenerateTree(): SkillTree | null { + let tree = loadTree(TREE_PATH); + + if (!tree) { + const index = loadIndexFromCache(); + if (index && index.skills.length > 0) { + tree = generateSkillTree(index.skills); + saveTree(tree, TREE_PATH); + } + } + + return tree; + } + + private showStats(tree: SkillTree): number { + if (!this.quiet) { + header('Skill Tree Statistics'); + } + + console.log(''); + console.log(colors.bold('Overview:')); + console.log(` Total Skills: ${colors.accent(String(tree.totalSkills))}`); + console.log(` Categories: ${colors.accent(String(tree.totalCategories))}`); + console.log(` Max Depth: ${colors.accent(String(tree.maxDepth))}`); + console.log(` Generated: ${colors.muted(tree.generatedAt)}`); + console.log(''); + + console.log(colors.bold('Top-Level Categories:')); + for (const child of tree.rootNode.children) { + const percentage = ((child.skillCount / tree.totalSkills) * 100).toFixed(1); + console.log( + ` ${colors.accent(child.name.padEnd(15))} ${String(child.skillCount).padStart(6)} skills (${percentage}%)` + ); + + if (child.children.length > 0) { + const subcats = child.children + .sort((a, b) => b.skillCount - a.skillCount) + .slice(0, 3) + .map((c) => `${c.name} (${c.skillCount})`) + .join(', '); + console.log(` ${colors.muted(subcats)}`); + } + } + console.log(''); + + return 0; + } + + private displayTree(tree: SkillTree): number { + if (!this.quiet) { + header('Skill Tree'); + console.log(colors.muted(`${tree.totalSkills} skills in ${tree.totalCategories} categories`)); + console.log(''); + } + + let targetNode: TreeNode = tree.rootNode; + + if (this.treePath) { + const segments = this.treePath.split('>').map((s) => s.trim()); + let current = tree.rootNode; + + for (const segment of segments) { + const child = current.children.find( + (c) => c.name.toLowerCase() === segment.toLowerCase() + ); + if (!child) { + warn(`Category not found: ${segment}`); + console.log(colors.muted(`Available categories: ${current.children.map((c) => c.name).join(', ')}`)); + return 1; + } + current = child; + } + targetNode = current; + } + + const maxDepth = this.depth ? parseInt(this.depth, 10) : 3; + this.renderNode(targetNode, '', true, 0, maxDepth); + + console.log(''); + console.log(colors.muted('Navigate: skillkit tree "Category > Subcategory"')); + console.log(colors.muted('Generate: skillkit tree --generate')); + + return 0; + } + + private renderNode( + node: TreeNode, + prefix: string, + isLast: boolean, + depth: number, + maxDepth: number + ): void { + if (depth > maxDepth) return; + + const connector = depth === 0 ? '' : isLast ? '└── ' : '├── '; + const icon = node.children.length > 0 ? '📁 ' : '📄 '; + const skillInfo = + node.skillCount > 0 ? colors.muted(` (${node.skillCount})`) : ''; + + const nameColor = depth === 0 ? colors.bold : depth === 1 ? colors.accent : colors.dim; + + console.log(`${prefix}${connector}${icon}${nameColor(node.name)}${skillInfo}`); + + if (depth === maxDepth && node.skills.length > 0 && node.skills.length <= 5) { + const childPrefix = prefix + (isLast ? ' ' : '│ '); + for (const skill of node.skills) { + console.log(`${childPrefix} • ${colors.muted(skill)}`); + } + } + + const newPrefix = prefix + (depth === 0 ? '' : isLast ? ' ' : '│ '); + + if (depth < maxDepth) { + node.children.forEach((child, index) => { + const childIsLast = index === node.children.length - 1; + this.renderNode(child, newPrefix, childIsLast, depth + 1, maxDepth); + }); + } else if (node.children.length > 0) { + console.log(`${newPrefix} ${colors.muted(`... ${node.children.length} more subcategories`)}`); + } + } +} diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts new file mode 100644 index 00000000..de9eeaf3 --- /dev/null +++ b/packages/core/src/connectors/index.ts @@ -0,0 +1,22 @@ +export * from './types.js'; +export * from './utils.js'; + +export { + STANDARD_PLACEHOLDERS, + ConnectorCategorySchema, + ConnectorPlaceholderSchema, + ConnectorMappingSchema, + ConnectorConfigSchema, +} from './types.js'; + +export { + detectPlaceholders, + analyzePlaceholders, + replacePlaceholders, + applyConnectorConfig, + getPlaceholderInfo, + generateConnectorsMarkdown, + createConnectorConfig, + validateConnectorConfig, + suggestMappingsFromMcp, +} from './utils.js'; diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts new file mode 100644 index 00000000..8595b545 --- /dev/null +++ b/packages/core/src/connectors/types.ts @@ -0,0 +1,162 @@ +import { z } from 'zod'; + +export const ConnectorCategorySchema = z.enum([ + 'crm', + 'chat', + 'email', + 'calendar', + 'docs', + 'data', + 'search', + 'enrichment', + 'analytics', + 'storage', + 'notifications', + 'ai', + 'custom', +]); + +export type ConnectorCategory = z.infer; + +export const ConnectorPlaceholderSchema = z.object({ + placeholder: z.string(), + category: ConnectorCategorySchema, + description: z.string(), + examples: z.array(z.string()).default([]), + required: z.boolean().default(false), +}); + +export type ConnectorPlaceholder = z.infer; + +export const ConnectorMappingSchema = z.object({ + placeholder: z.string(), + tool: z.string(), + mcpServer: z.string().optional(), + config: z.record(z.unknown()).optional(), +}); + +export type ConnectorMapping = z.infer; + +export const ConnectorConfigSchema = z.object({ + version: z.number().default(1), + mappings: z.array(ConnectorMappingSchema), + description: z.string().optional(), +}); + +export type ConnectorConfig = z.infer; + +export const STANDARD_PLACEHOLDERS: Record = { + crm: { + placeholder: '~~CRM', + category: 'crm', + description: 'Customer Relationship Management system', + examples: ['Salesforce', 'HubSpot', 'Pipedrive', 'Zoho CRM'], + required: false, + }, + chat: { + placeholder: '~~chat', + category: 'chat', + description: 'Team communication and messaging platform', + examples: ['Slack', 'Microsoft Teams', 'Discord'], + required: false, + }, + email: { + placeholder: '~~email', + category: 'email', + description: 'Email service for sending and receiving messages', + examples: ['Gmail', 'Outlook', 'SendGrid'], + required: false, + }, + calendar: { + placeholder: '~~calendar', + category: 'calendar', + description: 'Calendar and scheduling system', + examples: ['Google Calendar', 'Outlook Calendar', 'Calendly'], + required: false, + }, + docs: { + placeholder: '~~docs', + category: 'docs', + description: 'Document management and collaboration', + examples: ['Google Docs', 'Notion', 'Confluence', 'Coda'], + required: false, + }, + data: { + placeholder: '~~data', + category: 'data', + description: 'Data warehouse or database', + examples: ['BigQuery', 'Snowflake', 'PostgreSQL', 'Databricks'], + required: false, + }, + search: { + placeholder: '~~search', + category: 'search', + description: 'Enterprise search across tools', + examples: ['Glean', 'Elastic', 'Algolia'], + required: false, + }, + enrichment: { + placeholder: '~~enrichment', + category: 'enrichment', + description: 'Data enrichment service', + examples: ['Clearbit', 'ZoomInfo', 'Apollo'], + required: false, + }, + analytics: { + placeholder: '~~analytics', + category: 'analytics', + description: 'Analytics and reporting platform', + examples: ['Mixpanel', 'Amplitude', 'Google Analytics'], + required: false, + }, + storage: { + placeholder: '~~storage', + category: 'storage', + description: 'File storage service', + examples: ['Google Drive', 'Dropbox', 'S3', 'Box'], + required: false, + }, + notifications: { + placeholder: '~~notifications', + category: 'notifications', + description: 'Notification and alerting service', + examples: ['PagerDuty', 'Opsgenie', 'SMS'], + required: false, + }, + ai: { + placeholder: '~~ai', + category: 'ai', + description: 'AI/ML model or service', + examples: ['OpenAI', 'Anthropic', 'Hugging Face'], + required: false, + }, + custom: { + placeholder: '~~custom', + category: 'custom', + description: 'Custom integration', + examples: [], + required: false, + }, +}; + +export interface PlaceholderMatch { + placeholder: string; + category: ConnectorCategory | null; + line: number; + column: number; + context: string; +} + +export interface PlaceholderReplacement { + original: string; + replacement: string; + category: ConnectorCategory | null; +} + +export interface ConnectorAnalysis { + placeholders: PlaceholderMatch[]; + categories: ConnectorCategory[]; + requiredCount: number; + optionalCount: number; + hasUnknownPlaceholders: boolean; +} diff --git a/packages/core/src/connectors/utils.ts b/packages/core/src/connectors/utils.ts new file mode 100644 index 00000000..70b7f0df --- /dev/null +++ b/packages/core/src/connectors/utils.ts @@ -0,0 +1,268 @@ +import { + type ConnectorCategory, + type ConnectorPlaceholder, + type ConnectorMapping, + type ConnectorConfig, + type PlaceholderMatch, + type PlaceholderReplacement, + type ConnectorAnalysis, + STANDARD_PLACEHOLDERS, + ConnectorCategorySchema, +} from './types.js'; + +const PLACEHOLDER_REGEX = /~~(\w+)/g; + +export function detectPlaceholders(content: string): PlaceholderMatch[] { + const matches: PlaceholderMatch[] = []; + const lines = content.split('\n'); + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]; + let match: RegExpExecArray | null; + + const regex = new RegExp(PLACEHOLDER_REGEX.source, 'g'); + while ((match = regex.exec(line)) !== null) { + const placeholder = match[0]; + const categoryName = match[1].toLowerCase(); + + let category: ConnectorCategory | null = null; + try { + category = ConnectorCategorySchema.parse(categoryName); + } catch { + category = null; + } + + const contextStart = Math.max(0, match.index - 20); + const contextEnd = Math.min(line.length, match.index + placeholder.length + 20); + const context = line.slice(contextStart, contextEnd); + + matches.push({ + placeholder, + category, + line: lineNum + 1, + column: match.index + 1, + context: context.trim(), + }); + } + } + + return matches; +} + +export function analyzePlaceholders(content: string): ConnectorAnalysis { + const matches = detectPlaceholders(content); + const categories = new Set(); + let requiredCount = 0; + let optionalCount = 0; + let hasUnknownPlaceholders = false; + + for (const match of matches) { + if (match.category) { + categories.add(match.category); + const standardPlaceholder = STANDARD_PLACEHOLDERS[match.category]; + if (standardPlaceholder?.required) { + requiredCount++; + } else { + optionalCount++; + } + } else { + hasUnknownPlaceholders = true; + } + } + + return { + placeholders: matches, + categories: Array.from(categories), + requiredCount, + optionalCount, + hasUnknownPlaceholders, + }; +} + +export function replacePlaceholders( + content: string, + mappings: ConnectorMapping[] +): { result: string; replacements: PlaceholderReplacement[] } { + const replacements: PlaceholderReplacement[] = []; + let result = content; + + for (const mapping of mappings) { + const regex = new RegExp(escapeRegExp(mapping.placeholder), 'g'); + const originalMatches = content.match(regex); + + if (originalMatches) { + result = result.replace(regex, mapping.tool); + + const categoryName = mapping.placeholder.replace('~~', '').toLowerCase(); + let category: ConnectorCategory | null = null; + try { + category = ConnectorCategorySchema.parse(categoryName); + } catch { + category = null; + } + + replacements.push({ + original: mapping.placeholder, + replacement: mapping.tool, + category, + }); + } + } + + return { result, replacements }; +} + +export function applyConnectorConfig( + content: string, + config: ConnectorConfig +): { result: string; replacements: PlaceholderReplacement[] } { + return replacePlaceholders(content, config.mappings); +} + +export function getPlaceholderInfo(placeholder: string): ConnectorPlaceholder | null { + const categoryName = placeholder.replace('~~', '').toLowerCase(); + + try { + const category = ConnectorCategorySchema.parse(categoryName); + return STANDARD_PLACEHOLDERS[category]; + } catch { + return null; + } +} + +export function generateConnectorsMarkdown(analysis: ConnectorAnalysis): string { + const lines: string[] = []; + + lines.push('# Connectors'); + lines.push(''); + lines.push('This skill uses the following connector placeholders. Replace them with your specific tools.'); + lines.push(''); + lines.push('| Placeholder | Category | Description | Examples |'); + lines.push('|-------------|----------|-------------|----------|'); + + for (const match of analysis.placeholders) { + if (match.category) { + const info = STANDARD_PLACEHOLDERS[match.category]; + lines.push( + `| \`${match.placeholder}\` | ${match.category} | ${info.description} | ${info.examples.slice(0, 3).join(', ')} |` + ); + } else { + lines.push( + `| \`${match.placeholder}\` | custom | Custom integration | - |` + ); + } + } + + lines.push(''); + lines.push('## How to Customize'); + lines.push(''); + lines.push('1. Find all placeholders: `grep -n "~~" SKILL.md`'); + lines.push('2. Replace each `~~category` with your specific tool name'); + lines.push('3. Configure MCP server in `.mcp.json` if needed'); + lines.push(''); + + return lines.join('\n'); +} + +export function createConnectorConfig( + mappings: Array<{ placeholder: string; tool: string; mcpServer?: string }> +): ConnectorConfig { + return { + version: 1, + mappings: mappings.map((m) => ({ + placeholder: m.placeholder, + tool: m.tool, + mcpServer: m.mcpServer, + })), + }; +} + +export function validateConnectorConfig( + config: ConnectorConfig, + content: string +): { valid: boolean; errors: string[]; warnings: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + const analysis = analyzePlaceholders(content); + const placeholdersInContent = new Set(analysis.placeholders.map((p) => p.placeholder)); + const mappedPlaceholders = new Set(config.mappings.map((m) => m.placeholder)); + + for (const placeholder of placeholdersInContent) { + if (!mappedPlaceholders.has(placeholder)) { + const info = getPlaceholderInfo(placeholder); + if (info?.required) { + errors.push(`Required placeholder ${placeholder} is not mapped`); + } else { + warnings.push(`Optional placeholder ${placeholder} is not mapped`); + } + } + } + + for (const mapping of config.mappings) { + if (!placeholdersInContent.has(mapping.placeholder)) { + warnings.push(`Mapping for ${mapping.placeholder} exists but placeholder not found in content`); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +export function suggestMappingsFromMcp( + mcpServers: string[] +): ConnectorMapping[] { + const suggestions: ConnectorMapping[] = []; + const serverLower = mcpServers.map((s) => s.toLowerCase()); + + const serverToCategory: Record = { + slack: [{ category: 'chat', tool: 'Slack' }], + teams: [{ category: 'chat', tool: 'Microsoft Teams' }], + discord: [{ category: 'chat', tool: 'Discord' }], + gmail: [{ category: 'email', tool: 'Gmail' }], + outlook: [{ category: 'email', tool: 'Outlook' }], + sendgrid: [{ category: 'email', tool: 'SendGrid' }], + salesforce: [{ category: 'crm', tool: 'Salesforce' }], + hubspot: [{ category: 'crm', tool: 'HubSpot' }], + pipedrive: [{ category: 'crm', tool: 'Pipedrive' }], + notion: [{ category: 'docs', tool: 'Notion' }], + confluence: [{ category: 'docs', tool: 'Confluence' }], + google: [ + { category: 'docs', tool: 'Google Docs' }, + { category: 'calendar', tool: 'Google Calendar' }, + { category: 'storage', tool: 'Google Drive' }, + ], + bigquery: [{ category: 'data', tool: 'BigQuery' }], + snowflake: [{ category: 'data', tool: 'Snowflake' }], + postgres: [{ category: 'data', tool: 'PostgreSQL' }], + supabase: [{ category: 'data', tool: 'Supabase' }], + clearbit: [{ category: 'enrichment', tool: 'Clearbit' }], + zoominfo: [{ category: 'enrichment', tool: 'ZoomInfo' }], + apollo: [{ category: 'enrichment', tool: 'Apollo' }], + openai: [{ category: 'ai', tool: 'OpenAI' }], + anthropic: [{ category: 'ai', tool: 'Anthropic Claude' }], + }; + + for (const server of serverLower) { + for (const [keyword, mappings] of Object.entries(serverToCategory)) { + if (server.includes(keyword)) { + for (const mapping of mappings) { + suggestions.push({ + placeholder: STANDARD_PLACEHOLDERS[mapping.category].placeholder, + tool: mapping.tool, + mcpServer: server, + }); + } + } + } + } + + return suggestions; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/core/src/execution/index.ts b/packages/core/src/execution/index.ts new file mode 100644 index 00000000..bc4381e6 --- /dev/null +++ b/packages/core/src/execution/index.ts @@ -0,0 +1,23 @@ +export * from './types.js'; +export * from './manager.js'; +export * from './mode.js'; + +export { + ExecutionManager, + createExecutionManager, +} from './manager.js'; + +export { + ExecutionStepStatusSchema, + ExecutionStepSchema, + ExecutionFlowSchema, +} from './types.js'; + +export { + detectExecutionMode, + getModeDescription, + requireEnhancedMode, + requireCapability, + getStandaloneAlternative, + createModeAwareExecutor, +} from './mode.js'; diff --git a/packages/core/src/execution/manager.ts b/packages/core/src/execution/manager.ts new file mode 100644 index 00000000..3583f21e --- /dev/null +++ b/packages/core/src/execution/manager.ts @@ -0,0 +1,306 @@ +import { randomUUID } from 'node:crypto'; +import { + type ExecutionFlow, + type ExecutionStep, + type ExecutionFlowConfig, + type StepDefinition, + type ExecutionContext, + type FlowSummary, + type FlowMetrics, + ExecutionFlowSchema, +} from './types.js'; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; +const DEFAULT_TIMEOUT = 30000; + +export class ExecutionManager { + private flows: Map = new Map(); + private stepDefinitions: Map> = new Map(); + private config: ExecutionFlowConfig; + private metrics: FlowMetrics; + + constructor(config: ExecutionFlowConfig = {}) { + this.config = config; + this.metrics = { + totalFlows: 0, + completedFlows: 0, + failedFlows: 0, + averageDuration: 0, + stepMetrics: new Map(), + }; + } + + createFlow( + skillName: string, + steps: Array<{ name: string; description?: string }>, + options: { + mode?: 'standalone' | 'enhanced'; + mcpServers?: string[]; + context?: Record; + } = {} + ): ExecutionFlow { + const flowId = randomUUID(); + const executionSteps: ExecutionStep[] = steps.map((step, index) => ({ + id: `${flowId}-step-${index}`, + name: step.name, + description: step.description, + status: 'pending', + retryCount: 0, + })); + + const flow: ExecutionFlow = ExecutionFlowSchema.parse({ + id: flowId, + skillName, + version: 1, + steps: executionSteps, + currentStepIndex: -1, + status: 'pending', + context: options.context, + mode: options.mode || 'standalone', + mcpServers: options.mcpServers, + }); + + this.flows.set(flowId, flow); + this.metrics.totalFlows++; + + return flow; + } + + registerStepDefinitions(skillName: string, definitions: StepDefinition[]): void { + const skillSteps = new Map(); + for (const def of definitions) { + skillSteps.set(def.name, def); + } + this.stepDefinitions.set(skillName, skillSteps); + } + + async executeFlow(flowId: string): Promise { + const flow = this.flows.get(flowId); + if (!flow) { + throw new Error(`Flow ${flowId} not found`); + } + + flow.status = 'running'; + flow.startedAt = new Date().toISOString(); + + const skillSteps = this.stepDefinitions.get(flow.skillName); + + try { + let previousOutput: Record = {}; + + for (let i = 0; i < flow.steps.length; i++) { + const step = flow.steps[i]; + flow.currentStepIndex = i; + + const context: ExecutionContext = { + flow, + stepIndex: i, + previousOutput, + mcpTools: flow.mcpServers, + isEnhanced: flow.mode === 'enhanced', + }; + + const stepDef = skillSteps?.get(step.name); + + if (stepDef?.condition && !stepDef.condition(context)) { + step.status = 'skipped'; + continue; + } + + const result = await this.executeStep(step, stepDef, context); + + if (result.status === 'completed' && result.output) { + previousOutput = { ...previousOutput, ...result.output }; + } + + if (result.status === 'failed') { + flow.status = 'failed'; + flow.completedAt = new Date().toISOString(); + flow.totalDuration = this.calculateDuration(flow.startedAt, flow.completedAt); + this.metrics.failedFlows++; + this.config.onFlowComplete?.(flow); + return flow; + } + } + + flow.status = 'completed'; + flow.completedAt = new Date().toISOString(); + flow.totalDuration = this.calculateDuration(flow.startedAt, flow.completedAt); + this.metrics.completedFlows++; + this.updateAverageDuration(flow.totalDuration); + this.config.onFlowComplete?.(flow); + + return flow; + } catch (error) { + flow.status = 'failed'; + flow.completedAt = new Date().toISOString(); + flow.totalDuration = this.calculateDuration(flow.startedAt, flow.completedAt); + this.metrics.failedFlows++; + throw error; + } + } + + private async executeStep( + step: ExecutionStep, + stepDef: StepDefinition | undefined, + context: ExecutionContext + ): Promise { + const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES; + const retryDelay = this.config.retryDelay ?? DEFAULT_RETRY_DELAY; + const timeout = stepDef?.timeout ?? this.config.timeout ?? DEFAULT_TIMEOUT; + + step.status = 'running'; + step.startedAt = new Date().toISOString(); + this.config.onStepStart?.(step, context.flow); + + let lastError: Error | null = null; + + while (step.retryCount <= maxRetries) { + try { + if (stepDef) { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Step ${step.name} timed out`)), timeout); + }); + + const executePromise = stepDef.execute(step.input || {}, context); + step.output = await Promise.race([executePromise, timeoutPromise]); + } + + step.status = 'completed'; + step.completedAt = new Date().toISOString(); + step.duration = this.calculateDuration(step.startedAt, step.completedAt); + this.config.onStepComplete?.(step, context.flow); + this.updateStepMetrics(step.name, true, step.duration); + + return step; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + step.retryCount++; + + if (stepDef?.retryable !== false && step.retryCount <= maxRetries) { + await this.delay(retryDelay * step.retryCount); + } else { + break; + } + } + } + + step.status = 'failed'; + step.error = lastError?.message || 'Unknown error'; + step.completedAt = new Date().toISOString(); + step.duration = this.calculateDuration(step.startedAt, step.completedAt); + this.config.onStepError?.(step, lastError || new Error('Unknown error'), context.flow); + this.updateStepMetrics(step.name, false, step.duration); + + return step; + } + + getFlow(flowId: string): ExecutionFlow | undefined { + return this.flows.get(flowId); + } + + getFlowSummary(flowId: string): FlowSummary | null { + const flow = this.flows.get(flowId); + if (!flow) return null; + + const completedSteps = flow.steps.filter( + (s) => s.status === 'completed' || s.status === 'skipped' + ).length; + + const currentStep = + flow.currentStepIndex >= 0 ? flow.steps[flow.currentStepIndex]?.name : undefined; + + return { + id: flow.id, + skillName: flow.skillName, + status: flow.status, + progress: flow.steps.length > 0 ? (completedSteps / flow.steps.length) * 100 : 0, + currentStep, + startedAt: flow.startedAt, + completedAt: flow.completedAt, + totalDuration: flow.totalDuration, + stepsCompleted: completedSteps, + totalSteps: flow.steps.length, + mode: flow.mode, + }; + } + + getAllFlowSummaries(): FlowSummary[] { + return [...this.flows.keys()] + .map((id) => this.getFlowSummary(id)) + .filter((s): s is FlowSummary => s !== null); + } + + getMetrics(): FlowMetrics { + return { ...this.metrics }; + } + + cancelFlow(flowId: string): boolean { + const flow = this.flows.get(flowId); + if (!flow || flow.status !== 'running') return false; + + flow.status = 'failed'; + flow.completedAt = new Date().toISOString(); + + const currentStep = flow.steps[flow.currentStepIndex]; + if (currentStep && currentStep.status === 'running') { + currentStep.status = 'failed'; + currentStep.error = 'Cancelled'; + currentStep.completedAt = new Date().toISOString(); + } + + return true; + } + + clearCompletedFlows(): number { + let cleared = 0; + for (const [id, flow] of this.flows) { + if (flow.status === 'completed' || flow.status === 'failed') { + this.flows.delete(id); + cleared++; + } + } + return cleared; + } + + private calculateDuration(start?: string, end?: string): number { + if (!start || !end) return 0; + return new Date(end).getTime() - new Date(start).getTime(); + } + + private updateAverageDuration(duration: number): void { + const completedCount = this.metrics.completedFlows; + this.metrics.averageDuration = + (this.metrics.averageDuration * (completedCount - 1) + duration) / completedCount; + } + + private updateStepMetrics(stepName: string, success: boolean, duration: number): void { + const existing = this.metrics.stepMetrics.get(stepName) || { + executionCount: 0, + successCount: 0, + failureCount: 0, + averageDuration: 0, + }; + + existing.executionCount++; + if (success) { + existing.successCount++; + } else { + existing.failureCount++; + } + existing.averageDuration = + (existing.averageDuration * (existing.executionCount - 1) + duration) / + existing.executionCount; + + this.metrics.stepMetrics.set(stepName, existing); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export function createExecutionManager(config?: ExecutionFlowConfig): ExecutionManager { + return new ExecutionManager(config); +} diff --git a/packages/core/src/execution/mode.ts b/packages/core/src/execution/mode.ts new file mode 100644 index 00000000..0d6632af --- /dev/null +++ b/packages/core/src/execution/mode.ts @@ -0,0 +1,256 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ConnectorMapping } from '../connectors/types.js'; +import { suggestMappingsFromMcp } from '../connectors/utils.js'; + +export type ExecutionMode = 'standalone' | 'enhanced'; + +export interface ModeDetectionResult { + mode: ExecutionMode; + availableServers: string[]; + missingServers: string[]; + capabilities: ModeCapabilities; + connectorMappings: ConnectorMapping[]; +} + +export interface ModeCapabilities { + canAccessCRM: boolean; + canSendEmails: boolean; + canAccessCalendar: boolean; + canAccessDocs: boolean; + canQueryData: boolean; + canEnrichData: boolean; + canSendNotifications: boolean; + canUseAI: boolean; + hasChat: boolean; + hasStorage: boolean; + hasSearch: boolean; + hasAnalytics: boolean; +} + +export interface ExecutionModeConfig { + requiredServers?: string[]; + optionalServers?: string[]; + mcpConfigPaths?: string[]; + fallbackToStandalone?: boolean; +} + +const DEFAULT_MCP_CONFIG_PATHS = [ + join(process.env.HOME || '~', '.mcp.json'), + join(process.env.HOME || '~', '.config', 'claude', 'mcp.json'), + '.mcp.json', + 'mcp.json', +]; + +const CAPABILITY_SERVERS: Record = { + canAccessCRM: ['salesforce', 'hubspot', 'pipedrive', 'zoho'], + canSendEmails: ['gmail', 'outlook', 'sendgrid', 'mailgun', 'ses'], + canAccessCalendar: ['google-calendar', 'outlook', 'calendly'], + canAccessDocs: ['notion', 'confluence', 'google-docs', 'coda'], + canQueryData: ['postgres', 'mysql', 'bigquery', 'snowflake', 'supabase', 'mongodb'], + canEnrichData: ['clearbit', 'zoominfo', 'apollo', 'hunter'], + canSendNotifications: ['slack', 'teams', 'discord', 'pagerduty', 'opsgenie'], + canUseAI: ['openai', 'anthropic', 'ollama', 'huggingface'], + hasChat: ['slack', 'teams', 'discord'], + hasStorage: ['s3', 'gcs', 'dropbox', 'drive', 'box'], + hasSearch: ['glean', 'elastic', 'algolia', 'typesense'], + hasAnalytics: ['mixpanel', 'amplitude', 'posthog', 'segment'], +}; + +export function detectExecutionMode(config: ExecutionModeConfig = {}): ModeDetectionResult { + const { requiredServers = [], optionalServers = [], mcpConfigPaths, fallbackToStandalone = true } = config; + + const configPaths = mcpConfigPaths || DEFAULT_MCP_CONFIG_PATHS; + const availableServers = detectMcpServers(configPaths); + + const missingRequired = requiredServers.filter( + (server) => !availableServers.some((s) => s.toLowerCase().includes(server.toLowerCase())) + ); + + const capabilities = detectCapabilities(availableServers); + const connectorMappings = suggestMappingsFromMcp(availableServers); + + let mode: ExecutionMode = 'standalone'; + if (missingRequired.length === 0 && availableServers.length > 0) { + mode = 'enhanced'; + } else if (missingRequired.length > 0 && !fallbackToStandalone) { + throw new Error(`Missing required MCP servers: ${missingRequired.join(', ')}`); + } + + const missingOptional = optionalServers.filter( + (server) => !availableServers.some((s) => s.toLowerCase().includes(server.toLowerCase())) + ); + + return { + mode, + availableServers, + missingServers: [...missingRequired, ...missingOptional], + capabilities, + connectorMappings, + }; +} + +function detectMcpServers(configPaths: string[]): string[] { + const servers: string[] = []; + + for (const configPath of configPaths) { + try { + const fullPath = configPath.startsWith('~') + ? configPath.replace('~', process.env.HOME || '') + : configPath; + + if (existsSync(fullPath)) { + const content = readFileSync(fullPath, 'utf-8'); + const config = JSON.parse(content); + + if (config.mcpServers) { + servers.push(...Object.keys(config.mcpServers)); + } + + if (config.servers) { + servers.push(...Object.keys(config.servers)); + } + } + } catch { + continue; + } + } + + return [...new Set(servers)]; +} + +function detectCapabilities(servers: string[]): ModeCapabilities { + const serverLower = servers.map((s) => s.toLowerCase()); + + const capabilities: ModeCapabilities = { + canAccessCRM: false, + canSendEmails: false, + canAccessCalendar: false, + canAccessDocs: false, + canQueryData: false, + canEnrichData: false, + canSendNotifications: false, + canUseAI: false, + hasChat: false, + hasStorage: false, + hasSearch: false, + hasAnalytics: false, + }; + + for (const [capability, keywords] of Object.entries(CAPABILITY_SERVERS)) { + for (const keyword of keywords) { + if (serverLower.some((server) => server.includes(keyword))) { + capabilities[capability as keyof ModeCapabilities] = true; + break; + } + } + } + + return capabilities; +} + +export function getModeDescription(result: ModeDetectionResult): string { + const lines: string[] = []; + + lines.push(`Mode: ${result.mode.toUpperCase()}`); + lines.push(''); + + if (result.availableServers.length > 0) { + lines.push(`Available MCP Servers (${result.availableServers.length}):`); + for (const server of result.availableServers.slice(0, 10)) { + lines.push(` - ${server}`); + } + if (result.availableServers.length > 10) { + lines.push(` ... and ${result.availableServers.length - 10} more`); + } + lines.push(''); + } + + if (result.missingServers.length > 0) { + lines.push(`Missing Servers:`); + for (const server of result.missingServers) { + lines.push(` - ${server}`); + } + lines.push(''); + } + + lines.push('Capabilities:'); + const capabilityLabels: Record = { + canAccessCRM: 'CRM Access', + canSendEmails: 'Email', + canAccessCalendar: 'Calendar', + canAccessDocs: 'Documents', + canQueryData: 'Data Queries', + canEnrichData: 'Data Enrichment', + canSendNotifications: 'Notifications', + canUseAI: 'AI Services', + hasChat: 'Chat', + hasStorage: 'Storage', + hasSearch: 'Search', + hasAnalytics: 'Analytics', + }; + + for (const [key, label] of Object.entries(capabilityLabels)) { + const enabled = result.capabilities[key as keyof ModeCapabilities]; + lines.push(` ${enabled ? '✓' : '✗'} ${label}`); + } + + if (result.connectorMappings.length > 0) { + lines.push(''); + lines.push('Suggested Connector Mappings:'); + for (const mapping of result.connectorMappings.slice(0, 5)) { + lines.push(` ${mapping.placeholder} → ${mapping.tool}`); + } + } + + return lines.join('\n'); +} + +export function requireEnhancedMode(result: ModeDetectionResult): void { + if (result.mode !== 'enhanced') { + throw new Error( + `This skill requires enhanced mode with MCP servers. Missing: ${result.missingServers.join(', ')}` + ); + } +} + +export function requireCapability( + result: ModeDetectionResult, + capability: keyof ModeCapabilities +): void { + if (!result.capabilities[capability]) { + throw new Error(`This skill requires the ${capability} capability which is not available.`); + } +} + +export function getStandaloneAlternative(capability: keyof ModeCapabilities): string { + const alternatives: Record = { + canAccessCRM: 'Use a local JSON file to store contact information', + canSendEmails: 'Output email content to the terminal for manual sending', + canAccessCalendar: 'Use a local iCal file for scheduling', + canAccessDocs: 'Use local markdown files for documentation', + canQueryData: 'Use a local SQLite database or JSON files', + canEnrichData: 'Skip enrichment or use cached data', + canSendNotifications: 'Output notifications to the terminal', + canUseAI: 'Use simpler rule-based logic or skip AI features', + hasChat: 'Output messages to the terminal', + hasStorage: 'Use the local filesystem', + hasSearch: 'Use grep or local file search', + hasAnalytics: 'Skip analytics or use local logging', + }; + + return alternatives[capability]; +} + +export function createModeAwareExecutor( + enhancedFn: () => Promise, + standaloneFn: () => Promise, + modeResult: ModeDetectionResult +): () => Promise { + return async () => { + if (modeResult.mode === 'enhanced') { + return enhancedFn(); + } + return standaloneFn(); + }; +} diff --git a/packages/core/src/execution/types.ts b/packages/core/src/execution/types.ts new file mode 100644 index 00000000..0400d907 --- /dev/null +++ b/packages/core/src/execution/types.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; + +export const ExecutionStepStatusSchema = z.enum([ + 'pending', + 'running', + 'completed', + 'failed', + 'skipped', +]); + +export type ExecutionStepStatus = z.infer; + +export const ExecutionStepSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + status: ExecutionStepStatusSchema.default('pending'), + startedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + duration: z.number().optional(), + input: z.record(z.unknown()).optional(), + output: z.record(z.unknown()).optional(), + error: z.string().optional(), + retryCount: z.number().default(0), + metadata: z.record(z.unknown()).optional(), +}); + +export type ExecutionStep = z.infer; + +export const ExecutionFlowSchema = z.object({ + id: z.string(), + skillName: z.string(), + version: z.number().default(1), + steps: z.array(ExecutionStepSchema), + currentStepIndex: z.number().default(-1), + status: ExecutionStepStatusSchema.default('pending'), + startedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + totalDuration: z.number().optional(), + context: z.record(z.unknown()).optional(), + mode: z.enum(['standalone', 'enhanced']).default('standalone'), + mcpServers: z.array(z.string()).optional(), +}); + +export type ExecutionFlow = z.infer; + +export interface ExecutionFlowConfig { + maxRetries?: number; + retryDelay?: number; + timeout?: number; + onStepStart?: (step: ExecutionStep, flow: ExecutionFlow) => void; + onStepComplete?: (step: ExecutionStep, flow: ExecutionFlow) => void; + onStepError?: (step: ExecutionStep, error: Error, flow: ExecutionFlow) => void; + onFlowComplete?: (flow: ExecutionFlow) => void; +} + +export interface StepDefinition { + name: string; + description?: string; + execute: ( + input: Record, + context: ExecutionContext + ) => Promise>; + rollback?: (context: ExecutionContext) => Promise; + condition?: (context: ExecutionContext) => boolean; + retryable?: boolean; + timeout?: number; +} + +export interface ExecutionContext { + flow: ExecutionFlow; + stepIndex: number; + previousOutput?: Record; + mcpTools?: string[]; + isEnhanced: boolean; +} + +export interface FlowSummary { + id: string; + skillName: string; + status: ExecutionStepStatus; + progress: number; + currentStep?: string; + startedAt?: string; + completedAt?: string; + totalDuration?: number; + stepsCompleted: number; + totalSteps: number; + mode: 'standalone' | 'enhanced'; +} + +export interface FlowMetrics { + totalFlows: number; + completedFlows: number; + failedFlows: number; + averageDuration: number; + stepMetrics: Map< + string, + { + executionCount: number; + successCount: number; + failureCount: number; + averageDuration: number; + } + >; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 144eef17..398dc01e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,3 +89,15 @@ export * from './profiles/index.js'; // Coding Guidelines System export * from './guidelines/index.js'; + +// Skill Tree (Hierarchical Taxonomy - Phase 21) +export * from './tree/index.js'; + +// Reasoning Engine (LLM-based Tree Search - Phase 21) +export * from './reasoning/index.js'; + +// Connectors (Tool-Agnostic Placeholders - Phase 21) +export * from './connectors/index.js'; + +// Execution Flow (Step Tracking & Metrics - Phase 21) +export * from './execution/index.js'; diff --git a/packages/core/src/reasoning/engine.ts b/packages/core/src/reasoning/engine.ts new file mode 100644 index 00000000..91da4e72 --- /dev/null +++ b/packages/core/src/reasoning/engine.ts @@ -0,0 +1,614 @@ +import type { SkillSummary, ProjectProfile } from '../recommend/types.js'; +import type { SkillTree, TreeNode } from '../tree/types.js'; +import { TreeGenerator } from '../tree/generator.js'; +import { + type ReasoningConfig, + type TreeSearchQuery, + type TreeSearchResult, + type TreeReasoningResult, + type ExplainedRecommendation, + type ExplainedMatch, + type SearchPlan, + type ReasoningCacheEntry, + type ReasoningEngineStats, + DEFAULT_REASONING_CONFIG, +} from './types.js'; +import { + buildSearchPlanPrompt, + buildExplanationPrompt, + extractJsonFromResponse, + validateSearchPlan, +} from './prompts.js'; +import { CATEGORY_TAXONOMY } from '../tree/types.js'; + +const CACHE_TTL_MS = 5 * 60 * 1000; +const MAX_CACHE_SIZE = 100; + +export class ReasoningEngine { + private config: ReasoningConfig; + private skillMap: Map = new Map(); + private tree: SkillTree | null = null; + private cache: Map = new Map(); + private stats: ReasoningEngineStats = { + totalQueries: 0, + cacheHits: 0, + cacheMisses: 0, + averageProcessingTimeMs: 0, + averageResultsPerQuery: 0, + }; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_REASONING_CONFIG, ...config }; + } + + loadSkills(skills: SkillSummary[]): void { + this.skillMap.clear(); + for (const skill of skills) { + this.skillMap.set(skill.name, skill); + } + } + + loadTree(tree: SkillTree): void { + this.tree = tree; + } + + generateTree(skills: SkillSummary[]): void { + const generator = new TreeGenerator(); + this.tree = generator.generateTree(skills); + this.loadSkills(skills); + } + + async search(query: TreeSearchQuery): Promise { + const startTime = Date.now(); + this.stats.totalQueries++; + + const cacheKey = this.getCacheKey(query); + const cached = this.getFromCache(cacheKey); + if (cached) { + this.stats.cacheHits++; + return { + query: query.query, + results: cached.results, + exploredPaths: [], + reasoning: 'Retrieved from cache', + totalNodesVisited: 0, + processingTimeMs: Date.now() - startTime, + }; + } + + this.stats.cacheMisses++; + + const maxResults = query.maxResults ?? 10; + const minConfidence = query.minConfidence ?? 30; + + const plan = await this.createSearchPlan(query); + + const results: TreeSearchResult[] = []; + const exploredPaths: string[][] = []; + let totalNodesVisited = 0; + + if (this.tree) { + const traversalResult = await this.traverseTree(query, plan); + results.push(...traversalResult.results); + exploredPaths.push(...traversalResult.exploredPaths); + totalNodesVisited = traversalResult.nodesVisited; + } else { + const fallbackResults = this.fallbackSearch(query); + results.push(...fallbackResults); + } + + const filteredResults = results + .filter((r) => r.confidence >= minConfidence) + .sort((a, b) => b.confidence - a.confidence) + .slice(0, maxResults); + + const processingTimeMs = Date.now() - startTime; + + this.updateStats(processingTimeMs, filteredResults.length); + + this.addToCache(cacheKey, filteredResults); + + return { + query: query.query, + results: filteredResults, + exploredPaths, + reasoning: this.buildReasoningSummary(plan, filteredResults), + totalNodesVisited, + processingTimeMs, + }; + } + + async explain( + skill: SkillSummary, + score: number, + profile: ProjectProfile + ): Promise { + const treePath = this.getSkillPath(skill.name); + + const explanation = await this.generateExplanation(skill, score, profile); + + return { + skill, + score, + reasoning: explanation, + treePath, + }; + } + + async explainBatch( + skills: Array<{ skill: SkillSummary; score: number }>, + profile: ProjectProfile + ): Promise { + const results: ExplainedRecommendation[] = []; + + for (const { skill, score } of skills) { + const explained = await this.explain(skill, score, profile); + results.push(explained); + } + + return results; + } + + private async createSearchPlan(query: TreeSearchQuery): Promise { + const categories = CATEGORY_TAXONOMY.map((c) => c.category); + + if (this.config.provider === 'mock') { + return this.mockSearchPlan(query.query, query.context); + } + + const prompt = buildSearchPlanPrompt( + query.query, + categories, + query.context + ); + + const response = await this.callLLM(prompt); + + try { + const data = extractJsonFromResponse(response); + return validateSearchPlan(data); + } catch { + return this.mockSearchPlan(query.query, query.context); + } + } + + private mockSearchPlan(query: string, context?: ProjectProfile): SearchPlan { + const queryLower = query.toLowerCase(); + const keywords = queryLower + .split(/\s+/) + .filter((w) => w.length > 2); + + const categoryScores: { category: string; score: number }[] = []; + + for (const mapping of CATEGORY_TAXONOMY) { + let score = 0; + + for (const keyword of keywords) { + if (mapping.category.toLowerCase().includes(keyword)) { + score += 30; + } + + for (const tag of mapping.tags) { + if (tag.includes(keyword) || keyword.includes(tag)) { + score += 20; + } + } + + for (const kw of mapping.keywords) { + if (kw.includes(keyword) || keyword.includes(kw)) { + score += 15; + } + } + } + + if (context) { + for (const framework of context.stack.frameworks) { + if (mapping.tags.includes(framework.name.toLowerCase())) { + score += 10; + } + } + + for (const language of context.stack.languages) { + if (mapping.tags.includes(language.name.toLowerCase())) { + score += 5; + } + } + } + + if (score > 0) { + categoryScores.push({ category: mapping.category, score }); + } + } + + categoryScores.sort((a, b) => b.score - a.score); + + const primaryCategories = categoryScores + .slice(0, 3) + .map((c) => c.category); + const secondaryCategories = categoryScores + .slice(3, 6) + .map((c) => c.category); + + const filters: SearchPlan['filters'] = { + tags: keywords.filter((k) => + CATEGORY_TAXONOMY.some((m) => m.tags.includes(k)) + ), + frameworks: context?.stack.frameworks.map((f) => f.name) || [], + languages: context?.stack.languages.map((l) => l.name) || [], + }; + + return { + primaryCategories: primaryCategories.length > 0 ? primaryCategories : ['Development'], + secondaryCategories, + keywords, + filters, + strategy: keywords.length <= 2 ? 'breadth-first' : 'targeted', + }; + } + + private async traverseTree( + query: TreeSearchQuery, + plan: SearchPlan + ): Promise<{ + results: TreeSearchResult[]; + exploredPaths: string[][]; + nodesVisited: number; + }> { + if (!this.tree) { + return { results: [], exploredPaths: [], nodesVisited: 0 }; + } + + const results: TreeSearchResult[] = []; + const exploredPaths: string[][] = []; + let nodesVisited = 0; + + const relevantNodes = this.findRelevantNodes(plan); + + for (const { node, path } of relevantNodes) { + nodesVisited++; + exploredPaths.push(path); + + for (const skillName of node.skills) { + const skill = this.skillMap.get(skillName); + if (!skill) continue; + + const matchResult = this.evaluateSkillMatch(query.query, skill, query.context); + + if (matchResult.confidence >= (query.minConfidence ?? 30)) { + results.push({ + skill, + path, + reasoning: matchResult.reasoning, + confidence: matchResult.confidence, + relevantSections: matchResult.relevantSections, + matchedKeywords: matchResult.matchedKeywords, + }); + } + } + } + + return { results, exploredPaths, nodesVisited }; + } + + private findRelevantNodes( + plan: SearchPlan + ): Array<{ node: TreeNode; path: string[] }> { + if (!this.tree) return []; + + const relevant: Array<{ node: TreeNode; path: string[]; priority: number }> = []; + + const traverse = (node: TreeNode, path: string[], priorityBoost: number) => { + const nodeName = node.name.toLowerCase(); + + let priority = priorityBoost; + + if (plan.primaryCategories.some((c) => c.toLowerCase() === nodeName)) { + priority += 100; + } + + if (plan.secondaryCategories.some((c) => c.toLowerCase() === nodeName)) { + priority += 50; + } + + for (const keyword of plan.keywords) { + if (nodeName.includes(keyword) || keyword.includes(nodeName)) { + priority += 25; + } + } + + if (priority > 0 || node.depth === 0) { + relevant.push({ node, path: [...path, node.name], priority }); + } + + for (const child of node.children) { + traverse(child, [...path, node.name], priority > 0 ? priority * 0.5 : 0); + } + }; + + traverse(this.tree.rootNode, [], 0); + + relevant.sort((a, b) => b.priority - a.priority); + + return relevant.slice(0, 20).map(({ node, path }) => ({ node, path })); + } + + private evaluateSkillMatch( + query: string, + skill: SkillSummary, + context?: ProjectProfile + ): { + confidence: number; + reasoning: string; + relevantSections: string[]; + matchedKeywords: string[]; + } { + const queryTerms = query.toLowerCase().split(/\s+/); + const matchedKeywords: string[] = []; + let confidence = 0; + + const nameLower = skill.name.toLowerCase(); + for (const term of queryTerms) { + if (nameLower.includes(term)) { + confidence += 30; + matchedKeywords.push(term); + } + } + + const descLower = (skill.description || '').toLowerCase(); + for (const term of queryTerms) { + if (descLower.includes(term) && !matchedKeywords.includes(term)) { + confidence += 20; + matchedKeywords.push(term); + } + } + + const tagsLower = (skill.tags || []).map((t) => t.toLowerCase()); + for (const term of queryTerms) { + if (tagsLower.some((t) => t.includes(term) || term.includes(t))) { + if (!matchedKeywords.includes(term)) { + confidence += 25; + matchedKeywords.push(term); + } + } + } + + if (context) { + for (const framework of context.stack.frameworks) { + const fwLower = framework.name.toLowerCase(); + if (tagsLower.includes(fwLower) || nameLower.includes(fwLower)) { + confidence += 15; + } + } + + for (const language of context.stack.languages) { + const langLower = language.name.toLowerCase(); + if (tagsLower.includes(langLower) || nameLower.includes(langLower)) { + confidence += 10; + } + } + } + + confidence = Math.min(100, confidence); + + const relevantSections: string[] = []; + if (matchedKeywords.length > 0) { + relevantSections.push('description', 'tags'); + } + + const reasoning = matchedKeywords.length > 0 + ? `Matches keywords: ${matchedKeywords.join(', ')}` + : 'No direct keyword match'; + + return { + confidence, + reasoning, + relevantSections, + matchedKeywords, + }; + } + + private fallbackSearch(query: TreeSearchQuery): TreeSearchResult[] { + const results: TreeSearchResult[] = []; + + for (const skill of this.skillMap.values()) { + const matchResult = this.evaluateSkillMatch( + query.query, + skill, + query.context + ); + + if (matchResult.confidence >= (query.minConfidence ?? 30)) { + results.push({ + skill, + path: ['Uncategorized'], + reasoning: matchResult.reasoning, + confidence: matchResult.confidence, + relevantSections: matchResult.relevantSections, + matchedKeywords: matchResult.matchedKeywords, + }); + } + } + + return results; + } + + private async generateExplanation( + skill: SkillSummary, + score: number, + profile: ProjectProfile + ): Promise { + if (this.config.provider === 'mock') { + return this.mockExplanation(skill, score, profile); + } + + const prompt = buildExplanationPrompt(skill, score, profile); + + try { + const response = await this.callLLM(prompt); + const data = extractJsonFromResponse(response) as Partial; + + return { + matchedBecause: data.matchedBecause || [], + relevantFor: data.relevantFor || [], + differentFrom: data.differentFrom || [], + confidence: data.confidence || this.scoreToConfidence(score), + }; + } catch { + return this.mockExplanation(skill, score, profile); + } + } + + private mockExplanation( + skill: SkillSummary, + score: number, + profile: ProjectProfile + ): ExplainedMatch { + const matchedBecause: string[] = []; + const relevantFor: string[] = []; + const differentFrom: string[] = []; + + const skillTags = skill.tags || []; + + for (const framework of profile.stack.frameworks) { + const fwLower = framework.name.toLowerCase(); + if ( + skillTags.some((t) => t.toLowerCase().includes(fwLower)) || + skill.name.toLowerCase().includes(fwLower) + ) { + matchedBecause.push(`Uses ${framework.name}`); + } + } + + for (const language of profile.stack.languages) { + const langLower = language.name.toLowerCase(); + if (skillTags.some((t) => t.toLowerCase().includes(langLower))) { + matchedBecause.push(`Supports ${language.name}`); + } + } + + if (matchedBecause.length === 0) { + matchedBecause.push(`Related tags: ${skillTags.slice(0, 3).join(', ')}`); + } + + relevantFor.push(`Your ${profile.type || 'project'} project`); + if (profile.name) { + relevantFor.push(`Specifically for ${profile.name}`); + } + + differentFrom.push('General-purpose alternatives'); + + return { + matchedBecause, + relevantFor, + differentFrom, + confidence: this.scoreToConfidence(score), + }; + } + + private scoreToConfidence(score: number): 'high' | 'medium' | 'low' { + if (score >= 70) return 'high'; + if (score >= 50) return 'medium'; + return 'low'; + } + + private getSkillPath(skillName: string): string[] { + if (!this.tree) return ['Uncategorized']; + + const generator = new TreeGenerator(); + const path = generator.getPath(this.tree, skillName); + return path || ['Uncategorized']; + } + + private buildReasoningSummary( + plan: SearchPlan, + results: TreeSearchResult[] + ): string { + const parts: string[] = []; + + parts.push(`Search strategy: ${plan.strategy}`); + parts.push(`Primary categories: ${plan.primaryCategories.join(', ')}`); + + if (plan.keywords.length > 0) { + parts.push(`Keywords extracted: ${plan.keywords.join(', ')}`); + } + + parts.push(`Found ${results.length} matching skills`); + + if (results.length > 0) { + const avgConfidence = results.reduce((sum, r) => sum + r.confidence, 0) / results.length; + parts.push(`Average confidence: ${Math.round(avgConfidence)}%`); + } + + return parts.join('. '); + } + + private async callLLM(prompt: string): Promise { + return `Mock response for: ${prompt.slice(0, 50)}...`; + } + + private getCacheKey(query: TreeSearchQuery): string { + return JSON.stringify({ + query: query.query, + contextName: query.context?.name, + maxResults: query.maxResults, + minConfidence: query.minConfidence, + }); + } + + private getFromCache(key: string): ReasoningCacheEntry | null { + const entry = this.cache.get(key); + if (!entry) return null; + + const now = Date.now(); + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return null; + } + + return entry; + } + + private addToCache(key: string, results: TreeSearchResult[]): void { + if (this.cache.size >= MAX_CACHE_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(key, { + query: key, + results, + timestamp: Date.now(), + ttl: CACHE_TTL_MS, + }); + } + + private updateStats(processingTimeMs: number, resultCount: number): void { + const totalQueries = this.stats.totalQueries; + + this.stats.averageProcessingTimeMs = + (this.stats.averageProcessingTimeMs * (totalQueries - 1) + processingTimeMs) / totalQueries; + + this.stats.averageResultsPerQuery = + (this.stats.averageResultsPerQuery * (totalQueries - 1) + resultCount) / totalQueries; + } + + getStats(): ReasoningEngineStats { + return { ...this.stats }; + } + + clearCache(): void { + this.cache.clear(); + } + + getTree(): SkillTree | null { + return this.tree; + } +} + +export function createReasoningEngine( + config?: Partial +): ReasoningEngine { + return new ReasoningEngine(config); +} diff --git a/packages/core/src/reasoning/index.ts b/packages/core/src/reasoning/index.ts new file mode 100644 index 00000000..3bf452cd --- /dev/null +++ b/packages/core/src/reasoning/index.ts @@ -0,0 +1,14 @@ +export * from './types.js'; +export * from './engine.js'; +export * from './prompts.js'; + +export { ReasoningEngine, createReasoningEngine } from './engine.js'; +export { + buildSearchPlanPrompt, + buildCategoryRelevancePrompt, + buildSkillMatchPrompt, + buildExplanationPrompt, + extractJsonFromResponse, + validateSearchPlan, + validateCategoryScore, +} from './prompts.js'; diff --git a/packages/core/src/reasoning/prompts.ts b/packages/core/src/reasoning/prompts.ts new file mode 100644 index 00000000..382d3ab9 --- /dev/null +++ b/packages/core/src/reasoning/prompts.ts @@ -0,0 +1,224 @@ +import type { TreeNode } from '../tree/types.js'; +import type { ProjectProfile } from '../recommend/types.js'; +import type { SearchPlan, CategoryScore } from './types.js'; + +export const SEARCH_PLANNING_PROMPT = `You are a skill discovery assistant. Given a user query and optional project context, create a search plan. + +User Query: {{query}} + +{{#if context}} +Project Context: +- Languages: {{context.languages}} +- Frameworks: {{context.frameworks}} +- Project Type: {{context.type}} +{{/if}} + +Available Categories: +{{categories}} + +Respond with a JSON search plan: +{ + "primaryCategories": ["most relevant categories"], + "secondaryCategories": ["somewhat relevant categories"], + "keywords": ["extracted search terms"], + "filters": { + "tags": ["relevant tags"], + "frameworks": ["relevant frameworks"], + "languages": ["relevant languages"] + }, + "strategy": "breadth-first" | "depth-first" | "targeted" +}`; + +export const CATEGORY_RELEVANCE_PROMPT = `Rate the relevance of this category for the user's query. + +Query: {{query}} + +Category: {{category.name}} +Skills in category: {{category.skillCount}} +Subcategories: {{category.subcategories}} + +Rate from 0-100 and explain briefly. +Respond as JSON: {"score": number, "reasoning": "brief explanation"}`; + +export const SKILL_MATCH_PROMPT = `Evaluate how well this skill matches the user's query. + +Query: {{query}} + +Skill: {{skill.name}} +Description: {{skill.description}} +Tags: {{skill.tags}} + +{{#if context}} +Project Stack: +- Languages: {{context.languages}} +- Frameworks: {{context.frameworks}} +{{/if}} + +Evaluate match quality and explain why. +Respond as JSON: +{ + "confidence": 0-100, + "matchedKeywords": ["words from query that match"], + "relevantSections": ["which parts of skill are relevant"], + "reasoning": "explanation of match quality" +}`; + +export const EXPLANATION_PROMPT = `Explain why this skill was recommended for the user's project. + +Skill: {{skill.name}} +Description: {{skill.description}} +Tags: {{skill.tags}} +Score: {{score}} + +Project: +- Name: {{project.name}} +- Type: {{project.type}} +- Languages: {{project.languages}} +- Frameworks: {{project.frameworks}} + +Explain the match clearly and concisely. +Respond as JSON: +{ + "matchedBecause": ["specific match reasons"], + "relevantFor": ["how it helps this project"], + "differentFrom": ["what distinguishes it from alternatives"], + "confidence": "high" | "medium" | "low" +}`; + +export function buildSearchPlanPrompt( + query: string, + categories: string[], + context?: ProjectProfile +): string { + let prompt = SEARCH_PLANNING_PROMPT + .replace('{{query}}', query) + .replace('{{categories}}', categories.join('\n')); + + if (context) { + const languages = context.stack.languages.map(l => l.name).join(', '); + const frameworks = context.stack.frameworks.map(f => f.name).join(', '); + + prompt = prompt + .replace('{{#if context}}', '') + .replace('{{/if}}', '') + .replace('{{context.languages}}', languages) + .replace('{{context.frameworks}}', frameworks) + .replace('{{context.type}}', context.type || 'unknown'); + } else { + prompt = prompt.replace(/\{\{#if context\}\}[\s\S]*?\{\{\/if\}\}/g, ''); + } + + return prompt; +} + +export function buildCategoryRelevancePrompt( + query: string, + node: TreeNode +): string { + const subcategories = node.children.map(c => c.name).join(', '); + + return CATEGORY_RELEVANCE_PROMPT + .replace('{{query}}', query) + .replace('{{category.name}}', node.name) + .replace('{{category.skillCount}}', String(node.skillCount)) + .replace('{{category.subcategories}}', subcategories || 'none'); +} + +export function buildSkillMatchPrompt( + query: string, + skill: { name: string; description?: string; tags?: string[] }, + context?: ProjectProfile +): string { + let prompt = SKILL_MATCH_PROMPT + .replace('{{query}}', query) + .replace('{{skill.name}}', skill.name) + .replace('{{skill.description}}', skill.description || 'No description') + .replace('{{skill.tags}}', skill.tags?.join(', ') || 'No tags'); + + if (context) { + const languages = context.stack.languages.map(l => l.name).join(', '); + const frameworks = context.stack.frameworks.map(f => f.name).join(', '); + + prompt = prompt + .replace('{{#if context}}', '') + .replace('{{/if}}', '') + .replace('{{context.languages}}', languages) + .replace('{{context.frameworks}}', frameworks); + } else { + prompt = prompt.replace(/\{\{#if context\}\}[\s\S]*?\{\{\/if\}\}/g, ''); + } + + return prompt; +} + +export function buildExplanationPrompt( + skill: { name: string; description?: string; tags?: string[] }, + score: number, + project: ProjectProfile +): string { + const languages = project.stack.languages.map(l => l.name).join(', '); + const frameworks = project.stack.frameworks.map(f => f.name).join(', '); + + return EXPLANATION_PROMPT + .replace('{{skill.name}}', skill.name) + .replace('{{skill.description}}', skill.description || 'No description') + .replace('{{skill.tags}}', skill.tags?.join(', ') || 'No tags') + .replace('{{score}}', String(score)) + .replace('{{project.name}}', project.name) + .replace('{{project.type}}', project.type || 'unknown') + .replace('{{project.languages}}', languages) + .replace('{{project.frameworks}}', frameworks); +} + +export function extractJsonFromResponse(response: string): unknown { + const jsonMatch = response.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('No JSON found in response'); + } + + try { + return JSON.parse(jsonMatch[0]); + } catch { + throw new Error('Invalid JSON in response'); + } +} + +export function validateSearchPlan(data: unknown): SearchPlan { + const plan = data as Record; + + return { + primaryCategories: Array.isArray(plan.primaryCategories) + ? plan.primaryCategories + : [], + secondaryCategories: Array.isArray(plan.secondaryCategories) + ? plan.secondaryCategories + : [], + keywords: Array.isArray(plan.keywords) ? plan.keywords : [], + filters: { + tags: Array.isArray((plan.filters as Record)?.tags) + ? (plan.filters as Record).tags as string[] + : [], + frameworks: Array.isArray((plan.filters as Record)?.frameworks) + ? (plan.filters as Record).frameworks as string[] + : [], + languages: Array.isArray((plan.filters as Record)?.languages) + ? (plan.filters as Record).languages as string[] + : [], + }, + strategy: ['breadth-first', 'depth-first', 'targeted'].includes( + plan.strategy as string + ) + ? (plan.strategy as 'breadth-first' | 'depth-first' | 'targeted') + : 'breadth-first', + }; +} + +export function validateCategoryScore(data: unknown): CategoryScore { + const score = data as Record; + + return { + category: '', + score: typeof score.score === 'number' ? Math.min(100, Math.max(0, score.score)) : 0, + reasoning: typeof score.reasoning === 'string' ? score.reasoning : '', + }; +} diff --git a/packages/core/src/reasoning/types.ts b/packages/core/src/reasoning/types.ts new file mode 100644 index 00000000..e272cdbc --- /dev/null +++ b/packages/core/src/reasoning/types.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; +import type { SkillSummary, ProjectProfile } from '../recommend/types.js'; +import type { TreeNode } from '../tree/types.js'; + +export const ReasoningProviderSchema = z.enum(['openai', 'anthropic', 'ollama', 'mock']); +export type ReasoningProvider = z.infer; + +export interface ReasoningConfig { + provider: ReasoningProvider; + apiKey?: string; + model?: string; + maxTokens?: number; + temperature?: number; + baseUrl?: string; +} + +export interface TreeSearchQuery { + query: string; + context?: ProjectProfile; + maxResults?: number; + minConfidence?: number; + searchDepth?: number; +} + +export interface TreeTraversalStep { + node: TreeNode; + reasoning: string; + confidence: number; + action: 'explore' | 'skip' | 'select'; + selectedSkills: string[]; +} + +export interface TreeSearchResult { + skill: SkillSummary; + path: string[]; + reasoning: string; + confidence: number; + relevantSections: string[]; + matchedKeywords: string[]; +} + +export interface TreeReasoningResult { + query: string; + results: TreeSearchResult[]; + exploredPaths: string[][]; + reasoning: string; + totalNodesVisited: number; + processingTimeMs: number; +} + +export interface ExplainedMatch { + matchedBecause: string[]; + relevantFor: string[]; + differentFrom: string[]; + confidence: 'high' | 'medium' | 'low'; +} + +export interface ExplainedRecommendation { + skill: SkillSummary; + score: number; + reasoning: ExplainedMatch; + treePath: string[]; +} + +export interface ReasoningPrompt { + name: string; + template: string; + variables: string[]; +} + +export interface LLMResponse { + content: string; + tokensUsed: number; + model: string; +} + +export interface ReasoningCacheEntry { + query: string; + results: TreeSearchResult[]; + timestamp: number; + ttl: number; +} + +export const DEFAULT_REASONING_CONFIG: ReasoningConfig = { + provider: 'mock', + model: 'gpt-4o-mini', + maxTokens: 1000, + temperature: 0.3, +}; + +export interface CategoryScore { + category: string; + score: number; + reasoning: string; +} + +export interface SearchPlan { + primaryCategories: string[]; + secondaryCategories: string[]; + keywords: string[]; + filters: { + tags?: string[]; + frameworks?: string[]; + languages?: string[]; + }; + strategy: 'breadth-first' | 'depth-first' | 'targeted'; +} + +export interface ReasoningEngineStats { + totalQueries: number; + cacheHits: number; + cacheMisses: number; + averageProcessingTimeMs: number; + averageResultsPerQuery: number; +} diff --git a/packages/core/src/recommend/engine.ts b/packages/core/src/recommend/engine.ts index 19cfe5cd..6036cb4c 100644 --- a/packages/core/src/recommend/engine.ts +++ b/packages/core/src/recommend/engine.ts @@ -665,3 +665,136 @@ export function createRecommendationEngine( ): RecommendationEngine { return new RecommendationEngine(weights); } + +/** + * Enhanced Recommendation Engine with reasoning support + */ +export class ReasoningRecommendationEngine extends RecommendationEngine { + private reasoningEngine: import('../reasoning/engine.js').ReasoningEngine | null = null; + + async initReasoning(): Promise { + const { ReasoningEngine } = await import('../reasoning/engine.js'); + this.reasoningEngine = new ReasoningEngine({ provider: 'mock' }); + + const index = this.getIndex(); + if (index) { + this.reasoningEngine.loadSkills(index.skills); + this.reasoningEngine.generateTree(index.skills); + } + } + + async recommendWithReasoning( + profile: ProjectProfile, + options: import('./types.js').ReasoningRecommendOptions = {} + ): Promise { + const baseResult = this.recommend(profile, options); + + if (!options.reasoning || !this.reasoningEngine) { + return { + ...baseResult, + recommendations: baseResult.recommendations.map((r) => ({ + ...r, + treePath: [], + })), + }; + } + + const searchResult = await this.reasoningEngine.search({ + query: this.buildQueryFromProfile(profile), + context: profile, + maxResults: options.limit ?? 10, + minConfidence: options.minScore ?? 30, + }); + + const enhancedRecommendations: import('./types.js').ExplainedScoredSkill[] = []; + + for (const rec of baseResult.recommendations) { + const treeResult = searchResult.results.find( + (r) => r.skill.name === rec.skill.name + ); + + let explanation: import('./types.js').ExplainedMatchDetails | undefined; + + if (options.explainResults) { + const explainedRec = await this.reasoningEngine.explain( + rec.skill, + rec.score, + profile + ); + explanation = explainedRec.reasoning; + } + + enhancedRecommendations.push({ + ...rec, + explanation, + treePath: treeResult?.path ?? [], + reasoningDetails: treeResult?.reasoning, + }); + } + + return { + ...baseResult, + recommendations: enhancedRecommendations, + reasoningSummary: searchResult.reasoning, + searchPlan: { + primaryCategories: [], + secondaryCategories: [], + keywords: this.extractKeywords(profile), + strategy: 'breadth-first', + }, + }; + } + + private buildQueryFromProfile(profile: ProjectProfile): string { + const parts: string[] = []; + + if (profile.type) { + parts.push(profile.type); + } + + for (const framework of profile.stack.frameworks.slice(0, 3)) { + parts.push(framework.name); + } + + for (const language of profile.stack.languages.slice(0, 2)) { + parts.push(language.name); + } + + return parts.join(' '); + } + + private extractKeywords(profile: ProjectProfile): string[] { + const keywords: string[] = []; + + if (profile.type) { + keywords.push(profile.type); + } + + for (const framework of profile.stack.frameworks) { + keywords.push(framework.name.toLowerCase()); + } + + for (const language of profile.stack.languages) { + keywords.push(language.name.toLowerCase()); + } + + return [...new Set(keywords)]; + } + + getReasoningStats(): import('../reasoning/types.js').ReasoningEngineStats | null { + return this.reasoningEngine?.getStats() ?? null; + } + + getSkillTree(): import('../tree/types.js').SkillTree | null { + return this.reasoningEngine?.getTree() ?? null; + } +} + +/** + * Create an enhanced recommendation engine with reasoning support + */ +export function createReasoningRecommendationEngine( + weights?: Partial +): ReasoningRecommendationEngine { + return new ReasoningRecommendationEngine(weights); +} diff --git a/packages/core/src/recommend/index.ts b/packages/core/src/recommend/index.ts index d7e1e093..77ea1735 100644 --- a/packages/core/src/recommend/index.ts +++ b/packages/core/src/recommend/index.ts @@ -14,7 +14,12 @@ export * from './types.js'; export * from './engine.js'; export * from './fetcher.js'; -export { createRecommendationEngine, RecommendationEngine } from './engine.js'; +export { + createRecommendationEngine, + RecommendationEngine, + ReasoningRecommendationEngine, + createReasoningRecommendationEngine, +} from './engine.js'; export { fetchSkillsFromRepo, buildSkillIndex, diff --git a/packages/core/src/recommend/types.ts b/packages/core/src/recommend/types.ts index 3ee3710d..5e4990b5 100644 --- a/packages/core/src/recommend/types.ts +++ b/packages/core/src/recommend/types.ts @@ -284,3 +284,45 @@ export function getTechTags(techName: string): string[] { } return tags; } + +/** + * Enhanced recommendation options with reasoning support + */ +export interface ReasoningRecommendOptions extends RecommendOptions { + reasoning?: boolean; + explainResults?: boolean; + useTree?: boolean; +} + +/** + * Explained match details + */ +export interface ExplainedMatchDetails { + matchedBecause: string[]; + relevantFor: string[]; + differentFrom: string[]; + confidence: 'high' | 'medium' | 'low'; +} + +/** + * Enhanced scored skill with reasoning + */ +export interface ExplainedScoredSkill extends ScoredSkill { + explanation?: ExplainedMatchDetails; + treePath?: string[]; + reasoningDetails?: string; +} + +/** + * Enhanced recommendation result with reasoning + */ +export interface ReasoningRecommendationResult extends RecommendationResult { + recommendations: ExplainedScoredSkill[]; + reasoningSummary?: string; + searchPlan?: { + primaryCategories: string[]; + secondaryCategories: string[]; + keywords: string[]; + strategy: string; + }; +} diff --git a/packages/core/src/tree/generator.ts b/packages/core/src/tree/generator.ts new file mode 100644 index 00000000..9251caca --- /dev/null +++ b/packages/core/src/tree/generator.ts @@ -0,0 +1,358 @@ +import type { SkillSummary } from '../recommend/types.js'; +import { + type TreeNode, + type SkillTree, + type TreeGeneratorOptions, + type CategoryMapping, + CATEGORY_TAXONOMY, +} from './types.js'; + +const DEFAULT_OPTIONS: Required = { + maxDepth: 3, + minSkillsPerNode: 1, + includeEmpty: false, +}; + +export class TreeGenerator { + private options: Required; + private skillMap: Map = new Map(); + + constructor(options?: TreeGeneratorOptions) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + generateTree(skills: SkillSummary[]): SkillTree { + this.skillMap.clear(); + for (const skill of skills) { + this.skillMap.set(skill.name, skill); + } + + const rootNode = this.buildTreeFromTaxonomy(skills); + const { totalCategories, maxDepth } = this.countTreeStats(rootNode); + + return { + version: 1, + generatedAt: new Date().toISOString(), + rootNode, + totalSkills: skills.length, + totalCategories, + maxDepth, + }; + } + + private buildTreeFromTaxonomy(skills: SkillSummary[]): TreeNode { + const root: TreeNode = { + id: 'root', + name: 'Skills', + description: 'All available skills organized by category', + children: [], + skills: [], + skillCount: skills.length, + depth: 0, + }; + + const uncategorized: string[] = []; + + for (const category of CATEGORY_TAXONOMY) { + const categoryNode = this.buildCategoryNode(category, skills, 1); + if (categoryNode.skillCount > 0 || this.options.includeEmpty) { + root.children.push(categoryNode); + } + } + + for (const skill of skills) { + if (!this.isSkillCategorized(skill)) { + uncategorized.push(skill.name); + } + } + + if (uncategorized.length > 0) { + root.children.push({ + id: 'other', + name: 'Other', + description: 'Skills without specific categorization', + children: [], + skills: uncategorized, + skillCount: uncategorized.length, + depth: 1, + }); + } + + return root; + } + + private buildCategoryNode( + category: CategoryMapping, + skills: SkillSummary[], + depth: number + ): TreeNode { + const categorySkills = this.filterSkillsByCategory(skills, category); + const subcategoryNodes: TreeNode[] = []; + + if (depth < this.options.maxDepth) { + for (const subcategory of category.subcategories) { + const subcategorySkills = this.filterSkillsBySubcategory( + categorySkills, + subcategory, + category + ); + + if ( + subcategorySkills.length >= this.options.minSkillsPerNode || + this.options.includeEmpty + ) { + subcategoryNodes.push({ + id: `${category.category.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${subcategory.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, + name: subcategory, + description: `${subcategory} skills in ${category.category}`, + children: [], + skills: subcategorySkills.map((s) => s.name), + skillCount: subcategorySkills.length, + depth: depth + 1, + }); + } + } + } + + const directSkills = categorySkills.filter( + (skill) => + !subcategoryNodes.some((node) => node.skills.includes(skill.name)) + ); + + const totalSkillCount = + directSkills.length + + subcategoryNodes.reduce((sum, node) => sum + node.skillCount, 0); + + return { + id: category.category.toLowerCase().replace(/[^a-z0-9]/g, '-'), + name: category.category, + description: `${category.category} related skills`, + children: subcategoryNodes, + skills: directSkills.map((s) => s.name), + skillCount: totalSkillCount, + depth, + }; + } + + private filterSkillsByCategory( + skills: SkillSummary[], + category: CategoryMapping + ): SkillSummary[] { + return skills.filter((skill) => { + const skillTags = (skill.tags || []).map((t) => t.toLowerCase()); + const skillName = skill.name.toLowerCase(); + const skillDesc = (skill.description || '').toLowerCase(); + + const tagMatch = skillTags.some((tag) => + category.tags.includes(tag) + ); + + const keywordMatch = category.keywords.some( + (keyword) => + skillName.includes(keyword) || skillDesc.includes(keyword) + ); + + const categoryNameMatch = + skillName.includes(category.category.toLowerCase()) || + skillDesc.includes(category.category.toLowerCase()); + + return tagMatch || keywordMatch || categoryNameMatch; + }); + } + + private filterSkillsBySubcategory( + skills: SkillSummary[], + subcategory: string, + _parentCategory: CategoryMapping + ): SkillSummary[] { + const subcategoryLower = subcategory.toLowerCase(); + const subcategoryNormalized = subcategoryLower.replace(/[^a-z0-9]/g, ''); + + return skills.filter((skill) => { + const skillTags = (skill.tags || []).map((t) => t.toLowerCase()); + const skillName = skill.name.toLowerCase(); + const skillDesc = (skill.description || '').toLowerCase(); + + const tagMatch = skillTags.some( + (tag) => + tag === subcategoryLower || + tag === subcategoryNormalized || + tag.includes(subcategoryLower) + ); + + const nameMatch = + skillName.includes(subcategoryLower) || + skillName.includes(subcategoryNormalized); + + const descMatch = + skillDesc.includes(subcategoryLower) || + skillDesc.includes(subcategoryNormalized); + + const compatibilityMatch = + skill.compatibility?.frameworks?.some( + (f) => + f.toLowerCase() === subcategoryLower || + f.toLowerCase() === subcategoryNormalized + ) || + skill.compatibility?.languages?.some( + (l) => + l.toLowerCase() === subcategoryLower || + l.toLowerCase() === subcategoryNormalized + ); + + return tagMatch || nameMatch || descMatch || compatibilityMatch; + }); + } + + private isSkillCategorized(skill: SkillSummary): boolean { + for (const category of CATEGORY_TAXONOMY) { + const skillTags = (skill.tags || []).map((t) => t.toLowerCase()); + const skillName = skill.name.toLowerCase(); + const skillDesc = (skill.description || '').toLowerCase(); + + const tagMatch = skillTags.some((tag) => category.tags.includes(tag)); + + const keywordMatch = category.keywords.some( + (keyword) => + skillName.includes(keyword) || skillDesc.includes(keyword) + ); + + if (tagMatch || keywordMatch) { + return true; + } + } + return false; + } + + private countTreeStats(node: TreeNode): { + totalCategories: number; + maxDepth: number; + } { + let totalCategories = 1; + let maxDepth = node.depth; + + for (const child of node.children) { + const childStats = this.countTreeStats(child); + totalCategories += childStats.totalCategories; + maxDepth = Math.max(maxDepth, childStats.maxDepth); + } + + return { totalCategories, maxDepth }; + } + + findNode(tree: SkillTree, path: string[]): TreeNode | null { + let current = tree.rootNode; + + for (const segment of path) { + const child = current.children.find( + (c) => c.name.toLowerCase() === segment.toLowerCase() + ); + if (!child) { + return null; + } + current = child; + } + + return current; + } + + getPath(tree: SkillTree, skillName: string): string[] | null { + const findPath = (node: TreeNode, path: string[]): string[] | null => { + if (node.skills.includes(skillName)) { + return [...path, node.name]; + } + + for (const child of node.children) { + const result = findPath(child, [...path, node.name]); + if (result) { + return result; + } + } + + return null; + }; + + const result = findPath(tree.rootNode, []); + return result ? result.slice(1) : null; + } + + getAllPaths(tree: SkillTree): Map { + const paths = new Map(); + + const traverse = (node: TreeNode, currentPath: string[]) => { + for (const skillName of node.skills) { + paths.set(skillName, currentPath); + } + + for (const child of node.children) { + traverse(child, [...currentPath, child.name]); + } + }; + + traverse(tree.rootNode, []); + return paths; + } + + getNodesAtDepth(tree: SkillTree, depth: number): TreeNode[] { + const nodes: TreeNode[] = []; + + const traverse = (node: TreeNode) => { + if (node.depth === depth) { + nodes.push(node); + } else if (node.depth < depth) { + for (const child of node.children) { + traverse(child); + } + } + }; + + traverse(tree.rootNode); + return nodes; + } + + flattenTree(tree: SkillTree): TreeNode[] { + const nodes: TreeNode[] = []; + + const traverse = (node: TreeNode) => { + nodes.push(node); + for (const child of node.children) { + traverse(child); + } + }; + + traverse(tree.rootNode); + return nodes; + } + + searchTree(tree: SkillTree, query: string): TreeNode[] { + const queryLower = query.toLowerCase(); + const results: TreeNode[] = []; + + const traverse = (node: TreeNode) => { + const nameMatch = node.name.toLowerCase().includes(queryLower); + const descMatch = (node.description || '').toLowerCase().includes(queryLower); + const skillMatch = node.skills.some((s) => + s.toLowerCase().includes(queryLower) + ); + + if (nameMatch || descMatch || skillMatch) { + results.push(node); + } + + for (const child of node.children) { + traverse(child); + } + }; + + traverse(tree.rootNode); + return results; + } +} + +export function generateSkillTree( + skills: SkillSummary[], + options?: TreeGeneratorOptions +): SkillTree { + const generator = new TreeGenerator(options); + return generator.generateTree(skills); +} diff --git a/packages/core/src/tree/graph.ts b/packages/core/src/tree/graph.ts new file mode 100644 index 00000000..865e6c26 --- /dev/null +++ b/packages/core/src/tree/graph.ts @@ -0,0 +1,322 @@ +import type { SkillSummary } from '../recommend/types.js'; +import type { TreeNode, SkillTree } from './types.js'; + +export type RelationType = 'similar' | 'complementary' | 'dependency' | 'alternative'; + +export interface SkillRelation { + skillName: string; + relationType: RelationType; + strength: number; + reason: string; +} + +export interface SkillNode { + name: string; + tags: string[]; + source?: string; + relations: SkillRelation[]; +} + +export interface SkillGraph { + version: number; + generatedAt: string; + nodes: Map; + totalSkills: number; + totalRelations: number; +} + +export interface RelatedSkillResult { + skill: SkillSummary; + relationType: RelationType; + strength: number; + reason: string; + path?: string[]; +} + +const TAG_SIMILARITY_THRESHOLD = 0.3; +const MAX_RELATIONS_PER_SKILL = 10; + +export function buildSkillGraph(skills: SkillSummary[]): SkillGraph { + const nodes = new Map(); + let totalRelations = 0; + + for (const skill of skills) { + const node: SkillNode = { + name: skill.name, + tags: skill.tags || [], + source: skill.source, + relations: [], + }; + nodes.set(skill.name, node); + } + + for (const skill of skills) { + const node = nodes.get(skill.name); + if (!node) continue; + + const relatedSkills = findRelatedSkills(skill, skills); + node.relations = relatedSkills.slice(0, MAX_RELATIONS_PER_SKILL); + totalRelations += node.relations.length; + } + + return { + version: 1, + generatedAt: new Date().toISOString(), + nodes, + totalSkills: skills.length, + totalRelations, + }; +} + +function findRelatedSkills( + skill: SkillSummary, + allSkills: SkillSummary[] +): SkillRelation[] { + const relations: SkillRelation[] = []; + const skillTags = new Set(skill.tags || []); + const skillName = skill.name.toLowerCase(); + + for (const other of allSkills) { + if (other.name === skill.name) continue; + + const otherTags = new Set(other.tags || []); + const otherName = other.name.toLowerCase(); + + const commonTags: string[] = [...skillTags].filter((tag) => otherTags.has(tag)); + const totalTags = new Set([...skillTags, ...otherTags]).size; + const tagSimilarity = totalTags > 0 ? commonTags.length / totalTags : 0; + + if (tagSimilarity >= TAG_SIMILARITY_THRESHOLD) { + const relationType = determineRelationType(skill, other, commonTags); + const reason = generateRelationReason(relationType, commonTags); + + relations.push({ + skillName: other.name, + relationType, + strength: Math.round(tagSimilarity * 100), + reason, + }); + } + + if (skillName.includes(otherName) || otherName.includes(skillName)) { + const existing = relations.find((r) => r.skillName === other.name); + if (!existing) { + relations.push({ + skillName: other.name, + relationType: 'similar', + strength: 50, + reason: 'Related by name', + }); + } + } + + if (skill.source && other.source && skill.source === other.source) { + const existing = relations.find((r) => r.skillName === other.name); + if (existing) { + existing.strength = Math.min(100, existing.strength + 20); + } else if (commonTags.length > 0) { + relations.push({ + skillName: other.name, + relationType: 'similar', + strength: 30, + reason: `Same source: ${skill.source}`, + }); + } + } + } + + return relations.sort((a, b) => b.strength - a.strength); +} + +function determineRelationType( + skill: SkillSummary, + other: SkillSummary, + _commonTags: string[] +): RelationType { + const complementaryPairs = [ + ['frontend', 'backend'], + ['testing', 'development'], + ['security', 'api'], + ['database', 'api'], + ['docker', 'kubernetes'], + ]; + + const skillTags = skill.tags || []; + const otherTags = other.tags || []; + + for (const [tag1, tag2] of complementaryPairs) { + if ( + (skillTags.includes(tag1) && otherTags.includes(tag2)) || + (skillTags.includes(tag2) && otherTags.includes(tag1)) + ) { + return 'complementary'; + } + } + + const alternativeIndicators = ['vs', 'alternative', 'instead']; + const skillName = skill.name.toLowerCase(); + const otherName = other.name.toLowerCase(); + + for (const indicator of alternativeIndicators) { + if (skillName.includes(indicator) || otherName.includes(indicator)) { + return 'alternative'; + } + } + + return 'similar'; +} + +function generateRelationReason(relationType: RelationType, commonTags: string[]): string { + switch (relationType) { + case 'similar': + return commonTags.length > 0 + ? `Shares tags: ${commonTags.slice(0, 3).join(', ')}` + : 'Similar functionality'; + case 'complementary': + return 'Works well together'; + case 'dependency': + return 'Required by this skill'; + case 'alternative': + return 'Alternative approach'; + default: + return 'Related skill'; + } +} + +export function getRelatedSkills( + skillName: string, + graph: SkillGraph, + skills: SkillSummary[], + options: { + limit?: number; + types?: RelationType[]; + minStrength?: number; + } = {} +): RelatedSkillResult[] { + const { limit = 5, types, minStrength = 0 } = options; + const node = graph.nodes.get(skillName); + if (!node) return []; + + const skillMap = new Map(skills.map((s) => [s.name, s])); + + return node.relations + .filter((r) => { + if (types && !types.includes(r.relationType)) return false; + if (r.strength < minStrength) return false; + return true; + }) + .slice(0, limit) + .map((r) => ({ + skill: skillMap.get(r.skillName)!, + relationType: r.relationType, + strength: r.strength, + reason: r.reason, + })) + .filter((r) => r.skill); +} + +export function findSkillsByRelationType( + graph: SkillGraph, + skills: SkillSummary[], + relationType: RelationType, + limit: number = 10 +): Array<{ skill: SkillSummary; relatedCount: number }> { + const skillMap = new Map(skills.map((s) => [s.name, s])); + const counts: Map = new Map(); + + for (const [_skillName, node] of graph.nodes) { + const typeRelations = node.relations.filter((r) => r.relationType === relationType); + counts.set(node.name, typeRelations.length); + } + + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([name, count]) => ({ + skill: skillMap.get(name)!, + relatedCount: count, + })) + .filter((r) => r.skill); +} + +export function getSkillPath( + fromSkillName: string, + toSkillName: string, + graph: SkillGraph, + maxHops: number = 3 +): string[] | null { + if (fromSkillName === toSkillName) return [fromSkillName]; + + const visited = new Set(); + const queue: Array<{ name: string; path: string[] }> = [ + { name: fromSkillName, path: [fromSkillName] }, + ]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.path.length > maxHops + 1) continue; + + const node = graph.nodes.get(current.name); + if (!node) continue; + + for (const relation of node.relations) { + if (relation.skillName === toSkillName) { + return [...current.path, toSkillName]; + } + + if (!visited.has(relation.skillName)) { + visited.add(relation.skillName); + queue.push({ + name: relation.skillName, + path: [...current.path, relation.skillName], + }); + } + } + } + + return null; +} + +export function findSkillsInCategory(tree: SkillTree, categoryPath: string[]): string[] { + let current: TreeNode = tree.rootNode; + + for (const segment of categoryPath) { + const child = current.children.find( + (c) => c.name.toLowerCase() === segment.toLowerCase() + ); + if (!child) return []; + current = child; + } + + return collectSkillsRecursive(current); +} + +function collectSkillsRecursive(node: TreeNode): string[] { + const skills: string[] = [...node.skills]; + for (const child of node.children) { + skills.push(...collectSkillsRecursive(child)); + } + return skills; +} + +export function serializeGraph(graph: SkillGraph): string { + const serialized = { + version: graph.version, + generatedAt: graph.generatedAt, + totalSkills: graph.totalSkills, + totalRelations: graph.totalRelations, + nodes: Object.fromEntries(graph.nodes), + }; + return JSON.stringify(serialized, null, 2); +} + +export function deserializeGraph(json: string): SkillGraph { + const parsed = JSON.parse(json); + return { + version: parsed.version, + generatedAt: parsed.generatedAt, + totalSkills: parsed.totalSkills, + totalRelations: parsed.totalRelations, + nodes: new Map(Object.entries(parsed.nodes)), + }; +} diff --git a/packages/core/src/tree/index.ts b/packages/core/src/tree/index.ts new file mode 100644 index 00000000..97aefa4a --- /dev/null +++ b/packages/core/src/tree/index.ts @@ -0,0 +1,25 @@ +export * from './types.js'; +export * from './generator.js'; +export * from './serializer.js'; +export * from './graph.js'; + +export { TreeGenerator, generateSkillTree } from './generator.js'; +export { + serializeTree, + deserializeTree, + saveTree, + loadTree, + treeToText, + treeToMarkdown, + compareTreeVersions, + TREE_FILE_NAME, +} from './serializer.js'; +export { + buildSkillGraph, + getRelatedSkills, + findSkillsByRelationType, + getSkillPath, + findSkillsInCategory, + serializeGraph, + deserializeGraph, +} from './graph.js'; diff --git a/packages/core/src/tree/serializer.ts b/packages/core/src/tree/serializer.ts new file mode 100644 index 00000000..cb38feaa --- /dev/null +++ b/packages/core/src/tree/serializer.ts @@ -0,0 +1,191 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { SkillTree, TreeNode } from './types.js'; +import { SkillTreeSchema } from './types.js'; + +export const TREE_FILE_NAME = 'skill-tree.json'; + +export function serializeTree(tree: SkillTree): string { + return JSON.stringify(tree, null, 2); +} + +export function deserializeTree(json: string): SkillTree { + const parsed = JSON.parse(json); + return SkillTreeSchema.parse(parsed); +} + +export function saveTree(tree: SkillTree, path: string): void { + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const json = serializeTree(tree); + writeFileSync(path, json, 'utf-8'); +} + +export function loadTree(path: string): SkillTree | null { + if (!existsSync(path)) { + return null; + } + + try { + const json = readFileSync(path, 'utf-8'); + return deserializeTree(json); + } catch { + return null; + } +} + +export function treeToText(tree: SkillTree, options?: { maxDepth?: number }): string { + const maxDepth = options?.maxDepth ?? Infinity; + const lines: string[] = []; + + lines.push(`Skill Tree (${tree.totalSkills} skills, ${tree.totalCategories} categories)`); + lines.push(`Generated: ${tree.generatedAt}`); + lines.push(''); + + const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number) => { + if (depth > maxDepth) return; + + const connector = isLast ? '└── ' : '├── '; + const skillInfo = + node.skillCount > 0 ? ` (${node.skillCount} skills)` : ''; + + lines.push(`${prefix}${connector}${node.name}${skillInfo}`); + + const newPrefix = prefix + (isLast ? ' ' : '│ '); + + if (node.children.length > 0 && depth < maxDepth) { + node.children.forEach((child, index) => { + const childIsLast = index === node.children.length - 1; + renderNode(child, newPrefix, childIsLast, depth + 1); + }); + } + }; + + const root = tree.rootNode; + lines.push(root.name); + + root.children.forEach((child, index) => { + const isLast = index === root.children.length - 1; + renderNode(child, '', isLast, 1); + }); + + return lines.join('\n'); +} + +export function treeToMarkdown(tree: SkillTree): string { + const lines: string[] = []; + + lines.push(`# Skill Tree`); + lines.push(''); + lines.push(`> ${tree.totalSkills} skills organized in ${tree.totalCategories} categories`); + lines.push(''); + lines.push(`*Generated: ${tree.generatedAt}*`); + lines.push(''); + + const renderNode = (node: TreeNode, depth: number) => { + const indent = ' '.repeat(depth); + const bullet = depth === 0 ? '##' : '-'; + + if (depth === 0) { + lines.push(`${bullet} ${node.name}`); + } else { + const skillCount = node.skillCount > 0 ? ` *(${node.skillCount})*` : ''; + lines.push(`${indent}${bullet} **${node.name}**${skillCount}`); + } + + if (node.skills.length > 0 && node.skills.length <= 10) { + for (const skill of node.skills) { + lines.push(`${indent} - \`${skill}\``); + } + } else if (node.skills.length > 10) { + for (const skill of node.skills.slice(0, 5)) { + lines.push(`${indent} - \`${skill}\``); + } + lines.push(`${indent} - *... and ${node.skills.length - 5} more*`); + } + + for (const child of node.children) { + renderNode(child, depth + 1); + } + }; + + for (const category of tree.rootNode.children) { + renderNode(category, 0); + lines.push(''); + } + + return lines.join('\n'); +} + +export function compareTreeVersions( + oldTree: SkillTree, + newTree: SkillTree +): { + added: string[]; + removed: string[]; + moved: { skill: string; from: string[]; to: string[] }[]; +} { + const oldSkills = getAllSkillsWithPaths(oldTree.rootNode); + const newSkills = getAllSkillsWithPaths(newTree.rootNode); + + const oldNames = new Set(oldSkills.map((s) => s.name)); + const newNames = new Set(newSkills.map((s) => s.name)); + + const added: string[] = []; + const removed: string[] = []; + const moved: { skill: string; from: string[]; to: string[] }[] = []; + + for (const skill of newSkills) { + if (!oldNames.has(skill.name)) { + added.push(skill.name); + } + } + + for (const skill of oldSkills) { + if (!newNames.has(skill.name)) { + removed.push(skill.name); + } + } + + for (const newSkill of newSkills) { + const oldSkill = oldSkills.find((s) => s.name === newSkill.name); + if (oldSkill && !arraysEqual(oldSkill.path, newSkill.path)) { + moved.push({ + skill: newSkill.name, + from: oldSkill.path, + to: newSkill.path, + }); + } + } + + return { added, removed, moved }; +} + +function getAllSkillsWithPaths( + node: TreeNode, + currentPath: string[] = [] +): { name: string; path: string[] }[] { + const results: { name: string; path: string[] }[] = []; + const path = [...currentPath, node.name]; + + for (const skill of node.skills) { + results.push({ name: skill, path }); + } + + for (const child of node.children) { + results.push(...getAllSkillsWithPaths(child, path)); + } + + return results; +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/packages/core/src/tree/types.ts b/packages/core/src/tree/types.ts new file mode 100644 index 00000000..f1b5ce00 --- /dev/null +++ b/packages/core/src/tree/types.ts @@ -0,0 +1,142 @@ +import { z } from 'zod'; + +const BaseTreeNodeSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + skills: z.array(z.string()).default([]), + skillCount: z.number().default(0), + depth: z.number().default(0), +}); + +export interface TreeNode { + id: string; + name: string; + description?: string; + children: TreeNode[]; + skills: string[]; + skillCount: number; + depth: number; +} + +export const TreeNodeSchema: z.ZodType = BaseTreeNodeSchema.extend({ + children: z.lazy(() => z.array(TreeNodeSchema)).default([]), +}) as z.ZodType; + +export const SkillTreeSchema = z.object({ + version: z.number().default(1), + generatedAt: z.string(), + rootNode: TreeNodeSchema, + totalSkills: z.number(), + totalCategories: z.number(), + maxDepth: z.number(), +}); + +export type SkillTree = z.infer; + +export interface TreePath { + segments: string[]; + node: TreeNode; +} + +export interface CategoryMapping { + category: string; + subcategories: string[]; + tags: string[]; + keywords: string[]; +} + +export const CATEGORY_TAXONOMY: CategoryMapping[] = [ + { + category: 'Development', + subcategories: ['Frontend', 'Backend', 'Mobile', 'Desktop', 'Full-Stack'], + tags: ['development', 'coding', 'programming', 'software'], + keywords: ['build', 'create', 'develop', 'code', 'implement'], + }, + { + category: 'Frontend', + subcategories: ['React', 'Vue', 'Angular', 'Svelte', 'Solid', 'Astro', 'Next.js', 'Nuxt', 'Remix'], + tags: ['frontend', 'ui', 'ux', 'web', 'client', 'browser', 'react', 'vue', 'angular', 'svelte', 'solid', 'astro', 'nextjs', 'nuxt', 'remix'], + keywords: ['component', 'ui', 'interface', 'layout', 'style', 'css', 'html'], + }, + { + category: 'Backend', + subcategories: ['Node.js', 'Python', 'Go', 'Rust', 'Java', 'C#', 'Express', 'FastAPI', 'Django', 'Flask'], + tags: ['backend', 'server', 'api', 'node', 'nodejs', 'python', 'go', 'rust', 'java', 'express', 'fastapi', 'django', 'flask', 'fastify', 'koa', 'hono', 'nestjs'], + keywords: ['server', 'api', 'endpoint', 'route', 'handler', 'middleware'], + }, + { + category: 'Mobile', + subcategories: ['React Native', 'Flutter', 'iOS', 'Android', 'Expo'], + tags: ['mobile', 'ios', 'android', 'react-native', 'flutter', 'expo', 'native'], + keywords: ['app', 'mobile', 'phone', 'tablet', 'native'], + }, + { + category: 'DevOps', + subcategories: ['CI/CD', 'Kubernetes', 'Docker', 'Cloud', 'Infrastructure'], + tags: ['devops', 'ci', 'cd', 'cicd', 'kubernetes', 'k8s', 'docker', 'container', 'aws', 'gcp', 'azure', 'cloud', 'infrastructure', 'iac'], + keywords: ['deploy', 'pipeline', 'container', 'orchestration', 'infra'], + }, + { + category: 'Testing', + subcategories: ['Unit Testing', 'E2E Testing', 'Integration Testing', 'TDD'], + tags: ['testing', 'test', 'unit', 'e2e', 'integration', 'jest', 'vitest', 'playwright', 'cypress', 'tdd'], + keywords: ['test', 'spec', 'assertion', 'mock', 'coverage'], + }, + { + category: 'Security', + subcategories: ['Authentication', 'Authorization', 'Encryption', 'Audit'], + tags: ['security', 'auth', 'authentication', 'authorization', 'oauth', 'jwt', 'encryption', 'audit', 'vulnerability'], + keywords: ['secure', 'protect', 'encrypt', 'authenticate', 'authorize'], + }, + { + category: 'AI/ML', + subcategories: ['LLM Integration', 'Agents', 'RAG', 'Training', 'Inference'], + tags: ['ai', 'ml', 'llm', 'openai', 'anthropic', 'gpt', 'claude', 'agent', 'rag', 'embedding', 'vector', 'training', 'inference'], + keywords: ['ai', 'model', 'predict', 'generate', 'embed', 'chat'], + }, + { + category: 'Database', + subcategories: ['SQL', 'NoSQL', 'ORM', 'Migrations'], + tags: ['database', 'db', 'sql', 'nosql', 'postgres', 'postgresql', 'mysql', 'mongodb', 'redis', 'supabase', 'firebase', 'prisma', 'drizzle'], + keywords: ['query', 'schema', 'migration', 'model', 'record'], + }, + { + category: 'Tooling', + subcategories: ['Linting', 'Formatting', 'Building', 'Bundling'], + tags: ['tooling', 'tool', 'eslint', 'prettier', 'biome', 'webpack', 'vite', 'rollup', 'turbo', 'turborepo', 'monorepo'], + keywords: ['lint', 'format', 'build', 'bundle', 'compile'], + }, + { + category: 'Documentation', + subcategories: ['API Docs', 'Guides', 'READMEs', 'Comments'], + tags: ['documentation', 'docs', 'readme', 'api-docs', 'jsdoc', 'typedoc', 'storybook'], + keywords: ['doc', 'document', 'guide', 'readme', 'comment'], + }, + { + category: 'Performance', + subcategories: ['Optimization', 'Caching', 'Profiling', 'Monitoring'], + tags: ['performance', 'optimization', 'cache', 'caching', 'profiling', 'monitoring', 'metrics', 'observability'], + keywords: ['optimize', 'fast', 'cache', 'profile', 'monitor'], + }, +]; + +export const TAG_TO_CATEGORY: Record = {}; + +for (const mapping of CATEGORY_TAXONOMY) { + for (const tag of mapping.tags) { + if (!TAG_TO_CATEGORY[tag]) { + TAG_TO_CATEGORY[tag] = []; + } + TAG_TO_CATEGORY[tag].push(mapping.category); + for (const subcategory of mapping.subcategories) { + TAG_TO_CATEGORY[tag].push(`${mapping.category} > ${subcategory}`); + } + } +} + +export interface TreeGeneratorOptions { + maxDepth?: number; + minSkillsPerNode?: number; + includeEmpty?: boolean; +} diff --git a/packages/tui/src/screens/Marketplace.tsx b/packages/tui/src/screens/Marketplace.tsx index b84db3f4..d6e4a705 100644 --- a/packages/tui/src/screens/Marketplace.tsx +++ b/packages/tui/src/screens/Marketplace.tsx @@ -11,7 +11,15 @@ import { terminalColors } from '../theme/colors.js'; import { Header } from '../components/Header.js'; import { Spinner } from '../components/Spinner.js'; import { EmptyState, ErrorState } from '../components/EmptyState.js'; -import { SearchInput } from '../components/SearchInput.js'; +import { + loadOrGenerateTree, + getNodeChildren, + getNodeSkills, + formatTreePath, + type TreeServiceState, + type TreeNodeDisplay, +} from '../services/index.js'; +import type { TreeNode } from '@skillkit/core'; interface MarketplaceProps { onNavigate: (screen: Screen) => void; @@ -27,6 +35,8 @@ const CATEGORIES = [ { name: 'AI/ML', tag: 'ai' }, ]; +type ViewMode = 'list' | 'tree'; + export function Marketplace(props: MarketplaceProps) { const [allSkills, setAllSkills] = createSignal([]); const [selectedIndex, setSelectedIndex] = createSignal(0); @@ -37,6 +47,11 @@ export function Marketplace(props: MarketplaceProps) { const [loadedRepos, setLoadedRepos] = createSignal([]); const [failedRepos, setFailedRepos] = createSignal([]); + const [viewMode, setViewMode] = createSignal('list'); + const [treeState, setTreeState] = createSignal(null); + const [currentPath, setCurrentPath] = createSignal([]); + const [treeItems, setTreeItems] = createSignal<(TreeNodeDisplay | { type: 'skill'; name: string })[]>([]); + const rows = () => props.rows ?? 24; createEffect(() => { @@ -76,9 +91,28 @@ export function Marketplace(props: MarketplaceProps) { setError(`Some repos failed (${failed.join(', ')}) and no skills loaded`); } + const tree = await loadOrGenerateTree(); + setTreeState(tree); + if (tree.tree && tree.currentNode) { + updateTreeItems(tree.currentNode); + } + setLoading(false); }; + const updateTreeItems = (node: TreeNode) => { + const children = getNodeChildren(node); + const skills = getNodeSkills(node); + + const items: (TreeNodeDisplay | { type: 'skill'; name: string })[] = [ + ...children, + ...skills.map(name => ({ type: 'skill' as const, name })), + ]; + + setTreeItems(items); + setSelectedIndex(0); + }; + const filteredSkills = createMemo(() => { let skills = allSkills(); @@ -98,9 +132,15 @@ export function Marketplace(props: MarketplaceProps) { }); createEffect(() => { - const list = filteredSkills(); - const maxIndex = Math.max(0, list.length - 1); - setSelectedIndex((prev) => Math.max(0, Math.min(prev, maxIndex))); + if (viewMode() === 'list') { + const list = filteredSkills(); + const maxIndex = Math.max(0, list.length - 1); + setSelectedIndex((prev) => Math.max(0, Math.min(prev, maxIndex))); + } else { + const items = treeItems(); + const maxIndex = Math.max(0, items.length - 1); + setSelectedIndex((prev) => Math.max(0, Math.min(prev, maxIndex))); + } }); const maxVisible = () => Math.max(4, Math.floor((rows() - 14) / 2)); @@ -116,9 +156,25 @@ export function Marketplace(props: MarketplaceProps) { return { start, items: list.slice(start, start + visible) }; }); + const treeWindow = createMemo(() => { + const list = treeItems(); + const selected = selectedIndex(); + const visible = maxVisible(); + const total = list.length; + if (total <= visible) return { start: 0, items: list }; + let start = Math.max(0, selected - Math.floor(visible / 2)); + start = Math.min(start, total - visible); + return { start, items: list.slice(start, start + visible) }; + }); + const handleKeyNav = (delta: number) => { - const max = filteredSkills().length - 1; - setSelectedIndex((prev) => Math.max(0, Math.min(prev + delta, max))); + if (viewMode() === 'list') { + const max = filteredSkills().length - 1; + setSelectedIndex((prev) => Math.max(0, Math.min(prev + delta, max))); + } else { + const max = treeItems().length - 1; + setSelectedIndex((prev) => Math.max(0, Math.min(prev + delta, max))); + } }; const handleInstall = () => { @@ -128,6 +184,54 @@ export function Marketplace(props: MarketplaceProps) { } }; + const handleTreeEnter = () => { + const items = treeItems(); + const item = items[selectedIndex()]; + if (!item) return; + + if ('type' in item && item.type === 'skill') { + props.onNavigate('installed'); + } else if ('isCategory' in item && item.isCategory) { + const state = treeState(); + if (state?.tree) { + const newPath = [...currentPath(), item.name]; + setCurrentPath(newPath); + + let node = state.tree.rootNode; + for (const segment of newPath) { + const child = node.children.find(c => c.name === segment); + if (child) { + node = child; + } + } + updateTreeItems(node); + } + } + }; + + const handleTreeBack = () => { + const path = currentPath(); + if (path.length === 0) { + setViewMode('list'); + return; + } + + const newPath = path.slice(0, -1); + setCurrentPath(newPath); + + const state = treeState(); + if (state?.tree) { + let node = state.tree.rootNode; + for (const segment of newPath) { + const child = node.children.find(c => c.name === segment); + if (child) { + node = child; + } + } + updateTreeItems(node); + } + }; + const handleCategorySelect = (idx: number) => { const cat = CATEGORIES[idx]; if (cat) { @@ -136,37 +240,61 @@ export function Marketplace(props: MarketplaceProps) { } }; + const toggleViewMode = () => { + if (viewMode() === 'list') { + setViewMode('tree'); + setSelectedIndex(0); + const state = treeState(); + if (state?.tree) { + updateTreeItems(state.tree.rootNode); + } + } else { + setViewMode('list'); + setCurrentPath([]); + setSelectedIndex(0); + } + }; + useKeyboard((key: { name?: string; sequence?: string }) => { if (key.name === 'j' || key.name === 'down') handleKeyNav(1); else if (key.name === 'k' || key.name === 'up') handleKeyNav(-1); - else if (key.name === 'return') handleInstall(); + else if (key.name === 'return') { + if (viewMode() === 'tree') { + handleTreeEnter(); + } else { + handleInstall(); + } + } else if (key.name === 'b') props.onNavigate('browse'); else if (key.name === 'r') loadData(); + else if (key.sequence === 'v') toggleViewMode(); else if (key.sequence && ['1', '2', '3', '4', '5'].includes(key.sequence)) { - handleCategorySelect(parseInt(key.sequence) - 1); + if (viewMode() === 'list') { + handleCategorySelect(parseInt(key.sequence) - 1); + } } else if (key.name === 'escape') { - if (searchQuery() || selectedCategory()) { + if (viewMode() === 'tree') { + handleTreeBack(); + } else if (searchQuery() || selectedCategory()) { setSearchQuery(''); setSelectedCategory(null); } else { props.onNavigate('home'); } + } else if (key.name === 'left' && viewMode() === 'tree') { + handleTreeBack(); + } else if (key.name === 'right' && viewMode() === 'tree') { + handleTreeEnter(); } }); - const selectedSkill = () => { - const skills = filteredSkills(); - if (skills.length === 0) return null; - return skills[selectedIndex()]; - }; - return (
@@ -201,105 +329,187 @@ export function Marketplace(props: MarketplaceProps) { | {allSkills().length} skills + | + + [v] {viewMode() === 'tree' ? '🌳 Tree' : '📋 List'} + ───────────────────────────────────────────── - - Categories: - - {(cat, idx) => ( - + + Categories: + + {(cat, idx) => ( + + [{idx() + 1}]{cat.name}{' '} + + )} + + + + ───────────────────────────────────────────── + + + 0} + fallback={ + - [{idx() + 1}]{cat.name}{' '} + action={{ label: 'Clear Filter', key: 'Esc' }} + /> + } + > + + + {selectedCategory() ? `${selectedCategory()} Skills` : 'Featured Skills'} + {' '} + + ({filteredSkills().length} results) - )} - - + + + + 0}> + ▲ {skillsWindow().start} more + + + {(skill, idx) => { + const originalIndex = () => skillsWindow().start + idx(); + const isSelected = () => originalIndex() === selectedIndex(); + return ( + + + + {isSelected() ? '▸ ' : ' '} + + + {skill.name} + + + {skill.repoName} + + + + + {' '}{skill.description?.slice(0, 50)} + {(skill.description?.length || 0) > 50 ? '...' : ''} + + + + ); + }} + - ───────────────────────────────────────────── - + + + ▼ {filteredSkills().length - skillsWindow().start - maxVisible()} more + + + + - 0} - fallback={ - - } - > - - - {selectedCategory() ? `${selectedCategory()} Skills` : 'Featured Skills'} - {' '} - - ({filteredSkills().length} results) + + + + 📁 {currentPath().length === 0 ? 'Root' : currentPath().join(' > ')} - + + + ───────────────────────────────────────────── - 0}> - ▲ {skillsWindow().start} more - - - {(skill, idx) => { - const originalIndex = () => skillsWindow().start + idx(); - const isSelected = () => originalIndex() === selectedIndex(); - return ( - - + 0} + fallback={ + + } + > + 0}> + ▲ {treeWindow().start} more + + + {(item, idx) => { + const originalIndex = () => treeWindow().start + idx(); + const isSelected = () => originalIndex() === selectedIndex(); + const isCategory = () => 'isCategory' in item && item.isCategory; + const isSkill = () => 'type' in item && item.type === 'skill'; + + return ( + {isSelected() ? '▸ ' : ' '} + + {isCategory() ? '📁' : isSkill() ? '📄' : '📁'} + - {skill.name} - - - {skill.repoName} + {'name' in item ? item.name : ''} + + + ({(item as TreeNodeDisplay).skillCount} skills) + + - - - {' '}{skill.description?.slice(0, 50)} - {(skill.description?.length || 0) > 50 ? '...' : ''} - - - - ); - }} - + ); + }} + - - - ▼ {filteredSkills().length - skillsWindow().start - maxVisible()} more - + + + ▼ {treeItems().length - treeWindow().start - maxVisible()} more + + ───────────────────────────────────────────── - - j/k navigate Enter install 1-5 category b browse r refresh Esc back - + + + j/k navigate Enter install 1-5 category v tree view r refresh Esc back + + + + + j/k ↑↓ navigate Enter/→ open ←/Esc back v list view r refresh + + ); diff --git a/packages/tui/src/services/index.ts b/packages/tui/src/services/index.ts index a9bdd37f..5a5b3cc1 100644 --- a/packages/tui/src/services/index.ts +++ b/packages/tui/src/services/index.ts @@ -42,3 +42,14 @@ export { getAgentFormatInfo, translatorService, } from './translator.service.js'; + +export type { TreeServiceState, TreeNodeDisplay } from './tree.service.js'; +export { + loadOrGenerateTree, + navigateToPath, + getNodeChildren, + getNodeSkills, + getTreeStats, + searchInTree, + formatTreePath, +} from './tree.service.js'; diff --git a/packages/tui/src/services/recommend.service.ts b/packages/tui/src/services/recommend.service.ts index 903168ea..5ad2e721 100644 --- a/packages/tui/src/services/recommend.service.ts +++ b/packages/tui/src/services/recommend.service.ts @@ -6,6 +6,12 @@ export interface RecommendationDisplay { source: string; tags: string[]; installCommand?: string; + treePath?: string[]; + explanation?: { + matchedBecause: string[]; + relevantFor: string[]; + confidence: 'high' | 'medium' | 'low'; + }; } export interface RecommendationOptions { diff --git a/packages/tui/src/services/tree.service.ts b/packages/tui/src/services/tree.service.ts new file mode 100644 index 00000000..2ca3ffc2 --- /dev/null +++ b/packages/tui/src/services/tree.service.ts @@ -0,0 +1,160 @@ +import { join } from 'node:path'; +import { + generateSkillTree, + loadTree, + saveTree, + treeToText, + loadIndex, + type SkillTree, + type TreeNode, +} from '@skillkit/core'; + +const TREE_PATH = join(process.env.HOME || '~', '.skillkit', 'skill-tree.json'); + +export interface TreeServiceState { + tree: SkillTree | null; + currentNode: TreeNode | null; + currentPath: string[]; + loading: boolean; + error: string | null; +} + +export interface TreeNodeDisplay { + id: string; + name: string; + description?: string; + skillCount: number; + childCount: number; + depth: number; + isExpanded: boolean; + isCategory: boolean; +} + +export async function loadOrGenerateTree(): Promise { + try { + let tree = loadTree(TREE_PATH); + + if (!tree) { + const index = loadIndex(); + if (index && index.skills.length > 0) { + tree = generateSkillTree(index.skills); + saveTree(tree, TREE_PATH); + } + } + + if (!tree) { + return { + tree: null, + currentNode: null, + currentPath: [], + loading: false, + error: 'No skill index found. Run "skillkit recommend --update" first.', + }; + } + + return { + tree, + currentNode: tree.rootNode, + currentPath: [], + loading: false, + error: null, + }; + } catch (err) { + return { + tree: null, + currentNode: null, + currentPath: [], + loading: false, + error: err instanceof Error ? err.message : 'Failed to load tree', + }; + } +} + +export function navigateToPath(tree: SkillTree, path: string[]): TreeNode | null { + let current = tree.rootNode; + + for (const segment of path) { + const child = current.children.find( + (c) => c.name.toLowerCase() === segment.toLowerCase() + ); + if (!child) { + return null; + } + current = child; + } + + return current; +} + +export function getNodeChildren(node: TreeNode): TreeNodeDisplay[] { + return node.children.map((child) => ({ + id: child.id, + name: child.name, + description: child.description, + skillCount: child.skillCount, + childCount: child.children.length, + depth: child.depth, + isExpanded: false, + isCategory: child.children.length > 0, + })); +} + +export function getNodeSkills(node: TreeNode): string[] { + return node.skills; +} + +export function getTreeStats(tree: SkillTree): { + totalSkills: number; + totalCategories: number; + maxDepth: number; + topCategories: { name: string; count: number }[]; +} { + const topCategories = tree.rootNode.children + .sort((a, b) => b.skillCount - a.skillCount) + .slice(0, 5) + .map((c) => ({ name: c.name, count: c.skillCount })); + + return { + totalSkills: tree.totalSkills, + totalCategories: tree.totalCategories, + maxDepth: tree.maxDepth, + topCategories, + }; +} + +export function searchInTree(tree: SkillTree, query: string): TreeNodeDisplay[] { + const results: TreeNodeDisplay[] = []; + const queryLower = query.toLowerCase(); + + const traverse = (node: TreeNode) => { + const nameMatch = node.name.toLowerCase().includes(queryLower); + const skillMatch = node.skills.some((s) => + s.toLowerCase().includes(queryLower) + ); + + if (nameMatch || skillMatch) { + results.push({ + id: node.id, + name: node.name, + description: node.description, + skillCount: node.skillCount, + childCount: node.children.length, + depth: node.depth, + isExpanded: false, + isCategory: node.children.length > 0, + }); + } + + for (const child of node.children) { + traverse(child); + } + }; + + traverse(tree.rootNode); + return results; +} + +export function formatTreePath(path: string[]): string { + if (path.length === 0) return 'Root'; + return path.join(' > '); +} From 6f46741c3240896abbb1ec8058282e96c8cd12f5 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:40:56 +0000 Subject: [PATCH 2/3] fix: address CodeRabbit and Devin review feedback - Use os.homedir() instead of process.env.HOME || '~' fallback (tree.ts, tree.service.ts, mode.ts) - Add guard for division by zero in tree stats percentage - Validate parseInt result for --depth flag - Fix average stats calculation to use cacheMisses count - Extract category from data in validateCategoryScore - Remove unused skillMap field from TreeGenerator - Make reasoning provider configurable instead of hardcoded - Add try/catch for tree loading errors in Marketplace - Handle missing tree nodes during traversal --- packages/cli/src/commands/tree.ts | 13 ++++++++++--- packages/core/src/execution/mode.ts | 5 +++-- packages/core/src/reasoning/engine.ts | 16 ++++++++++------ packages/core/src/reasoning/prompts.ts | 7 +++++-- packages/core/src/recommend/engine.ts | 13 +++++++++++-- packages/core/src/tree/generator.ts | 6 ------ packages/tui/src/screens/Marketplace.tsx | 16 ++++++++++++---- packages/tui/src/services/tree.service.ts | 3 ++- 8 files changed, 53 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/tree.ts b/packages/cli/src/commands/tree.ts index 48dfdf7e..f25f6905 100644 --- a/packages/cli/src/commands/tree.ts +++ b/packages/cli/src/commands/tree.ts @@ -1,5 +1,6 @@ import { Command, Option } from 'clipanion'; import { join } from 'node:path'; +import { homedir } from 'node:os'; import { loadIndex as loadIndexFromCache, generateSkillTree, @@ -16,7 +17,7 @@ import { warn, } from '../onboarding/index.js'; -const TREE_PATH = join(process.env.HOME || '~', '.skillkit', 'skill-tree.json'); +const TREE_PATH = join(homedir(), '.skillkit', 'skill-tree.json'); export class TreeCommand extends Command { static override paths = [['tree']]; @@ -164,7 +165,9 @@ export class TreeCommand extends Command { console.log(colors.bold('Top-Level Categories:')); for (const child of tree.rootNode.children) { - const percentage = ((child.skillCount / tree.totalSkills) * 100).toFixed(1); + const percentage = tree.totalSkills > 0 + ? ((child.skillCount / tree.totalSkills) * 100).toFixed(1) + : '0.0'; console.log( ` ${colors.accent(child.name.padEnd(15))} ${String(child.skillCount).padStart(6)} skills (${percentage}%)` ); @@ -210,7 +213,11 @@ export class TreeCommand extends Command { targetNode = current; } - const maxDepth = this.depth ? parseInt(this.depth, 10) : 3; + let maxDepth = this.depth ? parseInt(this.depth, 10) : 3; + if (Number.isNaN(maxDepth) || maxDepth < 0) { + warn('Invalid depth value. Using default depth of 3.'); + maxDepth = 3; + } this.renderNode(targetNode, '', true, 0, maxDepth); console.log(''); diff --git a/packages/core/src/execution/mode.ts b/packages/core/src/execution/mode.ts index 0d6632af..f6e7dbef 100644 --- a/packages/core/src/execution/mode.ts +++ b/packages/core/src/execution/mode.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { homedir } from 'node:os'; import type { ConnectorMapping } from '../connectors/types.js'; import { suggestMappingsFromMcp } from '../connectors/utils.js'; @@ -36,8 +37,8 @@ export interface ExecutionModeConfig { } const DEFAULT_MCP_CONFIG_PATHS = [ - join(process.env.HOME || '~', '.mcp.json'), - join(process.env.HOME || '~', '.config', 'claude', 'mcp.json'), + join(homedir(), '.mcp.json'), + join(homedir(), '.config', 'claude', 'mcp.json'), '.mcp.json', 'mcp.json', ]; diff --git a/packages/core/src/reasoning/engine.ts b/packages/core/src/reasoning/engine.ts index 91da4e72..ec90a6b5 100644 --- a/packages/core/src/reasoning/engine.ts +++ b/packages/core/src/reasoning/engine.ts @@ -585,13 +585,17 @@ export class ReasoningEngine { } private updateStats(processingTimeMs: number, resultCount: number): void { - const totalQueries = this.stats.totalQueries; + const cacheMisses = this.stats.cacheMisses; - this.stats.averageProcessingTimeMs = - (this.stats.averageProcessingTimeMs * (totalQueries - 1) + processingTimeMs) / totalQueries; - - this.stats.averageResultsPerQuery = - (this.stats.averageResultsPerQuery * (totalQueries - 1) + resultCount) / totalQueries; + if (cacheMisses === 1) { + this.stats.averageProcessingTimeMs = processingTimeMs; + this.stats.averageResultsPerQuery = resultCount; + } else { + this.stats.averageProcessingTimeMs = + (this.stats.averageProcessingTimeMs * (cacheMisses - 1) + processingTimeMs) / cacheMisses; + this.stats.averageResultsPerQuery = + (this.stats.averageResultsPerQuery * (cacheMisses - 1) + resultCount) / cacheMisses; + } } getStats(): ReasoningEngineStats { diff --git a/packages/core/src/reasoning/prompts.ts b/packages/core/src/reasoning/prompts.ts index 382d3ab9..99e71330 100644 --- a/packages/core/src/reasoning/prompts.ts +++ b/packages/core/src/reasoning/prompts.ts @@ -213,11 +213,14 @@ export function validateSearchPlan(data: unknown): SearchPlan { }; } -export function validateCategoryScore(data: unknown): CategoryScore { +export function validateCategoryScore( + data: unknown, + fallbackCategory = '' +): CategoryScore { const score = data as Record; return { - category: '', + category: typeof score.category === 'string' ? score.category : fallbackCategory, score: typeof score.score === 'number' ? Math.min(100, Math.max(0, score.score)) : 0, reasoning: typeof score.reasoning === 'string' ? score.reasoning : '', }; diff --git a/packages/core/src/recommend/engine.ts b/packages/core/src/recommend/engine.ts index 6036cb4c..61b2a4be 100644 --- a/packages/core/src/recommend/engine.ts +++ b/packages/core/src/recommend/engine.ts @@ -671,10 +671,19 @@ export function createRecommendationEngine( */ export class ReasoningRecommendationEngine extends RecommendationEngine { private reasoningEngine: import('../reasoning/engine.js').ReasoningEngine | null = null; + private reasoningConfig?: import('../reasoning/types.js').ReasoningConfig; + + constructor( + weights?: Partial, + reasoningConfig?: Partial + ) { + super(weights); + this.reasoningConfig = reasoningConfig as import('../reasoning/types.js').ReasoningConfig; + } async initReasoning(): Promise { - const { ReasoningEngine } = await import('../reasoning/engine.js'); - this.reasoningEngine = new ReasoningEngine({ provider: 'mock' }); + const { createReasoningEngine } = await import('../reasoning/engine.js'); + this.reasoningEngine = createReasoningEngine(this.reasoningConfig); const index = this.getIndex(); if (index) { diff --git a/packages/core/src/tree/generator.ts b/packages/core/src/tree/generator.ts index 9251caca..7b86aeff 100644 --- a/packages/core/src/tree/generator.ts +++ b/packages/core/src/tree/generator.ts @@ -15,18 +15,12 @@ const DEFAULT_OPTIONS: Required = { export class TreeGenerator { private options: Required; - private skillMap: Map = new Map(); constructor(options?: TreeGeneratorOptions) { this.options = { ...DEFAULT_OPTIONS, ...options }; } generateTree(skills: SkillSummary[]): SkillTree { - this.skillMap.clear(); - for (const skill of skills) { - this.skillMap.set(skill.name, skill); - } - const rootNode = this.buildTreeFromTaxonomy(skills); const { totalCategories, maxDepth } = this.countTreeStats(rootNode); diff --git a/packages/tui/src/screens/Marketplace.tsx b/packages/tui/src/screens/Marketplace.tsx index d6e4a705..b6301fc2 100644 --- a/packages/tui/src/screens/Marketplace.tsx +++ b/packages/tui/src/screens/Marketplace.tsx @@ -91,10 +91,14 @@ export function Marketplace(props: MarketplaceProps) { setError(`Some repos failed (${failed.join(', ')}) and no skills loaded`); } - const tree = await loadOrGenerateTree(); - setTreeState(tree); - if (tree.tree && tree.currentNode) { - updateTreeItems(tree.currentNode); + try { + const tree = await loadOrGenerateTree(); + setTreeState(tree); + if (tree.tree && tree.currentNode) { + updateTreeItems(tree.currentNode); + } + } catch (e) { + console.error('Failed to load skill tree:', e); } setLoading(false); @@ -202,6 +206,10 @@ export function Marketplace(props: MarketplaceProps) { const child = node.children.find(c => c.name === segment); if (child) { node = child; + } else { + setCurrentPath([]); + updateTreeItems(state.tree.rootNode); + return; } } updateTreeItems(node); diff --git a/packages/tui/src/services/tree.service.ts b/packages/tui/src/services/tree.service.ts index 2ca3ffc2..0f99c8b0 100644 --- a/packages/tui/src/services/tree.service.ts +++ b/packages/tui/src/services/tree.service.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { homedir } from 'node:os'; import { generateSkillTree, loadTree, @@ -9,7 +10,7 @@ import { type TreeNode, } from '@skillkit/core'; -const TREE_PATH = join(process.env.HOME || '~', '.skillkit', 'skill-tree.json'); +const TREE_PATH = join(homedir(), '.skillkit', 'skill-tree.json'); export interface TreeServiceState { tree: SkillTree | null; From 1f323cd6b44b2bc6352bc4120a14bb25f33dd815 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:55:34 +0000 Subject: [PATCH 3/3] fix(execution): clear timeout timer to prevent resource leak Store setTimeout ID and clear it in finally block when step completes successfully before timeout. This prevents orphaned timers from accumulating during long-running applications. --- packages/core/src/execution/manager.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/execution/manager.ts b/packages/core/src/execution/manager.ts index 3583f21e..633edc5d 100644 --- a/packages/core/src/execution/manager.ts +++ b/packages/core/src/execution/manager.ts @@ -157,14 +157,22 @@ export class ExecutionManager { let lastError: Error | null = null; while (step.retryCount <= maxRetries) { + let timeoutId: ReturnType | undefined; try { if (stepDef) { const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Step ${step.name} timed out`)), timeout); + timeoutId = setTimeout( + () => reject(new Error(`Step ${step.name} timed out`)), + timeout + ); }); const executePromise = stepDef.execute(step.input || {}, context); - step.output = await Promise.race([executePromise, timeoutPromise]); + try { + step.output = await Promise.race([executePromise, timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } } step.status = 'completed';