feat(02-04): add shared skill symlink helper and wire orchestration-to-worker pipeline
- Add ManagedSkillRef type to adapter-utils for cross-package use - Add createManagedSkillSymlinks() with Windows junction support (D-13) - Add managedSkills field to worker RunJob and connector config - Add listRuntimeSkillEntries dependency to orchestration service - Wire SkillService lazy init in service-graph for runtime skill resolution - Include prerequisite skill system files from plans 02-01/02-02
This commit is contained in:
1 parent
c1de05a7f1
commit
3fb9ab86d9
12 files changed
+1867
-4
No files matched your search
@@ -21,6 +21,7 @@ export type {
|
||||
StdoutLineParser,
|
||||
CLIAdapterModule,
|
||||
CreateConfigValues,
|
||||
ManagedSkillRef,
|
||||
} from "./types.js";
|
||||
export {
|
||||
REDACTED_HOME_PATH_USER,
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+366
-4
@@ -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<SqliteRow>(
|
||||
"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<FileInventoryEntry[]>(row.file_inventory, []),
|
||||
metadata: parseJson<Record<string, unknown> | 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ interface EnqueueRunPayload {
|
||||
sessionParams: Record<string, unknown> | 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<typeof setInterval> | null = null;
|
||||
const scheduleLastFiredMap = new Map<string, number>();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ interface RunJob {
|
||||
sessionParams: Record<string, unknown> | 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<string,
|
||||
cwd: job.workspacePath ?? process.cwd(),
|
||||
model: job.model,
|
||||
env: sanitizedEnv,
|
||||
managedSkills: job.managedSkills,
|
||||
};
|
||||
|
||||
switch (job.connectorId) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createConnectorHealthService } from "./connector-health-service";
|
||||
import { createDesktopShellService } from "./desktop-shell-service";
|
||||
import { createOnboardingService } from "./onboarding-service";
|
||||
import { createOrchestrationService } from "./orchestration-service";
|
||||
import { createSkillService } from "./skill-service";
|
||||
import { createUpdaterService } from "./updater-service";
|
||||
import { createWorkerService } from "./worker-service";
|
||||
import { createAppLifecycleService } from "./app-lifecycle-service";
|
||||
@@ -178,6 +179,19 @@ export function createServiceGraph({
|
||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||
},
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
listRuntimeSkillEntries: (() => {
|
||||
let cachedSkillService: ReturnType<typeof createSkillService> | 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();
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
[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<string, unknown> = {};
|
||||
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<string, unknown> {
|
||||
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<string, unknown>; 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);
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
function fail(code: string, message: string): DesktopResult<never> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<DesktopResult<SkillImportResult[]>> {
|
||||
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<SkillImportResult[]> {
|
||||
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<string, unknown> = {
|
||||
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<SkillImportResult[]> {
|
||||
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<SkillImportResult[]> {
|
||||
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<SkillRecord> {
|
||||
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<DesktopResult<void>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
db.removeSkillFromAgent(agentId, skillId);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// ---- Update detection (SKILL-11, D-11) ----
|
||||
|
||||
async function checkUpdateStatus(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
): Promise<DesktopResult<SkillUpdateStatus>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> | 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<Record<string, number>> = {
|
||||
claude_local: 50000,
|
||||
codex_local: 30000,
|
||||
gemini_local: 50000,
|
||||
opencode_local: 30000,
|
||||
kimi_local: 20000,
|
||||
};
|
||||
Reference in new issue
Block a user