Initial commit

This commit is contained in:
2026-07-10 08:31:27 +08:00
commit 2b74c30d36
134 changed files with 21801 additions and 0 deletions

502
src/skills/loader.ts Normal file
View File

@@ -0,0 +1,502 @@
import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import type { BookKind } from "../types/runtime.js";
import type {
ParsedSkill,
ParsedWorkerSkill,
SkillIndexEntry,
SkillWorkerLlmBindings,
StartupInquiry,
} from "./types.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILLS_ROOT = path.resolve(__dirname, "../../skills");
export const ORCHESTRATOR_FILENAME = "orchestrator.md";
export const WORKER_SKILL_FILENAME = "SKILL.md";
export const DEFAULT_SHARED_CONTEXT_FILENAME = "shared-context.md";
export const LLM_BINDINGS_FILENAME = "llm-bindings.yaml";
/** 按 Book 形态分文件夹skill 可为平铺 .md 或 {name}/orchestrator.md 包 */
export const SKILL_BOOK_KIND_FOLDERS: BookKind[] = ["novel", "dialogue"];
type RegistryDoc = {
skills?: Array<
SkillIndexEntry & { path?: string; bookKind?: BookKind }
>;
};
/** 解析 YAML frontmatter仅支持本项目用到的简单字段 */
function parseFrontmatter(raw: string): {
meta: Record<string, string | string[] | number>;
body: string;
} {
if (!raw.startsWith("---")) {
return { meta: {}, body: raw };
}
const end = raw.indexOf("\n---", 3);
if (end === -1) {
return { meta: {}, body: raw };
}
const yaml = raw.slice(3, end).trim();
const body = raw.slice(end + 4).trim();
const meta: Record<string, string | string[] | number> = {};
let currentKey = "";
let listItems: string[] = [];
let inList = false;
const flushList = () => {
if (inList && currentKey) {
meta[currentKey] = listItems;
listItems = [];
inList = false;
}
};
for (const line of yaml.split("\n")) {
const listMatch = line.match(/^\s+-\s+(.+)$/);
if (listMatch && inList) {
listItems.push(listMatch[1].trim());
continue;
}
flushList();
const kv = line.match(/^([\w-]+):\s*(.*)$/);
if (!kv) continue;
const [, key, value] = kv;
currentKey = key;
if (value === "" || value === ">-" || value === "|") {
inList = true;
listItems = [];
} else if (value === ">-" || value.startsWith(">")) {
meta[key] = value;
} else {
meta[key] = value.trim();
inList = false;
}
}
flushList();
return { meta, body };
}
function extractSection(body: string, heading: string): string {
const re = new RegExp(`^## ${heading}\\s*$`, "m");
const match = re.exec(body);
if (!match) return "";
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^## /m);
return (next === -1 ? rest : rest.slice(0, next)).trim();
}
function parseStartupInquiry(section: string): StartupInquiry {
const promptBlock = section.match(/```text\n([\s\S]*?)```/);
const prompt = promptBlock?.[1]?.trim() ?? section.slice(0, 500);
const targetMatch = section.match(/\*\*写入目标:\*\*\s*`([^`]+)`/);
const targetKey = targetMatch?.[1]?.trim() ?? "book.brief";
const required: string[] = [];
const reqSection = section.match(/\*\*必须收集[:]*\*\*([\s\S]*?)(?=\n\*\*|$)/);
if (reqSection) {
for (const line of reqSection[1].split("\n")) {
const item = line.match(/^-\s+(.+)/);
if (item) required.push(item[1].trim());
}
}
const optional: string[] = [];
const optSection = section.match(/\*\*可选收集[:]*\*\*([\s\S]*?)(?=\n\*\*|$)/);
if (optSection) {
for (const line of optSection[1].split("\n")) {
const item = line.match(/^-\s+(.+)/);
if (item) optional.push(item[1].trim());
}
}
return { prompt, targetKey, requiredFields: required, optionalFields: optional };
}
function metaString(meta: Record<string, string | string[] | number>, key: string): string {
const v = meta[key];
return typeof v === "string" ? v : "";
}
function metaStringArray(meta: Record<string, string | string[] | number>, key: string): string[] {
const v = meta[key];
return Array.isArray(v) ? v : [];
}
function parseBookKind(value: string): BookKind | undefined {
if (value === "novel" || value === "dialogue") return value;
return undefined;
}
function bookKindFromRelativePath(relativePath: string): BookKind | undefined {
const folder = relativePath.split("/")[0];
return parseBookKind(folder);
}
function skillPackRootFromPath(relativePath: string): string | undefined {
const normalized = relativePath.replace(/\\/g, "/");
if (path.basename(normalized) === ORCHESTRATOR_FILENAME) {
return path.posix.dirname(normalized);
}
return undefined;
}
function workerIdsFromMeta(meta: Record<string, string | string[] | number>): string[] {
const workers = metaStringArray(meta, "workers");
if (workers.length > 0) return workers;
return metaStringArray(meta, "suggestedWorkers");
}
async function readRegistry(skillsRoot = SKILLS_ROOT): Promise<RegistryDoc["skills"]> {
const registryPath = path.join(skillsRoot, "registry.yaml");
try {
const raw = await readFile(registryPath, "utf8");
const doc = parseYaml(raw) as RegistryDoc;
return doc.skills ?? [];
} catch {
return [];
}
}
async function isDirectory(fullPath: string): Promise<boolean> {
try {
const s = await stat(fullPath);
return s.isDirectory();
} catch {
return false;
}
}
/** 扫描 skills/novel/*.md 与 skill 包 novel/{name}/orchestrator.md */
async function scanSkillFiles(skillsRoot = SKILLS_ROOT): Promise<string[]> {
const files: string[] = [];
for (const folder of SKILL_BOOK_KIND_FOLDERS) {
const dir = path.join(skillsRoot, folder);
let entries: string[];
try {
entries = await readdir(dir);
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(dir, entry);
if (entry.endsWith(".md")) {
files.push(`${folder}/${entry}`);
continue;
}
if (await isDirectory(full)) {
const orchestrator = path.join(full, ORCHESTRATOR_FILENAME);
try {
await readFile(orchestrator, "utf8");
files.push(`${folder}/${entry}/${ORCHESTRATOR_FILENAME}`);
} catch {
// not a skill pack
}
}
}
}
return files.sort();
}
async function loadWorkerLlmBindings(
packRoot: string | undefined,
skillsRoot = SKILLS_ROOT,
): Promise<SkillWorkerLlmBindings | undefined> {
if (!packRoot) return undefined;
const bindingsPath = path.join(skillsRoot, packRoot, LLM_BINDINGS_FILENAME);
try {
const raw = await readFile(bindingsPath, "utf8");
const doc = parseYaml(raw) as SkillWorkerLlmBindings;
if (!doc || typeof doc !== "object") return undefined;
return doc;
} catch {
return undefined;
}
}
async function parseSkillFile(
relativePath: string,
skillsRoot = SKILLS_ROOT,
): Promise<ParsedSkill> {
const fullPath = path.join(skillsRoot, relativePath);
const raw = await readFile(fullPath, "utf8");
const { meta, body } = parseFrontmatter(raw);
const startupSection = extractSection(body, "启动询问");
const folderBookKind = bookKindFromRelativePath(relativePath);
const category = metaString(meta, "category") || folderBookKind || "custom";
const bookKind =
parseBookKind(metaString(meta, "bookKind")) ??
folderBookKind ??
parseBookKind(category);
const normalizedPath = relativePath.replace(/\\/g, "/");
const fileStem = path.basename(normalizedPath, ".md");
const packRoot = skillPackRootFromPath(normalizedPath);
const defaultName = packRoot ? path.basename(packRoot) : fileStem;
const workerLlmBindings = await loadWorkerLlmBindings(packRoot, skillsRoot);
return {
name: metaString(meta, "name") || defaultName,
description: metaString(meta, "description"),
category,
bookKind,
path: normalizedPath,
skillPackRoot: packRoot,
version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1,
defaultFlowId: metaString(meta, "defaultFlowId") || undefined,
suggestedWorkers: workerIdsFromMeta(meta),
tags: metaStringArray(meta, "tags"),
sharedContextPath: resolveSharedContextPath(meta, packRoot),
workerLlmBindings,
startupInquiry: parseStartupInquiry(startupSection),
body,
};
}
function resolveSharedContextPath(
meta: Record<string, string | string[] | number>,
packRoot: string | undefined,
): string | undefined {
if (!packRoot) return undefined;
const explicit = metaString(meta, "sharedContext");
return explicit || DEFAULT_SHARED_CONTEXT_FILENAME;
}
async function parseWorkerSkillFile(
relativePath: string,
skillsRoot = SKILLS_ROOT,
): Promise<ParsedWorkerSkill> {
const fullPath = path.join(skillsRoot, relativePath);
const raw = await readFile(fullPath, "utf8");
const { meta, body } = parseFrontmatter(raw);
const normalizedPath = relativePath.replace(/\\/g, "/");
const idFromPath = path.posix.basename(path.posix.dirname(normalizedPath));
const inputTags =
metaStringArray(meta, "inputTags").length > 0
? metaStringArray(meta, "inputTags")
: metaStringArray(meta, "inputKeys");
const outputTags =
metaStringArray(meta, "outputTags").length > 0
? metaStringArray(meta, "outputTags")
: metaStringArray(meta, "outputKeys");
const inputMergeRaw = metaString(meta, "inputMerge");
const inputMerge =
inputMergeRaw === "concat" || inputMergeRaw === "latest"
? inputMergeRaw
: undefined;
const llmProfileId = metaString(meta, "llmProfileId") || undefined;
return {
id: metaString(meta, "id") || idFromPath,
skill: metaString(meta, "skill"),
name: metaString(meta, "name") || idFromPath,
description: metaString(meta, "description"),
version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1,
inputTags,
outputTags,
inputMerge,
llmProfileId,
path: normalizedPath,
body,
};
}
/** 列举 skill优先 registry.yaml否则扫描 novel/ 与 dialogue/ */
export async function listSkills(skillsRoot = SKILLS_ROOT): Promise<SkillIndexEntry[]> {
const registry = (await readRegistry(skillsRoot)) ?? [];
if (registry.length > 0) {
return registry.map((entry) => ({
name: entry.name,
description: entry.description,
category: entry.category,
bookKind: entry.bookKind ?? parseBookKind(entry.category),
path: entry.path,
}));
}
const entries: SkillIndexEntry[] = [];
for (const relativePath of await scanSkillFiles(skillsRoot)) {
try {
const parsed = await parseSkillFile(relativePath, skillsRoot);
entries.push({
name: parsed.name,
description: parsed.description,
category: parsed.category,
bookKind: parsed.bookKind,
path: parsed.path,
});
} catch {
// skip unreadable
}
}
return entries;
}
/** 按 skill name 解析相对路径 */
export async function resolveSkillPath(
skillIdOrName: string,
skillsRoot = SKILLS_ROOT,
): Promise<string | null> {
const trimmed = skillIdOrName.trim();
const registry = (await readRegistry(skillsRoot)) ?? [];
const fromRegistry = registry.find((s) => s.name === trimmed);
if (fromRegistry?.path) {
return fromRegistry.path.replace(/\\/g, "/");
}
for (const folder of SKILL_BOOK_KIND_FOLDERS) {
const packCandidate = `${folder}/${trimmed}/${ORCHESTRATOR_FILENAME}`;
try {
await readFile(path.join(skillsRoot, packCandidate), "utf8");
return packCandidate;
} catch {
// continue
}
const flatCandidate = `${folder}/${trimmed}.md`;
try {
await readFile(path.join(skillsRoot, flatCandidate), "utf8");
return flatCandidate;
} catch {
// continue
}
}
for (const relativePath of await scanSkillFiles(skillsRoot)) {
try {
const parsed = await parseSkillFile(relativePath, skillsRoot);
if (parsed.name === trimmed) {
return relativePath;
}
} catch {
continue;
}
}
return null;
}
/** 加载并解析总管 skillorchestrator.md 或平铺 .md */
export async function loadSkill(
skillIdOrName: string,
skillsRoot = SKILLS_ROOT,
): Promise<ParsedSkill> {
const relativePath = await resolveSkillPath(skillIdOrName, skillsRoot);
if (!relativePath) {
throw new Error(`未找到 skill: ${skillIdOrName}`);
}
return parseSkillFile(relativePath, skillsRoot);
}
/** 解析 skill 包内 worker 的 SKILL.md 相对路径 */
export async function resolveWorkerSkillPath(
skillIdOrName: string,
workerId: string,
skillsRoot = SKILLS_ROOT,
): Promise<string | null> {
const skill = await loadSkill(skillIdOrName, skillsRoot);
if (!skill.skillPackRoot) {
return null;
}
const relativePath = `${skill.skillPackRoot}/workers/${workerId}/${WORKER_SKILL_FILENAME}`;
try {
await readFile(path.join(skillsRoot, relativePath), "utf8");
return relativePath;
} catch {
return null;
}
}
/** 加载 skill 包固定上下文(注入所有 worker prompt 开头) */
export async function loadSkillSharedContext(
skillIdOrName: string,
skillsRoot = SKILLS_ROOT,
): Promise<string | null> {
const skill = await loadSkill(skillIdOrName, skillsRoot);
if (!skill.skillPackRoot || !skill.sharedContextPath) return null;
const fullPath = path.join(skillsRoot, skill.skillPackRoot, skill.sharedContextPath);
try {
return await readFile(fullPath, "utf8");
} catch {
return null;
}
}
/** 加载 worker skill 正文,可选拼接 skill 包固定上下文 */
export async function loadWorkerSkillWithContext(
skillIdOrName: string,
workerId: string,
skillsRoot = SKILLS_ROOT,
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
const sharedContext = await loadSkillSharedContext(skillIdOrName, skillsRoot);
const promptBody = sharedContext
? `# 固定创作上下文\n\n${sharedContext}\n\n---\n\n${worker.body}`
: worker.body;
return { worker, sharedContext, promptBody };
}
/** 加载 skill 包内专属 worker skill */
export async function loadWorkerSkill(
skillIdOrName: string,
workerId: string,
skillsRoot = SKILLS_ROOT,
): Promise<ParsedWorkerSkill> {
const relativePath = await resolveWorkerSkillPath(skillIdOrName, workerId, skillsRoot);
if (!relativePath) {
throw new Error(`未找到 worker skill: ${skillIdOrName}/${workerId}`);
}
return parseWorkerSkillFile(relativePath, skillsRoot);
}
/** 列举 skill 包内所有 worker skill */
export async function listWorkerSkills(
skillIdOrName: string,
skillsRoot = SKILLS_ROOT,
): Promise<ParsedWorkerSkill[]> {
const skill = await loadSkill(skillIdOrName, skillsRoot);
if (!skill.skillPackRoot) {
return [];
}
const workersDir = path.join(skillsRoot, skill.skillPackRoot, "workers");
let entries: string[];
try {
entries = await readdir(workersDir);
} catch {
return [];
}
const workers: ParsedWorkerSkill[] = [];
for (const entry of entries.sort()) {
const skillPath = `${skill.skillPackRoot}/workers/${entry}/${WORKER_SKILL_FILENAME}`;
try {
workers.push(await parseWorkerSkillFile(skillPath, skillsRoot));
} catch {
// skip
}
}
return workers;
}
/** 按 name 或文件名(不含 .md匹配 skill返回 loadSkill 可用的 id */
export async function resolveSkillId(
input: string,
skillsRoot = SKILLS_ROOT,
): Promise<string | null> {
const trimmed = input.trim();
const relativePath = await resolveSkillPath(trimmed, skillsRoot);
if (!relativePath) return null;
try {
const parsed = await parseSkillFile(relativePath, skillsRoot);
return parsed.name;
} catch {
return null;
}
}
export { SKILLS_ROOT };

19
src/skills/snapshot.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { ActiveSkillSnapshot } from "../types/runtime.js";
import type { IntakeFieldDef } from "../types/intake.js";
import type { ParsedSkill } from "./types.js";
import { intakeFieldsFromInquiry } from "../intake/intake.js";
export function toActiveSkillSnapshot(skill: ParsedSkill): ActiveSkillSnapshot {
const intakeFields: IntakeFieldDef[] = intakeFieldsFromInquiry(skill.startupInquiry);
return {
name: skill.name,
description: skill.description,
category: skill.category,
bookKind: skill.bookKind,
defaultFlowId: skill.defaultFlowId,
suggestedWorkers: skill.suggestedWorkers,
startupPrompt: skill.startupInquiry.prompt,
startupTargetKey: skill.startupInquiry.targetKey,
intakeFields,
};
}

76
src/skills/types.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { BlackboardInputMerge } from "../types/blackboard.js";
import type { AdvancePolicy } from "../types/runtime.js";
export type WorkerLlmBinding = {
/** 固定 ApiProfile.id省略 = 会话默认 */
profileId?: string | null;
/** 按当前决策角色 id 选用 profile多 AI 博弈) */
byRole?: Record<string, string>;
};
/** skill 包 llm-bindings.yaml 解析结果(可选) */
export type SkillWorkerLlmBindings = {
defaultProfileId?: string | null;
workers?: Record<string, WorkerLlmBinding>;
};
/** skills/registry.yaml 或目录扫描得到的索引项 */
export type SkillIndexEntry = {
name: string;
description: string;
/** 与 bookKind 一致,兼容旧字段名 */
category: string;
bookKind?: "novel" | "dialogue";
/** 相对 skills/ 的路径,如 novel/weird-rules-short/orchestrator.md 或 novel/basic.md */
path?: string;
};
/** 来自 skill 文件 ## 启动询问 */
export type StartupInquiry = {
prompt: string;
targetKey: string;
requiredFields: string[];
optionalFields: string[];
};
/** 解析后的总管 skill供 session.slots.activeSkill 使用 */
export type ParsedSkill = {
name: string;
description: string;
category: string;
bookKind?: "novel" | "dialogue";
/** 相对 skills/ 的路径orchestrator.md 或平铺 .md */
path: string;
/** skill 包根目录,如 novel/weird-rules-short平铺 .md 时为 undefined */
skillPackRoot?: string;
version: number;
defaultFlowId?: string;
/** 本包可调度 worker idfrontmatter workers 或 suggestedWorkers */
suggestedWorkers: string[];
tags: string[];
/** 包内固定上下文相对路径(可选);有则注入该包 worker prompt */
sharedContextPath?: string;
/** llm-bindings.yaml可选见 docs/worker-skill-format.md §9 */
workerLlmBindings?: SkillWorkerLlmBindings;
startupInquiry: StartupInquiry;
/** 推进策略预留。loader 第一版不解析 orchestrator ## 推进策略 */
advancePolicy?: AdvancePolicy;
body: string;
};
/** 解析后的 worker skillskills/{pack}/workers/{id}/SKILL.md */
export type ParsedWorkerSkill = {
id: string;
skill: string;
name: string;
description: string;
version: number;
inputTags: string[];
outputTags: string[];
inputMerge?: BlackboardInputMerge;
/** ApiProfile.id省略 = 走 llm-bindings 或会话默认 */
llmProfileId?: string;
/** 相对 skills/ 的路径 */
path: string;
body: string;
};

146
src/skills/worker-llm.ts Normal file
View File

@@ -0,0 +1,146 @@
import { getApiProfile, profileToLlmConfig } from "../config/api-profiles.js";
import {
OpenAiCompatibleProvider,
type LlmProvider,
} from "../llm/client.js";
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
import { resolveActivePreset } from "../preset/store.js";
import { loadAppSettings } from "../config/settings.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js";
type LlmTrackingRef = { current: LlmTrackingContext };
/** 按 ApiProfile.id 构建 LLM找不到 profile 时回退 fallback */
export function createLlmForProfileId(
profileId: string,
fallback: LlmProvider,
trackingRef?: LlmTrackingRef,
): LlmProvider {
const profile = getApiProfile(profileId);
if (!profile?.apiKey?.trim()) {
return fallback;
}
let inner: LlmProvider = new OpenAiCompatibleProvider(
profileToLlmConfig(profile),
);
const preset = resolveActivePreset(loadAppSettings().activePresetId);
if (preset) {
inner = new PresetLlmProvider(inner, () =>
resolveActivePreset(loadAppSettings().activePresetId),
);
}
if (!trackingRef) return inner;
return new TokenTrackingProvider(inner, () => trackingRef.current);
}
/**
* 解析 worker 应使用的 LLM。
*
* 优先级:
* 1. worker SKILL frontmatter `llmProfileId`
* 2. llm-bindings.yaml `workers[id].byRole[roleId]`roleId 来自 slots.世界.当前角色.id
* 3. llm-bindings.yaml `workers[id].profileId`
* 4. fallback会话默认 profile与总管相同
*/
export function resolveWorkerLlmProvider(options: {
worker: ParsedWorkerSkill;
bindings?: SkillWorkerLlmBindings;
slots: Record<string, unknown>;
fallbackLlm: LlmProvider;
trackingRef?: LlmTrackingRef;
}): LlmProvider {
const { worker, bindings, slots, fallbackLlm, trackingRef } = options;
if (worker.llmProfileId?.trim()) {
return createLlmForProfileId(
worker.llmProfileId.trim(),
fallbackLlm,
trackingRef,
);
}
const workerBinding = bindings?.workers?.[worker.id];
if (workerBinding?.byRole) {
const roleId = slotString(slots, "世界.当前角色.id");
if (roleId && workerBinding.byRole[roleId]?.trim()) {
return createLlmForProfileId(
workerBinding.byRole[roleId].trim(),
fallbackLlm,
trackingRef,
);
}
}
if (workerBinding?.profileId?.trim()) {
return createLlmForProfileId(
workerBinding.profileId.trim(),
fallbackLlm,
trackingRef,
);
}
if (bindings?.defaultProfileId?.trim()) {
return createLlmForProfileId(
bindings.defaultProfileId.trim(),
fallbackLlm,
trackingRef,
);
}
return fallbackLlm;
}
function slotString(slots: Record<string, unknown>, key: string): string | undefined {
const val = slots[key];
return typeof val === "string" && val.trim() ? val.trim() : undefined;
}
/** 角色 tag仅用户可见 */
export const ROLE_USER_ONLY_SUFFIXES = [".思考", ".推理.候选"] as const;
/** 角色 tag对其余角色 agent 可见(经 world-engine 公开) */
export const ROLE_AGENT_VISIBLE_SUFFIXES = [".行动", ".行动.候选"] as const;
export function isRoleUserOnlyTag(tag: string): boolean {
return ROLE_USER_ONLY_SUFFIXES.some((s) => tag.endsWith(s));
}
export function isRoleAgentVisibleTag(tag: string): boolean {
return ROLE_AGENT_VISIBLE_SUFFIXES.some((s) => tag.endsWith(s));
}
/**
* 供 executor角色 worker 只能看到当前角色的私有 tag。
* 其他角色的思考永不可见;对方行动仅经 world-engine 分发后的可见信息/公开叙述获知。
*/
export function filterInputsForRolePerspective(
inputs: Record<string, string>,
slots: Record<string, unknown>,
): Record<string, string> {
const roleId = slotString(slots, "世界.当前角色.id");
if (!roleId) return inputs;
const filtered: Record<string, string> = {};
for (const [tag, content] of Object.entries(inputs)) {
const rolePrefix = `角色.${roleId}.`;
const otherRoleMatch = tag.match(/^角色\.([^.]+)\./);
if (otherRoleMatch && otherRoleMatch[1] !== roleId) {
continue;
}
if (tag.startsWith("角色.") && !tag.startsWith(rolePrefix)) {
continue;
}
if (tag.startsWith(rolePrefix) && isRoleUserOnlyTag(tag)) {
continue;
}
filtered[tag] = content;
}
return filtered;
}
export type { LlmTrackingContext };