nexus/server/src/services/skill-registry-ratings.ts
Mikkel Georgsen b7dddfa266 feat(12-01): personalRatings schema, DB DDL, skillRatingService, and tests
- Add personalRatings table to skill-registry-schema.ts
- Add taskCount, avgCostUsd, lastUsedAt columns to agentSkills in schema
- Add CREATE_PERSONAL_RATINGS_TABLE DDL constant in skill-registry-db.ts
- Add ALTER TABLE statements for new agent_skills usage columns (idempotent)
- Create skill-registry-ratings.ts with skillRatingService factory
- rate() appends personal rating, validates stars 1-5
- getRatings() returns ratings ordered by createdAt DESC
- recordUsageForAgent() atomically updates task_count, avg_cost_usd, last_used_at
- All 8 tests pass
2026-04-02 15:08:50 +00:00

99 lines
3.4 KiB
TypeScript

import { eq, desc } from "drizzle-orm";
import { getSkillRegistryDb } from "./skill-registry-db.js";
import { personalRatings, agentSkills } from "./skill-registry-schema.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type RateOpts = {
skillId: string;
versionId?: string | null;
stars: number;
note?: string | null;
};
type PersonalRatingRow = typeof personalRatings.$inferSelect;
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Skill rating service factory.
* Manages personal ratings and usage tracking in the skill registry libSQL DB.
*/
export function skillRatingService() {
return {
/**
* Record a personal rating (1-5 stars) for a skill.
* Always appends — never upserts — so rating history is preserved.
*/
async rate(opts: RateOpts): Promise<void> {
if (opts.stars < 1 || opts.stars > 5) {
throw new RangeError(`stars must be between 1 and 5, got ${opts.stars}`);
}
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(personalRatings).values({
id: crypto.randomUUID(),
skillId: opts.skillId,
versionId: opts.versionId ?? null,
stars: opts.stars,
note: opts.note ?? null,
createdAt: now,
updatedAt: now,
});
},
/**
* Get all personal ratings for a skill, ordered by createdAt descending (newest first).
*/
async getRatings(skillId: string): Promise<PersonalRatingRow[]> {
const db = await getSkillRegistryDb();
return db
.select()
.from(personalRatings)
.where(eq(personalRatings.skillId, skillId))
.orderBy(desc(personalRatings.createdAt));
},
/**
* Record a heartbeat run completion for all skills installed by an agent.
* Increments task_count, updates running average cost, and sets last_used_at.
* Safe to call when agent has no skills (no-op).
*
* @param agentId - the agent that just completed a successful run
* @param costUsd - total cost of the run in USD, or null if unknown
*/
async recordUsageForAgent(agentId: string, costUsd: number | null): Promise<void> {
const db = await getSkillRegistryDb();
const client = db.$client as import("@libsql/client").Client;
const now = Date.now();
if (costUsd !== null) {
// Atomic update with running average calculation
await client.execute({
sql: `UPDATE agent_skills
SET task_count = task_count + 1,
avg_cost_usd = CASE
WHEN task_count = 0 THEN ?
ELSE (COALESCE(avg_cost_usd, 0) * task_count + ?) / (task_count + 1)
END,
last_used_at = ?
WHERE agent_id = ?`,
args: [costUsd, costUsd, now, agentId],
});
} else {
// Skip avg_cost_usd update when cost is unknown
await client.execute({
sql: `UPDATE agent_skills
SET task_count = task_count + 1,
last_used_at = ?
WHERE agent_id = ?`,
args: [now, agentId],
});
}
},
};
}