diff --git a/README.md b/README.md index d2b405f6..7abce242 100644 --- a/README.md +++ b/README.md @@ -161,15 +161,23 @@ You do **not** need to ask OpenCode to “remember” things for the plugin to w Manual search/add/list still work even if auto-capture has no provider configured. Auto-capture and user profile learning need a provider that can return structured/tool-call output. -### Memory vs AGENTS.md / project docs +### Memory vs AGENTS.md vs Skills -| Store in **memory** | Store in **AGENTS.md** / static docs | -| -------------------------------------------------------------------- | ----------------------------------------------------------- | -| Project-specific decisions, bug patterns, “we tried X and it failed” | Stable rules and workflows that rarely change | -| User preferences discovered over sessions | Always-on coding conventions and process | -| Facts that should follow you across chats | Instructions every agent should see regardless of retrieval | +| Store in **memory** | Store in **AGENTS.md** / static docs | Store as **Skills** (web UI / `skills.db`) | +| -------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | +| Project-specific decisions, bug patterns, “we tried X and it failed” | Stable rules and workflows that rarely change | Reusable procedures the agent loads on demand | +| User preferences discovered over sessions | Always-on coding conventions and process | Team-editable skill packs shared via the same storage | +| Facts that should follow you across chats | Instructions every agent should see regardless of retrieval | OpenCode-native `skill` tool (V2 plugin bridge) | -Rule of thumb: if it is a lasting project instruction, put it in AGENTS.md; if it is context that grows from real work, let memory (or auto-capture) hold it. +Rule of thumb: lasting always-on project instructions → `AGENTS.md`; growing work context → memory; reusable on-demand procedures → **Skills**. + +#### Shared Skills (V2) + +- Create/edit skills in the web explorer (**Skills** tab at `/skill-library`). +- Persisted in `skills.db` under the plugin storage path (not as memory vectors). +- On OpenCode **V2**, the plugin registers enabled skills via `ctx.skill.transform` so the agent can load them with the native `skill` tool. +- Optional HTTP catalog for other OpenCode instances: `"skills": ["http://127.0.0.1:4747/skills/"]` (serves `/skills/index.json` and `/skills//.md` — separate from the UI path). +- REST: `GET/POST /api/skills`, `GET/PUT/DELETE /api/skills/:id`. ### Intelligent prompt-based memory extraction diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index 050ab856..abf8cc55 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -1669,3 +1669,148 @@ export async function handleRunTagMigrationBatch( return { success: false, error: String(error) }; } } + +function skillToApi(skill: { + id: string; + name: string; + description: string; + content: string; + autoinvoke: boolean; + enabled: boolean; + containerTag: string | null; + createdAt: number; + updatedAt: number; +}) { + return { + id: skill.id, + name: skill.name, + description: skill.description, + content: skill.content, + autoinvoke: skill.autoinvoke, + enabled: skill.enabled, + containerTag: skill.containerTag, + createdAt: safeToISOString(skill.createdAt), + updatedAt: safeToISOString(skill.updatedAt), + }; +} + +export async function handleListSkills(options?: { + containerTag?: string; + enabledOnly?: boolean; +}): Promise[] }>> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const items = await skillsManager.list({ + containerTag: options?.containerTag, + enabledOnly: options?.enabledOnly, + }); + return { success: true, data: { items: items.map(skillToApi) } }; + } catch (error) { + log("handleListSkills: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleGetSkill( + id: string +): Promise>> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const skill = await skillsManager.get(id); + if (!skill) return { success: false, error: `Skill not found: ${id}` }; + return { success: true, data: skillToApi(skill) }; + } catch (error) { + log("handleGetSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleCreateSkill(body: { + id?: string; + name?: string; + description?: string; + content?: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; +}): Promise>> { + try { + if (!body?.id || !body?.name || !body?.content) { + return { success: false, error: "id, name, and content are required" }; + } + const { skillsManager } = await import("./skills/skills-manager.js"); + const skill = await skillsManager.create({ + id: body.id, + name: body.name, + description: body.description, + content: body.content, + autoinvoke: body.autoinvoke, + enabled: body.enabled, + containerTag: body.containerTag, + }); + return { success: true, data: skillToApi(skill) }; + } catch (error) { + log("handleCreateSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleUpdateSkill( + id: string, + body: { + name?: string; + description?: string; + content?: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; + } +): Promise>> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const skill = await skillsManager.update(id, body); + return { success: true, data: skillToApi(skill) }; + } catch (error) { + log("handleUpdateSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleDeleteSkill(id: string): Promise> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const deleted = await skillsManager.delete(id); + if (!deleted) return { success: false, error: `Skill not found: ${id}` }; + return { success: true, data: { deleted: true } }; + } catch (error) { + log("handleDeleteSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleSkillsCatalogIndex(): Promise< + ApiResponse<{ skills: Array<{ name: string; version: string; files: string[] }> }> +> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const catalog = await skillsManager.toHttpCatalog(); + return { success: true, data: catalog }; + } catch (error) { + log("handleSkillsCatalogIndex: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleSkillsCatalogFile( + id: string +): Promise> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const markdown = await skillsManager.toCatalogMarkdown(id); + if (!markdown) return { success: false, error: `Skill not found: ${id}` }; + return { success: true, data: { markdown } }; + } catch (error) { + log("handleSkillsCatalogFile: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} diff --git a/src/services/skills/skills-manager.ts b/src/services/skills/skills-manager.ts new file mode 100644 index 00000000..214ae3a2 --- /dev/null +++ b/src/services/skills/skills-manager.ts @@ -0,0 +1,261 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tursoConnectionManager } from "../turso/connection-manager.js"; +import type { TursoDb } from "../turso/turso-db.js"; +import { CONFIG } from "../../config.js"; +import { log } from "../logger.js"; +import { isValidSkillId, type SkillInput, type SkillRecord, type SkillUpdate } from "./types.js"; + +const SKILLS_DB_NAME = "skills.db"; + +type SkillChangeListener = () => void; + +const skillChangeListeners = new Set(); + +export function onSkillsChanged(listener: SkillChangeListener): () => void { + skillChangeListeners.add(listener); + return () => skillChangeListeners.delete(listener); +} + +function notifySkillsChanged(): void { + for (const listener of skillChangeListeners) { + try { + listener(); + } catch (error) { + log("skills change listener failed", { error: String(error) }); + } + } +} + +function rowToSkill(row: Record): SkillRecord { + return { + id: String(row.id), + name: String(row.name), + description: String(row.description ?? ""), + content: String(row.content ?? ""), + autoinvoke: Number(row.autoinvoke ?? 1) === 1, + enabled: Number(row.enabled ?? 1) === 1, + containerTag: row.container_tag == null ? null : String(row.container_tag), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +export class SkillsManager { + private db: TursoDb | null = null; + private dbPath: string; + private initPromise: Promise | null = null; + + constructor() { + this.dbPath = join(CONFIG.storagePath || "", SKILLS_DB_NAME); + } + + reset(): void { + this.db = null; + this.initPromise = null; + this.dbPath = join(CONFIG.storagePath || "", SKILLS_DB_NAME); + } + + private async initialize(): Promise { + if (this.initPromise) return this.initPromise; + this.initPromise = this.initDatabase(); + return this.initPromise; + } + + private async initDatabase(): Promise { + this.dbPath = join(CONFIG.storagePath || "", SKILLS_DB_NAME); + const db = await tursoConnectionManager.getConnection(this.dbPath); + const { applySchemaMigrations, SKILLS_MIGRATIONS } = + await import("../turso/schema-migrations.js"); + await applySchemaMigrations(db, SKILLS_MIGRATIONS, { + dbPath: this.dbPath, + label: "skills", + }); + this.db = db; + } + + private async getDb(): Promise { + await this.initialize(); + if (!this.db) throw new Error("Skills database not initialized"); + return this.db; + } + + /** Absolute SKILL.md path for OpenCode Skill.Info.path */ + skillPath(id: string): string { + return join(this.dbPath.replace(/skills\.db$/, "skills-content"), id, "SKILL.md"); + } + + /** OpenCode-compatible SKILL.md (frontmatter + body). */ + formatMarkdown(skill: SkillRecord): string { + return [ + "---", + `name: ${JSON.stringify(skill.name)}`, + `description: ${JSON.stringify(skill.description)}`, + "metadata:", + ` opencode/autoinvoke: ${skill.autoinvoke ? '"true"' : '"false"'}`, + "---", + "", + skill.content, + ].join("\n"); + } + + private async writeSkillFile(skill: SkillRecord): Promise { + const filePath = this.skillPath(skill.id); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, this.formatMarkdown(skill), "utf8"); + } + + private async removeSkillFile(id: string): Promise { + const dir = dirname(this.skillPath(id)); + await rm(dir, { recursive: true, force: true }); + } + + async list(options?: { + containerTag?: string | null; + enabledOnly?: boolean; + }): Promise { + const db = await this.getDb(); + const clauses: string[] = []; + const args: Array = []; + + if (options?.enabledOnly) { + clauses.push(`enabled = 1`); + } + if (options?.containerTag !== undefined) { + if (options.containerTag === null) { + clauses.push(`container_tag IS NULL`); + } else { + clauses.push(`(container_tag IS NULL OR container_tag = ?)`); + args.push(options.containerTag); + } + } + + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = await db.all( + `SELECT * FROM skills ${where} ORDER BY updated_at DESC, id ASC`, + args + ); + return (rows as Record[]).map(rowToSkill); + } + + async get(id: string): Promise { + const db = await this.getDb(); + const row = await db.get(`SELECT * FROM skills WHERE id = ?`, [id]); + return row ? rowToSkill(row as Record) : null; + } + + async create(input: SkillInput): Promise { + const id = input.id.trim(); + if (!isValidSkillId(id)) { + throw new Error( + "Invalid skill id: use lowercase kebab-case (a-z, 0-9, single hyphens), 1–64 characters" + ); + } + const name = input.name.trim(); + if (!name) throw new Error("Skill name is required"); + const content = input.content; + if (typeof content !== "string" || !content.trim()) { + throw new Error("Skill content is required"); + } + + const existing = await this.get(id); + if (existing) throw new Error(`Skill already exists: ${id}`); + + const now = Date.now(); + const db = await this.getDb(); + await db.run( + `INSERT INTO skills ( + id, name, description, content, autoinvoke, enabled, container_tag, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + name, + (input.description ?? "").trim(), + content, + input.autoinvoke === false ? 0 : 1, + input.enabled === false ? 0 : 1, + input.containerTag ?? null, + now, + now, + ] + ); + + const created = await this.get(id); + if (!created) throw new Error("Failed to create skill"); + if (created.enabled) await this.writeSkillFile(created); + notifySkillsChanged(); + return created; + } + + async update(id: string, update: SkillUpdate): Promise { + const existing = await this.get(id); + if (!existing) throw new Error(`Skill not found: ${id}`); + + const name = update.name !== undefined ? update.name.trim() : existing.name; + if (!name) throw new Error("Skill name is required"); + const content = update.content !== undefined ? update.content : existing.content; + if (typeof content !== "string" || !content.trim()) { + throw new Error("Skill content is required"); + } + + const description = + update.description !== undefined ? update.description.trim() : existing.description; + const autoinvoke = update.autoinvoke !== undefined ? update.autoinvoke : existing.autoinvoke; + const enabled = update.enabled !== undefined ? update.enabled : existing.enabled; + const containerTag = + update.containerTag !== undefined ? update.containerTag : existing.containerTag; + const now = Date.now(); + + const db = await this.getDb(); + await db.run( + `UPDATE skills SET + name = ?, description = ?, content = ?, autoinvoke = ?, enabled = ?, + container_tag = ?, updated_at = ? + WHERE id = ?`, + [name, description, content, autoinvoke ? 1 : 0, enabled ? 1 : 0, containerTag, now, id] + ); + + const updated = await this.get(id); + if (!updated) throw new Error("Failed to update skill"); + if (updated.enabled) { + await this.writeSkillFile(updated); + } else { + await this.removeSkillFile(id); + } + notifySkillsChanged(); + return updated; + } + + async delete(id: string): Promise { + const existing = await this.get(id); + if (!existing) return false; + const db = await this.getDb(); + await db.run(`DELETE FROM skills WHERE id = ?`, [id]); + await this.removeSkillFile(id); + notifySkillsChanged(); + return true; + } + + /** Catalog payload for OpenCode HTTP skills sources (`skills: ["http://host/skills/"]`). */ + async toHttpCatalog(): Promise<{ + skills: Array<{ name: string; version: string; files: string[] }>; + }> { + const enabled = await this.list({ enabledOnly: true }); + return { + skills: enabled.map((skill) => ({ + name: skill.id, + version: String(skill.updatedAt), + // OpenCode fetches `{base}/{name}/{file}` — named .md form → skill id = name. + files: [`${skill.id}.md`], + })), + }; + } + + async toCatalogMarkdown(id: string): Promise { + const skill = await this.get(id); + if (!skill || !skill.enabled) return null; + return this.formatMarkdown(skill); + } +} + +export const skillsManager = new SkillsManager(); diff --git a/src/services/skills/types.ts b/src/services/skills/types.ts new file mode 100644 index 00000000..c18875a5 --- /dev/null +++ b/src/services/skills/types.ts @@ -0,0 +1,37 @@ +export interface SkillRecord { + id: string; + name: string; + description: string; + content: string; + autoinvoke: boolean; + enabled: boolean; + containerTag: string | null; + createdAt: number; + updatedAt: number; +} + +export interface SkillInput { + id: string; + name: string; + description?: string; + content: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; +} + +export interface SkillUpdate { + name?: string; + description?: string; + content?: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; +} + +/** OpenCode-compatible skill id: lowercase kebab-case, 1–64 chars. */ +export const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +export function isValidSkillId(id: string): boolean { + return id.length >= 1 && id.length <= 64 && SKILL_ID_PATTERN.test(id); +} diff --git a/src/services/turso/schema-migrations.ts b/src/services/turso/schema-migrations.ts index 831db97e..ceeb270f 100644 --- a/src/services/turso/schema-migrations.ts +++ b/src/services/turso/schema-migrations.ts @@ -261,6 +261,33 @@ export const USER_PROFILES_MIGRATIONS: SchemaMigration[] = [ }, ]; +export const SKILLS_MIGRATIONS: SchemaMigration[] = [ + { + version: 1, + description: "Baseline shared skills table", + statements: [ + { + sql: ` + CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + autoinvoke INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + container_tag TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `, + }, + { sql: `CREATE INDEX IF NOT EXISTS idx_skills_enabled ON skills(enabled)` }, + { sql: `CREATE INDEX IF NOT EXISTS idx_skills_container_tag ON skills(container_tag)` }, + { sql: `CREATE INDEX IF NOT EXISTS idx_skills_updated_at ON skills(updated_at DESC)` }, + ], + }, +]; + export const AI_SESSIONS_MIGRATIONS: SchemaMigration[] = [ { version: 1, diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 056d8312..02b145a7 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -36,6 +36,13 @@ import { handleAICleanup, handleApplyCleanup, handleUpdateProfileItem, + handleListSkills, + handleGetSkill, + handleCreateSkill, + handleUpdateSkill, + handleDeleteSkill, + handleSkillsCatalogIndex, + handleSkillsCatalogFile, } from "./api-handlers.js"; /** @@ -703,6 +710,81 @@ export class WebServer { return this.jsonResponse(result); } + if (path === "/api/skills" && method === "GET") { + const containerTag = url.searchParams.get("tag") || undefined; + const enabledOnly = url.searchParams.get("enabledOnly") === "true"; + const result = await handleListSkills({ containerTag, enabledOnly }); + return this.jsonResponse(result); + } + + if (path === "/api/skills" && method === "POST") { + const body = (await req.json().catch(() => ({}))) as any; + const result = await handleCreateSkill(body); + return this.jsonResponse(result, result.success ? 200 : 400); + } + + if (path.startsWith("/api/skills/") && method === "GET") { + const id = decodeURIComponent(path.slice("/api/skills/".length)); + if (!id || id.includes("/")) { + return this.jsonResponse({ success: false, error: "Invalid skill id" }, 400); + } + const result = await handleGetSkill(id); + return this.jsonResponse(result, result.success ? 200 : 404); + } + + if (path.startsWith("/api/skills/") && method === "PUT") { + const id = decodeURIComponent(path.slice("/api/skills/".length)); + if (!id || id.includes("/")) { + return this.jsonResponse({ success: false, error: "Invalid skill id" }, 400); + } + const body = (await req.json().catch(() => ({}))) as any; + const result = await handleUpdateSkill(id, body); + return this.jsonResponse(result, result.success ? 200 : 400); + } + + if (path.startsWith("/api/skills/") && method === "DELETE") { + const id = decodeURIComponent(path.slice("/api/skills/".length)); + if (!id || id.includes("/")) { + return this.jsonResponse({ success: false, error: "Invalid skill id" }, 400); + } + const result = await handleDeleteSkill(id); + return this.jsonResponse(result, result.success ? 200 : 404); + } + + // OpenCode HTTP skill catalog (public path; prefer loopback / network auth). + if (path === "/skills/index.json" && method === "GET") { + const result = await handleSkillsCatalogIndex(); + if (!result.success || !result.data) { + return this.jsonResponse(result, 500); + } + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } + + // OpenCode catalog layout: /skills/{id}/{id}.md + if (path.startsWith("/skills/") && path.endsWith(".md") && method === "GET") { + const rest = path.slice("/skills/".length); + const parts = rest.split("/").filter(Boolean); + if (parts.length !== 2) { + return new Response("Not Found", { status: 404 }); + } + const id = decodeURIComponent(parts[0]!); + const filename = decodeURIComponent(parts[1]!); + if (!id || filename !== `${id}.md`) { + return new Response("Not Found", { status: 404 }); + } + const result = await handleSkillsCatalogFile(id); + if (!result.success || !result.data?.markdown) { + return new Response("Not Found", { status: 404 }); + } + return new Response(result.data.markdown, { + status: 200, + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); + } + // SPA fallback for client routes (e.g. /profile) — serve the app shell. if (method === "GET" && !path.startsWith("/api/") && !extname(path)) { return this.serveStaticFile("index.html", "text/html"); diff --git a/src/v2/adapter.ts b/src/v2/adapter.ts index eaff7c0b..867f3266 100644 --- a/src/v2/adapter.ts +++ b/src/v2/adapter.ts @@ -1,5 +1,8 @@ +import { isAbsolute, resolve } from "node:path"; import type { Context } from "@opencode/plugin/promise/plugin"; import { log } from "../services/logger.js"; +import { onSkillsChanged, skillsManager } from "../services/skills/skills-manager.js"; +import { getTags } from "../services/tags.js"; import { eventBelongsToLocation, legacyToolResult, toLegacyEvent } from "./legacy-client.js"; interface MemoryContextHooks { @@ -52,6 +55,62 @@ export async function registerV2Adapter(ctx: Context, legacy: any) { const messageIDs = new Map(); const suppressed = new Set(); const memoryContext = legacy.memoryContext as MemoryContextHooks | undefined; + /** Skill IDs last registered from opencode-mem DB (so we can remove stale ones). */ + const registeredSkillIds = new Set(); + + async function registerDbSkills(): Promise { + if (!ctx.skill?.transform) return; + + const projectTag = getTags(ctx.location.directory).project.tag; + let skills: Awaited> = []; + try { + skills = await skillsManager.list({ + enabledOnly: true, + containerTag: projectTag, + }); + } catch (error) { + log("Failed to load skills for OpenCode registration", { error: String(error) }); + return; + } + + const nextIds = new Set(skills.map((skill) => skill.id)); + + await ctx.skill.transform((editor) => { + for (const id of registeredSkillIds) { + if (!nextIds.has(id) && editor.get(id)) { + editor.remove(id); + } + } + + for (const skill of skills) { + const path = skillsManager.skillPath(skill.id); + const absolutePath = isAbsolute(path) ? path : resolve(path); + const payload = { + id: skill.id, + name: skill.name, + description: skill.description || undefined, + autoinvoke: skill.autoinvoke, + path: absolutePath, + content: skill.content, + } as any; + + if (editor.get(skill.id)) { + editor.update(skill.id, (entry) => { + entry.name = payload.name; + entry.description = payload.description; + entry.autoinvoke = payload.autoinvoke; + entry.path = payload.path; + entry.content = payload.content; + }); + } else { + editor.add(payload); + } + } + }); + + registeredSkillIds.clear(); + for (const id of nextIds) registeredSkillIds.add(id); + } await ctx.tool.transform((editor) => editor.add({ @@ -74,6 +133,18 @@ export async function registerV2Adapter(ctx: Context, legacy: any) { } as any) ); + await registerDbSkills(); + const unsubscribeSkills = onSkillsChanged(() => { + void (async () => { + try { + await registerDbSkills(); + await ctx.skill?.reload?.(); + } catch (error) { + log("Failed to reload OpenCode skills after DB change", { error: String(error) }); + } + })(); + }); + await ctx.session.hook("prompt", async (event) => { messageIDs.set(event.sessionID, event.messageID); const original = { type: "text", text: event.prompt.text }; @@ -156,6 +227,7 @@ export async function registerV2Adapter(ctx: Context, legacy: any) { })(); return async () => { + unsubscribeSkills(); controller.abort(); await watcher; contexts.clear(); diff --git a/tests/skills-api-handlers.test.ts b/tests/skills-api-handlers.test.ts new file mode 100644 index 00000000..ba693a54 --- /dev/null +++ b/tests/skills-api-handlers.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; + +let tmpDir: string; + +describe("skills API handlers", () => { + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-skills-api-")); + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + }); + + afterEach(async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it("supports CRUD through handlers", async () => { + const { + handleCreateSkill, + handleListSkills, + handleGetSkill, + handleUpdateSkill, + handleDeleteSkill, + handleSkillsCatalogIndex, + handleSkillsCatalogFile, + } = await import("../src/services/api-handlers.js"); + + const created = await handleCreateSkill({ + id: "ship-it", + name: "Ship It", + description: "Release flow", + content: "## Ship\n\n1. Build\n2. Tag", + }); + expect(created.success).toBe(true); + expect(created.data?.id).toBe("ship-it"); + + const listed = await handleListSkills(); + expect(listed.success).toBe(true); + expect(listed.data?.items).toHaveLength(1); + + const got = await handleGetSkill("ship-it"); + expect(got.success).toBe(true); + expect(got.data?.name).toBe("Ship It"); + + const updated = await handleUpdateSkill("ship-it", { name: "Ship It Now" }); + expect(updated.success).toBe(true); + expect(updated.data?.name).toBe("Ship It Now"); + + const catalog = await handleSkillsCatalogIndex(); + expect(catalog.success).toBe(true); + expect(catalog.data?.skills[0]?.name).toBe("ship-it"); + + const file = await handleSkillsCatalogFile("ship-it"); + expect(file.success).toBe(true); + expect(file.data?.markdown).toContain("## Ship"); + + const deleted = await handleDeleteSkill("ship-it"); + expect(deleted.success).toBe(true); + expect((await handleGetSkill("ship-it")).success).toBe(false); + }); +}); diff --git a/tests/skills-manager.test.ts b/tests/skills-manager.test.ts new file mode 100644 index 00000000..952c7b49 --- /dev/null +++ b/tests/skills-manager.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; +import { isValidSkillId } from "../src/services/skills/types.js"; + +let tmpDir: string; + +async function makeManager() { + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { SkillsManager } = await import("../src/services/skills/skills-manager.js"); + return new SkillsManager(); +} + +describe("isValidSkillId", () => { + it("accepts kebab-case ids", () => { + expect(isValidSkillId("git-release")).toBe(true); + expect(isValidSkillId("a")).toBe(true); + expect(isValidSkillId("pr-review-v2")).toBe(true); + }); + + it("rejects invalid ids", () => { + expect(isValidSkillId("Git-Release")).toBe(false); + expect(isValidSkillId("-bad")).toBe(false); + expect(isValidSkillId("bad-")).toBe(false); + expect(isValidSkillId("bad--id")).toBe(false); + expect(isValidSkillId("has space")).toBe(false); + }); +}); + +describe("SkillsManager", () => { + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-skills-")); + }); + + afterEach(async () => { + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it("creates, lists, updates, and deletes skills", async () => { + const mgr = await makeManager(); + const created = await mgr.create({ + id: "git-release", + name: "Git Release", + description: "Prepare releases", + content: "## Steps\n\n1. Tag\n2. Publish", + autoinvoke: true, + enabled: true, + }); + + expect(created.id).toBe("git-release"); + expect(created.name).toBe("Git Release"); + expect(created.autoinvoke).toBe(true); + + const listed = await mgr.list(); + expect(listed).toHaveLength(1); + + const updated = await mgr.update("git-release", { + content: "## Updated", + enabled: false, + }); + expect(updated.content).toBe("## Updated"); + expect(updated.enabled).toBe(false); + + const enabledOnly = await mgr.list({ enabledOnly: true }); + expect(enabledOnly).toHaveLength(0); + + const deleted = await mgr.delete("git-release"); + expect(deleted).toBe(true); + expect(await mgr.list()).toHaveLength(0); + }); + + it("rejects invalid and duplicate ids", async () => { + const mgr = await makeManager(); + await expect(mgr.create({ id: "Bad_ID", name: "x", content: "body" })).rejects.toThrow( + /Invalid skill id/ + ); + + await mgr.create({ id: "ok-skill", name: "Ok", content: "body" }); + await expect(mgr.create({ id: "ok-skill", name: "Dup", content: "body" })).rejects.toThrow( + /already exists/ + ); + }); + + it("builds an OpenCode HTTP catalog", async () => { + const mgr = await makeManager(); + await mgr.create({ + id: "review", + name: "Review", + description: "Review changes", + content: "Review carefully.", + }); + await mgr.create({ + id: "hidden", + name: "Hidden", + content: "off", + enabled: false, + }); + + const catalog = await mgr.toHttpCatalog(); + expect(catalog.skills).toHaveLength(1); + expect(catalog.skills[0]).toEqual({ + name: "review", + version: expect.any(String), + files: ["review.md"], + }); + + const md = await mgr.toCatalogMarkdown("review"); + expect(md).toContain('name: "Review"'); + expect(md).toContain("Review carefully."); + expect(await mgr.toCatalogMarkdown("hidden")).toBeNull(); + }); + + it("notifies listeners on change", async () => { + const mgr = await makeManager(); + const { onSkillsChanged } = await import("../src/services/skills/skills-manager.js"); + let hits = 0; + const stop = onSkillsChanged(() => { + hits++; + }); + await mgr.create({ id: "notify-me", name: "N", content: "c" }); + await mgr.update("notify-me", { name: "N2" }); + await mgr.delete("notify-me"); + stop(); + expect(hits).toBe(3); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 00000000..740d8e04 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"], + "exclude": ["node_modules", "../dist", "../web"] +} diff --git a/tests/v2-skills-bridge.test.ts b/tests/v2-skills-bridge.test.ts new file mode 100644 index 00000000..973d7917 --- /dev/null +++ b/tests/v2-skills-bridge.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; +import { registerV2Adapter } from "../src/v2/adapter.js"; + +let tmpDir: string; + +describe("V2 skills bridge helpers", () => { + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-v2-skills-")); + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + }); + + afterEach(async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it("exposes absolute skill paths for OpenCode Skill.Info", async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + await skillsManager.create({ + id: "bridge-demo", + name: "Bridge Demo", + description: "demo", + content: "Do the thing.", + }); + const path = skillsManager.skillPath("bridge-demo"); + expect(isAbsolute(path)).toBe(true); + expect(path.replace(/\\/g, "/").endsWith("bridge-demo/SKILL.md")).toBe(true); + expect(existsSync(path)).toBe(true); + const md = readFileSync(path, "utf8"); + expect(md).toContain('name: "Bridge Demo"'); + expect(md).toContain("Do the thing."); + expect(md).toContain('opencode/autoinvoke: "true"'); + }); + + it("lists only enabled skills for registration filtering", async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + await skillsManager.create({ + id: "on", + name: "On", + content: "a", + enabled: true, + }); + await skillsManager.create({ + id: "off", + name: "Off", + content: "b", + enabled: false, + }); + const enabled = await skillsManager.list({ enabledOnly: true }); + expect(enabled.map((s) => s.id)).toEqual(["on"]); + expect(existsSync(skillsManager.skillPath("on"))).toBe(true); + expect(existsSync(skillsManager.skillPath("off"))).toBe(false); + }); + + it("registers enabled DB skills through OpenCode skill.transform", async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + await skillsManager.create({ + id: "code-review", + name: "Code Review", + description: "Review diffs", + content: "## Review\n\nBe thorough.", + autoinvoke: true, + enabled: true, + }); + await skillsManager.create({ + id: "disabled-skill", + name: "Disabled", + content: "hidden", + enabled: false, + }); + + const registry = new Map(); + let reloadCount = 0; + const hooks = new Map Promise>(); + + const ctx = { + location: { + directory: "/workspace/project", + project: { id: "project", directory: "/workspace/project", canonical: "project" }, + }, + tool: { + transform: async (callback: (editor: any) => void) => { + callback({ add() {} }); + }, + }, + skill: { + transform: async (callback: (editor: any) => void) => { + callback({ + get: (id: string) => registry.get(id), + add: (skill: any) => { + registry.set(skill.id, { ...skill }); + }, + update: (id: string, mutate: (skill: any) => void) => { + const current = registry.get(id); + if (!current) return; + mutate(current); + }, + remove: (id: string) => { + registry.delete(id); + }, + }); + }, + reload: async () => { + reloadCount++; + }, + }, + session: { + hook: async (name: string, callback: (event: any) => Promise) => { + hooks.set(name, callback); + }, + }, + event: { + async *subscribe({ signal }: { signal: AbortSignal }) { + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + // Satisfy require-yield; cleanup aborts before any events are needed. + if (signal.aborted) yield null; + }, + }, + } as any; + + const cleanup = await registerV2Adapter(ctx, { + tool: { memory: { description: "memory", execute: async () => "{}" } }, + event: async () => {}, + dispose: async () => {}, + }); + + expect([...registry.keys()].sort()).toEqual(["code-review"]); + const registered = registry.get("code-review"); + expect(registered.name).toBe("Code Review"); + expect(registered.description).toBe("Review diffs"); + expect(registered.autoinvoke).toBe(true); + expect(registered.content).toBe("## Review\n\nBe thorough."); + expect(registered.path).toBe(skillsManager.skillPath("code-review")); + expect(existsSync(registered.path)).toBe(true); + + await skillsManager.update("code-review", { + description: "Review PRs carefully", + autoinvoke: false, + }); + await new Promise((r) => setTimeout(r, 50)); + + expect(registry.get("code-review")?.description).toBe("Review PRs carefully"); + expect(registry.get("code-review")?.autoinvoke).toBe(false); + expect(reloadCount).toBeGreaterThan(0); + + await skillsManager.update("code-review", { enabled: false }); + await new Promise((r) => setTimeout(r, 50)); + expect(registry.has("code-review")).toBe(false); + + await cleanup(); + }); + + it("builds OpenCode HTTP catalog markdown with autoinvoke metadata", async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + await skillsManager.create({ + id: "git-release", + name: "Git Release", + description: "Cut a release", + content: "Tag and publish.", + autoinvoke: false, + }); + + const catalog = await skillsManager.toHttpCatalog(); + expect(catalog.skills).toEqual([ + { + name: "git-release", + version: expect.any(String), + files: ["git-release.md"], + }, + ]); + + const md = await skillsManager.toCatalogMarkdown("git-release"); + expect(md).toContain('name: "Git Release"'); + expect(md).toContain('description: "Cut a release"'); + expect(md).toContain('opencode/autoinvoke: "false"'); + expect(md).toContain("Tag and publish."); + }); +}); diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json index 99e242bb..6442ed8d 100644 --- a/web/.vscode/extensions.json +++ b/web/.vscode/extensions.json @@ -1,3 +1,8 @@ { - "recommendations": ["bradlc.vscode-tailwindcss"] + "recommendations": [ + "svelte.svelte-vscode", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss" + ] } diff --git a/web/README.md b/web/README.md index 307c879a..17f9dfc1 100644 --- a/web/README.md +++ b/web/README.md @@ -17,9 +17,10 @@ via a Vite middleware (see [`vite-plugins/opencode-mem-sim.ts`](./vite-plugins/o Normal `bun run dev` still proxies to `:4747`. Covered: health, tags, stats, memories (paginated + tag filter), search, user -profile, changelog, migration detect (no-op), AI cleanup preview. Mutating +profile, changelog, migration detect (no-op), AI cleanup preview, skills (CRUD +in-memory from [`sim/skills.json`](./sim/skills.json)). Other mutating `POST`/`PUT`/`DELETE` calls return stub success so toast paths stay testable — -nothing is persisted. +nothing is written back to disk. GitHub Markdown cannot paint CSS, but it **can** show colors via small SVG swatches (see [`docs/swatches/`](./docs/swatches/)). diff --git a/web/sim/skills.json b/web/sim/skills.json new file mode 100644 index 00000000..c0e93534 --- /dev/null +++ b/web/sim/skills.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "id": "code-review", + "name": "Code Review", + "description": "Use when reviewing a pull request or diff: focus on correctness, edge cases, and clear feedback.", + "content": "## Goal\n\nReview the current change with a senior-engineer lens.\n\n## Checklist\n\n- Correctness and regressions\n- Edge cases and error paths\n- Naming / readability only when it blocks understanding\n- Tests covering the risky paths\n\n## Output\n\n- Short summary\n- Ordered findings (severity → medium → nit)\n- Concrete fix suggestions", + "autoinvoke": true, + "enabled": true, + "containerTag": null, + "createdAt": "2026-09-15T10:00:00.000Z", + "updatedAt": "2026-10-01T12:30:00.000Z" + }, + { + "id": "git-release", + "name": "Git Release", + "description": "Use when cutting a release: version bump, changelog, tag, and publish checklist.", + "content": "## Goal\n\nShip a clean release without skipping checks.\n\n## Steps\n\n1. Confirm `main` is green and up to date\n2. Bump version (Conventional Commits / package)\n3. Update changelog with user-facing notes\n4. Tag and push\n5. Verify CI / publish artifacts\n\n## Don't\n\n- Force-push release tags\n- Skip failing checks \"just this once\"", + "autoinvoke": false, + "enabled": true, + "containerTag": null, + "createdAt": "2026-09-20T09:00:00.000Z", + "updatedAt": "2026-09-28T16:00:00.000Z" + }, + { + "id": "draft-pr", + "name": "Draft PR", + "description": "Use when opening a pull request: write a clear Conventional Commit title and test plan.", + "content": "## Goal\n\nOpen a reviewable PR with a strong title and test plan.\n\n## Title\n\nUse Conventional Commits (`feat(scope): …`, `fix: …`).\n\n## Body\n\n- Summary (why, not only what)\n- Test plan checklist\n- Screenshots when UI changes", + "autoinvoke": true, + "enabled": false, + "containerTag": null, + "createdAt": "2026-10-02T11:00:00.000Z", + "updatedAt": "2026-10-05T08:15:00.000Z" + } + ] +} diff --git a/web/src/App.svelte b/web/src/App.svelte index 9c6f8b53..65d02770 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -13,6 +13,8 @@ import EditMemoryDialog from "$lib/components/explorer/EditMemoryDialog.svelte"; import MemoryList from "$lib/components/explorer/MemoryList.svelte"; import ProfileView from "$lib/components/explorer/ProfileView.svelte"; + import SkillDialog from "$lib/components/explorer/SkillDialog.svelte"; + import SkillsView from "$lib/components/explorer/SkillsView.svelte"; import TagMigrationDialog from "$lib/components/explorer/TagMigrationDialog.svelte"; import TagFilter from "$lib/components/explorer/TagFilter.svelte"; import Alert from "$lib/components/ui/alert.svelte"; @@ -28,6 +30,7 @@ import { initRouter, navigate, ROUTES, shouldHandleSpaClick } from "$lib/router"; import { createRouter } from "$lib/router.svelte"; import { createMemoriesExplorer } from "$lib/stores/memories-explorer.svelte"; + import { createSkillsStore } from "$lib/stores/skills.svelte"; import { createUserProfile } from "$lib/stores/user-profile.svelte"; import { GAP, @@ -56,6 +59,7 @@ const router = createRouter(); const explorer = createMemoriesExplorer(); const profile = createUserProfile(); + const skills = createSkillsStore(); let sidebarOpen = $state(false); let sidebarCollapsed = $state(readCollapsed()); @@ -63,7 +67,11 @@ let headerScrolled = $state(false); const pageTitle = $derived( - router.view === "project" ? i18n.t("tab-project") : i18n.t("tab-profile") + router.view === "project" + ? i18n.t("tab-project") + : router.view === "skills" + ? i18n.t("tab-skills") + : i18n.t("tab-profile") ); $effect(() => { @@ -108,6 +116,9 @@ if (router.view === "profile") { void profile.loadUserProfile(); } + if (router.view === "skills") { + void skills.loadSkills(); + } }); function onLangToggle() { @@ -115,6 +126,7 @@ void explorer.loadMemories(); void explorer.loadStats(); if (router.view === "profile") void profile.loadUserProfile(); + if (router.view === "skills") void skills.loadSkills(); } function onHomeClick(event: MouseEvent) { @@ -136,6 +148,7 @@ brand={i18n.t("brand")} projectLabel={i18n.t("tab-project")} profileLabel={i18n.t("tab-profile")} + skillsLabel={i18n.t("tab-skills")} {langLabel} languageLabel={i18n.t("nav-language")} themeLabel={i18n.t("nav-theme")} @@ -154,9 +167,9 @@
+ {:else if router.view === "skills"} + + {i18n.t("text-total", { + count: skills.isSearching ? skills.filteredSkills.length : skills.skills.length, + })} + {/if} @@ -334,6 +353,39 @@ onTagsChange={explorer.updateMemoryTags} knownTags={explorer.knownTags} /> + {:else if router.view === "skills"} +
+
+
+ +
+
+ +
+
+
+ {:else} + (skills.dialogOpen = open)} + onSave={skills.saveSkill} +/> + (explorer.addOpen = open)} diff --git a/web/src/app.css b/web/src/app.css index 9b6891cf..e8683c89 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -159,9 +159,73 @@ html.theme-switching *::after { .markdown-content { line-height: 1.65; word-break: break-word; + color: var(--foreground); +} +.markdown-content > :first-child { + margin-top: 0; +} +.markdown-content > :last-child { + margin-bottom: 0; +} +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4 { + color: var(--foreground-bright); + font-weight: 600; + line-height: 1.3; + margin: 1em 0 0.4em; +} +.markdown-content h1 { + font-size: 1.35rem; +} +.markdown-content h2 { + font-size: 1.15rem; +} +.markdown-content h3 { + font-size: 1.05rem; +} +.markdown-content h4 { + font-size: 1rem; +} +.markdown-content p { + margin: 0.45em 0; +} +.markdown-content ul, +.markdown-content ol { + margin: 0.45em 0; + padding-inline-start: 1.35rem; +} +.markdown-content li { + margin: 0.2em 0; +} +.markdown-content li > ul, +.markdown-content li > ol { + margin: 0.15em 0; +} +.markdown-content strong { + color: var(--foreground-bright); + font-weight: 600; +} +.markdown-content a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} +.markdown-content hr { + margin: 0.9em 0; + border: 0; + border-top: 1px solid var(--border); +} +.markdown-content blockquote { + margin: 0.6em 0; + padding-inline-start: 0.85rem; + border-inline-start: 3px solid var(--border); + color: var(--muted-foreground); } .markdown-content pre { overflow-x: auto; + margin: 0.6em 0; padding: 0.75rem; background: var(--muted); border: 1px solid var(--border); @@ -171,8 +235,10 @@ html.theme-switching *::after { font-family: var(--font-mono); font-size: 0.85em; } -.markdown-content p { - margin: 0.4em 0; +.markdown-content :not(pre) > code { + padding: 0.1em 0.35em; + border-radius: 0.35rem; + background: var(--muted); } .confidence-ring { diff --git a/web/src/lib/components/explorer/AppSidebar.svelte b/web/src/lib/components/explorer/AppSidebar.svelte index 9e767454..d1c0ff29 100644 --- a/web/src/lib/components/explorer/AppSidebar.svelte +++ b/web/src/lib/components/explorer/AppSidebar.svelte @@ -3,6 +3,7 @@ import Folder from "@lucide/svelte/icons/folder"; import Languages from "@lucide/svelte/icons/languages"; import Moon from "@lucide/svelte/icons/moon"; + import ScrollText from "@lucide/svelte/icons/scroll-text"; import Sun from "@lucide/svelte/icons/sun"; import User from "@lucide/svelte/icons/user"; import X from "@lucide/svelte/icons/x"; @@ -30,6 +31,7 @@ brand: string; projectLabel: string; profileLabel: string; + skillsLabel: string; langLabel: string; languageLabel: string; themeLabel: string; @@ -45,6 +47,7 @@ brand, projectLabel, profileLabel, + skillsLabel, langLabel, languageLabel, themeLabel, @@ -93,7 +96,7 @@