import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import { encodeMessage, MAX_WEBSOCKET_FRAME_BYTES } from "../protocol/codec.js"; import { ERROR_INVALID_CONFIG_PREFIX, ERROR_PARSE_CONFIG_PREFIX, ERROR_READ_CONFIG_PREFIX, } from "../shared/messages.js"; import { type CharacterCard, type CharacterImport, loadCharacterCards } from "./character-card.js"; import { loadMessageTemplateFile, type MessageTemplateKey, mergeMessageTemplates } from "./message-templates.js"; export interface TavernConfig { configMaxMessages: number; characters: CharacterCard[]; /** * 白板模型:白板额度(可选——缺省 = store 默认 5/140, * 兑现「可配置」承诺)。装配透传:commands → startNew/resume → creator-factory * → createBoardStore;未配置时 undefined 走 store 默认。 */ boardMaxNotes?: number; boardMaxNoteLength?: number; /** :欢迎文案(可选——缺省 = DEFAULT_WELCOME_MESSAGE 代码默认值)。 */ welcomeMessage?: string; /** :合并后的消息文案模板集(项目 > 全局 > 内置;缺省 = 内置中文全量)。 */ messageTemplates?: Record; } interface LoadTavernConfigOptions { agentDir: string; cwd: string; } const TavernConfigFileSchema = Type.Object( { config_max_messages: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), characters: Type.Optional(Type.Array(Type.String())), // 白板模型:白板额度(可选;最小 1——额度 0 无业务意义)。 board_max_notes: Type.Optional(Type.Integer({ minimum: 1 })), board_max_note_length: Type.Optional(Type.Integer({ minimum: 1 })), // 欢迎文案(可选——缺省 = 代码默认;旧配置兼容,缺省键不报错)。 welcome_message: Type.Optional(Type.String()), // 消息文案模板文件(可选——相对声明它的 tavern.json 解析;缺省 = 内置中文)。 message_templates: Type.Optional(Type.String()), }, { additionalProperties: false }, ); type TavernConfigFile = Static; const checkTavernConfigFile = Compile(TavernConfigFileSchema); /** * 新建群聊的默认消息配额。唯一事实源—— * creator-runtime.ts 与 commands.ts import 本常量而非重复声明 * (三个相同的常量曾是 10→100 配额漏改的根因)。 */ export const DEFAULT_CONFIG_MAX_MESSAGES = 20; export async function loadTavernConfig(options: LoadTavernConfigOptions): Promise { const globalConfigPath = join(resolve(options.agentDir), "tavern.json"); const projectConfigPath = join(resolve(options.cwd), ".pi", "tavern.json"); const globalConfig = await readConfigFile(globalConfigPath); const projectConfig = await readConfigFile(projectConfigPath); const imports = [ ...toCharacterImports(globalConfig, globalConfigPath), ...toCharacterImports(projectConfig, projectConfigPath), ]; // 消息文案模板三层合并(项目 > 全局 > 内置),容错回退不阻断启动。 // 两层均未声明 message_templates → 不带字段(消费面回落 DEFAULT_TEMPLATES)。 const [projectTemplates, globalTemplates] = await Promise.all([ loadMessageTemplateFile(dirname(projectConfigPath), projectConfig?.message_templates), loadMessageTemplateFile(dirname(globalConfigPath), globalConfig?.message_templates), ]); const { templates: mergedTemplates, warnings: templateWarnings } = mergeMessageTemplates( projectTemplates.templates, globalTemplates.templates, ); for (const warning of [...projectTemplates.warnings, ...globalTemplates.warnings, ...templateWarnings]) { console.warn(warning); } const boardMaxNotes = projectConfig?.board_max_notes ?? globalConfig?.board_max_notes; const boardMaxNoteLength = projectConfig?.board_max_note_length ?? globalConfig?.board_max_note_length; // 欢迎文案三档合并(项目 > 全局 > 代码默认),沿用 board 先例; // 未配置 = undefined(管线侧回落 DEFAULT_WELCOME_MESSAGE)。 // 空白归一化必须在合并**之前**分别进行——否则 // `??` 先选中项目空串(空串非 null/undefined),再归一化 undefined 后直接回落 // 代码默认,截断三档回退链(反例:全局有效 + 项目空串 → 应回全局,实际默认)。 // 归一化语义:空白串视为未配置(PM 口径,与「欢迎语必非空 → join 后必有首次 // 可见注入」文档依据一致)。 const effectiveWelcomeMessage = normalizeWelcomeMessage(projectConfig?.welcome_message) ?? normalizeWelcomeMessage(globalConfig?.welcome_message); // 超 WebSocket 帧上限 → 配置错误 fail-fast。校验完整信封字节(Arch 补充:content // 单独校验留边界窗口——信封包裹 + JSON 转义膨胀可令完整帧超限,运行时 encodeMessage // 仍抛错断线;直接复用运行时同一 encodeMessage 校验最终帧,零窗口)。 if (effectiveWelcomeMessage !== undefined) { const systemMessageFrame = { jsonrpc: "2.0", method: "system_message", params: { content: effectiveWelcomeMessage }, }; try { encodeMessage(systemMessageFrame); } catch { throw new Error( `${ERROR_INVALID_CONFIG_PREFIX}welcome_message exceeds the WebSocket frame limit (${MAX_WEBSOCKET_FRAME_BYTES} bytes)`, ); } } return { configMaxMessages: projectConfig?.config_max_messages ?? globalConfig?.config_max_messages ?? DEFAULT_CONFIG_MAX_MESSAGES, ...(boardMaxNotes !== undefined ? { boardMaxNotes } : {}), ...(boardMaxNoteLength !== undefined ? { boardMaxNoteLength } : {}), ...(effectiveWelcomeMessage !== undefined ? { welcomeMessage: effectiveWelcomeMessage } : {}), ...(projectTemplates.templates !== null || globalTemplates.templates !== null ? { messageTemplates: mergedTemplates } : {}), characters: await loadCharacterCards(imports), }; } async function readConfigFile(path: string): Promise { let contents: string; try { contents = await readFile(path, "utf8"); } catch (error) { if (isNodeError(error, "ENOENT")) { return null; } throw new Error(`${ERROR_READ_CONFIG_PREFIX}${path}`, { cause: error }); } let value: unknown; try { value = JSON.parse(contents); } catch (error) { throw new Error(`${ERROR_PARSE_CONFIG_PREFIX}${path}`, { cause: error }); } if (!checkTavernConfigFile.Check(value)) { throw new Error(`${ERROR_INVALID_CONFIG_PREFIX}${path}`); } return value; } function normalizeWelcomeMessage(value: string | undefined): string | undefined { return value !== undefined && value.trim().length > 0 ? value : undefined; } function toCharacterImports(config: TavernConfigFile | null, configPath: string): CharacterImport[] { return (config?.characters ?? []).map((path) => ({ path: resolve(dirname(configPath), path), configPath, })); } function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error && error.code === code; }