diff --git a/src/main/database.ts b/src/main/database.ts index 274f63c8..224d551b 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -6,6 +6,7 @@ import log from "electron-log/main.js"; import { DatabaseSync, backup as backupDatabase, type SQLInputValue } from "node:sqlite"; import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors"; import { SecretVault } from "./vault"; +import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "./skill-types.js"; import type { ActivityRecord, AgentMessageRecord, @@ -1407,6 +1408,14 @@ export class AppDatabase { // New table cleanup (budget_incidents before budget_policies due to FK) this.db.prepare("delete from budget_incidents where company_id = ?").run(companyId); this.db.prepare("delete from budget_policies where company_id = ?").run(companyId); + // agent_skill_assignments references company_skills -- delete before skills + try { + this.db.prepare( + "delete from agent_skill_assignments where skill_id in (select id from company_skills where company_id = ?)", + ).run(companyId); + } catch (err) { + if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; + } this.db.prepare("delete from company_skills where company_id = ?").run(companyId); this.db.prepare("delete from task_sessions where company_id = ?").run(companyId); this.db.prepare("delete from wakeup_requests where company_id = ?").run(companyId); @@ -2139,6 +2148,150 @@ export class AppDatabase { this.db.prepare("update agents set status = ?, updated_at = ? where id = ?").run(status, ts, agentId); } + // --------------------------------------------------------------------------- + // Skill CRUD methods + // --------------------------------------------------------------------------- + + /** + * Upsert a company skill -- INSERT with ON CONFLICT UPDATE. + * Returns isUpdate=true if the record already existed and was updated. + */ + upsertCompanySkill(skill: { + id: string; + companyId: string; + key: string; + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: string; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: string; + compatibility: string; + fileInventory: string; + metadata: string | null; + createdAt: string; + updatedAt: string; + }): { isUpdate: boolean } { + // Check if record exists before upsert to determine isUpdate + const existing = this.db + .prepare("SELECT 1 FROM company_skills WHERE company_id = ? AND key = ?") + .get(skill.companyId, skill.key); + const isUpdate = existing != null; + + this.db.prepare(` + INSERT INTO company_skills ( + id, company_id, key, slug, name, description, markdown, + source_type, source_locator, source_ref, trust_level, + compatibility, file_inventory, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(company_id, key) DO UPDATE SET + slug = excluded.slug, + name = excluded.name, + description = excluded.description, + markdown = excluded.markdown, + source_ref = excluded.source_ref, + trust_level = excluded.trust_level, + file_inventory = excluded.file_inventory, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `).run( + skill.id, skill.companyId, skill.key, skill.slug, + skill.name, skill.description, skill.markdown, + skill.sourceType, skill.sourceLocator, skill.sourceRef, + skill.trustLevel, skill.compatibility, skill.fileInventory, + skill.metadata, skill.createdAt, skill.updatedAt, + ); + + return { isUpdate }; + } + + /** List all skills for a company. */ + listCompanySkills(companyId: string): SkillRecord[] { + const rows = this.readAll( + "SELECT * FROM company_skills WHERE company_id = ? ORDER BY created_at ASC", + companyId, + ); + return rows.map((row) => this.mapSkillRow(row)); + } + + /** Get a single skill by ID within a company. */ + getCompanySkill(companyId: string, skillId: string): SkillRecord | null { + const row = this.db + .prepare("SELECT * FROM company_skills WHERE id = ? AND company_id = ?") + .get(skillId, companyId) as SqliteRow | undefined; + return row ? this.mapSkillRow(row) : null; + } + + /** Get a single skill by canonical key within a company. */ + getCompanySkillByKey(companyId: string, key: string): SkillRecord | null { + const row = this.db + .prepare("SELECT * FROM company_skills WHERE company_id = ? AND key = ?") + .get(companyId, key) as SqliteRow | undefined; + return row ? this.mapSkillRow(row) : null; + } + + /** Delete a skill record. Cascade deletes agent_skill_assignments via FK. */ + deleteCompanySkill(companyId: string, skillId: string): boolean { + const result = this.db + .prepare("DELETE FROM company_skills WHERE id = ? AND company_id = ?") + .run(skillId, companyId); + return result.changes > 0; + } + + /** Assign a skill to an agent (INSERT OR IGNORE for idempotency). */ + assignSkillToAgent(agentId: string, skillId: string): void { + this.db + .prepare("INSERT OR IGNORE INTO agent_skill_assignments (agent_id, skill_id, created_at) VALUES (?, ?, ?)") + .run(agentId, skillId, nowIso()); + } + + /** Remove a skill assignment from an agent. */ + removeSkillFromAgent(agentId: string, skillId: string): void { + this.db + .prepare("DELETE FROM agent_skill_assignments WHERE agent_id = ? AND skill_id = ?") + .run(agentId, skillId); + } + + /** List all skill IDs assigned to a specific agent. */ + listAgentSkillAssignments(agentId: string): string[] { + return this.readAll<{ skill_id: string }>( + "SELECT skill_id FROM agent_skill_assignments WHERE agent_id = ?", + agentId, + ).map((row) => row.skill_id); + } + + /** List all agent IDs assigned to a specific skill. */ + listSkillAgentAssignments(skillId: string): string[] { + return this.readAll<{ agent_id: string }>( + "SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?", + skillId, + ).map((row) => row.agent_id); + } + + /** Map a raw SQLite row to a typed SkillRecord. */ + private mapSkillRow(row: SqliteRow): SkillRecord { + return { + id: row.id, + companyId: row.company_id, + key: row.key, + slug: row.slug, + name: row.name, + description: row.description ?? null, + markdown: row.markdown ?? "", + sourceType: (row.source_type ?? "local_path") as SkillSourceType, + sourceLocator: row.source_locator ?? null, + sourceRef: row.source_ref ?? null, + trustLevel: (row.trust_level ?? "markdown_only") as TrustLevel, + compatibility: row.compatibility ?? "compatible", + fileInventory: parseJson(row.file_inventory, []), + metadata: parseJson | null>(row.metadata, null), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + private getSetting(key: string): string | null { const row = this.db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined; return row?.value ?? null; @@ -3387,6 +3540,21 @@ export class AppDatabase { log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`); }, }, + { + version: 16, + name: "create_agent_skill_assignments", + sql: ` + CREATE TABLE IF NOT EXISTS agent_skill_assignments ( + agent_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (agent_id, skill_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id) + `, + }, ]; for (const migration of migrations) { diff --git a/src/main/skill-github.ts b/src/main/skill-github.ts new file mode 100644 index 00000000..36c46a72 --- /dev/null +++ b/src/main/skill-github.ts @@ -0,0 +1,162 @@ +// --------------------------------------------------------------------------- +// GitHub REST API skill fetcher -- imports skills without git clone (D-02) +// Public repos only (D-03). Uses raw.githubusercontent.com for content +// downloads (does not count against API rate limits per research). +// --------------------------------------------------------------------------- + +/** + * Data returned for each skill found in a GitHub repository. + */ +export interface GitHubSkillData { + skillMdPath: string; + markdown: string; + inventory: string[]; + owner: string; + repo: string; +} + +interface Logger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +// --------------------------------------------------------------------------- +// Commit SHA resolution +// --------------------------------------------------------------------------- + +/** + * Resolve the latest commit SHA for a given ref (branch, tag, or HEAD). + * Returns the 40-character hex SHA on success, null on error. + */ +export async function resolveGitHubCommitSha( + owner: string, + repo: string, + ref?: string, + logger?: Logger, +): Promise { + const target = ref || "HEAD"; + const url = `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(target)}`; + try { + const response = await fetch(url, { + headers: { Accept: "application/vnd.github.sha" }, + }); + if (!response.ok) { + if (response.status === 403) { + const resetHeader = response.headers.get("X-RateLimit-Reset"); + logger?.warn( + `GitHub API rate limit hit for ${owner}/${repo}`, + resetHeader ? `resets at ${new Date(Number(resetHeader) * 1000).toISOString()}` : "", + ); + } else { + logger?.warn(`GitHub commit resolve failed: ${response.status} for ${owner}/${repo}@${target}`); + } + return null; + } + const sha = (await response.text()).trim(); + return /^[0-9a-f]{40}$/i.test(sha) ? sha : null; + } catch (err) { + logger?.error("GitHub commit resolve network error:", err); + return null; + } +} + +// --------------------------------------------------------------------------- +// GitHub tree + SKILL.md fetcher +// --------------------------------------------------------------------------- + +/** + * Fetch all SKILL.md files from a GitHub repository via the REST API. + * + * 1. GET the recursive git tree to find all SKILL.md paths + * 2. Optionally filter to a specific skill slug directory + * 3. Download each SKILL.md via raw.githubusercontent.com + * 4. Collect sibling files as file inventory + */ +export async function fetchGitHubSkills( + owner: string, + repo: string, + ref: string, + requestedSlug: string | null, + logger?: Logger, +): Promise<{ skills: GitHubSkillData[]; commitSha: string }> { + const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`; + try { + const treeResponse = await fetch(treeUrl, { + headers: { Accept: "application/json" }, + }); + if (!treeResponse.ok) { + logger?.warn(`GitHub tree fetch failed: ${treeResponse.status} for ${owner}/${repo}@${ref}`); + return { skills: [], commitSha: ref }; + } + const treeData = (await treeResponse.json()) as { + tree?: Array<{ path: string; type: string }>; + sha?: string; + }; + const treeSha = typeof treeData.sha === "string" ? treeData.sha : ref; + const allEntries = (treeData.tree ?? []).filter( + (entry) => entry.type === "blob" && typeof entry.path === "string", + ); + const allPaths = allEntries.map((entry) => entry.path); + + // Find SKILL.md files (at root or nested) + let skillMdPaths = allPaths.filter( + (p) => p.endsWith("/SKILL.md") || p === "SKILL.md", + ); + + // Filter to requested slug directory if provided + if (requestedSlug) { + skillMdPaths = skillMdPaths.filter((p) => { + const dir = p === "SKILL.md" ? "" : p.slice(0, p.lastIndexOf("/")); + const dirName = dir.split("/").pop() ?? ""; + return dirName.toLowerCase() === requestedSlug.toLowerCase(); + }); + } + + if (skillMdPaths.length === 0) { + logger?.info(`No SKILL.md files found in ${owner}/${repo}@${ref}`); + return { skills: [], commitSha: treeSha }; + } + + const skills: GitHubSkillData[] = []; + + for (const skillMdPath of skillMdPaths) { + try { + // Download content via raw.githubusercontent.com (no API rate limit) + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${skillMdPath}`; + const contentResponse = await fetch(rawUrl); + if (!contentResponse.ok) { + logger?.warn(`Failed to download ${rawUrl}: ${contentResponse.status}`); + continue; + } + const markdown = await contentResponse.text(); + + // Collect sibling files in the same directory (or subdirectories) + const skillDir = + skillMdPath === "SKILL.md" + ? "" + : skillMdPath.slice(0, skillMdPath.lastIndexOf("/")); + const prefix = skillDir ? `${skillDir}/` : ""; + const inventory = allPaths.filter( + (p) => p !== skillMdPath && (prefix ? p.startsWith(prefix) : !p.includes("/")), + ); + + skills.push({ + skillMdPath, + markdown, + inventory, + owner, + repo, + }); + } catch (err) { + logger?.error(`Error downloading skill ${skillMdPath}:`, err); + continue; + } + } + + return { skills, commitSha: treeSha }; + } catch (err) { + logger?.error(`GitHub tree fetch network error for ${owner}/${repo}:`, err); + return { skills: [], commitSha: ref }; + } +}