Files
makelore/electron/opencode/agent-writer.ts
2026-07-29 17:22:35 +08:00

387 lines
13 KiB
TypeScript

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(),
};
}