merge: wave 1 executor (02-01) into main
This commit is contained in:
commit
e3a5e20d90
7 files changed
+1078
No files matched your search
@@ -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,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,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