← 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 };