328 lines
12 KiB
TypeScript
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;
|
|
}
|