Files
makelore/electron/coding-projects/project-config.ts

328 lines
12 KiB
TypeScript

import { mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import {
isProjectAgentAvatarDataUrl,
normalizeProjectType,
type ProjectAgentResponsibility,
type ProjectType,
} from '../../shared/project-config';
import type {
CodingProjectAgent,
CodingProjectConfig,
ProjectIdentityChoice,
} from '../../shared/coding-project-contracts';
import type {
ConversationModelState,
ConversationThinkingLevel,
ProductModelRef,
} from '../coding-runtime/contracts';
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
// Makelore owns project metadata; Pi only consumes the resolved project resources.
export const CODING_PROJECT_CONFIG_PATH = '.makelore/project.json';
export interface CodingProjectAgentV2 extends CodingProjectAgent, ConversationModelState {}
export interface CodingProjectConfigV2 extends CodingProjectConfig {
agents: CodingProjectAgentV2[];
}
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 CANONICAL_PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
export function isCanonicalCodingProjectId(value: unknown): value is string {
return typeof value === 'string' && CANONICAL_PROJECT_ID_PATTERN.test(value);
}
export function normalizeProjectIdentityChoice(value: unknown): ProjectIdentityChoice {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Project identity choice is invalid');
}
const record = value as { kind?: unknown; projectId?: unknown };
if (record.kind === 'create') return { kind: 'create' };
if (record.kind === 'bind' && isCanonicalCodingProjectId(record.projectId)) {
return { kind: 'bind', projectId: record.projectId };
}
throw new Error('Project identity choice is invalid');
}
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<ConversationThinkingLevel>([
'off',
'minimal',
'low',
'medium',
'high',
'max',
]);
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<ProjectAgentResponsibility>;
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<ProductModelRef>;
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<CodingProjectAgentV2>;
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<string>();
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<CodingProjectConfigV2>;
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
const projectType = normalizeProjectType(record.projectType);
if (!projectType) throw new Error('Invalid project type');
if (record.projectId !== undefined && !isCanonicalCodingProjectId(record.projectId)) {
throw new Error('Project identity is invalid');
}
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');
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.projectId !== undefined ? { projectId: record.projectId } : {}),
initialized: record.initialized === true,
agents,
knowledgeDirectory: 'knowledge',
createdAt,
updatedAt,
};
}
export function createCodingProjectConfigV2(
now = new Date().toISOString(),
projectType: ProjectType = 'custom',
projectId?: string,
): CodingProjectConfigV2 {
if (projectId !== undefined && !isCanonicalCodingProjectId(projectId)) {
throw new Error('Project identity is invalid');
}
return {
schemaVersion: 2,
projectType,
...(projectId !== undefined ? { projectId } : {}),
initialized: false,
agents: [],
knowledgeDirectory: 'knowledge',
createdAt: now,
updatedAt: now,
};
}
export async function readCodingProjectConfigV2(
projectPath: string,
): Promise<CodingProjectConfigReadResult> {
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<CodingProjectConfigV2> {
const config = normalizeCodingProjectConfigV2(value);
await writer(projectConfigPath(projectPath), config);
return config;
}
export async function createCodingProjectMetadata(
projectPath: string,
options: {
projectType?: ProjectType;
projectId?: string;
now?: string;
writer?: JsonFileWriter;
} = {},
): Promise<CodingProjectConfigV2> {
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, options.projectId);
await Promise.all([
mkdir(path.join(projectPath, '.makelore'), { 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<CodingProjectAgentV2> {
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;
}