← back to The Ai Factory
src/stages/research.js
64 lines
// Stage 2 — research. Scan ~/.claude/agents/ and ~/.claude/skills/ for artifacts
// that look similar to spec.artifact_name. Non-blocking: this only WARNS via an
// event so Steve doesn't accidentally activate a near-duplicate. Activation can
// still proceed; the warning lives in the events log.
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');
async function listExisting() {
const out = [];
try {
for (const f of await fs.readdir(AGENTS_DIR)) {
if (f.endsWith('.md')) out.push({ type: 'subagent', name: f.replace(/\.md$/, '') });
}
} catch (e) { if (e.code !== 'ENOENT') throw e; }
try {
for (const d of await fs.readdir(SKILLS_DIR, { withFileTypes: true })) {
if (!d.isDirectory() || d.name.startsWith('_') || d.name.startsWith('.')) continue;
out.push({ type: 'skill', name: d.name });
}
} catch (e) { if (e.code !== 'ENOENT') throw e; }
return out;
}
// Bigram Jaccard similarity, 0.0 – 1.0. Cheap, deterministic, no LLM call.
function similarity(a, b) {
const bigrams = (s) => {
const out = new Set();
const lower = s.toLowerCase();
for (let i = 0; i < lower.length - 1; i++) out.add(lower.slice(i, i + 2));
return out;
};
const A = bigrams(a);
const B = bigrams(b);
if (!A.size || !B.size) return 0;
let inter = 0;
for (const x of A) if (B.has(x)) inter++;
const union = A.size + B.size - inter;
return union === 0 ? 0 : inter / union;
}
async function runResearch({ spec }) {
const existing = await listExisting();
const matches = [];
for (const e of existing) {
if (e.type !== spec.artifact_type) continue;
if (e.name === spec.artifact_name) {
matches.push({ ...e, similarity: 1.0, conflict: 'exact-name' });
continue;
}
const s = similarity(e.name, spec.artifact_name);
if (s >= 0.5) matches.push({ ...e, similarity: Number(s.toFixed(2)) });
}
matches.sort((a, b) => b.similarity - a.similarity);
const exact = matches.find(m => m.conflict === 'exact-name');
return { existing_count: existing.length, matches: matches.slice(0, 5), exact_conflict: !!exact };
}
module.exports = { runResearch };