feat: update Makelore modules and conversations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user