merge: integrate upstream main with local Makelore changes
This commit is contained in:
@@ -5,7 +5,7 @@ import type { ConversationModelState, ProductModelRef } from '../coding-runtime/
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
|
||||
import { normalizeProductModelRef } from './project-config';
|
||||
|
||||
export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json';
|
||||
export const CODING_CONVERSATIONS_PATH = '.makelore/conversations.json';
|
||||
|
||||
export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState {
|
||||
piSessionId?: string;
|
||||
|
||||
@@ -56,7 +56,7 @@ export class GameAssetReviewConflictError extends Error {
|
||||
const stateWriteQueues = new Map<string, Promise<void>>();
|
||||
|
||||
function assetReviewStatePath(projectPath: string): string {
|
||||
return join(projectPath, '.niancode', STATE_FILE_NAME);
|
||||
return join(projectPath, '.makelore', STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
@@ -136,7 +136,7 @@ async function readState(projectPath: string): Promise<GameAssetReviewState> {
|
||||
}
|
||||
|
||||
async function writeState(projectPath: string, state: GameAssetReviewState): Promise<void> {
|
||||
const directory = join(projectPath, '.niancode');
|
||||
const directory = join(projectPath, '.makelore');
|
||||
await mkdir(directory, { recursive: true });
|
||||
const target = assetReviewStatePath(projectPath);
|
||||
const temporary = `${target}.${randomUUID()}.tmp`;
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
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<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',
|
||||
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<ProjectConfig>;
|
||||
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<ProviderAccount, 'id' | 'vendorId'>): string {
|
||||
return account.vendorId === 'custom' || account.vendorId === 'ollama'
|
||||
? sanitizeLegacyProviderKey(account.id)
|
||||
: sanitizeLegacyProviderKey(account.vendorId);
|
||||
}
|
||||
|
||||
function configuredLegacyModelIds(account: ProviderAccount, providerKey: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
unlink,
|
||||
} from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { ProductModelRef } from '../coding-runtime/contracts';
|
||||
import {
|
||||
buildLegacyProjectAgentManifest,
|
||||
normalizeLegacyProjectConfigV1,
|
||||
} from './legacy-v1';
|
||||
import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
atomicWriteText,
|
||||
type JsonFileWriter,
|
||||
} from './atomic-json';
|
||||
import {
|
||||
CODING_PROJECT_CONFIG_PATH,
|
||||
normalizeCodingProjectConfigV2,
|
||||
type CodingProjectAgentV2,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
import {
|
||||
CODING_CONVERSATIONS_PATH,
|
||||
createEmptyConversationFileV2,
|
||||
} from './conversation-store';
|
||||
|
||||
export interface LegacyModelResolutionInput {
|
||||
agentId: string;
|
||||
legacyModel: string;
|
||||
}
|
||||
|
||||
export type LegacyModelResolver = (
|
||||
input: LegacyModelResolutionInput,
|
||||
) => Promise<ProductModelRef | null>;
|
||||
|
||||
export interface CodingProjectMigrationDependencies {
|
||||
resolveLegacyModel: LegacyModelResolver;
|
||||
now?: () => string;
|
||||
writeJson?: JsonFileWriter;
|
||||
copyFile?: typeof copyFile;
|
||||
}
|
||||
|
||||
export type CodingProjectMigrationResult =
|
||||
| {
|
||||
status: 'already-current';
|
||||
config: CodingProjectConfigV2;
|
||||
}
|
||||
| {
|
||||
status: 'migrated';
|
||||
config: CodingProjectConfigV2;
|
||||
backupDirectory: string;
|
||||
removedGeneratedAgents: string[];
|
||||
backedUpUncertainAgents: string[];
|
||||
};
|
||||
|
||||
type AgentFileSnapshot = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
generated: boolean;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
|
||||
function timestampKey(value: string): string {
|
||||
return value.replace(/[^0-9A-Za-z_-]/g, '-');
|
||||
}
|
||||
|
||||
async function readOptionalText(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotLegacyAgentFiles(
|
||||
projectPath: string,
|
||||
config: ProjectConfig,
|
||||
backupDirectory: string,
|
||||
copy: typeof copyFile,
|
||||
): Promise<AgentFileSnapshot[]> {
|
||||
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
|
||||
const expected = new Map(buildLegacyProjectAgentManifest(config).map((entry) => [
|
||||
path.basename(entry.relativePath),
|
||||
entry.content,
|
||||
]));
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(agentDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
const snapshots: AgentFileSnapshot[] = [];
|
||||
for (const entry of entries
|
||||
.filter((candidate) => candidate.isFile())
|
||||
.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const fileName = entry.name;
|
||||
const filePath = path.join(agentDirectory, fileName);
|
||||
const content = await readOptionalText(filePath);
|
||||
if (content === null) continue;
|
||||
const generated = expected.get(fileName) === content;
|
||||
if (generated) {
|
||||
snapshots.push({ fileName, filePath, content, generated });
|
||||
continue;
|
||||
}
|
||||
const backupPath = path.join(backupDirectory, '.opencode', 'agent', fileName);
|
||||
await mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await copy(filePath, backupPath);
|
||||
snapshots.push({ fileName, filePath, content, generated, backupPath });
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function legacyModelForAgent(config: ProjectConfig, agent: ProjectAgentConfig): string | null {
|
||||
const direct = typeof agent.model === 'string' ? agent.model.trim() : '';
|
||||
const fallback = typeof config.defaultModel === 'string' ? config.defaultModel.trim() : '';
|
||||
return direct || fallback || null;
|
||||
}
|
||||
|
||||
function preserveLegacyAgentFields(
|
||||
rawProject: unknown,
|
||||
normalized: ProjectConfig,
|
||||
): ProjectConfig {
|
||||
if (!rawProject || typeof rawProject !== 'object' || Array.isArray(rawProject)) return normalized;
|
||||
const rawAgents = (rawProject as { agents?: unknown }).agents;
|
||||
if (!Array.isArray(rawAgents)) return normalized;
|
||||
const rawById = new Map<string, { prompt?: unknown; skillIds?: unknown }>();
|
||||
for (const value of rawAgents) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const record = value as { id?: unknown; prompt?: unknown; skillIds?: unknown };
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
if (id) rawById.set(id, record);
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
agents: normalized.agents.map((agent) => {
|
||||
const raw = rawById.get(agent.id);
|
||||
const rawSkillIds = Array.isArray(raw?.skillIds)
|
||||
? [...new Set(raw.skillIds
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean))]
|
||||
: agent.skillIds;
|
||||
return {
|
||||
...agent,
|
||||
prompt: typeof raw?.prompt === 'string' ? raw.prompt : agent.prompt,
|
||||
skillIds: rawSkillIds,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateAgent(
|
||||
config: ProjectConfig,
|
||||
agent: ProjectAgentConfig,
|
||||
resolveLegacyModel: LegacyModelResolver,
|
||||
fallbackTimestamp: string,
|
||||
): Promise<CodingProjectAgentV2> {
|
||||
const legacyModel = legacyModelForAgent(config, agent);
|
||||
const model = legacyModel
|
||||
? await resolveLegacyModel({ agentId: agent.id, legacyModel })
|
||||
: null;
|
||||
return {
|
||||
id: agent.id,
|
||||
avatarId: agent.avatarId,
|
||||
...(agent.avatarDataUrl ? { avatarDataUrl: agent.avatarDataUrl } : {}),
|
||||
roleName: agent.roleName,
|
||||
name: agent.name,
|
||||
builtIn: agent.builtIn,
|
||||
enabled: agent.enabled,
|
||||
model,
|
||||
modelResolution: model ? 'resolved' : 'required',
|
||||
skillIds: [...agent.skillIds],
|
||||
responsibility: {
|
||||
mission: agent.responsibility.mission,
|
||||
owns: [...agent.responsibility.owns],
|
||||
boundaries: [...agent.responsibility.boundaries],
|
||||
collaborators: [...agent.responsibility.collaborators],
|
||||
principles: [...agent.responsibility.principles],
|
||||
},
|
||||
prompt: agent.prompt,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
pinned: agent.pinned === true,
|
||||
createdAt: agent.createdAt ?? fallbackTimestamp,
|
||||
updatedAt: agent.updatedAt ?? fallbackTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreOriginalState(
|
||||
projectConfigPath: string,
|
||||
projectSource: string,
|
||||
conversationsPath: string,
|
||||
conversationsSource: string | null,
|
||||
agentFiles: AgentFileSnapshot[],
|
||||
): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
const restore = async (label: string, operation: () => Promise<void>) => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
await restore('project config', () => atomicWriteText(projectConfigPath, projectSource));
|
||||
if (conversationsSource === null) {
|
||||
await restore('Conversation metadata', () => rm(conversationsPath, { force: true }));
|
||||
} else {
|
||||
await restore('Conversation metadata', () => atomicWriteText(conversationsPath, conversationsSource));
|
||||
}
|
||||
for (const agent of agentFiles) {
|
||||
await restore(`Agent ${agent.fileName}`, () => atomicWriteText(agent.filePath, agent.content));
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Pi cutover rollback was incomplete: ${failures.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateCodingProjectToV2(
|
||||
projectPath: string,
|
||||
dependencies: CodingProjectMigrationDependencies,
|
||||
): Promise<CodingProjectMigrationResult> {
|
||||
const projectConfigPath = path.join(projectPath, CODING_PROJECT_CONFIG_PATH);
|
||||
const conversationsPath = path.join(projectPath, CODING_CONVERSATIONS_PATH);
|
||||
const projectSource = await readFile(projectConfigPath, 'utf8');
|
||||
const rawProject = JSON.parse(projectSource) as unknown;
|
||||
if (rawProject && typeof rawProject === 'object' && !Array.isArray(rawProject)
|
||||
&& (rawProject as { schemaVersion?: unknown }).schemaVersion === 2) {
|
||||
return {
|
||||
status: 'already-current',
|
||||
config: normalizeCodingProjectConfigV2(rawProject),
|
||||
};
|
||||
}
|
||||
const normalizedLegacyConfig = normalizeLegacyProjectConfigV1(rawProject);
|
||||
const legacyConfig = preserveLegacyAgentFields(rawProject, normalizedLegacyConfig);
|
||||
const conversationsSource = await readOptionalText(conversationsPath);
|
||||
const migrationTimestamp = dependencies.now?.() ?? new Date().toISOString();
|
||||
const backupDirectory = path.join(
|
||||
projectPath,
|
||||
'.niancode',
|
||||
'migration-backups',
|
||||
`pi-cutover-${timestampKey(migrationTimestamp)}`,
|
||||
);
|
||||
const copy = dependencies.copyFile ?? copyFile;
|
||||
const writeJson = dependencies.writeJson ?? atomicWriteJson;
|
||||
|
||||
await mkdir(path.dirname(backupDirectory), { recursive: true });
|
||||
await mkdir(backupDirectory, { recursive: false });
|
||||
await copy(projectConfigPath, path.join(backupDirectory, 'project.json'));
|
||||
if (conversationsSource !== null) {
|
||||
await copy(conversationsPath, path.join(backupDirectory, 'conversations.json'));
|
||||
}
|
||||
const agentFiles = await snapshotLegacyAgentFiles(
|
||||
projectPath,
|
||||
normalizedLegacyConfig,
|
||||
backupDirectory,
|
||||
copy,
|
||||
);
|
||||
|
||||
const agents = await Promise.all(legacyConfig.agents.map((agent) => migrateAgent(
|
||||
legacyConfig,
|
||||
agent,
|
||||
dependencies.resolveLegacyModel,
|
||||
migrationTimestamp,
|
||||
)));
|
||||
const migratedConfig = normalizeCodingProjectConfigV2({
|
||||
schemaVersion: 2,
|
||||
projectType: legacyConfig.projectType,
|
||||
initialized: legacyConfig.initialized,
|
||||
agents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'pending',
|
||||
createdAt: legacyConfig.createdAt,
|
||||
updatedAt: migrationTimestamp,
|
||||
});
|
||||
|
||||
try {
|
||||
await writeJson(conversationsPath, createEmptyConversationFileV2());
|
||||
await writeJson(projectConfigPath, migratedConfig);
|
||||
for (const agent of agentFiles) await unlink(agent.filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await restoreOriginalState(
|
||||
projectConfigPath,
|
||||
projectSource,
|
||||
conversationsPath,
|
||||
conversationsSource,
|
||||
agentFiles,
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
const rollbackMessage = rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: String(rollbackError);
|
||||
const migrationMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Pi project migration failed (${migrationMessage}) and rollback was incomplete: ${rollbackMessage}`,
|
||||
{ cause: rollbackError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'migrated',
|
||||
config: migratedConfig,
|
||||
backupDirectory,
|
||||
removedGeneratedAgents: agentFiles.filter((agent) => agent.generated).map((agent) => agent.fileName),
|
||||
backedUpUncertainAgents: agentFiles.filter((agent) => !agent.generated).map((agent) => agent.fileName),
|
||||
};
|
||||
}
|
||||
@@ -18,15 +18,13 @@ import type {
|
||||
} 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];
|
||||
// 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[];
|
||||
legacyConversationNotice: LegacyConversationNotice;
|
||||
}
|
||||
|
||||
export type CodingProjectConfigReadResult =
|
||||
@@ -210,9 +208,6 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
|
||||
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');
|
||||
@@ -225,7 +220,6 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
|
||||
initialized: record.initialized === true,
|
||||
agents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
@@ -246,7 +240,6 @@ export function createCodingProjectConfigV2(
|
||||
initialized: false,
|
||||
agents: [],
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'none',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -293,7 +286,7 @@ export async function createCodingProjectMetadata(
|
||||
}
|
||||
const config = createCodingProjectConfigV2(options.now, options.projectType, options.projectId);
|
||||
await Promise.all([
|
||||
mkdir(path.join(projectPath, '.niancode'), { recursive: true }),
|
||||
mkdir(path.join(projectPath, '.makelore'), { recursive: true }),
|
||||
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),
|
||||
]);
|
||||
await (options.writer ?? atomicWriteJson)(projectConfigPath(projectPath), config);
|
||||
@@ -330,17 +323,3 @@ export async function createCodingProjectAgent(
|
||||
}, options.writer);
|
||||
return agent;
|
||||
}
|
||||
|
||||
export async function acknowledgeLegacyConversationNotice(
|
||||
projectPath: string,
|
||||
options: { now?: string; writer?: JsonFileWriter } = {},
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type CodingConversationV2,
|
||||
} from './conversation-store';
|
||||
import {
|
||||
acknowledgeLegacyConversationNotice,
|
||||
isCanonicalCodingProjectId,
|
||||
normalizeCodingProjectConfigV2,
|
||||
normalizeProjectIdentityChoice,
|
||||
@@ -16,10 +15,6 @@ import {
|
||||
writeCodingProjectConfigV2,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
import {
|
||||
migrateCodingProjectToV2,
|
||||
type CodingProjectMigrationDependencies,
|
||||
} from './migration';
|
||||
import {
|
||||
createLocalCodingProject,
|
||||
normalizeCodingProjectPath,
|
||||
@@ -79,7 +74,6 @@ export interface CodingProjectServiceOptions {
|
||||
): Promise<void> | void;
|
||||
createConversationStore?: typeof createCodingConversationStore;
|
||||
writeConfig?: typeof writeCodingProjectConfigV2;
|
||||
migration?: CodingProjectMigrationDependencies;
|
||||
}
|
||||
|
||||
function requiredAbsolutePath(value: string | undefined, label: string): string {
|
||||
@@ -147,7 +141,6 @@ export class CodingProjectService {
|
||||
ReturnType<typeof createCodingConversationStore>
|
||||
>();
|
||||
private activeTransitionTail = Promise.resolve();
|
||||
private readonly migrationFlights = new Map<string, Promise<CodingProjectConfigV2>>();
|
||||
private readonly identityTransitionTails = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@@ -508,16 +501,6 @@ export class CodingProjectService {
|
||||
return await this.listKnowledgeFiles(project.path);
|
||||
}
|
||||
|
||||
async acknowledgeLegacyConversationNotice(projectId: string): Promise<CodingProjectConfigSnapshot> {
|
||||
const project = await this.getProject(projectId);
|
||||
const config = await acknowledgeLegacyConversationNotice(project.path);
|
||||
return {
|
||||
project,
|
||||
config,
|
||||
knowledgeFiles: await this.listKnowledgeFiles(project.path),
|
||||
};
|
||||
}
|
||||
|
||||
conversationStore(projectPath: string): ReturnType<typeof createCodingConversationStore> {
|
||||
const existing = this.conversationStores.get(projectPath);
|
||||
if (existing) return existing;
|
||||
@@ -569,19 +552,7 @@ export class CodingProjectService {
|
||||
|
||||
private async readCurrentConfig(projectPath: string): Promise<CodingProjectConfigV2 | null> {
|
||||
const current = await readCodingProjectConfigV2(projectPath);
|
||||
if (current.status === 'valid') return current.config;
|
||||
if (current.status !== 'invalid' || !this.options.migration) return null;
|
||||
const existing = this.migrationFlights.get(projectPath);
|
||||
if (existing) return await existing;
|
||||
const flight = migrateCodingProjectToV2(projectPath, this.options.migration)
|
||||
.then((result) => result.config)
|
||||
.finally(() => {
|
||||
if (this.migrationFlights.get(projectPath) === flight) {
|
||||
this.migrationFlights.delete(projectPath);
|
||||
}
|
||||
});
|
||||
this.migrationFlights.set(projectPath, flight);
|
||||
return await flight;
|
||||
return current.status === 'valid' ? current.config : null;
|
||||
}
|
||||
|
||||
private transitionActiveProject<T>(operation: () => Promise<{
|
||||
|
||||
@@ -73,8 +73,7 @@ export function createCodingProjectStorageFromStore(
|
||||
export async function createElectronCodingProjectStorage(): Promise<CodingProjectStorage> {
|
||||
const Store = (await import('electron-store')).default;
|
||||
const store = new Store<{ projects?: CodingProjectStoreData }>({
|
||||
// Legacy installed-user storage identity. Renaming it would orphan projects.
|
||||
name: 'opencode-projects',
|
||||
name: 'makelore-projects',
|
||||
});
|
||||
return createCodingProjectStorageFromStore(store, 'projects');
|
||||
}
|
||||
|
||||
@@ -104,8 +104,11 @@ export async function listProductCodingSkills(
|
||||
];
|
||||
const visibleSources = sources.filter(({ id, available }) => available !== false || selected.has(id));
|
||||
return await Promise.all(visibleSources.map(async ({ id, directory, entryPath, available = true }) => {
|
||||
const location = path.resolve(directory);
|
||||
const content = await readFile(path.join(location, entryPath ?? 'SKILL.md'), 'utf8');
|
||||
const sourceLocation = path.resolve(directory);
|
||||
const location = (BUNDLED_CODING_SKILL_IDS as readonly string[]).includes(id)
|
||||
? path.posix.join('resources', 'coding-skills', id)
|
||||
: sourceLocation;
|
||||
const content = await readFile(path.join(sourceLocation, entryPath ?? 'SKILL.md'), 'utf8');
|
||||
return {
|
||||
id: productSkillId(id),
|
||||
name: frontmatterScalar(content, 'name') ?? id,
|
||||
@@ -115,7 +118,7 @@ export async function listProductCodingSkills(
|
||||
effective: available && selected.has(id),
|
||||
location,
|
||||
content,
|
||||
entries: await listSkillEntries(location),
|
||||
entries: await listSkillEntries(sourceLocation),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user