import { mkdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { isProjectAgentAvatarDataUrl, isProjectType, type ProjectAgentResponsibility, type ProjectType, } from '../../shared/project-config'; import type { ConversationModelState, ConversationThinkingLevel, ProductModelRef, } from '../coding-runtime/contracts'; import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json'; export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json'; export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const; export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number]; export interface CodingProjectAgentV2 extends ConversationModelState { id: string; avatarId: string; avatarDataUrl?: string; roleName: string; name: string; builtIn: boolean; enabled: boolean; skillIds: string[]; responsibility: ProjectAgentResponsibility; prompt: string; archivedAt: string | null; pinned: boolean; createdAt: string; updatedAt: string; } export interface CodingProjectConfigV2 { schemaVersion: 2; projectType: ProjectType; initialized: boolean; agents: CodingProjectAgentV2[]; knowledgeDirectory: 'knowledge'; legacyConversationNotice: LegacyConversationNotice; createdAt: string; updatedAt: string; } export type CodingProjectConfigReadResult = | { status: 'valid'; config: CodingProjectConfigV2 } | { status: 'missing' } | { status: 'invalid'; error: string }; export interface CreateCodingProjectAgentInput { id: string; avatarId: string; avatarDataUrl?: string; roleName: string; name: string; model: ProductModelRef | null; modelResolution: 'resolved' | 'required'; skillIds?: string[]; responsibility: ProjectAgentResponsibility; prompt?: string; pinned?: boolean; } const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/; const THINKING_LEVELS = new Set([ 'off', 'minimal', 'low', 'medium', 'high', ]); function projectConfigPath(projectPath: string): string { return path.join(projectPath, CODING_PROJECT_CONFIG_PATH); } function cleanString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } function cleanStringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return [...new Set(value.map(cleanString).filter(Boolean))]; } function normalizeResponsibility(value: unknown): ProjectAgentResponsibility { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Project Agent responsibility is invalid'); } const record = value as Partial; if (typeof record.mission !== 'string' || !Array.isArray(record.owns) || !Array.isArray(record.boundaries) || !Array.isArray(record.collaborators) || !Array.isArray(record.principles)) { throw new Error('Project Agent responsibility is invalid'); } return { mission: cleanString(record.mission), owns: cleanStringList(record.owns), boundaries: cleanStringList(record.boundaries), collaborators: cleanStringList(record.collaborators), principles: cleanStringList(record.principles), }; } export function normalizeProductModelRef(value: unknown): ProductModelRef { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Product model must be an object'); } const record = value as Partial; const accountId = cleanString(record.accountId); const modelId = cleanString(record.modelId); if (!accountId || !modelId || !THINKING_LEVELS.has(record.thinkingLevel as ConversationThinkingLevel)) { throw new Error('Product model reference is invalid'); } return { accountId, modelId, thinkingLevel: record.thinkingLevel as ConversationThinkingLevel, }; } function normalizeModelState(value: { model?: unknown; modelResolution?: unknown; }): ConversationModelState { if (value.modelResolution === 'required') { if (value.model !== null) throw new Error('Required model selection must not contain a model'); return { model: null, modelResolution: 'required' }; } if (value.modelResolution !== 'resolved') throw new Error('Project Agent model resolution is invalid'); return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' }; } function normalizeAgent(value: unknown): CodingProjectAgentV2 { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Project Agent must be an object'); } const record = value as Partial; const id = cleanString(record.id); const name = cleanString(record.name); const avatarId = cleanString(record.avatarId); const roleName = cleanString(record.roleName); const createdAt = cleanString(record.createdAt); const updatedAt = cleanString(record.updatedAt); if (!AGENT_ID_PATTERN.test(id)) throw new Error('Project Agent id is invalid'); if (!name) throw new Error('Project Agent name is required'); if (!AVATAR_ID_PATTERN.test(avatarId)) throw new Error('Project Agent avatar is invalid'); if (!roleName) throw new Error('Project Agent role is required'); if (!createdAt || !updatedAt) throw new Error('Project Agent timestamps are required'); if (typeof record.builtIn !== 'boolean' || typeof record.enabled !== 'boolean' || typeof record.pinned !== 'boolean' || !Array.isArray(record.skillIds) || typeof record.prompt !== 'string' || !(record.archivedAt === null || typeof record.archivedAt === 'string')) { throw new Error('Project Agent metadata is invalid'); } return { id, avatarId, ...(isProjectAgentAvatarDataUrl(record.avatarDataUrl) ? { avatarDataUrl: record.avatarDataUrl } : {}), roleName, name, builtIn: record.builtIn === true, enabled: record.enabled !== false, ...normalizeModelState(record), skillIds: cleanStringList(record.skillIds), responsibility: normalizeResponsibility(record.responsibility), prompt: record.prompt, archivedAt: cleanString(record.archivedAt) || null, pinned: record.pinned === true, createdAt, updatedAt, }; } function validateAgentNames(agents: CodingProjectAgentV2[]): void { const names = new Set(); for (const agent of agents) { if (agent.name.length > 30) throw new Error('Project Agent name is too long'); const key = agent.name.toLocaleLowerCase(); if (names.has(key)) throw new Error('Duplicate project Agent name'); names.add(key); } } export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectConfigV2 { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Coding project config must be an object'); } const record = value as Partial; if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema'); if (!isProjectType(record.projectType)) throw new Error('Invalid project type'); if (typeof record.initialized !== 'boolean') throw new Error('Project initialized state is invalid'); if (record.knowledgeDirectory !== 'knowledge') throw new Error('Project knowledge directory is invalid'); if (!Array.isArray(record.agents)) throw new Error('Project Agents must be an array'); const createdAt = cleanString(record.createdAt); const updatedAt = cleanString(record.updatedAt); if (!createdAt || !updatedAt) throw new Error('Project timestamps are required'); if (!LEGACY_CONVERSATION_NOTICE_VALUES.includes(record.legacyConversationNotice as LegacyConversationNotice)) { throw new Error('Legacy Conversation notice state is invalid'); } const agents = record.agents.map(normalizeAgent); if (new Set(agents.map((agent) => agent.id)).size !== agents.length) { throw new Error('Duplicate project Agent id'); } validateAgentNames(agents); return { schemaVersion: 2, projectType: record.projectType, initialized: record.initialized === true, agents, knowledgeDirectory: 'knowledge', legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice, createdAt, updatedAt, }; } export function createCodingProjectConfigV2( now = new Date().toISOString(), projectType: ProjectType = 'custom', ): CodingProjectConfigV2 { return { schemaVersion: 2, projectType, initialized: false, agents: [], knowledgeDirectory: 'knowledge', legacyConversationNotice: 'none', createdAt: now, updatedAt: now, }; } export async function readCodingProjectConfigV2( projectPath: string, ): Promise { try { return { status: 'valid', config: normalizeCodingProjectConfigV2(await readJsonFile(projectConfigPath(projectPath))), }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }; return { status: 'invalid', error: error instanceof Error ? error.message : String(error) }; } } export async function writeCodingProjectConfigV2( projectPath: string, value: unknown, writer: JsonFileWriter = atomicWriteJson, ): Promise { const config = normalizeCodingProjectConfigV2(value); await writer(projectConfigPath(projectPath), config); return config; } export async function createCodingProjectMetadata( projectPath: string, options: { projectType?: ProjectType; now?: string; writer?: JsonFileWriter; } = {}, ): Promise { try { await stat(projectConfigPath(projectPath)); throw new Error('Coding project configuration already exists'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } const config = createCodingProjectConfigV2(options.now, options.projectType); await Promise.all([ mkdir(path.join(projectPath, '.niancode'), { recursive: true }), mkdir(path.join(projectPath, 'knowledge'), { recursive: true }), ]); await (options.writer ?? atomicWriteJson)(projectConfigPath(projectPath), config); return config; } export async function createCodingProjectAgent( projectPath: string, input: CreateCodingProjectAgentInput, options: { now?: string; writer?: JsonFileWriter } = {}, ): Promise { const result = await readCodingProjectConfigV2(projectPath); if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid'); if (result.config.agents.some((agent) => agent.id === input.id.trim())) { throw new Error('Project Agent id already exists'); } const now = options.now ?? new Date().toISOString(); const agent = normalizeAgent({ ...input, builtIn: false, enabled: true, skillIds: input.skillIds ?? [], prompt: input.prompt ?? '', archivedAt: null, pinned: input.pinned ?? false, createdAt: now, updatedAt: now, }); await writeCodingProjectConfigV2(projectPath, { ...result.config, initialized: true, agents: [...result.config.agents, agent], updatedAt: now, }, options.writer); return agent; } export async function acknowledgeLegacyConversationNotice( projectPath: string, options: { now?: string; writer?: JsonFileWriter } = {}, ): Promise { const result = await readCodingProjectConfigV2(projectPath); if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid'); if (result.config.legacyConversationNotice !== 'pending') return result.config; return await writeCodingProjectConfigV2(projectPath, { ...result.config, legacyConversationNotice: 'acknowledged', updatedAt: options.now ?? new Date().toISOString(), }, options.writer); }