import path from 'node:path'; import { isProjectAgentAvatarDataUrl, isProjectType, validateAgentNames, type ProjectAgentConfig, type ProjectConfig, type ProjectType, } from '../../shared/project-config'; import type { ProductModelRef } from '../coding-runtime/contracts'; import type { ProviderAccount } from '../shared/providers/types'; const RETIRED_PROJECT_SKILL_IDS = new Set(['game-engine']); function normalizeStringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return [...new Set(value .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean))]; } function normalizeProjectSkillIds(value: unknown): string[] { return normalizeStringList(value).filter((skillId) => !RETIRED_PROJECT_SKILL_IDS.has(skillId)); } function normalizeProjectType(value: unknown): ProjectType { if (value === undefined) return 'custom'; if (isProjectType(value)) return value; throw new Error('Invalid project type'); } function normalizeAgent(value: unknown): ProjectAgentConfig | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const raw = value as Partial; const id = typeof raw.id === 'string' ? raw.id.trim() : ''; const name = typeof raw.name === 'string' ? raw.name.trim() : ''; if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null; const rawResponsibility = raw.responsibility; const responsibility = rawResponsibility && typeof rawResponsibility === 'object' ? rawResponsibility as Partial : {}; return { id, avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId) ? raw.avatarId : 'avatar-01', avatarDataUrl: isProjectAgentAvatarDataUrl(raw.avatarDataUrl) ? raw.avatarDataUrl : undefined, roleName: typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴', name, builtIn: raw.builtIn === true, enabled: raw.enabled !== false, model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null, skillIds: normalizeProjectSkillIds(raw.skillIds), responsibility: { mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '', owns: normalizeStringList(responsibility.owns), boundaries: normalizeStringList(responsibility.boundaries), collaborators: normalizeStringList(responsibility.collaborators), principles: normalizeStringList(responsibility.principles), }, prompt: typeof raw.prompt === 'string' ? raw.prompt.trim() : '', archivedAt: typeof raw.archivedAt === 'string' && raw.archivedAt.trim() ? raw.archivedAt.trim() : null, pinned: raw.pinned === true, createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt.trim() : undefined, updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt.trim() : undefined, }; } export function normalizeLegacyProjectConfigV1(value: unknown): ProjectConfig { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Project config must be an object'); } const raw = value as Partial; if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema'); const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim() ? raw.defaultModel.trim() : null; const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : []; if (agents.some((agent) => !agent)) throw new Error('Invalid project Agent configuration'); const normalizedAgents = agents .filter((agent): agent is ProjectAgentConfig => Boolean(agent)) .map((agent) => agent.model || !defaultModel ? agent : { ...agent, model: defaultModel }); if (new Set(normalizedAgents.map((agent) => agent.id)).size !== normalizedAgents.length) { throw new Error('Duplicate project Agent id'); } const createdAt = typeof raw.createdAt === 'string' && raw.createdAt ? raw.createdAt : new Date().toISOString(); const initialized = raw.initialized === true; if (initialized && validateAgentNames(normalizedAgents).length > 0) { throw new Error('Initialized project has invalid Agent names'); } return { schemaVersion: 1, projectType: normalizeProjectType(raw.projectType), initialized, defaultModel, agents: normalizedAgents, knowledgeDirectory: 'knowledge', createdAt, updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt, }; } function yamlString(value: string): string { return JSON.stringify(value); } function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectAgentConfig): string { const peers = config.agents.filter((agent) => agent.id !== current.id); const responsibility = current.responsibility.mission.trim() ? [ '## 你的职责', current.responsibility.mission, '', '## 负责内容', ...current.responsibility.owns.map((item) => `- ${item}`), '', '## 工作边界', ...current.responsibility.boundaries.map((item) => `- ${item}`), '', '## 工作原则', ...current.responsibility.principles.map((item) => `- ${item}`), '', ] : []; return [ `# ${current.name} · ${current.roleName}`, '', `你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`, `你在当前项目中的职能是「${current.roleName}」。`, `你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`, '', current.prompt.trim(), ...responsibility, '请尊重用户已有文件和未提交改动;遇到不确定的高影响决策时先说明影响。', '', '## 项目伙伴', ...(peers.length > 0 ? peers.map((peer) => `- ${peer.name}(${peer.roleName},${peer.id})`) : ['- 暂无其他项目伙伴。']), '', '项目目录是当前项目的长期上下文。开始工作前读取与任务相关的文件,结束时把实际进展、假设、证据、风险和下一步写入项目文件。', ].join('\n'); } function buildSelectedSkillGuidance(skillIds: string[]): string { const guidance: string[] = []; if (skillIds.includes('frontend-slides')) { guidance.push([ '## 自动调用:项目演示', '当用户要求项目汇报、结题展示、答辩、项目介绍或其他演示文稿时,主动调用 `frontend-slides`,把当前项目的真实内容整理成可播放的本地 HTML 幻灯片。项目接近完成且交付清单需要项目演示时,主动提醒用户并准备生成;不要生成 PPTX、不要部署到云端。', ].join('\n')); } if (skillIds.includes('grilling')) { guidance.push([ '## 自动调用:方案质询', '当任务涉及重要实现、设计、架构或其他未确认的高影响决策时,主动调用 `grilling`。在用户确认共享理解前,不要修改项目状态。', ].join('\n')); } if (skillIds.includes('planning-with-files')) { guidance.push([ '## 自动调用:项目规划', '当任务包含多个阶段、需要研究或可能跨会话继续时,主动调用 `planning-with-files`,并按该技能在当前项目根目录直接维护 `task_plan.md`、`findings.md` 和 `progress.md`;使用当前会话项目目录的绝对路径,不要写入 Skill 安装目录、进程工作目录、用户目录或 `.niancode/agent-planning/`。', ].join('\n')); } return guidance.join('\n\n'); } function buildLegacyAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string { const skills = [ ' "*": deny', ...normalizeProjectSkillIds(agent.skillIds) .filter((skill) => skill !== '*') .map((skill) => ` ${skill}: allow`), ].join('\n'); const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : ''; const prompt = [ agent.prompt.trim() || buildProjectAgentPrompt(config, agent), buildSelectedSkillGuidance(agent.skillIds), ].filter(Boolean).join('\n\n'); return `--- description: ${yamlString(agent.name)} mode: all ${agent.model ? `model: ${yamlString(agent.model)}\n` : ''}permission: ${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap. skill: ${skills} --- ${prompt}`; } export interface LegacyProjectAgentManifestEntry { relativePath: string; content: string; } export function buildLegacyProjectAgentManifest( config: ProjectConfig, ): LegacyProjectAgentManifestEntry[] { return config.agents .map((agent) => ({ relativePath: path.posix.join('agent', `${agent.id}.md`), content: buildLegacyAgentMarkdown(config, agent), })) .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); } function sanitizeLegacyProviderKey(value: string): string { return value .trim() .replace(/[^A-Za-z0-9_-]+/g, '-') .replace(/^-+|-+$/g, '') || 'provider'; } function legacyProviderKey(account: Pick): string { return account.vendorId === 'custom' || account.vendorId === 'ollama' ? sanitizeLegacyProviderKey(account.id) : sanitizeLegacyProviderKey(account.vendorId); } function configuredLegacyModelIds(account: ProviderAccount, providerKey: string): Set { const ids = new Set(); for (const value of [ account.model, ...(account.fallbackModels ?? []), ...(account.metadata?.customModels ?? []), ]) { const normalized = value?.trim(); if (!normalized) continue; ids.add(normalized.startsWith(`${providerKey}/`) ? normalized.slice(providerKey.length + 1) : normalized); } return ids; } export function resolveLegacyProjectModel( legacyModel: string, accounts: ProviderAccount[], ): ProductModelRef | null { const separator = legacyModel.indexOf('/'); if (separator <= 0 || separator >= legacyModel.length - 1) return null; const providerKey = legacyModel.slice(0, separator); const modelId = legacyModel.slice(separator + 1); const matches = accounts.filter((account) => ( legacyProviderKey(account) === providerKey && configuredLegacyModelIds(account, providerKey).has(modelId) )); if (matches.length !== 1) return null; return { accountId: matches[0].id, modelId, thinkingLevel: 'off', }; }