172 lines
12 KiB
JavaScript
172 lines
12 KiB
JavaScript
/** Reconfigure the user's YCode expert packages through the production expert API. Never reads WorkBuddy. */
|
|
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { pathToFileURL, fileURLToPath } from 'node:url'
|
|
import { build } from 'esbuild'
|
|
|
|
const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
export const definitions = JSON.parse(await fs.readFile(path.join(project, 'resources/agents/general-development.json'), 'utf8'))
|
|
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
export function packageFiles(agent) {
|
|
const manifest = {
|
|
name: agent.id, plugin: agent.id, agentName: agent.agentName, version: '2.1.0', expertType: 'agent',
|
|
description: agent.description, displayName: agent.displayName, profession: agent.profession,
|
|
displayDescription: agent.displayDescription, categoryId: '02-Engineering', author: { name: 'YCode' },
|
|
agents: [`./agents/${agent.agentName}.md`], skills: [], tags: agent.tags,
|
|
defaultInitPrompt: agent.quickPrompts[0], quickPrompts: agent.quickPrompts, avatar: agent.avatar,
|
|
ycode: { generalDevelopmentRole: agent.role, definitionVersion: definitions.version }
|
|
}
|
|
const prompt = [`# ${agent.profession.zh}`, '', ...definitions.commonInstructions, '', '## 工作职责', '', ...agent.instructions].join('\n\n')
|
|
return {
|
|
'.codebuddy-plugin/plugin.json': JSON.stringify(manifest, null, 2) + '\n',
|
|
[`agents/${agent.agentName}.md`]: `---\nname: ${agent.agentName}\ndescription: ${JSON.stringify(agent.description)}\nmaxTurns: 50\nskills: []\n---\n\n${prompt}\n`,
|
|
'README.md': `# ${agent.profession.zh}\n\n${agent.displayDescription.zh}\n\n这是用户定制的通用开发角色,职责、提示词和快捷提问来自 YCode 的 general-development.json。\n角色配置不授予额外工具权限。\n`
|
|
}
|
|
}
|
|
async function ordinaryDirectory(target) {
|
|
const absolute = path.resolve(target)
|
|
let current = path.parse(absolute).root
|
|
for (const part of path.relative(current, absolute).split(path.sep)) {
|
|
current = path.join(current, part)
|
|
const stat = await fs.lstat(current)
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('Ordinary directory required: ' + current)
|
|
}
|
|
}
|
|
async function inventory(root) {
|
|
const items = []
|
|
async function walk(directory) {
|
|
await ordinaryDirectory(directory)
|
|
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, entry.name)
|
|
if (entry.isSymbolicLink()) throw Error('Links are not supported: ' + file)
|
|
if (entry.isDirectory()) await walk(file)
|
|
else if (entry.isFile()) items.push([path.relative(root, file), hash(await fs.readFile(file))])
|
|
else throw Error('Ordinary file required: ' + file)
|
|
}
|
|
}
|
|
await walk(root)
|
|
return items.sort(([left], [right]) => left.localeCompare(right))
|
|
}
|
|
async function avatarResource(agent) {
|
|
if (!/^[a-z-]+$/.test(agent.role) || agent.avatar !== `./avatars/${agent.role}.png`) throw Error('Invalid general role avatar: ' + agent.id)
|
|
const bytes = await fs.readFile(path.join(project, 'resources/agents', agent.avatar))
|
|
if (bytes.length > 500 * 1024 || bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
|
|
|| bytes.readUInt32BE(16) !== 256 || bytes.readUInt32BE(20) !== 256) throw Error('A 256px PNG avatar below 500KiB is required: ' + agent.id)
|
|
return { bytes, url: 'data:image/png;base64,' + bytes.toString('base64'), sha256: hash(bytes) }
|
|
}
|
|
async function installAvatar(packageDirectory, agent, avatar) {
|
|
await ordinaryDirectory(packageDirectory)
|
|
const directory = path.join(packageDirectory, 'avatars')
|
|
await fs.mkdir(directory, { recursive: true })
|
|
await ordinaryDirectory(directory)
|
|
const target = path.join(directory, `${agent.role}.png`)
|
|
try { const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('Avatar must be an ordinary file: ' + target) }
|
|
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
const temporary = target + '.' + randomUUID() + '.tmp'
|
|
await fs.writeFile(temporary, avatar.bytes, { flag: 'wx' })
|
|
try { await fs.rename(temporary, target) } finally { await fs.rm(temporary, { force: true }) }
|
|
if (hash(await fs.readFile(target)) !== avatar.sha256) throw Error('Installed avatar differs: ' + agent.id)
|
|
}
|
|
async function loadService(userData, directory) {
|
|
const modulePath = path.join(directory, 'expert-service.mjs')
|
|
await build({ stdin: { contents: "export * from './src/main/capabilities/experts'", resolveDir: project, loader: 'ts' },
|
|
outfile: modulePath, bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning',
|
|
plugins: [{ name: 'explicit-ycode-data', setup(builder) {
|
|
builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'explicit-ycode-data' }))
|
|
builder.onLoad({ filter: /.*/, namespace: 'explicit-ycode-data' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(userData)}};export const safeStorage={isEncryptionAvailable:()=>false}` }))
|
|
} }] })
|
|
return import(pathToFileURL(modulePath).href)
|
|
}
|
|
export async function configure(userData, { checkOnly = false } = {}) {
|
|
userData = path.resolve(userData)
|
|
if (path.basename(userData).toLowerCase() !== 'ycode') throw Error('An explicit YCode user-data folder is required')
|
|
await ordinaryDirectory(userData)
|
|
const reports = await fs.mkdtemp(path.join(project, '.tmp/general-development-agents-'))
|
|
const service = await loadService(userData, reports)
|
|
const marketRoot = path.join(userData, 'plugins/marketplaces/my-experts')
|
|
const packageRoot = path.join(marketRoot, 'plugins')
|
|
const marketFile = path.join(marketRoot, '.codebuddy-plugin/marketplace.json')
|
|
const beforeMarket = JSON.parse(await fs.readFile(marketFile, 'utf8'))
|
|
const ids = new Set(definitions.agents.map(agent => agent.id))
|
|
const unrelated = beforeMarket.plugins.filter(entry => !ids.has(entry.name))
|
|
const plans = []
|
|
for (const agent of definitions.agents) {
|
|
const files = packageFiles(agent), validation = service.validateExpertFiles(files, agent.id)
|
|
const avatar = await avatarResource(agent)
|
|
if (validation.errors.length) throw Error(validation.errors.join('\n'))
|
|
let previous
|
|
try { previous = await service.readExpert(agent.id) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
if (previous && previous.expert.agentName !== agent.agentName) throw Error('Agent identity changed: ' + agent.id)
|
|
const alreadyConfigured = previous && Object.entries(files).every(([name, content]) => previous.files[name] === content)
|
|
&& Object.keys(previous.files).every(name => name in files || name === '.created-by-session')
|
|
&& previous.expert.avatar === avatar.url
|
|
plans.push({ agent, files, avatar, previous, alreadyConfigured, warnings: validation.warnings })
|
|
}
|
|
let backup
|
|
const changed = plans.filter(plan => !plan.alreadyConfigured)
|
|
if (checkOnly && changed.length) throw Error('Installed definitions differ: ' + changed.map(plan => plan.agent.role).join(', '))
|
|
if (changed.length) {
|
|
backup = path.join(userData, 'capabilities/expert-backups', 'general-development-' + randomUUID())
|
|
const backupRelative = path.relative(userData, backup)
|
|
if (path.isAbsolute(backupRelative) || backupRelative.startsWith('..') || !backupRelative) throw Error('Backup path escaped YCode')
|
|
await fs.mkdir(backup, { recursive: true })
|
|
await ordinaryDirectory(backup)
|
|
await fs.writeFile(path.join(backup, 'marketplace.before.json'), JSON.stringify(beforeMarket, null, 2), { flag: 'wx' })
|
|
const snapshots = []
|
|
for (const { agent, previous } of changed) {
|
|
if (!previous) { snapshots.push({ id: agent.id, existed: false }); continue }
|
|
const source = path.join(packageRoot, agent.id), target = path.join(backup, agent.id)
|
|
const before = await inventory(source)
|
|
await fs.cp(source, target, { recursive: true, errorOnExist: true, force: false })
|
|
if (JSON.stringify(before) !== JSON.stringify(await inventory(target)) || JSON.stringify(before) !== JSON.stringify(await inventory(source))) throw Error('Expert changed while backing up: ' + agent.id)
|
|
snapshots.push({ id: agent.id, existed: true, files: before })
|
|
}
|
|
await fs.writeFile(path.join(backup, 'backup.json'), JSON.stringify({ source: userData, snapshots }, null, 2), { flag: 'wx' })
|
|
const completed = []
|
|
for (const { agent, files, avatar, previous } of changed) {
|
|
const owner = 'general-development-' + randomUUID()
|
|
const draft = await service.expertPackageOperation(owner, { id: agent.id, action: 'read', refresh: true })
|
|
if (JSON.stringify(draft.files) !== JSON.stringify(previous?.files ?? {})) throw Error('Expert changed after backup: ' + agent.id)
|
|
const edits = Object.fromEntries(Object.keys(draft.files).filter(name => !(name in files) && !['.created-by-session', '.pending-preview-session'].includes(name)).map(name => [name, null]))
|
|
await service.expertPackageOperation(owner, { id: agent.id, action: 'write', files: { ...edits, ...files } })
|
|
const validation = await service.expertPackageOperation(owner, { id: agent.id, action: 'validate' })
|
|
if (!validation.valid) throw Error(validation.errors.join('\n'))
|
|
const result = await service.expertPackageOperation(owner, { id: agent.id, action: 'register' })
|
|
if (!result.success) throw Error('Expert registration failed: ' + agent.id)
|
|
// The text-only editing API preserves binaries but cannot add them; install our verified PNG separately.
|
|
await installAvatar(path.join(packageRoot, agent.id), agent, avatar)
|
|
completed.push(agent.id)
|
|
await fs.writeFile(path.join(backup, 'applied.json'), JSON.stringify({ completed }, null, 2))
|
|
}
|
|
}
|
|
const checks = []
|
|
const listed = await service.listExperts()
|
|
for (const { agent, files, avatar } of plans) {
|
|
const document = await service.readExpert(agent.id)
|
|
const binding = await service.activateExpert(agent.id)
|
|
const turn = await service.expertTurnContext(agent.id)
|
|
if (!listed.some(item => item.id === agent.id && !item.error) || binding.profession !== agent.profession.zh
|
|
|| binding.name !== agent.displayName.zh || turn.display.profession !== agent.profession.zh) throw Error('Role activation mismatch: ' + agent.id)
|
|
for (const [name, content] of Object.entries(files)) if (document.files[name] !== content) throw Error('Role text mismatch: ' + name)
|
|
if (document.expert.skills?.length || turn.context.includes('<expert-skill path=') || turn.context.includes('<expert-skill-directory>')) throw Error('Specialist skill remains loaded: ' + agent.id)
|
|
if (Object.keys(document.files).some(name => !(name in files) && name !== '.created-by-session')) throw Error('Unexpected specialist text remains: ' + agent.id)
|
|
if (!turn.context.includes(document.prompt) || !agent.instructions.every(instruction => turn.context.includes(instruction))) throw Error('Role instructions did not reach turn context')
|
|
if (document.expert.avatar !== avatar.url || binding.avatarUrl !== avatar.url) throw Error('Avatar did not reach the list/detail/activation data: ' + agent.id)
|
|
checks.push({ id: agent.id, role: agent.role, profession: binding.profession, activated: true, filesMatched: Object.keys(files).length, startupSkills: 0,
|
|
avatarBytes: avatar.bytes.length, avatarSha256: avatar.sha256 })
|
|
}
|
|
const afterMarket = JSON.parse(await fs.readFile(marketFile, 'utf8'))
|
|
if (JSON.stringify(unrelated) !== JSON.stringify(afterMarket.plugins.filter(entry => !ids.has(entry.name)))) throw Error('Unrelated expert catalog entries changed')
|
|
const report = { result: 'PASS', checkOnly, userData, backup, changed: changed.map(plan => plan.agent.id), checks,
|
|
warnings: plans.flatMap(plan => plan.warnings.map(warning => ({ id: plan.agent.id, warning }))), messagesSent: 0 }
|
|
await fs.writeFile(path.join(reports, 'report.json'), JSON.stringify(report, null, 2))
|
|
return { ...report, reportDirectory: reports }
|
|
}
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const index = process.argv.indexOf('--user-data')
|
|
if (index < 0 || !process.argv[index + 1]) throw Error('Pass --user-data <absolute YCode user-data path>')
|
|
const report = await configure(process.argv[index + 1], { checkOnly: process.argv.includes('--check') })
|
|
console.log(JSON.stringify(report, null, 2))
|
|
}
|