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

View File

@@ -0,0 +1,117 @@
import { randomUUID } from "node:crypto";
import { isFullAccessPattern, tagMatchesPattern } from "./tag-match.js";
import type {
BlackboardItem,
BlackboardTagIndex,
BlackboardWriteInput,
} from "../types/blackboard.js";
export class Blackboard {
private items = new Map<string, BlackboardItem>();
private writeSeq = 0;
listTagIndex(): BlackboardTagIndex[] {
return [...this.items.values()]
.map(({ id, tag, source, scope, updatedAt }) => ({
id,
tag,
source,
scope,
updatedAt,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
get(id: string): BlackboardItem | undefined {
return this.items.get(id);
}
getLatestByTag(tag: string): BlackboardItem | undefined {
const matches = [...this.items.values()].filter((item) => item.tag === tag);
if (matches.length === 0) return undefined;
return matches.sort((a, b) => compareItemsByRecency(a, b))[0];
}
getContentByTag(tag: string): string | undefined {
return this.getLatestByTag(tag)?.content;
}
/**
* 按 worker inputTags 模式取条目。
* 同一 pattern 多条命中时latest 取最新一条concat 合并 content。
*/
queryByPatterns(
patterns: string[],
merge: "latest" | "concat" = "latest",
): BlackboardItem[] {
if (patterns.some(isFullAccessPattern)) {
return [...this.items.values()].sort((a, b) =>
a.updatedAt.localeCompare(b.updatedAt),
);
}
const result: BlackboardItem[] = [];
for (const pattern of patterns) {
const matched = [...this.items.values()]
.filter((item) => tagMatchesPattern(item.tag, pattern))
.sort(compareItemsByRecency);
if (matched.length === 0) continue;
if (merge === "concat" && matched.length > 1) {
result.push({
...matched[0],
id: `merged:${pattern}`,
content: matched
.slice()
.reverse()
.map((m) => m.content)
.join("\n\n---\n\n"),
});
} else {
result.push(matched[0]);
}
}
return result;
}
write(input: BlackboardWriteInput): BlackboardItem {
const now = new Date().toISOString();
this.writeSeq += 1;
const existing = this.getLatestByTag(input.tag);
const item: BlackboardItem = {
id: randomUUID(),
tag: input.tag,
content: input.content,
source: input.source,
scope: input.scope ?? existing?.scope,
createdAt: now,
updatedAt: `${now}#${this.writeSeq}`,
dependencies: input.dependencies,
metadata: input.metadata,
};
this.items.set(item.id, item);
return item;
}
seed(items: BlackboardItem[]): void {
for (const item of items) {
this.items.set(item.id, item);
}
}
exportItems(): BlackboardItem[] {
return [...this.items.values()];
}
/** 测试 / 调试:当前条目数 */
size(): number {
return this.items.size;
}
}
function compareItemsByRecency(a: BlackboardItem, b: BlackboardItem): number {
return b.updatedAt.localeCompare(a.updatedAt);
}

View File

@@ -0,0 +1,13 @@
/** 判断 itemTag 是否匹配 worker 声明的 inputTag 模式(精确或 前缀.* */
export function tagMatchesPattern(itemTag: string, pattern: string): boolean {
if (pattern === "**") return true;
if (pattern.endsWith(".*")) {
const prefix = pattern.slice(0, -2);
return itemTag === prefix || itemTag.startsWith(`${prefix}.`);
}
return itemTag === pattern;
}
export function isFullAccessPattern(pattern: string): boolean {
return pattern === "**";
}

28
src/book/orchestrators.ts Normal file
View File

@@ -0,0 +1,28 @@
import { listSkills } from "../skills/loader.js";
import type { SkillPackInfo } from "../types/book.js";
/** @deprecated 使用 listSkillPacks保留兼容旧 import */
export async function listOrchestrators(): Promise<SkillPackInfo[]> {
return listSkillPacks();
}
export async function listSkillPacks(): Promise<SkillPackInfo[]> {
const skills = await listSkills();
return skills.map((s) => ({
id: s.name,
name: s.name,
description: s.description,
category: s.category,
bookKind: s.bookKind,
}));
}
export async function getSkillPack(id: string): Promise<SkillPackInfo | null> {
const all = await listSkillPacks();
return all.find((o) => o.id === id) ?? null;
}
/** @deprecated */
export async function getOrchestrator(id: string): Promise<SkillPackInfo | null> {
return getSkillPack(id);
}

View File

@@ -0,0 +1,96 @@
import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
import type { RunSnapshot, RunSnapshotMeta } from "../types/run-snapshot.js";
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
function snapshotsDir(bookId: string): string {
return path.join(getUserDataDir(), "books", bookId, "run-snapshots");
}
function snapshotPath(bookId: string, snapshotId: string): string {
return path.join(snapshotsDir(bookId), `${snapshotId}.json`);
}
function isValidRunSnapshot(value: unknown): value is RunSnapshot {
if (!value || typeof value !== "object") return false;
const s = value as RunSnapshot;
const kind = s.kind ?? "run";
return (
s.version === 1 &&
typeof s.id === "string" &&
typeof s.bookId === "string" &&
typeof s.label === "string" &&
(kind === "instance" || kind === "run") &&
typeof s.orchestratorId === "string" &&
s.runtimeSession != null &&
Array.isArray(s.blackboardItems) &&
Array.isArray(s.messages) &&
typeof s.createdAt === "string"
);
}
function normalizeSnapshot(raw: RunSnapshot): RunSnapshot {
return { ...raw, kind: raw.kind ?? "run" };
}
/** 保存运行快照(同 bookId + snapshotId 则覆盖) */
export function saveRunSnapshot(snapshot: RunSnapshot): void {
ensureUserDataDirs();
const dir = snapshotsDir(snapshot.bookId);
mkdirSync(dir, { recursive: true });
writeFileSync(snapshotPath(snapshot.bookId, snapshot.id), JSON.stringify(snapshot, null, 2), "utf8");
}
/** 读取单个运行快照;不存在或格式无效时返回 null */
export function loadRunSnapshot(bookId: string, snapshotId: string): RunSnapshot | null {
try {
const raw = readFileSync(snapshotPath(bookId, snapshotId), "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isValidRunSnapshot(parsed) || parsed.bookId !== bookId || parsed.id !== snapshotId) {
return null;
}
return normalizeSnapshot(parsed);
} catch {
return null;
}
}
/** 删除单个运行快照;成功删除返回 true */
export function deleteRunSnapshot(bookId: string, snapshotId: string): boolean {
try {
unlinkSync(snapshotPath(bookId, snapshotId));
return true;
} catch {
return false;
}
}
/** 列出某 Book 下全部运行快照(按 createdAt 降序) */
export function listRunSnapshots(bookId: string): RunSnapshotMeta[] {
ensureUserDataDirs();
let files: string[];
try {
files = readdirSync(snapshotsDir(bookId)).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
const metas: RunSnapshotMeta[] = [];
for (const file of files) {
const id = file.replace(/\.json$/, "");
const snapshot = loadRunSnapshot(bookId, id);
if (snapshot) metas.push(toRunSnapshotMeta(snapshot));
}
return metas.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/** 删除某 Book 下全部运行快照(删 Book 时调用) */
export function deleteAllRunSnapshots(bookId: string): void {
try {
rmSync(snapshotsDir(bookId), { recursive: true, force: true });
} catch {
/* ignore */
}
}

46
src/book/session-store.ts Normal file
View File

@@ -0,0 +1,46 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
import type { PersistedBookSession } from "../types/book-session.js";
const SESSION_FILENAME = "session.json";
function bookDir(bookId: string): string {
return path.join(getUserDataDir(), "books", bookId);
}
function sessionPath(bookId: string): string {
return path.join(bookDir(bookId), SESSION_FILENAME);
}
export function saveBookSession(snapshot: PersistedBookSession): void {
ensureUserDataDirs();
const dir = bookDir(snapshot.bookId);
mkdirSync(dir, { recursive: true });
writeFileSync(sessionPath(snapshot.bookId), JSON.stringify(snapshot, null, 2), "utf8");
}
export function loadBookSession(bookId: string): PersistedBookSession | null {
try {
const raw = readFileSync(sessionPath(bookId), "utf8");
const parsed = JSON.parse(raw) as PersistedBookSession;
if (parsed.version !== 1 || !parsed.sessionId || !parsed.runtimeSession) {
return null;
}
return parsed;
} catch {
return null;
}
}
export function deleteBookSession(bookId: string): void {
try {
rmSync(bookDir(bookId), { recursive: true, force: true });
} catch {
/* ignore */
}
}
export function hasBookSession(bookId: string): boolean {
return loadBookSession(bookId) !== null;
}

37
src/book/skill-id.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { BookProject } from "../types/book.js";
import type { PersistedBookSession } from "../types/book-session.js";
import type { RunSnapshot } from "../types/run-snapshot.js";
import type { RuntimeSession } from "../types/runtime.js";
import type { ActiveSkillSnapshot } from "../types/runtime.js";
/** 作品或快照绑定的 skill 包 id兼容旧 orchestratorId 字段) */
export function bookSkillPackId(book: BookProject): string | undefined {
return book.activeSkillId ?? book.orchestratorId;
}
export function sessionSkillPackId(session: RuntimeSession): string | undefined {
const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
return snap?.name;
}
export function persistedSkillPackId(snapshot: PersistedBookSession): string | undefined {
return (
sessionSkillPackId(snapshot.runtimeSession) ?? snapshot.orchestratorId ?? undefined
);
}
export function runSnapshotSkillPackId(snapshot: RunSnapshot): string | undefined {
return (
sessionSkillPackId(snapshot.runtimeSession) ?? snapshot.orchestratorId ?? undefined
);
}
export function skillPacksMatch(
book: BookProject,
snapshotSkill: string | undefined,
): boolean {
const bookSkill = bookSkillPackId(book);
if (!snapshotSkill) return true;
if (!bookSkill) return true;
return bookSkill === snapshotSkill;
}

View File

@@ -0,0 +1,53 @@
import type { BlackboardItem } from "../types/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
/**
* 判断黑板 tag 是否属于 run 阶段(非实例化确认稿)。
* 实例快照保存/加载时会剥离这些 tag只保留「实例化后的对象」。
*/
export function isRunPhaseBlackboardTag(tag: string): boolean {
// 实例化层:保留
if (/^角色\.[^.]+\.设定$/.test(tag)) return false;
if (/^用户\./.test(tag)) return false;
if (/^book\./.test(tag)) return false;
if (/^情境\./.test(tag)) return false;
if (/^博弈\./.test(tag)) return false;
// run 层:剥离
return (
/^运行\./.test(tag) ||
/^世界\.(当前|裁决)/.test(tag) ||
/^场景\.公开/.test(tag) ||
/^输出\./.test(tag) ||
/^角色\.[^.]+\.(可见信息|思考|行动)$/.test(tag) ||
/^review\./.test(tag)
);
}
export function filterBlackboardForInstance(items: BlackboardItem[]): BlackboardItem[] {
return items.filter((item) => !isRunPhaseBlackboardTag(item.tag));
}
/** 读档实例时:去掉 run 产物记录,清空 pending便于从 instanceReady 重新开跑 */
export function prepareRuntimeSessionForInstance(session: RuntimeSession): RuntimeSession {
const copy = structuredClone(session);
copy.pendingArtifactId = undefined;
copy.pendingDecision = undefined;
copy.currentWorkerId = undefined;
copy.waitingReason = undefined;
copy.artifacts = copy.artifacts.filter((a) =>
a.outputTags.every((tag) => !isRunPhaseBlackboardTag(tag)),
);
if (copy.phase === "done") copy.phase = "running";
return copy;
}
export function materializeInstanceSnapshotPayload(payload: {
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
}): { runtimeSession: RuntimeSession; blackboardItems: BlackboardItem[] } {
return {
blackboardItems: filterBlackboardForInstance(payload.blackboardItems),
runtimeSession: prepareRuntimeSessionForInstance(payload.runtimeSession),
};
}

117
src/book/store.ts Normal file
View File

@@ -0,0 +1,117 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { BookProject, BookSummary } from "../types/book.js";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
import { deleteBookSession } from "./session-store.js";
import { deleteAllRunSnapshots } from "./run-snapshot-store.js";
function booksDir(): string {
return path.join(getUserDataDir(), "books");
}
function bookPath(id: string): string {
return path.join(booksDir(), `${id}.json`);
}
export function listBooks(): BookSummary[] {
ensureUserDataDirs();
mkdirSync(booksDir(), { recursive: true });
let files: string[];
try {
files = readdirSync(booksDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
const books: BookProject[] = [];
for (const file of files) {
try {
const raw = readFileSync(path.join(booksDir(), file), "utf8");
books.push(JSON.parse(raw) as BookProject);
} catch {
/* skip */
}
}
return books
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map((b) => ({
id: b.id,
title: b.title,
activeSkillId: b.activeSkillId ?? b.orchestratorId,
activeSkillName: b.activeSkillName ?? b.orchestratorName,
preview: b.preview,
updatedAt: b.updatedAt,
orchestratorId: b.orchestratorId,
orchestratorName: b.orchestratorName,
}));
}
export function getBook(id: string): BookProject | null {
try {
const raw = readFileSync(bookPath(id), "utf8");
return JSON.parse(raw) as BookProject;
} catch {
return null;
}
}
export function createBook(input: { title?: string }): BookProject {
ensureUserDataDirs();
mkdirSync(booksDir(), { recursive: true });
const now = new Date().toISOString();
const book: BookProject = {
id: randomUUID(),
title: input.title?.trim() || "未命名作品",
preview: "新建作品,选择 skill 包开始…",
sessionIds: [],
createdAt: now,
updatedAt: now,
};
writeFileSync(bookPath(book.id), JSON.stringify(book, null, 2), "utf8");
return book;
}
export function updateBook(
id: string,
patch: Partial<
Pick<
BookProject,
| "title"
| "preview"
| "sessionIds"
| "activeSessionId"
| "activeSkillId"
| "activeSkillName"
>
>,
): BookProject {
const book = getBook(id);
if (!book) throw new Error("Book 不存在");
const updated: BookProject = {
...book,
...patch,
updatedAt: new Date().toISOString(),
};
writeFileSync(bookPath(id), JSON.stringify(updated, null, 2), "utf8");
return updated;
}
export function appendBookSession(id: string, sessionId: string): void {
const book = getBook(id);
if (!book) return;
if (!book.sessionIds.includes(sessionId)) {
updateBook(id, { sessionIds: [...book.sessionIds, sessionId] });
}
}
export function deleteBook(id: string): void {
deleteBookSession(id);
deleteAllRunSnapshots(id);
try {
unlinkSync(bookPath(id));
} catch {
/* ignore */
}
}

169
src/cli/phase-demo.ts Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* 阶段机 + Skill 演示 CLI不依赖 LLM
*
* 运行npm run phase-demo
* npm run phase-script
*/
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import {
createDecision,
formatSession,
PhaseRuntime,
runMinimalClosedLoop,
} from "../runtime/phase-runtime.js";
import { getAllowedEvents } from "../runtime/phase-machine.js";
const autoStub = process.argv.includes("--auto");
const runScript = process.argv.includes("--script");
function printHelp(): void {
console.log(`阶段机 + Skill 演示
启动流程:
1. /start → 列出 skills/ 下的 SKILL.md
2. 输入 skill name 或编号(如 basic 或 1
3. 按 SKILL.md「启动询问」回答
4. /decide worker outline-worker approve → /approve → …
命令:
/start 开始会话
/skills 列出可用 skill
/status 当前 phase
/decide ... 模拟总管决策(见下)
/approve /accept 用户确认
/worker-start 手动启动 worker
/worker-done worker 完成
/quit
/decide 子命令:
/decide worker <id> [approve]
/decide finish [理由]
加 --auto 时 worker 自动占位完成。`);
}
async function main(): Promise<void> {
const runtime = new PhaseRuntime({
autoStubWorker: autoStub || runScript,
onMessage: (msg) => console.log(msg),
});
if (runScript) {
const session = await runMinimalClosedLoop(runtime);
console.log(formatSession(session));
console.log(session.phase === "done" ? "✓ 闭环完成" : "✗ 未完成");
process.exit(session.phase === "done" ? 0 : 1);
}
console.log("阶段机 + Skill 演示\n");
printHelp();
const rl = readline.createInterface({ input, output });
try {
while (true) {
const line = (await rl.question("> ")).trim();
if (!line) continue;
try {
if (line === "/quit") break;
if (line === "/help") {
printHelp();
continue;
}
if (line === "/start") {
await runtime.start();
console.log(formatSession(runtime.getSession()));
continue;
}
if (line === "/skills") {
for (const [i, s] of runtime.getAvailableSkills().entries()) {
console.log(` ${i + 1}. ${s.name}${s.description}`);
}
continue;
}
if (line === "/status") {
console.log(formatSession(runtime.getSession()));
if (runtime.getActiveSkill()) {
console.log("activeSkill:", runtime.getActiveSkill()?.name);
}
continue;
}
if (line === "/events") {
console.log(getAllowedEvents(runtime.getSession()).join(", "));
continue;
}
if (line === "/approve") {
await runtime.approve();
console.log(formatSession(runtime.getSession()));
continue;
}
if (line === "/accept") {
await runtime.acceptArtifact();
console.log(formatSession(runtime.getSession()));
continue;
}
if (line === "/worker-start") {
await runtime.startPendingWorker();
console.log(formatSession(runtime.getSession()));
continue;
}
if (line === "/worker-done") {
await runtime.workerComplete();
console.log(formatSession(runtime.getSession()));
continue;
}
if (line.startsWith("/decide ")) {
await handleDecide(runtime, line.slice("/decide ".length));
console.log(formatSession(runtime.getSession()));
continue;
}
await runtime.submitInput(line);
console.log(formatSession(runtime.getSession()));
if (runtime.getSession().phase === "done") {
console.log("流程已完成。");
break;
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
}
}
} finally {
rl.close();
}
}
async function handleDecide(runtime: PhaseRuntime, args: string): Promise<void> {
const parts = args.split(" ");
if (parts[0] === "finish") {
await runtime.submitDecision(
createDecision({
action: "finish",
reason: parts.slice(1).join(" ") || "完成",
requiresApproval: false,
}),
);
return;
}
if (parts[0] === "worker") {
const workerId = parts[1];
if (!workerId) throw new Error("用法: /decide worker <id> [approve]");
await runtime.submitDecision(
createDecision({
action: "run_worker",
reason: `调度 ${workerId}`,
workerId,
requiresApproval: parts[2] === "approve",
}),
);
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});

134
src/cli/run.ts Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Skill 流程 + 总管 LLM 演示 CLI
*
* npm run dev # 真实 LLM需 OPENAI_API_KEY
* npm run demo # Mock LLM
*/
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { loadLlmConfigOptional } from "../config/env.js";
import {
createMockMainAgentResponse,
MockLlmProvider,
} from "../llm/client.js";
import { createDefaultMainAgentLlm } from "../runtime/llm-factory.js";
import { formatSession, PhaseRuntime } from "../runtime/phase-runtime.js";
const useMock = process.argv.includes("--mock");
function printSession(runtime: PhaseRuntime): void {
console.log("\n--- 会话 ---");
console.log(formatSession(runtime.getSession()));
const index = runtime.getBlackboard().listTagIndex();
if (index.length > 0) {
console.log("blackboard tags:");
for (const entry of index) {
console.log(` - ${entry.tag} [${entry.source}]`);
}
}
console.log("----------------\n");
}
function printHelp(): void {
console.log(`命令:
<文本> 选 skill / 提交输入 / 驳回时发修改意见
/approve 确认总管建议的 worker
/accept 接受当前产物
/reject-art 拒绝当前产物
/status 查看状态
/quit 退出`);
}
async function main(): Promise<void> {
const llm = useMock
? new MockLlmProvider([
createMockMainAgentResponse({
action: "run_worker",
reason: "建议生成大纲",
workerId: "outline-worker",
requiresApproval: true,
}),
createMockMainAgentResponse({
action: "finish",
reason: "完成",
requiresApproval: false,
}),
])
: loadLlmConfigOptional()
? createDefaultMainAgentLlm()
: null;
if (!llm) {
console.error("请设置 OPENAI_API_KEY 或使用 --mock");
process.exit(1);
}
if (useMock || !loadLlmConfigOptional()) {
console.log("使用 Mock LLM\n");
}
const runtime = new PhaseRuntime({
autoStubWorker: true,
llm,
onMessage: (msg) => console.log(msg),
});
await runtime.start();
printSession(runtime);
printHelp();
const rl = readline.createInterface({ input, output });
try {
while (true) {
const line = (await rl.question("> ")).trim();
if (!line) continue;
if (line === "/quit") break;
if (line === "/help") {
printHelp();
continue;
}
if (line === "/status") {
printSession(runtime);
continue;
}
if (line === "/approve") {
await runtime.approve();
printSession(runtime);
continue;
}
if (line === "/accept") {
await runtime.acceptArtifact();
printSession(runtime);
continue;
}
if (line === "/reject-art") {
await runtime.rejectArtifact("用户拒绝产物");
printSession(runtime);
continue;
}
const reason = runtime.getSession().waitingReason;
if (reason?.kind === "approve_step") {
await runtime.rejectStep(line);
} else {
await runtime.submitInput(line);
}
printSession(runtime);
if (runtime.getSession().phase === "done") {
console.log("流程已完成。");
break;
}
}
} finally {
rl.close();
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});

159
src/config/api-profiles.ts Normal file
View File

@@ -0,0 +1,159 @@
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { LlmConfig } from "./env.js";
import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
export type ApiProfile = {
id: string;
name: string;
baseUrl: string;
apiKey: string;
model: string;
createdAt: string;
updatedAt: string;
};
type ApiProfilesFile = {
version: 1;
profiles: ApiProfile[];
};
const FILE_NAME = "profiles.json";
function profilesPath(): string {
return path.join(getUserDataDir(), FILE_NAME);
}
function readFile(): ApiProfilesFile {
ensureUserDataDirs();
try {
const raw = readFileSync(profilesPath(), "utf8");
const parsed = JSON.parse(raw) as ApiProfilesFile;
if (parsed.version !== 1 || !Array.isArray(parsed.profiles)) {
return { version: 1, profiles: [] };
}
return parsed;
} catch {
return { version: 1, profiles: [] };
}
}
function writeFile(data: ApiProfilesFile): void {
ensureUserDataDirs();
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
}
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
if (data.profiles.length > 0) return data;
const env = loadLlmConfigOptional();
if (!env) return data;
const now = new Date().toISOString();
const profile: ApiProfile = {
id: randomUUID(),
name: "环境变量 (.env)",
baseUrl: env.baseUrl,
apiKey: env.apiKey,
model: env.model,
createdAt: now,
updatedAt: now,
};
return { version: 1, profiles: [profile] };
}
export function listApiProfiles(): ApiProfile[] {
const data = seedFromEnvIfEmpty(readFile());
if (data.profiles.length !== readFile().profiles.length) {
writeFile(data);
}
return data.profiles;
}
export function getApiProfile(id: string): ApiProfile | null {
return listApiProfiles().find((p) => p.id === id) ?? null;
}
export function createApiProfile(input: {
name: string;
baseUrl: string;
apiKey: string;
model: string;
}): ApiProfile {
const data = readFile();
const now = new Date().toISOString();
const profile: ApiProfile = {
id: randomUUID(),
name: input.name.trim() || "未命名",
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
apiKey: input.apiKey.trim(),
model: input.model.trim() || "deepseek-v4-pro",
createdAt: now,
updatedAt: now,
};
data.profiles.push(profile);
writeFile(data);
return profile;
}
export function updateApiProfile(
id: string,
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index];
const updated: ApiProfile = {
...current,
name: input.name?.trim() || current.name,
baseUrl: input.baseUrl?.trim() || current.baseUrl,
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim()
: current.apiKey,
model: input.model?.trim() || current.model,
updatedAt: new Date().toISOString(),
};
data.profiles[index] = updated;
writeFile(data);
return updated;
}
export function deleteApiProfile(id: string): void {
const data = readFile();
data.profiles = data.profiles.filter((p) => p.id !== id);
writeFile(data);
}
export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
return {
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model: profile.model,
};
}
export async function testApiProfile(profile: ApiProfile): Promise<{
ok: boolean;
message: string;
}> {
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${profile.apiKey}` },
});
if (response.ok) {
return { ok: true, message: "连接成功" };
}
const body = await response.text();
return {
ok: false,
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "连接失败",
};
}
}

56
src/config/env.ts Normal file
View File

@@ -0,0 +1,56 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
export type LlmConfig = {
baseUrl: string;
apiKey: string;
model: string;
};
export function loadLlmConfig(): LlmConfig {
const baseUrl =
process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com";
const apiKey = process.env.OPENAI_API_KEY ?? "";
const model = process.env.OPENAI_MODEL ?? "deepseek-v4-pro";
if (!apiKey) {
throw new Error(
"Missing OPENAI_API_KEY. Set it in environment or use --mock for offline demo.",
);
}
return { baseUrl, apiKey, model };
}
export function loadLlmConfigOptional(): LlmConfig | null {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return null;
return {
baseUrl: process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com",
apiKey,
model: process.env.OPENAI_MODEL ?? "deepseek-v4-pro",
};
}
export function loadDotEnv(cwd = process.cwd()): void {
const envPath = path.join(cwd, ".env");
if (!existsSync(envPath)) return;
const content = readFileSync(envPath, "utf8");
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
if (process.env[key] !== undefined) continue;
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}

101
src/config/settings.ts Normal file
View File

@@ -0,0 +1,101 @@
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { listApiProfiles } from "./api-profiles.js";
import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
export type AppSettings = {
version: 1;
activeProfileId: string | null;
activePresetId: string | null;
};
const FILE_NAME = "settings.json";
function settingsPath(): string {
return path.join(getUserDataDir(), FILE_NAME);
}
function defaultSettings(): AppSettings {
return {
version: 1,
activeProfileId: null,
activePresetId: null,
};
}
export function loadAppSettings(): AppSettings {
ensureUserDataDirs();
try {
const raw = readFileSync(settingsPath(), "utf8");
const parsed = JSON.parse(raw) as AppSettings;
if (parsed.version !== 1) return defaultSettings();
return {
version: 1,
activeProfileId: parsed.activeProfileId ?? null,
activePresetId: parsed.activePresetId ?? null,
};
} catch {
return defaultSettings();
}
}
export function saveAppSettings(settings: AppSettings): void {
ensureUserDataDirs();
writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), "utf8");
}
export function setActiveProfileId(id: string | null): AppSettings {
const settings = loadAppSettings();
settings.activeProfileId = id;
saveAppSettings(settings);
return settings;
}
export function setActivePresetId(id: string | null): AppSettings {
const settings = loadAppSettings();
settings.activePresetId = id;
saveAppSettings(settings);
return settings;
}
/** 解析当前应使用的 API profilesettings > 首个 profile > .env */
export function resolveActiveProfile() {
const settings = loadAppSettings();
const profiles = listApiProfiles();
if (settings.activeProfileId) {
const found = profiles.find((p) => p.id === settings.activeProfileId);
if (found?.apiKey?.trim()) return found;
}
if (profiles.length > 0) {
const withKey = profiles.find((p) => p.apiKey?.trim());
if (withKey) return withKey;
}
const env = loadLlmConfigOptional();
if (env) {
return {
id: "__env__",
name: "环境变量 (.env)",
baseUrl: env.baseUrl,
apiKey: env.apiKey,
model: env.model,
createdAt: "",
updatedAt: "",
};
}
return null;
}
export function ensureActiveProfileDefault(): AppSettings {
const settings = loadAppSettings();
const profiles = listApiProfiles();
if (!settings.activeProfileId && profiles.length > 0) {
settings.activeProfileId = profiles[0].id;
saveAppSettings(settings);
}
return settings;
}

View File

@@ -0,0 +1,25 @@
import { mkdirSync } from "node:fs";
import os from "node:os";
import path from "node:path";
/** 本地用户数据目录(不进 git、不同步 */
export function getUserDataDir(): string {
if (process.env.WRITING_AGENT_DATA_DIR) {
return path.resolve(process.env.WRITING_AGENT_DATA_DIR);
}
return path.join(os.homedir(), ".writing-agent");
}
export function getPresetsDir(): string {
return path.join(getUserDataDir(), "presets");
}
export function getBooksDir(): string {
return path.join(getUserDataDir(), "books");
}
export function ensureUserDataDirs(): void {
mkdirSync(getPresetsDir(), { recursive: true });
mkdirSync(getBooksDir(), { recursive: true });
mkdirSync(path.join(getUserDataDir(), "stats"), { recursive: true });
}

86
src/intake/extract.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { LlmProvider } from "../llm/client.js";
import type { IntakeFieldDef } from "../types/intake.js";
import { extractIntakeHeuristic } from "./intake.js";
export async function extractIntakeFromMessage(
text: string,
fields: IntakeFieldDef[],
current: Record<string, string>,
llm?: LlmProvider,
): Promise<Record<string, string>> {
if (!text.trim()) return { ...current };
if (llm) {
try {
return await extractWithLlm(text, fields, current, llm);
} catch {
/* fallback */
}
}
return extractIntakeHeuristic(text, fields, current);
}
async function extractWithLlm(
text: string,
fields: IntakeFieldDef[],
current: Record<string, string>,
llm: LlmProvider,
): Promise<Record<string, string>> {
const fieldList = fields.map((f) => ({
id: f.id,
label: f.label,
required: f.required,
current: current[f.id] ?? null,
}));
const result = await llm.complete(
[
{
role: "system",
content: `你是信息抽取助手。根据用户最新消息,更新「填空题」各字段的值。
规则:
1. 只输出 JSON{ "updates": { "<fieldId>": "<完整字段值或 null>" } }
2. 仅更新用户本条消息明确提到或能推断的字段;未提及的字段不要出现在 updates 中
3. 若某字段已有 current 值且用户是在补充,合并新旧内容
4. 不要臆造用户未说的细节
5. 「角色」须能识别出至少 2 个参与者或其倾向;「进程」包含轮次、局数或终止条件(如破产)`,
},
{
role: "user",
content: JSON.stringify(
{ fields: fieldList, userMessage: text },
null,
2,
),
},
],
{ responseFormat: "json_object", caller: "intake_extract" },
);
let parsed: unknown;
try {
parsed = JSON.parse(result.content);
} catch {
return extractIntakeHeuristic(text, fields, current);
}
const updates = (parsed as { updates?: Record<string, unknown> }).updates;
if (!updates || typeof updates !== "object") {
return extractIntakeHeuristic(text, fields, current);
}
const next = { ...current };
for (const field of fields) {
const val = updates[field.id];
if (val === null || val === undefined) continue;
if (typeof val !== "string" || !val.trim()) continue;
const merged = next[field.id]?.trim()
? `${next[field.id].trim()}\n${val.trim()}`
: val.trim();
next[field.id] = merged;
}
if (Object.keys(updates).length === 0) {
return extractIntakeHeuristic(text, fields, current);
}
return next;
}

170
src/intake/intake.ts Normal file
View File

@@ -0,0 +1,170 @@
import type {
IntakeFieldDef,
IntakeFieldStatus,
IntakeProgress,
} from "../types/intake.js";
import type { StartupInquiry } from "../skills/types.js";
export function intakeFieldId(
label: string,
index: number,
required: boolean,
): string {
const slug = label
.replace(/[(][^)]*[)]/g, "")
.replace(/\*\*/g, "")
.replace(/至少\s*\d+\s*个/g, "")
.trim()
.slice(0, 28)
.replace(/[^\w\u4e00-\u9fff-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return `${required ? "r" : "o"}-${index}-${slug || "field"}`;
}
export function intakeFieldsFromInquiry(inquiry: StartupInquiry): IntakeFieldDef[] {
const required = inquiry.requiredFields.map((label, i) => ({
id: intakeFieldId(label, i, true),
label,
required: true as const,
}));
const optional = (inquiry.optionalFields ?? []).map((label, i) => ({
id: intakeFieldId(label, i, false),
label,
required: false as const,
}));
return [...required, ...optional];
}
export function readIntakeValues(
slots: Record<string, unknown>,
): Record<string, string> {
const raw = slots.intakeValues;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof v === "string" && v.trim()) out[k] = v.trim();
}
return out;
}
export function buildIntakeProgress(
fields: IntakeFieldDef[],
values: Record<string, string>,
): IntakeProgress {
const items: IntakeFieldStatus[] = fields.map((f) => {
const value = values[f.id]?.trim();
return {
id: f.id,
label: f.label,
required: f.required,
value: value || undefined,
filled: Boolean(value),
};
});
const required = items.filter((i) => i.required);
const optional = items.filter((i) => !i.required);
return {
fields: items,
requiredTotal: required.length,
requiredFilled: required.filter((i) => i.filled).length,
optionalTotal: optional.length,
optionalFilled: optional.filter((i) => i.filled).length,
ready: required.length > 0 ? required.every((i) => i.filled) : items.some((i) => i.filled),
};
}
/** 将各字段拼成写入黑板 / slots 的需求文档 */
export function synthesizeDemandText(
fields: IntakeFieldDef[],
values: Record<string, string>,
): string {
const parts: string[] = [];
for (const f of fields) {
const v = values[f.id]?.trim();
if (!v) continue;
parts.push(`## ${f.label}\n${v}`);
}
return parts.join("\n\n");
}
/** 必要项未齐时的一次追问文案 */
export function buildIntakeFollowUpMessage(progress: IntakeProgress): string {
const missing = progress.fields.filter((f) => f.required && !f.filled);
if (!missing.length) return "";
const lines = missing.map((f) => `- ${f.label}`);
return `还缺以下必要项,请补充(可简短回答):\n${lines.join("\n")}`;
}
/** 合并用户在启动阶段的原始输入worker 兜底用) */
export function collectUserInputTranscript(
slots: Record<string, unknown>,
): string {
const parts: string[] = [];
const userInputs = slots.userInputs;
if (Array.isArray(userInputs)) {
for (const entry of userInputs) {
if (typeof entry === "string" && entry.trim()) {
parts.push(entry.trim());
}
}
}
return parts.join("\n\n");
}
/** 无 LLM 时的启发式:优先填第一个未填必要项,并尝试关键词匹配 */
export function extractIntakeHeuristic(
text: string,
fields: IntakeFieldDef[],
current: Record<string, string>,
): Record<string, string> {
const trimmed = text.trim();
if (!trimmed) return { ...current };
const next = { ...current };
const unfilledRequired = fields.filter((f) => f.required && !next[f.id]?.trim());
for (const field of fields) {
if (next[field.id]?.trim()) continue;
const keywords = fieldKeywords(field.label);
if (keywords.some((kw) => trimmed.includes(kw))) {
next[field.id] = mergeFieldValue(next[field.id], trimmed);
}
}
if (unfilledRequired.length === 1 && !next[unfilledRequired[0].id]?.trim()) {
next[unfilledRequired[0].id] = trimmed;
} else if (
unfilledRequired.length > 1 &&
!fields.some((f) => next[f.id]?.trim() && !current[f.id]?.trim())
) {
next[unfilledRequired[0].id] = mergeFieldValue(next[unfilledRequired[0].id], trimmed);
}
return next;
}
function mergeFieldValue(existing: string | undefined, addition: string): string {
const a = addition.trim();
if (!existing?.trim()) return a;
if (existing.includes(a)) return existing;
return `${existing.trim()}\n${a}`;
}
function fieldKeywords(label: string): string[] {
const words: string[] = [];
if (/情境|实验|局面|框架/.test(label)) {
words.push("情境", "实验", "囚徒", "通牒", "扑克", "博弈", "游戏");
}
if (/角色|参与/.test(label)) words.push("角色", "参与", "玩家", "人", "赌徒");
if (/轮次|轮|进程|结束|破产/.test(label)) {
words.push("轮", "单轮", "多轮", "进程", "结束", "破产", "直到");
}
if (/题材/.test(label)) words.push("题材", "科幻", "悬疑", "言情");
if (/篇幅/.test(label)) words.push("篇幅", "短篇", "中篇", "长篇");
if (/人称/.test(label)) words.push("人称", "第一", "第三");
if (/输出|思考|描写/.test(label)) words.push("思考", "描写", "报告", "场景");
if (/信息/.test(label)) words.push("信息", "私密", "公开");
if (words.length === 0) words.push(label.slice(0, 4));
return words;
}

389
src/llm/client.ts Normal file
View File

@@ -0,0 +1,389 @@
import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js";
export type ToolCallPayload = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type ChatMessage =
| { role: "system" | "user"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls?: ToolCallPayload[];
}
| { role: "tool"; content: string; tool_call_id: string };
export type ToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type ParsedToolCall = {
id: string;
name: string;
arguments: string;
};
export type TokenUsage = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
/** Prompt tokens served from provider cache (OpenAI cached_tokens, DeepSeek prompt_cache_hit_tokens) */
cachedTokens?: number;
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
cacheMissTokens?: number;
};
export type CompleteResult = {
content: string;
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content */
reasoning?: string;
usage?: TokenUsage;
model?: string;
};
export type CompleteOptions = {
responseFormat?: "json_object" | "text";
generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */
caller?: string;
};
export type CompleteWithToolsOptions = CompleteOptions & {
tools: ToolDefinition[];
};
export type CompleteWithToolsResult = {
content: string | null;
toolCalls: ParsedToolCall[];
reasoning?: string;
usage?: TokenUsage;
model?: string;
};
export type LlmProvider = {
complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult>;
completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult>;
};
function buildRequestBody(
config: LlmConfig,
messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[] },
): Record<string, unknown> {
const gen = options?.generation ?? {};
const body: Record<string, unknown> = {
model: config.model,
messages,
};
if (options?.tools?.length) {
body.tools = options.tools;
body.tool_choice = "auto";
}
if (gen.temperature !== undefined) body.temperature = gen.temperature;
else body.temperature = 0.2;
if (gen.topP !== undefined) body.top_p = gen.topP;
if (gen.topK !== undefined) body.top_k = gen.topK;
if (gen.minP !== undefined) body.min_p = gen.minP;
if (gen.frequencyPenalty !== undefined) {
body.frequency_penalty = gen.frequencyPenalty;
}
if (gen.presencePenalty !== undefined) {
body.presence_penalty = gen.presencePenalty;
}
if (gen.repetitionPenalty !== undefined) {
body.repetition_penalty = gen.repetitionPenalty;
}
if (gen.maxOutputTokens !== undefined) {
body.max_tokens = gen.maxOutputTokens;
}
if (gen.seed !== undefined) body.seed = gen.seed;
if (gen.reasoningEffort !== undefined) {
body.reasoning_effort = gen.reasoningEffort;
}
if (options?.responseFormat === "json_object") {
body.response_format = { type: "json_object" };
}
return body;
}
function readFiniteNumber(value: unknown): number | undefined {
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
function readCachedTokens(u: Record<string, unknown>): number | undefined {
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
if (details && typeof details === "object") {
const d = details as Record<string, unknown>;
const cached = readFiniteNumber(d.cached_tokens ?? d.cachedTokens);
if (cached != null) return cached;
}
return readFiniteNumber(u.prompt_cache_hit_tokens ?? u.promptCacheHitTokens);
}
function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
}
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
export function parseUsage(raw: unknown): TokenUsage | undefined {
if (!raw || typeof raw !== "object") return undefined;
const u = raw as Record<string, unknown>;
const prompt = Number(u.prompt_tokens ?? u.promptTokens);
const completion = Number(u.completion_tokens ?? u.completionTokens);
const total = Number(u.total_tokens ?? u.totalTokens);
if (!Number.isFinite(total) && !Number.isFinite(prompt)) return undefined;
const cachedTokens = readCachedTokens(u);
const cacheMissTokens = readCacheMissTokens(u);
return {
promptTokens: Number.isFinite(prompt) ? prompt : 0,
completionTokens: Number.isFinite(completion) ? completion : 0,
totalTokens: Number.isFinite(total)
? total
: (Number.isFinite(prompt) ? prompt : 0) +
(Number.isFinite(completion) ? completion : 0),
...(cachedTokens != null ? { cachedTokens } : {}),
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
};
}
function extractMessageParts(message: Record<string, unknown> | undefined): {
content: string | null;
reasoning?: string;
toolCalls: ParsedToolCall[];
} {
if (!message) return { content: "", toolCalls: [] };
const rawContent = message.content;
const content =
typeof rawContent === "string"
? rawContent.trim() || null
: rawContent == null
? null
: "";
const reasoning =
typeof message.reasoning_content === "string"
? message.reasoning_content.trim()
: undefined;
const toolCalls: ParsedToolCall[] = [];
const rawCalls = message.tool_calls;
if (Array.isArray(rawCalls)) {
for (const call of rawCalls) {
if (!call || typeof call !== "object") continue;
const c = call as Record<string, unknown>;
const fn = c.function;
if (!fn || typeof fn !== "object") continue;
const f = fn as Record<string, unknown>;
const name = typeof f.name === "string" ? f.name : "";
const id = typeof c.id === "string" ? c.id : "";
const args =
typeof f.arguments === "string" ? f.arguments : "{}";
if (name && id) {
toolCalls.push({ id, name, arguments: args });
}
}
}
if (toolCalls.length > 0) {
return { content, reasoning: reasoning || undefined, toolCalls };
}
if (content) return { content, reasoning: reasoning || undefined, toolCalls };
if (reasoning) return { content: reasoning, reasoning, toolCalls };
return { content: "", toolCalls };
}
export class OpenAiCompatibleProvider implements LlmProvider {
constructor(private readonly config: LlmConfig) {}
async complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(buildRequestBody(this.config, messages, options)),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
model?: string;
usage?: unknown;
choices?: Array<{ message?: Record<string, unknown> }>;
};
const parts = extractMessageParts(data.choices?.[0]?.message);
if (!parts.content && parts.toolCalls.length === 0) {
throw new Error("LLM returned empty content");
}
return {
content: parts.content ?? "",
reasoning: parts.reasoning,
usage: parseUsage(data.usage),
model: data.model ?? this.config.model,
};
}
async completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
}),
),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
model?: string;
usage?: unknown;
choices?: Array<{ message?: Record<string, unknown> }>;
};
const parts = extractMessageParts(data.choices?.[0]?.message);
if (!parts.content && parts.toolCalls.length === 0) {
throw new Error("LLM returned empty content and no tool calls");
}
return {
content: parts.content,
toolCalls: parts.toolCalls,
reasoning: parts.reasoning,
usage: parseUsage(data.usage),
model: data.model ?? this.config.model,
};
}
}
export type MockLlmStep =
| string
| {
toolCalls: Array<{ name: string; arguments: Record<string, unknown>; id?: string }>;
content?: string | null;
};
export class MockLlmProvider implements LlmProvider {
private readonly responses: MockLlmStep[];
private index = 0;
constructor(responses: MockLlmStep[]) {
this.responses = responses;
}
private nextStep(): MockLlmStep {
const step = this.responses[this.index] ?? this.responses.at(-1)!;
this.index += 1;
return step;
}
async complete(
_messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
const step = this.nextStep();
const response =
typeof step === "string"
? step
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
const approx = Math.max(1, Math.ceil(response.length / 4));
return {
content: response,
usage: {
promptTokens: approx,
completionTokens: approx,
totalTokens: approx * 2,
},
model: "mock",
...(options?.caller ? {} : {}),
};
}
async completeWithTools(
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const step = this.nextStep();
if (typeof step === "string") {
return {
content: step,
toolCalls: [],
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
model: "mock",
};
}
const toolCalls: ParsedToolCall[] = step.toolCalls.map((tc, i) => ({
id: tc.id ?? `mock_call_${this.index}_${i}`,
name: tc.name,
arguments: JSON.stringify(tc.arguments),
}));
return {
content: step.content ?? null,
toolCalls,
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
model: "mock",
};
}
}
export function createMockMainAgentResponse(
overrides: Record<string, unknown> = {},
): string {
return JSON.stringify({
action: "ask_user",
reason: "请告诉我你想创作什么类型的作品、目标篇幅和风格偏好。",
workerId: null,
requiresApproval: false,
...overrides,
});
}
export function createMockToolCall(
name: string,
args: Record<string, unknown>,
id?: string,
): MockLlmStep {
return { toolCalls: [{ name, arguments: args, id }] };
}

58
src/llm/preset-wrapper.ts Normal file
View File

@@ -0,0 +1,58 @@
import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js";
import type { PresetPackage } from "../types/preset.js";
import type {
ChatMessage,
CompleteOptions,
CompleteResult,
CompleteWithToolsOptions,
CompleteWithToolsResult,
LlmProvider,
} from "./client.js";
/**
* 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。
*/
export class PresetLlmProvider implements LlmProvider {
constructor(
private readonly inner: LlmProvider,
private readonly getPreset: () => PresetPackage | null,
) {}
async complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
const preset = this.getPreset();
if (!preset) {
return this.inner.complete(messages, options);
}
const presetMessages = assemblePresetMessages(preset);
const merged = mergeMessages(presetMessages, messages);
const generation = options?.generation ?? preset.generation;
return this.inner.complete(merged, {
...options,
generation,
});
}
async completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const preset = this.getPreset();
if (!preset) {
return this.inner.completeWithTools(messages, options);
}
const presetMessages = assemblePresetMessages(preset);
const merged = mergeMessages(presetMessages, messages);
const generation = options.generation ?? preset.generation;
return this.inner.completeWithTools(merged, {
...options,
generation,
});
}
}

85
src/llm/token-tracker.ts Normal file
View File

@@ -0,0 +1,85 @@
import type {
CompleteOptions,
CompleteResult,
CompleteWithToolsOptions,
CompleteWithToolsResult,
LlmProvider,
} from "./client.js";
import {
recordTokenUsage,
toMessageTokenUsage,
type MessageTokenUsage,
} from "../stats/token-store.js";
export type LlmTrackingContext = {
sessionId?: string;
bookId?: string;
bookTitle?: string;
orchestratorId?: string;
/** Set after each LLM call; consumed when the next system chat message is created */
pendingUsage?: MessageTokenUsage;
pendingReasoning?: string;
};
export class TokenTrackingProvider implements LlmProvider {
constructor(
private readonly inner: LlmProvider,
private readonly getContext: () => LlmTrackingContext,
) {}
async complete(
messages: Parameters<LlmProvider["complete"]>[0],
options?: CompleteOptions,
): Promise<CompleteResult> {
const result = await this.inner.complete(messages, options);
const ctx = this.getContext();
if (result.usage) {
const record = recordTokenUsage({
sessionId: ctx.sessionId,
bookId: ctx.bookId,
bookTitle: ctx.bookTitle,
orchestratorId: ctx.orchestratorId,
caller: options?.caller ?? "unknown",
model: result.model ?? "unknown",
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
cachedTokens: result.usage.cachedTokens,
cacheMissTokens: result.usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
}
async completeWithTools(
messages: Parameters<LlmProvider["completeWithTools"]>[0],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const result = await this.inner.completeWithTools(messages, options);
const ctx = this.getContext();
if (result.usage) {
const record = recordTokenUsage({
sessionId: ctx.sessionId,
bookId: ctx.bookId,
bookTitle: ctx.bookTitle,
orchestratorId: ctx.orchestratorId,
caller: options.caller ?? "unknown",
model: result.model ?? "unknown",
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
cachedTokens: result.usage.cachedTokens,
cacheMissTokens: result.usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
}
}

View File

@@ -0,0 +1,191 @@
import { randomUUID } from "node:crypto";
import type { LlmProvider } from "../llm/client.js";
import type { BlackboardTagIndex } from "../types/blackboard.js";
import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js";
import { runMainAgentToolLoop, type ToolLoopHandlers } from "./tool-loop.js";
export type MainAgentContext = {
session: RuntimeSession;
blackboardIndex: BlackboardTagIndex[];
availableWorkers: Array<{ id: string; description: string }>;
};
export type MainAgentRunOptions = {
handlers: ToolLoopHandlers;
};
function buildMainAgentSystemPrompt(
workers: Array<{ id: string; description: string }>,
): string {
const workerLines =
workers.length > 0
? workers.map((w) => `- ${w.id}${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是写作系统的总管Main Agent。你的职责是调度 worker而不是直接创作正文。
规则:
1. 你不能直接生成小说/文章正文。
2. 你不能修改运行状态statePatchAllowed 必须始终为 false。
3. 你只能建议下一步动作ask_user、run_worker、create_temp_worker、review_blackboard、finish。
4. 当信息不足时,使用 ask_user 向用户提问。
5. 当需要执行任务时,使用 run_worker只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。
6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。
7. run_worker 可选 workerContext{ "roleId": "A" },用于 role-decide 等指定当前决策角色Runtime 写入 世界.当前角色.id
8. 你不能把未验收内容当作事实。
9. 向用户提问是 worker 的能力ask_user tool不是独立 worker。总管只在调度层提问。
10. blackboardIndex 只有 tag 索引,不含正文 content。
当前 skill 可用 workerworkerId 必须与下列 id 完全一致):
${workerLines}
输出必须是 JSON 对象,字段:
{
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
"reason": "string",
"workerId": "string | null",
"requiresApproval": boolean,
"workerContext": { "roleId": "string" } | null
}`;
}
export class MainAgent {
constructor(private readonly llm: LlmProvider) {}
/** @deprecated 单次 JSON 决策;请用 runToolLoop */
async decide(context: MainAgentContext): Promise<MainAgentDecision> {
const userPrompt = buildMainAgentUserPrompt(context);
const systemPrompt = buildMainAgentSystemPrompt(context.availableWorkers);
const result = await this.llm.complete(
[
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
{ responseFormat: "json_object", caller: "main_agent" },
);
return parseMainAgentDecision(result.content);
}
/** running 相位tool loop 直到终止 tool */
async runToolLoop(
context: MainAgentContext,
options: MainAgentRunOptions,
): Promise<MainAgentDecision> {
const { decision } = await runMainAgentToolLoop(
this.llm,
context,
options.handlers,
);
return decision;
}
}
export function buildMainAgentUserPrompt(context: MainAgentContext): string {
const { session, blackboardIndex, availableWorkers } = context;
return JSON.stringify(
{
runtimePhase: session.phase,
waitingReason: session.waitingReason,
flowId: session.flowId,
currentStepId: session.currentStepId,
currentWorkerId: session.currentWorkerId,
acceptanceMode: session.acceptanceMode,
slots: session.slots,
pendingDecision: session.pendingDecision
? {
id: session.pendingDecision.id,
action: session.pendingDecision.action,
reason: session.pendingDecision.reason,
}
: null,
pendingArtifactId: session.pendingArtifactId,
artifacts: session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
blackboardIndex,
availableWorkers,
instruction:
"根据当前状态决定下一步。若 collecting_input 或 revision_requested优先理解用户最新输入。若 planning 且已有足够信息,建议 run_worker仅 workerId。",
},
null,
2,
);
}
export function parseMainAgentDecision(raw: string): MainAgentDecision {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Main Agent returned invalid JSON: ${raw.slice(0, 200)}`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Main Agent decision must be an object");
}
const obj = parsed as Record<string, unknown>;
const action = obj.action;
if (
action !== "ask_user" &&
action !== "run_worker" &&
action !== "create_temp_worker" &&
action !== "review_blackboard" &&
action !== "finish"
) {
throw new Error(`Invalid action: ${String(action)}`);
}
const reason = typeof obj.reason === "string" ? obj.reason : "";
if (!reason) {
throw new Error("Main Agent decision requires reason");
}
const workerId =
typeof obj.workerId === "string"
? obj.workerId
: obj.workerId === null || obj.workerId === undefined
? undefined
: undefined;
let workerContext: MainAgentDecision["workerContext"];
const ctxRaw = obj.workerContext;
if (ctxRaw && typeof ctxRaw === "object" && !Array.isArray(ctxRaw)) {
const roleIdRaw = (ctxRaw as Record<string, unknown>).roleId;
const roleId = typeof roleIdRaw === "string" ? roleIdRaw.trim() : undefined;
if (roleId) workerContext = { roleId };
}
return {
id: randomUUID(),
action,
reason,
workerId,
workerContext,
requiresApproval: Boolean(obj.requiresApproval),
statePatchAllowed: false,
};
}
export const DEFAULT_WORKERS = [
{
id: "write-rules",
description: "规则怪谈:内部 core.danger + 护命规则 rules.draft + 解析",
},
{
id: "outline",
description: "根据 book.brief 生成 outline.draft",
},
{
id: "review-infer",
description: "读者视角验收 rules不含 core.danger",
},
{
id: "review-author",
description: "作者视角验收 rules 与 core 一致性",
},
] as const;

173
src/main-agent/tool-loop.ts Normal file
View File

@@ -0,0 +1,173 @@
import type { ChatMessage, LlmProvider, ParsedToolCall } from "../llm/client.js";
import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js";
import type { MainAgentDecision } from "../types/runtime.js";
import { isMainAgentTerminalTool } from "../types/tools.js";
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
import type { MainAgentContext } from "./main-agent.js";
export type ToolLoopHandlers = {
readBlackboard: (tags: string[]) => Record<string, string>;
listWorkers: () => Array<{ id: string; description: string }>;
listArtifacts: () => Array<{
id: string;
workerId: string;
status: string;
summary?: string;
outputTags: string[];
}>;
onToolCall?: (name: string, detail: string) => void;
};
export type ToolLoopResult = {
decision: MainAgentDecision;
iterations: number;
toolTrace: string[];
};
const MAX_TOOL_LOOP_ITERATIONS = 12;
function buildToolLoopSystemPrompt(
workers: Array<{ id: string; description: string }>,
): string {
const workerLines =
workers.length > 0
? workers.map((w) => `- ${w.id}${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是写作系统的总管Main Agent。你在 running 相位通过 **tool call** 推进流程。
规则:
1. 你不能直接生成小说/文章正文。
2. 你不能修改运行状态。
3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。
4. 终止动作只能用 toolask_user、run_worker、review_blackboard、finish。
5. run_worker 只传 workerIdinputTags/outputTags 由 Runtime 从 Worker Skill 读取。
6. requiresApproval=true 时 run_worker 需用户确认后再执行。
7. run_worker 可选 roleId用于 role-decide 等指定当前决策角色。
8. 不能把未验收产物当作已定事实。
当前 skill 可用 worker
${workerLines}
在信息足够前可多次调用 read_blackboard 等;一旦调用终止 tool本轮循环结束。`;
}
function executeLoopTool(
call: ParsedToolCall,
handlers: ToolLoopHandlers,
): string {
const name = validateLoopToolCall(call);
const args = JSON.parse(call.arguments || "{}") as Record<string, unknown>;
switch (name) {
case "read_blackboard": {
const tags = Array.isArray(args.tags)
? args.tags.filter((t): t is string => typeof t === "string")
: [];
if (tags.length === 0) {
return JSON.stringify({ error: "tags must be a non-empty string array" });
}
return JSON.stringify(handlers.readBlackboard(tags));
}
case "list_workers":
return JSON.stringify(handlers.listWorkers());
case "list_artifacts":
return JSON.stringify(handlers.listArtifacts());
default:
return JSON.stringify({ error: `Unhandled loop tool: ${name}` });
}
}
function assistantMessageFromToolCalls(
content: string | null,
toolCalls: ParsedToolCall[],
): ChatMessage {
return {
role: "assistant",
content,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
})),
};
}
/**
* 总管 tool loop在 running 相位内可多轮调用 read_blackboard 等,
* 直到调用终止 tool 并返回 MainAgentDecision。
*/
export async function runMainAgentToolLoop(
llm: LlmProvider,
context: MainAgentContext,
handlers: ToolLoopHandlers,
): Promise<ToolLoopResult> {
const messages: ChatMessage[] = [
{
role: "system",
content: buildToolLoopSystemPrompt(context.availableWorkers),
},
{ role: "user", content: buildMainAgentUserPrompt(context) },
];
const toolTrace: string[] = [];
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
const result = await llm.completeWithTools(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
});
if (result.toolCalls.length === 0) {
if (result.content?.trim()) {
const decision = parseMainAgentDecision(result.content);
return { decision, iterations: iteration, toolTrace };
}
throw new Error("Main Agent returned no tool calls and no content");
}
const terminalCalls = result.toolCalls.filter((tc) =>
isMainAgentTerminalTool(tc.name),
);
const loopCalls = result.toolCalls.filter(
(tc) => !isMainAgentTerminalTool(tc.name),
);
if (terminalCalls.length > 1) {
throw new Error(
`Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`,
);
}
messages.push(
assistantMessageFromToolCalls(result.content, result.toolCalls),
);
if (terminalCalls.length === 1) {
const terminal = terminalCalls[0];
handlers.onToolCall?.(terminal.name, terminal.arguments);
toolTrace.push(`${terminal.name} (terminal)`);
return {
decision: toolCallToDecision(terminal),
iterations: iteration,
toolTrace,
};
}
for (const call of loopCalls) {
const output = executeLoopTool(call, handlers);
handlers.onToolCall?.(call.name, output.slice(0, 200));
toolTrace.push(call.name);
messages.push({
role: "tool",
content: output,
tool_call_id: call.id,
});
}
}
throw new Error(
`Main Agent tool loop exceeded ${MAX_TOOL_LOOP_ITERATIONS} iterations`,
);
}

121
src/main-agent/tools.ts Normal file
View File

@@ -0,0 +1,121 @@
import type { ToolDefinition } from "../llm/client.js";
/** 总管可用 tool 的 OpenAI function 定义 */
export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
{
type: "function",
function: {
name: "read_blackboard",
description:
"读取黑板 tag 正文。tags 可为精确 tag 或带 * 前缀模式。调度前用此了解已有内容。",
parameters: {
type: "object",
properties: {
tags: {
type: "array",
items: { type: "string" },
description: "要读取的 tag 或模式列表",
},
},
required: ["tags"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "list_workers",
description: "列出当前 skill 可调度的 worker id 与说明。",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "list_artifacts",
description: "列出当前会话 worker 产物id、workerId、status、summary。",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "ask_user",
description: "信息不足时向用户提问,将暂停 agent 循环等待用户输入。",
parameters: {
type: "object",
properties: {
reason: { type: "string", description: "为何需要用户输入" },
message: { type: "string", description: "展示给用户的问题或说明" },
},
required: ["reason"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "run_worker",
description:
"调度 worker 执行任务。只传 workerIdinputTags/outputTags 由 Runtime 从 Worker Skill 读取。",
parameters: {
type: "object",
properties: {
workerId: { type: "string" },
reason: { type: "string" },
requiresApproval: {
type: "boolean",
description: "true 时需用户确认后才执行",
},
roleId: {
type: "string",
description: "role-decide 等 worker 的当前角色 id",
},
},
required: ["workerId", "reason", "requiresApproval"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "review_blackboard",
description: "向用户说明当前黑板与进度概况,并暂停等待用户回复。",
parameters: {
type: "object",
properties: {
reason: { type: "string" },
summary: { type: "string", description: "给用户看的概况说明" },
},
required: ["reason", "summary"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "finish",
description: "正常结束当前创作流程。",
parameters: {
type: "object",
properties: {
reason: { type: "string" },
},
required: ["reason"],
additionalProperties: false,
},
},
},
];

49
src/preset/assembler.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
export type MarkerResolver = (identifier: string) => string | null;
const defaultMarkerResolver: MarkerResolver = () => null;
/**
* 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。
*/
export function assemblePresetMessages(
preset: PresetPackage,
resolveMarker: MarkerResolver = defaultMarkerResolver,
): ChatMessage[] {
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
const messages: ChatMessage[] = [];
const ordered = [...preset.promptOrder].sort(
(a, b) => a.orderIndex - b.orderIndex,
);
for (const orderItem of ordered) {
if (!orderItem.enabled) continue;
const entry = promptById.get(orderItem.promptId);
if (!entry || !entry.enabled) continue;
let content = entry.content.trim();
if (!content && entry.marker) {
const resolved = resolveMarker(entry.sourceIdentifier);
if (resolved?.trim()) content = resolved.trim();
}
if (!content) continue;
messages.push({
role: entry.role,
content,
});
}
return messages;
}
export function mergeMessages(
presetMessages: ChatMessage[],
taskMessages: ChatMessage[],
): ChatMessage[] {
return [...presetMessages, ...taskMessages];
}

46
src/preset/entries.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { PresetPackage, PresetPromptRole } from "../types/preset.js";
export type PresetEnabledEntryView = {
orderIndex: number;
id: string;
name: string;
role: PresetPromptRole;
marker: boolean;
content: string;
/** 实际会注入 LLM 请求(有非空 content */
willInject: boolean;
};
/** 按 prompt_order 列出所有启用条目及其内容 */
export function listEnabledPresetEntries(
preset: PresetPackage,
): PresetEnabledEntryView[] {
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
const ordered = [...preset.promptOrder].sort(
(a, b) => a.orderIndex - b.orderIndex,
);
const entries: PresetEnabledEntryView[] = [];
for (const orderItem of ordered) {
if (!orderItem.enabled) continue;
const entry = promptById.get(orderItem.promptId);
if (!entry || !entry.enabled) continue;
const content = entry.content.trim();
entries.push({
orderIndex: orderItem.orderIndex,
id: entry.id,
name: entry.name,
role: entry.role,
marker: entry.marker,
content,
willInject: content.length > 0,
});
}
return entries;
}
export function countInjectingEntries(entries: PresetEnabledEntryView[]): number {
return entries.filter((e) => e.willInject).length;
}

235
src/preset/importer.ts Normal file
View File

@@ -0,0 +1,235 @@
import { randomUUID } from "node:crypto";
import type {
GenerationParameters,
PresetImportReport,
PresetPackage,
PresetPromptEntry,
PresetPromptOrderItem,
UnsupportedPresetSection,
} from "../types/preset.js";
type StPrompt = {
identifier?: string;
name?: string;
enabled?: boolean;
role?: string;
content?: string;
marker?: boolean;
system_prompt?: boolean;
injection_position?: number;
injection_depth?: number;
injection_order?: number;
forbid_overrides?: boolean;
};
type StPromptOrderBlock = {
character_id?: number;
order?: Array<{ identifier: string; enabled: boolean }>;
};
type SillyTavernPreset = {
temperature?: number;
top_p?: number;
top_k?: number;
min_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
repetition_penalty?: number;
openai_max_context?: number;
openai_max_tokens?: number;
stream_openai?: boolean;
reasoning_effort?: string;
verbosity?: string;
seed?: number;
n?: number;
prompts?: StPrompt[];
prompt_order?: StPromptOrderBlock[];
regex_scripts?: unknown;
extensions?: unknown;
[key: string]: unknown;
};
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
function normalizeRole(role: string | undefined): PresetPromptEntry["role"] {
if (role === "user" || role === "assistant" || role === "system") {
return role;
}
return "system";
}
function readGeneration(raw: SillyTavernPreset): GenerationParameters {
return {
temperature: raw.temperature,
topP: raw.top_p,
topK: raw.top_k,
minP: raw.min_p,
frequencyPenalty: raw.frequency_penalty,
presencePenalty: raw.presence_penalty,
repetitionPenalty: raw.repetition_penalty,
maxContextTokens: raw.openai_max_context,
maxOutputTokens: raw.openai_max_tokens,
stream: raw.stream_openai,
reasoningEffort: raw.reasoning_effort,
verbosity: raw.verbosity,
seed: raw.seed !== undefined && raw.seed >= 0 ? raw.seed : undefined,
variants: raw.n,
};
}
function selectPromptOrder(
raw: SillyTavernPreset,
promptMap: Map<string, StPrompt>,
): StPromptOrderBlock {
const orders = raw.prompt_order ?? [];
if (orders.length === 0) return { order: [] };
if (orders.length === 1) return orders[0];
let best = orders[0];
let bestScore = -1;
for (const block of orders) {
let score = 0;
for (const item of block.order ?? []) {
if (!item.enabled) continue;
const prompt = promptMap.get(item.identifier);
if (!prompt) continue;
if (prompt.content?.trim()) score += 3;
else if (prompt.marker) score += 0;
else score += 1;
}
if (score > bestScore) {
bestScore = score;
best = block;
}
}
return best;
}
function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] {
const unsupported: UnsupportedPresetSection[] = [];
if (raw.regex_scripts) {
unsupported.push({
path: "regex_scripts",
reason: "第一版不支持正则脚本",
});
}
if (raw.extensions) {
unsupported.push({
path: "extensions",
reason: "第一版不支持扩展脚本",
});
}
return unsupported;
}
export function importSillyTavernPreset(
rawInput: unknown,
options: { name?: string; id?: string } = {},
): PresetImportReport {
const raw = rawInput as SillyTavernPreset;
const warnings: string[] = [];
const stPrompts = raw.prompts ?? [];
const promptMap = new Map<string, StPrompt>();
for (const p of stPrompts) {
const id = p.identifier ?? randomUUID();
if (promptMap.has(id)) {
warnings.push(`重复 identifier: ${id}`);
}
promptMap.set(id, p);
}
const selectedOrder = selectPromptOrder(raw, promptMap);
if ((raw.prompt_order?.length ?? 0) > 1) {
warnings.push(
`检测到 ${raw.prompt_order!.length} 套 prompt_order已自动选用启用内容最多的一套 (character_id=${selectedOrder.character_id ?? "?"})`,
);
}
const prompts: PresetPromptEntry[] = stPrompts.map((p) => {
const sourceId = p.identifier ?? randomUUID();
return {
id: sourceId,
name: p.name ?? sourceId,
enabled: p.enabled !== false,
role: normalizeRole(p.role),
content: p.content ?? "",
marker: Boolean(p.marker),
sourceIdentifier: sourceId,
injection: {
position: p.injection_position,
depth: p.injection_depth,
order: p.injection_order,
},
};
});
const promptOrder: PresetPromptOrderItem[] = [];
const missingIdentifiers: string[] = [];
const orderItems = selectedOrder.order ?? [];
orderItems.forEach((item, index) => {
if (!promptMap.has(item.identifier)) {
missingIdentifiers.push(item.identifier);
return;
}
promptOrder.push({
promptId: item.identifier,
enabled: item.enabled,
orderIndex: index,
});
});
const referenced = new Set(promptOrder.map((o) => o.promptId));
const unreferencedCount = prompts.filter(
(p) => !referenced.has(p.id),
).length;
const enabledCount = promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
}).length;
const generation = readGeneration(raw);
const generationFields = Object.entries(generation)
.filter(([, v]) => v !== undefined)
.map(([k]) => k);
const presetName =
options.name?.trim() ||
(typeof rawInput === "object" &&
rawInput &&
"name" in rawInput &&
typeof (rawInput as { name: unknown }).name === "string"
? (rawInput as { name: string }).name
: "导入的预设");
const preset: PresetPackage = {
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
name: presetName,
source: "sillytavern",
prompts,
promptOrder,
generation,
unsupported: collectUnsupported(raw),
importedAt: new Date().toISOString(),
raw: rawInput,
};
return {
preset,
promptCount: prompts.length,
enabledCount,
unreferencedCount,
missingIdentifiers,
generationFields,
warnings,
};
}

67
src/preset/store.ts Normal file
View File

@@ -0,0 +1,67 @@
import { readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { PresetPackage } from "../types/preset.js";
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
import { importSillyTavernPreset } from "./importer.js";
function presetPath(id: string): string {
return path.join(getPresetsDir(), `${id}.json`);
}
export function listPresets(): PresetPackage[] {
ensureUserDataDirs();
let files: string[];
try {
files = readdirSync(getPresetsDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
const presets: PresetPackage[] = [];
for (const file of files) {
try {
const raw = readFileSync(path.join(getPresetsDir(), file), "utf8");
presets.push(JSON.parse(raw) as PresetPackage);
} catch {
/* skip corrupt files */
}
}
return presets.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
}
export function getPreset(id: string): PresetPackage | null {
try {
const raw = readFileSync(presetPath(id), "utf8");
return JSON.parse(raw) as PresetPackage;
} catch {
return null;
}
}
export function savePreset(preset: PresetPackage): PresetPackage {
ensureUserDataDirs();
writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8");
return preset;
}
export function deletePreset(id: string): void {
try {
unlinkSync(presetPath(id));
} catch {
/* ignore */
}
}
export function importAndSavePreset(
raw: unknown,
options: { name?: string } = {},
) {
const report = importSillyTavernPreset(raw, options);
savePreset(report.preset);
return report;
}
export function resolveActivePreset(activePresetId: string | null): PresetPackage | null {
if (!activePresetId) return null;
return getPreset(activePresetId);
}

View File

@@ -0,0 +1,78 @@
import { profileToLlmConfig } from "../config/api-profiles.js";
import {
ensureActiveProfileDefault,
loadAppSettings,
resolveActiveProfile,
} from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import {
createMockMainAgentResponse,
MockLlmProvider,
OpenAiCompatibleProvider,
type LlmProvider,
} from "../llm/client.js";
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
export type LlmTrackingRef = { current: LlmTrackingContext };
function buildInnerLlm(): LlmProvider {
ensureActiveProfileDefault();
const profile = resolveActiveProfile();
if (profile?.apiKey?.trim()) {
return new OpenAiCompatibleProvider(profileToLlmConfig(profile));
}
return new MockLlmProvider([
createMockMainAgentResponse({
action: "run_worker",
reason: "信息已足够,建议运行 outline-worker 生成大纲。",
workerId: "outline-worker",
requiresApproval: true,
}),
createMockMainAgentResponse({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
]);
}
function wrapWithPreset(inner: LlmProvider): LlmProvider {
const settings = loadAppSettings();
const preset = resolveActivePreset(settings.activePresetId);
if (!preset) return inner;
return new PresetLlmProvider(inner, () =>
resolveActivePreset(loadAppSettings().activePresetId),
);
}
/** Web/CLI 默认 LLM本地 profile + 全局 preset + token 统计 */
export function createDefaultMainAgentLlm(
trackingRef?: LlmTrackingRef,
): LlmProvider {
const llm = wrapWithPreset(buildInnerLlm());
if (!trackingRef) return llm;
return new TokenTrackingProvider(llm, () => trackingRef.current);
}
export function hasRealLlmConfig(): boolean {
ensureActiveProfileDefault();
return Boolean(resolveActiveProfile()?.apiKey?.trim());
}
/** @deprecated 包装层下 instanceof 不可靠,请用 hasRealLlmConfig */
export function isMockLlm(_llm: LlmProvider): boolean {
return !hasRealLlmConfig();
}
/** 设置页切换 profile / preset 后调用,返回新 LLM 实例 */
export function reloadDefaultMainAgentLlm(
trackingRef?: LlmTrackingRef,
): LlmProvider {
return createDefaultMainAgentLlm(trackingRef);
}

291
src/runtime/orchestrator.ts Normal file
View File

@@ -0,0 +1,291 @@
/**
* @deprecated 请使用 PhaseRuntime已含 Skill 流程 + 总管 LLM 循环)。
* 本文件保留供对照测试,新入口见 phase-demo / web / run.ts。
*/
import { randomUUID } from "node:crypto";
import { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import {
DEFAULT_WORKERS,
MainAgent,
} from "../main-agent/main-agent.js";
import {
applyEvent,
createArtifact,
createSession,
} from "../runtime/phase-machine.js";
import type {
AcceptanceMode,
MainAgentDecision,
PhaseEffect,
RuntimeEvent,
RuntimeSession,
} from "../types/runtime.js";
export type RuntimeOrchestratorOptions = {
presetId?: string;
flowId?: string;
llm: LlmProvider;
blackboard?: Blackboard;
acceptanceMode?: AcceptanceMode;
onMessage?: (message: string) => void;
};
export class RuntimeOrchestrator {
private session: RuntimeSession;
private readonly blackboard: Blackboard;
private readonly mainAgent: MainAgent;
private readonly acceptanceMode: AcceptanceMode;
private readonly onMessage: (message: string) => void;
constructor(options: RuntimeOrchestratorOptions) {
this.session = createSession(options.presetId ?? "default", options.flowId);
this.blackboard = options.blackboard ?? new Blackboard();
this.mainAgent = new MainAgent(options.llm);
this.acceptanceMode = options.acceptanceMode ?? "user_confirmed";
this.onMessage = options.onMessage ?? (() => {});
}
getSession(): RuntimeSession {
return this.session;
}
getBlackboard(): Blackboard {
return this.blackboard;
}
async start(): Promise<RuntimeSession> {
await this.dispatch({
type: "session_started",
payload: {
presetId: this.session.presetId,
flowId: this.session.flowId,
availableSkills: [{ name: "basic", description: "fallback", category: "novel" }],
},
});
return this.session;
}
async submitUserInput(text: string): Promise<RuntimeSession> {
await this.dispatch({
type: "user_submitted_input",
payload: { text },
});
return this.session;
}
async approveNextStep(): Promise<RuntimeSession> {
const decisionId = this.session.pendingDecision?.id;
if (!decisionId) {
throw new Error("No pending decision to approve");
}
await this.dispatch({
type: "user_approved_next_step",
payload: { decisionId },
});
return this.session;
}
async rejectNextStep(reason?: string): Promise<RuntimeSession> {
const decisionId = this.session.pendingDecision?.id;
if (!decisionId) {
throw new Error("No pending decision to reject");
}
await this.dispatch({
type: "user_rejected_next_step",
payload: { decisionId, reason },
});
return this.session;
}
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId;
if (!id) {
throw new Error("No pending artifact to accept");
}
await this.dispatch({
type: "user_accepted_artifact",
payload: { artifactId: id },
});
return this.session;
}
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId;
if (!id) {
throw new Error("No pending artifact to reject");
}
await this.dispatch({
type: "user_rejected_artifact",
payload: { artifactId: id, reason },
});
return this.session;
}
async completeFlow(): Promise<RuntimeSession> {
await this.dispatch({ type: "flow_completed", payload: {} });
return this.session;
}
private async dispatch(event: RuntimeEvent): Promise<void> {
let result = applyEvent(this.session, event);
this.session = result.session;
if (result.error && event.type !== "runtime_failed") {
this.onMessage(result.error);
}
await this.processEffects(result.effects);
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
}
private async processEffects(effects: PhaseEffect[]): Promise<void> {
for (const effect of effects) {
switch (effect.type) {
case "emit_message":
this.onMessage(effect.message);
break;
case "run_worker":
await this.runStubWorker(effect);
break;
case "resume_worker":
await this.resumeStubWorker();
break;
case "run_programmatic_review":
await this.runProgrammaticReview(effect.artifactId);
break;
case "invoke_main_agent":
break;
}
}
}
private async runMainAgent(): Promise<void> {
if (this.session.phase !== "running") {
return;
}
const decision = await this.mainAgent.decide({
session: this.session,
blackboardIndex: this.blackboard.listTagIndex(),
availableWorkers: [...DEFAULT_WORKERS],
});
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = applyEvent(this.session, {
type: "main_agent_decision_created",
payload: { decision },
});
this.session = result.session;
await this.processEffects(result.effects);
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
}
private async runStubWorker(
effect: Extract<PhaseEffect, { type: "run_worker" }>,
): Promise<void> {
await this.executeStubWorker(effect.workerId);
}
private async resumeStubWorker(): Promise<void> {
const ctx = this.session.resumeContext;
if (!ctx) {
throw new Error("No resume context");
}
await this.executeStubWorker(ctx.workerId);
}
private async executeStubWorker(workerId: string): Promise<void> {
const stepId = this.session.currentStepId;
this.session = applyEvent(this.session, {
type: "worker_started",
payload: {
workerId,
stepId,
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
},
}).session;
this.onMessage(`[Worker 占位] 正在执行 ${workerId} ...`);
const lastInput = String(this.session.slots.lastUserInput ?? "");
const summary = `${workerId} 针对用户输入生成的占位产物`;
const value = `[${workerId} 占位输出]\n用户输入: ${lastInput}\n说明: 这是第一版 stub worker后续会替换为真实 LLM worker。`;
const outputTags = ["output.草稿"];
for (const tag of outputTags) {
this.blackboard.write({
tag,
content: value,
source: workerId,
});
}
const artifact = createArtifact({
workerId,
stepId,
outputTags,
summary,
});
this.session = {
...this.session,
artifacts: [...this.session.artifacts, artifact],
};
const completed = applyEvent(this.session, {
type: "worker_completed",
payload: { artifactId: artifact.id },
});
this.session = completed.session;
await this.processEffects(completed.effects);
if (completed.effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
}
private async runProgrammaticReview(artifactId: string): Promise<void> {
this.session = applyEvent(this.session, {
type: "programmatic_review_started",
payload: { artifactId },
}).session;
const artifact = this.session.artifacts.find((a) => a.id === artifactId);
const passed = Boolean(artifact?.summary && artifact.summary.length > 0);
const reviewEvent: RuntimeEvent = passed
? { type: "programmatic_review_passed", payload: { artifactId } }
: {
type: "programmatic_review_failed",
payload: { artifactId, reason: "产物 summary 为空" },
};
const result = applyEvent(this.session, reviewEvent);
this.session = result.session;
await this.processEffects(result.effects);
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
}
}
export function createDecision(
partial: Omit<MainAgentDecision, "id" | "statePatchAllowed"> &
Partial<Pick<MainAgentDecision, "id">>,
): MainAgentDecision {
return {
id: partial.id ?? randomUUID(),
statePatchAllowed: false,
...partial,
};
}

View File

@@ -0,0 +1,793 @@
/**
* 纯函数运行阶段机。
*
* - 无副作用:不读写文件、不调用 LLM、不启动 worker
* - 输入 (session, event),输出 { session, effects }
* - phase 只能由 applyEvent 改变;非法事件返回 error 并进入 phase=error
*
* 上层调用方PhaseRuntime.dispatch() → applyEvent → processEffects()
*/
import { randomUUID } from "node:crypto";
import type {
ApplyEventResult,
ArtifactRecord,
PhaseEffect,
ResumeContext,
RuntimeEvent,
RuntimePhase,
RuntimeSession,
WaitingReason,
} from "../types/runtime.js";
import type { ActiveSkillSnapshot } from "../types/runtime.js";
import {
buildIntakeProgress,
buildIntakeFollowUpMessage,
readIntakeValues,
synthesizeDemandText,
} from "../intake/intake.js";
function nowIso(): string {
return new Date().toISOString();
}
/** 更新 updatedAt 时间戳 */
function touch(session: RuntimeSession): RuntimeSession {
return { ...session, updatedAt: nowIso() };
}
/** 将用户补充文本追加到 slot用于合并多轮 ask_user / worker 答复到需求 tag */
function mergeSlotText(
slots: Record<string, unknown>,
key: string,
text: string,
): void {
const trimmed = text.trim();
if (!trimmed) return;
const prev = String(slots[key] ?? "").trim();
slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed;
}
/** 追加事件到 history 并 touch */
function appendHistory(
session: RuntimeSession,
event: RuntimeEvent,
): RuntimeSession {
return touch({ ...session, history: [...session.history, event] });
}
/** 进入 waiting_user并设置 waitingReason */
function waiting(session: RuntimeSession, reason: WaitingReason): RuntimeSession {
return touch({ ...session, phase: "waiting_user", waitingReason: reason });
}
/** 进入 running清除 waitingReason */
function running(session: RuntimeSession): RuntimeSession {
return touch({ ...session, phase: "running", waitingReason: undefined });
}
function findArtifact(
session: RuntimeSession,
artifactId: string,
): ArtifactRecord | undefined {
return session.artifacts.find((a) => a.id === artifactId);
}
function updateArtifact(
session: RuntimeSession,
artifactId: string,
patch: Partial<ArtifactRecord>,
): RuntimeSession {
return {
...session,
artifacts: session.artifacts.map((a) =>
a.id === artifactId ? { ...a, ...patch, updatedAt: nowIso() } : a,
),
};
}
/** 创建 idle 状态的新会话 */
export function createSession(
presetId: string,
flowId?: string,
): RuntimeSession {
const ts = nowIso();
return {
id: randomUUID(),
phase: "idle",
presetId,
flowId,
slots: {},
artifacts: [],
history: [],
createdAt: ts,
updatedAt: ts,
};
}
/**
* 按当前 phase 返回允许的事件类型列表(第一层校验)。
* waitingReason 的细粒度匹配见 canApplyEvent。
*/
export function getAllowedEvents(
session: RuntimeSession,
): RuntimeEvent["type"][] {
switch (session.phase) {
case "idle":
return ["session_started", "runtime_failed"];
case "running":
return [
"main_agent_decision_created",
"worker_started",
"worker_completed",
"worker_needs_input",
"programmatic_review_started",
"programmatic_review_passed",
"programmatic_review_failed",
"flow_completed",
"runtime_failed",
];
case "waiting_user":
return [
"skill_selected",
"user_submitted_input",
"user_confirmed_intake",
"user_approved_next_step",
"user_rejected_next_step",
"user_accepted_artifact",
"user_rejected_artifact",
"user_requested_revision",
"main_agent_decision_created",
"runtime_failed",
];
case "done":
case "error":
return [];
default:
return [];
}
}
/**
* 判断事件是否可在当前会话状态下应用。
* 除 phase 白名单外waiting_user 还需 waitingReason 与事件类型匹配。
*/
export function canApplyEvent(
session: RuntimeSession,
event: RuntimeEvent,
): boolean {
if (!getAllowedEvents(session).includes(event.type)) {
return false;
}
const reason = session.waitingReason;
switch (event.type) {
case "skill_selected":
return reason?.kind === "skill_selection";
case "user_submitted_input":
return (
reason?.kind === "intake" ||
reason?.kind === "input" ||
reason?.kind === "worker_questions" ||
reason?.kind === "revision"
);
case "user_confirmed_intake":
return reason?.kind === "intake";
case "user_approved_next_step":
case "user_rejected_next_step":
return reason?.kind === "approve_step";
case "user_accepted_artifact":
case "user_rejected_artifact":
return reason?.kind === "review_artifact";
case "user_requested_revision":
return (
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
);
case "main_agent_decision_created":
// running 时可决策waiting_user(input) 时允许总管在启动阶段插话
return session.phase === "running" || reason?.kind === "input";
default:
return true;
}
}
/** 校验失败或 runtime_failed进入 error phase 并 emit_message */
function fail(session: RuntimeSession, reason: string): ApplyEventResult {
return {
session: touch({ ...session, phase: "error", waitingReason: undefined }),
effects: [{ type: "emit_message", message: reason }],
error: reason,
};
}
/**
* worker_completed 的核心分支:按 acceptanceMode 决定下一步。
* user_confirmed 时 caller 需再包一层 waiting(review_artifact)。
*/
function handleWorkerCompleted(
session: RuntimeSession,
event: RuntimeEvent & { type: "worker_completed" },
): ApplyEventResult {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const mode = session.acceptanceMode ?? "user_confirmed";
let next = appendHistory(session, event);
if (mode === "user_confirmed") {
next = updateArtifact(next, artifact.id, { status: "under_review" });
return {
session: touch({
...next,
pendingArtifactId: artifact.id,
currentWorkerId: undefined,
resumeContext: undefined,
}),
effects: [],
// worker_completed case 外层会补 waiting(review_artifact)
};
}
if (mode === "no_confirmation") {
next = updateArtifact(next, artifact.id, { status: "accepted" });
return {
session: touch({
...running(next),
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
}),
effects: [{ type: "invoke_main_agent" }],
};
}
// programmatic_review
next = updateArtifact(next, artifact.id, { status: "under_review" });
return {
session: touch({
...running(next),
pendingArtifactId: artifact.id,
currentWorkerId: undefined,
}),
effects: [{ type: "run_programmatic_review", artifactId: artifact.id }],
};
}
/**
* 应用单个 RuntimeEvent返回新会话与副作用列表。
* 不合法的事件不会抛异常,而是返回 error 并将 phase 置为 error。
*/
export function applyEvent(
session: RuntimeSession,
event: RuntimeEvent,
): ApplyEventResult {
if (!canApplyEvent(session, event)) {
return fail(
session,
`Event ${event.type} is not allowed in phase ${session.phase}`,
);
}
switch (event.type) {
// ── 启动:选 skill ──
case "session_started": {
const next = appendHistory(
{
...session,
presetId: event.payload.presetId,
flowId: event.payload.flowId,
},
event,
);
const skills = event.payload.availableSkills;
return {
session: waiting(next, {
kind: "skill_selection",
availableSkills: skills,
}),
effects: [
{
type: "emit_message",
message: formatSkillSelectionPrompt(skills),
},
],
};
}
case "skill_selected": {
const { skill } = event.payload;
const next = appendHistory(
{
...session,
flowId: skill.defaultFlowId ?? session.flowId,
slots: {
...session.slots,
activeSkill: skill,
},
},
event,
);
return {
session: waiting(next, {
kind: "intake",
prompt: skill.startupPrompt,
}),
effects: [{ type: "emit_message", message: skill.startupPrompt }],
};
}
case "user_confirmed_intake": {
if (session.waitingReason?.kind !== "intake") {
return fail(session, "user_confirmed_intake requires intake waiting");
}
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (!activeSkill?.startupTargetKey || !activeSkill.intakeFields?.length) {
return fail(session, "No intake fields on active skill");
}
const values = readIntakeValues(session.slots);
const progress = buildIntakeProgress(activeSkill.intakeFields, values);
if (!progress.ready) {
return fail(session, "必要项尚未填完,无法确认");
}
const demandText = synthesizeDemandText(activeSkill.intakeFields, values);
const slots: Record<string, unknown> = {
...session.slots,
intakeValues: values,
startupCompleted: true,
[activeSkill.startupTargetKey]: demandText,
lastUserInput: demandText,
};
const next = appendHistory({ ...session, slots, resumeContext: undefined }, event);
return {
session: touch(running(next)),
effects: [{ type: "invoke_main_agent" }],
};
}
// ── 用户输入 ──
case "user_submitted_input": {
const hadResume = Boolean(session.resumeContext);
const activeSkill = session.slots.activeSkill as
| { startupTargetKey?: string }
| undefined;
const slots: Record<string, unknown> = {
...session.slots,
lastUserInput: event.payload.text,
userInputs: [
...((session.slots.userInputs as string[] | undefined) ?? []),
event.payload.text,
],
};
const demandKey = activeSkill?.startupTargetKey;
const text = event.payload.text.trim();
if (session.waitingReason?.kind === "intake") {
const skill = activeSkill as ActiveSkillSnapshot | undefined;
const intakeValues =
event.payload.intakeValues ??
readIntakeValues(session.slots);
slots.intakeValues = intakeValues;
slots.intakeSubmitCount =
((session.slots.intakeSubmitCount as number) ?? 0) + 1;
if (skill?.intakeFields?.length) {
const preview = synthesizeDemandText(skill.intakeFields, intakeValues);
if (preview.trim()) {
slots[demandKey ?? "intake.preview"] = preview;
} else if (text) {
mergeSlotText(slots, demandKey ?? "intake.preview", text);
}
} else if (text && demandKey) {
mergeSlotText(slots, demandKey, text);
}
const progress = skill?.intakeFields?.length
? buildIntakeProgress(skill.intakeFields, intakeValues)
: null;
const followUpSent = Boolean(session.slots.intakeFollowUpSent);
const effects: PhaseEffect[] = [];
if (
progress &&
!progress.ready &&
!followUpSent &&
(slots.intakeSubmitCount as number) >= 1
) {
const msg = buildIntakeFollowUpMessage(progress);
if (msg) {
slots.intakeFollowUpSent = true;
effects.push({ type: "emit_message", message: msg });
}
}
const next = appendHistory(
{
...session,
slots,
resumeContext: hadResume ? session.resumeContext : undefined,
},
event,
);
const prompt =
session.waitingReason.prompt ?? skill?.startupPrompt ?? "";
return {
session: waiting(touch(next), { kind: "intake", prompt }),
effects,
};
}
if (session.waitingReason?.kind === "worker_questions") {
slots["用户.worker答复"] = text;
if (demandKey && text) {
mergeSlotText(slots, demandKey, text);
}
} else if (demandKey && session.waitingReason?.kind === "input" && text) {
if (!session.slots.startupCompleted) {
slots[demandKey] = text;
slots.startupCompleted = true;
} else {
mergeSlotText(slots, demandKey, text);
}
}
const next = appendHistory(
{
...session,
slots,
resumeContext: hadResume ? session.resumeContext : undefined,
},
event,
);
if (hadResume) {
// worker 提问后的回复 → 恢复 worker
return {
session: touch(running({ ...next, resumeContext: session.resumeContext })),
effects: [{ type: "resume_worker" }],
};
}
// 常规定稿用户输入 → 等总管下一步
return {
session: touch(running({ ...next, resumeContext: undefined })),
effects: [{ type: "invoke_main_agent" }],
};
}
// ── 总管决策 ──
case "main_agent_decision_created": {
const { decision } = event.payload;
if (decision.statePatchAllowed !== false) {
return fail(session, "Main Agent decision must set statePatchAllowed to false");
}
const next = appendHistory({ ...session, pendingDecision: decision }, event);
switch (decision.action) {
case "ask_user":
case "review_blackboard":
return {
session: waiting(
{ ...next, pendingDecision: undefined },
{ kind: "input", message: decision.reason },
),
effects: [],
};
case "finish":
return {
session: touch({
...next,
phase: "done",
waitingReason: undefined,
pendingDecision: undefined,
}),
effects: [
{ type: "emit_message", message: decision.reason || "流程已完成。" },
],
};
case "create_temp_worker":
case "run_worker": {
if (!decision.workerId) {
return fail(session, "run_worker requires workerId");
}
if (decision.requiresApproval) {
// 需用户确认后才真正 run_worker
return {
session: waiting(next, {
kind: "approve_step",
decisionId: decision.id,
}),
effects: [
{ type: "emit_message", message: `等待确认:${decision.reason}` },
],
};
}
return {
session: touch(running({ ...next, currentWorkerId: decision.workerId })),
effects: [
{
type: "run_worker",
workerId: decision.workerId,
workerContext: decision.workerContext,
},
],
};
}
default:
return fail(session, "Unknown decision action");
}
}
case "user_approved_next_step": {
const decision = session.pendingDecision;
if (!decision || decision.id !== event.payload.decisionId) {
return fail(session, "No matching pending decision to approve");
}
if (!decision.workerId) {
return fail(session, "Approved decision has no workerId");
}
return {
session: touch(
running(
appendHistory(
{ ...session, currentWorkerId: decision.workerId },
event,
),
),
),
effects: [
{
type: "run_worker",
workerId: decision.workerId,
workerContext: decision.workerContext,
},
],
};
}
case "user_rejected_next_step": {
return {
session: touch(
running(
appendHistory(
{ ...session, pendingDecision: undefined },
event,
),
),
),
effects: [{ type: "invoke_main_agent" }],
};
}
// ── Worker 生命周期 ──
case "worker_started": {
return {
session: touch(
running(
appendHistory(
{
...session,
currentWorkerId: event.payload.workerId,
currentStepId: event.payload.stepId,
acceptanceMode: event.payload.acceptanceMode,
},
event,
),
),
),
effects: [],
};
}
case "worker_needs_input": {
const questions = event.payload.questions
.map((q) => q.trim())
.filter(Boolean);
const normalized =
questions.length > 0
? questions
: ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
const ctx: ResumeContext = {
workerId: event.payload.workerId,
stepId: event.payload.stepId ?? session.currentStepId,
acceptanceMode: session.acceptanceMode ?? "user_confirmed",
questions: normalized,
};
return {
session: waiting(
appendHistory(
{ ...session, resumeContext: ctx, currentWorkerId: event.payload.workerId },
event,
),
{
kind: "worker_questions",
workerId: event.payload.workerId,
questions: normalized,
},
),
effects: [
{
type: "emit_message",
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q}`).join("\n")}`,
},
],
};
}
case "worker_completed": {
const result = handleWorkerCompleted(session, event);
const mode = session.acceptanceMode ?? "user_confirmed";
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
return {
...result,
session: waiting(result.session, {
kind: "review_artifact",
artifactId: result.session.pendingArtifactId,
}),
};
}
return result;
}
// ── 产物验收 ──
case "user_accepted_artifact": {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "accepted" }),
event,
);
return {
session: touch(
running({
...next,
pendingArtifactId: undefined,
pendingDecision: undefined,
}),
),
effects: [{ type: "invoke_main_agent" }],
};
}
case "user_rejected_artifact": {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "rejected" }),
event,
);
return {
session: waiting(next, {
kind: "revision",
instruction: event.payload.reason,
}),
effects: [
{
type: "emit_message",
message: event.payload.reason ?? "产物已被拒绝,等待修改指示。",
},
],
};
}
case "user_requested_revision": {
if (session.waitingReason?.kind === "approve_step") {
// 在确认步骤时发修改意见,等同拒绝并回到总管
return {
session: touch(
running(
appendHistory({ ...session, pendingDecision: undefined }, event),
),
),
effects: [{ type: "invoke_main_agent" }],
};
}
return {
session: waiting(
appendHistory({ ...session, pendingArtifactId: undefined }, event),
{ kind: "revision", instruction: event.payload.instruction },
),
effects: [
{
type: "emit_message",
message: `返工请求:${event.payload.instruction}`,
},
],
};
}
// ── 程序验收 ──
case "programmatic_review_started":
return { session: appendHistory(running(session), event), effects: [] };
case "programmatic_review_passed": {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "accepted" }),
event,
);
return {
session: touch(
running({ ...next, pendingArtifactId: undefined }),
),
effects: [{ type: "invoke_main_agent" }],
};
}
case "programmatic_review_failed": {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "revision_requested" }),
event,
);
return {
session: waiting(next, {
kind: "revision",
instruction: event.payload.reason,
}),
effects: [
{
type: "emit_message",
message: `程序验收失败:${event.payload.reason}`,
},
],
};
}
// ── 终止 ──
case "flow_completed":
return {
session: touch({
...appendHistory(session, event),
phase: "done",
waitingReason: undefined,
}),
effects: [],
};
case "runtime_failed":
return fail(appendHistory(session, event), event.payload.reason);
default:
return fail(session, "Unhandled event type");
}
}
/** 创建 worker 产物记录drafted 状态),由 runtime 层在 worker_complete 前写入 session.artifacts */
export function createArtifact(params: {
workerId: string;
stepId?: string;
outputTags: string[];
summary?: string;
}): ArtifactRecord {
const ts = nowIso();
return {
id: randomUUID(),
workerId: params.workerId,
stepId: params.stepId,
outputTags: params.outputTags,
status: "drafted",
summary: params.summary,
createdAt: ts,
updatedAt: ts,
};
}
export { type RuntimePhase, type WaitingReason };
function formatSkillSelectionPrompt(
skills: Array<{ name: string; description: string }>,
): string {
const lines = skills.map((s, i) => ` ${i + 1}. ${s.name}${s.description}`);
return ["请选择创作 skill输入 name 或编号):", ...lines].join("\n");
}

View File

@@ -0,0 +1,763 @@
/**
* 阶段机运行层(有副作用的执行器)。
*
* 职责:
* - 持有可变 session统一 dispatch(event) 入口
* - 调用纯函数 applyEvent再 processEffects 执行 IO消息、worker、验收
* - 启动时从 skills/ 加载 registry驱动 skill 选择与 SKILL.md 启动询问
*
* 与 phase-machine.ts 的分工:前者「算状态」,本文件「跑状态」。
*/
import { randomUUID } from "node:crypto";
import {
applyEvent,
createArtifact,
createSession,
getAllowedEvents,
} from "./phase-machine.js";
import { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
import { listSkills, listWorkerSkills, loadSkill, loadWorkerSkill, resolveSkillId } from "../skills/loader.js";
import { toActiveSkillSnapshot } from "../skills/snapshot.js";
import { runWorkerSkill } from "../worker/executor.js";
import { resolveWorkerId } from "../worker/resolve-id.js";
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
import { extractIntakeFromMessage } from "../intake/extract.js";
import { readIntakeValues } from "../intake/intake.js";
import type {
AcceptanceMode,
ActiveSkillSnapshot,
ApplyEventResult,
MainAgentDecision,
PhaseEffect,
RuntimeEvent,
RuntimeSession,
SkillIndexEntry,
} from "../types/runtime.js";
import type { BlackboardItem } from "../types/blackboard.js";
export type PhaseRuntimeOptions = {
presetId?: string;
flowId?: string;
/** worker 完成后的默认验收模式,会写入 worker_started 事件 */
acceptanceMode?: AcceptanceMode;
/** true 时 run_worker 自动走占位 worker演示 / 测试用) */
autoStubWorker?: boolean;
/** 传入后 invoke_main_agent 会自动调用总管 LLM */
llm?: LlmProvider;
/** 运行时黑板worker 产出写入此处 */
blackboard?: Blackboard;
/** 副作用 emit_message 与用户提示的回调Web / CLI 接入点) */
onMessage?: (message: string) => void;
/** 从磁盘恢复时使用,跳过 createSession */
initialSession?: RuntimeSession;
/** 与 initialSession 一并恢复黑板 */
initialBlackboardItems?: BlackboardItem[];
};
/**
* 阶段机运行层:包装纯函数 phase-machine处理副作用。
* 启动时加载 skills/,先选 skill再读 SKILL.md 启动询问。
*/
export class PhaseRuntime {
private session: RuntimeSession;
private readonly acceptanceMode: AcceptanceMode;
private autoStubWorker: boolean;
private readonly onMessage: (message: string) => void;
private readonly blackboard: Blackboard;
private llm?: LlmProvider;
private mainAgent?: MainAgent;
/** autoStubWorker=false 时run_worker effect 暂存于此,等 startPendingWorker() */
private pendingWorkerEffect: Extract<PhaseEffect, { type: "run_worker" }> | null =
null;
/** start() 时从 registry 加载,供 skill_selection 展示与编号解析 */
private availableSkills: SkillIndexEntry[] = [];
constructor(options: PhaseRuntimeOptions = {}) {
this.session =
options.initialSession ??
createSession(options.presetId ?? "default", options.flowId);
this.acceptanceMode = options.acceptanceMode ?? "user_confirmed";
this.autoStubWorker = options.autoStubWorker ?? false;
this.onMessage = options.onMessage ?? (() => {});
this.blackboard = options.blackboard ?? new Blackboard();
if (options.initialBlackboardItems?.length) {
this.blackboard.seed(options.initialBlackboardItems);
}
if (options.llm) {
this.llm = options.llm;
this.mainAgent = new MainAgent(options.llm);
}
}
getBlackboard(): Blackboard {
return this.blackboard;
}
hasMainAgent(): boolean {
return Boolean(this.mainAgent);
}
/** 设置变更后热更新 LLMAPI profile / preset */
reloadLlm(llm: LlmProvider, autoStubWorker?: boolean): void {
this.llm = llm;
this.mainAgent = new MainAgent(llm);
if (autoStubWorker !== undefined) {
this.autoStubWorker = autoStubWorker;
}
}
getLlm(): LlmProvider | undefined {
return this.llm;
}
getSession(): RuntimeSession {
return this.session;
}
setLifecycleStage(stage: "design" | "play"): void {
this.session = {
...this.session,
slots: { ...this.session.slots, uiLifecycleStage: stage },
updatedAt: new Date().toISOString(),
};
}
getAvailableSkills(): SkillIndexEntry[] {
return this.availableSkills;
}
/** 恢复会话或首次 start 前加载 skill 列表(供 UI 展示) */
async ensureAvailableSkills(): Promise<void> {
if (this.availableSkills.length === 0) {
this.availableSkills = await listSkills();
}
}
getActiveSkill(): ActiveSkillSnapshot | undefined {
return this.session.slots.activeSkill as ActiveSkillSnapshot | undefined;
}
getAllowedEventTypes(): RuntimeEvent["type"][] {
return getAllowedEvents(this.session);
}
/**
* 是否处于「running 且无事可做,等总管决策」状态。
* Web 层据此显示「生成大纲」等按钮。
*/
needsMainAgentDecision(): boolean {
return (
this.session.phase === "running" &&
!this.session.currentWorkerId &&
!this.pendingWorkerEffect
);
}
/** 加载 skills/registry → 触发 session_started → waiting_user(skill_selection) */
async start(): Promise<RuntimeSession> {
this.availableSkills = await listSkills();
if (this.availableSkills.length === 0) {
throw new Error("skills/ 下没有找到任何 skill 文件novel/*.md 或 dialogue/*.md");
}
await this.dispatch({
type: "session_started",
payload: {
presetId: this.session.presetId,
flowId: this.session.flowId,
availableSkills: this.availableSkills,
},
});
return this.session;
}
/** 启动并预选总管,跳过 skill_selection */
async startWithOrchestrator(orchestratorId: string): Promise<RuntimeSession> {
await this.start();
if (this.session.waitingReason?.kind === "skill_selection") {
await this.selectSkill(orchestratorId);
}
return this.session;
}
/** 解析并加载 SKILL.md → 触发 skill_selected → waiting_user(input) */
async selectSkill(skillIdOrName: string): Promise<RuntimeSession> {
const dir = await resolveSkillId(skillIdOrName);
if (!dir) {
throw new Error(`未找到 skill: ${skillIdOrName}`);
}
const parsed = await loadSkill(dir);
await this.dispatch({
type: "skill_selected",
payload: { skill: toActiveSkillSnapshot(parsed) },
});
return this.session;
}
/** 统一事件入口applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
const result = applyEvent(this.session, event);
this.session = result.session;
if (
event.type === "user_submitted_input" ||
event.type === "user_confirmed_intake"
) {
this.syncSlotsToBlackboard(this.session);
}
if (result.error && event.type !== "runtime_failed") {
this.onMessage(result.error);
}
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
return result;
}
/** 将 slots 中的启动目标等同步为黑板 tag迁移期 tag 名可与旧 key 相同) */
private syncSlotsToBlackboard(session: RuntimeSession): void {
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (activeSkill?.startupTargetKey) {
const tag = activeSkill.startupTargetKey;
const content = session.slots[tag];
if (typeof content === "string" && content.trim()) {
this.blackboard.write({
tag,
content: content.trim(),
source: "user",
});
}
}
const workerReply = session.slots["用户.worker答复"];
if (typeof workerReply === "string" && workerReply.trim()) {
this.blackboard.write({
tag: "用户.worker答复",
content: workerReply.trim(),
source: "user",
});
}
const revision = session.slots.revisionInstruction;
if (typeof revision === "string" && revision.trim()) {
this.blackboard.write({
tag: "用户.修改说明",
content: revision.trim(),
source: "user",
});
}
}
/**
* 用户文本输入的统一入口。
* skill_selection 阶段会先解析为 selectSkill否则走 user_submitted_input。
*/
async submitInput(text: string): Promise<RuntimeSession> {
if (this.session.waitingReason?.kind === "skill_selection") {
const picked = await this.resolveSkillFromUserInput(text);
if (!picked) {
throw new Error(`无法识别 skill: ${text}。请输入 name 或列表编号。`);
}
return this.selectSkill(picked);
}
if (this.session.waitingReason?.kind === "intake") {
const skill = this.getActiveSkill();
const fields = skill?.intakeFields ?? [];
const current = readIntakeValues(this.session.slots);
const intakeValues = await extractIntakeFromMessage(
text,
fields,
current,
this.llm,
);
await this.dispatch({
type: "user_submitted_input",
payload: { text, intakeValues },
});
return this.session;
}
await this.dispatch({ type: "user_submitted_input", payload: { text } });
return this.session;
}
/** 必要项已填完:确认进入实例化 / 下一阶段 */
async confirmIntake(): Promise<RuntimeSession> {
if (this.session.waitingReason?.kind !== "intake") {
throw new Error("当前不在填空收集阶段");
}
await this.dispatch({ type: "user_confirmed_intake", payload: {} });
this.syncSlotsToBlackboard(this.session);
return this.session;
}
/** 支持按编号1-based或 skill name 解析 */
private async resolveSkillFromUserInput(text: string): Promise<string | null> {
const trimmed = text.trim();
const num = Number(trimmed);
if (Number.isInteger(num) && num >= 1 && num <= this.availableSkills.length) {
const entry = this.availableSkills[num - 1];
return resolveSkillId(entry.name);
}
return resolveSkillId(trimmed);
}
/** 提交总管决策(通常来自 Main Agent / 手动按钮) */
async submitDecision(decision: MainAgentDecision): Promise<RuntimeSession> {
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
return this.session;
}
/** 用户确认 pendingDecisionapprove_step */
async approve(): Promise<RuntimeSession> {
const id = this.session.pendingDecision?.id;
if (!id) throw new Error("当前没有待确认的决策");
await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } });
return this.session;
}
/** 用户拒绝 pendingDecision回到总管 */
async rejectStep(reason?: string): Promise<RuntimeSession> {
const id = this.session.pendingDecision?.id;
if (!id) throw new Error("当前没有待拒绝的决策");
await this.dispatch({
type: "user_rejected_next_step",
payload: { decisionId: id, reason },
});
return this.session;
}
/** 用户接受 pendingArtifact */
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId;
if (!id) throw new Error("当前没有待验收的产物");
await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } });
return this.session;
}
/** 用户拒绝 pendingArtifact进入 revision */
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId;
if (!id) throw new Error("当前没有待拒绝的产物");
await this.dispatch({
type: "user_rejected_artifact",
payload: { artifactId: id, reason },
});
return this.session;
}
/** worker 调用 ask_user 能力时,由外层触发此事件 */
async workerAsk(questions: string[]): Promise<RuntimeSession> {
const workerId = this.session.currentWorkerId;
if (!workerId) throw new Error("当前没有运行中的 worker");
await this.dispatch({
type: "worker_needs_input",
payload: { workerId, stepId: this.session.currentStepId, questions },
});
return this.session;
}
/**
* 手动标记 worker 完成:先创建 artifact 写入 session再 dispatch worker_completed。
* 真实 worker 集成时,产物内容应在此之前写入 slots / blackboard。
*/
async workerComplete(summary?: string): Promise<RuntimeSession> {
const workerId = this.session.currentWorkerId;
if (!workerId) throw new Error("当前没有运行中的 worker");
const artifact = createArtifact({
workerId,
stepId: this.session.currentStepId,
outputTags: ["output.草稿"],
summary: summary ?? `${workerId} 产物`,
});
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
await this.dispatch({
type: "worker_completed",
payload: { artifactId: artifact.id },
});
return this.session;
}
/** 非 autoStub 模式下,手动启动 pendingWorkerEffect */
async startPendingWorker(): Promise<RuntimeSession> {
const effect = this.pendingWorkerEffect;
if (!effect) throw new Error("没有待启动的 worker");
this.pendingWorkerEffect = null;
await this.runStubWorker(effect, false);
return this.session;
}
/** 消费 applyEvent 返回的 PhaseEffect 列表 */
private async processEffects(effects: PhaseEffect[]): Promise<void> {
for (const effect of effects) {
switch (effect.type) {
case "emit_message":
this.onMessage(effect.message);
break;
case "invoke_main_agent":
if (!this.mainAgent) {
this.onMessage(
"[阶段机] running等待总管决策。请用 /decide 提交 run_worker 或 finish。",
);
}
break;
case "run_worker":
if (this.autoStubWorker) {
await this.runStubWorker(effect);
} else if (this.llm) {
await this.runRealWorker(effect);
} else {
this.pendingWorkerEffect = effect;
this.onMessage(
`[阶段机] 待启动 worker: ${effect.workerId}。输入 /worker-start`,
);
}
break;
case "resume_worker": {
const ctx = this.session.resumeContext;
if (!ctx) break;
const workerEffect: Extract<PhaseEffect, { type: "run_worker" }> = {
type: "run_worker",
workerId: ctx.workerId,
};
if (this.autoStubWorker) {
await this.runStubWorker(workerEffect);
} else if (this.llm) {
await this.runRealWorker(workerEffect);
} else {
this.pendingWorkerEffect = workerEffect;
this.onMessage(`[阶段机] 恢复 worker: ${ctx.workerId}。输入 /worker-start`);
}
break;
}
case "run_programmatic_review":
await this.runProgrammaticReview(effect.artifactId);
break;
}
}
}
private async runRealWorker(
effect: Extract<PhaseEffect, { type: "run_worker" }>,
): Promise<void> {
const activeSkill = this.getActiveSkill();
if (!activeSkill?.name) {
throw new Error("当前没有 active skill无法运行 worker");
}
if (!this.llm) {
throw new Error("未配置 LLM无法运行 worker");
}
const workerId = resolveWorkerId(effect.workerId);
let slots = { ...this.session.slots };
this.syncSlotsToBlackboard(this.session);
if (effect.workerContext?.roleId?.trim()) {
const roleId = effect.workerContext.roleId.trim();
slots["世界.当前角色.id"] = roleId;
this.blackboard.write({
tag: "世界.当前角色.id",
content: roleId,
source: "runtime",
});
this.session = { ...this.session, slots };
}
this.session = (
await this.dispatch({
type: "worker_started",
payload: {
workerId: effect.workerId,
stepId: this.session.currentStepId,
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
},
})
).session;
const skill = await loadSkill(activeSkill.name);
const worker = await loadWorkerSkill(activeSkill.name, effect.workerId);
const workerLlm = resolveWorkerLlmProvider({
worker,
bindings: skill.workerLlmBindings,
slots,
fallbackLlm: this.llm,
});
this.onMessage(`[Worker] ${workerId} 执行中…`);
const result = await runWorkerSkill({
skillName: activeSkill.name,
workerId: effect.workerId,
slots,
blackboard: this.blackboard,
llm: workerLlm,
});
if (result.askUser?.length) {
await this.workerAsk(result.askUser);
return;
}
slots = { ...this.session.slots };
for (const [tag, content] of Object.entries(result.outputs)) {
this.blackboard.write({
tag,
content,
source: workerId,
});
slots[tag] = content;
}
this.session = { ...this.session, slots };
const artifact = createArtifact({
workerId: effect.workerId,
stepId: this.session.currentStepId,
outputTags: Object.keys(result.outputs),
summary: result.summary,
});
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
this.onMessage(
`[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`,
);
await this.dispatch({
type: "worker_completed",
payload: { artifactId: artifact.id },
});
}
/**
* 占位 workerworker_started → 可选自动 worker_completed。
* 演示与单测用,真实环境应替换为真实 worker 调度。
*/
private async runStubWorker(
effect: Extract<PhaseEffect, { type: "run_worker" }>,
autoComplete = true,
): Promise<void> {
this.session = (
await this.dispatch({
type: "worker_started",
payload: {
workerId: effect.workerId,
stepId: this.session.currentStepId,
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
},
})
).session;
this.onMessage(`[Worker 占位] ${effect.workerId} 已开始。`);
if (!autoComplete) return;
const lastInput = String(this.session.slots.lastUserInput ?? "");
const value = `[${effect.workerId} 占位输出]\n${lastInput ? `输入: ${lastInput}\n` : ""}说明: stub worker后续替换为真实 LLM worker。`;
const outputTags = await this.resolveWorkerOutputTags(effect.workerId);
const tagsToWrite = outputTags.length > 0 ? outputTags : ["output.草稿"];
for (const tag of tagsToWrite) {
this.blackboard.write({
tag,
content: value,
source: effect.workerId,
});
}
const artifact = createArtifact({
workerId: effect.workerId,
stepId: this.session.currentStepId,
outputTags: tagsToWrite,
summary: `${effect.workerId} 占位产物`,
});
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
await this.dispatch({
type: "worker_completed",
payload: { artifactId: artifact.id },
});
}
private async resolveWorkerOutputTags(workerId: string): Promise<string[]> {
const active = this.getActiveSkill();
if (!active?.name) return [];
try {
const worker = await loadWorkerSkill(active.name, workerId);
return worker.outputTags;
} catch {
return [];
}
}
/** 简易程序验收summary 非空则通过,否则 revision */
private async runProgrammaticReview(artifactId: string): Promise<void> {
await this.dispatch({ type: "programmatic_review_started", payload: { artifactId } });
const artifact = this.session.artifacts.find((a) => a.id === artifactId);
const passed = Boolean(artifact?.summary && artifact.summary.length > 0);
await this.dispatch(
passed
? { type: "programmatic_review_passed", payload: { artifactId } }
: {
type: "programmatic_review_failed",
payload: { artifactId, reason: "产物 summary 为空" },
},
);
}
/** invoke_main_agent 副作用running 时调用总管 LLM链式推进直到需用户介入 */
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
}
private async runMainAgent(): Promise<void> {
if (!this.mainAgent || this.session.phase !== "running") {
return;
}
const availableWorkers = await this.resolveAvailableWorkers();
const decision = await this.mainAgent.runToolLoop(
{
session: this.session,
blackboardIndex: this.blackboard.listTagIndex(),
availableWorkers,
},
{
handlers: {
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
listWorkers: () => availableWorkers,
listArtifacts: () =>
this.session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
onToolCall: (name, detail) => {
const preview =
detail.length > 120 ? `${detail.slice(0, 120)}` : detail;
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
},
},
},
);
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = applyEvent(this.session, {
type: "main_agent_decision_created",
payload: { decision },
});
this.session = result.session;
if (result.error) {
this.onMessage(result.error);
}
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
}
/** 总管 read_blackboard tool按 tag 或模式读取正文 */
private readBlackboardForAgent(tags: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const pattern of tags) {
const items = this.blackboard.queryByPatterns([pattern], "latest");
if (items.length === 0) {
const slotVal = this.session.slots[pattern];
if (typeof slotVal === "string" && slotVal.trim()) {
out[pattern] = slotVal.trim();
} else {
out[pattern] = "";
}
continue;
}
for (const item of items) {
out[item.tag] = item.content;
}
}
return out;
}
private async resolveAvailableWorkers(): Promise<
Array<{ id: string; description: string }>
> {
const active = this.getActiveSkill();
if (!active?.name) return [...DEFAULT_WORKERS];
try {
const workers = await listWorkerSkills(active.name);
if (workers.length === 0) return [...DEFAULT_WORKERS];
return workers.map((w) => ({ id: w.id, description: w.description }));
} catch {
return [...DEFAULT_WORKERS];
}
}
}
/** 构造总管决策,强制 statePatchAllowed=false */
export function createDecision(
partial: Omit<MainAgentDecision, "id" | "statePatchAllowed"> &
Partial<Pick<MainAgentDecision, "id">>,
): MainAgentDecision {
return {
id: partial.id ?? randomUUID(),
statePatchAllowed: false,
...partial,
};
}
/** CLI 调试:单行摘要当前会话状态 */
export function formatSession(session: RuntimeSession): string {
const active = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
const lines = [
`phase: ${session.phase}`,
session.waitingReason ? `waitingReason: ${session.waitingReason.kind}` : null,
active ? `skill: ${active.name}` : null,
session.slots.startupCompleted ? "startup: done" : null,
session.slots["book.brief"] ? "book.brief: yes" : null,
session.currentWorkerId ? `worker: ${session.currentWorkerId}` : null,
`artifacts: ${session.artifacts.length}`,
`events: ${session.history.length}`,
];
return lines.filter(Boolean).join("\n");
}
/** 集成测试用:从 start 到 finish 的最小闭环脚本 */
export async function runMinimalClosedLoop(
runtime: PhaseRuntime,
): Promise<RuntimeSession> {
await runtime.start();
await runtime.selectSkill("basic");
await runtime.submitInput("科幻长篇,第三人称,约 20 万字");
await runtime.confirmIntake();
const decision = createDecision({
action: "run_worker",
reason: "生成大纲",
workerId: "outline-worker",
requiresApproval: true,
});
await runtime.submitDecision(decision);
await runtime.approve();
if (runtime.getSession().currentWorkerId) {
await runtime.workerComplete();
}
await runtime.acceptArtifact();
await runtime.submitDecision(
createDecision({ action: "finish", reason: "完成", requiresApproval: false }),
);
return runtime.getSession();
}

View File

@@ -0,0 +1,8 @@
/** @deprecated 请使用 phase-machine.ts */
export {
applyEvent,
canApplyEvent,
createArtifact,
createSession,
getAllowedEvents,
} from "./phase-machine.js";

View File

@@ -0,0 +1,119 @@
import { randomUUID } from "node:crypto";
import type { ParsedToolCall } from "../llm/client.js";
import type { MainAgentDecision } from "../types/runtime.js";
import {
isMainAgentLoopTool,
isMainAgentTerminalTool,
type MainAgentToolName,
} from "../types/tools.js";
export class ToolValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolValidationError";
}
}
function parseArgs(raw: string): Record<string, unknown> {
try {
const parsed = JSON.parse(raw || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new ToolValidationError("Tool arguments must be a JSON object");
}
return parsed as Record<string, unknown>;
} catch (e) {
if (e instanceof ToolValidationError) throw e;
throw new ToolValidationError(`Invalid tool arguments JSON: ${raw.slice(0, 120)}`);
}
}
function requireString(
args: Record<string, unknown>,
key: string,
): string {
const val = args[key];
if (typeof val !== "string" || !val.trim()) {
throw new ToolValidationError(`Tool parameter "${key}" must be a non-empty string`);
}
return val.trim();
}
function optionalString(
args: Record<string, unknown>,
key: string,
): string | undefined {
const val = args[key];
if (typeof val !== "string") return undefined;
const trimmed = val.trim();
return trimmed || undefined;
}
/** 将终止 tool call 转为 MainAgentDecision */
export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision {
const name = call.name;
if (!isMainAgentTerminalTool(name)) {
throw new ToolValidationError(`Not a terminal tool: ${name}`);
}
const args = parseArgs(call.arguments);
switch (name as MainAgentToolName) {
case "ask_user": {
const reason = requireString(args, "reason");
const message = optionalString(args, "message");
return {
id: randomUUID(),
action: "ask_user",
reason: message ? `${reason}\n${message}` : reason,
requiresApproval: false,
statePatchAllowed: false,
};
}
case "run_worker": {
const workerId = requireString(args, "workerId");
const reason = requireString(args, "reason");
const requiresApproval = Boolean(args.requiresApproval);
const roleId = optionalString(args, "roleId");
return {
id: randomUUID(),
action: "run_worker",
reason,
workerId,
workerContext: roleId ? { roleId } : undefined,
requiresApproval,
statePatchAllowed: false,
};
}
case "review_blackboard": {
const reason = requireString(args, "reason");
const summary = requireString(args, "summary");
return {
id: randomUUID(),
action: "review_blackboard",
reason: `${reason}\n${summary}`,
requiresApproval: false,
statePatchAllowed: false,
};
}
case "finish": {
const reason = requireString(args, "reason");
return {
id: randomUUID(),
action: "finish",
reason,
requiresApproval: false,
statePatchAllowed: false,
};
}
default:
throw new ToolValidationError(`Unknown terminal tool: ${name}`);
}
}
export function validateLoopToolCall(call: ParsedToolCall): MainAgentToolName {
if (!isMainAgentLoopTool(call.name)) {
throw new ToolValidationError(`Unknown loop tool: ${call.name}`);
}
parseArgs(call.arguments);
return call.name;
}

362
src/server/agent-view.ts Normal file
View File

@@ -0,0 +1,362 @@
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
import type { IntakeProgress } from "../types/intake.js";
import {
buildSkillCatalog,
inferLifecycleStage,
canEnterPlay,
TOOL_LOOP_BURST_MAX,
type LifecycleStage,
type SkillCatalogEntry,
} from "./skill-catalog.js";
export type AgentMessageKind =
| "user_input"
| "orchestrator_decision"
| "orchestrator_prompt"
| "agent_tool"
| "worker_running"
| "worker_output"
| "worker_questions"
| "worker_stub"
| "system_info"
| "error";
export type SessionFocus = {
actorType: "orchestrator" | "worker" | "user" | "idle";
actorId?: string;
actorLabel: string;
action: string;
detail?: string;
};
export type EnrichedMessage = {
kind: AgentMessageKind;
actor?: string;
title: string;
body: string;
text: string;
};
export type ToolTraceEntry = {
at: string;
name: string;
summary: string;
};
export type BurstState = {
count: number;
max: number;
};
/** @deprecated 用 skillCatalog + lifecycleStage */
export type StageStep = {
id: string;
label: string;
status: "pending" | "active" | "done";
};
const WORKER_QUESTION_FALLBACK =
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。";
function formatWorkerQuestionBody(raw: string | undefined): string {
const lines = (raw ?? "")
.split("\n")
.map((line) => line.replace(/^\s*[-*•]\s*/, "").trim())
.filter((line) => line.length > 0 && !/^askUser$/i.test(line));
if (lines.length === 0) return WORKER_QUESTION_FALLBACK;
return lines.map((line) => `- ${line}`).join("\n");
}
export function classifyAgentMessage(text: string): EnrichedMessage {
const trimmed = text.trim();
const agentTool = trimmed.match(/^\[总管 tool\]\s*([^\s:]+)(?::\s*([\s\S]*))?$/);
if (agentTool) {
const name = agentTool[1];
const detail = agentTool[2]?.trim() ?? "";
return {
kind: "agent_tool",
actor: "orchestrator",
title: `Tool · ${name}`,
body: detail || "(无输出)",
text: trimmed,
};
}
const orchestrator = trimmed.match(/^\[总管\]\s*(\w+):\s*([\s\S]+)$/);
if (orchestrator) {
return {
kind: "orchestrator_decision",
actor: "orchestrator",
title: `Agent · ${orchestrator[1]}`,
body: orchestrator[2].trim(),
text: trimmed,
};
}
const workerRunning = trimmed.match(/^\[Worker\]\s*(\S+)\s*执行中/);
if (workerRunning) {
return {
kind: "worker_running",
actor: workerRunning[1],
title: `Worker · ${workerRunning[1]}`,
body: "正在调用模型执行 SKILL…",
text: trimmed,
};
}
const workerDone = trimmed.match(/^\[Worker\]\s*(\S+)\s*已完成\s*\n?\n?([\s\S]*)$/);
if (workerDone) {
return {
kind: "worker_output",
actor: workerDone[1],
title: `Worker · ${workerDone[1]} 产出`,
body: workerDone[2]?.trim() || "(无正文)",
text: trimmed,
};
}
if (trimmed.startsWith("[Worker 占位]")) {
const stub = trimmed.match(/^\[Worker 占位\]\s*(\S+)/);
return {
kind: "worker_stub",
actor: stub?.[1],
title: `占位 Worker · ${stub?.[1] ?? "?"}`,
body: trimmed,
text: trimmed,
};
}
const workerAskTagged = trimmed.match(
/^\[Worker\]\s*(\S+)\s*提问[:]\s*\n?([\s\S]*)$/,
);
if (workerAskTagged) {
const body = formatWorkerQuestionBody(workerAskTagged[2]);
return {
kind: "worker_questions",
actor: workerAskTagged[1],
title: `Worker · ${workerAskTagged[1]} 提问`,
body,
text: trimmed,
};
}
const workerQuestions = trimmed.match(/^Worker 提问[:]\s*\n?([\s\S]*)$/);
if (workerQuestions) {
const body = formatWorkerQuestionBody(workerQuestions[1]);
return {
kind: "worker_questions",
title: "Worker 需要你补充",
body,
text: trimmed,
};
}
if (trimmed.startsWith("[请求失败]")) {
return {
kind: "error",
title: "请求失败",
body: trimmed.replace(/^\[请求失败\]\s*/, ""),
text: trimmed,
};
}
if (trimmed.startsWith("[阶段机]")) {
return {
kind: "system_info",
title: "阶段机",
body: trimmed.replace(/^\[阶段机\]\s*/, ""),
text: trimmed,
};
}
if (
trimmed.includes("请告诉我") ||
trimmed.includes("启动询问") ||
/^你选择了/.test(trimmed)
) {
return {
kind: "orchestrator_prompt",
actor: "orchestrator",
title: "Agent · 启动询问",
body: trimmed,
text: trimmed,
};
}
return {
kind: "system_info",
title: "系统",
body: trimmed,
text: trimmed,
};
}
/** @deprecated 保留兼容;返回空数组 */
export function buildPipeline(_session: RuntimeSession): StageStep[] {
return [];
}
export function buildToolTrace(
messages: Array<{ kind?: AgentMessageKind; text: string; createdAt: string }>,
): ToolTraceEntry[] {
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].kind === "user_input") {
lastUserIdx = i;
break;
}
}
const slice = lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages;
return slice
.filter((m) => m.kind === "agent_tool")
.map((m) => {
const match = m.text.match(/^\[总管 tool\]\s*([^\s:]+)/);
return {
at: m.createdAt,
name: match?.[1] ?? "tool",
summary: m.text.replace(/^\[总管 tool\]\s*\S+:?\s*/, "").slice(0, 200),
};
});
}
export function buildBurstState(
messages: Array<{ kind?: AgentMessageKind }>,
session: RuntimeSession,
): BurstState {
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].kind === "user_input") {
lastUserIdx = i;
break;
}
}
const slice = lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages;
const toolCount = slice.filter(
(m) =>
m.kind === "agent_tool" ||
m.kind === "orchestrator_decision",
).length;
const stored =
typeof session.slots.toolLoopBurstCount === "number"
? session.slots.toolLoopBurstCount
: toolCount;
return {
count: Math.max(stored, toolCount),
max: TOOL_LOOP_BURST_MAX,
};
}
export function buildFocus(
session: RuntimeSession,
reason?: WaitingReason,
intake?: IntakeProgress,
lifecycle?: LifecycleStage,
): SessionFocus {
const stage = lifecycle ?? inferLifecycleStage(session);
if (session.phase === "done") {
return {
actorType: "idle",
actorLabel: "流程",
action: "已完成",
detail: stage === "design" ? "设计阶段结束" : "游玩会话结束",
};
}
if (reason?.kind === "intake") {
const detail =
intake && intake.requiredTotal > 0
? `必要项 ${intake.requiredFilled}/${intake.requiredTotal}`
: "完成必要项后可进入实例化";
return {
actorType: "user",
actorLabel: "你",
action: "填写创作信息",
detail,
};
}
if (reason?.kind === "input") {
return {
actorType: "user",
actorLabel: "你",
action: "补充说明",
detail: reason.message?.slice(0, 120),
};
}
if (reason?.kind === "approve_step") {
const worker = session.pendingDecision?.workerId ?? "skill";
return {
actorType: "orchestrator",
actorId: "orchestrator",
actorLabel: "Agent",
action: `建议 invoke ${worker}`,
detail: session.pendingDecision?.reason,
};
}
if (reason?.kind === "worker_questions") {
const q = reason.questions?.filter((s) => s?.trim()).join("") ?? "";
return {
actorType: "user",
actorId: reason.workerId,
actorLabel: "你",
action: `回答 · ${reason.workerId}`,
detail: q.slice(0, 200) || "请在下框补充",
};
}
if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
return {
actorType: "user",
actorLabel: "你",
action: "验收产物",
detail: art?.summary ?? art?.workerId,
};
}
if (session.currentWorkerId) {
return {
actorType: "worker",
actorId: session.currentWorkerId,
actorLabel: `Skill · ${session.currentWorkerId}`,
action: "执行中",
detail: "模型按 SKILL 产出…",
};
}
if (session.phase === "running" && !reason) {
return {
actorType: "orchestrator",
actorId: "orchestrator",
actorLabel: "Agent",
action: stage === "design" ? "设计 burst" : "游玩 burst",
detail: "tool loop读黑板 → 选 skill",
};
}
if (reason?.kind === "skill_selection") {
return {
actorType: "user",
actorLabel: "你",
action: "选择 Skill 包",
};
}
return {
actorType: "idle",
actorLabel: "系统",
action: "待命",
};
}
export {
inferLifecycleStage,
canEnterPlay,
buildSkillCatalog,
type LifecycleStage,
type SkillCatalogEntry,
};

190
src/server/book-handlers.ts Normal file
View File

@@ -0,0 +1,190 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { listSkills } from "../skills/loader.js";
import { createBook, deleteBook, getBook, listBooks, updateBook } from "../book/store.js";
import { sessionManager } from "./session-manager.js";
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
function json(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
export async function handleBooksApi(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
): Promise<boolean> {
if (pathname === "/api/skills" && req.method === "GET") {
const skills = await listSkills();
json(res, 200, {
skills: skills.map((s) => ({
id: s.name,
name: s.name,
description: s.description,
category: s.category,
bookKind: s.bookKind,
})),
});
return true;
}
if (pathname === "/api/books" && req.method === "GET") {
json(res, 200, { books: listBooks() });
return true;
}
if (pathname === "/api/books" && req.method === "POST") {
const body = JSON.parse(await readBody(req)) as { title?: string };
const book = createBook({ title: body.title });
const session = await sessionManager.createForBook(book.id);
json(res, 201, { book, session });
return true;
}
const savesListMatch = pathname.match(/^\/api\/books\/([^/]+)\/saves$/);
if (savesListMatch) {
const bookId = decodeURIComponent(savesListMatch[1]);
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
if (req.method === "GET") {
try {
const saves = sessionManager.listGameSnapshots(bookId);
json(res, 200, { saves });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "读取存档失败",
});
}
return true;
}
if (req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
label?: string;
note?: string;
sessionId?: string;
kind?: "instance" | "run";
};
const sessionId =
body.sessionId?.trim() ||
sessionManager.getActiveSessionForBook(bookId)?.id;
if (!sessionId) {
json(res, 400, { error: "当前作品没有活跃会话,无法存档" });
return true;
}
const kind = body.kind === "instance" ? "instance" : "run";
try {
const save = sessionManager.saveGameSnapshot(
sessionId,
body.label ?? "",
kind,
body.note,
);
json(res, 201, { save });
} catch (err) {

View File

@@ -0,0 +1,829 @@
import { randomUUID } from "node:crypto";
import { appendBookSession, getBook, updateBook } from "../book/store.js";
import { loadBookSession, saveBookSession } from "../book/session-store.js";
import {
deleteRunSnapshot as deleteRunSnapshotFile,
listRunSnapshots,
loadRunSnapshot as loadRunSnapshotFile,
saveRunSnapshot as saveRunSnapshotFile,
} from "../book/run-snapshot-store.js";
import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js";
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js";
import { createSession } from "../runtime/phase-machine.js";
import {
createDefaultMainAgentLlm,
hasRealLlmConfig,
reloadDefaultMainAgentLlm,
type LlmTrackingRef,
} from "../runtime/llm-factory.js";
import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js";
import type { PersistedBookSession } from "../types/book-session.js";
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
import type { IntakeProgress } from "../types/intake.js";
import {
buildFocus,
buildPipeline,
buildToolTrace,
buildBurstState,
buildSkillCatalog,
inferLifecycleStage,
canEnterPlay,
classifyAgentMessage,
type AgentMessageKind,
type SessionFocus,
type StageStep,
type LifecycleStage,
type SkillCatalogEntry,
type ToolTraceEntry,
type BurstState,
} from "./agent-view.js";
import {
buildIntakeProgress,
readIntakeValues,
} from "../intake/intake.js";
import {
bookSkillPackId,
persistedSkillPackId,
runSnapshotSkillPackId,
sessionSkillPackId,
skillPacksMatch,
} from "../book/skill-id.js";
import type { ActiveSkillSnapshot } from "../types/runtime.js";
export type ChatMessage = {
id: string;
role: "system" | "user";
text: string;
createdAt: string;
kind?: AgentMessageKind;
actor?: string;
title?: string;
body?: string;
/** LLM 思维链 / reasoning_content */
thinking?: string;
tokenUsage?: MessageTokenUsage;
};
export type SessionView = {
id: string;
bookId?: string;
bookTitle?: string;
activeSkill?: string;
phase: RuntimeSession["phase"];
waitingReason?: WaitingReason;
startupCompleted: boolean;
skills: Array<{ name: string; description: string; category: string }>;
messages: ChatMessage[];
hints: string[];
actions: SessionAction[];
/** @deprecated 用 skillCatalog */
pipeline: StageStep[];
lifecycleStage: LifecycleStage;
playReady: boolean;
skillCatalog: SkillCatalogEntry[];
toolTrace: ToolTraceEntry[];
burst: BurstState;
focus: SessionFocus;
/** 启动填空进度waitingReason.kind === intake 时有值) */
intake?: IntakeProgress;
intakePrompt?: string;
resumed?: boolean;
tokenStats?: {
sessionTotal: number;
sessionCached?: number;
sessionCacheMiss?: number;
lastCaller?: string;
lastTotal?: number;
byCaller?: Record<
string,
{ totalTokens: number; cachedTokens: number; cacheMissTokens: number; calls: number }
>;
};
};
export type SessionAction =
| { type: "send_message"; label: string; placeholder: string }
| { type: "confirm_intake"; label: string }
| { type: "approve"; label: string }
| { type: "accept"; label: string }
| { type: "reject"; label: string }
| { type: "run_outline"; label: string }
| { type: "finish"; label: string };
type ManagedSession = {
runtime: PhaseRuntime;
messages: ChatMessage[];
bookId?: string;
trackingRef: LlmTrackingRef;
};
export class SessionManager {
private readonly sessions = new Map<string, ManagedSession>();
/** bookId → 当前内存中的 sessionId */
private readonly activeBookSessions = new Map<string, string>();
async create(): Promise<SessionView> {
return this.createForBook();
}
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView {
const s = this.require(sessionId);
const session = s.runtime.getSession();
if (stage === "play" && !canEnterPlay(session)) {
throw new Error("实例尚未就绪,无法进入游玩");
}
s.runtime.setLifecycleStage(stage);
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/**
* 打开 Book优先恢复磁盘快照无快照则新建 Session。
* 若该 Book 已在内存中,直接返回当前视图。
*/
async openBook(bookId: string): Promise<SessionView> {
const book = getBook(bookId);
if (!book) throw new Error("Book 不存在");
const inMemory = this.getActiveSessionForBook(bookId);
if (inMemory) return inMemory;
const snapshot = loadBookSession(bookId);
if (snapshot && skillPacksMatch(book, persistedSkillPackId(snapshot))) {
return this.restoreFromSnapshot(snapshot);
}
const legacyPreselect = bookSkillPackId(book);
return this.createForBook(bookId, legacyPreselect);
}
async createForBook(
bookId?: string,
preselectSkillId?: string,
): Promise<SessionView> {
const id = randomUUID();
const messages: ChatMessage[] = [];
const book = bookId ? getBook(bookId) : null;
const initialSession = createSession("default");
initialSession.id = id;
const trackingRef: LlmTrackingRef = {
current: {
sessionId: id,
bookId,
bookTitle: book?.title,
orchestratorId: preselectSkillId ?? (book ? bookSkillPackId(book) : undefined),
},
};
const managed: ManagedSession = {
runtime: null as unknown as PhaseRuntime,
messages,
bookId,
trackingRef,
};
const onMessage = this.buildOnMessageHandler(() => managed);
const llm = createDefaultMainAgentLlm(trackingRef);
const runtime = new PhaseRuntime({
autoStubWorker: !hasRealLlmConfig(),
llm,
onMessage,
initialSession,
});
managed.runtime = runtime;
if (preselectSkillId) {
await runtime.startWithOrchestrator(preselectSkillId);
} else {
await runtime.start();
}
this.sessions.set(id, managed);
if (bookId) {
this.activeBookSessions.set(bookId, id);
appendBookSession(bookId, id);
}
return this.toView(id);
}
get(id: string): SessionView | null {
if (!this.sessions.has(id)) return null;
return this.toView(id);
}
getActiveSessionForBook(bookId: string): SessionView | null {
const sessionId = this.activeBookSessions.get(bookId);
if (!sessionId) return null;
return this.get(sessionId);
}
/**
* 保存快照。
* - instance实例化后的对象情境/规则/角色设定等),不含 run 轮次状态
* - run运行存档完整进度
*/
saveGameSnapshot(
sessionId: string,
label: string,
kind: SnapshotKind = "run",
note?: string,
): RunSnapshotMeta {
const s = this.require(sessionId);
if (!s.bookId) throw new Error("仅绑定作品时可存档");
const book = getBook(s.bookId);
if (!book) throw new Error("Book 不存在");
const trimmed = label.trim();
if (!trimmed) throw new Error("请输入存档名称");
let runtimeSession = structuredClone(s.runtime.getSession());
let blackboardItems = s.runtime.getBlackboard().exportItems();
if (kind === "instance") {
if (!runtimeSession.slots.startupCompleted) {
throw new Error("实例尚未完成(需先完成启动与 setup 验收),无法保存实例快照");
}
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
}
const skillPackId =
sessionSkillPackId(runtimeSession) ?? bookSkillPackId(book) ?? "";
const snapshot: RunSnapshot = {
version: 1,
id: randomUUID(),
bookId: s.bookId,
label: trimmed,
kind,
orchestratorId: skillPackId,
runtimeSession,
blackboardItems,
messages: s.messages.map((m) => ({ ...m })),
createdAt: new Date().toISOString(),
note: note?.trim() || undefined,
};
saveRunSnapshotFile(snapshot);
return toRunSnapshotMeta(snapshot);
}
/** 列出某 Book 的全部存档 */
listGameSnapshots(bookId: string): RunSnapshotMeta[] {
if (!getBook(bookId)) throw new Error("Book 不存在");
return listRunSnapshots(bookId);
}
/** 从存档读档:替换当前作品进度,可继续创作 */
async loadGameSnapshot(bookId: string, snapshotId: string): Promise<SessionView> {
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
if (!snapshot) throw new Error("存档不存在");
const book = getBook(bookId);
if (!book) throw new Error("Book 不存在");
if (!skillPacksMatch(book, runSnapshotSkillPackId(snapshot))) {
throw new Error("存档与当前作品 skill 包不匹配,无法读档");
}
this.dropBookSessions(bookId);
const newSessionId = randomUUID();
const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
let runtimeSession = structuredClone(snapshot.runtimeSession);
let blackboardItems = snapshot.blackboardItems;
if (snapshot.kind === "instance") {
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
}
runtimeSession.id = newSessionId;
const resumeHint =
snapshot.kind === "instance"
? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。`
: `已从存档「${snapshot.label}」读档,可继续创作。`;
return this.mountRestoredSession({
sessionId: newSessionId,
bookId,
skillPackId: runSnapshotSkillPackId(snapshot) ?? "",
runtimeSession,
blackboardItems,
messages,
resumeHint,
});
}
/** 删除单个存档 */
deleteGameSnapshot(bookId: string, snapshotId: string): void {
if (!getBook(bookId)) throw new Error("Book 不存在");
if (!deleteRunSnapshotFile(bookId, snapshotId)) {
throw new Error("存档不存在");
}
}
/** 删除 Book 时清理内存中的会话 */
dropBookSessions(bookId: string): void {
for (const [sessionId, managed] of this.sessions) {
if (managed.bookId === bookId) {
this.sessions.delete(sessionId);
}
}
this.activeBookSessions.delete(bookId);
}
/** 将当前 API / 预设设置应用到所有活跃会话 */
reloadAllLlms(): number {
let count = 0;
for (const s of this.sessions.values()) {
const llm = reloadDefaultMainAgentLlm(s.trackingRef);
s.runtime.reloadLlm(llm, !hasRealLlmConfig());
count += 1;
}
return count;
}
async sendMessage(id: string, text: string): Promise<SessionView> {
const s = this.require(id);
s.messages.push(this.msg("user", text));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
const reason = s.runtime.getSession().waitingReason;
try {
if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(text);
} else if (reason?.kind === "review_artifact") {
await s.runtime.rejectArtifact(text);
} else {
await s.runtime.submitInput(text);
}
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[session] sendMessage failed:", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
return this.toView(id);
}
async approve(id: string): Promise<SessionView> {
const s = this.require(id);
try {
await s.runtime.approve();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[session] approve failed:", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
async confirmIntake(id: string): Promise<SessionView> {
const s = this.require(id);
try {
await s.runtime.confirmIntake();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[session] confirmIntake failed:", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
return this.toView(id);
}
async accept(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.acceptArtifact();
return this.toView(id);
}
/** 拒绝当前待确认步骤或待验收产物(无说明时触发重新来 / 回到总管) */
async reject(id: string, reason?: string): Promise<SessionView> {
const s = this.require(id);
const waiting = s.runtime.getSession().waitingReason;
try {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
throw new Error("当前没有可拒绝的确认或验收");
}
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[session] reject failed:", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
return this.toView(id);
}
async runOutline(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "根据创作简报生成大纲",
workerId: "outline",
requiresApproval: true,
}),
);
return this.toView(id);
}
async finish(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.submitDecision(
createDecision({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
);
return this.toView(id);
}
private async restoreFromSnapshot(
snapshot: PersistedBookSession,
): Promise<SessionView> {
return this.mountRestoredSession({
sessionId: snapshot.sessionId,
bookId: snapshot.bookId,
skillPackId: persistedSkillPackId(snapshot) ?? "",
runtimeSession: snapshot.runtimeSession,
blackboardItems: snapshot.blackboardItems,
messages: snapshot.messages.map((m) => ({ ...m })) as ChatMessage[],
resumeHint: "已从上次进度恢复,可继续创作。",
});
}
private async mountRestoredSession(params: {
sessionId: string;
bookId: string;
skillPackId: string;
runtimeSession: RuntimeSession;
blackboardItems: import("../types/blackboard.js").BlackboardItem[];
messages: ChatMessage[];
resumeHint: string;
}): Promise<SessionView> {
const book = getBook(params.bookId);
const trackingRef: LlmTrackingRef = {
current: {
sessionId: params.sessionId,
bookId: params.bookId,
bookTitle: book?.title,
orchestratorId: params.skillPackId || undefined,
},
};
let managedRef: ManagedSession | null = null;
const onMessage = this.buildOnMessageHandler(() => managedRef);
const runtime = new PhaseRuntime({
autoStubWorker: !hasRealLlmConfig(),
llm: createDefaultMainAgentLlm(trackingRef),
onMessage,
initialSession: params.runtimeSession,
initialBlackboardItems: params.blackboardItems,
});
await runtime.ensureAvailableSkills();
managedRef = {
runtime,
messages: params.messages,
bookId: params.bookId,
trackingRef,
};
this.sessions.set(params.sessionId, managedRef);
this.activeBookSessions.set(params.bookId, params.sessionId);
appendBookSession(params.bookId, params.sessionId);
this.persist(managedRef);
return this.toView(params.sessionId, true, params.resumeHint);
}
private buildOnMessageHandler(getManaged: () => ManagedSession | null) {
return (text: string) => {
const managed = getManaged();
if (!managed) return;
const usage = managed.trackingRef.current.pendingUsage;
const thinking = managed.trackingRef.current.pendingReasoning;
managed.trackingRef.current.pendingUsage = undefined;
managed.trackingRef.current.pendingReasoning = undefined;
managed.messages.push(this.msg("system", text, usage, thinking));
if (managed.bookId) {
this.syncBookPreview(managed.bookId, managed.messages);
this.persist(managed);
}
};
}
private persist(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
if (!book) return;
const session = s.runtime.getSession();
const skillPackId =
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined) ?? "";
const snapshot: PersistedBookSession = {
version: 1,
sessionId: session.id,
bookId: s.bookId,
orchestratorId: skillPackId || undefined,
runtimeSession: session,
blackboardItems: s.runtime.getBlackboard().exportItems(),
messages: s.messages,
savedAt: new Date().toISOString(),
};
saveBookSession(snapshot);
this.syncBookSkill(s.bookId, session);
updateBook(s.bookId, { activeSessionId: session.id });
}
private syncBookSkill(bookId: string, session: RuntimeSession): void {
const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (!snap?.name) return;
try {
updateBook(bookId, {
activeSkillId: snap.name,
activeSkillName: snap.name,
});
} catch {
/* book may have been deleted */
}
}
private syncBookPreview(bookId: string, messages: ChatMessage[]): void {
const preview = this.previewFromMessages(messages);
try {
updateBook(bookId, { preview });
} catch {
/* book may have been deleted */
}
}
private previewFromMessages(messages: ChatMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const t = messages[i].text?.trim();
if (t) return t.slice(0, 80);
}
return "等待开始…";
}
private require(id: string): ManagedSession {
const s = this.sessions.get(id);
if (!s) throw new Error("会话不存在");
return s;
}
private enrichDisplayMessages(
session: RuntimeSession,
messages: ChatMessage[],
): ChatMessage[] {
const reason = session.waitingReason;
if (reason?.kind === "intake" && !session.slots.startupCompleted) {
const prompt = reason.prompt?.trim();
if (!prompt) return messages;
const needle = prompt.slice(0, 48);
const hasStartup = messages.some(
(m) =>
m.kind === "orchestrator_prompt" ||
(typeof m.text === "string" && m.text.includes(needle)),
);
if (hasStartup) return messages;
return [...messages, this.msg("system", prompt)];
}
if (reason?.kind !== "input" || session.slots.startupCompleted) {
return messages;
}
const prompt = reason.message?.trim();
if (!prompt) return messages;
const needle = prompt.slice(0, 48);
const hasStartup = messages.some(
(m) =>
m.kind === "orchestrator_prompt" ||
(typeof m.text === "string" && m.text.includes(needle)),
);
if (hasStartup) return messages;
return [...messages, this.msg("system", prompt)];
}
private msg(
role: ChatMessage["role"],
text: string,
tokenUsage?: MessageTokenUsage,
thinking?: string,
): ChatMessage {
const base = {
id: randomUUID(),
role,
text,
createdAt: new Date().toISOString(),
...(tokenUsage ? { tokenUsage } : {}),
...(thinking ? { thinking } : {}),
};
if (role === "user") {
return {
...base,
kind: "user_input",
title: "你的输入",
body: text,
};
}
const classified = classifyAgentMessage(text);
return { ...base, ...classified };
}
private toView(id: string, resumed = false, resumeHint?: string): SessionView {
const s = this.require(id);
const session = s.runtime.getSession();
const reason = session.waitingReason;
const book = s.bookId ? getBook(s.bookId) : null;
const hints: string[] = [];
const actions: SessionAction[] = [];
let intake: IntakeProgress | undefined;
let intakePrompt: string | undefined;
const activeSnap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (resumeHint) {
hints.push(resumeHint);
} else if (resumed) {
hints.push("已从上次进度恢复,可继续创作。");
}
if (reason?.kind === "intake" && activeSnap?.intakeFields?.length) {
intake = buildIntakeProgress(
activeSnap.intakeFields,
readIntakeValues(session.slots),
);
intakePrompt = reason.prompt;
if (intake.ready) {
hints.push("必要项已齐,可确认进入实例化;也可继续补充可选项。");
actions.push({
type: "confirm_intake",
label: "确认,进入实例化",
});
actions.push({
type: "send_message",
label: "继续补充",
placeholder: "补充可选项或修正已填内容…",
});
} else {
hints.push(
`填写必要项(${intake.requiredFilled}/${intake.requiredTotal})后可确认进入实例化`,
);
actions.push({
type: "send_message",
label: "发送",
placeholder: "按上方说明补充信息,可一次说多项…",
});
}
} else if (reason?.kind === "skill_selection") {
hints.push("请选择创作类型(输入 skill 名称或编号)");
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如basic 或 1",
});
} else if (reason?.kind === "intake") {
hints.push(reason.prompt?.trim() ?? "请按填空项补充创作信息");
actions.push({
type: "send_message",
label: "发送",
placeholder: "描述你想写什么…",
});
} else if (reason?.kind === "input") {
if (reason.message?.trim()) {
hints.push(reason.message.trim());
} else {
hints.push("请回答启动问题,或补充创作目标");
}
actions.push({
type: "send_message",
label: "发送",
placeholder: "描述你想写什么…",
});
} else if (reason?.kind === "worker_questions") {
const qs = reason.questions.filter((q) => q?.trim());
if (qs.length) {
hints.push(`Worker · ${reason.workerId} 提问:${qs.join(" ")}`);
} else {
hints.push(`Worker · ${reason.workerId} 需要更多信息,请补充说明`);
}
const placeholder =
qs[0]?.slice(0, 120) ?? "回答 Worker 的问题,或补充情境与参数…";
actions.push({
type: "send_message",
label: "发送",
placeholder,
});
} else if (reason?.kind === "approve_step") {
actions.push({ type: "approve", label: "确认执行" });
actions.push({ type: "reject", label: "暂不执行" });
} else if (reason?.kind === "review_artifact") {
actions.push({ type: "accept", label: "接受产物" });
actions.push({ type: "reject", label: "不接受,重新来" });
} else if (
session.phase === "running" &&
s.runtime.needsMainAgentDecision() &&
!s.runtime.hasMainAgent()
) {
hints.push("简报已就绪,可手动生成大纲(未配置 Agent LLM");
actions.push({ type: "run_outline", label: "生成大纲" });
actions.push({
type: "send_message",
label: "发送",
placeholder: "补充说明…",
});
} else if (session.phase === "running" && s.runtime.needsMainAgentDecision()) {
hints.push("Agent 正在调度…");
} else if (session.phase === "running" && session.pendingArtifactId) {
/* worker 运行中 */
} else if (
session.phase === "running" &&
session.slots.startupCompleted &&
!session.pendingDecision
) {
actions.push({ type: "finish", label: "结束流程" });
} else if (session.phase === "done") {
hints.push("流程已完成");
} else {
actions.push({
type: "send_message",
label: "发送",
placeholder: "输入消息…",
});
}
if (s.bookId) {
this.persist(s);
}
const tokens = getSessionTokenTotals(id);
const messages = this.enrichDisplayMessages(session, [...s.messages]);
const lifecycleStage = inferLifecycleStage(session);
const skillPackId =
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
const skillCatalog = buildSkillCatalog(
session,
skillPackId,
lifecycleStage,
);
return {
id,
bookId: s.bookId,
bookTitle: book?.title,
activeSkill: s.runtime.getActiveSkill()?.name ?? skillPackId,
phase: session.phase,
waitingReason: reason,
startupCompleted: Boolean(session.slots.startupCompleted),
skills: s.runtime.getAvailableSkills(),
messages,
hints,
actions,
pipeline: buildPipeline(session),
lifecycleStage,
playReady: canEnterPlay(session),
skillCatalog,
toolTrace: buildToolTrace(messages),
burst: buildBurstState(messages, session),
focus: buildFocus(session, reason, intake, lifecycleStage),
intake,
intakePrompt,
resumed: resumed || undefined,
tokenStats: {
sessionTotal: tokens.totalTokens,
sessionCached: tokens.totalCached || undefined,
sessionCacheMiss: tokens.totalCacheMiss || undefined,
lastCaller: tokens.last?.caller,
lastTotal: tokens.last?.totalTokens,
byCaller: tokens.byCaller,
},
};
}
}
export const sessionManager = new SessionManager();
function formatRuntimeError(detail: string): string {
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
return (
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL" +
"或点击「测试连接」验证。"
);
}
if (detail.startsWith("LLM request failed")) {
return `[请求失败] ${detail.replace(/^LLM request failed \(\d+\): /, "").slice(0, 300)}`;
}
if (detail.startsWith("Main Agent returned invalid JSON")) {
return "[请求失败] 总管返回了无效 JSON请检查模型是否支持 json 输出,或暂时关闭预设后重试。";
}
return `[请求失败] ${detail.slice(0, 300)}`;
}

View File

@@ -0,0 +1,267 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
createApiProfile,
deleteApiProfile,
getApiProfile,
listApiProfiles,
testApiProfile,
updateApiProfile,
} from "../config/api-profiles.js";
import {
ensureActiveProfileDefault,
loadAppSettings,
saveAppSettings,
setActivePresetId,
setActiveProfileId,
} from "../config/settings.js";
import {
deletePreset,
getPreset,
importAndSavePreset,
listPresets,
} from "../preset/store.js";
import {
countInjectingEntries,
listEnabledPresetEntries,
} from "../preset/entries.js";
import { sessionManager } from "./session-manager.js";
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
function json(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
export async function handleSettingsApi(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
): Promise<boolean> {
if (pathname === "/api/settings" && req.method === "GET") {
ensureActiveProfileDefault();
const settings = loadAppSettings();
const profiles = listApiProfiles();
const presets = listPresets().map((p) => {
const entries = listEnabledPresetEntries(p);
return {
id: p.id,
name: p.name,
source: p.source,
enabledCount: p.promptOrder.filter((o) => o.enabled).length,
injectingCount: countInjectingEntries(entries),
importedAt: p.importedAt,
};
});
json(res, 200, { settings, profiles, presets });
return true;
}
if (pathname === "/api/settings" && req.method === "PUT") {
const body = JSON.parse(await readBody(req)) as {
activeProfileId?: string | null;
activePresetId?: string | null;
};
const settings = loadAppSettings();
if (body.activeProfileId !== undefined) {
settings.activeProfileId = body.activeProfileId;
}
if (body.activePresetId !== undefined) {
settings.activePresetId = body.activePresetId;
}
saveAppSettings(settings);
json(res, 200, { settings });
return true;
}
if (pathname === "/api/profiles" && req.method === "GET") {
ensureActiveProfileDefault();
json(res, 200, { profiles: listApiProfiles() });
return true;
}
if (pathname === "/api/profiles" && req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
name?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
};
const profile = createApiProfile({
name: body.name ?? "新配置",
baseUrl: body.baseUrl ?? "https://api.deepseek.com",
apiKey: body.apiKey ?? "",
model: body.model ?? "deepseek-v4-pro",
});
const activate = (body as { activate?: boolean }).activate === true;
if (activate) {
setActiveProfileId(profile.id);
}
const reloadedSessions = activate ? sessionManager.reloadAllLlms() : 0;
json(res, 201, { profile, activeProfileId: activate ? profile.id : null, reloadedSessions });
return true;
}
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/);
if (profileMatch) {
const id = decodeURIComponent(profileMatch[1]);
const isTest = profileMatch[2] === "/test";
if (isTest && req.method === "POST") {
const profile = getApiProfile(id);
if (!profile) {
json(res, 404, { error: "配置不存在" });
return true;
}
const result = await testApiProfile(profile);
json(res, 200, result);
return true;
}
if (req.method === "GET") {
const profile = getApiProfile(id);
if (!profile) {
json(res, 404, { error: "配置不存在" });
return true;
}
json(res, 200, { profile });
return true;
}
if (req.method === "PUT") {
const body = JSON.parse(await readBody(req)) as {
name?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
};
try {
const profile = updateApiProfile(id, body);
json(res, 200, { profile });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "更新失败",
});
}
return true;
}
if (req.method === "DELETE") {
deleteApiProfile(id);
const settings = loadAppSettings();
if (settings.activeProfileId === id) {
settings.activeProfileId = listApiProfiles()[0]?.id ?? null;
saveAppSettings(settings);
}
json(res, 200, { ok: true });
return true;
}
}
const activateProfileMatch = pathname.match(
/^\/api\/profiles\/([^/]+)\/activate$/,
);
if (activateProfileMatch && req.method === "POST") {
const id = decodeURIComponent(activateProfileMatch[1]);
if (!getApiProfile(id)) {
json(res, 404, { error: "配置不存在" });
return true;
}
setActiveProfileId(id);
const reloadedSessions = sessionManager.reloadAllLlms();
json(res, 200, { activeProfileId: id, reloadedSessions });
return true;
}
if (pathname === "/api/presets" && req.method === "GET") {
json(res, 200, { presets: listPresets() });
return true;
}
if (pathname === "/api/presets/import" && req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
raw?: unknown;
name?: string;
};
if (!body.raw) {
json(res, 400, { error: "缺少 raw 字段" });
return true;
}
const report = importAndSavePreset(body.raw, { name: body.name });
const activate = (body as { activate?: boolean }).activate === true;
if (activate) {
setActivePresetId(report.preset.id);
sessionManager.reloadAllLlms();
}
json(res, 201, { ...report, activePresetId: activate ? report.preset.id : null });
return true;
}
const presetEntriesMatch = pathname.match(
/^\/api\/presets\/([^/]+)\/entries$/,
);
if (presetEntriesMatch && req.method === "GET") {
const id = decodeURIComponent(presetEntriesMatch[1]);
const preset = getPreset(id);
if (!preset) {
json(res, 404, { error: "预设不存在" });
return true;
}
const entries = listEnabledPresetEntries(preset);
json(res, 200, {
presetId: preset.id,
presetName: preset.name,
generation: preset.generation,
entries,
injectingCount: countInjectingEntries(entries),
});
return true;
}
const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/);
if (presetMatch) {
const id = decodeURIComponent(presetMatch[1]);
const isActivate = presetMatch[2] === "/activate";
if (isActivate && req.method === "POST") {
if (!getPreset(id)) {
json(res, 404, { error: "预设不存在" });
return true;
}
setActivePresetId(id);
const reloadedSessions = sessionManager.reloadAllLlms();
json(res, 200, { activePresetId: id, reloadedSessions });
return true;
}
if (req.method === "GET") {
const preset = getPreset(id);
if (!preset) {
json(res, 404, { error: "预设不存在" });
return true;
}
const entries = listEnabledPresetEntries(preset);
json(res, 200, { preset, entries, injectingCount: countInjectingEntries(entries) });
return true;
}
if (req.method === "DELETE") {
deletePreset(id);
const settings = loadAppSettings();
if (settings.activePresetId === id) {
settings.activePresetId = null;
saveAppSettings(settings);
}
json(res, 200, { ok: true });
return true;
}
}
return false;
}

285
src/server/skill-catalog.ts Normal file
View File

@@ -0,0 +1,285 @@
import type { RuntimeSession } from "../types/runtime.js";
export type LifecycleStage = "design" | "play";
export type SkillCatalogEntry = {
id: string;
stage: "design" | "run";
label: string;
/** 这一步要干嘛(占位说明,详细设计后续补充) */
purpose: string;
status: "pending" | "active" | "done" | "skipped";
};
type CatalogTemplate = {
id: string;
stage: "design" | "run";
label: string;
purpose: string;
};
const GENERIC_DESIGN: CatalogTemplate[] = [
{
id: "interaction-paradigm",
stage: "design",
label: "交互范式",
purpose: "弄清用户要什么体验,产出 run skill 清单(要哪些能力)。",
},
{
id: "intake",
stage: "design",
label: "启动收集",
purpose: "收集最小需求,写入用户.需求 / book.brief。",
},
{
id: "world-blueprint",
stage: "design",
label: "世界蓝图",
purpose: "定背景板与核心冲突,供后续 skill 引用。",
},
{
id: "narrative-guide",
stage: "design",
label: "叙事指南",
purpose: "定 POV、时态、文风static 上下文上半)。",
},
{
id: "declare-ready",
stage: "design",
label: "实例就绪",
purpose: "agent 确认设计够开跑,进入游玩阶段。",
},
];
const GENERIC_RUN: CatalogTemplate[] = [
{
id: "agent-burst",
stage: "run",
label: "Agent 调度",
purpose: "总管 tool loop读黑板 → 选择 invoke 哪个 run skill。",
},
{
id: "narrator",
stage: "run",
label: "转述 / 展示",
purpose: "把世界状态编排成给用户看的叙事回复。",
},
{
id: "world-simulator",
stage: "run",
label: "世界模拟",
purpose: "裁决规则、更新事件流与可见信息。",
},
];
const BY_SKILL_PACK: Record<string, CatalogTemplate[]> = {
basic: [
{
id: "intake",
stage: "design",
label: "创作简报",
purpose: "收集题材、篇幅、风格 → book.brief。",
},
{
id: "declare-ready",
stage: "design",
label: "进入运行",
purpose: "简报确认后 declare ready。",
},
{
id: "outline",
stage: "run",
label: "生成大纲",
purpose: "根据 brief 生成 outline 产物。",
},
],
"weird-rules-short": [
{
id: "intake",
stage: "design",
label: "创作简报",
purpose: "收集规则怪谈情境与条数。",
},
{
id: "write-rules",
stage: "run",
label: "写规则",
purpose: "产出规则草稿与隐藏 core。",
},
{
id: "review-infer",
stage: "run",
label: "读者验收",
purpose: "盲读规则,不写 core。",
},
{
id: "review-author",
stage: "run",
label: "作者验收",
purpose: "对照 core 查一致性。",
},
],
"roleplay-game-theory": [
{
id: "intake",
stage: "design",
label: "博弈需求",
purpose: "收集情境、角色、轮次 → 用户.博弈需求。",
},
{
id: "setup-scenario",
stage: "design",
label: "结构化设定",
purpose: "整理为情境、规则、角色设定 tag。",
},
{
id: "declare-ready",
stage: "design",
label: "开始模拟",
purpose: "setup 验收后进入 run。",
},
{
id: "world-engine",
stage: "run",
label: "世界机",
purpose: "发可见信息、收行动、裁决回合。",
},
{
id: "role-decide",
stage: "run",
label: "角色决策",
purpose: "各角色独立产出思考与行动。",
},
{
id: "present-round",
stage: "run",
label: "回合展示",
purpose: "编排给用户看的本轮摘要。",
},
],
"world-simulator": [
{
id: "interaction-paradigm",
stage: "design",
label: "交互范式",
purpose: "定体验与 run skill 清单。",
},
{
id: "world-blueprint",
stage: "design",
label: "世界蓝图",
purpose: "背景板与核心设定。",
},
{
id: "topology",
stage: "design",
label: "拓扑 / 关系",
purpose: "地图、关系网或进阶路径(按需)。",
},
{
id: "generation-rules",
stage: "design",
label: "生成规则",
purpose: "元规则:如何生成实例内容。",
},
{
id: "narrative-guide",
stage: "design",
label: "叙事指南",
purpose: "正文气质与禁忌static 上)。",
},
{
id: "variable-catalog",
stage: "design",
label: "变量目录",
purpose: "要跟踪的状态与更新格式。",
},
{
id: "declare-ready",
stage: "design",
label: "实例就绪",
purpose: "agent 声明可开跑。",
},
{
id: "world-simulator",
stage: "run",
label: "世界模拟器",
purpose: "每轮推进世界状态与事件流。",
},
{
id: "narrator",
stage: "run",
label: "转述者",
purpose: "把状态写成用户可见叙事。",
},
],
};
function templatesFor(skillPackId?: string): CatalogTemplate[] {
if (skillPackId && BY_SKILL_PACK[skillPackId]) {
return BY_SKILL_PACK[skillPackId];
}
return [...GENERIC_DESIGN, ...GENERIC_RUN];
}
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
const override = session.slots.uiLifecycleStage;
if (override === "design" || override === "play") {
return override;
}
if (!session.slots.startupCompleted) return "design";
if (session.phase === "done") return "play";
return "play";
}
export function canEnterPlay(session: RuntimeSession): boolean {
return Boolean(session.slots.startupCompleted);
}
export function buildSkillCatalog(
session: RuntimeSession,
skillPackId?: string,
lifecycle: LifecycleStage = inferLifecycleStage(session),
): SkillCatalogEntry[] {
const templates = templatesFor(skillPackId);
const filtered = templates.filter((t) =>
lifecycle === "design" ? t.stage === "design" : t.stage === "run",
);
const workerIds = new Set(
session.artifacts.map((a) => a.workerId).filter(Boolean),
);
const acceptedWorkers = new Set(
session.artifacts
.filter((a) => a.status === "accepted")
.map((a) => a.workerId),
);
return filtered.map((t) => {
let status: SkillCatalogEntry["status"] = "pending";
if (t.id === "intake") {
if (session.slots.startupCompleted) status = "done";
else if (
session.waitingReason?.kind === "intake" ||
session.waitingReason?.kind === "input"
) {
status = "active";
}
} else if (t.id === "declare-ready") {
if (session.slots.startupCompleted) status = "done";
} else if (t.id === "agent-burst") {
if (session.phase === "running" && !session.currentWorkerId) {
status = "active";
}
} else if (workerIds.has(t.id)) {
status = acceptedWorkers.has(t.id) ? "done" : "active";
} else if (session.currentWorkerId === t.id) {
status = "active";
}
return { ...t, status };
});
}
export const TOOL_LOOP_BURST_MAX = 12;

View File

@@ -0,0 +1,37 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
readRecords,
summarizeTokenUsage,
type TokenStatsQuery,
} from "../stats/token-store.js";
function json(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
export async function handleStatsApi(
_req: IncomingMessage,
res: ServerResponse,
pathname: string,
searchParams: URLSearchParams,
): Promise<boolean> {
if (pathname !== "/api/stats/tokens") return false;
const query: TokenStatsQuery = {
bookId: searchParams.get("bookId") ?? undefined,
orchestratorId: searchParams.get("orchestratorId") ?? undefined,
sessionId: searchParams.get("sessionId") ?? undefined,
from: searchParams.get("from") ?? undefined,
to: searchParams.get("to") ?? undefined,
limit: searchParams.get("limit")
? Number(searchParams.get("limit"))
: undefined,
};
json(res, 200, {
summary: summarizeTokenUsage(query),
records: readRecords({ ...query, limit: query.limit ?? 100 }),
});
return true;
}

203
src/server/web-server.ts Normal file
View File

@@ -0,0 +1,203 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadDotEnv } from "../config/env.js";
import { sessionManager } from "./session-manager.js";
import { handleSettingsApi } from "./settings-handlers.js";
import { handleBooksApi } from "./book-handlers.js";
import { handleStatsApi } from "./stats-handlers.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
loadDotEnv(PROJECT_ROOT);
const WEB_ROOT = path.resolve(__dirname, "../../web");
const PORT = Number(process.env.PORT ?? 23337);
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
function json(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
async function serveStatic(res: ServerResponse, filePath: string): Promise<void> {
const ext = path.extname(filePath);
const types: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
};
const content = await readFile(filePath);
res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" });
res.end(content);
}
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
try {
if (req.method === "GET" && url.pathname === "/api/health") {
json(res, 200, {
ok: true,
version: "0.1.0",
routes: [
"GET /api/health",
"GET /api/settings",
"GET /api/profiles",
"POST /api/profiles",
"GET /api/presets",
"GET /api/books",
"GET /api/skills",
"GET /api/stats/tokens",
],
});
return;
}
if (await handleSettingsApi(req, res, url.pathname)) {
return;
}
if (await handleBooksApi(req, res, url.pathname)) {
return;
}
if (await handleStatsApi(req, res, url.pathname, url.searchParams)) {
return;
}
if (req.method === "POST" && url.pathname === "/api/sessions") {
const body = await readBody(req).catch(() => "");
let bookId: string | undefined;
let orchestratorId: string | undefined;
if (body) {
try {
const parsed = JSON.parse(body) as {
bookId?: string;
orchestratorId?: string;
};
bookId = parsed.bookId;
orchestratorId = parsed.orchestratorId;
} catch {
/* empty body ok */
}
}
const view = await sessionManager.createForBook(bookId, orchestratorId);
json(res, 201, view);
return;
}
const sessionMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)(\/.*)?$/);
if (sessionMatch) {
const sessionId = decodeURIComponent(sessionMatch[1]);
const sub = sessionMatch[2] ?? "";
if (req.method === "GET" && sub === "") {
const view = sessionManager.get(sessionId);
if (!view) {
json(res, 404, { error: "会话不存在" });
return;
}
json(res, 200, view);
return;
}
if (req.method === "POST" && sub === "/messages") {
const body = JSON.parse(await readBody(req)) as { text?: string };
if (!body.text?.trim()) {
json(res, 400, { error: "text 不能为空" });
return;
}
const view = await sessionManager.sendMessage(sessionId, body.text.trim());
json(res, 200, view);
return;
}
if (req.method === "POST" && sub === "/lifecycle") {
const body = JSON.parse(await readBody(req)) as { stage?: string };
if (body.stage !== "design" && body.stage !== "play") {
json(res, 400, { error: "stage 须为 design 或 play" });
return;
}
try {
const view = sessionManager.setLifecycleStage(sessionId, body.stage);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "无法切换模式",
});
}
return;
}
if (req.method === "POST" && sub === "/actions") {
const body = JSON.parse(await readBody(req)) as { action?: string };
let view;
switch (body.action) {
case "approve":
view = await sessionManager.approve(sessionId);
break;
case "confirm_intake":
view = await sessionManager.confirmIntake(sessionId);
break;
case "accept":
view = await sessionManager.accept(sessionId);
break;
case "reject":
view = await sessionManager.reject(sessionId);
break;
case "run_outline":
view = await sessionManager.runOutline(sessionId);
break;
case "finish":
view = await sessionManager.finish(sessionId);
break;
default:
json(res, 400, { error: "未知 action" });
return;
}
json(res, 200, view);
return;
}
}
let file = url.pathname === "/" ? "/index.html" : url.pathname;
const safe = path.normalize(file).replace(/^(\.\.[/\\])+/, "");
const full = path.join(WEB_ROOT, safe);
if (!full.startsWith(WEB_ROOT)) {
json(res, 403, { error: "Forbidden" });
return;
}
try {
await serveStatic(res, full);
} catch {
json(res, 404, { error: "Not found" });
}
} catch (err) {
console.error("[api]", req.method, url.pathname, err);
json(res, 500, {
error: err instanceof Error ? err.message : "服务器错误",
});
}
});
server.listen(PORT, () => {
console.log(`Writing Agent 对话页: http://localhost:${PORT}`);
});

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 };

247
src/stats/token-store.ts Normal file
View File

@@ -0,0 +1,247 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
export type TokenUsageRecord = {
id: string;
at: string;
bookId?: string;
bookTitle?: string;
orchestratorId?: string;
sessionId?: string;
caller: string;
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
cachedTokens?: number;
cacheMissTokens?: number;
messageId?: string;
};
/** Token usage attached to a chat message in SessionView */
export type MessageTokenUsage = {
totalTokens: number;
promptTokens: number;
completionTokens: number;
cachedTokens?: number;
cacheMissTokens?: number;
caller: string;
model?: string;
recordId?: string;
};
export type CallerTokenBreakdown = {
totalTokens: number;
cachedTokens: number;
cacheMissTokens: number;
calls: number;
};
export type TokenStatsQuery = {
bookId?: string;
orchestratorId?: string;
sessionId?: string;
from?: string;
to?: string;
limit?: number;
};
export type TokenStatsSummary = {
totalCalls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
totalCached: number;
totalCacheMiss: number;
byCaller: Record<string, number>;
byCallerDetailed: Record<string, CallerTokenBreakdown>;
byBook: Record<string, number>;
byOrchestrator: Record<string, number>;
};
function statsDir(): string {
return path.join(getUserDataDir(), "stats");
}
function dayFile(date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return path.join(statsDir(), `${y}-${m}-${d}.jsonl`);
}
function ensureStatsDir(): void {
ensureUserDataDirs();
mkdirSync(statsDir(), { recursive: true });
}
export function recordTokenUsage(
partial: Omit<TokenUsageRecord, "id" | "at"> & { at?: string },
): TokenUsageRecord {
ensureStatsDir();
const record: TokenUsageRecord = {
id: randomUUID(),
at: partial.at ?? new Date().toISOString(),
bookId: partial.bookId,
bookTitle: partial.bookTitle,
orchestratorId: partial.orchestratorId,
sessionId: partial.sessionId,
caller: partial.caller,
model: partial.model,
promptTokens: partial.promptTokens,
completionTokens: partial.completionTokens,
totalTokens: partial.totalTokens,
cachedTokens: partial.cachedTokens,
cacheMissTokens: partial.cacheMissTokens,
messageId: partial.messageId,
};
appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8");
return record;
}
function listStatFiles(): string[] {
ensureStatsDir();
const dir = statsDir();
try {
return readdirSync(dir)
.filter((f) => f.endsWith(".jsonl"))
.sort()
.map((f) => path.join(dir, f));
} catch {
return [];
}
}
export function readRecords(query: TokenStatsQuery = {}): TokenUsageRecord[] {
const files = listStatFiles();
const records: TokenUsageRecord[] = [];
const fromMs = query.from ? Date.parse(query.from) : NaN;
const toMs = query.to ? Date.parse(query.to) : NaN;
const limit = query.limit ?? 500;
for (let i = files.length - 1; i >= 0 && records.length < limit; i--) {
const file = files[i];
if (!existsSync(file)) continue;
const lines = readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean);
for (let j = lines.length - 1; j >= 0 && records.length < limit; j--) {
try {
const record = JSON.parse(lines[j]) as TokenUsageRecord;
if (query.bookId && record.bookId !== query.bookId) continue;
if (query.orchestratorId && record.orchestratorId !== query.orchestratorId) {
continue;
}
if (query.sessionId && record.sessionId !== query.sessionId) continue;
const atMs = Date.parse(record.at);
if (Number.isFinite(fromMs) && atMs < fromMs) continue;
if (Number.isFinite(toMs) && atMs > toMs) continue;
records.push(record);
} catch {
/* skip bad line */
}
}
}
return records.sort((a, b) => b.at.localeCompare(a.at));
}
function accumulateCaller(
map: Record<string, CallerTokenBreakdown>,
caller: string,
record: Pick<
TokenUsageRecord,
"totalTokens" | "cachedTokens" | "cacheMissTokens"
>,
): void {
const prev = map[caller] ?? {
totalTokens: 0,
cachedTokens: 0,
cacheMissTokens: 0,
calls: 0,
};
map[caller] = {
totalTokens: prev.totalTokens + record.totalTokens,
cachedTokens: prev.cachedTokens + (record.cachedTokens ?? 0),
cacheMissTokens: prev.cacheMissTokens + (record.cacheMissTokens ?? 0),
calls: prev.calls + 1,
};
}
export function summarizeTokenUsage(query: TokenStatsQuery = {}): TokenStatsSummary {
const records = readRecords({ ...query, limit: query.limit ?? 2000 });
const summary: TokenStatsSummary = {
totalCalls: records.length,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
totalCached: 0,
totalCacheMiss: 0,
byCaller: {},
byCallerDetailed: {},
byBook: {},
byOrchestrator: {},
};
for (const r of records) {
summary.promptTokens += r.promptTokens;
summary.completionTokens += r.completionTokens;
summary.totalTokens += r.totalTokens;
summary.totalCached += r.cachedTokens ?? 0;
summary.totalCacheMiss += r.cacheMissTokens ?? 0;
summary.byCaller[r.caller] = (summary.byCaller[r.caller] ?? 0) + r.totalTokens;
accumulateCaller(summary.byCallerDetailed, r.caller, r);
if (r.bookId) {
const label = r.bookTitle ? `${r.bookTitle} (${r.bookId.slice(0, 8)})` : r.bookId;
summary.byBook[label] = (summary.byBook[label] ?? 0) + r.totalTokens;
}
if (r.orchestratorId) {
summary.byOrchestrator[r.orchestratorId] =
(summary.byOrchestrator[r.orchestratorId] ?? 0) + r.totalTokens;
}
}
return summary;
}
export function getSessionTokenTotals(sessionId: string): {
totalTokens: number;
totalCached: number;
totalCacheMiss: number;
byCaller: Record<string, CallerTokenBreakdown>;
last?: TokenUsageRecord;
records: TokenUsageRecord[];
} {
const records = readRecords({ sessionId, limit: 500 });
let totalTokens = 0;
let totalCached = 0;
let totalCacheMiss = 0;
const byCaller: Record<string, CallerTokenBreakdown> = {};
for (const r of records) {
totalTokens += r.totalTokens;
totalCached += r.cachedTokens ?? 0;
totalCacheMiss += r.cacheMissTokens ?? 0;
accumulateCaller(byCaller, r.caller, r);
}
return {
totalTokens,
totalCached,
totalCacheMiss,
byCaller,
last: records[0],
records,
};
}
export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage {
return {
totalTokens: record.totalTokens,
promptTokens: record.promptTokens,
completionTokens: record.completionTokens,
cachedTokens: record.cachedTokens,
cacheMissTokens: record.cacheMissTokens,
caller: record.caller,
model: record.model,
recordId: record.id,
};
}

29
src/types/blackboard.ts Normal file
View File

@@ -0,0 +1,29 @@
/** worker 读取多条同 tag 时的合并策略 */
export type BlackboardInputMerge = "latest" | "concat";
export type BlackboardItem = {
id: string;
tag: string;
content: string;
source: string;
scope?: string;
createdAt: string;
updatedAt: string;
dependencies?: string[];
metadata?: Record<string, unknown>;
};
/** 总管可见:无 content */
export type BlackboardTagIndex = Pick<
BlackboardItem,
"id" | "tag" | "source" | "scope" | "updatedAt"
>;
export type BlackboardWriteInput = {
tag: string;
content: string;
source: string;
scope?: string;
dependencies?: string[];
metadata?: Record<string, unknown>;
};

34
src/types/book-session.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { BlackboardItem } from "../types/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
/** 磁盘上保存的 Book 绑定的 Session 快照(用于续作) */
export type PersistedBookSession = {
version: 1;
sessionId: string;
bookId: string;
orchestratorId?: string;
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
messages: PersistedChatMessage[];
savedAt: string;
};
/** 与 session-manager ChatMessage 同形,独立类型避免循环依赖 */
export type PersistedChatMessage = {
id: string;
role: "system" | "user";
text: string;
createdAt: string;
kind?: string;
actor?: string;
title?: string;
body?: string;
thinking?: string;
tokenUsage?: {
totalTokens: number;
cachedTokens?: number;
cacheMissTokens?: number;
caller?: string;
model?: string;
};
};

37
src/types/book.ts Normal file
View File

@@ -0,0 +1,37 @@
export type BookProject = {
id: string;
title: string;
/** 用户选定或 agent 确认的 skill 包 */
activeSkillId?: string;
activeSkillName?: string;
preview: string;
sessionIds: string[];
/** 最近一次持久化的 sessionId用于续作 */
activeSessionId?: string;
createdAt: string;
updatedAt: string;
/** @deprecated 旧字段,等同 activeSkillId */
orchestratorId?: string;
/** @deprecated 旧字段,等同 activeSkillName */
orchestratorName?: string;
};
export type BookSummary = Pick<
BookProject,
| "id"
| "title"
| "activeSkillId"
| "activeSkillName"
| "preview"
| "updatedAt"
| "orchestratorId"
| "orchestratorName"
>;
export type SkillPackInfo = {
id: string;
name: string;
description: string;
category: string;
bookKind?: string;
};

26
src/types/intake.ts Normal file
View File

@@ -0,0 +1,26 @@
/** 启动「填空题」字段定义(来自 SKILL ## 启动询问) */
export type IntakeFieldDef = {
id: string;
label: string;
required: boolean;
};
/** 单字段展示状态 */
export type IntakeFieldStatus = {
id: string;
label: string;
required: boolean;
value?: string;
filled: boolean;
};
/** 填空进度(供 Web 展示) */
export type IntakeProgress = {
fields: IntakeFieldStatus[];
requiredTotal: number;
requiredFilled: number;
optionalTotal: number;
optionalFilled: number;
/** 全部必要项已填 */
ready: boolean;
};

66
src/types/preset.ts Normal file
View File

@@ -0,0 +1,66 @@
export type PresetPromptRole = "system" | "user" | "assistant";
export type PresetPromptEntry = {
id: string;
name: string;
enabled: boolean;
role: PresetPromptRole;
content: string;
marker: boolean;
sourceIdentifier: string;
injection?: {
position?: number;
depth?: number;
order?: number;
};
};
export type PresetPromptOrderItem = {
promptId: string;
enabled: boolean;
orderIndex: number;
};
export type GenerationParameters = {
temperature?: number;
topP?: number;
topK?: number;
minP?: number;
frequencyPenalty?: number;
presencePenalty?: number;
repetitionPenalty?: number;
maxContextTokens?: number;
maxOutputTokens?: number;
stream?: boolean;
reasoningEffort?: string;
verbosity?: string;
seed?: number;
variants?: number;
};
export type UnsupportedPresetSection = {
path: string;
reason: string;
};
export type PresetPackage = {
id: string;
name: string;
source: "native" | "sillytavern";
prompts: PresetPromptEntry[];
promptOrder: PresetPromptOrderItem[];
generation: GenerationParameters;
unsupported: UnsupportedPresetSection[];
importedAt: string;
raw?: unknown;
};
export type PresetImportReport = {
preset: PresetPackage;
promptCount: number;
enabledCount: number;
unreferencedCount: number;
missingIdentifiers: string[];
generationFields: string[];
warnings: string[];
};

56
src/types/run-snapshot.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { BlackboardItem } from "./blackboard.js";
import type { PersistedChatMessage } from "./book-session.js";
import type { RuntimeSession } from "./runtime.js";
/** 实例化完成后的对象,或 run 中某一时刻的完整进度 */
export type SnapshotKind = "instance" | "run";
/**
* Book 快照(手动保存,可多档)
* - instance实例化后的对象情境、规则、角色设定等用于复用思想实验框架、换角色重跑
* - run运行存档含轮次进度读档后续玩
*/
export type RunSnapshot = {
version: 1;
id: string;
bookId: string;
label: string;
kind: SnapshotKind;
/** skill 包 id兼容旧字段名 */
orchestratorId?: string;
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
/** 保存时的对话;实例快照通常较短(到 setup 验收为止) */
messages: PersistedChatMessage[];
createdAt: string;
note?: string;
};
/** 列表展示用,不含正文 payload */
export type RunSnapshotMeta = {
id: string;
bookId: string;
label: string;
kind: SnapshotKind;
/** skill 包 id兼容旧字段名 */
orchestratorId?: string;
createdAt: string;
note?: string;
};
export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta {
return {
id: snapshot.id,
bookId: snapshot.bookId,
label: snapshot.label,
kind: snapshot.kind,
orchestratorId: snapshot.orchestratorId,
createdAt: snapshot.createdAt,
note: snapshot.note,
};
}
export const SNAPSHOT_KIND_LABELS: Record<SnapshotKind, string> = {
instance: "实例",
run: "进度",
};

257
src/types/runtime.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* 运行阶段机类型契约。
*
* 设计原则:
* - 运行相位RuntimePhase只有 5 种,表示「系统在等什么」
* - 业务细节不扩 phase而用 waitingReason、slots、artifacts 承载
* - LLM / 总管不能直接改 phase只能通过 RuntimeEvent 驱动 applyEvent
*/
import type { IntakeFieldDef } from "./intake.js";
/** 运行相位:系统当前在等什么。只有 5 种。 */
export type RuntimePhase =
| "idle" // 会话已创建,尚未开始
| "running" // 正在推进总管决策、worker 执行、程序验收)
| "waiting_user" // 等待用户介入
| "done" // 流程正常结束
| "error"; // 不可恢复错误
/**
* waiting_user 时的具体原因。
* 用单一 phase + reason 替代多个独立 status避免「胖状态机」。
*/
export type WaitingReason =
| { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } // 启动:选 SKILL.md
| { kind: "intake"; prompt: string } // 启动填空:必要/可选项收集
| { kind: "input"; message?: string } // 总管 ask_user / 返工说明(启动完成后)
| { kind: "approve_step"; decisionId: string } // 总管建议 run_worker等用户确认
| { kind: "review_artifact"; artifactId: string } // worker 产物待验收
| { kind: "worker_questions"; workerId: string; questions: string[] } // worker 中途提问
| { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
export type SkillIndexEntry = {
name: string;
description: string;
category: string;
};
/**
* Book 存储形态(由 SKILL.md 选定,选 skill 后不可变更)。
* 第一版仅两种:小说线性结构 / 多轮多角色对话。
*/
export type BookKind = "novel" | "dialogue";
/** 选中的 skill 快照,写入 session.slots.activeSkill供启动询问与后续流程使用 */
export type ActiveSkillSnapshot = {
name: string;
description: string;
category: string;
/** 选定 skill 时确定,对应 Book 最终产物结构 */
bookKind?: BookKind;
defaultFlowId?: string;
suggestedWorkers: string[];
/** 来自 SKILL.md ## 启动询问 的展示文案 */
startupPrompt: string;
/** 用户首次输入写入的 slots 键,如 book.brief */
startupTargetKey: string;
/** 启动填空字段(必要 + 可选) */
intakeFields: IntakeFieldDef[];
};
/** worker 产物验收方式 */
export type AcceptanceMode =
| "user_confirmed" // 默认:产物完成后等用户验收
| "no_confirmation" // 自动接受,直接回到总管
| "programmatic_review"; // 走程序验收规则
/**
* Worker 链推进模式(预留)。
*
* - manual默认。每次 run_worker 可要求 approve_step产物默认 user_confirmed。
* - semi_auto半自动串联 worker仅在 skill 声明的 pauseCheckpoint 处强制暂停。
*
* 第一版 Runtime 仅实现 manualsemi_auto 由 orchestrator 文档化,待后续接入。
* 见 docs/runtime-state-machine.md §10、docs/orchestrator-skill-format.md §10。
*/
export type WorkerAdvanceMode = "manual" | "semi_auto";
/**
* semi_auto 下的暂停检查点(由各 skill 在 orchestrator 中声明)。
*
* `when` 为 skill 自定义的暂停条件描述或未来可解析的表达式;
* 具体变量轮次、token 预算、阶段界等)不由全局 enum 限定。
*/
export type AdvancePauseCheckpoint = {
id: string;
description: string;
/** 文档化条件;未来可绑定 slot / tag / 计数器,现阶段 Runtime 不解析 */
when?: string;
};
/**
* 会话级 worker 推进策略(预留)。
* 未设置或未解析时等价 `{ mode: "manual" }`。
*/
export type AdvancePolicy = {
mode: WorkerAdvanceMode;
pauseCheckpoints?: AdvancePauseCheckpoint[];
};
/** 总管Main Agent可执行的意图对应 tool call 的 action 字段 */
export type MainAgentAction =
| "ask_user" // 向用户提问
| "run_worker" // 调度已有 worker
| "create_temp_worker" // 临时 worker与 run_worker 共用阶段机分支)
| "review_blackboard" // 查看黑板后向用户说明
| "finish"; // 结束流程
/**
* 总管的一次决策。
* statePatchAllowed 必须为 false禁止 LLM 直接 patch 会话状态。
*/
export type MainAgentDecision = {
id: string;
action: MainAgentAction;
reason: string;
workerId?: string;
/** 调度 role-decide 等时指定当前决策角色Runtime 写入 世界.当前角色.id */
workerContext?: { roleId?: string };
/** true 时进入 waiting_user(approve_step),等用户确认后才 run_worker */
requiresApproval: boolean;
statePatchAllowed: false;
};
/** worker 产物的生命周期状态 */
export type ArtifactStatus =
| "drafted"
| "under_review"
| "accepted"
| "rejected"
| "revision_requested"
| "superseded";
/** worker 产出的一条产物记录 */
export type ArtifactRecord = {
id: string;
workerId: string;
stepId?: string;
outputTags: string[];
status: ArtifactStatus;
summary?: string;
createdAt: string;
updatedAt: string;
};
/** worker 因 ask_user 中途暂停时保存的上下文,用户回复后用于 resume_worker */
export type ResumeContext = {
workerId: string;
stepId?: string;
acceptanceMode: AcceptanceMode;
questions: string[];
};
/**
* 阶段机唯一合法的状态变更入口。
* 所有 phase 转移都必须通过 dispatch → applyEvent 处理某种 RuntimeEvent。
*/
export type RuntimeEvent =
| {
type: "session_started";
payload: { presetId: string; flowId?: string; availableSkills: SkillIndexEntry[] };
}
| { type: "skill_selected"; payload: { skill: ActiveSkillSnapshot } }
| {
type: "user_submitted_input";
payload: { text: string; intakeValues?: Record<string, string> };
}
| { type: "user_confirmed_intake"; payload: Record<string, never> }
| {
type: "main_agent_decision_created";
payload: { decision: MainAgentDecision };
}
| { type: "user_approved_next_step"; payload: { decisionId: string } }
| {
type: "user_rejected_next_step";
payload: { decisionId: string; reason?: string };
}
| {
type: "worker_started";
payload: {
workerId: string;
stepId?: string;
acceptanceMode: AcceptanceMode;
};
}
| { type: "worker_completed"; payload: { artifactId: string } }
| {
type: "worker_needs_input";
payload: { workerId: string; stepId?: string; questions: string[] };
}
| { type: "user_accepted_artifact"; payload: { artifactId: string } }
| {
type: "user_rejected_artifact";
payload: { artifactId: string; reason?: string };
}
| {
type: "user_requested_revision";
payload: { artifactId?: string; instruction: string };
}
| { type: "programmatic_review_started"; payload: { artifactId: string } }
| { type: "programmatic_review_passed"; payload: { artifactId: string } }
| {
type: "programmatic_review_failed";
payload: { artifactId: string; reason: string };
}
| { type: "flow_completed"; payload: Record<string, never> }
| { type: "runtime_failed"; payload: { reason: string } };
/** 一次会话的完整运行时快照 */
export type RuntimeSession = {
id: string;
phase: RuntimePhase;
waitingReason?: WaitingReason;
presetId: string;
flowId?: string;
/** 业务 flow 当前步骤execution flow 层,与 phase 正交) */
currentStepId?: string;
currentWorkerId?: string;
acceptanceMode?: AcceptanceMode;
/** 推进策略(预留)。缺省 = manual见 WorkerAdvanceMode */
advancePolicy?: AdvancePolicy;
reviewRequirements?: string[];
resumeContext?: ResumeContext;
/** 轻量槽位activeSkill、book.brief、startupCompleted 等 */
slots: Record<string, unknown>;
artifacts: ArtifactRecord[];
/** 待用户确认的总管决策approve_step 时有效) */
pendingDecision?: MainAgentDecision;
/** 待验收的产物 idreview_artifact 时有效) */
pendingArtifactId?: string;
/** 已应用的 RuntimeEvent 日志,便于调试与回放 */
history: RuntimeEvent[];
createdAt: string;
updatedAt: string;
};
/**
* applyEvent 返回的副作用清单。
* 纯函数 phase-machine 不执行 IO由 phase-runtime 的 processEffects 消费。
*/
export type PhaseEffect =
| { type: "invoke_main_agent" }
| { type: "run_worker"; workerId: string; workerContext?: { roleId?: string } }
| { type: "resume_worker" }
| { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string };
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
export type ApplyEventResult = {
session: RuntimeSession;
effects: PhaseEffect[];
error?: string;
};
/** @deprecated 使用 RuntimePhase */
export type RuntimeStatus = RuntimePhase;

61
src/types/tools.ts Normal file
View File

@@ -0,0 +1,61 @@
/** 总管 tool 名称 */
export type MainAgentToolName =
| "read_blackboard"
| "list_workers"
| "list_artifacts"
| "ask_user"
| "run_worker"
| "review_blackboard"
| "finish";
/** 可在 tool loop 内反复调用、不改变运行相位的 tool */
export const MAIN_AGENT_LOOP_TOOLS: MainAgentToolName[] = [
"read_blackboard",
"list_workers",
"list_artifacts",
];
/** 调用后应退出 loop、交给阶段机的终止 tool */
export const MAIN_AGENT_TERMINAL_TOOLS: MainAgentToolName[] = [
"ask_user",
"run_worker",
"review_blackboard",
"finish",
];
export function isMainAgentTerminalTool(name: string): name is MainAgentToolName {
return (MAIN_AGENT_TERMINAL_TOOLS as string[]).includes(name);
}
export function isMainAgentLoopTool(name: string): name is MainAgentToolName {
return (MAIN_AGENT_LOOP_TOOLS as string[]).includes(name);
}
export type ReadBlackboardParams = {
tags: string[];
};
export type ListWorkersParams = Record<string, never>;
export type ListArtifactsParams = Record<string, never>;
export type AskUserParams = {
reason: string;
message?: string;
};
export type RunWorkerParams = {
workerId: string;
reason: string;
requiresApproval: boolean;
roleId?: string;
};
export type ReviewBlackboardParams = {
reason: string;
summary: string;
};
export type FinishParams = {
reason: string;
};

218
src/worker/executor.ts Normal file
View File

@@ -0,0 +1,218 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import { loadWorkerSkillWithContext } from "../skills/loader.js";
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
import { collectUserInputTranscript } from "../intake/intake.js";
import { resolveWorkerId } from "./resolve-id.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
export type WorkerRunParams = {
skillName: string;
workerId: string;
slots: Record<string, unknown>;
blackboard: Blackboard;
llm: LlmProvider;
};
export type WorkerRunResult = {
outputs: Record<string, string>;
summary: string;
preview: string;
askUser?: string[];
};
const WORKER_OUTPUT_INSTRUCTION = `
---
## 运行时输出协议(必须遵守)
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段:
{
"outputs": { "<outputTag>": "<内容字符串>" },
"summary": "50字以内产物摘要",
"askUser": null 或 ["需要用户补充的问题"]
}
- outputs 的 key 必须是要求的 outputTags
- 若信息不足outputs 可为空对象askUser 填入问题
- 若 inputs 中 \`用户.博弈需求\`(或 book.brief已有实质内容禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
function slotValueForTag(
tag: string,
slots: Record<string, unknown>,
): string | undefined {
const val = slots[tag];
if (typeof val === "string" && val.trim()) return val.trim();
return undefined;
}
function gatherInputs(
inputTags: string[],
inputMerge: BlackboardInputMerge,
slots: Record<string, unknown>,
blackboard: Blackboard,
): Record<string, string> {
const inputs: Record<string, string> = {};
const items = blackboard.queryByPatterns(inputTags, inputMerge);
for (const item of items) {
inputs[item.tag] = item.content;
}
for (const pattern of inputTags) {
if (pattern.endsWith(".*") || pattern === "**") continue;
const slotVal = slotValueForTag(pattern, slots);
if (slotVal) inputs[pattern] = slotVal;
}
const workerReply = slotValueForTag("用户.worker答复", slots);
if (workerReply && !inputs["用户.worker答复"]) {
inputs["用户.worker答复"] = workerReply;
}
if (inputTags.includes("用户.博弈需求") && !inputs["用户.博弈需求"]) {
const fromSlot = slotValueForTag("用户.博弈需求", slots);
if (fromSlot) {
inputs["用户.博弈需求"] = fromSlot;
} else {
const transcript = collectUserInputTranscript(slots);
if (transcript) inputs["用户.博弈需求"] = transcript;
}
}
return inputs;
}
function parseWorkerResponse(
raw: string,
outputTags: string[],
): WorkerRunResult {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
const fallback: Record<string, string> = {};
if (outputTags.length === 1) {
fallback[outputTags[0]] = raw;
} else {
fallback[outputTags[0] ?? "output.草稿"] = raw;
}
return {
outputs: fallback,
summary: raw.slice(0, 80),
preview: raw.slice(0, 600),
};
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Worker 返回了无效 JSON");
}
const obj = parsed as Record<string, unknown>;
const outputsRaw = obj.outputs;
const outputs: Record<string, string> = {};
if (outputsRaw && typeof outputsRaw === "object") {
for (const tag of outputTags) {
const val = (outputsRaw as Record<string, unknown>)[tag];
if (typeof val === "string" && val.trim()) {
outputs[tag] = val.trim();
}
}
}
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
let askUser: string[] | undefined;
if (Array.isArray(obj.askUser)) {
askUser = obj.askUser.filter(
(q): q is string =>
typeof q === "string" && q.trim().length > 0 && !isMetaAskToken(q),
);
} else if (typeof obj.askUser === "string" && obj.askUser.trim()) {
const q = obj.askUser.trim();
askUser = isMetaAskToken(q) ? undefined : [q];
}
if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) {
const summaryText =
typeof obj.summary === "string" ? obj.summary.trim() : "";
if (
summaryText &&
!isMetaAskToken(summaryText) &&
summaryText.length > 8 &&
/[?]/.test(summaryText)
) {
askUser = [summaryText];
} else if (isMetaAskToken(summaryText)) {
askUser = ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
}
}
const summary =
typeof obj.summary === "string" && obj.summary.trim()
? obj.summary.trim()
: Object.values(outputs)[0]?.slice(0, 80) ?? "Worker 已完成";
const preview =
Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary;
return { outputs, summary, preview, askUser: askUser?.length ? askUser : undefined };
}
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
const workerId = resolveWorkerId(params.workerId);
const { worker, promptBody } = await loadWorkerSkillWithContext(
params.skillName,
workerId,
);
const inputMerge = worker.inputMerge ?? "latest";
let inputs = gatherInputs(
worker.inputTags,
inputMerge,
params.slots,
params.blackboard,
);
if (worker.id === "role-decide") {
inputs = filterInputsForRolePerspective(inputs, params.slots);
}
const userPayload = {
workerId: worker.id,
workerName: worker.name,
inputTags: worker.inputTags,
outputTags: worker.outputTags,
inputs,
instruction:
"根据 SKILL 说明完成创作任务。若 inputs 不足,使用 askUser 提问而非臆造。inputs 中已有用户.博弈需求 / book.brief 时,应直接据此产出,勿重复索要已提供信息。",
};
const result = await params.llm.complete(
[
{ role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION },
{ role: "user", content: JSON.stringify(userPayload, null, 2) },
],
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,
},
);
return parseWorkerResponse(result.content, worker.outputTags);
}
/** @internal 供单测验证 inputTags → inputs 拼接 */
export function gatherWorkerInputs(
inputTags: string[],
inputMerge: BlackboardInputMerge,
slots: Record<string, unknown>,
blackboard: Blackboard,
): Record<string, string> {
return gatherInputs(inputTags, inputMerge, slots, blackboard);
}

10
src/worker/resolve-id.ts Normal file
View File

@@ -0,0 +1,10 @@
/** 总管/历史命名与 skill 包内 worker id 的映射 */
const WORKER_ALIASES: Record<string, string> = {
"rules-worker": "write-rules",
"outline-worker": "outline",
"drafting-worker": "drafting",
};
export function resolveWorkerId(workerId: string): string {
return WORKER_ALIASES[workerId] ?? workerId;
}