exportfunctionpublicAction(r){if(!r)returnnull;consttool=toolByName(r.tool);constargs=parseJson(r.args_json,{});return{id:r.id,roomId:r.room_id,messageId:r.message_id,connectionId:r.connection_id,tool:r.tool,args,risk:r.risk,riskLabel:RISK_LABEL[r.risk]||r.risk,description:tool?tool.description:'(알 수 없는 도구)',identifiers:tool&&tool.identifiers?tool.identifiers(args):[],status:r.status,proposedAt:r.proposed_at,decidedBy:r.decided_by,decidedAt:r.decided_at,result:parseJson(r.result_json,null),error:r.error,toolUseId:r.tool_use_id,relayDepth:r.relay_depth};}
room.kind==='group'?`방: 통합 방 — 연결된 모든 활성 모델이 함께 있다. 참여자: ${participants.map((p)=>`"${p.name}"${p.id===conn.id?'(나)':''}${p.identity&&p.identity.role?` — ${p.identity.role}`:''}`).join(', ')}. 운영자의 지시는 모두에게 전달되며, 운영자가 "@이름" 으로 지정한 모델만 답해야 할 때는 그 모델만 답한다(지금 네가 부름을 받았다). 다른 참여자에게 말을 넘기려면 "@이름" 을 쓴다(그 참여자는 자동으로 1회만 답한다). 자기 자신(@${conn.name})을 부르지 마라.${others.length?'':' 지금은 다른 활성 참여자가 없다.'}`:`방: 개별 방 — 운영자와 1:1 대화. 다른 모델은 이 대화를 보지 않는다.`,
operator?`운영자: ${operator.displayName||operator.login}(${operator.login}).`:'',context&&(context.page||context.id)?`운영자가 보고 있는 HAL 화면: ${context.page||''}${context.id?` (${context.id})`:''}.`:'',
conn.toolsSupported?`도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다. 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).`:`도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
if(!tool){results.push({type:'tool_result',toolUseId:u.id,name:u.name,ok:false,output:`알 수 없는 도구: ${u.name}. HAL 이 제공하는 도구만 부를 수 있다(임의 명령·셸 실행 도구는 없다).`});continue;}
if(tool.risk==='read'){try{constout=awaitrunTool(d,tool,u.input,actorFor(operator,conn));results.push({type:'tool_result',toolUseId:u.id,name:u.name,ok:true,output:toolOutputText(out)});}catch(e){results.push({type:'tool_result',toolUseId:u.id,name:u.name,ok:false,output:`도구 실행 실패(${e.code||'error'}): ${safeError(e)}`});}continue;}
if(proposals.length)return;// 승인 대기 — 승인·거부 전에는 모델을 다시 부르지 않는다
}
if(iter>MAX_ITER)R.appendMessage(d,{roomId:room.id,role:'system',author:'HAL',content:[{type:'text',text:`${conn.name} 의 도구 왕복이 ${MAX_ITER}회를 넘어 이 턴을 멈췄습니다 — 필요하면 다시 지시하세요.`}],relayDepth});
elseR.appendMessage(d,{roomId:room.id,role:'system',author:'HAL',content:[{type:'text',text:`${conn.name} 이(가) ${targets.map((t)=>'@'+t.name).join(' ')} 을(를) 불렀지만 모델→모델 전달은 자동으로 1회만 이어집니다 — 추가 반응은 사용자 지시로 진행합니다.`}],relayDepth});
if(['oauth_required','oauth_expired','provider_401','cli_not_logged_in'].includes(code))d.store.run('UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE \'reauth_required\' END, last_error=?, updated_at=? WHERE id=?',msg,nowIso(),conn.id);
constt=String(text||'').trim();if(!t)thrownewHttpError(400,'empty_message','지시 내용이 비어 있습니다');if(t.length>MAX_TEXT)thrownewHttpError(400,'message_too_long',`지시는 ${MAX_TEXT}자까지입니다`);
if(room.kind==='direct'){constconn=C.get(d,room.connection_id);if(!conn)thrownewHttpError(404,'connection_not_found','연결이 없습니다');if(conn.status!=='active')thrownewHttpError(409,'connection_inactive',`연결 "${conn.name}" 이(가) ${conn.status} 상태라 보낼 수 없습니다`,{status:conn.status,lastError:conn.lastError});responders=[conn];}
else{constparticipants=C.active(d);if(!participants.length)thrownewHttpError(409,'no_active_connections','활성 AI 연결이 없습니다 — AI 연동 화면에서 연결을 추가하세요');const{addressed,unknown}=R.parseAddressing(t,participants);if(unknown.length)thrownewHttpError(400,'unknown_addressee',`모르는 이름입니다: ${unknown.map((n)=>'@'+n).join(', ')}`,{unknown,participants:participants.map((p)=>p.name)});responders=addressed.length?participants.filter((p)=>addressed.includes(p.id)):participants;}
listActions({roomId=null,status=null,limit=200}={}){constw=[];constp=[];if(roomId){w.push('room_id=?');p.push(roomId);}if(status){w.push('status=?');p.push(status);}returnd.store.all(`SELECT * FROM ai_actions ${w.length?'WHERE '+w.join(' AND '):''} ORDER BY proposed_at DESC LIMIT ?`,...p,Math.min(1000,limit)).map(publicAction);},
getAction(id){returnpublicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?',id));},
/** 승인 뒤 실행(라우트가 재인증·3단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */
constclaimed=d.store.run('UPDATE ai_actions SET status=\'executing\', decided_by=?, decided_at=? WHERE id=? AND status=\'proposed\'',operator.id,nowIso(),actionId).changes;if(!claimed)thrownewHttpError(409,'action_not_proposed','이미 처리된 제안입니다');
letresult=null;leterror=null;
try{if(!tool)thrownewHttpError(400,'unknown_tool','알 수 없는 도구');result=awaitrunTool(d,tool,a.args,{operatorId:operator.id,label:`ai:${a.connectionId}`});}catch(e){error=`${e.code||'error'}: ${safeError(e)}`;}
d.store.run('UPDATE ai_actions SET status=?, result_json=?, error=? WHERE id=?',error?'failed':'executed',result?toJson(result):null,error,actionId);
d.store.run('UPDATE ai_actions SET status=\'rejected\', decided_by=?, decided_at=?, error=? WHERE id=?',operator.id,nowIso(),reason?String(reason).slice(0,500):null,actionId);
constconn=C.get(d,a.connectionId);if(conn)R.appendMessage(d,{roomId:a.roomId,role:'tool',connectionId:conn.id,author:conn.name,content:[{type:'tool_result',toolUseId:a.toolUseId,name:a.tool,ok:false,output:`운영자가 이 제안을 거부했습니다${reason?` — ${String(reason).slice(0,500)}`:''}. 실행되지 않았습니다.`,actionId}],relayDepth:a.relayDepth||0});
returnengine.getAction(actionId);// 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)