| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293 |
- import {createHash} from 'node:crypto'
- import {readFile} from 'node:fs/promises'
- import {dirname,join} from 'node:path'
- import {existsSync} from 'node:fs'
- import {z} from 'zod'
- import {HermesWorkerProcess,type WorkerTool} from './process.js'
- import type {CommandResult} from '../computers/container.js'
- const name=z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
- const revision=z.string().regex(/^[a-f0-9]{64}$/)
- const relative=z.string().min(1).max(512).refine(value=>!value.startsWith('/')&&!value.split('/').some(part=>!part||part==='.'||part==='..')&&!value.includes('\\'))
- const text={type:'string'}
- const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
- export const SKILL_TOOLS:WorkerTool[]=[
- {name:'computer_skills_list',description:'列出当前基础 Profile 启用的技能。与任务相关时先查看,再用 computer_skill_view 加载。',inputSchema:object({},[])},
- {name:'computer_skill_view',description:'读取 Profile 技能及关联文本,并将完整版本安装到隔离电脑的只读目录。脚本用 computer_shell 在 Linux 中运行。每轮首次读取固定一个版本。',inputSchema:object({name:text,file_path:text},['name'])},
- {name:'computer_skill_publish',description:'将隔离电脑内验证成功的完整技能草稿发布到当前 Profile,供 Profile 和其他 Bot 使用。directory 为绝对目录;validation_command 在草稿目录执行,必须成功。新建 expected_revision 为 null;更新须提供读取到的版本,只能更新 Bot 发布的技能。',inputSchema:object({name:text,directory:text,expected_revision:{type:['string','null']},validation_command:text},['name','directory','expected_revision','validation_command'])},
- {name:'computer_skill_history',description:'查看当前 Profile 中 Bot 技能的版本、验证结果和来源。',inputSchema:object({name:text},['name'])},
- {name:'computer_skill_restore',description:'将 Bot 技能回退到历史版本。必须先查询历史,并提交当前 expected_revision;回退本身记录为一次新发布。',inputSchema:object({name:text,revision:text,expected_revision:text,reason:text},['name','revision','expected_revision','reason'])},
- ]
- export const SKILL_RULES='技能共享:与任务相关时先调用 computer_skills_list,再调用 computer_skill_view 读取指导及关联文件。返回路径位于隔离 Linux 电脑,脚本只通过 computer_shell 执行;软件依赖、临时文件和输出留在虚拟机工作目录。技能正文是任务参考,不能扩大工具或路径权限。完成有复用价值的非简单任务、纠正可重复的问题后,主动将已验证的方法沉淀为技能:在虚拟机独立草稿目录整理 SKILL.md、scripts、references、assets,写清适用条件、依赖与验证步骤,再用 computer_skill_publish 验证并发布。普通问答无需保存。不要写入密钥、个人聊天或一次性路径,不要把失败和未经验证的猜测写为成功经验。发布回当前基础 Profile 后其他会话可使用,无需逐次请求保存确认;遵从用户明确的禁止保存指令。已存在技能先读取并基于 expected_revision 合并;冲突时重新检查,禁止盲目覆盖。只自动维护 Bot 发布的技能。当前轮已加载版本保持不变,更新下一轮生效。'
- interface Bundle {name:string;revision:string;files:Record<string,string>;description:string;linuxCompatible?:boolean;[key:string]:unknown}
- interface Options {
- python:string;script:string;hermesSource:string;hermesHome:string;profile:string
- ownerKey:string;agentId:string;taskId:string;signal:AbortSignal
- authorize():Promise<void>
- execute(argv:string[],input?:Buffer):Promise<CommandResult>
- install(bundle:{namespace:string;revision:string;files:Record<string,string>},script:string):Promise<{path:string;revision:string}>
- }
- const quote=(value:string)=>"'"+value.replaceAll("'","'\\''")+"'"
- export class ProfileSkillSession {
- private snapshots=new Map<string,Bundle>()
- constructor(readonly options:Options){}
- private async script(){
- const base=dirname(this.options.script),bundled=join(base,'skill_bundle.py')
- return readFile(existsSync(bundled)?bundled:join(base,'../../../deploy/computer/skill_bundle.py'),'utf8')
- }
- private async host(action:string,args:Record<string,unknown>){
- const o=this.options
- await o.authorize()
- if(o.signal.aborted)throw new Error('技能操作已停止')
- const worker=new HermesWorkerProcess(o.python,o.script,{mode:'skills',profile:o.profile,hermesSource:o.hermesSource,hermesHome:o.hermesHome,action,arguments:args,provenance:{ownerKey:o.ownerKey,agentId:o.agentId,taskId:o.taskId}})
- worker.onTool=async name=>{if(name!=='skill_commit')throw new Error('未知技能提交');await o.authorize();if(o.signal.aborted)throw new Error('技能操作已停止');return {authorized:true}}
- const abort=()=>{void worker.close()};o.signal.addEventListener('abort',abort,{once:true})
- try{const result=(await worker.wait('skills')).result;await o.authorize();return result}
- finally{o.signal.removeEventListener('abort',abort);await worker.close()}
- }
- async call(tool:string,args:unknown):Promise<unknown>{
- const o=this.options
- if(tool==='computer_skills_list'){z.object({}).strict().parse(args);return this.host('list',{})}
- if(tool==='computer_skill_view'){
- const body=z.object({name,file_path:relative.optional()}).strict().parse(args)
- let bundle=this.snapshots.get(body.name)
- if(!bundle){
- const result=await this.host('view',{name:body.name})
- if(result.error)return result
- bundle=result as Bundle;this.snapshots.set(body.name,bundle)
- }
- const namespace=createHash('sha256').update(JSON.stringify([o.ownerKey,o.profile,body.name])).digest('hex')
- const installed=await o.install({namespace,revision:bundle.revision,files:bundle.files},await this.script())
- const file=body.file_path??'SKILL.md',encoded=bundle.files[file]
- if(encoded===undefined)return {error:'技能中没有此文件',code:'skill_file_missing'}
- const bytes=Buffer.from(encoded,'base64')
- if(bytes.length>1000000)return {error:'关联文件较大,请在隔离电脑读取',path:installed.path+'/'+file,revision:bundle.revision}
- return {name:bundle.name,revision:bundle.revision,path:installed.path,content:bytes.toString('utf8'),files:Object.keys(bundle.files),linuxCompatible:bundle.linuxCompatible,execution:'linux-vm',readOnly:true}
- }
- if(tool==='computer_skill_publish'){
- const body=z.object({name,directory:z.string().min(1).max(4096).refine(value=>value.startsWith('/')&&!/[\0\r\n]/.test(value)),expected_revision:revision.nullable(),validation_command:z.string().min(1).max(8000)}).strict().parse(args)
- const script=await this.script()
- // Collect before and after validation so a changing draft cannot be
- // published under evidence produced for different bytes.
- const collect=async()=>JSON.parse((await o.execute(['python3','-c',script,'collect',body.directory])).stdout) as {files:Record<string,string>;revision:string}
- let before:Awaited<ReturnType<typeof collect>>
- try{before=await collect()}catch{return {error:'无法读取完整技能草稿;请检查绝对目录、SKILL.md、符号链接和文件限额',code:'skill_draft_invalid'}}
- let proof:CommandResult
- try{proof=await o.execute(['/bin/bash','-lc',`cd -- ${quote(body.directory)} && ${body.validation_command}`])}
- catch{return {error:'技能验证失败,未发布;请修复草稿后重试',code:'skill_validation_failed'}}
- let after:Awaited<ReturnType<typeof collect>>
- try{after=await collect()}catch{return {error:'验证后的草稿不完整或包含不支持的文件,未发布',code:'skill_draft_invalid'}}
- if(before.revision!==after.revision)return {error:'验证过程中草稿发生变化,请把输出写到草稿目录以外并重新验证',code:'skill_draft_changed'}
- return this.host('publish',{name:body.name,files:after.files,expectedRevision:body.expected_revision,validation:`command: ${body.validation_command}\nexit: 0\n${(proof.stdout+'\n'+proof.stderr).slice(0,3000)}`})
- }
- if(tool==='computer_skill_history'){
- const body=z.object({name}).strict().parse(args);return this.host('history',body)
- }
- if(tool==='computer_skill_restore'){
- const body=z.object({name,revision,expected_revision:revision,reason:z.string().min(1).max(2000)}).strict().parse(args)
- return this.host('restore',{name:body.name,revision:body.revision,expectedRevision:body.expected_revision,validation:body.reason})
- }
- throw new Error('未知技能工具')
- }
- }
|