← back to Fleet Rag

lib/index.js

213 lines

'use strict';

const fs   = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { embed } = require('./embed');
const { redact } = require('./redact');

const VECTORS_PATH = path.join(__dirname, '../data/vectors.jsonl');
const CHUNK_SIZE   = 512;
const OVERLAP      = 50;
const HOME         = process.env.HOME || '/Users/macstudio3';

// ── helpers ──────────────────────────────────────────────────────────────────

function chunkText(text, source_type, source_path) {
  const chunks = [];
  let i = 0;
  while (i < text.length) {
    const slice = text.slice(i, i + CHUNK_SIZE);
    if (slice.trim().length > 20) {
      chunks.push({ source_type, source_path, chunk_text: slice });
    }
    i += CHUNK_SIZE - OVERLAP;
  }
  return chunks;
}

function safeRead(filePath, maxLines) {
  try {
    if (!fs.existsSync(filePath)) return null;
    const raw = fs.readFileSync(filePath, 'utf8');
    if (!maxLines) return raw;
    return raw.split('\n').slice(0, maxLines).join('\n');
  } catch { return null; }
}

// ── source collectors ─────────────────────────────────────────────────────────

function collectPm2() {
  const docs = [];
  try {
    const raw = execSync('pm2 jlist 2>/dev/null', { encoding: 'utf8', timeout: 10000 });
    const procs = JSON.parse(raw);
    for (const p of procs) {
      const name   = p.name || 'unknown';
      const cwd    = p.pm2_env?.pm_cwd || p.pm2_env?.cwd || '';
      const exec   = p.pm2_env?.pm_exec_path || '';
      const status = p.pm2_env?.status || 'unknown';
      const port   = p.pm2_env?.env?.PORT || p.pm2_env?.PORT || '';
      const text = [
        `pm2 service: ${name}`,
        `status: ${status}`,
        `cwd: ${cwd}`,
        `exec: ${exec}`,
        port ? `port: ${port}` : '',
      ].filter(Boolean).join('\n');
      docs.push({ source_type: 'pm2', source_path: `pm2://${name}`, chunk_text: text });

      // Also grab README, package.json description, first 50 lines of server.js
      if (cwd) {
        const readmePath = path.join(cwd, 'README.md');
        const readme = safeRead(readmePath);
        if (readme) {
          chunkText(readme, 'readme', readmePath).forEach(c => docs.push(c));
        }

        const pkgPath = path.join(cwd, 'package.json');
        const pkgRaw = safeRead(pkgPath);
        if (pkgRaw) {
          try {
            const pkg = JSON.parse(pkgRaw);
            const desc = pkg.description || '';
            const scripts = JSON.stringify(pkg.scripts || {});
            if (desc || scripts) {
              docs.push({
                source_type: 'package_json',
                source_path: pkgPath,
                chunk_text: `project: ${name}\ndescription: ${desc}\nscripts: ${scripts}`,
              });
            }
          } catch {}
        }

        // Prefer server.js, then index.js, then app.js
        for (const entry of ['server.js', 'index.js', 'app.js', 'src/server.js', 'src/index.js']) {
          const ep = path.join(cwd, entry);
          const lines = safeRead(ep, 50);
          if (lines) {
            docs.push({
              source_type: 'server_head',
              source_path: ep,
              chunk_text: `service: ${name} (${entry} first 50 lines)\n${lines}`,
            });
            break;
          }
        }
      }
    }
  } catch (e) {
    console.warn('[indexer] pm2 jlist failed:', e.message);
  }
  return docs;
}

function collectMemory() {
  const docs = [];
  // Claude keys its per-project memory dir on the working dir with slashes → dashes.
  // Derive it from the live HOME so this tracks the current machine instead of a
  // hardcoded pre-migration home (was -Users-stevestudio2, which post mac2→mac3
  // migration points at a STALE memory copy — the live memory is under the derived path).
  const dashedHome = HOME.replace(/\//g, '-');
  const memDir = path.join(HOME, '.claude/projects', `${dashedHome}/memory`);
  if (!fs.existsSync(memDir)) return docs;
  const files = fs.readdirSync(memDir).filter(f => f.endsWith('.md'));
  for (const f of files) {
    const fp = path.join(memDir, f);
    const text = safeRead(fp);
    if (text) chunkText(text, 'memory', fp).forEach(c => docs.push(c));
  }
  return docs;
}

function collectCncp() {
  const docs = [];
  const cfgPath = path.join(HOME, 'cncp-starter/cncp-config.json');
  const raw = safeRead(cfgPath);
  if (!raw) return docs;
  try {
    const cfg = JSON.parse(raw);
    const domains = cfg.domains || [];
    // Group into chunks of 10 domains each
    for (let i = 0; i < domains.length; i += 10) {
      const slice = domains.slice(i, i + 10);
      const text = slice.map(d =>
        typeof d === 'string' ? d : `${d.name || ''} | ${d.status || ''} | ${d.reg || ''} | ${d.suggestion || ''}`
      ).join('\n');
      docs.push({ source_type: 'cncp_domains', source_path: cfgPath, chunk_text: `domains:\n${text}` });
    }
  } catch (e) {
    console.warn('[indexer] cncp-config.json parse failed:', e.message);
  }
  return docs;
}

// ── main export ───────────────────────────────────────────────────────────────

async function buildIndex(onProgress) {
  const log = onProgress || console.log;

  log('[indexer] collecting pm2 + project files...');
  const pm2Docs    = collectPm2();
  log(`[indexer] pm2 yielded ${pm2Docs.length} chunks`);

  log('[indexer] collecting memory files...');
  const memDocs    = collectMemory();
  log(`[indexer] memory yielded ${memDocs.length} chunks`);

  log('[indexer] collecting cncp domains...');
  const cncpDocs   = collectCncp();
  log(`[indexer] cncp yielded ${cncpDocs.length} chunks`);

  const all = [...pm2Docs, ...memDocs, ...cncpDocs];
  log(`[indexer] total chunks to embed: ${all.length}`);

  const out = fs.createWriteStream(VECTORS_PATH);
  // Resolve only once the file is fully flushed to disk. server.js calls
  // refreshCache() (a synchronous read) immediately after buildIndex() returns,
  // so returning before 'finish' can load a truncated index. Attach the error
  // listener at creation so a mid-write error can't become an uncaught 'error'.
  const streamDone = new Promise((resolve, reject) => {
    out.on('finish', resolve);
    out.on('error', reject);
  });
  let ok = 0, fail = 0;

  for (let i = 0; i < all.length; i++) {
    const doc = all[i];
    doc.chunk_text = redact(doc.chunk_text);   // strip known secret shapes before embed/persist
    try {
      const embedding = await embed(doc.chunk_text);
      const record = { id: i, ...doc, embedding };
      out.write(JSON.stringify(record) + '\n');
      ok++;
      if (ok % 20 === 0) log(`[indexer] embedded ${ok}/${all.length}...`);
    } catch (e) {
      fail++;
      console.warn(`[indexer] embed fail for chunk ${i}:`, e.message);
    }
  }

  out.end();
  await streamDone;
  log(`[indexer] done. ok=${ok} fail=${fail} path=${VECTORS_PATH}`);
  return { ok, fail, path: VECTORS_PATH };
}

function loadVectors() {
  if (!fs.existsSync(VECTORS_PATH)) return [];
  const lines = fs.readFileSync(VECTORS_PATH, 'utf8').split('\n').filter(Boolean);
  // Skip (don't throw on) an unparseable line so one truncated/corrupt record can't
  // crash server startup — refreshCache() runs at module load and would take the
  // whole process down otherwise.
  const records = [];
  for (const l of lines) {
    try { records.push(JSON.parse(l)); }
    catch (e) { console.warn('[fleet-rag] skipping unparseable vectors.jsonl line:', e.message); }
  }
  return records;
}

module.exports = { buildIndex, loadVectors, VECTORS_PATH };