Makelore 2.0 initial clean snapshot
This commit is contained in:
386
electron/opencode/agent-writer.ts
Normal file
386
electron/opencode/agent-writer.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { BuiltInRoleSubagentId } from '../../src/lib/role-subagents';
|
||||
|
||||
const COURSE_ROLE_IDS = [
|
||||
'pm',
|
||||
'product',
|
||||
'designer',
|
||||
'dev',
|
||||
'marketing',
|
||||
'deploy',
|
||||
'gameplay',
|
||||
'assets',
|
||||
'game-dev',
|
||||
'game-release',
|
||||
'game-showcase',
|
||||
] as const satisfies readonly BuiltInRoleSubagentId[];
|
||||
const BASE_REQUIRED_SKILL_IDS = ['youth-plain-language'] as const;
|
||||
|
||||
export type CourseRuntimeRoleId = typeof COURSE_ROLE_IDS[number];
|
||||
export type RuntimeSubagentTemplateRoleId = CourseRuntimeRoleId | 'custom';
|
||||
|
||||
export interface RuntimeSubagentCreateInput {
|
||||
projectPath: string;
|
||||
displayName: string;
|
||||
templateRoleId: RuntimeSubagentTemplateRoleId;
|
||||
prompt?: string;
|
||||
requiredSkillIds?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeSubagentCreateResult {
|
||||
id: string;
|
||||
displayName: string;
|
||||
templateRoleId: RuntimeSubagentTemplateRoleId;
|
||||
filePath: string;
|
||||
skillIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSubagentUpdateInput {
|
||||
projectPath: string;
|
||||
agentId: string;
|
||||
skillIds: string[];
|
||||
requiredSkillIds?: string[];
|
||||
displayName?: string;
|
||||
templateRoleId?: RuntimeSubagentTemplateRoleId;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSubagentUpdateResult {
|
||||
id: string;
|
||||
filePath: string;
|
||||
skillIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function isCourseRoleId(value: unknown): value is CourseRuntimeRoleId {
|
||||
return typeof value === 'string' && COURSE_ROLE_IDS.includes(value as CourseRuntimeRoleId);
|
||||
}
|
||||
|
||||
function isTemplateRoleId(value: unknown): value is RuntimeSubagentTemplateRoleId {
|
||||
return value === 'custom' || isCourseRoleId(value);
|
||||
}
|
||||
|
||||
function getResourcesPath(): string | null {
|
||||
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
|
||||
return typeof processWithResources.resourcesPath === 'string' && processWithResources.resourcesPath
|
||||
? processWithResources.resourcesPath
|
||||
: null;
|
||||
}
|
||||
|
||||
function getTemplateCandidates(templateRoleId: CourseRuntimeRoleId): string[] {
|
||||
const resourcesPath = getResourcesPath();
|
||||
return [
|
||||
...(resourcesPath ? [path.join(resourcesPath, 'course-agents', `${templateRoleId}.md`)] : []),
|
||||
path.resolve(currentDir, '..', '..', '.opencode', 'agent', `${templateRoleId}.md`),
|
||||
path.resolve(process.cwd(), '.opencode', 'agent', `${templateRoleId}.md`),
|
||||
];
|
||||
}
|
||||
|
||||
async function readBuiltInTemplate(templateRoleId: CourseRuntimeRoleId): Promise<string> {
|
||||
const templatePath = getTemplateCandidates(templateRoleId).find((candidate) => existsSync(candidate));
|
||||
if (!templatePath) {
|
||||
throw new Error(`Missing built-in subagent template: ${templateRoleId}`);
|
||||
}
|
||||
return await readFile(templatePath, 'utf8');
|
||||
}
|
||||
|
||||
function yamlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildCustomAgentMarkdown(displayName: string, prompt: string): string {
|
||||
return `---
|
||||
description: ${yamlString(displayName)}
|
||||
mode: all
|
||||
color: "#64D2C8"
|
||||
permission:
|
||||
"*": deny
|
||||
read: allow
|
||||
glob: allow
|
||||
grep: allow
|
||||
list: allow
|
||||
question: allow
|
||||
todowrite: allow
|
||||
skill:
|
||||
"*": deny
|
||||
---
|
||||
|
||||
${prompt.trim()}
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (!normalized) throw new Error('Missing subagent display name');
|
||||
if (normalized.length > 80) throw new Error('Subagent display name is too long');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePrompt(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (!normalized) throw new Error('Missing custom subagent prompt');
|
||||
if (normalized.length > 20000) throw new Error('Custom subagent prompt is too long');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (!normalized) throw new Error('Missing subagent id');
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(normalized)) throw new Error('Invalid subagent id');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSkillIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) throw new Error('Missing skill ids');
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
const skillId = typeof item === 'string' ? item.trim() : '';
|
||||
if (!skillId) continue;
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillId)) throw new Error(`Invalid skill id: ${skillId}`);
|
||||
if (!seen.has(skillId)) {
|
||||
seen.add(skillId);
|
||||
result.push(skillId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeOptionalSkillIds(value: unknown): string[] {
|
||||
return value === undefined ? [] : normalizeSkillIds(value);
|
||||
}
|
||||
|
||||
function mergeSkillIds(...skillLists: Array<readonly string[]>): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const skillList of skillLists) {
|
||||
for (const skillId of skillList) {
|
||||
if (!seen.has(skillId)) {
|
||||
seen.add(skillId);
|
||||
result.push(skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateInput(input: RuntimeSubagentCreateInput): Omit<RuntimeSubagentCreateInput, 'prompt'> & { prompt?: string } {
|
||||
const displayName = normalizeDisplayName(input.displayName);
|
||||
if (!isTemplateRoleId(input.templateRoleId)) throw new Error('Invalid template role id');
|
||||
return {
|
||||
projectPath: input.projectPath,
|
||||
displayName,
|
||||
templateRoleId: input.templateRoleId,
|
||||
...(input.templateRoleId === 'custom' ? { prompt: normalizePrompt(input.prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateRestoreInput(input: RuntimeSubagentUpdateInput): RuntimeSubagentCreateInput {
|
||||
const displayName = normalizeDisplayName(input.displayName);
|
||||
if (!isTemplateRoleId(input.templateRoleId)) throw new Error('Invalid template role id');
|
||||
return {
|
||||
projectPath: input.projectPath,
|
||||
displayName,
|
||||
templateRoleId: input.templateRoleId,
|
||||
...(input.templateRoleId === 'custom' ? { prompt: normalizePrompt(input.prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentId(templateRoleId: RuntimeSubagentTemplateRoleId): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 14);
|
||||
return `nitu-${templateRoleId}-${timestamp}-${randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
async function writeUniqueAgentFile(agentDir: string, id: string, markdown: string): Promise<{ id: string; filePath: string }> {
|
||||
let candidateId = id;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const filePath = path.join(agentDir, `${candidateId}.md`);
|
||||
if (!existsSync(filePath)) {
|
||||
await writeFile(filePath, markdown, { encoding: 'utf8', flag: 'wx' });
|
||||
return { id: candidateId, filePath };
|
||||
}
|
||||
candidateId = `${id}-${randomBytes(2).toString('hex')}`;
|
||||
}
|
||||
throw new Error('Failed to create a unique subagent id');
|
||||
}
|
||||
|
||||
async function buildRuntimeSubagentMarkdown(input: RuntimeSubagentCreateInput): Promise<string> {
|
||||
return input.templateRoleId === 'custom'
|
||||
? buildCustomAgentMarkdown(input.displayName, normalizePrompt(input.prompt))
|
||||
: await readBuiltInTemplate(input.templateRoleId);
|
||||
}
|
||||
|
||||
function splitFrontmatter(markdown: string): { frontmatter: string; body: string } {
|
||||
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?([\s\S]*)$/);
|
||||
if (!match) throw new Error('Subagent file is missing YAML frontmatter');
|
||||
return {
|
||||
frontmatter: match[1] ?? '',
|
||||
body: match[2] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function countIndent(line: string): number {
|
||||
const match = line.match(/^ */);
|
||||
return match?.[0].length ?? 0;
|
||||
}
|
||||
|
||||
function yamlKey(value: string): string {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(value) ? value : yamlString(value);
|
||||
}
|
||||
|
||||
function buildSkillPermissionLines(skillIds: string[]): string[] {
|
||||
return [
|
||||
' skill:',
|
||||
...(skillIds.length > 0
|
||||
? skillIds.map((skillId) => ` ${yamlKey(skillId)}: allow`)
|
||||
: [' "*": deny']),
|
||||
];
|
||||
}
|
||||
|
||||
function findPermissionBlock(lines: string[]): { start: number; end: number } | null {
|
||||
const start = lines.findIndex((line) => /^permission:\s*$/.test(line));
|
||||
if (start < 0) return null;
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.trim() && countIndent(line) === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function findPermissionSkillBlock(lines: string[], permissionStart: number, permissionEnd: number): { start: number; end: number } | null {
|
||||
for (let index = permissionStart + 1; index < permissionEnd; index += 1) {
|
||||
if (/^ {2}skill:\s*$/.test(lines[index])) {
|
||||
let end = permissionEnd;
|
||||
for (let cursor = index + 1; cursor < permissionEnd; cursor += 1) {
|
||||
const line = lines[cursor];
|
||||
if (line.trim() && countIndent(line) <= 2) {
|
||||
end = cursor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { start: index, end };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateAgentSkillPermissions(markdown: string, skillIds: string[]): string {
|
||||
const { frontmatter, body } = splitFrontmatter(markdown);
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const skillLines = buildSkillPermissionLines(skillIds);
|
||||
const permissionBlock = findPermissionBlock(lines);
|
||||
|
||||
if (!permissionBlock) {
|
||||
lines.push('permission:', ...skillLines);
|
||||
} else {
|
||||
const skillBlock = findPermissionSkillBlock(lines, permissionBlock.start, permissionBlock.end);
|
||||
if (skillBlock) {
|
||||
lines.splice(skillBlock.start, skillBlock.end - skillBlock.start, ...skillLines);
|
||||
} else {
|
||||
lines.splice(permissionBlock.end, 0, ...skillLines);
|
||||
}
|
||||
}
|
||||
|
||||
return `---\n${lines.join('\n')}\n---\n\n${body.replace(/^\r?\n/, '')}`;
|
||||
}
|
||||
|
||||
function readBoundSkillIds(markdown: string): string[] {
|
||||
const { frontmatter } = splitFrontmatter(markdown);
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const permissionBlock = findPermissionBlock(lines);
|
||||
if (!permissionBlock) return [];
|
||||
const skillBlock = findPermissionSkillBlock(lines, permissionBlock.start, permissionBlock.end);
|
||||
if (!skillBlock) return [];
|
||||
|
||||
const result: string[] = [];
|
||||
for (let index = skillBlock.start + 1; index < skillBlock.end; index += 1) {
|
||||
const match = lines[index].match(/^ {4}(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9._-]*)):\s*(allow|deny)\s*$/);
|
||||
if (!match) continue;
|
||||
const key = match[1] ?? match[2] ?? '';
|
||||
const action = match[3];
|
||||
if (key !== '*' && action === 'allow') result.push(key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function canRefreshFromMetadata(input: RuntimeSubagentUpdateInput): boolean {
|
||||
const hasDisplayName = typeof input.displayName === 'string' && input.displayName.trim().length > 0;
|
||||
if (!hasDisplayName || !isTemplateRoleId(input.templateRoleId)) return false;
|
||||
if (input.templateRoleId === 'custom') {
|
||||
return typeof input.prompt === 'string' && input.prompt.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createRuntimeSubagent(input: RuntimeSubagentCreateInput): Promise<RuntimeSubagentCreateResult> {
|
||||
const normalized = validateInput(input);
|
||||
const requiredSkillIds = normalizeOptionalSkillIds(input.requiredSkillIds);
|
||||
const agentDir = path.join(path.resolve(normalized.projectPath), '.opencode', 'agent');
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
|
||||
const baseMarkdown = await buildRuntimeSubagentMarkdown(normalized);
|
||||
const skillIds = mergeSkillIds(readBoundSkillIds(baseMarkdown), requiredSkillIds, BASE_REQUIRED_SKILL_IDS);
|
||||
const markdown = updateAgentSkillPermissions(baseMarkdown, skillIds);
|
||||
const createdAt = new Date().toISOString();
|
||||
const written = await writeUniqueAgentFile(agentDir, createAgentId(normalized.templateRoleId), markdown);
|
||||
|
||||
return {
|
||||
id: written.id,
|
||||
displayName: normalized.displayName,
|
||||
templateRoleId: normalized.templateRoleId,
|
||||
filePath: written.filePath,
|
||||
skillIds,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateRuntimeSubagent(input: RuntimeSubagentUpdateInput): Promise<RuntimeSubagentUpdateResult> {
|
||||
const projectPath = path.resolve(input.projectPath);
|
||||
const agentId = normalizeAgentId(input.agentId);
|
||||
const skillIds = mergeSkillIds(
|
||||
normalizeSkillIds(input.skillIds),
|
||||
normalizeOptionalSkillIds(input.requiredSkillIds),
|
||||
BASE_REQUIRED_SKILL_IDS,
|
||||
);
|
||||
const agentDir = path.join(projectPath, '.opencode', 'agent');
|
||||
const filePath = path.resolve(agentDir, `${agentId}.md`);
|
||||
const relative = path.relative(agentDir, filePath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Invalid subagent id');
|
||||
|
||||
let currentMarkdown: string;
|
||||
try {
|
||||
currentMarkdown = await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
const restoreInput = validateRestoreInput(input);
|
||||
currentMarkdown = await buildRuntimeSubagentMarkdown(restoreInput);
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
}
|
||||
const baseMarkdown = canRefreshFromMetadata(input)
|
||||
? await buildRuntimeSubagentMarkdown(validateRestoreInput(input))
|
||||
: currentMarkdown;
|
||||
const nextMarkdown = updateAgentSkillPermissions(baseMarkdown, skillIds);
|
||||
await writeFile(filePath, nextMarkdown, 'utf8');
|
||||
|
||||
return {
|
||||
id: agentId,
|
||||
filePath,
|
||||
skillIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user