feat: sync shared skills contract and graph-first discipline

This commit is contained in:
“BeeRad”
2026-04-17 20:58:21 +10:00
parent 5f4d1f61c2
commit 7e663abfb0
64 changed files with 1924 additions and 1902 deletions
@@ -1,10 +0,0 @@
'use strict';
const skillService = require('./skillService');
module.exports = {
listGuides: skillService.listSkills,
readGuide: skillService.readSkill,
writeGuide: skillService.writeSkill,
deleteGuide: skillService.deleteSkill,
};
@@ -2,6 +2,108 @@
const { query, transaction, getDb } = require('./sqlite-client');
function sanitizeFtsQuery(input) {
return String(input || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.trim()
.split(/\s+/)
.filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word))
.join(' ');
}
function extractRelaxedSearchTerms(queryText) {
const stopWords = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find',
'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on',
'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you'
]);
const rawTerms = String(queryText || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const expanded = new Set();
for (const term of rawTerms) {
if (!stopWords.has(term) && term.length >= 3) {
expanded.add(term);
}
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
for (const part of alphaParts) {
if (!stopWords.has(part) && part.length >= 3) {
expanded.add(part);
}
}
}
return Array.from(expanded).slice(0, 8);
}
function normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function getQueryTerms(queryText) {
return normalizeSearchText(queryText).split(' ').filter(term => term.length > 0);
}
function countOccurrences(text, term) {
if (!text || !term) return 0;
const matches = text.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
return matches ? matches.length : 0;
}
function orderedTermMatches(text, terms) {
let position = 0;
for (const term of terms) {
const index = text.indexOf(term, position);
if (index === -1) return false;
position = index + term.length;
}
return terms.length > 0;
}
function scoreNodeSearchMatch(node, queryText) {
const normalizedQuery = normalizeSearchText(queryText);
const normalizedTitle = normalizeSearchText(node.title || '');
const normalizedDescription = normalizeSearchText(node.description || '');
const normalizedSource = normalizeSearchText(node.source || '');
const terms = getQueryTerms(queryText);
let score = 0;
if (normalizedTitle === normalizedQuery) score += 2000;
if (normalizedTitle.startsWith(normalizedQuery)) score += 1200;
if (normalizedTitle.includes(normalizedQuery)) score += 700;
if (orderedTermMatches(normalizedTitle, terms)) score += 500;
if (terms.length > 0 && terms.every(term => normalizedTitle.includes(term))) score += 350;
if (normalizedDescription.includes(normalizedQuery)) score += 180;
if (orderedTermMatches(normalizedDescription, terms)) score += 120;
if (normalizedSource.includes(normalizedQuery)) score += 90;
for (const term of terms) {
score += countOccurrences(normalizedTitle, term) * 40;
score += countOccurrences(normalizedDescription, term) * 8;
score += countOccurrences(normalizedSource, term) * 3;
}
if (node.updated_at) {
score += new Date(node.updated_at).getTime() / 1e13;
}
return score;
}
function parseMetadata(metadata) {
if (!metadata) return {};
if (typeof metadata === 'string') {
@@ -56,6 +158,165 @@ function mapNodeRow(row) {
};
}
function buildNodeFilterClauses(filters = {}, alias = 'n') {
const clauses = [];
const params = [];
if (filters.createdAfter) {
clauses.push(`${alias}.created_at >= ?`);
params.push(filters.createdAfter);
}
if (filters.createdBefore) {
clauses.push(`${alias}.created_at < ?`);
params.push(filters.createdBefore);
}
if (filters.eventAfter) {
clauses.push(`${alias}.event_date >= ?`);
params.push(filters.eventAfter);
}
if (filters.eventBefore) {
clauses.push(`${alias}.event_date < ?`);
params.push(filters.eventBefore);
}
return { clauses, params };
}
function searchNodes(filters = {}) {
const search = normalizeString(filters.search);
const limit = Math.min(Math.max(filters.limit || 25, 1), 100);
if (!search) return [];
const rowsById = new Map();
for (const row of searchNodesFts(search, filters, Math.max(limit * 2, 25))) {
rowsById.set(row.id, row);
}
for (const row of searchNodesLike(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
for (const row of searchNodesLikeRelaxed(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
return Array.from(rowsById.values())
.map(row => ({ row, score: scoreNodeSearchMatch(row, search) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(entry => mapNodeRow(entry.row));
}
function searchNodesFts(search, filters, limit) {
const db = getDb();
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
const ftsExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'").get();
if (!ftsExists) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
try {
return query(`
WITH fts_matches AS (
SELECT rowid, rank
FROM nodes_fts
WHERE nodes_fts MATCH ?
LIMIT ?
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
fm.rank
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
${whereClause}
ORDER BY fm.rank
LIMIT ?
`, [ftsQuery, Math.max(limit * 2, 50), ...params, limit]);
} catch {
return [];
}
}
function searchNodesLike(search, filters, limit) {
const words = search.split(/\s+/).filter(Boolean);
const { clauses, params } = buildNodeFilterClauses(filters);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
for (const word of words) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
}
sql += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(search, `${search}%`, `%${search}%`, `%${search}%`, `%${search}%`, limit);
return query(sql, queryParams);
}
function searchNodesLikeRelaxed(search, filters, limit) {
const terms = extractRelaxedSearchTerms(search);
if (terms.length === 0) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
const termClauses = [];
for (const term of terms) {
termClauses.push(`n.title LIKE ? COLLATE NOCASE`);
termClauses.push(`n.description LIKE ? COLLATE NOCASE`);
termClauses.push(`n.source LIKE ? COLLATE NOCASE`);
queryParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
sql += ` AND (${termClauses.join(' OR ')})`;
const scoreClauses = [];
const scoreParams = [];
for (const term of terms) {
scoreClauses.push(`CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 2 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 1 ELSE 0 END`);
scoreParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
sql += ` ORDER BY
(${scoreClauses.join(' + ')}) DESC,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 0 ELSE 1 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(...scoreParams, `%${search}%`, limit);
return query(sql, queryParams);
}
/**
* Get nodes with optional filtering.
*/
@@ -105,16 +366,6 @@ function getNodes(filters = {}) {
return rows.map(mapNodeRow);
}
/**
* Search nodes using the same filter object as getNodes.
*/
function searchNodes(filters = {}) {
if (typeof filters === 'string') {
return getNodes({ search: filters });
}
return getNodes(filters);
}
/**
* Get a single node by ID.
*/
@@ -17,18 +17,6 @@ const LEGACY_GUIDES_DIR = path.join(
const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
const SEED_MIGRATION_FLAG = path.join(SKILLS_DIR, '.seed-migrated-2026-03-07-skills-overhaul');
const SEEDED_SKILL_IDS = new Set([
'db-operations',
'create-skill',
'audit',
'traverse',
'onboarding',
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
'start-here',
'schema',
@@ -46,6 +34,14 @@ const DEPRECATED_SKILL_IDS = new Set([
'research',
'survey',
'traverse-graph',
'context-capsule',
'audit',
'calibration',
'connect',
'db-operations',
'node-context-enrichment',
'persona',
'traverse',
]);
function parseFrontmatter(raw) {
@@ -97,6 +93,16 @@ function listMarkdownFiles(dir) {
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
}
function getBundledSkillFiles() {
const bundledById = new Map();
for (const file of listMarkdownFiles(BUNDLED_SKILLS_DIR)) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
return bundledById;
}
function ensureSkillsDir() {
if (!fs.existsSync(SKILLS_DIR)) {
fs.mkdirSync(SKILLS_DIR, { recursive: true });
@@ -116,15 +122,8 @@ function migrateLegacyGuides() {
function seedSkills() {
ensureSkillsDir();
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest);
@@ -137,15 +136,8 @@ function migrateSeededBaseline() {
return;
}
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
fs.copyFileSync(source, dest);
}