- 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
99 lines
3.4 KiB
TypeScript
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],
|
|
});
|
|
}
|
|
},
|
|
};
|
|
}
|