feat: update Makelore modules and conversations
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

This commit is contained in:
inman
2026-07-31 10:08:41 +08:00
parent b8ca3f8eea
commit 80e8386fa6
175 changed files with 8036 additions and 10581 deletions

View File

@@ -134,8 +134,8 @@ export async function handleFileRoutes(
return true;
}
const config = await readProjectConfig(activeProject.path);
if (config.status !== 'valid' || !config.config.initialized || config.config.templateId !== 'game-development') {
sendJson(res, 403, { success: false, error: 'Game asset browsing is unavailable for this project template' });
if (config.status !== 'valid' || !config.config.initialized) {
sendJson(res, 403, { success: false, error: 'Game asset browsing is unavailable for an uninitialized project' });
return true;
}
sendJson(res, 200, { assets: await loadGameAssetCandidates(activeProject.path) });
@@ -153,8 +153,8 @@ export async function handleFileRoutes(
return true;
}
const config = await readProjectConfig(activeProject.path);
if (config.status !== 'valid' || !config.config.initialized || config.config.templateId !== 'game-development') {
sendJson(res, 403, { success: false, error: 'Game asset review is unavailable for this project template' });
if (config.status !== 'valid' || !config.config.initialized) {
sendJson(res, 403, { success: false, error: 'Game asset review is unavailable for an uninitialized project' });
return true;
}

View File

@@ -38,9 +38,17 @@ import {
type ProjectConfigReadResult,
} from '../../opencode/project-config';
import {
isProjectTemplateId,
type ProjectConfig,
} from '../../../shared/project-config';
readProjectConversationState,
writeProjectConversationState,
} from '../../opencode/project-conversations';
import {
completeProjectSession,
removeProjectSessionMetadata,
patchProjectSessionMetadata,
upsertProjectSessionMetadata,
type ProjectConversationState,
} from '../../../shared/project-conversations';
import type { ProjectConfig } from '../../../shared/project-config';
import { initializeProjectDirectory } from '../../opencode/project-directory-initialization';
const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
@@ -74,9 +82,6 @@ const COMMAND_IMAGE_DATA_URL_PATTERN =
/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/iu;
const STRICT_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
const AUTO_APPROVE_PERMISSION_MAX_ATTEMPTS = 3;
const AUTO_APPROVE_PERMISSION_CACHE_LIMIT = 1_000;
const permissionAutoApprovals = new Map<string, Promise<void>>();
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -580,11 +585,11 @@ async function findProjectById(ctx: HostApiContext, projectId: string) {
return projects.find((project) => project.id === projectId) ?? null;
}
type ProjectTemplateActivationCheck =
type ProjectActivationCheck =
| { ok: true }
| { ok: false; status: Exclude<ProjectConfigReadResult['status'], 'valid'> | 'incomplete'; error: string };
async function validateProjectTemplateForActivation(project: { path: string }): Promise<ProjectTemplateActivationCheck> {
async function validateProjectForActivation(project: { path: string }): Promise<ProjectActivationCheck> {
const result = await readProjectConfig(project.path);
if (result.status === 'valid') return { ok: true };
return {
@@ -601,18 +606,18 @@ async function getValidatedActiveProject<TProject extends { id: string; path: st
if (!activeProject) return null;
const project = projects.find((candidate) => candidate.id === activeProject.id);
if (!project) return null;
const templateCheck = await validateProjectTemplateForActivation(project);
return templateCheck.ok ? project : null;
const projectCheck = await validateProjectForActivation(project);
return projectCheck.ok ? project : null;
}
function sendProjectTemplateActivationFailure(
function sendProjectActivationFailure(
res: ServerResponse,
templateCheck: Exclude<ProjectTemplateActivationCheck, { ok: true }>,
projectCheck: Exclude<ProjectActivationCheck, { ok: true }>,
) {
sendJson(res, 409, {
success: false,
error: templateCheck.error,
templateStatus: templateCheck.status,
error: projectCheck.error,
projectStatus: projectCheck.status,
});
}
@@ -629,15 +634,15 @@ async function getValidatedActiveProjectForRuntime<TProject extends { path: stri
return null;
}
const templateCheck = await validateProjectTemplateForActivation(activeProject);
if (!templateCheck.ok) {
sendProjectTemplateActivationFailure(res, templateCheck);
const projectCheck = await validateProjectForActivation(activeProject);
if (!projectCheck.ok) {
sendProjectActivationFailure(res, projectCheck);
return null;
}
const config = await readProjectConfig(activeProject.path);
if (config.status !== 'valid' || !config.config.initialized) {
sendJson(res, 409, { success: false, error: 'Project initialization is incomplete', templateStatus: 'incomplete' });
sendJson(res, 409, { success: false, error: 'Project initialization is incomplete', projectStatus: 'incomplete' });
return null;
}
@@ -732,73 +737,6 @@ function getEventSessionId(input: unknown): string | null {
?? null;
}
function getPermissionRequestId(input: unknown): string | null {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
if (typeof record.id === 'string' && record.id.trim()) return record.id.trim();
if (typeof record.requestID === 'string' && record.requestID.trim()) return record.requestID.trim();
return getPermissionRequestId(record.request)
?? getPermissionRequestId(record.permission)
?? null;
}
function prunePermissionAutoApprovalCache(): void {
while (permissionAutoApprovals.size >= AUTO_APPROVE_PERMISSION_CACHE_LIMIT) {
const oldestKey = permissionAutoApprovals.keys().next().value;
if (typeof oldestKey !== 'string') return;
permissionAutoApprovals.delete(oldestKey);
}
}
async function autoApprovePermission(
client: ActiveProjectClient,
projectPath: string,
permission: unknown,
): Promise<boolean> {
const requestID = getPermissionRequestId(permission);
if (!requestID) {
logger.warn('[opencode-permission] Cannot auto-approve permission without request id');
return false;
}
const cacheKey = `${projectPath}\u0000${requestID}`;
let approval = permissionAutoApprovals.get(cacheKey);
if (!approval) {
prunePermissionAutoApprovalCache();
approval = (async () => {
let lastError: unknown;
for (let attempt = 1; attempt <= AUTO_APPROVE_PERMISSION_MAX_ATTEMPTS; attempt += 1) {
try {
await client.replyPermission(requestID, 'always');
logger.info('[opencode-permission] Auto-approved permission', {
requestID,
sessionID: getEventSessionId(permission),
reply: 'always',
});
return;
} catch (error) {
lastError = error;
logger.warn('[opencode-permission] Auto-approval attempt failed', {
requestID,
attempt,
error: error instanceof Error ? error.message : String(error),
});
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
})();
permissionAutoApprovals.set(cacheKey, approval);
void approval.catch(() => {
if (permissionAutoApprovals.get(cacheKey) === approval) {
permissionAutoApprovals.delete(cacheKey);
}
});
}
await approval;
return true;
}
function isQuestionAnswers(input: unknown): input is string[][] {
return Array.isArray(input)
&& input.every((answer) => (
@@ -924,10 +862,6 @@ export async function handleOpencodeRoutes(
}
const sessionFilter = url.searchParams.get('sessionId')?.trim() ?? null;
const client = createOpencodeClient({
baseUrl: context.status.url!,
directory: context.activeProject.path,
});
res.statusCode = 200;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -949,18 +883,6 @@ export async function handleOpencodeRoutes(
const parsed = parseSseFrame(frameText);
const normalized = parsed ? normalizeOpencodeEventFrame(parsed, sessionFilter) : null;
if (normalized) {
if (normalized.type === 'permission.asked') {
try {
await autoApprovePermission(client, context.activeProject.path, normalized.payload);
} catch (error) {
logger.warn('[opencode-permission] Auto-approval failed after retries', {
requestID: getPermissionRequestId(normalized.payload),
error: error instanceof Error ? error.message : String(error),
});
}
boundary = buffer.indexOf('\n\n');
continue;
}
if (normalized.type === 'session.error') {
const summary = summarizeOpencodeSessionError(normalized.payload);
logger.warn('[opencode-events] session.error from runtime', summary ?? {});
@@ -1016,10 +938,8 @@ export async function handleOpencodeRoutes(
projectPath?: string;
parentPath?: string;
projectName?: string;
templateId?: string;
defaultModel?: unknown;
}>(req);
if (!isProjectTemplateId(body.templateId)) throw new Error('Missing or invalid project template');
const defaultModel = body.defaultModel === undefined || body.defaultModel === null
? null
: typeof body.defaultModel === 'string' && body.defaultModel.trim()
@@ -1034,7 +954,6 @@ export async function handleOpencodeRoutes(
: buildNewProjectPath(body.parentPath, body.projectName);
const { config } = await initializeProjectDirectory({
projectPath,
templateId: body.templateId,
defaultModel,
allowExistingDirectory: useSelectedDirectory,
});
@@ -1068,9 +987,16 @@ export async function handleOpencodeRoutes(
if (!body.projectId || !body.config) throw new Error('Missing project configuration');
const project = await findProjectById(ctx, body.projectId);
if (!project) throw new Error('Project not found');
const previousResult = await readProjectConfig(project.path);
const previousConfig = previousResult.status === 'valid' ? previousResult.config : null;
const config = await writeProjectConfig(project.path, body.config);
let status = ctx.opencodeManager.getStatus();
if (config.initialized && status.state === 'running') status = await ctx.opencodeManager.restart();
const runtimeSettingsChanged = !previousConfig
|| previousConfig.defaultModel !== config.defaultModel
|| previousConfig.superpowersEnabled !== config.superpowersEnabled;
if (config.initialized && runtimeSettingsChanged && status.state === 'running') {
status = await ctx.opencodeManager.restart();
}
sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
@@ -1078,6 +1004,76 @@ export async function handleOpencodeRoutes(
return true;
}
if (url.pathname === '/api/opencode/projects/conversations' && req.method === 'GET') {
try {
const projectId = url.searchParams.get('projectId')?.trim();
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
sendJson(res, 200, { state: await readProjectConversationState(project.path) });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/conversations' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
projectId?: string;
sessionId?: string;
agentId?: string;
action?: 'link' | 'archive' | 'restore' | 'delete' | 'read' | 'increment-unread' | 'complete';
unread?: boolean;
}>(req);
const projectId = body.projectId?.trim();
const sessionId = body.sessionId?.trim();
if (!projectId || !sessionId) throw new Error('Missing conversation identifiers');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
const state = await readProjectConversationState(project.path);
let nextState: ProjectConversationState;
const now = new Date().toISOString();
switch (body.action) {
case 'link':
if (!body.agentId?.trim()) throw new Error('Missing Agent id');
nextState = upsertProjectSessionMetadata(state, sessionId, body.agentId.trim(), now);
break;
case 'archive':
nextState = patchProjectSessionMetadata(state, sessionId, { archivedAt: now }, now);
break;
case 'restore':
nextState = patchProjectSessionMetadata(state, sessionId, { archivedAt: null }, now);
break;
case 'delete':
nextState = removeProjectSessionMetadata(state, sessionId, now);
break;
case 'read':
nextState = patchProjectSessionMetadata(state, sessionId, { unreadCount: 0 }, now);
break;
case 'increment-unread': {
const current = state.sessions.find((item) => item.sessionId === sessionId);
nextState = patchProjectSessionMetadata(
state,
sessionId,
{ unreadCount: (current?.unreadCount ?? 0) + 1 },
now,
);
break;
}
case 'complete':
nextState = completeProjectSession(state, sessionId, body.unread === true, now);
break;
default:
throw new Error('Unknown conversation action');
}
sendJson(res, 200, { success: true, state: await writeProjectConversationState(project.path, nextState) });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/knowledge' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ projectId?: string; fileName?: string; contentBase64?: string }>(req);
@@ -1192,9 +1188,9 @@ export async function handleOpencodeRoutes(
if (!body.projectId) throw new Error('Missing project id');
const requestedProject = await findProjectById(ctx, body.projectId);
if (!requestedProject) throw new Error('Project not found');
const templateCheck = await validateProjectTemplateForActivation(requestedProject);
if (!templateCheck.ok) {
sendProjectTemplateActivationFailure(res, templateCheck);
const projectCheck = await validateProjectForActivation(requestedProject);
if (!projectCheck.ok) {
sendProjectActivationFailure(res, projectCheck);
return true;
}
const project = await ctx.opencodeProjectStore.setActiveProject(body.projectId);
@@ -1306,7 +1302,13 @@ export async function handleOpencodeRoutes(
runtimeConfigSummary,
);
if (!client) return true;
const model = await resolveRuntimePromptModel(runtimeConfigSummary);
const body = await parseJsonBody<{ model?: unknown }>(req);
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
return true;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
if (!model) {
sendJson(res, 409, { success: false, error: 'No runtime model configured' });
return true;
@@ -1445,19 +1447,8 @@ export async function handleOpencodeRoutes(
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
const permissions = await client.listPermissions();
await Promise.allSettled(permissions.map(async (permission) => {
try {
await autoApprovePermission(client, activeProject?.path ?? '', permission);
} catch (error) {
logger.warn('[opencode-permission] Failed to recover pending permission with auto-approval', {
requestID: getPermissionRequestId(permission),
error: error instanceof Error ? error.message : String(error),
});
}
}));
sendJson(res, 200, { permissions: [] });
sendJson(res, 200, { permissions });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
@@ -1683,14 +1674,25 @@ export async function handleOpencodeRoutes(
runtimeConfigSummary,
);
if (!client) return true;
const body = await parseJsonBody<{ text?: string; files?: unknown; userContext?: unknown; agent?: unknown }>(req);
const body = await parseJsonBody<{
text?: string;
files?: unknown;
userContext?: unknown;
agent?: unknown;
model?: unknown;
}>(req);
const text = typeof body.text === 'string' ? body.text.trim() : '';
if (!text) {
sendJson(res, 400, { success: false, error: 'Missing message text' });
return true;
}
const sessionID = decodeURIComponent(sessionMessagesMatch[1]);
const model = await resolveRuntimePromptModel(runtimeConfigSummary);
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
return true;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
const files = normalizePromptFileParts(body.files);
const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : '';
const agent = typeof body.agent === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agent.trim())