Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions packages/cli/src/commands/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export class RecommendCommand extends Command {
['Update skill index', '$0 recommend --update'],
['Search for skills by task', '$0 recommend --task "authentication"'],
['Search for skills (alias)', '$0 recommend --search "testing"'],
['Hybrid search (vector + keyword)', '$0 recommend --search "auth" --hybrid'],
['Hybrid search with query expansion', '$0 recommend --search "auth" --hybrid --expand'],
['Build hybrid search index', '$0 recommend --build-index'],
],
});

Expand Down Expand Up @@ -125,6 +128,26 @@ export class RecommendCommand extends Command {
description: 'Show category path for each recommendation',
});

// Hybrid search mode
hybrid = Option.Boolean('--hybrid,-H', false, {
description: 'Use hybrid search (vector + keyword)',
});

// Query expansion
expand = Option.Boolean('--expand,-x', false, {
description: 'Enable query expansion (requires --hybrid)',
});

// Reranking
rerank = Option.Boolean('--rerank', false, {
description: 'Enable LLM reranking (requires --hybrid)',
});

// Build index
buildIndex = Option.Boolean('--build-index', false, {
description: 'Build/rebuild the hybrid search embedding index',
});

async execute(): Promise<number> {
const targetPath = resolve(this.projectPath || process.cwd());

Expand All @@ -133,6 +156,16 @@ export class RecommendCommand extends Command {
return await this.updateIndex();
}

// Handle hybrid index building
if (this.buildIndex) {
return await this.buildHybridIndex();
}

// Validate hybrid-dependent options
if ((this.expand || this.rerank) && !this.hybrid) {
warn('--expand and --rerank require --hybrid flag. These options will be ignored.');
}

if (!this.quiet && !this.json) {
header('Skill Recommendations');
}
Expand Down Expand Up @@ -168,6 +201,9 @@ export class RecommendCommand extends Command {
// Handle search mode (--search or --task)
const searchQuery = this.search || this.task;
if (searchQuery) {
if (this.hybrid) {
return await this.handleHybridSearch(engine, searchQuery);
}
return this.handleSearch(engine, searchQuery);
}

Expand Down Expand Up @@ -446,6 +482,132 @@ export class RecommendCommand extends Command {
console.log(colors.muted('More details: skillkit recommend --explain --verbose'));
}

private async handleHybridSearch(engine: RecommendationEngine, query: string): Promise<number> {
if (!this.quiet && !this.json) {
header(`Hybrid Search: "${query}"`);
}

const s = !this.quiet && !this.json ? spinner() : null;
s?.start('Initializing hybrid search...');

try {
await engine.initHybridSearch();
s?.message('Searching...');

const results = await engine.hybridSearch({
query,
limit: this.limit ? parseInt(this.limit, 10) : 10,
hybrid: true,
enableExpansion: this.expand,
enableReranking: this.rerank,
filters: {
minScore: this.minScore ? parseInt(this.minScore, 10) : undefined,
},
});

s?.stop(`Found ${results.length} results`);

if (this.json) {
console.log(JSON.stringify(results, null, 2));
return 0;
}

if (results.length === 0) {
warn(`No skills found matching "${query}"`);
return 0;
}

console.log('');
console.log(colors.bold(`Hybrid search results for "${query}" (${results.length} found):`));
if (this.expand && results[0]?.expandedTerms?.length) {
console.log(colors.muted(` Expanded: ${results[0].expandedTerms.join(', ')}`));
}
console.log('');

for (const result of results) {
let relevanceColor: (text: string) => string;
if (result.relevance >= 70) {
relevanceColor = colors.success;
} else if (result.relevance >= 50) {
relevanceColor = colors.warning;
} else {
relevanceColor = colors.muted;
}
const relevanceBar = progressBar(result.relevance, 100, 10);

console.log(` ${relevanceColor(`${result.relevance}%`)} ${colors.dim(relevanceBar)} ${colors.bold(result.skill.name)}`);

if (result.skill.description) {
console.log(` ${colors.muted(truncate(result.skill.description, 70))}`);
}

if (this.verbose) {
const scores: string[] = [];
if (typeof result.vectorSimilarity === 'number') {
scores.push(`vector: ${(result.vectorSimilarity * 100).toFixed(0)}%`);
}
if (typeof result.keywordScore === 'number') {
scores.push(`keyword: ${result.keywordScore.toFixed(0)}%`);
}
if (typeof result.rrfScore === 'number') {
scores.push(`rrf: ${result.rrfScore.toFixed(3)}`);
}
if (scores.length > 0) {
console.log(` ${colors.dim('Scores:')} ${scores.join(' | ')}`);
}
}

if (result.matchedTerms.length > 0) {
console.log(` ${colors.dim('Matched:')} ${result.matchedTerms.join(', ')}`);
}

console.log('');
}

return 0;
} catch (err) {
s?.stop(colors.error('Hybrid search failed'));
console.log(colors.muted(err instanceof Error ? err.message : String(err)));
console.log(colors.muted('Falling back to standard search...'));
return this.handleSearch(engine, query);
}
}

private async buildHybridIndex(): Promise<number> {
if (!this.quiet) {
header('Build Hybrid Search Index');
}

const index = this.loadIndex();
if (!index || index.skills.length === 0) {
warn('No skill index found. Run --update first.');
return 1;
}

const s = spinner();
s.start('Initializing...');

try {
const engine = new RecommendationEngine();
engine.loadIndex(index);

await engine.buildHybridIndex((progress) => {
const percentage = Math.round((progress.current / progress.total) * 100);
s.message(`${progress.phase}: ${progress.message || ''} (${percentage}%)`);
});

s.stop(colors.success(`${symbols.success} Built hybrid index for ${index.skills.length} skills`));
console.log(colors.muted(' Index stored in: ~/.skillkit/search.db'));
console.log(colors.muted(' Use --hybrid flag for vector+keyword search\n'));

return 0;
} catch (err) {
s.stop(colors.error('Failed to build hybrid index'));
console.log(colors.muted(err instanceof Error ? err.message : String(err)));
return 1;
}
}

private handleSearch(engine: RecommendationEngine, query: string): number {
if (!this.quiet && !this.json) {
header(`Search: "${query}"`);
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"./learning": {
"import": "./dist/learning/index.js",
"types": "./dist/learning/index.d.ts"
},
"./search": {
"import": "./dist/search/index.js",
"types": "./dist/search/index.d.ts"
}
},
"files": [
Expand All @@ -53,7 +57,13 @@
"yaml": "^2.6.1",
"zod": "^3.24.1"
},
"optionalDependencies": {
"node-llama-cpp": "^3.15.0",
"better-sqlite3": "^12.0.0",
"sqlite-vec": "^0.1.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.5",
Comment on lines 60 to 67
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check metadata for optional native deps
npm view node-llama-cpp@^3.0.0 engines os cpu 2>&1 | head -50
echo "---"
npm view better-sqlite3@^11.0.0 engines os cpu 2>&1 | head -50
echo "---"
npm view sqlite-vec@^0.1.0 engines os cpu 2>&1 | head -50

Repository: rohitg00/skillkit

Length of output: 1553


🏁 Script executed:

# Search for imports and usage of these optional dependencies
rg "node-llama-cpp|better-sqlite3|sqlite-vec" --type js --type ts --type json -B 2 -A 2

Repository: rohitg00/skillkit

Length of output: 5201


🏁 Script executed:

# Check if there are conditional imports or try-catch blocks for these packages
rg "require.*node-llama-cpp|require.*better-sqlite3|require.*sqlite-vec|import.*node-llama-cpp|import.*better-sqlite3|import.*sqlite-vec" --type js --type ts -B 3 -A 3

Repository: rohitg00/skillkit

Length of output: 2141


🏁 Script executed:

# Check the package.json file for context
cat -n packages/core/package.json | sed -n '55,75p'

Repository: rohitg00/skillkit

Length of output: 669


🏁 Script executed:

# Get the full error handling in vector-store.ts
cat -n packages/core/src/search/vector-store.ts | grep -A 30 "sqlite-vec.*optional"

Repository: rohitg00/skillkit

Length of output: 1235


🌐 Web query:

better-sqlite3 Node.js version requirements engines compatibility

💡 Result:

  • Current better-sqlite3 (v12.4.6) Node.js engine constraint (npm engines): node: "20.x || 22.x || 23.x || 24.x || 25.x" — notably Node 21.x is not included. [1]
  • Project docs/README minimum: states “Requires Node.js v14.21.1 or later”, and notes prebuilt binaries are available for LTS versions (but the actual install-time compatibility is governed by the published version’s package.json / native ABI). [2]
  • Older major line (example: v11.9.0): its published package.json on unpkg shows no engines field, so npm won’t enforce a Node version via engines for that release line (though it still may or may not build/run on your Node due to native ABI). [3]

Sources:
[1] better-sqlite3@12.4.6 package.json (engines) (app.unpkg.com)
[2] better-sqlite3 README / install notes (github.com)
[3] better-sqlite3@11.9.0 package.json (no engines) (app.unpkg.com)

Citations:


🌐 Web query:

sqlite-vec Node.js version requirements engines

💡 Result:

The sqlite-vec npm distribution does not declare a Node.js version requirement via package.json#engines (at least in the published 0.1.7-alpha.2 artifacts). For example, the platform packages’ package.json files contain no engines field—only metadata like os/cpu and entrypoints. [1], [2]

Practically, that means npm won’t enforce a minimum Node version for sqlite-vec itself; your effective Node version requirement will come from whichever SQLite driver you use to load the extension (e.g., better-sqlite3 / sqlite3). The upstream project documents installing via npm install sqlite-vec for Node.js. [3]

Sources: [1], [2], [3]


Tighten node-llama-cpp version constraint and ensure consistent fallback behavior.

The node-llama-cpp range ^3.0.0 can resolve to versions 3.7.0+ which require Node >=20.0.0, but the package declares engines Node >=18.0.0. This creates an incompatibility: installation will fail on Node 18.x.

Additionally, fallback behavior is inconsistent—better-sqlite3 and sqlite-vec gracefully degrade when missing, but node-llama-cpp throws an error in expansion.ts and embeddings.ts rather than following the same fallback pattern used in vector-store.ts.

Either:

  • Narrow node-llama-cpp to ^3.0.0 <3.7.0 to maintain Node 18 compatibility, or
  • Update the engines field to >=20.0.0 and document the breaking change.
🤖 Prompt for AI Agents
In `@packages/core/package.json` around lines 60 - 67, Tighten the node-llama-cpp
dependency and align fallback behavior: change the node-llama-cpp semver in
package.json to restrict upgrades (e.g., ^3.0.0 <3.7.0) unless you intend to
raise the engines field to ">=20.0.0" and document that breaking change; then
update expansion.ts and embeddings.ts to mirror the safe import/fallback pattern
used in vector-store.ts (i.e., attempt to require/import node-llama-cpp inside a
try/catch or guarded lazy loader, set a null/undefined client when unavailable,
and throw only when actually calling functionality that requires it) so missing
node-llama-cpp degrades gracefully like better-sqlite3 and sqlite-vec.

"tsup": "^8.3.5",
"typescript": "^5.7.2",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,6 @@ export * from './connectors/index.js';

// Execution Flow (Step Tracking & Metrics - Phase 21)
export * from './execution/index.js';

// Hybrid Search (QMD-Inspired Vector + Keyword Search)
export * from './search/index.js';
91 changes: 91 additions & 0 deletions packages/core/src/recommend/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,51 @@ import type {
SearchOptions,
SearchResult,
FreshnessResult,
RecommendHybridSearchOptions,
RecommendHybridSearchResult,
} from './types.js';
import { DEFAULT_SCORING_WEIGHTS, TAG_TO_TECH, getTechTags } from './types.js';
import type { HybridSearchPipeline } from '../search/hybrid.js';

/**
* Recommendation engine for matching skills to project profiles
*/
export class RecommendationEngine {
private weights: ScoringWeights;
private index: SkillIndex | null = null;
private hybridPipeline: HybridSearchPipeline | null = null;

constructor(weights?: Partial<ScoringWeights>) {
this.weights = { ...DEFAULT_SCORING_WEIGHTS, ...weights };
}

/**
* Initialize hybrid search pipeline for vector + keyword search
*/
async initHybridSearch(): Promise<void> {
const { createHybridSearchPipeline } = await import('../search/hybrid.js');
this.hybridPipeline = createHybridSearchPipeline();
if (this.index) {
this.hybridPipeline.loadSkillsIndex(this.index);
}
await this.hybridPipeline.initialize();
}

/**
* Check if hybrid search is available
*/
isHybridSearchAvailable(): boolean {
return this.hybridPipeline !== null && this.hybridPipeline.isInitialized();
}

/**
* Load skill index from cache or generate from local skills
*/
loadIndex(index: SkillIndex): void {
this.index = index;
if (this.hybridPipeline) {
this.hybridPipeline.loadSkillsIndex(index);
}
}

/**
Expand Down Expand Up @@ -603,6 +629,71 @@ export class RecommendationEngine {
return { relevance, matchedTerms, snippet };
}

/**
* Hybrid search combining vector embeddings and keyword matching
*/
async hybridSearch(options: RecommendHybridSearchOptions): Promise<RecommendHybridSearchResult[]> {
const { query, limit = 10, hybrid = true, enableExpansion = false, enableReranking = false, filters } = options;

if (!hybrid || !this.hybridPipeline) {
const basicResults = this.search({ query, limit, semantic: true, filters });
return basicResults.map((r) => ({
...r,
hybridScore: r.relevance / 100,
}));
}

const response = await this.hybridPipeline.search({
query,
limit,
enableExpansion,
enableReranking,
});

let results = response.results.map((r) => ({
skill: r.skill,
relevance: r.relevance,
matchedTerms: r.matchedTerms,
snippet: r.snippet,
hybridScore: r.hybridScore,
vectorSimilarity: r.vectorSimilarity,
keywordScore: r.keywordScore,
rrfScore: r.rrfScore,
expandedTerms: r.expandedTerms,
}));

if (filters?.tags && filters.tags.length > 0) {
results = results.filter((r) =>
r.skill.tags?.some((t) => filters.tags!.includes(t))
);
}
if (filters?.verified) {
results = results.filter((r) => r.skill.verified);
}
if (filters?.minScore) {
results = results.filter((r) => r.relevance >= filters.minScore!);
}

return results.slice(0, limit);
}

/**
* Build hybrid search index from skills
*/
async buildHybridIndex(
onProgress?: (progress: { phase: string; current: number; total: number; message?: string }) => void
): Promise<void> {
if (!this.index) {
throw new Error('No skill index loaded. Call loadIndex() first.');
}

if (!this.hybridPipeline) {
await this.initHybridSearch();
}

await this.hybridPipeline!.buildIndex(this.index.skills, onProgress);
}

/**
* Check freshness of installed skills against project dependencies
*
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/recommend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,25 @@ export interface ReasoningRecommendationResult extends RecommendationResult {
strategy: string;
};
}

/**
* Hybrid search options for RecommendationEngine
*/
export interface RecommendHybridSearchOptions extends SearchOptions {
hybrid?: boolean;
enableExpansion?: boolean;
enableReranking?: boolean;
semanticWeight?: number;
keywordWeight?: number;
}

/**
* Hybrid search result with additional metadata for RecommendationEngine
*/
export interface RecommendHybridSearchResult extends SearchResult {
hybridScore?: number;
vectorSimilarity?: number;
keywordScore?: number;
rrfScore?: number;
expandedTerms?: string[];
}
Loading