nexus/server/src/services/skill-registry-db.ts

229 lines
6.5 KiB
TypeScript

import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { drizzle } from "drizzle-orm/libsql";
import { createClient, type Client as LibSQLClient } from "@libsql/client";
import * as schema from "./skill-registry-schema.js";
import { resolveSkillRegistryDbPath } from "../home-paths.js";
export type SkillRegistryDb = ReturnType<typeof drizzle<typeof schema>>;
let _db: SkillRegistryDb | null = null;
let _client: LibSQLClient | null = null;
const CREATE_SKILLS_TABLE = `
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
source_url TEXT,
active_version_id TEXT,
removed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const CREATE_SKILL_VERSIONS_TABLE = `
CREATE TABLE IF NOT EXISTS skill_versions (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
version TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
cache_dir TEXT
)`;
const CREATE_SKILL_FILES_TABLE = `
CREATE TABLE IF NOT EXISTS skill_files (
id TEXT PRIMARY KEY,
version_id TEXT NOT NULL,
path TEXT NOT NULL,
kind TEXT NOT NULL,
size_bytes INTEGER
)`;
const CREATE_COMMUNITY_RATINGS_TABLE = `
CREATE TABLE IF NOT EXISTS community_ratings (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
average_rating REAL,
rating_count INTEGER,
source TEXT
)`;
const CREATE_SKILL_GROUPS_TABLE = `
CREATE TABLE IF NOT EXISTS skill_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const CREATE_SKILL_GROUP_MEMBERS_TABLE = `
CREATE TABLE IF NOT EXISTS skill_group_members (
group_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (group_id, skill_id)
)`;
const CREATE_SKILL_GROUP_INHERITANCE_TABLE = `
CREATE TABLE IF NOT EXISTS skill_group_inheritance (
child_group_id TEXT NOT NULL,
parent_group_id TEXT NOT NULL,
PRIMARY KEY (child_group_id, parent_group_id)
)`;
const CREATE_AGENT_SKILL_GROUPS_TABLE = `
CREATE TABLE IF NOT EXISTS agent_skill_groups (
agent_id TEXT NOT NULL,
group_id TEXT NOT NULL,
assigned_at INTEGER NOT NULL,
PRIMARY KEY (agent_id, group_id)
)`;
const CREATE_AGENT_SKILLS_TABLE = `
CREATE TABLE IF NOT EXISTS agent_skills (
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
installed_at INTEGER NOT NULL,
PRIMARY KEY (agent_id, skill_id)
)`;
const CREATE_PERSONAL_RATINGS_TABLE = `
CREATE TABLE IF NOT EXISTS personal_ratings (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
version_id TEXT,
stars INTEGER NOT NULL,
note TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const BUILTIN_GROUPS = [
{
id: "builtin/pm-essentials",
name: "PM Essentials",
description: "Core planning and project-management skills",
},
{
id: "builtin/engineer-core",
name: "Engineer Core",
description: "Foundational engineering skills",
},
{
id: "builtin/frontend",
name: "Frontend",
description: "UI and frontend development skills",
},
{
id: "builtin/backend",
name: "Backend",
description: "API, database, and infrastructure skills",
},
{
id: "builtin/creative",
name: "Creative",
description: "Writing, branding, and creative production",
},
] as const;
async function seedBuiltinGroups(client: LibSQLClient): Promise<void> {
const now = Date.now();
for (const group of BUILTIN_GROUPS) {
await client.execute({
sql: `INSERT OR IGNORE INTO skill_groups (id, name, description, is_builtin, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)`,
args: [group.id, group.name, group.description, now, now],
});
}
}
export async function getSkillRegistryDb(): Promise<SkillRegistryDb> {
if (_db !== null) return _db;
const dbPath = resolveSkillRegistryDbPath();
await mkdir(dirname(dbPath), { recursive: true });
const client = createClient({ url: `file:${dbPath}` });
_client = client;
_db = drizzle({ client, schema });
await client.execute(CREATE_SKILLS_TABLE);
await client.execute(CREATE_SKILL_VERSIONS_TABLE);
await client.execute(CREATE_SKILL_FILES_TABLE);
await client.execute(CREATE_COMMUNITY_RATINGS_TABLE);
await client.execute(CREATE_SKILL_GROUPS_TABLE);
await client.execute(CREATE_SKILL_GROUP_MEMBERS_TABLE);
await client.execute(CREATE_SKILL_GROUP_INHERITANCE_TABLE);
await client.execute(CREATE_AGENT_SKILL_GROUPS_TABLE);
await client.execute(CREATE_AGENT_SKILLS_TABLE);
await client.execute(CREATE_PERSONAL_RATINGS_TABLE);
// Add usage-tracking columns to agent_skills if they don't exist yet (idempotent)
const agentSkillsAlters = [
`ALTER TABLE agent_skills ADD COLUMN task_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agent_skills ADD COLUMN avg_cost_usd REAL`,
`ALTER TABLE agent_skills ADD COLUMN last_used_at INTEGER`,
`ALTER TABLE agent_skills ADD COLUMN source TEXT NOT NULL DEFAULT 'managed'`,
];
for (const sql of agentSkillsAlters) {
try {
await client.execute(sql);
} catch {
// Column already exists — ignore
}
}
await seedBuiltinGroups(client);
return _db;
}
/**
* Return the raw LibSQL client singleton.
* Must be called after getSkillRegistryDb() to ensure the client is initialized.
*/
export function getRawClient(): LibSQLClient {
if (_client === null) {
throw new Error("Skill registry DB not initialized — call getSkillRegistryDb() first");
}
return _client;
}
const NEXUS_CONTENT_SKILL_IDS = [
"nexus-content/diagram",
"nexus-content/icon-set",
"nexus-content/theme-palette",
"nexus-content/wallpaper",
"nexus-content/social-post",
"nexus-content/convert",
"nexus-content/pdf-document",
"nexus-content/brand-kit",
"nexus-content/presentation",
] as const;
/**
* Seed the builtin/creative skill group with all 9 Nexus content skill IDs.
* Uses INSERT OR IGNORE for idempotency — safe to call multiple times.
* Must be called AFTER skillRegistryService().fetchAll() so skill rows exist.
*/
export async function seedCreativeGroupMembers(): Promise<void> {
const client = getRawClient();
const now = Date.now();
for (const skillId of NEXUS_CONTENT_SKILL_IDS) {
await client.execute({
sql: `INSERT OR IGNORE INTO skill_group_members (group_id, skill_id, added_at) VALUES (?, ?, ?)`,
args: ["builtin/creative", skillId, now],
});
}
}
/** Reset the singleton — used for test cleanup */
export function resetSkillRegistryDb(): void {
_db = null;
_client = null;
}