Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this state. Subsequent commits in this branch layer the knowledge base, post-mortem coach, and persona narration on top. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
116 lines
3.8 KiB
JavaScript
116 lines
3.8 KiB
JavaScript
// Skill-library retrieval — adapted from Mindcraft skill_library.js.
|
|
//
|
|
// Each registered skill exposes a doc string (id, title, top-of-file
|
|
// jsdoc comment when present). When auto-patch.js builds the prompt for
|
|
// Pi, we rank docs by overlap with the proposal text and include the
|
|
// top-k similar skills so Pi can crib patterns from working code.
|
|
//
|
|
// We deliberately use word-overlap (Mindcraft's fallback) instead of
|
|
// embeddings — zero deps, no network, deterministic. If we later want
|
|
// embeddings the API stays the same.
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { listSkills } from "./skills/index.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// Pull the leading jsdoc-style comment block out of a skill source file.
|
|
// Mineflayer-style skill files start with a `// ... // ...` comment header
|
|
// that already documents intent; we treat that as the doc string.
|
|
function extractHeaderComment(src) {
|
|
if (!src) return "";
|
|
const lines = src.split("\n");
|
|
const out = [];
|
|
for (const line of lines) {
|
|
const t = line.trim();
|
|
if (t.startsWith("//")) {
|
|
out.push(t.replace(/^\/\/\s?/, ""));
|
|
} else if (out.length > 0) {
|
|
break;
|
|
} else if (t === "") {
|
|
continue;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return out.join(" ").slice(0, 800);
|
|
}
|
|
|
|
function skillFilePath(id) {
|
|
const slug = id.replace(/\./g, "-");
|
|
const tail = id.split(".").slice(1).join("-");
|
|
const candidates = [
|
|
path.join(__dirname, "skills", `${slug}.js`),
|
|
path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`),
|
|
tail ? path.join(__dirname, "skills", `${tail}.js`) : null,
|
|
tail ? path.join(__dirname, "skills", `${tail.replace(/-/g, "_")}.js`) : null,
|
|
];
|
|
for (const p of candidates) if (p && fs.existsSync(p)) return p;
|
|
return null;
|
|
}
|
|
|
|
let cache = null;
|
|
function loadDocs() {
|
|
if (cache) return cache;
|
|
const entries = [];
|
|
for (const sk of listSkills()) {
|
|
const fp = skillFilePath(sk.id);
|
|
let header = "";
|
|
if (fp) {
|
|
try { header = extractHeaderComment(fs.readFileSync(fp, "utf8")); } catch {}
|
|
}
|
|
entries.push({ id: sk.id, title: sk.title, doc: `${sk.id} — ${sk.title}\n${header}` });
|
|
}
|
|
cache = entries;
|
|
return cache;
|
|
}
|
|
|
|
// Word-overlap scoring identical in spirit to Mindcraft's
|
|
// wordOverlapScore: lower-case, split on non-word, count overlap.
|
|
const STOP = new Set(["the", "a", "an", "to", "of", "and", "or", "for", "in", "on", "with", "is", "are", "was", "be", "if", "we", "you", "i", "it", "that", "this", "by", "from", "at", "as", "but", "not"]);
|
|
function tokenise(s) {
|
|
return new Set(
|
|
String(s || "")
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9_]+/)
|
|
.filter((w) => w.length > 2 && !STOP.has(w)),
|
|
);
|
|
}
|
|
export function wordOverlapScore(a, b) {
|
|
const A = tokenise(a);
|
|
const B = tokenise(b);
|
|
if (A.size === 0 || B.size === 0) return 0;
|
|
let inter = 0;
|
|
for (const w of A) if (B.has(w)) inter++;
|
|
return inter / Math.sqrt(A.size * B.size);
|
|
}
|
|
|
|
export function relevantSkillDocs(query, { k = 3, alwaysShow = [] } = {}) {
|
|
const docs = loadDocs();
|
|
const scored = docs.map((e) => ({ ...e, score: wordOverlapScore(query, e.doc) }));
|
|
scored.sort((a, b) => b.score - a.score);
|
|
const picked = new Map();
|
|
for (const id of alwaysShow) {
|
|
const hit = docs.find((d) => d.id === id);
|
|
if (hit) picked.set(hit.id, hit);
|
|
}
|
|
for (const s of scored.slice(0, k)) picked.set(s.id, s);
|
|
return Array.from(picked.values());
|
|
}
|
|
|
|
// Render the relevant-docs block for embedding into a Pi prompt.
|
|
export function renderRelevantDocs(query, opts) {
|
|
const picked = relevantSkillDocs(query, opts);
|
|
if (picked.length === 0) return "_(no skill docs registered)_";
|
|
return picked
|
|
.map((e) => `### \`${e.id}\` — ${e.title}\n${e.doc}`)
|
|
.join("\n\n");
|
|
}
|
|
|
|
// Reset for tests.
|
|
export function _resetCache() { cache = null; }
|