Initial commit
This commit is contained in:
502
src/skills/loader.ts
Normal file
502
src/skills/loader.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 加载并解析总管 skill(orchestrator.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
19
src/skills/snapshot.ts
Normal 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
76
src/skills/types.ts
Normal 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 id(frontmatter 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 skill(skills/{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
146
src/skills/worker-llm.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user