diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 56579022..956e7431 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -21,6 +21,7 @@ export type { StdoutLineParser, CLIAdapterModule, CreateConfigValues, + ManagedSkillRef, } from "./types.js"; export { REDACTED_HOME_PATH_USER, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3bc670a2..0b1e67d2 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { constants as fsConstants, promises as fs } from "node:fs"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; +import type { ManagedSkillRef } from "./types.js"; /** * Decode a Buffer as GBK (CP936). Used as a fallback when UTF-8 decoding @@ -466,3 +467,34 @@ export async function runChildProcess( .catch(reject); }); } + +/** + * Create symlinks for managed skills in the target directory. + * Per D-13: uses 'junction' type on Windows to avoid Developer Mode requirement. + * Falls back gracefully -- skill injection is best-effort and should not block runs. + */ +export async function createManagedSkillSymlinks( + managedSkills: ManagedSkillRef[], + targetDir: string, + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, +): Promise { + await fs.mkdir(targetDir, { recursive: true }); + const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined; + for (const skill of managedSkills) { + const target = path.join(targetDir, skill.runtimeName); + const existing = await fs.lstat(target).catch(() => null); + if (existing) { + // Remove stale symlink from previous run + await fs.rm(target, { recursive: true, force: true }).catch(() => {}); + } + try { + await fs.symlink(skill.sourcePath, target, symlinkType); + await onLog("stderr", `[agent-company] Injected managed skill "${skill.runtimeName}"\n`); + } catch (err) { + await onLog( + "stderr", + `[agent-company] Failed to inject skill "${skill.runtimeName}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 6503e5a1..78bca57a 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -220,6 +220,14 @@ export interface CLIAdapterModule { // UI config form values (moved from ui/src/components/AgentConfigForm.tsx) // --------------------------------------------------------------------------- +/** Skill reference passed from main process to worker for adapter injection. */ +export interface ManagedSkillRef { + /** Skill slug used as directory name in the adapter's skill directory */ + runtimeName: string; + /** Absolute path to the materialized skill directory on disk */ + sourcePath: string; +} + export interface CreateConfigValues { adapterType: string; cwd: string; diff --git a/src/main/database.ts b/src/main/database.ts index 7dc9f034..224d551b 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -2,9 +2,11 @@ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readF import { appendFile, copyFile, cp, rm } from "node:fs/promises"; import { extname, join, resolve, sep } from "node:path"; import { randomUUID } from "node:crypto"; +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, @@ -1403,6 +1405,20 @@ export class AppDatabase { } catch (err) { if (!(err instanceof Error) || !err.message.includes("no such table")) throw err; } + // 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); this.db.prepare("delete from pending_wakes where company_id = ?").run(companyId); this.db.prepare("delete from cost_entries where company_id = ?").run(companyId); this.db.prepare("delete from activity where company_id = ?").run(companyId); @@ -2132,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; @@ -2979,7 +3139,7 @@ export class AppDatabase { .map((row) => row.version), ); - const migrations: Array<{ version: number; name: string; sql: string }> = [ + const migrations: Array<{ version: number; name: string; sql?: string; fn?: () => void }> = [ { version: 1, name: "add_workspace_lock_lease", @@ -3197,14 +3357,216 @@ export class AppDatabase { create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id); `, }, + { + version: 13, + name: "add_checkout_session_budget_columns", + sql: ` + ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; + ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; + ALTER TABLE tasks ADD COLUMN active_session_id TEXT; + ALTER TABLE cost_entries ADD COLUMN project_id TEXT; + CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id) + `, + }, + { + version: 14, + name: "create_skills_budget_wakeup_session_tables", + sql: ` + CREATE TABLE IF NOT EXISTS company_skills ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + key TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + markdown TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'local_path', + source_locator TEXT, + source_ref TEXT, + trust_level TEXT NOT NULL DEFAULT 'markdown_only', + compatibility TEXT NOT NULL DEFAULT 'compatible', + file_inventory TEXT NOT NULL DEFAULT '[]', + metadata TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); + CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); + + CREATE TABLE IF NOT EXISTS budget_policies ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + metric TEXT NOT NULL DEFAULT 'billed_cents', + window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', + amount INTEGER NOT NULL DEFAULT 0, + warn_percent INTEGER NOT NULL DEFAULT 80, + hard_stop_enabled INTEGER NOT NULL DEFAULT 1, + notify_enabled INTEGER NOT NULL DEFAULT 1, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); + CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); + + CREATE TABLE IF NOT EXISTS budget_incidents ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + policy_id TEXT NOT NULL REFERENCES budget_policies(id), + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + metric TEXT NOT NULL, + window_kind TEXT NOT NULL, + window_start TEXT NOT NULL, + window_end TEXT NOT NULL, + threshold_type TEXT NOT NULL, + amount_limit INTEGER NOT NULL, + amount_observed INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + resolved_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); + CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); + CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); + + CREATE TABLE IF NOT EXISTS task_sessions ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + task_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + adapter_type TEXT NOT NULL, + session_params_json TEXT, + session_display_id TEXT, + last_run_id TEXT, + last_error TEXT, + run_count INTEGER NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + rotated_at TEXT, + rotation_reason TEXT, + handoff_markdown TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); + CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); + + CREATE TABLE IF NOT EXISTS wakeup_requests ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + source TEXT NOT NULL, + trigger_detail TEXT, + reason TEXT, + payload TEXT, + status TEXT NOT NULL DEFAULT 'queued', + coalesced_count INTEGER NOT NULL DEFAULT 0, + requested_by_actor_type TEXT, + requested_by_actor_id TEXT, + idempotency_key TEXT, + run_id TEXT, + requested_at TEXT NOT NULL, + claimed_at TEXT, + finished_at TEXT, + error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); + CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); + CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at) + `, + }, + { + version: 15, + name: "migrate_session_data_to_task_sessions", + fn: () => { + // D-01: Eager copy of session_json to task_sessions table + // Only process tasks that have session data but no active_session_id yet (idempotent) + const tasksWithSessions = this.readAll<{ + id: string; + companyId: string; + assigneeAgentId: string | null; + sessionJson: string | null; + sessionDisplayId: string | null; + }>( + `SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, + session_json as sessionJson, session_display_id as sessionDisplayId + FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL` + ); + + let migrated = 0; + let skipped = 0; + + for (const task of tasksWithSessions) { + try { + // Validate JSON is parseable (D-02: skip corrupt rows) + JSON.parse(task.sessionJson!); + + // Look up agent's connector_id for adapter_type + const agent = task.assigneeAgentId + ? this.db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined + : undefined; + const adapterType = agent?.connector_id ?? "unknown"; + const sessionId = randomUUID(); + const ts = nowIso(); + + this.db.prepare( + `INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, + session_params_json, session_display_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + sessionId, task.companyId, task.id, + task.assigneeAgentId ?? "unassigned", adapterType, + task.sessionJson, task.sessionDisplayId, ts, ts + ); + + this.db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?") + .run(sessionId, task.id); + + migrated++; + } catch (err) { + // D-02: Skip bad rows, log warning, don't block app startup + log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + skipped++; + } + } + + 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) { if (applied.has(migration.version)) continue; try { this.transaction(() => { - for (const stmt of migration.sql.split(";").map((s) => s.trim()).filter(Boolean)) { - this.db.prepare(stmt).run(); + if (migration.fn) { + migration.fn(); + } else if (migration.sql) { + for (const stmt of migration.sql.split(";").map((s) => s.trim()).filter(Boolean)) { + this.db.prepare(stmt).run(); + } } this.db .prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)") @@ -3709,7 +4071,7 @@ export class AppDatabase { } private openConnection(): SqliteDatabase { - return new DatabaseSync(this.dbPath); + return new DatabaseSync(this.dbPath, { timeout: 5000 }); } } diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index e6e40a36..33e47b4f 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -44,6 +44,7 @@ interface EnqueueRunPayload { sessionParams: Record | null; systemPrompt: string | null; trigger: string | null; + managedSkills: Array<{ runtimeName: string; sourcePath: string }>; } interface OrchestrationDependencies { @@ -61,6 +62,7 @@ interface OrchestrationDependencies { hasWorkerReady: () => boolean; enqueueWorkerRun: (payload: EnqueueRunPayload) => void; isQuitting: () => boolean; + listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string }>; } export function createOrchestrationService({ @@ -74,6 +76,7 @@ export function createOrchestrationService({ hasWorkerReady, enqueueWorkerRun, isQuitting, + listRuntimeSkillEntries, }: OrchestrationDependencies) { let heartbeatTimer: ReturnType | null = null; const scheduleLastFiredMap = new Map(); @@ -293,6 +296,14 @@ export function createOrchestrationService({ thinkingEffort = null; } + // Fetch managed skills for this agent -- best-effort, never blocks run dispatch + let managedSkills: Array<{ runtimeName: string; sourcePath: string }> = []; + try { + managedSkills = listRuntimeSkillEntries(companyId, agent.id, connector.id); + } catch (err) { + logger.warn(`[run] Failed to fetch managed skills for agent ${agent.name}: ${err instanceof Error ? err.message : String(err)}`); + } + enqueueWorkerRun({ runId, companyId, @@ -312,6 +323,7 @@ export function createOrchestrationService({ sessionParams: taskSession.sessionParams, systemPrompt, trigger, + managedSkills, }); } diff --git a/src/main/orchestrator/worker.ts b/src/main/orchestrator/worker.ts index 9956f6b9..649ed567 100644 --- a/src/main/orchestrator/worker.ts +++ b/src/main/orchestrator/worker.ts @@ -42,6 +42,7 @@ interface RunJob { sessionParams: Record | null; systemPrompt: string | null; trigger: string | null; + managedSkills: Array<{ runtimeName: string; sourcePath: string }>; } type ParentMessage = @@ -95,6 +96,7 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record isQuittingRef.current, + listRuntimeSkillEntries: (() => { + let cachedSkillService: ReturnType | null = null; + return (companyId: string, agentId: string, connectorId: string) => { + try { + if (!cachedSkillService) { + cachedSkillService = createSkillService({ db, profileDir: db.profileDir, logger }); + } + return cachedSkillService.listRuntimeSkillEntries(companyId, agentId, connectorId); + } catch { + return []; + } + }; + })(), }); workerService.createWorker(); diff --git a/src/main/skill-format.ts b/src/main/skill-format.ts new file mode 100644 index 00000000..c3448b18 --- /dev/null +++ b/src/main/skill-format.ts @@ -0,0 +1,243 @@ +// --------------------------------------------------------------------------- +// SKILL.md format parser, inventory classifier, and trust level derivation +// --------------------------------------------------------------------------- + +import { extname } from "node:path"; +import type { FileInventoryEntry, TrustLevel } from "./skill-types.js"; + +// --------------------------------------------------------------------------- +// Minimal YAML frontmatter parsing (no external YAML library) +// Follows the Paperclip reference pattern for SKILL.md files. +// --------------------------------------------------------------------------- + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseYamlScalar(rawValue: string): unknown { + const trimmed = rawValue.trim(); + if (trimmed === "") return ""; + if (trimmed === "null" || trimmed === "~") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "[]") return []; + if (trimmed === "{}") return {}; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); + if ( + trimmed.startsWith('"') || + trimmed.startsWith("[") || + trimmed.startsWith("{") + ) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; +} + +function prepareYamlLines( + raw: string, +): Array<{ indent: number; content: string }> { + return raw + .split("\n") + .map((line) => ({ + indent: line.match(/^ */)?.[0].length ?? 0, + content: line.trim(), + })) + .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); +} + +function parseYamlBlock( + lines: Array<{ indent: number; content: string }>, + startIndex: number, + indentLevel: number, +): { value: unknown; nextIndex: number } { + let index = startIndex; + while (index < lines.length && lines[index]!.content.length === 0) + index += 1; + if (index >= lines.length || lines[index]!.indent < indentLevel) { + return { value: {}, nextIndex: index }; + } + + const isArray = + lines[index]!.indent === indentLevel && + lines[index]!.content.startsWith("-"); + + if (isArray) { + const values: unknown[] = []; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel || !line.content.startsWith("-")) break; + const remainder = line.content.slice(1).trim(); + index += 1; + if (!remainder) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + values.push(nested.value); + index = nested.nextIndex; + continue; + } + const inlineObjectSeparator = remainder.indexOf(":"); + if ( + inlineObjectSeparator > 0 && + !remainder.startsWith('"') && + !remainder.startsWith("{") && + !remainder.startsWith("[") + ) { + const key = remainder.slice(0, inlineObjectSeparator).trim(); + const rawValue = remainder.slice(inlineObjectSeparator + 1).trim(); + const nextObject: Record = { + [key]: parseYamlScalar(rawValue), + }; + if (index < lines.length && lines[index]!.indent > indentLevel) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + if (isPlainRecord(nested.value)) { + Object.assign(nextObject, nested.value); + } + index = nested.nextIndex; + } + values.push(nextObject); + continue; + } + values.push(parseYamlScalar(remainder)); + } + return { value: values, nextIndex: index }; + } + + // Object parsing + const record: Record = {}; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + const colonIndex = line.content.indexOf(":"); + if (colonIndex < 0) { + index += 1; + continue; + } + const key = line.content.slice(0, colonIndex).trim(); + const remainder = line.content.slice(colonIndex + 1).trim(); + index += 1; + if (!remainder && index < lines.length && lines[index]!.indent > indentLevel) { + const nested = parseYamlBlock(lines, index, lines[index]!.indent); + record[key] = nested.value; + index = nested.nextIndex; + continue; + } + record[key] = parseYamlScalar(remainder); + } + return { value: record, nextIndex: index }; +} + +function parseYamlFrontmatter(raw: string): Record { + const prepared = prepareYamlLines(raw); + if (prepared.length === 0) return {}; + const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); + return isPlainRecord(parsed.value) ? parsed.value : {}; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Parse a SKILL.md file with optional YAML frontmatter. + * Normalizes CRLF to LF before parsing. + */ +export function parseFrontmatterMarkdown( + raw: string, +): { frontmatter: Record; body: string } { + const normalized = raw.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) { + return { frontmatter: {}, body: normalized.trim() }; + } + const closing = normalized.indexOf("\n---\n", 4); + if (closing < 0) { + // Check for closing delimiter at EOF (no trailing newline) + const eofClosing = normalized.indexOf("\n---", 4); + if (eofClosing >= 0 && eofClosing + 4 >= normalized.length) { + const frontmatterRaw = normalized.slice(4, eofClosing).trim(); + return { + frontmatter: parseYamlFrontmatter(frontmatterRaw), + body: "", + }; + } + return { frontmatter: {}, body: normalized.trim() }; + } + const frontmatterRaw = normalized.slice(4, closing).trim(); + const body = normalized.slice(closing + 5).trim(); + return { + frontmatter: parseYamlFrontmatter(frontmatterRaw), + body, + }; +} + +/** + * Classify a file path within a skill package into its inventory kind. + * Uses directory prefixes and file extensions for classification. + */ +export function classifyInventoryKind( + relativePath: string, +): FileInventoryEntry["kind"] { + // Normalize path separators and lowercase for matching + const normalized = relativePath.replace(/\\/g, "/").toLowerCase(); + + if (normalized.endsWith("/skill.md") || normalized === "skill.md") + return "skill"; + if (normalized.startsWith("references/")) return "reference"; + if (normalized.startsWith("scripts/")) return "script"; + if (normalized.startsWith("assets/")) return "asset"; + if (normalized.endsWith(".md")) return "markdown"; + + const ext = extname(normalized).toLowerCase(); + const scriptExtensions = new Set([ + ".sh", + ".js", + ".mjs", + ".cjs", + ".ts", + ".py", + ".rb", + ".bash", + ]); + if (scriptExtensions.has(ext)) return "script"; + + const assetExtensions = new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".webp", + ".pdf", + ]); + if (assetExtensions.has(ext)) return "asset"; + + return "other"; +} + +/** + * Derive the trust level for a skill based on its file inventory. + * Scripts/executables -> highest risk; assets -> medium; markdown only -> lowest. + */ +export function deriveTrustLevel(inventory: FileInventoryEntry[]): TrustLevel { + if (inventory.some((entry) => entry.kind === "script")) + return "scripts_executables"; + if ( + inventory.some( + (entry) => entry.kind === "asset" || entry.kind === "other", + ) + ) + return "assets"; + return "markdown_only"; +} + +/** + * Estimate the token count for a text string. + * Uses a rough 1:4 character-to-token ratio. + */ +export function estimateTokens(text: string): number { + if (text.length === 0) return 0; + return Math.ceil(text.length / 4); +} 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 }; + } +} diff --git a/src/main/skill-keys.ts b/src/main/skill-keys.ts new file mode 100644 index 00000000..15ae0510 --- /dev/null +++ b/src/main/skill-keys.ts @@ -0,0 +1,194 @@ +// --------------------------------------------------------------------------- +// Canonical skill key derivation and skill import source resolution +// --------------------------------------------------------------------------- + +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import type { SkillSourceType } from "./skill-types.js"; + +// --------------------------------------------------------------------------- +// Slug normalization helpers +// --------------------------------------------------------------------------- + +const SLUG_DELIM_RE = /[^a-z0-9]+/g; +const SLUG_TRIM_RE = /^-+|-+$/g; + +/** + * Normalize a value into a URL-safe slug. + * Returns null if the value is falsy or results in an empty string. + */ +export function normalizeSkillSlug( + value: string | undefined | null, +): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed + .toLowerCase() + .replace(SLUG_DELIM_RE, "-") + .replace(SLUG_TRIM_RE, ""); + return normalized.length > 0 ? normalized : null; +} + +// --------------------------------------------------------------------------- +// Hashing +// --------------------------------------------------------------------------- + +/** + * Compute the first 10 characters of a SHA-256 hex digest. + * Used for hash segments in canonical keys. + */ +export function hashSkillValue(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 10); +} + +// --------------------------------------------------------------------------- +// Canonical key derivation +// --------------------------------------------------------------------------- + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Derive a deterministic canonical key for a skill. + * + * Key formats by source type: + * - GitHub/skills_sh with owner+repo: `{owner}/{repo}/{slug}` + * - URL with sourceLocator: `url/{normalizedHost}/{hash10}/{slug}` + * - local_path with sourceLocator: `local/{hash10 of resolved path}/{slug}` + * - Fallback (managed or unknown): `company/{companyId}/{slug}` + */ +export function deriveCanonicalSkillKey( + companyId: string, + input: { + slug: string; + sourceType: SkillSourceType; + sourceLocator: string | null; + metadata: Record | null; + }, +): string { + const slug = normalizeSkillSlug(input.slug) ?? "skill"; + const metadata = isPlainRecord(input.metadata) ? input.metadata : null; + + const owner = normalizeSkillSlug(asString(metadata?.owner)); + const repo = normalizeSkillSlug(asString(metadata?.repo)); + + // GitHub or skills.sh with owner/repo metadata + if ( + (input.sourceType === "github" || input.sourceType === "skills_sh") && + owner && + repo + ) { + return `${owner}/${repo}/${slug}`; + } + + // URL source with locator + if (input.sourceType === "url") { + const locator = asString(input.sourceLocator); + if (locator) { + try { + const url = new URL(locator); + const host = normalizeSkillSlug(url.host) ?? "url"; + return `url/${host}/${hashSkillValue(locator)}/${slug}`; + } catch { + return `url/unknown/${hashSkillValue(locator)}/${slug}`; + } + } + } + + // Local path source with locator + if (input.sourceType === "local_path") { + const locator = asString(input.sourceLocator); + if (locator) { + return `local/${hashSkillValue(resolve(locator))}/${slug}`; + } + } + + // Fallback: company-scoped key + return `company/${companyId}/${slug}`; +} + +// --------------------------------------------------------------------------- +// Skill import source resolution +// --------------------------------------------------------------------------- + +// Three-part key: org/repo/skill +const THREE_PART_KEY_RE = + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +// Two-part key: org/repo +const TWO_PART_KEY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +// skills.sh URL pattern +const SKILLS_SH_RE = + /^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i; + +export interface ParsedSkillImportSource { + resolvedSource: string; + requestedSkillSlug: string | null; + originalSkillsShUrl: string | null; +} + +/** + * Parse a raw skill import source string and resolve it to a normalized form. + * + * Supported input formats: + * - `owner/repo/skill` -> GitHub URL with skills.sh origin tracking + * - `owner/repo` -> GitHub URL + * - `https://skills.sh/owner/repo/skill` -> GitHub URL with skills.sh origin + * - `https://github.com/owner/repo` -> pass through + * - `/local/path` -> pass through + */ +export function parseSkillImportSource( + rawInput: string, +): ParsedSkillImportSource { + const trimmed = rawInput.trim(); + + // Three-part key (org/repo/skill) -- originates from skills.sh registry + if ( + !/^https?:\/\//i.test(trimmed) && + THREE_PART_KEY_RE.test(trimmed) + ) { + const [owner, repo, skillSlugRaw] = trimmed.split("/"); + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: normalizeSkillSlug(skillSlugRaw), + originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`, + }; + } + + // Two-part key (org/repo) -- resolve to GitHub + if ( + !/^https?:\/\//i.test(trimmed) && + TWO_PART_KEY_RE.test(trimmed) + ) { + return { + resolvedSource: `https://github.com/${trimmed}`, + requestedSkillSlug: null, + originalSkillsShUrl: null, + }; + } + + // skills.sh URL -> resolve to GitHub + const skillsShMatch = trimmed.match(SKILLS_SH_RE); + if (skillsShMatch) { + const [, owner, repo, skillSlugRaw] = skillsShMatch; + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: skillSlugRaw + ? normalizeSkillSlug(skillSlugRaw) + : null, + originalSkillsShUrl: trimmed, + }; + } + + // Everything else -- pass through as resolvedSource + return { + resolvedSource: trimmed, + requestedSkillSlug: null, + originalSkillsShUrl: null, + }; +} diff --git a/src/main/skill-service.ts b/src/main/skill-service.ts new file mode 100644 index 00000000..0fdcdfc9 --- /dev/null +++ b/src/main/skill-service.ts @@ -0,0 +1,719 @@ +// --------------------------------------------------------------------------- +// SkillService -- full skill lifecycle: import, CRUD, assignment, token budget, +// update detection. Factory pattern following createOrchestrationService. +// --------------------------------------------------------------------------- + +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join, resolve, basename, dirname, relative, posix } from "node:path"; +import type { AppDatabase } from "./database"; +import type { DesktopResult } from "@shared/contracts"; +import type { + FileInventoryEntry, + SkillRecord, + SkillImportResult, + SkillSourceType, + SkillUpdateStatus, + RuntimeSkillEntry, +} from "./skill-types.js"; +import { + parseFrontmatterMarkdown, + classifyInventoryKind, + deriveTrustLevel, + estimateTokens, +} from "./skill-format.js"; +import { + deriveCanonicalSkillKey, + normalizeSkillSlug, + hashSkillValue, + parseSkillImportSource, +} from "./skill-keys.js"; +import { + resolveGitHubCommitSha, + fetchGitHubSkills, +} from "./skill-github.js"; +import { TOKEN_BUDGETS } from "./skill-types.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SkillServiceDependencies { + db: AppDatabase; + profileDir: string; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) { + // ---- Result helpers ---- + function ok(data: T): DesktopResult { + return { ok: true, data }; + } + function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; + } + + // ---- Path helpers (D-10: skills stored in profileDir/skills/{companyId}/) ---- + function skillsRoot(companyId: string): string { + return join(profileDir, "skills", companyId); + } + + function skillFilesDir(companyId: string, slug: string): string { + return join(skillsRoot(companyId), slug); + } + + function runtimeDir(companyId: string): string { + return join(skillsRoot(companyId), "__runtime__"); + } + + // ---- File materialization helpers ---- + + /** + * Write SKILL.md (and tracked inventory files from GitHub downloads) + * to the local skill directory. + */ + async function materializeSkillFiles( + companyId: string, + slug: string, + markdown: string, + _inventory: FileInventoryEntry[], + ): Promise { + const dir = skillFilesDir(companyId, slug); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); + return dir; + } + + /** Remove a skill's file directory from disk. */ + async function cleanupSkillFiles(companyId: string, slug: string): Promise { + const dir = skillFilesDir(companyId, slug); + if (existsSync(dir)) { + await rm(dir, { recursive: true, force: true }); + } + } + + // ---- GitHub URL parser ---- + + function parseGitHubUrl(url: string): { owner: string; repo: string } | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 2) return null; + return { + owner: parts[0]!, + repo: parts[1]!.replace(/\.git$/i, ""), + }; + } catch { + return null; + } + } + + // ---- Import routing ---- + + /** + * Import skills from any supported source. + * Routes to the correct handler based on parseSkillImportSource. + */ + async function importFromSource( + companyId: string, + rawSource: string, + ): Promise> { + try { + const parsed = parseSkillImportSource(rawSource); + const source = parsed.resolvedSource; + + // skills.sh resolved sources are always GitHub URLs + if (parsed.originalSkillsShUrl) { + const results = await importFromGitHub( + companyId, + source, + parsed.requestedSkillSlug, + parsed.originalSkillsShUrl, + ); + return ok(results); + } + + // GitHub URL + if (source.includes("github.com")) { + const results = await importFromGitHub( + companyId, + source, + parsed.requestedSkillSlug, + null, + ); + return ok(results); + } + + // Local path (absolute path, drive letter on Windows, or ~ home dir) + if ( + source.startsWith("/") || + /^[A-Za-z]:/.test(source) || + source.startsWith("~") + ) { + const results = await importFromLocal(companyId, source); + return ok(results); + } + + // HTTP/HTTPS URL + if (/^https?:\/\//i.test(source)) { + const results = await importFromUrl(companyId, source); + return ok(results); + } + + return fail("invalid_source", `Unrecognized skill source format: ${rawSource}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("Skill import failed:", message); + return fail("import_error", message); + } + } + + // ---- GitHub import (SKILL-01) ---- + + async function importFromGitHub( + companyId: string, + url: string, + requestedSlug: string | null, + originalSkillsShUrl: string | null, + ): Promise { + const ghParsed = parseGitHubUrl(url); + if (!ghParsed) { + throw new Error(`Invalid GitHub URL: ${url}`); + } + const { owner, repo } = ghParsed; + + // Resolve latest commit SHA + const sha = await resolveGitHubCommitSha(owner, repo, undefined, logger); + if (!sha) { + throw new Error(`Failed to resolve commit SHA for ${owner}/${repo}`); + } + + // Fetch SKILL.md files from repo + const { skills: githubSkills, commitSha } = await fetchGitHubSkills( + owner, repo, sha, requestedSlug, logger, + ); + + if (githubSkills.length === 0) { + throw new Error(`No SKILL.md files found in ${owner}/${repo}`); + } + + const results: SkillImportResult[] = []; + const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github"; + const nowTs = new Date().toISOString(); + + for (const ghSkill of githubSkills) { + const parsed = parseFrontmatterMarkdown(ghSkill.markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug( + ghSkill.skillMdPath === "SKILL.md" + ? repo + : basename(dirname(ghSkill.skillMdPath)), + ) ?? + repo; + + // Build file inventory from the tree paths + const skillDir = + ghSkill.skillMdPath === "SKILL.md" + ? "" + : ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/")); + const prefix = skillDir ? `${skillDir}/` : ""; + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" as const }, + ...ghSkill.inventory.map((filePath) => { + const relativePath = prefix ? filePath.slice(prefix.length) : filePath; + return { + path: relativePath, + kind: classifyInventoryKind(relativePath), + }; + }), + ]; + + const metadata: Record = { + sourceKind: "github", + owner, + repo, + ref: commitSha, + trackingRef: "HEAD", + ...(originalSkillsShUrl ? { originalSkillsShUrl } : {}), + }; + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType, + sourceLocator: url, + metadata, + }); + + const trustLevel = deriveTrustLevel(inventory); + const name = + (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? + rawSlug; + const description = + typeof parsed.frontmatter.description === "string" + ? parsed.frontmatter.description + : null; + + const skillId = randomUUID(); + + // Materialize files to disk + await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory); + + // Upsert to database + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name, + description, + markdown: ghSkill.markdown, + sourceType, + sourceLocator: url, + sourceRef: commitSha, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: JSON.stringify(metadata), + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"}: ${key} from ${owner}/${repo}`); + } + + return results; + } + + // ---- Local path import (SKILL-02, D-04) ---- + + async function importFromLocal( + companyId: string, + localPath: string, + ): Promise { + const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")); + + if (!existsSync(resolvedPath)) { + throw new Error(`Local path does not exist: ${resolvedPath}`); + } + + const stat = statSync(resolvedPath); + const results: SkillImportResult[] = []; + const nowTs = new Date().toISOString(); + + if (stat.isFile()) { + // Single file treated as a skill + const markdown = readFileSync(resolvedPath, "utf-8"); + const parsed = parseFrontmatterMarkdown(markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug(basename(resolvedPath, ".md")) ?? + "skill"; + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const trustLevel = deriveTrustLevel(inventory); + const contentHash = hashSkillValue(markdown); + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "local_path", + sourceLocator: resolvedPath, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(companyId, rawSlug, markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + markdown, + sourceType: "local_path", + sourceLocator: resolvedPath, + sourceRef: contentHash, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name: rawSlug, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`); + return results; + } + + // Directory: walk to find SKILL.md files + const skillMdFiles = findSkillMdFiles(resolvedPath); + if (skillMdFiles.length === 0) { + throw new Error(`No SKILL.md files found in ${resolvedPath}`); + } + + for (const skillMdPath of skillMdFiles) { + const skillDir = dirname(skillMdPath); + const markdown = readFileSync(skillMdPath, "utf-8"); + const parsed = parseFrontmatterMarkdown(markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug(basename(skillDir)) ?? + "skill"; + + // Build inventory from directory listing + const inventory = buildLocalInventory(skillDir, skillMdPath); + const trustLevel = deriveTrustLevel(inventory); + const contentHash = hashSkillValue(markdown); + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "local_path", + sourceLocator: skillDir, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(companyId, rawSlug, markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + markdown, + sourceType: "local_path", + sourceLocator: skillDir, + sourceRef: contentHash, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name: rawSlug, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`); + } + + return results; + } + + // ---- URL import (SKILL-03, D-05) ---- + + async function importFromUrl( + companyId: string, + url: string, + ): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`); + } + const markdown = await response.text(); + const parsed = parseFrontmatterMarkdown(markdown); + const nowTs = new Date().toISOString(); + + // Derive slug from frontmatter or URL path + let urlSlug: string | null = null; + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split("/").filter(Boolean); + const lastPart = pathParts[pathParts.length - 1] ?? ""; + urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, "")); + } catch { + // ignore URL parsing errors + } + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + urlSlug ?? + "skill"; + + // Single-file skill: only SKILL.md + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const trustLevel: "markdown_only" = "markdown_only"; + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "url", + sourceLocator: url, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(companyId, rawSlug, markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + markdown, + sourceType: "url", + sourceLocator: url, + sourceRef: null, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + const results: SkillImportResult[] = [ + { skillId, key, name: rawSlug, isUpdate }, + ]; + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`); + return results; + } + + // ---- CRUD ---- + + function listForCompany(companyId: string): SkillRecord[] { + return db.listCompanySkills(companyId); + } + + function getDetail(companyId: string, skillId: string): DesktopResult { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found`); + return ok(skill); + } + + /** Delete a skill: (1) assignments cascade via FK, (2) files from disk, (3) DB record per D-12. */ + async function deleteSkill( + companyId: string, + skillId: string, + ): Promise> { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found`); + + try { + // D-12 order: (1) assignments cascade via FK on delete, + // (2) delete files from disk + await cleanupSkillFiles(companyId, skill.slug); + // (3) delete DB record (cascades agent_skill_assignments via FK) + db.deleteCompanySkill(companyId, skillId); + logger.info(`Skill deleted: ${skill.key}`); + return ok(undefined); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("Skill deletion failed:", message); + return fail("delete_error", message); + } + } + + // ---- Agent assignment (SKILL-07) ---- + + function assignToAgent( + companyId: string, + agentId: string, + skillId: string, + ): DesktopResult { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found for company`); + db.assignSkillToAgent(agentId, skillId); + return ok(undefined); + } + + function removeFromAgent( + _companyId: string, + agentId: string, + skillId: string, + ): DesktopResult { + db.removeSkillFromAgent(agentId, skillId); + return ok(undefined); + } + + // ---- Update detection (SKILL-11, D-11) ---- + + async function checkUpdateStatus( + companyId: string, + skillId: string, + ): Promise> { + const skill = db.getCompanySkill(companyId, skillId); + if (!skill) return fail("not_found", `Skill ${skillId} not found`); + + // Only GitHub/skills_sh sources support update checks + if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef: null, + hasUpdate: false, + }); + } + + // Parse owner/repo from metadata + const metadata = skill.metadata; + const owner = typeof metadata?.owner === "string" ? metadata.owner : null; + const repo = typeof metadata?.repo === "string" ? metadata.repo : null; + + if (!owner || !repo) { + // Try to parse from sourceLocator URL + const ghParsed = skill.sourceLocator ? parseGitHubUrl(skill.sourceLocator) : null; + if (!ghParsed) { + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef: null, + hasUpdate: false, + }); + } + const latestRef = await resolveGitHubCommitSha(ghParsed.owner, ghParsed.repo, undefined, logger); + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef, + hasUpdate: latestRef != null && latestRef !== skill.sourceRef, + }); + } + + const trackingRef = typeof metadata?.trackingRef === "string" ? metadata.trackingRef : undefined; + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, logger); + + return ok({ + skillId: skill.id, + key: skill.key, + currentRef: skill.sourceRef, + latestRef, + hasUpdate: latestRef != null && latestRef !== skill.sourceRef, + }); + } + + // ---- Runtime skill entries (SKILL-09 token budget) ---- + + /** + * Build the list of RuntimeSkillEntry for adapter injection. + * Applies token budget enforcement: skills sorted by creation date, + * accumulated until budget exceeded, then skipped with warning. + */ + function listRuntimeSkillEntries( + companyId: string, + agentId: string, + adapterType: string, + ): RuntimeSkillEntry[] { + const allSkills = db.listCompanySkills(companyId); + const agentAssignments = db.listAgentSkillAssignments(agentId); + + // If agent has specific assignments, filter to those; otherwise use all + const skills = + agentAssignments.length > 0 + ? allSkills.filter((s) => agentAssignments.includes(s.id)) + : allSkills; + + // Sort by creation date (oldest first -- first imported gets priority) + const sorted = [...skills].sort( + (a, b) => a.createdAt.localeCompare(b.createdAt), + ); + + const budget = TOKEN_BUDGETS[adapterType] ?? 30000; + let accumulated = 0; + const entries: RuntimeSkillEntry[] = []; + + for (const skill of sorted) { + const tokenEstimate = estimateTokens(skill.markdown); + if (accumulated + tokenEstimate > budget) { + logger.warn( + `Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`, + ); + continue; + } + accumulated += tokenEstimate; + entries.push({ + runtimeName: skill.slug, + sourcePath: skillFilesDir(companyId, skill.slug), + name: skill.name, + tokenEstimate, + }); + } + + return entries; + } + + // ---- Local directory walker ---- + + function findSkillMdFiles(rootDir: string): string[] { + const found: string[] = []; + function walk(dir: string) { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name === "SKILL.md") { + found.push(fullPath); + } + } + } catch { + // Skip unreadable directories + } + } + walk(rootDir); + return found; + } + + function buildLocalInventory(skillDir: string, skillMdPath: string): FileInventoryEntry[] { + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + try { + const entries = readdirSync(skillDir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const entryPath = entry.parentPath + ? join(entry.parentPath, entry.name) + : join(skillDir, entry.name); + const fullPath = resolve(entryPath); + if (fullPath === skillMdPath) continue; + const relativePath = relative(skillDir, fullPath).replace(/\\/g, "/"); + inventory.push({ + path: relativePath, + kind: classifyInventoryKind(relativePath), + }); + } + } catch { + // If directory listing fails, return just the SKILL.md entry + } + return inventory; + } + + // ---- Public API ---- + + return { + importFromSource, + importFromGitHub, + importFromLocal, + importFromUrl, + listForCompany, + getDetail, + deleteSkill, + assignToAgent, + removeFromAgent, + checkUpdateStatus, + listRuntimeSkillEntries, + }; +} diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts new file mode 100644 index 00000000..43cfe4f1 --- /dev/null +++ b/src/main/skill-types.ts @@ -0,0 +1,114 @@ +// --------------------------------------------------------------------------- +// Skill system type definitions and constants +// --------------------------------------------------------------------------- + +/** + * Source type for a skill -- matches the company_skills.source_type column. + */ +export type SkillSourceType = + | "github" + | "local_path" + | "url" + | "skills_sh" + | "managed"; + +/** + * Trust level derived from the file inventory of a skill package. + * Determines what content is injected and how cautiously. + */ +export type TrustLevel = + | "markdown_only" + | "assets" + | "scripts_executables"; + +/** + * A single file entry within a skill package inventory. + */ +export interface FileInventoryEntry { + path: string; + kind: "skill" | "reference" | "script" | "asset" | "markdown" | "other"; +} + +/** + * Full row interface matching the company_skills table. + * Property names use camelCase (JS convention) even though DB columns are snake_case. + */ +export interface SkillRecord { + id: string; + companyId: string; + key: string; + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: SkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: TrustLevel; + compatibility: string; + fileInventory: FileInventoryEntry[]; + metadata: Record | null; + createdAt: string; + updatedAt: string; +} + +/** + * Result returned after importing or updating a skill. + */ +export interface SkillImportResult { + skillId: string; + key: string; + name: string; + isUpdate: boolean; +} + +/** + * Runtime representation of a skill entry for adapter injection. + */ +export interface RuntimeSkillEntry { + runtimeName: string; + sourcePath: string; + name: string; + tokenEstimate: number; +} + +/** + * Minimal skill reference passed to worker threads. + * Subset of RuntimeSkillEntry to minimize serialization overhead. + */ +export interface ManagedSkillRef { + runtimeName: string; + sourcePath: string; +} + +/** + * Skill-to-agent assignment record. + */ +export interface SkillAssignment { + agentId: string; + skillId: string; +} + +/** + * Update status for a skill -- used to check if a newer version is available. + */ +export interface SkillUpdateStatus { + skillId: string; + key: string; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; +} + +/** + * Per-adapter token budgets for skill content injection. + * Values represent the maximum token count of skill content that can be + * included in a single prompt for each adapter type. + */ +export const TOKEN_BUDGETS: Readonly> = { + claude_local: 50000, + codex_local: 30000, + gemini_local: 50000, + opencode_local: 30000, + kimi_local: 20000, +};