Files
makelore/electron/opencode/project-config.ts
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

276 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import path from 'node:path';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import {
createProjectConfig,
validateAgentConfigs,
validateAgentNames,
type ProjectAgentConfig,
type ProjectConfig,
} from '../../shared/project-config';
export const PROJECT_CONFIG_PATH = '.niancode/project.json';
function configPath(projectPath: string): string {
return path.join(projectPath, PROJECT_CONFIG_PATH);
}
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 normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
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<ProjectAgentConfig['responsibility']>
: {};
return {
id,
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId)
? raw.avatarId
: 'avatar-01',
roleName: typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴',
name,
// Preserve this legacy marker when reading an existing project. New
// projects never populate it because they start with no Agents.
builtIn: raw.builtIn === true,
enabled: raw.enabled !== false,
model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
skillIds: normalizeStringList(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 type ProjectConfigReadResult =
| { status: 'valid'; config: ProjectConfig }
| { status: 'missing' }
| { status: 'invalid'; error: string };
export function normalizeProjectConfig(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<ProjectConfig>;
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
if (agents.some((item) => !item)) throw new Error('Invalid project Agent configuration');
const normalizedAgents = agents.filter((item): item is ProjectAgentConfig => Boolean(item));
if (new Set(normalizedAgents.map((item) => item.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,
initialized,
superpowersEnabled: raw.superpowersEnabled === true,
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
? raw.defaultModel.trim()
: null,
agents: normalizedAgents,
knowledgeDirectory: 'knowledge',
createdAt,
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt,
};
}
export async function readProjectConfig(projectPath: string): Promise<ProjectConfigReadResult> {
try {
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
return { status: 'valid', config: normalizeProjectConfig(raw) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
}
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
export 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 buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
const skills = agent.skillIds.length > 0
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
: ' "*": deny';
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
const model = agent.model ?? config.defaultModel;
const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent);
return `---
description: ${yamlString(agent.name)}
mode: all
${model ? `model: ${yamlString(model)}\n` : ''}permission:
${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap.
skill:
${skills}
---
${prompt}`;
}
async function materializeAgents(projectPath: string, config: ProjectConfig): Promise<void> {
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
await mkdir(agentDirectory, { recursive: true });
await Promise.all(config.agents.map(async (agent) => {
await writeFile(path.join(agentDirectory, `${agent.id}.md`), buildAgentMarkdown(config, agent), 'utf8');
}));
}
function initialVersionDocument(now: string): string {
return `# Project Version
Current: v0.1.0
Status: active
Version Owner: user
Started At: ${now}
Task Plan: TASKS.md
Summary: docs/versions/v0.1.0/VERSION_SUMMARY.md
Previous: none
`;
}
function initialTaskDocument(): string {
return `# TASKS
Project Version: v0.1.0
Document Revision: 1
Last Updated By: user
> 这里记录当前项目的任务状态、验收、证据、阻塞和下一步;专业事实写入对应项目文件。
## Version Goal
- 定义 v0.1.0 的最小可验证目标。
## Now
- [ ] TASK-001 明确当前版本目标
- Owner: user
- Status: todo
- Acceptance: 当前版本范围、验收和受影响文档已写清
- Evidence: pending
## Next
- None
## Blocked
- None
## Version Proposals
- None
## Done
- None
`;
}
export async function createInitialProjectConfig(
projectPath: string,
options?: { defaultModel?: string | null },
): Promise<ProjectConfig> {
const config = createProjectConfig();
if (typeof options?.defaultModel === 'string' && options.defaultModel.trim()) {
config.defaultModel = options.defaultModel.trim();
}
await mkdir(path.join(projectPath, '.niancode'), { recursive: true });
await mkdir(path.join(projectPath, 'knowledge'), { recursive: true });
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(config.createdAt), { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(), { encoding: 'utf8', flag: 'wx' });
return config;
}
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
const previous = await readProjectConfig(projectPath);
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
const config = normalizeProjectConfig({
...(value as object),
schemaVersion: 1,
createdAt: previous.config.createdAt,
updatedAt: new Date().toISOString(),
});
if (config.initialized) {
const validationErrors = validateAgentConfigs(config.agents);
if (validationErrors.length > 0) {
throw new Error(`Invalid project contact configuration: ${validationErrors.join(', ')}`);
}
await materializeAgents(projectPath, config);
}
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
return config;
}
export async function listProjectKnowledge(projectPath: string): Promise<string[]> {
const directory = path.join(projectPath, 'knowledge');
await mkdir(directory, { recursive: true });
return (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
}