@hera-al/server 1.6.33 → 1.6.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{Hono as e}from"hono";import{serve as t}from"@hono/node-server";import{getCookie as r,setCookie as n,deleteCookie as o}from"hono/cookie";import{stringify as s}from"yaml";import{writeFileSync as i,readFileSync as a,readdirSync as c,statSync as l,existsSync as d,rmSync as u,mkdirSync as f,unlinkSync as p,cpSync as m}from"node:fs";import{execFileSync as h}from"node:child_process";import{createHash as g}from"node:crypto";import{resolve as y,join as j,basename as v}from"node:path";import{WebSocketServer as k}from"ws";import{isHeartbeatContentEffectivelyEmpty as w}from"../cron/heartbeat-token.js";import{buildWebChatId as $}from"../gateway/channels/webchat.js";import{loadConfig as S,loadRawConfig as C,backupConfig as b,resolveModelEntry as N}from"../config.js";import{renderSPA as I}from"./ui.js";import{buildSystemPrompt as T}from"../agent/prompt-builder.js";import{loadWorkspaceFiles as q}from"../agent/workspace-files.js";import{resolveBundledDir as D}from"../utils/package-paths.js";import{readKey as P,isDefaultKey as A,validateKey as x,regenerateKey as O,createSession as E,validateSession as R,validateCsrf as K,getCsrfToken as _,destroySession as F,checkRateLimit as L,recordLoginFailure as B,resetLoginAttempts as M}from"./auth.js";import{createLogger as z,getLogsDir as U,getLogLevel as W,setLogLevel as J}from"../utils/logger.js";const H=z("Nostromo"),Y="nostromo_session";export class Nostromo{app;httpServer=null;wss=null;host;port;server;nodeRegistry;startedAt;pendingNodes=new Map;configHash="";configPath;basePath;constructor(t,r,n,o,s="/nostromo"){this.server=t,this.host=r,this.port=n,this.nodeRegistry=o,this.basePath=s.replace(/\/+$/,"")||"/",this.startedAt=Date.now(),this.app=new e,this.configPath=y(process.cwd(),"config.yaml"),this.configHash=this.hashConfigFile(),P(),this.setupRoutes()}hashConfigFile(){try{const e=a(this.configPath,"utf-8");return g("sha256").update(e).digest("hex")}catch{return""}}setupRoutes(){const e="/"===this.basePath?"":this.basePath,t=e?this.app.basePath(e):this.app;e&&this.app.get("/",t=>t.redirect(e+"/")),t.use("*",async(t,r)=>{const n=Date.now();await r();const o=Date.now()-n;t.req.path===e+"/api/logs/current"||t.req.path===e+"/api/config/check"||t.req.path===e+"/api/config"||t.req.path===e+"/api/memory-files"||t.req.path===e+"/api/status"||t.req.path===e+"/api/auth/session"?H.debug(`${t.req.method} ${t.req.path} ${t.res.status} ${o}ms`):H.info(`${t.req.method} ${t.req.path} ${t.res.status} ${o}ms`)}),t.use("/api/*",async(e,t)=>{const r=e.req.header("Origin");return r&&(e.header("Access-Control-Allow-Origin",r),e.header("Access-Control-Allow-Credentials","true"),e.header("Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS"),e.header("Access-Control-Allow-Headers","Content-Type, Authorization, X-CSRF-Token")),"OPTIONS"===e.req.method?new Response(null,{status:204}):t()}),t.get("/",t=>t.html(I(A(),e))),t.post("/api/auth/login",async t=>{const r=t.req.header("x-forwarded-for")||t.req.header("x-real-ip")||"unknown",o=L(r);if(!o.allowed)return t.json({error:`Too many attempts. Try again in ${o.retryAfter}s`},429);const s=(await t.req.json()).key;if(!x(s))return B(r),t.json({error:"Invalid access key"},401);M(r);const{sessionToken:i,csrfToken:a}=E();if(n(t,Y,i,{httpOnly:!0,sameSite:"Lax",path:e||"/",maxAge:86400}),A()){const e=O();return H.info("Default key used; rotated to new key"),t.json({ok:!0,newKey:e,csrfToken:a})}return t.json({ok:!0,csrfToken:a})}),t.post("/api/auth/logout",async t=>{const n=r(t,Y);return n&&F(n),o(t,Y,{path:e||"/"}),t.json({ok:!0})}),t.get("/api/auth/session",e=>{const t=r(e,Y);if(!R(t))return e.json({valid:!1},401);const n=_(t);return e.json({valid:!0,csrfToken:n})}),t.use("/api/*",async(t,n)=>{if(t.req.path.startsWith(e+"/api/auth/"))return n();const o=t.req.header("Authorization");if(o?.startsWith("Bearer ")){const e=o.slice(7);return this.server.getTokenDb().validateToken(e,"nostromo")?n():t.json({error:"Invalid API token"},401)}const s=r(t,Y);if(!R(s))return t.json({error:"Unauthorized"},401);if("POST"===t.req.method||"PUT"===t.req.method||"DELETE"===t.req.method){const e=t.req.header("X-CSRF-Token");if(!K(s,e))return t.json({error:"Invalid CSRF token"},403)}return n()}),t.get("/api/config",e=>{const t=C(),r=this.server.getConfig();return t.dataDir=r.dataDir,t.gmabPath=r.gmabPath,e.json(t)}),t.put("/api/config",async e=>{try{const t=await e.req.json();if(t.cron?.heartbeat?.enabled&&(!t.cron.heartbeat.message||t.cron.heartbeat.message.trim().length<15))return e.json({error:"Heartbeat message is required and must be at least 15 characters"},400);const{dataDir:r,dbPath:n,memoryDir:o,cronStorePath:c,...l}=t,u=function(e){const t={};if(e.channels)for(const[r,n]of Object.entries(Z)){const o=e.channels[r];if(o?.accounts)for(const[e,s]of Object.entries(o.accounts))for(const o of n){const n=s[o.field];if("string"==typeof n&&n.length>0&&!V(n)){const i=`${r.toUpperCase()}_${e.toUpperCase().replace(/-/g,"_")}_${o.envSuffix}`;t[i]=n,s[o.field]=`\${${i}}`}}}if(Array.isArray(e.models))for(const r of e.models){if((r.types||["external"]).includes("internal"))continue;const e=r.apiKey;if("string"==typeof e&&e.length>0&&!V(e)){const n=r.useEnvVar||"OPENAI_API_KEY";t[n]=e,r.apiKey=`\${${n}}`,r.useEnvVar||(r.useEnvVar=n)}const n=r.fastProxyApiKey;if("string"==typeof n&&n.length>0&&!V(n)){const e="FAST_PROXY_APIKEY";t[e]=n,r.fastProxyApiKey=`\${${e}}`}}if(e.stt?.["openai-whisper"]?.apiKey){const r=e.stt["openai-whisper"].apiKey;if("string"==typeof r&&r.length>0&&!V(r)){const n="STT_OPENAI_WHISPER_API_KEY";t[n]=r,e.stt["openai-whisper"].apiKey=`\${${n}}`}}if(Object.keys(t).length>0){!function(e){const t=y(process.cwd(),".env");let r=d(t)?a(t,"utf-8"):"";for(const[t,n]of Object.entries(e)){const e=new RegExp(`^${t}=.*$`,"m");e.test(r)?r=r.replace(e,`${t}=${n}`):(r.length>0&&!r.endsWith("\n")&&(r+="\n"),r+=`${t}=${n}\n`)}i(t,r,"utf-8")}(t);for(const[e,r]of Object.entries(t))process.env[e]=r;H.info(`Protected ${Object.keys(t).length} secret(s) → .env`)}return e}(l),f=s(u),p=y(process.cwd(),"config.yaml");return b(p),i(p,f,"utf-8"),H.info("Config updated via Nostromo"),e.json({ok:!0})}catch(t){return H.error(`Config save failed: ${t}`),e.json({error:"Failed to save configuration"},500)}}),t.get("/api/tokens",e=>{const t=this.server.getTokenDb().listTokens();return e.json(t)}),t.post("/api/tokens",async e=>{const t=await e.req.json(),r=this.server.getTokenDb().createToken({userId:t.userId,channel:t.channel,label:t.label});return e.json(r,201)}),t.delete("/api/tokens/:id",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().deleteToken(t),e.json({ok:!0})}),t.post("/api/tokens/:id/revoke",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().revokeToken(t),e.json({ok:!0})}),t.post("/api/tokens/:id/approve",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().approveToken(t),e.json({ok:!0})}),t.get("/api/key",e=>e.json({key:P()})),t.post("/api/key/regenerate",e=>{const t=O();return e.json({key:t})}),t.get("/api/whatsapp/qr",e=>{const t=this.server.getWhatsAppQrState();return e.json(t)}),t.get("/api/status",e=>{const t=Date.now()-this.startedAt,r=Math.floor(t/1e3),n=`${Math.floor(r/3600)}h ${Math.floor(r%3600/60)}m ${r%60}s`;return e.json({status:"online",uptime:n,uptimeMs:t})}),t.post("/api/server/restart",async e=>{try{H.info("Server restart requested via Nostromo");const t=S();return await this.server.reconfigure(t),this.startedAt=Date.now(),this.configHash=this.hashConfigFile(),H.info("Server restarted successfully via Nostromo"),e.json({ok:!0})}catch(t){return H.error(`Server restart failed: ${t}`),e.json({error:`Restart failed: ${t instanceof Error?t.message:String(t)}`},500)}}),t.get("/api/config/check",e=>{const t=this.hashConfigFile(),r=t!==this.configHash&&""!==t;return e.json({restartNeeded:r})}),t.get("/api/sessions",e=>{const t=this.server.getSessionDb().listSessions().map(e=>{const[t,...r]=e.sessionKey.split(":"),n=r.join(":"),o=new Date(e.createdAt+"Z"),s=e=>String(e).padStart(2,"0"),i=`${o.getFullYear()}${s(o.getMonth()+1)}${s(o.getDate())}_${s(o.getHours())}${s(o.getMinutes())}`,a=`${e.sdkSessionId??"pending"}:${n}:${t}:${i}`;return{sessionKey:e.sessionKey,channelName:t,chatId:n,sdkSessionId:e.sdkSessionId,modelOverride:e.modelOverride,createdAt:e.createdAt,lastActivity:e.lastActivity,displayId:a}});return e.json(t)}),t.post("/api/sessions/:sessionKey/messages",async e=>{try{const t=e.req.param("sessionKey"),r=(await e.req.json()).message;if(!r)return e.json({error:"message is required"},400);const n=this.server.getSessionDb();if(!n.getBySessionKey(t))return e.json({error:"Session not found"},404);const[o,...s]=t.split(":"),i=s.join(":"),a={chatId:i,userId:"nostromo",channelName:o,text:r,attachments:[]},c=await this.server.handleMessage(a),l=this.server.getChannelManager();return await l.sendToChannel(o,i,c),e.json({ok:!0,sessionKey:t,response:c})}catch(t){return H.error(`Cross-session message failed: ${t}`),e.json({error:"Failed to process message"},500)}}),t.get("/api/nodes",e=>{const t=this.nodeRegistry.listNodes();return e.json(t)}),t.get("/api/nodes/signatures",e=>{const t=this.server.getNodeSignatureDb(),r=e.req.query("status"),n=r?t.listByStatus(r):t.listAll();return e.json(n)}),t.post("/api/nodes/signatures/:id/approve",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(r.approve(t),this.notifyPairingChange(t,"approved"),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.post("/api/nodes/signatures/:id/revoke",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(r.revoke(t),this.notifyPairingChange(t,"revoked"),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.delete("/api/nodes/signatures/:id",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(this.notifyPairingChange(t,"revoked"),r.delete(t),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.get("/api/cron/status",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);const r=await t.status();return e.json(r)}),t.get("/api/cron/jobs",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);const r="true"===e.req.query("includeDisabled"),n=await t.list({includeDisabled:r});return e.json(n)}),t.post("/api/cron/jobs",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=await e.req.json(),n=await t.add(r);return e.json(n,201)}catch(t){return H.error(`Cron job add failed: ${t}`),e.json({error:String(t)},400)}}),t.put("/api/cron/jobs/:id",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n=await e.req.json(),o=await t.update(r,n);return e.json(o)}catch(t){return H.error(`Cron job update failed: ${t}`),e.json({error:String(t)},400)}}),t.delete("/api/cron/jobs/:id",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n=await t.remove(r);return e.json(n)}catch(t){return H.error(`Cron job remove failed: ${t}`),e.json({error:String(t)},400)}}),t.post("/api/cron/jobs/:id/run",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n="due"===(await e.req.json().catch(()=>({}))).mode?"due":"force",o=await t.run(r,n);return e.json(o)}catch(t){return H.error(`Cron job run failed: ${t}`),e.json({error:String(t)},400)}}),t.post("/api/heartbeat/simulate",async e=>{const t=this.server.getConfig(),r=j(t.dataDir,"HEARTBEAT.md");let n="",o=!1;try{d(r)&&(o=!0,n=a(r,"utf-8"))}catch{}const s=!(o&&w(n));return e.json({accepted:s,fileExists:o,content:n})}),t.post("/api/inject",async e=>{try{const t=await e.req.json(),{channel:r,chatId:n,text:o}=t;if(!r||!n||!o)return e.json({error:"channel, chatId, and text are required"},400);const s={chatId:n,userId:"inject",channelName:r,text:o,attachments:[]},i=await this.server.handleMessage(s),a=this.server.getChannelManager();await a.sendToChannel(r,n,i);const c=`${r}:${n}`;return e.json({ok:!0,sessionKey:c,response:i})}catch(t){return H.error(`Inject message failed: ${t}`),e.json({error:"Failed to inject message"},500)}}),t.get("/api/internal-tools",e=>{const t=this.server.getAgentService().getToolServers(),r=[];for(const e of t){const t=e,n=t.name||"unknown",o=t.instance?._registeredTools;if(!o)continue;const s=[];for(const[e,t]of Object.entries(o)){const r=[],n=t.inputSchema?.def?.shape;if(n)for(const[e,t]of Object.entries(n)){let n=t.type||"unknown";const o=t.isOptional?.()??!1;"optional"===n&&t.def?.innerType&&(n=t.def.innerType.type||"unknown","enum"===n&&t.def.innerType.def?.entries&&(n=Object.keys(t.def.innerType.def.entries).join("|"))),"enum"===n&&t.def?.entries&&(n=Object.keys(t.def.entries).join("|")),r.push({name:e,type:n,required:!o,description:t.description||""})}s.push({name:e,description:t.description||"",params:r})}r.push({server:n,tools:s})}return e.json(r)});const g=["AGENTS.md","SOUL.md","TOOLS.md","IDENTITY.md","USER.md","HEARTBEAT.md","BOOTSTRAP.md","MEMORY.md"],k=["SYSTEM_PROMPT.md","SYSTEM_PROMPT_SUBAGENT.md","CBINT.json"];t.get("/api/workspace-files",e=>{const t=this.server.getConfig().dataDir,r=[];for(const e of g){const n=j(t,e);if(d(n))try{r.push({name:e,content:a(n,"utf-8"),exists:!0,isTemplate:!1})}catch{r.push({name:e,content:"",exists:!1,isTemplate:!1})}else r.push({name:e,content:"",exists:!1,isTemplate:!1})}for(const e of k){const n=j(t,".templates",e);if(d(n))try{r.push({name:e,content:a(n,"utf-8"),exists:!0,isTemplate:!0})}catch{r.push({name:e,content:"",exists:!1,isTemplate:!0})}else r.push({name:e,content:"",exists:!1,isTemplate:!0})}return e.json(r)}),t.put("/api/workspace-files/:name",async e=>{const t=e.req.param("name"),r=await e.req.json(),n=r.content,o=!0===r.isTemplate;if(null==n)return e.json({error:"content is required"},400);const s=this.server.getConfig().dataDir;let a;if(o){if(!k.includes(t))return e.json({error:"Invalid template name"},400);a=j(s,".templates",t)}else{if(!g.includes(t))return e.json({error:"Invalid file name"},400);a=j(s,t)}try{return i(a,n,"utf-8"),H.info(`Workspace file saved via Nostromo: ${a}`),e.json({ok:!0})}catch(t){return H.error(`Failed to save workspace file: ${t}`),e.json({error:"Failed to save file"},500)}}),t.post("/api/plugins/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const c=(r.get("basePath")||"").trim()||j(t.agent.workspacePath,".plugins"),l=j(c,n);f(l,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=j(l,r),a=j(n,"..");f(a,{recursive:!0});const c=Buffer.from(await t.arrayBuffer());i(n,c)}let u=n,p=n;const m=j(l,".claude-plugin","plugin.json");if(d(m))try{const e=a(m,"utf-8"),t=JSON.parse(e);t.name&&(u=t.name),t.description&&(p=t.description)}catch{}return e.json({ok:!0,path:l,name:u,description:p})}catch(t){return H.error(`Plugin upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.post("/api/plugins/delete-folder",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t)return e.json({error:"path is required"},400);const r=y(t);return d(r)?l(r).isDirectory()?(u(r,{recursive:!0,force:!0}),H.info(`Plugin folder deleted via Nostromo: ${r}`),e.json({ok:!0})):e.json({error:"Path is not a directory"},400):e.json({ok:!0,message:"Directory already gone"})}catch(t){return H.error(`Plugin folder delete failed: ${t}`),e.json({error:"Failed to delete folder"},500)}}),t.get("/api/plugins/download/:index",e=>{try{const t=parseInt(e.req.param("index"),10),r=this.server.getConfig(),n=r.agent?.plugins||[];if(isNaN(t)||t<0||t>=n.length)return e.json({error:"Invalid plugin index"},400);const o=n[t].path;if(!o)return e.json({error:"Plugin has no path"},400);const s=y(o);if(!d(s)||!l(s).isDirectory())return e.json({error:"Plugin folder not found"},404);const i=v(s),a=j(s,".."),c=h("zip",["-r","-q","-",i],{cwd:a,maxBuffer:104857600});return new Response(c,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${i}.zip"`}})}catch(t){return H.error(`Plugin download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.post("/api/plugins/verify-path",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t)return e.json({ok:!1,error:"Path is empty"});const r=y(t);return d(r)?l(r).isDirectory()?e.json({ok:!0,path:r}):e.json({ok:!1,error:"Path is not a directory"}):e.json({ok:!1,error:"Directory does not exist"})}catch(t){return e.json({ok:!1,error:"Invalid path"})}}),t.get("/api/plugins/info",e=>{const t=this.server.getConfig().agent.plugins||[],r=[];for(let e=0;e<t.length;e++){const n=t[e];let o=n.name,s=n.description||"",i=!1;try{if(d(n.path)&&l(n.path).isDirectory()){i=c(n.path).length>0;const e=j(n.path,".claude-plugin","plugin.json");if(d(e))try{const t=a(e,"utf-8"),r=JSON.parse(t);r.name&&(o=r.name),r.description&&!s&&(s=r.description)}catch{}}}catch{}r.push({index:e,name:o,description:s,valid:i})}return e.json(r)}),t.get("/api/commands",e=>{const t=this.server.getConfig(),r=j(t.agent.workspacePath,".claude","commands"),n=[];if(!d(r))return e.json(n);const o=(e,t)=>{try{const r=c(e);for(const s of r){const r=j(e,s);if(l(r).isDirectory())o(r,t?`${t}/${s}`:s);else if(s.endsWith(".md")){const e=t?`${t}/${s}`:s;let o=s.replace(/\.md$/,""),i="",c="";try{const e=G(a(r,"utf-8"));e.name&&(o=e.name),e.description&&(i=e.description),e.model&&(c=e.model)}catch{}n.push({file:e,folder:t,name:o,description:i,model:c})}}}catch{}};return o(r,""),n.sort((e,t)=>e.folder!==t.folder?e.folder.localeCompare(t.folder):e.name.localeCompare(t.name)),e.json(n)}),t.post("/api/commands/delete",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands",t);if(!d(n))return e.json({error:"Command not found"},404);if(l(n).isDirectory())u(n,{recursive:!0,force:!0});else{p(n);const e=j(n,"..");if(e!==j(r.agent.workspacePath,".claude","commands"))try{0===c(e).length&&u(e,{recursive:!0,force:!0})}catch{}}return H.info(`Command deleted via Nostromo: ${t}`),e.json({ok:!0})}catch(t){return H.error(`Failed to delete command: ${t}`),e.json({error:"Failed to delete command"},500)}}),t.post("/api/commands/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const a=j(t.agent.workspacePath,".claude","commands",n);f(a,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=j(a,r),c=j(n,"..");f(c,{recursive:!0});const l=Buffer.from(await t.arrayBuffer());i(n,l)}return H.info(`Command folder uploaded via Nostromo: ${n}`),e.json({ok:!0,name:n})}catch(t){return H.error(`Command upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.get("/api/commands/download/:folder",e=>{try{const t=e.req.param("folder");if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands"),o=j(n,t);if(!d(o)||!l(o).isDirectory())return e.json({error:"Command folder not found"},404);const s=h("zip",["-r","-q","-",t],{cwd:n,maxBuffer:104857600});return new Response(s,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${t}.zip"`}})}catch(t){return H.error(`Command download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/commands/download-file",e=>{try{const t=(e.req.query("path")||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands",t);if(!d(n)||!l(n).isFile())return e.json({error:"File not found"},404);const o=a(n),s=v(n);return new Response(o,{headers:{"Content-Type":"application/octet-stream","Content-Disposition":`attachment; filename="${s}"`}})}catch(t){return H.error(`Command file download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/commands/file",e=>{try{const t=(e.req.query("path")||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","commands",t);if(!d(n)||!l(n).isFile())return e.json({error:"File not found"},404);const o=a(n,"utf-8");return e.json({path:t,content:o})}catch(t){return H.error(`Command file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/commands/file",async e=>{try{const t=await e.req.json(),r=(t.path||"").trim(),n=t.content;if(!r||r.includes(".."))return e.json({error:"Invalid path"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=this.server.getConfig(),s=j(o.agent.workspacePath,".claude","commands",r),a=j(s,"..");return f(a,{recursive:!0}),i(s,n,"utf-8"),H.info(`Command file saved via Nostromo: ${r}`),e.json({ok:!0})}catch(t){return H.error(`Command file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.post("/api/commands/create",async e=>{try{const t=await e.req.json(),r=(t.name||"").trim();if(!r||!/^[a-zA-Z0-9_-]+$/.test(r))return e.json({error:"Invalid name. Use only letters, digits, hyphens, and underscores."},400);const n=(t.description||"").trim(),o=(t.model||"").trim(),s=this.server.getConfig(),a=j(s.agent.workspacePath,".claude","commands");f(a,{recursive:!0});const c=j(a,r+".md");if(d(c))return e.json({error:"A command with this name already exists"},409);let l="---\n";return l+=`name: ${r}\n`,n&&(l+=`description: ${n}\n`),o&&(l+=`model: ${o}\n`),l+="---\n\n",i(c,l,"utf-8"),H.info(`Standalone command created via Nostromo: ${r}.md`),e.json({ok:!0,file:r+".md"})}catch(t){return H.error(`Command creation failed: ${t}`),e.json({error:"Creation failed"},500)}}),t.get("/api/skills",e=>{const t=this.server.getConfig(),r=j(t.agent.workspacePath,".claude","skills"),n=[];if(!d(r))return e.json(n);try{const e=c(r);for(const t of e){const e=j(r,t);if(!l(e).isDirectory())continue;const o=j(e,"SKILL.md");let s=t,i="";if(d(o))try{const e=G(a(o,"utf-8"));e.name&&(s=e.name),e.description&&(i=e.description)}catch{}n.push({folder:t,name:s,description:i})}}catch(e){H.error(`Failed to list skills: ${e}`)}return e.json(n)}),t.post("/api/skills/delete",async e=>{try{const t=((await e.req.json()).folder||"").trim();if(!t||t.includes("..")||t.includes("/"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","skills",t);return d(n)?(l(n).isDirectory()?u(n,{recursive:!0,force:!0}):p(n),H.info(`Skill deleted via Nostromo: ${t}`),e.json({ok:!0})):e.json({error:"Skill not found"},404)}catch(t){return H.error(`Failed to delete skill: ${t}`),e.json({error:"Failed to delete skill"},500)}}),t.post("/api/skills/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const a=j(t.agent.workspacePath,".claude","skills",n);f(a,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=j(a,r),c=j(n,"..");f(c,{recursive:!0});const l=Buffer.from(await t.arrayBuffer());i(n,l)}return H.info(`Skill folder uploaded via Nostromo: ${n}`),e.json({ok:!0,name:n})}catch(t){return H.error(`Skill upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.get("/api/skills/download/:folder",e=>{try{const t=e.req.param("folder");if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","skills"),o=j(n,t);if(!d(o)||!l(o).isDirectory())return e.json({error:"Skill folder not found"},404);const s=h("zip",["-r","-q","-",t],{cwd:n,maxBuffer:104857600});return new Response(s,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${t}.zip"`}})}catch(t){return H.error(`Skill download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/skills/file",e=>{try{const t=(e.req.query("folder")||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=j(r.agent.workspacePath,".claude","skills",t,"SKILL.md");if(!d(n))return e.json({path:t+"/SKILL.md",content:""});const o=a(n,"utf-8");return e.json({path:t+"/SKILL.md",content:o})}catch(t){return H.error(`Skill file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/skills/file",async e=>{try{const t=await e.req.json(),r=(t.folder||"").trim(),n=t.content;if(!r||r.includes("..")||r.includes("/")||r.includes("\\"))return e.json({error:"Invalid folder name"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=this.server.getConfig(),s=j(o.agent.workspacePath,".claude","skills",r);f(s,{recursive:!0});const a=j(s,"SKILL.md");return i(a,n,"utf-8"),H.info(`Skill file saved via Nostromo: ${r}/SKILL.md`),e.json({ok:!0})}catch(t){return H.error(`Skill file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.get("/api/skills/bundled",e=>{try{const t=D();if(!t)return e.json([]);const r=c(t,{withFileTypes:!0}),n=[];for(const e of r){if(!e.isDirectory())continue;const r=j(t,e.name,"SKILL.md");if(!d(r))continue;const o=G(a(r,"utf-8"));n.push({folder:e.name,name:o.name||e.name,description:o.description||""})}return n.sort((e,t)=>e.name.localeCompare(t.name)),e.json(n)}catch(t){return H.error(`Failed to list bundled skills: ${t}`),e.json({error:"Failed to list bundled skills"},500)}}),t.post("/api/skills/use-bundled",async e=>{try{const t=((await e.req.json()).folder||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=D();if(!r)return e.json({error:"Bundled directory not found"},404);const n=j(r,t);if(!d(n))return e.json({error:"Bundled skill not found"},404);const o=this.server.getConfig(),s=j(o.agent.workspacePath,".claude","skills",t);return m(n,s,{recursive:!0}),H.info(`Bundled skill installed via Nostromo: ${t}`),e.json({ok:!0,name:t})}catch(t){return H.error(`Use bundled skill failed: ${t}`),e.json({error:"Install failed"},500)}}),t.get("/api/skills/bundled/file",e=>{try{const t=(e.req.query("folder")||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=D();if(!r)return e.json({error:"Bundled directory not found"},404);const n=j(r,t,"SKILL.md");if(!d(n))return e.json({error:"File not found"},404);const o=a(n,"utf-8");return e.json({content:o})}catch(t){return H.error(`Bundled skill file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/skills/bundled/file",async e=>{try{const t=await e.req.json(),r=(t.folder||"").trim(),n=t.content;if(!r||r.includes("..")||r.includes("/")||r.includes("\\"))return e.json({error:"Invalid folder name"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=D();if(!o)return e.json({error:"Bundled directory not found"},404);const s=j(o,r);if(!d(s))return e.json({error:"Bundled skill folder not found"},404);const a=j(s,"SKILL.md");return i(a,n,"utf-8"),H.info(`Bundled skill file saved via Nostromo: ${r}/SKILL.md`),e.json({ok:!0})}catch(t){return H.error(`Bundled skill file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.get("/api/prompt-simulate",e=>{try{const t=S(),r=q(t.dataDir),n={sessionKey:"simulate:preview",channel:"simulate",chatId:"preview",sessionId:"(simulated)",memoryFile:j(t.dataDir,"memory","simulate_preview","2026-01-01.md"),attachmentsDir:j(t.dataDir,"memory","simulate_preview","2026-01-01")},o=this.server.getAgentService().getToolServers(),s=T({config:t,sessionContext:n,workspaceFiles:r,mode:"full",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,toolServers:o}),i=T({config:t,sessionContext:n,workspaceFiles:r,mode:"minimal",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,subagentTask:"(example subagent task description)",toolServers:o});return e.json({main:s,subagent:i})}catch(t){return H.error(`Prompt simulation failed: ${t}`),e.json({error:"Failed to simulate prompt: "+String(t)},500)}}),t.post("/api/memory-search/test-embedding",async e=>{try{const t=await e.req.json(),r=t.modelRef||"",n=t.embeddingModel||"text-embedding-3-small",o=t.embeddingDimensions||1536,s=this.server.getConfig(),i=N(s,r),a=(i?.useEnvVar?process.env[i.useEnvVar]:i?.apiKey)||"",c=i?.baseURL||"";if(!a)return e.json({ok:!1,error:`No API key found for modelRef "${r}"`},400);const{default:l}=await import("openai"),d=new l({apiKey:a,...c?{baseURL:c}:{}}),u=Date.now(),f=await d.embeddings.create({model:n,input:"test embedding connection",dimensions:o}),p=Date.now()-u,m=f.data?.[0]?.embedding?.length??0;return e.json({ok:!0,model:n,dimensions:m,latencyMs:p})}catch(t){const r=t instanceof Error?t.message:String(t);return H.error(`Embedding test failed: ${r}`),e.json({ok:!1,error:r},500)}}),t.get("/api/memory-files",e=>{const t=this.server.getConfig().memoryDir;if(!d(t))return e.json([]);try{const r=[],n=c(t,{withFileTypes:!0});for(const e of n){if(!e.isDirectory())continue;const n=j(t,e.name),o=c(n).filter(e=>e.endsWith(".md")).map(e=>{const t=l(j(n,e));return{name:e,size:t.size,modified:t.mtime.toISOString()}}).sort((e,t)=>t.name.localeCompare(e.name));o.length>0&&r.push({sessionKey:e.name,files:o})}return r.sort((e,t)=>e.sessionKey.localeCompare(t.sessionKey)),e.json(r)}catch(t){return H.error(`Failed to list memory files: ${t}`),e.json([])}}),t.get("/api/memory-files/:sessionKey/:fileName",e=>{const t=e.req.param("sessionKey"),r=e.req.param("fileName");if(!/^[\w-]+\.md$/.test(r))return e.json({error:"Invalid file name"},400);if(/[/\\.]/.test(t.replace(/:/g,"")))return e.json({error:"Invalid session key"},400);const n=this.server.getConfig(),o=j(n.memoryDir,t.replace(/:/g,"_"),r);if(!d(o))return e.json({error:"File not found"},404);try{const n=a(o,"utf-8");return e.json({sessionKey:t,fileName:r,content:n})}catch(t){return e.json({error:"Failed to read file"},500)}}),t.get("/api/logs/current",e=>{const t=U();if(!t)return e.json({lines:[],total:0});const r=j(t,"gmab.log");try{const t=a(r,"utf-8").split("\n").filter(e=>e.length>0),n=Math.min(Math.max(parseInt(e.req.query("lines")||"200")||200,1),1e3),o=t.slice(-n);return e.json({lines:o,total:t.length})}catch{return e.json({lines:[],total:0})}}),t.get("/api/logs/current/download",async e=>{const t=U();if(!t)return e.json({error:"Logs not initialized"},503);const r=j(t,"gmab.log"),n="true"===e.req.query("compress");try{if(n){const e=a(r),{gzipSync:t}=await import("node:zlib"),n=t(e);return new Response(n,{headers:{"Content-Type":"application/gzip","Content-Disposition":'attachment; filename="gmab.log.gz"'}})}const e=a(r);return new Response(e,{headers:{"Content-Type":"text/plain","Content-Disposition":'attachment; filename="gmab.log"'}})}catch{return e.json({error:"Log file not found"},404)}}),t.get("/api/logs/files",e=>{const t=U();if(!t)return e.json([]);try{const r=c(t).filter(e=>/^gmab\.\d+\.log$/.test(e)).map(e=>{const r=l(j(t,e));return{name:e,size:r.size,modified:r.mtime.toISOString()}}).sort((e,t)=>e.name.localeCompare(t.name,void 0,{numeric:!0}));return e.json(r)}catch{return e.json([])}}),t.get("/api/logs/files/:name/download",async e=>{const t=U();if(!t)return e.json({error:"Logs not initialized"},503);const r=e.req.param("name");if(!/^gmab\.\d+\.log$/.test(r))return e.json({error:"Invalid file name"},400);const n=j(t,r);try{const e=a(n),{gzipSync:t}=await import("node:zlib"),o=t(e);return new Response(o,{headers:{"Content-Type":"application/gzip","Content-Disposition":`attachment; filename="${r}.gz"`}})}catch{return e.json({error:"File not found"},404)}}),t.get("/api/logs/level",e=>e.json({level:W()})),t.put("/api/logs/level",async e=>{try{const t=(await e.req.json()).level,r=["debug","info","warn","error"];if(!r.includes(t))return e.json({error:"Invalid level. Must be one of: "+r.join(", ")},400);J(t);try{const e=y(process.cwd(),"config.yaml"),{parse:r}=await import("yaml"),n=r(a(e,"utf-8"))||{};n.logLevel=t;const o=s(n);b(e),i(e,o,"utf-8")}catch(e){H.warn(`Failed to persist logLevel to config.yaml: ${e}`)}return H.info(`Log level changed to ${t}`),e.json({ok:!0,level:t})}catch(t){return e.json({error:"Failed to set log level"},400)}}),t.put("/api/logs/verbose",async e=>{try{const t=!!(await e.req.json()).enabled;try{const e=y(process.cwd(),"config.yaml"),{parse:r}=await import("yaml"),n=r(a(e,"utf-8"))||{};n.verboseDebugLogs=t;const o=s(n);b(e),i(e,o,"utf-8")}catch(e){H.warn(`Failed to persist verboseDebugLogs to config.yaml: ${e}`)}return H.info("Verbose debug logs "+(t?"enabled":"disabled")),e.json({ok:!0,enabled:t})}catch(t){return e.json({error:"Failed to set verbose logs"},400)}})}async start(){this.httpServer=t({fetch:this.app.fetch,hostname:this.host,port:this.port}),this.wss=new k({noServer:!0}),this.httpServer.on("upgrade",(e,t,r)=>{const n=new URL(e.url??"/",`http://localhost:${this.port}`),o="/"===this.basePath?"":this.basePath;n.pathname===o+"/ws/nodes"?this.wss.handleUpgrade(e,t,r,t=>{this.wss.emit("connection",t,e)}):t.destroy()}),this.wss.on("connection",e=>{let t=null,r=null,n=!1;const o=()=>{n&&t&&(this.nodeRegistry.unregister(t),n=!1),r&&this.pendingNodes.delete(r);const o=this.server.getWebChatChannel();o&&o.unregisterByWs(e)};e.on("message",o=>{try{const s=JSON.parse(o.toString());if("hello"===s.type){t=s.nodeId;const o=s.signature;if(!t)return void e.close(1008,"Missing nodeId in hello");if(!o)return void e.close(1008,"Missing signature");r=o;const i=this.server.getNodeSignatureDb(),a=i.createOrUpdatePending(t,o,s.hostname??"",s.displayName??"",s.platform??"",s.arch??""),c={displayName:s.displayName,platform:s.platform,arch:s.arch,hostname:s.hostname,capabilities:s.capabilities??[],commands:s.commands??[]};switch(a.status){case"pending":e.send(JSON.stringify({type:"pairing_status",status:"pending"})),this.pendingNodes.set(o,{ws:e,nodeId:t,info:c}),H.info(`Node ${t} (${s.displayName??"unnamed"}) awaiting approval`);break;case"approved":e.send(JSON.stringify({type:"pairing_status",status:"approved"})),this.nodeRegistry.register(t,e,c),n=!0,i.updateLastSeen(a.id),H.info(`Node ${t} (${s.displayName??"unnamed"}) approved and registered`);break;case"revoked":e.send(JSON.stringify({type:"pairing_status",status:"revoked"})),e.close(1008,"Signature revoked")}}else if("command_result"===s.type)this.nodeRegistry.handleCommandResult(s.id,{ok:s.ok,result:s.result,error:s.error});else if("chat"===s.type){if(!n||!t)return;const r=this.server.getWebChatChannel();if(!r)return;const o=this.nodeRegistry.getNode(t),i=$(o?.displayName??"node",t),a=s.chatId,c=a?`${i}/${a}`:i;r.registerConnection(c,e),r.handleNodeChat(c,t,{text:s.text,attachments:s.attachments})}else if("a2ui_action"===s.type){if(!n||!t)return;const e=this.server.getWebChatChannel();if(!e)return;const r=s.userAction;if(!r||!r.name)return;const o=this.nodeRegistry.getNode(t),i=$(o?.displayName??"node",t),a=s.chatId,c=a?`${i}/${a}`:i,l=r.context&&Object.keys(r.context).length>0?` ${JSON.stringify(r.context)}`:"",d=`[A2UI Action] ${r.name} on surface ${r.surfaceId||"main"} (component: ${r.sourceComponentId||"unknown"})${l}`;e.handleNodeChat(c,t,{text:d}),H.info(`A2UI action from node ${t}: ${r.name} on ${r.surfaceId||"main"}`)}else if("dynamic_ui_action"===s.type){if(!n||!t)return;const r=this.server.getWebChatChannel();if(!r)return;const o=s.action;if(!o||!o.activityId)return;const i=s.channel,a=s.chatId;let c;if(i&&a)c=a,r.registerConnection(a,e);else{const e=this.nodeRegistry.getNode(t);c=$(e?.displayName??"node",t)}const l=o.data?` data=${JSON.stringify(o.data)}`:"",d=o.context&&Object.keys(o.context).length>0?` context=${JSON.stringify(o.context)}`:"",u=`[Dynamic UI Action] ${o.type} on #${o.activityId}${l}${d}`;if(i&&"webchat"!==i&&a){const e=this.server.getChannelManager(),n=e?.getAdapter(i);n&&e?(e.setTyping(i,a).catch(()=>{}),this.server.handleMessage({chatId:a,userId:t,channelName:i,text:u,attachments:[]}).then(async t=>{t&&t.trim()&&await e.sendResponse(i,a,t)}).catch(e=>{H.error(`Error handling dynamic_ui_action for ${i}:${a}: ${e}`)})):r.handleNodeChat(c,t,{text:u})}else r.handleNodeChat(c,t,{text:u});H.info(`Dynamic UI action from node ${t}: ${o.type} on #${o.activityId} → ${i??"webchat"}:${c}`)}else"ping"===s.type&&e.send(JSON.stringify({type:"pong"}))}catch{}}),e.on("close",()=>{o()}),e.on("error",e=>{H.error(`Node WS error: ${e.message}`),o()})}),H.info(`Nostromo listening on http://${this.host}:${this.port}${this.basePath}`)}notifyPairingChange(e,t){const r=this.server.getNodeSignatureDb(),n=r.getById(e);if(!n)return;const o=n.signature;if("approved"===t){const t=this.pendingNodes.get(o);t&&(t.ws.send(JSON.stringify({type:"pairing_status",status:"approved"})),this.nodeRegistry.register(t.nodeId,t.ws,t.info),this.pendingNodes.delete(o),r.updateLastSeen(e),H.info(`Node ${t.nodeId} approved via Nostromo`))}else if("revoked"===t){const e=this.pendingNodes.get(o);if(e)return e.ws.send(JSON.stringify({type:"pairing_status",status:"revoked"})),e.ws.close(1008,"Signature revoked"),void this.pendingNodes.delete(o);const t=this.nodeRegistry.listNodes();for(const e of t){const t=this.nodeRegistry.getNode(e.nodeId);if(t&&e.nodeId===n.nodeId){t.ws.send(JSON.stringify({type:"pairing_status",status:"revoked"})),t.ws.close(1008,"Signature revoked"),this.nodeRegistry.unregister(e.nodeId);break}}}}async stop(){this.wss&&this.wss.close(),this.httpServer&&this.httpServer.close(),H.info("Nostromo stopped")}}function V(e){return"string"==typeof e&&/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(e)}const Z={telegram:[{field:"botToken",envSuffix:"BOT_TOKEN"}],discord:[{field:"token",envSuffix:"TOKEN"}],slack:[{field:"botToken",envSuffix:"BOT_TOKEN"},{field:"appToken",envSuffix:"APP_TOKEN"}],msteams:[{field:"appSecret",envSuffix:"APP_SECRET"}],line:[{field:"channelAccessToken",envSuffix:"CHANNEL_ACCESS_TOKEN"},{field:"channelSecret",envSuffix:"CHANNEL_SECRET"}],matrix:[{field:"accessToken",envSuffix:"ACCESS_TOKEN"}]};function G(e){const t=e.match(/^---\r?\n([\s\S]*?)\r?\n---/);if(!t)return{};const r={};for(const e of t[1].split("\n")){const t=e.indexOf(":");if(t<0)continue;const n=e.slice(0,t).trim(),o=e.slice(t+1).trim().replace(/^["']|["']$/g,"");n&&(r[n]=o)}return r}
1
+ import{Hono as e}from"hono";import{serve as t}from"@hono/node-server";import{getCookie as r,setCookie as n,deleteCookie as o}from"hono/cookie";import{stringify as s}from"yaml";import{writeFileSync as i,readFileSync as a,readdirSync as c,statSync as l,existsSync as d,rmSync as u,mkdirSync as f,unlinkSync as p,cpSync as m}from"node:fs";import{execFileSync as h}from"node:child_process";import{createHash as g}from"node:crypto";import{resolve as y,join as v,basename as j}from"node:path";import{WebSocketServer as k}from"ws";import{isHeartbeatContentEffectivelyEmpty as w}from"../cron/heartbeat-token.js";import{buildWebChatId as S}from"../gateway/channels/webchat.js";import{loadConfig as $,loadRawConfig as C,backupConfig as b,resolveModelEntry as N}from"../config.js";import{renderSPA as I}from"./ui.js";import{buildSystemPrompt as T,buildSystemPromptWithMeta as q}from"../agent/prompt-builder.js";import{loadWorkspaceFiles as D,scanFileIncludes as A}from"../agent/workspace-files.js";import{resolveBundledDir as P}from"../utils/package-paths.js";import{readKey as x,isDefaultKey as O,validateKey as E,regenerateKey as R,createSession as F,validateSession as K,validateCsrf as _,getCsrfToken as L,destroySession as B,checkRateLimit as M,recordLoginFailure as z,resetLoginAttempts as U}from"./auth.js";import{createLogger as W,getLogsDir as J,getLogLevel as H,setLogLevel as Y}from"../utils/logger.js";const V=W("Nostromo"),Z="nostromo_session";export class Nostromo{app;httpServer=null;wss=null;host;port;server;nodeRegistry;startedAt;pendingNodes=new Map;configHash="";configPath;basePath;constructor(t,r,n,o,s="/nostromo"){this.server=t,this.host=r,this.port=n,this.nodeRegistry=o,this.basePath=s.replace(/\/+$/,"")||"/",this.startedAt=Date.now(),this.app=new e,this.configPath=y(process.cwd(),"config.yaml"),this.configHash=this.hashConfigFile(),x(),this.setupRoutes()}hashConfigFile(){try{const e=a(this.configPath,"utf-8");return g("sha256").update(e).digest("hex")}catch{return""}}setupRoutes(){const e="/"===this.basePath?"":this.basePath,t=e?this.app.basePath(e):this.app;e&&this.app.get("/",t=>t.redirect(e+"/")),t.use("*",async(t,r)=>{const n=Date.now();await r();const o=Date.now()-n;t.req.path===e+"/api/logs/current"||t.req.path===e+"/api/config/check"||t.req.path===e+"/api/config"||t.req.path===e+"/api/memory-files"||t.req.path===e+"/api/status"||t.req.path===e+"/api/auth/session"?V.debug(`${t.req.method} ${t.req.path} ${t.res.status} ${o}ms`):V.info(`${t.req.method} ${t.req.path} ${t.res.status} ${o}ms`)}),t.use("/api/*",async(e,t)=>{const r=e.req.header("Origin");return r&&(e.header("Access-Control-Allow-Origin",r),e.header("Access-Control-Allow-Credentials","true"),e.header("Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS"),e.header("Access-Control-Allow-Headers","Content-Type, Authorization, X-CSRF-Token")),"OPTIONS"===e.req.method?new Response(null,{status:204}):t()}),t.get("/",t=>t.html(I(O(),e))),t.post("/api/auth/login",async t=>{const r=t.req.header("x-forwarded-for")||t.req.header("x-real-ip")||"unknown",o=M(r);if(!o.allowed)return t.json({error:`Too many attempts. Try again in ${o.retryAfter}s`},429);const s=(await t.req.json()).key;if(!E(s))return z(r),t.json({error:"Invalid access key"},401);U(r);const{sessionToken:i,csrfToken:a}=F();if(n(t,Z,i,{httpOnly:!0,sameSite:"Lax",path:e||"/",maxAge:86400}),O()){const e=R();return V.info("Default key used; rotated to new key"),t.json({ok:!0,newKey:e,csrfToken:a})}return t.json({ok:!0,csrfToken:a})}),t.post("/api/auth/logout",async t=>{const n=r(t,Z);return n&&B(n),o(t,Z,{path:e||"/"}),t.json({ok:!0})}),t.get("/api/auth/session",e=>{const t=r(e,Z);if(!K(t))return e.json({valid:!1},401);const n=L(t);return e.json({valid:!0,csrfToken:n})}),t.use("/api/*",async(t,n)=>{if(t.req.path.startsWith(e+"/api/auth/"))return n();const o=t.req.header("Authorization");if(o?.startsWith("Bearer ")){const e=o.slice(7);return this.server.getTokenDb().validateToken(e,"nostromo")?n():t.json({error:"Invalid API token"},401)}const s=r(t,Z);if(!K(s))return t.json({error:"Unauthorized"},401);if("POST"===t.req.method||"PUT"===t.req.method||"DELETE"===t.req.method){const e=t.req.header("X-CSRF-Token");if(!_(s,e))return t.json({error:"Invalid CSRF token"},403)}return n()}),t.get("/api/config",e=>{const t=C(),r=this.server.getConfig();return t.dataDir=r.dataDir,t.gmabPath=r.gmabPath,e.json(t)}),t.put("/api/config",async e=>{try{const t=await e.req.json();if(t.cron?.heartbeat?.enabled&&(!t.cron.heartbeat.message||t.cron.heartbeat.message.trim().length<15))return e.json({error:"Heartbeat message is required and must be at least 15 characters"},400);const{dataDir:r,dbPath:n,memoryDir:o,cronStorePath:c,...l}=t,u=function(e){const t={};if(e.channels)for(const[r,n]of Object.entries(X)){const o=e.channels[r];if(o?.accounts)for(const[e,s]of Object.entries(o.accounts))for(const o of n){const n=s[o.field];if("string"==typeof n&&n.length>0&&!G(n)){const i=`${r.toUpperCase()}_${e.toUpperCase().replace(/-/g,"_")}_${o.envSuffix}`;t[i]=n,s[o.field]=`\${${i}}`}}}if(Array.isArray(e.models))for(const r of e.models){if((r.types||["external"]).includes("internal"))continue;const e=r.apiKey;if("string"==typeof e&&e.length>0&&!G(e)){const n=r.useEnvVar||"OPENAI_API_KEY";t[n]=e,r.apiKey=`\${${n}}`,r.useEnvVar||(r.useEnvVar=n)}const n=r.fastProxyApiKey;if("string"==typeof n&&n.length>0&&!G(n)){const e="FAST_PROXY_APIKEY";t[e]=n,r.fastProxyApiKey=`\${${e}}`}}if(e.stt?.["openai-whisper"]?.apiKey){const r=e.stt["openai-whisper"].apiKey;if("string"==typeof r&&r.length>0&&!G(r)){const n="STT_OPENAI_WHISPER_API_KEY";t[n]=r,e.stt["openai-whisper"].apiKey=`\${${n}}`}}if(Object.keys(t).length>0){!function(e){const t=y(process.cwd(),".env");let r=d(t)?a(t,"utf-8"):"";for(const[t,n]of Object.entries(e)){const e=new RegExp(`^${t}=.*$`,"m");e.test(r)?r=r.replace(e,`${t}=${n}`):(r.length>0&&!r.endsWith("\n")&&(r+="\n"),r+=`${t}=${n}\n`)}i(t,r,"utf-8")}(t);for(const[e,r]of Object.entries(t))process.env[e]=r;V.info(`Protected ${Object.keys(t).length} secret(s) → .env`)}return e}(l),f=s(u),p=y(process.cwd(),"config.yaml");return b(p),i(p,f,"utf-8"),V.info("Config updated via Nostromo"),e.json({ok:!0})}catch(t){return V.error(`Config save failed: ${t}`),e.json({error:"Failed to save configuration"},500)}}),t.get("/api/tokens",e=>{const t=this.server.getTokenDb().listTokens();return e.json(t)}),t.post("/api/tokens",async e=>{const t=await e.req.json(),r=this.server.getTokenDb().createToken({userId:t.userId,channel:t.channel,label:t.label});return e.json(r,201)}),t.delete("/api/tokens/:id",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().deleteToken(t),e.json({ok:!0})}),t.post("/api/tokens/:id/revoke",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().revokeToken(t),e.json({ok:!0})}),t.post("/api/tokens/:id/approve",e=>{const t=parseInt(e.req.param("id"));return this.server.getTokenDb().approveToken(t),e.json({ok:!0})}),t.get("/api/key",e=>e.json({key:x()})),t.post("/api/key/regenerate",e=>{const t=R();return e.json({key:t})}),t.get("/api/whatsapp/qr",e=>{const t=this.server.getWhatsAppQrState();return e.json(t)}),t.get("/api/status",e=>{const t=Date.now()-this.startedAt,r=Math.floor(t/1e3),n=`${Math.floor(r/3600)}h ${Math.floor(r%3600/60)}m ${r%60}s`;return e.json({status:"online",uptime:n,uptimeMs:t})}),t.post("/api/server/restart",async e=>{try{V.info("Server restart requested via Nostromo");const t=$();return await this.server.reconfigure(t),this.startedAt=Date.now(),this.configHash=this.hashConfigFile(),V.info("Server restarted successfully via Nostromo"),e.json({ok:!0})}catch(t){return V.error(`Server restart failed: ${t}`),e.json({error:`Restart failed: ${t instanceof Error?t.message:String(t)}`},500)}}),t.get("/api/config/check",e=>{const t=this.hashConfigFile(),r=t!==this.configHash&&""!==t;return e.json({restartNeeded:r})}),t.get("/api/sessions",e=>{const t=this.server.getSessionDb().listSessions(),r=this.server.getAgentService(),n=new Set(r.getActiveSessions()),o=t.map(e=>{const[t,...o]=e.sessionKey.split(":"),s=o.join(":"),i=new Date(e.createdAt+"Z"),a=e=>String(e).padStart(2,"0"),c=`${i.getFullYear()}${a(i.getMonth()+1)}${a(i.getDate())}_${a(i.getHours())}${a(i.getMinutes())}`,l=`${e.sdkSessionId??"pending"}:${s}:${t}:${c}`;return{sessionKey:e.sessionKey,channelName:t,chatId:s,sdkSessionId:e.sdkSessionId,modelOverride:e.modelOverride,createdAt:e.createdAt,lastActivity:e.lastActivity,displayId:l,active:n.has(e.sessionKey),busy:r.isBusy(e.sessionKey)}});return e.json(o)}),t.post("/api/sessions/:sessionKey/messages",async e=>{try{const t=e.req.param("sessionKey"),r=(await e.req.json()).message;if(!r)return e.json({error:"message is required"},400);const n=this.server.getSessionDb();if(!n.getBySessionKey(t))return e.json({error:"Session not found"},404);const[o,...s]=t.split(":"),i=s.join(":"),a={chatId:i,userId:"nostromo",channelName:o,text:r,attachments:[]},c=await this.server.handleMessage(a),l=this.server.getChannelManager();return await l.sendToChannel(o,i,c),e.json({ok:!0,sessionKey:t,response:c})}catch(t){return V.error(`Cross-session message failed: ${t}`),e.json({error:"Failed to process message"},500)}}),t.get("/api/nodes",e=>{const t=this.nodeRegistry.listNodes();return e.json(t)}),t.get("/api/nodes/signatures",e=>{const t=this.server.getNodeSignatureDb(),r=e.req.query("status"),n=r?t.listByStatus(r):t.listAll();return e.json(n)}),t.post("/api/nodes/signatures/:id/approve",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(r.approve(t),this.notifyPairingChange(t,"approved"),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.post("/api/nodes/signatures/:id/revoke",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(r.revoke(t),this.notifyPairingChange(t,"revoked"),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.delete("/api/nodes/signatures/:id",e=>{const t=parseInt(e.req.param("id")),r=this.server.getNodeSignatureDb();return r.getById(t)?(this.notifyPairingChange(t,"revoked"),r.delete(t),e.json({ok:!0})):e.json({error:"Not found"},404)}),t.get("/api/cron/status",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);const r=await t.status();return e.json(r)}),t.get("/api/cron/jobs",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);const r="true"===e.req.query("includeDisabled"),n=await t.list({includeDisabled:r});return e.json(n)}),t.post("/api/cron/jobs",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=await e.req.json(),n=await t.add(r);return e.json(n,201)}catch(t){return V.error(`Cron job add failed: ${t}`),e.json({error:String(t)},400)}}),t.put("/api/cron/jobs/:id",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n=await e.req.json(),o=await t.update(r,n);return e.json(o)}catch(t){return V.error(`Cron job update failed: ${t}`),e.json({error:String(t)},400)}}),t.delete("/api/cron/jobs/:id",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n=await t.remove(r);return e.json(n)}catch(t){return V.error(`Cron job remove failed: ${t}`),e.json({error:String(t)},400)}}),t.post("/api/cron/jobs/:id/run",async e=>{const t=this.server.getCronService();if(!t)return e.json({error:"Cron not available"},503);try{const r=e.req.param("id"),n="due"===(await e.req.json().catch(()=>({}))).mode?"due":"force",o=await t.run(r,n);return e.json(o)}catch(t){return V.error(`Cron job run failed: ${t}`),e.json({error:String(t)},400)}}),t.post("/api/heartbeat/simulate",async e=>{const t=this.server.getConfig(),r=v(t.dataDir,"HEARTBEAT.md");let n="",o=!1;try{d(r)&&(o=!0,n=a(r,"utf-8"))}catch{}const s=!(o&&w(n));return e.json({accepted:s,fileExists:o,content:n})}),t.post("/api/inject",async e=>{try{const t=await e.req.json(),{channel:r,chatId:n,text:o}=t;if(!r||!n||!o)return e.json({error:"channel, chatId, and text are required"},400);const s={chatId:n,userId:"inject",channelName:r,text:o,attachments:[]},i=await this.server.handleMessage(s),a=this.server.getChannelManager();await a.sendToChannel(r,n,i);const c=`${r}:${n}`;return e.json({ok:!0,sessionKey:c,response:i})}catch(t){return V.error(`Inject message failed: ${t}`),e.json({error:"Failed to inject message"},500)}}),t.get("/api/internal-tools",e=>{const t=this.server.getAgentService().getToolServers(),r=[];for(const e of t){const t=e,n=t.name||"unknown",o=t.instance?._registeredTools;if(!o)continue;const s=[];for(const[e,t]of Object.entries(o)){const r=[],n=t.inputSchema?.def?.shape;if(n)for(const[e,t]of Object.entries(n)){let n=t.type||"unknown";const o=t.isOptional?.()??!1;"optional"===n&&t.def?.innerType&&(n=t.def.innerType.type||"unknown","enum"===n&&t.def.innerType.def?.entries&&(n=Object.keys(t.def.innerType.def.entries).join("|"))),"enum"===n&&t.def?.entries&&(n=Object.keys(t.def.entries).join("|")),r.push({name:e,type:n,required:!o,description:t.description||""})}s.push({name:e,description:t.description||"",params:r})}r.push({server:n,tools:s})}return e.json(r)});const g=["AGENTS.md","SOUL.md","TOOLS.md","IDENTITY.md","USER.md","HEARTBEAT.md","BOOTSTRAP.md","MEMORY.md"],k=["SYSTEM_PROMPT.md","SYSTEM_PROMPT_SUBAGENT.md","CBINT.json"];t.get("/api/workspace-files",e=>{const t=this.server.getConfig().dataDir,r=[];for(const e of g){const n=v(t,e);if(d(n))try{r.push({name:e,content:a(n,"utf-8"),exists:!0,isTemplate:!1})}catch{r.push({name:e,content:"",exists:!1,isTemplate:!1})}else r.push({name:e,content:"",exists:!1,isTemplate:!1})}for(const e of k){const n=v(t,".templates",e);if(d(n))try{r.push({name:e,content:a(n,"utf-8"),exists:!0,isTemplate:!0})}catch{r.push({name:e,content:"",exists:!1,isTemplate:!0})}else r.push({name:e,content:"",exists:!1,isTemplate:!0})}const n=D(t),o=A(n),s=new Set(r.map(e=>e.name));for(const e of o){if(s.has(e.fileName))continue;s.add(e.fileName);const n=v(t,e.fileName);if(d(n))try{r.push({name:e.fileName,content:a(n,"utf-8"),exists:!0,isTemplate:!1,isInclude:!0,includedBy:e.sourceFile})}catch{r.push({name:e.fileName,content:"",exists:!1,isTemplate:!1,isInclude:!0,includedBy:e.sourceFile})}else r.push({name:e.fileName,content:"",exists:!1,isTemplate:!1,isInclude:!0,includedBy:e.sourceFile})}return e.json(r)}),t.put("/api/workspace-files/:name",async e=>{const t=e.req.param("name"),r=await e.req.json(),n=r.content,o=!0===r.isTemplate;if(null==n)return e.json({error:"content is required"},400);const s=this.server.getConfig().dataDir;let a;if(o){if(!k.includes(t))return e.json({error:"Invalid template name"},400);a=v(s,".templates",t)}else{if(!g.includes(t))return e.json({error:"Invalid file name"},400);a=v(s,t)}try{return i(a,n,"utf-8"),V.info(`Workspace file saved via Nostromo: ${a}`),e.json({ok:!0})}catch(t){return V.error(`Failed to save workspace file: ${t}`),e.json({error:"Failed to save file"},500)}}),t.post("/api/plugins/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const c=(r.get("basePath")||"").trim()||v(t.agent.workspacePath,".plugins"),l=v(c,n);f(l,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=v(l,r),a=v(n,"..");f(a,{recursive:!0});const c=Buffer.from(await t.arrayBuffer());i(n,c)}let u=n,p=n;const m=v(l,".claude-plugin","plugin.json");if(d(m))try{const e=a(m,"utf-8"),t=JSON.parse(e);t.name&&(u=t.name),t.description&&(p=t.description)}catch{}return e.json({ok:!0,path:l,name:u,description:p})}catch(t){return V.error(`Plugin upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.post("/api/plugins/delete-folder",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t)return e.json({error:"path is required"},400);const r=y(t);return d(r)?l(r).isDirectory()?(u(r,{recursive:!0,force:!0}),V.info(`Plugin folder deleted via Nostromo: ${r}`),e.json({ok:!0})):e.json({error:"Path is not a directory"},400):e.json({ok:!0,message:"Directory already gone"})}catch(t){return V.error(`Plugin folder delete failed: ${t}`),e.json({error:"Failed to delete folder"},500)}}),t.get("/api/plugins/download/:index",e=>{try{const t=parseInt(e.req.param("index"),10),r=this.server.getConfig(),n=r.agent?.plugins||[];if(isNaN(t)||t<0||t>=n.length)return e.json({error:"Invalid plugin index"},400);const o=n[t].path;if(!o)return e.json({error:"Plugin has no path"},400);const s=y(o);if(!d(s)||!l(s).isDirectory())return e.json({error:"Plugin folder not found"},404);const i=j(s),a=v(s,".."),c=h("zip",["-r","-q","-",i],{cwd:a,maxBuffer:104857600});return new Response(c,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${i}.zip"`}})}catch(t){return V.error(`Plugin download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.post("/api/plugins/verify-path",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t)return e.json({ok:!1,error:"Path is empty"});const r=y(t);return d(r)?l(r).isDirectory()?e.json({ok:!0,path:r}):e.json({ok:!1,error:"Path is not a directory"}):e.json({ok:!1,error:"Directory does not exist"})}catch(t){return e.json({ok:!1,error:"Invalid path"})}}),t.get("/api/plugins/info",e=>{const t=this.server.getConfig().agent.plugins||[],r=[];for(let e=0;e<t.length;e++){const n=t[e];let o=n.name,s=n.description||"",i=!1;try{if(d(n.path)&&l(n.path).isDirectory()){i=c(n.path).length>0;const e=v(n.path,".claude-plugin","plugin.json");if(d(e))try{const t=a(e,"utf-8"),r=JSON.parse(t);r.name&&(o=r.name),r.description&&!s&&(s=r.description)}catch{}}}catch{}r.push({index:e,name:o,description:s,valid:i})}return e.json(r)}),t.get("/api/commands",e=>{const t=this.server.getConfig(),r=v(t.agent.workspacePath,".claude","commands"),n=[];if(!d(r))return e.json(n);const o=(e,t)=>{try{const r=c(e);for(const s of r){const r=v(e,s);if(l(r).isDirectory())o(r,t?`${t}/${s}`:s);else if(s.endsWith(".md")){const e=t?`${t}/${s}`:s;let o=s.replace(/\.md$/,""),i="",c="";try{const e=Q(a(r,"utf-8"));e.name&&(o=e.name),e.description&&(i=e.description),e.model&&(c=e.model)}catch{}n.push({file:e,folder:t,name:o,description:i,model:c})}}}catch{}};return o(r,""),n.sort((e,t)=>e.folder!==t.folder?e.folder.localeCompare(t.folder):e.name.localeCompare(t.name)),e.json(n)}),t.post("/api/commands/delete",async e=>{try{const t=((await e.req.json()).path||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","commands",t);if(!d(n))return e.json({error:"Command not found"},404);if(l(n).isDirectory())u(n,{recursive:!0,force:!0});else{p(n);const e=v(n,"..");if(e!==v(r.agent.workspacePath,".claude","commands"))try{0===c(e).length&&u(e,{recursive:!0,force:!0})}catch{}}return V.info(`Command deleted via Nostromo: ${t}`),e.json({ok:!0})}catch(t){return V.error(`Failed to delete command: ${t}`),e.json({error:"Failed to delete command"},500)}}),t.post("/api/commands/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const a=v(t.agent.workspacePath,".claude","commands",n);f(a,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=v(a,r),c=v(n,"..");f(c,{recursive:!0});const l=Buffer.from(await t.arrayBuffer());i(n,l)}return V.info(`Command folder uploaded via Nostromo: ${n}`),e.json({ok:!0,name:n})}catch(t){return V.error(`Command upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.get("/api/commands/download/:folder",e=>{try{const t=e.req.param("folder");if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","commands"),o=v(n,t);if(!d(o)||!l(o).isDirectory())return e.json({error:"Command folder not found"},404);const s=h("zip",["-r","-q","-",t],{cwd:n,maxBuffer:104857600});return new Response(s,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${t}.zip"`}})}catch(t){return V.error(`Command download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/commands/download-file",e=>{try{const t=(e.req.query("path")||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","commands",t);if(!d(n)||!l(n).isFile())return e.json({error:"File not found"},404);const o=a(n),s=j(n);return new Response(o,{headers:{"Content-Type":"application/octet-stream","Content-Disposition":`attachment; filename="${s}"`}})}catch(t){return V.error(`Command file download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/commands/file",e=>{try{const t=(e.req.query("path")||"").trim();if(!t||t.includes(".."))return e.json({error:"Invalid path"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","commands",t);if(!d(n)||!l(n).isFile())return e.json({error:"File not found"},404);const o=a(n,"utf-8");return e.json({path:t,content:o})}catch(t){return V.error(`Command file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/commands/file",async e=>{try{const t=await e.req.json(),r=(t.path||"").trim(),n=t.content;if(!r||r.includes(".."))return e.json({error:"Invalid path"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=this.server.getConfig(),s=v(o.agent.workspacePath,".claude","commands",r),a=v(s,"..");return f(a,{recursive:!0}),i(s,n,"utf-8"),V.info(`Command file saved via Nostromo: ${r}`),e.json({ok:!0})}catch(t){return V.error(`Command file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.post("/api/commands/create",async e=>{try{const t=await e.req.json(),r=(t.name||"").trim();if(!r||!/^[a-zA-Z0-9_-]+$/.test(r))return e.json({error:"Invalid name. Use only letters, digits, hyphens, and underscores."},400);const n=(t.description||"").trim(),o=(t.model||"").trim(),s=this.server.getConfig(),a=v(s.agent.workspacePath,".claude","commands");f(a,{recursive:!0});const c=v(a,r+".md");if(d(c))return e.json({error:"A command with this name already exists"},409);let l="---\n";return l+=`name: ${r}\n`,n&&(l+=`description: ${n}\n`),o&&(l+=`model: ${o}\n`),l+="---\n\n",i(c,l,"utf-8"),V.info(`Standalone command created via Nostromo: ${r}.md`),e.json({ok:!0,file:r+".md"})}catch(t){return V.error(`Command creation failed: ${t}`),e.json({error:"Creation failed"},500)}}),t.get("/api/skills",e=>{const t=this.server.getConfig(),r=v(t.agent.workspacePath,".claude","skills"),n=[];if(!d(r))return e.json(n);try{const e=c(r);for(const t of e){const e=v(r,t);if(!l(e).isDirectory())continue;const o=v(e,"SKILL.md");let s=t,i="";if(d(o))try{const e=Q(a(o,"utf-8"));e.name&&(s=e.name),e.description&&(i=e.description)}catch{}n.push({folder:t,name:s,description:i})}}catch(e){V.error(`Failed to list skills: ${e}`)}return e.json(n)}),t.post("/api/skills/delete",async e=>{try{const t=((await e.req.json()).folder||"").trim();if(!t||t.includes("..")||t.includes("/"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","skills",t);return d(n)?(l(n).isDirectory()?u(n,{recursive:!0,force:!0}):p(n),V.info(`Skill deleted via Nostromo: ${t}`),e.json({ok:!0})):e.json({error:"Skill not found"},404)}catch(t){return V.error(`Failed to delete skill: ${t}`),e.json({error:"Failed to delete skill"},500)}}),t.post("/api/skills/upload",async e=>{try{const t=this.server.getConfig(),r=await e.req.formData(),n=(r.get("folderName")||"").trim();if(!n)return e.json({error:"folderName is required"},400);if(n.includes("..")||n.includes("/")||n.includes("\\"))return e.json({error:"Invalid folder name"},400);const o=r.getAll("files"),s=r.getAll("paths");if(0===o.length)return e.json({error:"No files provided"},400);if(o.length!==s.length)return e.json({error:"files and paths count mismatch"},400);const a=v(t.agent.workspacePath,".claude","skills",n);f(a,{recursive:!0});for(let e=0;e<o.length;e++){const t=o[e],r=s[e];if(r.includes(".."))continue;const n=v(a,r),c=v(n,"..");f(c,{recursive:!0});const l=Buffer.from(await t.arrayBuffer());i(n,l)}return V.info(`Skill folder uploaded via Nostromo: ${n}`),e.json({ok:!0,name:n})}catch(t){return V.error(`Skill upload failed: ${t}`),e.json({error:"Upload failed"},500)}}),t.get("/api/skills/download/:folder",e=>{try{const t=e.req.param("folder");if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","skills"),o=v(n,t);if(!d(o)||!l(o).isDirectory())return e.json({error:"Skill folder not found"},404);const s=h("zip",["-r","-q","-",t],{cwd:n,maxBuffer:104857600});return new Response(s,{headers:{"Content-Type":"application/zip","Content-Disposition":`attachment; filename="${t}.zip"`}})}catch(t){return V.error(`Skill download failed: ${t}`),e.json({error:"Download failed"},500)}}),t.get("/api/skills/file",e=>{try{const t=(e.req.query("folder")||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=this.server.getConfig(),n=v(r.agent.workspacePath,".claude","skills",t,"SKILL.md");if(!d(n))return e.json({path:t+"/SKILL.md",content:""});const o=a(n,"utf-8");return e.json({path:t+"/SKILL.md",content:o})}catch(t){return V.error(`Skill file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/skills/file",async e=>{try{const t=await e.req.json(),r=(t.folder||"").trim(),n=t.content;if(!r||r.includes("..")||r.includes("/")||r.includes("\\"))return e.json({error:"Invalid folder name"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=this.server.getConfig(),s=v(o.agent.workspacePath,".claude","skills",r);f(s,{recursive:!0});const a=v(s,"SKILL.md");return i(a,n,"utf-8"),V.info(`Skill file saved via Nostromo: ${r}/SKILL.md`),e.json({ok:!0})}catch(t){return V.error(`Skill file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.get("/api/skills/bundled",e=>{try{const t=P();if(!t)return e.json([]);const r=c(t,{withFileTypes:!0}),n=[];for(const e of r){if(!e.isDirectory())continue;const r=v(t,e.name,"SKILL.md");if(!d(r))continue;const o=Q(a(r,"utf-8"));n.push({folder:e.name,name:o.name||e.name,description:o.description||""})}return n.sort((e,t)=>e.name.localeCompare(t.name)),e.json(n)}catch(t){return V.error(`Failed to list bundled skills: ${t}`),e.json({error:"Failed to list bundled skills"},500)}}),t.post("/api/skills/use-bundled",async e=>{try{const t=((await e.req.json()).folder||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=P();if(!r)return e.json({error:"Bundled directory not found"},404);const n=v(r,t);if(!d(n))return e.json({error:"Bundled skill not found"},404);const o=this.server.getConfig(),s=v(o.agent.workspacePath,".claude","skills",t);return m(n,s,{recursive:!0}),V.info(`Bundled skill installed via Nostromo: ${t}`),e.json({ok:!0,name:t})}catch(t){return V.error(`Use bundled skill failed: ${t}`),e.json({error:"Install failed"},500)}}),t.get("/api/skills/bundled/file",e=>{try{const t=(e.req.query("folder")||"").trim();if(!t||t.includes("..")||t.includes("/")||t.includes("\\"))return e.json({error:"Invalid folder name"},400);const r=P();if(!r)return e.json({error:"Bundled directory not found"},404);const n=v(r,t,"SKILL.md");if(!d(n))return e.json({error:"File not found"},404);const o=a(n,"utf-8");return e.json({content:o})}catch(t){return V.error(`Bundled skill file read failed: ${t}`),e.json({error:"Read failed"},500)}}),t.post("/api/skills/bundled/file",async e=>{try{const t=await e.req.json(),r=(t.folder||"").trim(),n=t.content;if(!r||r.includes("..")||r.includes("/")||r.includes("\\"))return e.json({error:"Invalid folder name"},400);if("string"!=typeof n)return e.json({error:"Content is required"},400);const o=P();if(!o)return e.json({error:"Bundled directory not found"},404);const s=v(o,r);if(!d(s))return e.json({error:"Bundled skill folder not found"},404);const a=v(s,"SKILL.md");return i(a,n,"utf-8"),V.info(`Bundled skill file saved via Nostromo: ${r}/SKILL.md`),e.json({ok:!0})}catch(t){return V.error(`Bundled skill file save failed: ${t}`),e.json({error:"Save failed"},500)}}),t.get("/api/prompt-simulate",e=>{try{const t=$(),r=D(t.dataDir),n={sessionKey:"simulate:preview",channel:"simulate",chatId:"preview",sessionId:"(simulated)",memoryFile:v(t.dataDir,"memory","simulate_preview","2026-01-01.md"),attachmentsDir:v(t.dataDir,"memory","simulate_preview","2026-01-01")},o=this.server.getAgentService().getToolServers(),s=T({config:t,sessionContext:n,workspaceFiles:r,mode:"full",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,toolServers:o}),i=T({config:t,sessionContext:n,workspaceFiles:r,mode:"minimal",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,subagentTask:"(example subagent task description)",toolServers:o});return e.json({main:s,subagent:i})}catch(t){return V.error(`Prompt simulation failed: ${t}`),e.json({error:"Failed to simulate prompt: "+String(t)},500)}}),t.get("/api/prompt-build-meta",e=>{try{const t=$(),r=D(t.dataDir),n={sessionKey:"simulate:preview",channel:"simulate",chatId:"preview",sessionId:"(simulated)",memoryFile:v(t.dataDir,"memory","simulate_preview","2026-01-01.md"),attachmentsDir:v(t.dataDir,"memory","simulate_preview","2026-01-01")},o=this.server.getAgentService().getToolServers(),s=q({config:t,sessionContext:n,workspaceFiles:r,mode:"full",hasNodeTools:this.server.getAgentService().hasNodeTools(),hasMessageTools:this.server.getAgentService().hasMessageTools(),coderSkill:t.agent.builtinCoderSkill,toolServers:o});return e.json(s.meta)}catch(t){return V.error(`Build meta failed: ${t}`),e.json({error:"Failed to get build meta: "+String(t)},500)}}),t.post("/api/memory-search/test-embedding",async e=>{try{const t=await e.req.json(),r=t.modelRef||"",n=t.embeddingModel||"text-embedding-3-small",o=t.embeddingDimensions||1536,s=this.server.getConfig(),i=N(s,r),a=(i?.useEnvVar?process.env[i.useEnvVar]:i?.apiKey)||"",c=i?.baseURL||"";if(!a)return e.json({ok:!1,error:`No API key found for modelRef "${r}"`},400);const{default:l}=await import("openai"),d=new l({apiKey:a,...c?{baseURL:c}:{}}),u=Date.now(),f=await d.embeddings.create({model:n,input:"test embedding connection",dimensions:o}),p=Date.now()-u,m=f.data?.[0]?.embedding?.length??0;return e.json({ok:!0,model:n,dimensions:m,latencyMs:p})}catch(t){const r=t instanceof Error?t.message:String(t);return V.error(`Embedding test failed: ${r}`),e.json({ok:!1,error:r},500)}}),t.get("/api/memory-files",e=>{const t=this.server.getConfig().memoryDir;if(!d(t))return e.json([]);try{const r=[],n=c(t,{withFileTypes:!0});for(const e of n){if(!e.isDirectory())continue;const n=v(t,e.name),o=c(n).filter(e=>e.endsWith(".md")).map(e=>{const t=l(v(n,e));return{name:e,size:t.size,modified:t.mtime.toISOString()}}).sort((e,t)=>t.name.localeCompare(e.name));o.length>0&&r.push({sessionKey:e.name,files:o})}return r.sort((e,t)=>e.sessionKey.localeCompare(t.sessionKey)),e.json(r)}catch(t){return V.error(`Failed to list memory files: ${t}`),e.json([])}}),t.get("/api/memory-files/:sessionKey/:fileName",e=>{const t=e.req.param("sessionKey"),r=e.req.param("fileName");if(!/^[\w-]+\.md$/.test(r))return e.json({error:"Invalid file name"},400);if(/[/\\.]/.test(t.replace(/:/g,"")))return e.json({error:"Invalid session key"},400);const n=this.server.getConfig(),o=v(n.memoryDir,t.replace(/:/g,"_"),r);if(!d(o))return e.json({error:"File not found"},404);try{const n=a(o,"utf-8");return e.json({sessionKey:t,fileName:r,content:n})}catch(t){return e.json({error:"Failed to read file"},500)}}),t.get("/api/logs/current",e=>{const t=J();if(!t)return e.json({lines:[],total:0});const r=v(t,"gmab.log");try{const t=a(r,"utf-8").split("\n").filter(e=>e.length>0),n=Math.min(Math.max(parseInt(e.req.query("lines")||"200")||200,1),1e3),o=t.slice(-n);return e.json({lines:o,total:t.length})}catch{return e.json({lines:[],total:0})}}),t.get("/api/logs/current/download",async e=>{const t=J();if(!t)return e.json({error:"Logs not initialized"},503);const r=v(t,"gmab.log"),n="true"===e.req.query("compress");try{if(n){const e=a(r),{gzipSync:t}=await import("node:zlib"),n=t(e);return new Response(n,{headers:{"Content-Type":"application/gzip","Content-Disposition":'attachment; filename="gmab.log.gz"'}})}const e=a(r);return new Response(e,{headers:{"Content-Type":"text/plain","Content-Disposition":'attachment; filename="gmab.log"'}})}catch{return e.json({error:"Log file not found"},404)}}),t.get("/api/logs/files",e=>{const t=J();if(!t)return e.json([]);try{const r=c(t).filter(e=>/^gmab\.\d+\.log$/.test(e)).map(e=>{const r=l(v(t,e));return{name:e,size:r.size,modified:r.mtime.toISOString()}}).sort((e,t)=>e.name.localeCompare(t.name,void 0,{numeric:!0}));return e.json(r)}catch{return e.json([])}}),t.get("/api/logs/files/:name/download",async e=>{const t=J();if(!t)return e.json({error:"Logs not initialized"},503);const r=e.req.param("name");if(!/^gmab\.\d+\.log$/.test(r))return e.json({error:"Invalid file name"},400);const n=v(t,r);try{const e=a(n),{gzipSync:t}=await import("node:zlib"),o=t(e);return new Response(o,{headers:{"Content-Type":"application/gzip","Content-Disposition":`attachment; filename="${r}.gz"`}})}catch{return e.json({error:"File not found"},404)}}),t.get("/api/logs/level",e=>e.json({level:H()})),t.put("/api/logs/level",async e=>{try{const t=(await e.req.json()).level,r=["debug","info","warn","error"];if(!r.includes(t))return e.json({error:"Invalid level. Must be one of: "+r.join(", ")},400);Y(t);try{const e=y(process.cwd(),"config.yaml"),{parse:r}=await import("yaml"),n=r(a(e,"utf-8"))||{};n.logLevel=t;const o=s(n);b(e),i(e,o,"utf-8")}catch(e){V.warn(`Failed to persist logLevel to config.yaml: ${e}`)}return V.info(`Log level changed to ${t}`),e.json({ok:!0,level:t})}catch(t){return e.json({error:"Failed to set log level"},400)}}),t.put("/api/logs/verbose",async e=>{try{const t=!!(await e.req.json()).enabled;try{const e=y(process.cwd(),"config.yaml"),{parse:r}=await import("yaml"),n=r(a(e,"utf-8"))||{};n.verboseDebugLogs=t;const o=s(n);b(e),i(e,o,"utf-8")}catch(e){V.warn(`Failed to persist verboseDebugLogs to config.yaml: ${e}`)}return V.info("Verbose debug logs "+(t?"enabled":"disabled")),e.json({ok:!0,enabled:t})}catch(t){return e.json({error:"Failed to set verbose logs"},400)}})}async start(){this.httpServer=t({fetch:this.app.fetch,hostname:this.host,port:this.port}),this.wss=new k({noServer:!0}),this.httpServer.on("upgrade",(e,t,r)=>{const n=new URL(e.url??"/",`http://localhost:${this.port}`),o="/"===this.basePath?"":this.basePath;n.pathname===o+"/ws/nodes"?this.wss.handleUpgrade(e,t,r,t=>{this.wss.emit("connection",t,e)}):t.destroy()}),this.wss.on("connection",e=>{let t=null,r=null,n=!1;const o=()=>{n&&t&&(this.nodeRegistry.unregister(t),n=!1),r&&this.pendingNodes.delete(r);const o=this.server.getWebChatChannel();o&&o.unregisterByWs(e)};e.on("message",o=>{try{const s=JSON.parse(o.toString());if("hello"===s.type){t=s.nodeId;const o=s.signature;if(!t)return void e.close(1008,"Missing nodeId in hello");if(!o)return void e.close(1008,"Missing signature");r=o;const i=this.server.getNodeSignatureDb(),a=i.createOrUpdatePending(t,o,s.hostname??"",s.displayName??"",s.platform??"",s.arch??""),c={displayName:s.displayName,platform:s.platform,arch:s.arch,hostname:s.hostname,capabilities:s.capabilities??[],commands:s.commands??[]};switch(a.status){case"pending":e.send(JSON.stringify({type:"pairing_status",status:"pending"})),this.pendingNodes.set(o,{ws:e,nodeId:t,info:c}),V.info(`Node ${t} (${s.displayName??"unnamed"}) awaiting approval`);break;case"approved":e.send(JSON.stringify({type:"pairing_status",status:"approved"})),this.nodeRegistry.register(t,e,c),n=!0,i.updateLastSeen(a.id),V.info(`Node ${t} (${s.displayName??"unnamed"}) approved and registered`);break;case"revoked":e.send(JSON.stringify({type:"pairing_status",status:"revoked"})),e.close(1008,"Signature revoked")}}else if("command_result"===s.type)this.nodeRegistry.handleCommandResult(s.id,{ok:s.ok,result:s.result,error:s.error});else if("chat"===s.type){if(!n||!t)return;const r=this.server.getWebChatChannel();if(!r)return;const o=this.nodeRegistry.getNode(t),i=S(o?.displayName??"node",t),a=s.chatId,c=a?`${i}/${a}`:i;r.registerConnection(c,e),r.handleNodeChat(c,t,{text:s.text,attachments:s.attachments})}else if("a2ui_action"===s.type){if(!n||!t)return;const e=this.server.getWebChatChannel();if(!e)return;const r=s.userAction;if(!r||!r.name)return;const o=this.nodeRegistry.getNode(t),i=S(o?.displayName??"node",t),a=s.chatId,c=a?`${i}/${a}`:i,l=r.context&&Object.keys(r.context).length>0?` ${JSON.stringify(r.context)}`:"",d=`[A2UI Action] ${r.name} on surface ${r.surfaceId||"main"} (component: ${r.sourceComponentId||"unknown"})${l}`;e.handleNodeChat(c,t,{text:d}),V.info(`A2UI action from node ${t}: ${r.name} on ${r.surfaceId||"main"}`)}else if("dynamic_ui_action"===s.type){if(!n||!t)return;const r=this.server.getWebChatChannel();if(!r)return;const o=s.action;if(!o||!o.activityId)return;const i=s.channel,a=s.chatId;let c;if(i&&a)c=a,r.registerConnection(a,e);else{const e=this.nodeRegistry.getNode(t);c=S(e?.displayName??"node",t)}const l=o.data?` data=${JSON.stringify(o.data)}`:"",d=o.context&&Object.keys(o.context).length>0?` context=${JSON.stringify(o.context)}`:"",u=`[Dynamic UI Action] ${o.type} on #${o.activityId}${l}${d}`;if(i&&"webchat"!==i&&a){const e=this.server.getChannelManager(),n=e?.getAdapter(i);n&&e?(e.setTyping(i,a).catch(()=>{}),this.server.handleMessage({chatId:a,userId:t,channelName:i,text:u,attachments:[]}).then(async t=>{t&&t.trim()&&await e.sendResponse(i,a,t)}).catch(e=>{V.error(`Error handling dynamic_ui_action for ${i}:${a}: ${e}`)})):r.handleNodeChat(c,t,{text:u})}else r.handleNodeChat(c,t,{text:u});V.info(`Dynamic UI action from node ${t}: ${o.type} on #${o.activityId} → ${i??"webchat"}:${c}`)}else"ping"===s.type&&e.send(JSON.stringify({type:"pong"}))}catch{}}),e.on("close",()=>{o()}),e.on("error",e=>{V.error(`Node WS error: ${e.message}`),o()})}),V.info(`Nostromo listening on http://${this.host}:${this.port}${this.basePath}`)}notifyPairingChange(e,t){const r=this.server.getNodeSignatureDb(),n=r.getById(e);if(!n)return;const o=n.signature;if("approved"===t){const t=this.pendingNodes.get(o);t&&(t.ws.send(JSON.stringify({type:"pairing_status",status:"approved"})),this.nodeRegistry.register(t.nodeId,t.ws,t.info),this.pendingNodes.delete(o),r.updateLastSeen(e),V.info(`Node ${t.nodeId} approved via Nostromo`))}else if("revoked"===t){const e=this.pendingNodes.get(o);if(e)return e.ws.send(JSON.stringify({type:"pairing_status",status:"revoked"})),e.ws.close(1008,"Signature revoked"),void this.pendingNodes.delete(o);const t=this.nodeRegistry.listNodes();for(const e of t){const t=this.nodeRegistry.getNode(e.nodeId);if(t&&e.nodeId===n.nodeId){t.ws.send(JSON.stringify({type:"pairing_status",status:"revoked"})),t.ws.close(1008,"Signature revoked"),this.nodeRegistry.unregister(e.nodeId);break}}}}async stop(){this.wss&&this.wss.close(),this.httpServer&&this.httpServer.close(),V.info("Nostromo stopped")}}function G(e){return"string"==typeof e&&/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(e)}const X={telegram:[{field:"botToken",envSuffix:"BOT_TOKEN"}],discord:[{field:"token",envSuffix:"TOKEN"}],slack:[{field:"botToken",envSuffix:"BOT_TOKEN"},{field:"appToken",envSuffix:"APP_TOKEN"}],msteams:[{field:"appSecret",envSuffix:"APP_SECRET"}],line:[{field:"channelAccessToken",envSuffix:"CHANNEL_ACCESS_TOKEN"},{field:"channelSecret",envSuffix:"CHANNEL_SECRET"}],matrix:[{field:"accessToken",envSuffix:"ACCESS_TOKEN"}]};function Q(e){const t=e.match(/^---\r?\n([\s\S]*?)\r?\n---/);if(!t)return{};const r={};for(const e of t[1].split("\n")){const t=e.indexOf(":");if(t<0)continue;const n=e.slice(0,t).trim(),o=e.slice(t+1).trim().replace(/^["']|["']$/g,"");n&&(r[n]=o)}return r}
@@ -1 +1 @@
1
- export function renderModals(){return'\n\x3c!-- Restart confirmation modal --\x3e\n<div class="modal-overlay" id="restartModal">\n <div class="modal">\n <button class="close-btn" onclick="closeRestartModal()">&times;</button>\n <h3>Restart Server</h3>\n <p>This will restart all channels, agents, and services. Active conversations will be interrupted.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn" onclick="doRestart()">Restart</button>\n <button class="btn-ghost" onclick="closeRestartModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="placeholderRefModal">\n <div class="modal" style="max-width:620px;text-align:left;max-height:85vh;overflow-y:auto">\n <button class="close-btn" onclick="document.getElementById(\'placeholderRefModal\').classList.remove(\'open\')">&times;</button>\n <h3 style="margin-bottom:14px">Placeholder Reference</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">These placeholders are resolved at runtime when building the system prompt from templates.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:40%">Placeholder</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><code>{{ATTACHMENTS_DIR}}</code></td><td>Path to the session attachments folder, or <code>(memory disabled)</code></td></tr>\n <tr><td><code>{{AVAILABLE_TOOLS}}</code></td><td>Auto-discovered list of all registered MCP tools with name, description, and parameters</td></tr>\n <tr><td><code>{{CHANNEL}}</code></td><td>Channel name, e.g. <code>telegram</code>, <code>responses</code></td></tr>\n <tr><td><code>{{CHAT_ID}}</code></td><td>Chat / conversation ID within the channel</td></tr>\n <tr><td><code>{{DATA_DIR}}</code></td><td>Absolute path to the data directory (workspace files, templates)</td></tr>\n <tr><td><code>{{HEARTBEAT_INSTRUCTIONS}}</code></td><td>Heartbeat/cron behavioral instructions, or empty if cron is disabled</td></tr>\n <tr><td><code>{{HEARTBEAT_PROMPT}}</code></td><td>Raw heartbeat prompt message from config</td></tr>\n <tr><td><code>{{HOSTNAME}}</code></td><td>Server hostname</td></tr>\n <tr><td><code>{{MEMORY_FILE}}</code></td><td>Path to the current conversation memory file, or <code>(memory disabled)</code></td></tr>\n <tr><td><code>{{MESSAGE_TOOLS_INSTRUCTIONS}}</code></td><td>Instructions for cross-channel messaging tools, or empty if not available</td></tr>\n <tr><td><code>{{MODEL}}</code></td><td>Active model ID, e.g. <code>claude-opus-4-6</code></td></tr>\n <tr><td><code>{{NODE_TOOLS_INSTRUCTIONS}}</code></td><td>Instructions for node remote-execution tools, or empty if no nodes connected</td></tr>\n <tr><td><code>{{OS}}</code></td><td>Operating system info (type, release, arch)</td></tr>\n <tr><td><code>{{RUNTIME_LINE}}</code></td><td>Composed runtime info line (host, OS, model, channel, session)</td></tr>\n <tr><td><code>{{SEARCH_IN_MEMORIES}}</code></td><td>Memory search tools instructions (<code>memory_search</code>, <code>memory_get</code>). Populated when Recall Strategy is not <code>builtin-only</code>, empty otherwise.</td></tr>\n <tr><td><code>{{SESSION_ID}}</code></td><td>SDK session ID for resumption, or <code>(new session)</code></td></tr>\n <tr><td><code>{{SESSION_KEY}}</code></td><td>Current session identifier, e.g. <code>telegram:12345</code></td></tr>\n <tr><td><code>{{SUBAGENT_TASK}}</code></td><td>Task description for subagent mode (empty for main agent)</td></tr>\n <tr><td><code>{{TIMEZONE}}</code></td><td>Server timezone, e.g. <code>Europe/Rome</code></td></tr>\n <tr><td><code>{{WORKSPACE_DIR}}</code></td><td>Absolute path to the agent workspace directory</td></tr>\n <tr><td><code>{{WORKSPACE_FILES}}</code></td><td>All workspace .md files inlined as <code>## FileName.md</code> sections</td></tr>\n </tbody>\n </table>\n </div>\n</div>\n<div class="modal-overlay" id="agentHelpModal">\n <div class="modal" style="max-width:680px;text-align:left;max-height:85vh;overflow-y:auto">\n <button class="close-btn" onclick="document.getElementById(\'agentHelpModal\').classList.remove(\'open\')">&times;</button>\n <h3 style="margin-bottom:14px">Agent Configuration Reference</h3>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Main</h4>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Default Model</strong></td><td>The model used for all conversations. Must be one of the models defined in the Models section.</td></tr>\n <tr><td><strong>Fallback Model</strong></td><td>If the default model fails (API error, overloaded, etc.), the agent automatically retries with this model. Leave empty to disable fallback.</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Configuration</h4>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Max Turns</strong></td><td>Maximum number of agentic turns (tool use round-trips) per single invocation. Prevents runaway loops. A typical value is 5&ndash;15.</td></tr>\n <tr><td><strong>Permission Mode</strong></td><td>Controls how the SDK handles tool permissions.<br><code>default</code> &mdash; ask the user before running tools.<br><code>bypassPermissions</code> &mdash; run all allowed tools without asking (recommended for automated agents).<br><code>plan</code> &mdash; the agent proposes a plan and waits for approval before executing.</td></tr>\n <tr><td><strong>Session TTL</strong></td><td>Time in seconds before an idle session expires. When a session expires, the next message starts a new conversation (no history carried over). Default: 3600 (1 hour).</td></tr>\n <tr><td><strong>Setting Sources</strong></td><td>Which SDK settings files to load.<br><code>nothing</code> &mdash; ignore all settings files.<br><code>user</code> &mdash; load user-level settings (~/.claude).<br><code>project</code> &mdash; load project-level settings from workspace.<br><code>both</code> &mdash; load both user and project settings.</td></tr>\n <tr><td><strong>Coder Skill</strong></td><td>When enabled, the builtin coding prompt is always prepended to the system prompt, adding coding-oriented instructions. When disabled, the system prompt is built entirely from your templates (SYSTEM_PROMPT.md).</td></tr>\n <tr><td><strong>Allowed Tools</strong></td><td>Which SDK tools the agent can use. Unchecked tools are not available to the agent. Common tools: <code>Read</code>, <code>Write</code>, <code>Edit</code> for file operations; <code>Bash</code> for shell commands; <code>Glob</code>/<code>Grep</code> for search; <code>WebSearch</code>/<code>WebFetch</code> for web access; <code>Task</code> for sub-agents; <code>Skill</code> for slash commands.</td></tr>\n <tr><td><strong>Auto Approve Tools</strong></td><td>Controls how tool permission requests from the SDK are handled.<br><strong>On (default)</strong> &mdash; all tool invocations are automatically approved. This prevents the SDK from blocking when <code>permissionMode</code> is set to <code>default</code>.<br><strong>Off</strong> &mdash; tool permission requests are forwarded to the user&rsquo;s channel as interactive messages with Approve/Deny buttons. The SDK waits until the user responds before proceeding.</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Message Queue</h4>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:10px">Controls how incoming messages are handled while the agent is already processing a response.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Queue Mode</strong></td><td>\n <code>queue</code> &mdash; Simple FIFO. Each message is processed one at a time in order. The user must wait for each response before the next message is handled.<br>\n <code>collect</code> &mdash; Messages sent while the agent is busy are buffered. After a debounce period, all buffered messages are merged into a single prompt and processed together.<br>\n <code>steer</code> &mdash; A new message interrupts the current processing. The agent stops its current response and immediately starts handling the new message, combining context from both.\n </td></tr>\n <tr><td><strong>Debounce (ms)</strong></td><td>Only applies in <code>collect</code> mode. After the last buffered message arrives, the system waits this many milliseconds before flushing the batch. This allows grouping rapid-fire messages into a single prompt. Set to 0 for immediate flush.</td></tr>\n <tr><td><strong>Queue Cap</strong></td><td>Maximum number of messages that can accumulate in the buffer. When this limit is reached, the Drop Policy kicks in. Set to 0 for unlimited.</td></tr>\n <tr><td><strong>Drop Policy</strong></td><td>What happens when Queue Cap is exceeded:<br>\n <code>new</code> &mdash; The incoming message is rejected; the user receives an error asking to wait.<br>\n <code>old</code> &mdash; The oldest buffered message is silently discarded to make room.<br>\n <code>summarize</code> &mdash; The oldest message is discarded but a truncated preview (first 140 chars) is preserved. When the batch is flushed, these previews are prepended so the agent knows messages were dropped and can see a summary of their content.\n </td></tr>\n <tr><td><strong>Inflight Typing</strong></td><td>When enabled, the typing indicator stays active as long as at least one message is being processed. Without this, a fast command (e.g. <code>/status</code>) finishing while the agent is still working would clear the typing indicator prematurely.</td></tr>\n </tbody>\n </table>\n </div>\n</div>\n<div class="modal-overlay" id="memViewModal">\n <div class="modal sim-modal">\n <button class="close-btn" onclick="closeMemViewModal()">&times;</button>\n <h3 id="memViewTitle">Memory Log</h3>\n <div id="memEditorWrap" class="monaco-wrap readonly" style="height:450px"></div>\n <div class="sim-info">\n <span id="memViewInfo"></span>\n <button class="btn btn-ghost btn-sm" onclick="copyMemView()">Copy</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="simModal">\n <div class="modal sim-modal">\n <button class="close-btn" onclick="closeSimModal()">&times;</button>\n <h3>Simulate Building</h3>\n <div class="sim-tabs">\n <button class="sim-tab active" onclick="switchSimTab(\'main\',this)">Main Agent</button>\n <button class="sim-tab" onclick="switchSimTab(\'subagent\',this)">Subagent</button>\n </div>\n <div id="simEditorWrap" class="monaco-wrap readonly" style="height:450px"></div>\n <div class="sim-info">\n <span id="simCharCount"></span>\n <button class="btn btn-ghost btn-sm" onclick="copySimPrompt()">Copy</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="memDisableModal">\n <div class="modal">\n <button class="close-btn" onclick="closeMemDisableModal(false)">&times;</button>\n <h3>Disable Memories</h3>\n <p>Disabling memories means the agent will no longer keep track of past conversations. Conversation logs will not be saved and the agent will have no continuity between sessions.</p>\n <p style="font-size:13px;color:var(--text-muted)">Existing memory files will not be deleted.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn btn-danger" onclick="closeMemDisableModal(true)">Disable</button>\n <button class="btn-ghost" onclick="closeMemDisableModal(false)">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSkillDeleteModal()">&times;</button>\n <h3>Delete Skill</h3>\n <p>Are you sure you want to delete <strong id="skillDeleteName"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteSkill()">Delete</button>\n <button class="btn-ghost" onclick="closeSkillDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeCommandDeleteModal()">&times;</button>\n <h3>Delete Command</h3>\n <p>Are you sure you want to delete <strong id="commandDeleteName"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteCommand()">Delete</button>\n <button class="btn-ghost" onclick="closeCommandDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="pluginDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closePluginDeleteModal()">&times;</button>\n <h3>Delete Plugin</h3>\n <p>Are you sure you want to delete <strong id="pluginDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeletePlugin()">Delete</button>\n <button class="btn-ghost" onclick="closePluginDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="pluginUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closePluginUploadedModal()">&times;</button>\n <h3>Plugin Added</h3>\n <p><strong id="pluginUploadedName"></strong> has been uploaded and saved to the configuration.</p>\n <p style="font-size:13px;color:var(--text-muted)">The plugin will appear as <em>invalid</em> until the server is restarted. Restart the server from the Dashboard to activate it.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closePluginUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillEditModal">\n <div class="modal" style="max-width:800px;width:90vw;text-align:left">\n <button class="close-btn" onclick="closeSkillEditModal()">&times;</button>\n <h3 style="margin-bottom:4px">Edit Skill</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px"><code id="skillEditPath"></code></p>\n <div id="skillEditorWrap" class="monaco-wrap" style="height:400px"></div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost" onclick="closeSkillEditModal()">Close</button>\n <button class="btn" onclick="saveSkillEdit()">Save</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandEditModal">\n <div class="modal" style="max-width:800px;width:90vw;text-align:left">\n <button class="close-btn" onclick="closeCommandEditModal()">&times;</button>\n <h3 style="margin-bottom:4px">Edit Command</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px"><code id="commandEditPath"></code></p>\n <div id="commandEditorWrap" class="monaco-wrap" style="height:400px"></div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost" onclick="closeCommandEditModal()">Close</button>\n <button class="btn" onclick="saveCommandEdit()">Save</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closeCommandUploadedModal()">&times;</button>\n <h3>Command Added</h3>\n <p><strong id="commandUploadedName"></strong> has been uploaded.</p>\n <p style="font-size:13px;color:var(--text-muted)">The command is ready to use immediately.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeCommandUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSkillUploadedModal()">&times;</button>\n <h3>Skill Added</h3>\n <p><strong id="skillUploadedName"></strong> has been uploaded.</p>\n <p style="font-size:13px;color:var(--text-muted)">The skill is ready to use immediately.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeSkillUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="varDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeVarDeleteModal()">&times;</button>\n <h3>Delete Var</h3>\n <p>Are you sure you want to delete <strong id="varDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteVar()">Delete</button>\n <button class="btn-ghost" onclick="closeVarDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="modelDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeModelDeleteModal()">&times;</button>\n <h3>Delete Model</h3>\n <p>Are you sure you want to delete <strong id="modelDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteModel()">Delete</button>\n <button class="btn-ghost" onclick="closeModelDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="saDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSaDeleteModal()">&times;</button>\n <h3>Delete Sub Agent</h3>\n <p>Are you sure you want to delete <strong id="saDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteSubAgent()">Delete</button>\n <button class="btn-ghost" onclick="closeSaDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="tokenDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeTokenDeleteModal()">&times;</button>\n <h3>Delete Token</h3>\n <p>Are you sure you want to delete token <strong id="tokenDeleteId"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteToken()">Delete</button>\n <button class="btn-ghost" onclick="closeTokenDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="regenKeyModal">\n <div class="modal">\n <button class="close-btn" onclick="closeRegenKeyModal()">&times;</button>\n <h3>Regenerate Access Key</h3>\n <p>This will invalidate the current access key. Any saved links or bookmarks using the old key will stop working.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="closeRegenKeyModal();regenKey()">Regenerate</button>\n <button class="btn-ghost" onclick="closeRegenKeyModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="heartbeatSimModal">\n <div class="modal" style="max-width:600px;text-align:left">\n <button class="close-btn" onclick="closeHeartbeatSimModal()">&times;</button>\n <h3 id="heartbeatSimTitle">Heartbeat Simulation</h3>\n <p id="heartbeatSimResult" style="margin:12px 0"></p>\n <label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px">HEARTBEAT.md</label>\n <textarea id="heartbeatSimContent" readonly style="width:100%;height:200px;font-family:var(--mono);font-size:13px;resize:vertical;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px;color:var(--text)"></textarea>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeHeartbeatSimModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="qrModal">\n <div class="modal">\n <button class="close-btn" onclick="closeQrModal()">&times;</button>\n <h3>Connect WhatsApp</h3>\n <p id="qrMsg">Waiting for QR code...</p>\n <div id="qrSpinner"><span class="spinner"></span> Connecting...</div>\n <img id="qrImg" style="display:none" alt="WhatsApp QR Code">\n <div id="qrStatus"></div>\n <button class="btn btn-ghost" onclick="closeQrModal()" style="margin-top:8px">Close</button>\n </div>\n</div>\n<div class="modal-overlay" id="internalToolsModal">\n <div class="modal" style="max-width:780px;text-align:left;max-height:85vh;display:flex;flex-direction:column">\n <button class="close-btn" onclick="document.getElementById(\'internalToolsModal\').classList.remove(\'open\')">&times;</button>\n <h3 style="margin-bottom:6px">Internal MCP Tools</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Built-in MCP tool servers registered for the agent.</p>\n <div style="overflow-y:auto;flex:1;min-height:0">\n <div id="internalToolsBody"><span class="spinner"></span> Loading...</div>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="cronMsgModal">\n <div class="modal" style="max-width:680px;text-align:left;max-height:80vh;display:flex;flex-direction:column">\n <button class="close-btn" onclick="document.getElementById(\'cronMsgModal\').classList.remove(\'open\')">&times;</button>\n <h3 id="cronMsgTitle" style="margin-bottom:10px">Job Message</h3>\n <textarea id="cronMsgBody" readonly style="flex:1;min-height:200px;width:100%;resize:none;font-family:monospace;font-size:13px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:12px"></textarea>\n </div>\n</div>\n\x3c!-- Session expired modal --\x3e\n<div class="modal-overlay" id="sessionExpiredModal">\n <div class="modal" style="text-align:center">\n <h3>Session Expired</h3>\n <p style="margin:16px 0">The server was restarted or your session is no longer valid. Please re-authenticate.</p>\n <button class="btn" onclick="location.reload()">OK</button>\n </div>\n</div>\n'}
1
+ export function renderModals(){return'\n\x3c!-- Restart confirmation modal --\x3e\n<div class="modal-overlay" id="restartModal">\n <div class="modal">\n <button class="close-btn" onclick="closeRestartModal()">&times;</button>\n <h3>Restart Server</h3>\n <p>This will restart all channels, agents, and services. Active conversations will be interrupted.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn" onclick="doRestart()">Restart</button>\n <button class="btn-ghost" onclick="closeRestartModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="placeholderRefModal">\n <div class="modal" style="max-width:620px;text-align:left;max-height:85vh;overflow-y:auto">\n <button class="close-btn" onclick="document.getElementById(\'placeholderRefModal\').classList.remove(\'open\')">&times;</button>\n <h3 style="margin-bottom:14px">Placeholder Reference</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">These placeholders are resolved at runtime when building the system prompt from templates.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:40%">Placeholder</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><code>{{ATTACHMENTS_DIR}}</code></td><td>Path to the session attachments folder, or <code>(memory disabled)</code></td></tr>\n <tr><td><code>{{AVAILABLE_TOOLS}}</code></td><td>Auto-discovered list of all registered MCP tools with name, description, and parameters</td></tr>\n <tr><td><code>{{CHANNEL}}</code></td><td>Channel name, e.g. <code>telegram</code>, <code>responses</code></td></tr>\n <tr><td><code>{{CHAT_ID}}</code></td><td>Chat / conversation ID within the channel</td></tr>\n <tr><td><code>{{DATA_DIR}}</code></td><td>Absolute path to the data directory (workspace files, templates)</td></tr>\n <tr><td><code>{{HEARTBEAT_INSTRUCTIONS}}</code></td><td>Heartbeat/cron behavioral instructions, or empty if cron is disabled</td></tr>\n <tr><td><code>{{HEARTBEAT_PROMPT}}</code></td><td>Raw heartbeat prompt message from config</td></tr>\n <tr><td><code>{{HOSTNAME}}</code></td><td>Server hostname</td></tr>\n <tr><td><code>{{MEMORY_FILE}}</code></td><td>Path to the current conversation memory file, or <code>(memory disabled)</code></td></tr>\n <tr><td><code>{{MESSAGE_TOOLS_INSTRUCTIONS}}</code></td><td>Instructions for cross-channel messaging tools, or empty if not available</td></tr>\n <tr><td><code>{{MODEL}}</code></td><td>Active model ID, e.g. <code>claude-opus-4-6</code></td></tr>\n <tr><td><code>{{NODE_TOOLS_INSTRUCTIONS}}</code></td><td>Instructions for node remote-execution tools, or empty if no nodes connected</td></tr>\n <tr><td><code>{{OS}}</code></td><td>Operating system info (type, release, arch)</td></tr>\n <tr><td><code>{{RUNTIME_LINE}}</code></td><td>Composed runtime info line (host, OS, model, channel, session)</td></tr>\n <tr><td><code>{{SEARCH_IN_MEMORIES}}</code></td><td>Memory search tools instructions (<code>memory_search</code>, <code>memory_get</code>). Populated when Recall Strategy is not <code>builtin-only</code>, empty otherwise.</td></tr>\n <tr><td><code>{{SESSION_ID}}</code></td><td>SDK session ID for resumption, or <code>(new session)</code></td></tr>\n <tr><td><code>{{SESSION_KEY}}</code></td><td>Current session identifier, e.g. <code>telegram:12345</code></td></tr>\n <tr><td><code>{{SUBAGENT_TASK}}</code></td><td>Task description for subagent mode (empty for main agent)</td></tr>\n <tr><td><code>{{TIMEZONE}}</code></td><td>Server timezone, e.g. <code>Europe/Rome</code></td></tr>\n <tr><td><code>{{WORKSPACE_DIR}}</code></td><td>Absolute path to the agent workspace directory</td></tr>\n <tr><td><code>{{WORKSPACE_FILES}}</code></td><td>All workspace .md files inlined as <code>## FileName.md</code> sections</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:18px 0 8px;font-size:14px">File Includes</h4>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:10px">Workspace files can include other <code>.md</code> files from the data directory using inline placeholders. Includes are resolved once (no recursion).</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:40%">Syntax</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><code>{{FILE:name.md}}</code></td><td>Replaced with the full contents of <code>name.md</code> from the data directory. If the file is not found, a comment <code>&lt;!-- FILE:name.md not found --&gt;</code> is inserted instead. Only <code>[A-Za-z0-9_-]+.md</code> filenames are matched.</td></tr>\n </tbody>\n </table>\n <p style="font-size:12px;color:var(--text-muted);margin-top:6px">Example: place <code>{{FILE:BEHAVIOUR.md}}</code> inside <code>AGENTS.md</code> to inline BEHAVIOUR.md at that position.</p>\n\n <h4 style="margin:18px 0 8px;font-size:14px">Memory Budget (Hot/Cold)</h4>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:10px">When <code>MEMORY.md</code> exceeds 8000 chars, only sections wrapped in hot markers are included in the prompt. Cold content is omitted but remains searchable via <code>memory_search</code>.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:40%">Marker</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><code>&lt;!-- hot --&gt;</code></td><td>Start of a hot section (included in prompt)</td></tr>\n <tr><td><code>&lt;!-- /hot --&gt;</code></td><td>End of a hot section</td></tr>\n </tbody>\n </table>\n <p style="font-size:12px;color:var(--text-muted);margin-top:6px">If no hot markers are found, the entire file is loaded (backward compatible). Multiple hot sections are supported and concatenated.</p>\n </div>\n</div>\n<div class="modal-overlay" id="agentHelpModal">\n <div class="modal" style="max-width:680px;text-align:left;max-height:85vh;overflow-y:auto">\n <button class="close-btn" onclick="document.getElementById(\'agentHelpModal\').classList.remove(\'open\')">&times;</button>\n <h3 style="margin-bottom:14px">Agent Configuration Reference</h3>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Main</h4>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Default Model</strong></td><td>The model used for all conversations. Must be one of the models defined in the Models section.</td></tr>\n <tr><td><strong>Fallback Model</strong></td><td>If the default model fails (API error, overloaded, etc.), the agent automatically retries with this model. Leave empty to disable fallback.</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Configuration</h4>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Max Turns</strong></td><td>Maximum number of agentic turns (tool use round-trips) per single invocation. Prevents runaway loops. A typical value is 5&ndash;15.</td></tr>\n <tr><td><strong>Permission Mode</strong></td><td>Controls how the SDK handles tool permissions.<br><code>default</code> &mdash; ask the user before running tools.<br><code>bypassPermissions</code> &mdash; run all allowed tools without asking (recommended for automated agents).<br><code>plan</code> &mdash; the agent proposes a plan and waits for approval before executing.</td></tr>\n <tr><td><strong>Session TTL</strong></td><td>Time in seconds before an idle session expires. When a session expires, the next message starts a new conversation (no history carried over). Default: 3600 (1 hour).</td></tr>\n <tr><td><strong>Setting Sources</strong></td><td>Which SDK settings files to load.<br><code>nothing</code> &mdash; ignore all settings files.<br><code>user</code> &mdash; load user-level settings (~/.claude).<br><code>project</code> &mdash; load project-level settings from workspace.<br><code>both</code> &mdash; load both user and project settings.</td></tr>\n <tr><td><strong>Coder Skill</strong></td><td>When enabled, the builtin coding prompt is always prepended to the system prompt, adding coding-oriented instructions. When disabled, the system prompt is built entirely from your templates (SYSTEM_PROMPT.md).</td></tr>\n <tr><td><strong>Allowed Tools</strong></td><td>Which SDK tools the agent can use. Unchecked tools are not available to the agent. Common tools: <code>Read</code>, <code>Write</code>, <code>Edit</code> for file operations; <code>Bash</code> for shell commands; <code>Glob</code>/<code>Grep</code> for search; <code>WebSearch</code>/<code>WebFetch</code> for web access; <code>Task</code> for sub-agents; <code>Skill</code> for slash commands.</td></tr>\n <tr><td><strong>Auto Approve Tools</strong></td><td>Controls how tool permission requests from the SDK are handled.<br><strong>On (default)</strong> &mdash; all tool invocations are automatically approved. This prevents the SDK from blocking when <code>permissionMode</code> is set to <code>default</code>.<br><strong>Off</strong> &mdash; tool permission requests are forwarded to the user&rsquo;s channel as interactive messages with Approve/Deny buttons. The SDK waits until the user responds before proceeding.</td></tr>\n </tbody>\n </table>\n\n <h4 style="margin:16px 0 8px;font-size:14px;color:var(--accent)">Message Queue</h4>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:10px">Controls how incoming messages are handled while the agent is already processing a response.</p>\n <table class="tbl" style="font-size:13px">\n <thead><tr><th style="width:30%">Field</th><th>Description</th></tr></thead>\n <tbody>\n <tr><td><strong>Queue Mode</strong></td><td>\n <code>queue</code> &mdash; Simple FIFO. Each message is processed one at a time in order. The user must wait for each response before the next message is handled.<br>\n <code>collect</code> &mdash; Messages sent while the agent is busy are buffered. After a debounce period, all buffered messages are merged into a single prompt and processed together.<br>\n <code>steer</code> &mdash; A new message interrupts the current processing. The agent stops its current response and immediately starts handling the new message, combining context from both.\n </td></tr>\n <tr><td><strong>Debounce (ms)</strong></td><td>Only applies in <code>collect</code> mode. After the last buffered message arrives, the system waits this many milliseconds before flushing the batch. This allows grouping rapid-fire messages into a single prompt. Set to 0 for immediate flush.</td></tr>\n <tr><td><strong>Queue Cap</strong></td><td>Maximum number of messages that can accumulate in the buffer. When this limit is reached, the Drop Policy kicks in. Set to 0 for unlimited.</td></tr>\n <tr><td><strong>Drop Policy</strong></td><td>What happens when Queue Cap is exceeded:<br>\n <code>new</code> &mdash; The incoming message is rejected; the user receives an error asking to wait.<br>\n <code>old</code> &mdash; The oldest buffered message is silently discarded to make room.<br>\n <code>summarize</code> &mdash; The oldest message is discarded but a truncated preview (first 140 chars) is preserved. When the batch is flushed, these previews are prepended so the agent knows messages were dropped and can see a summary of their content.\n </td></tr>\n <tr><td><strong>Inflight Typing</strong></td><td>When enabled, the typing indicator stays active as long as at least one message is being processed. Without this, a fast command (e.g. <code>/status</code>) finishing while the agent is still working would clear the typing indicator prematurely.</td></tr>\n </tbody>\n </table>\n </div>\n</div>\n<div class="modal-overlay" id="memViewModal">\n <div class="modal sim-modal">\n <button class="close-btn" onclick="closeMemViewModal()">&times;</button>\n <h3 id="memViewTitle">Memory Log</h3>\n <div id="memEditorWrap" class="monaco-wrap readonly" style="height:450px"></div>\n <div class="sim-info">\n <span id="memViewInfo"></span>\n <button class="btn btn-ghost btn-sm" onclick="copyMemView()">Copy</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="simModal">\n <div class="modal sim-modal">\n <button class="close-btn" onclick="closeSimModal()">&times;</button>\n <h3>Simulate Building</h3>\n <div class="sim-tabs">\n <button class="sim-tab active" onclick="switchSimTab(\'main\',this)">Main Agent</button>\n <button class="sim-tab" onclick="switchSimTab(\'subagent\',this)">Subagent</button>\n <button class="sim-tab" onclick="switchSimTab(\'buildinfo\',this)">Build Info</button>\n </div>\n <div id="simEditorWrap" class="monaco-wrap readonly" style="height:450px"></div>\n <div id="simBuildInfo" style="display:none;height:450px;overflow-y:auto;padding:16px;font-size:13px;line-height:1.6"></div>\n <div class="sim-info">\n <span id="simCharCount"></span>\n <button class="btn btn-ghost btn-sm" onclick="copySimPrompt()">Copy</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="memDisableModal">\n <div class="modal">\n <button class="close-btn" onclick="closeMemDisableModal(false)">&times;</button>\n <h3>Disable Memories</h3>\n <p>Disabling memories means the agent will no longer keep track of past conversations. Conversation logs will not be saved and the agent will have no continuity between sessions.</p>\n <p style="font-size:13px;color:var(--text-muted)">Existing memory files will not be deleted.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn btn-danger" onclick="closeMemDisableModal(true)">Disable</button>\n <button class="btn-ghost" onclick="closeMemDisableModal(false)">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSkillDeleteModal()">&times;</button>\n <h3>Delete Skill</h3>\n <p>Are you sure you want to delete <strong id="skillDeleteName"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteSkill()">Delete</button>\n <button class="btn-ghost" onclick="closeSkillDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeCommandDeleteModal()">&times;</button>\n <h3>Delete Command</h3>\n <p>Are you sure you want to delete <strong id="commandDeleteName"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteCommand()">Delete</button>\n <button class="btn-ghost" onclick="closeCommandDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="pluginDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closePluginDeleteModal()">&times;</button>\n <h3>Delete Plugin</h3>\n <p>Are you sure you want to delete <strong id="pluginDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeletePlugin()">Delete</button>\n <button class="btn-ghost" onclick="closePluginDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="pluginUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closePluginUploadedModal()">&times;</button>\n <h3>Plugin Added</h3>\n <p><strong id="pluginUploadedName"></strong> has been uploaded and saved to the configuration.</p>\n <p style="font-size:13px;color:var(--text-muted)">The plugin will appear as <em>invalid</em> until the server is restarted. Restart the server from the Dashboard to activate it.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closePluginUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillEditModal">\n <div class="modal" style="max-width:800px;width:90vw;text-align:left">\n <button class="close-btn" onclick="closeSkillEditModal()">&times;</button>\n <h3 style="margin-bottom:4px">Edit Skill</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px"><code id="skillEditPath"></code></p>\n <div id="skillEditorWrap" class="monaco-wrap" style="height:400px"></div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost" onclick="closeSkillEditModal()">Close</button>\n <button class="btn" onclick="saveSkillEdit()">Save</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandEditModal">\n <div class="modal" style="max-width:800px;width:90vw;text-align:left">\n <button class="close-btn" onclick="closeCommandEditModal()">&times;</button>\n <h3 style="margin-bottom:4px">Edit Command</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px"><code id="commandEditPath"></code></p>\n <div id="commandEditorWrap" class="monaco-wrap" style="height:400px"></div>\n <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">\n <button class="btn-ghost" onclick="closeCommandEditModal()">Close</button>\n <button class="btn" onclick="saveCommandEdit()">Save</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="commandUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closeCommandUploadedModal()">&times;</button>\n <h3>Command Added</h3>\n <p><strong id="commandUploadedName"></strong> has been uploaded.</p>\n <p style="font-size:13px;color:var(--text-muted)">The command is ready to use immediately.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeCommandUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="skillUploadedModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSkillUploadedModal()">&times;</button>\n <h3>Skill Added</h3>\n <p><strong id="skillUploadedName"></strong> has been uploaded.</p>\n <p style="font-size:13px;color:var(--text-muted)">The skill is ready to use immediately.</p>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeSkillUploadedModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="varDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeVarDeleteModal()">&times;</button>\n <h3>Delete Var</h3>\n <p>Are you sure you want to delete <strong id="varDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteVar()">Delete</button>\n <button class="btn-ghost" onclick="closeVarDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="modelDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeModelDeleteModal()">&times;</button>\n <h3>Delete Model</h3>\n <p>Are you sure you want to delete <strong id="modelDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteModel()">Delete</button>\n <button class="btn-ghost" onclick="closeModelDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="saDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeSaDeleteModal()">&times;</button>\n <h3>Delete Sub Agent</h3>\n <p>Are you sure you want to delete <strong id="saDeleteName"></strong>?</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteSubAgent()">Delete</button>\n <button class="btn-ghost" onclick="closeSaDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="tokenDeleteModal">\n <div class="modal">\n <button class="close-btn" onclick="closeTokenDeleteModal()">&times;</button>\n <h3>Delete Token</h3>\n <p>Are you sure you want to delete token <strong id="tokenDeleteId"></strong>? This cannot be undone.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="doDeleteToken()">Delete</button>\n <button class="btn-ghost" onclick="closeTokenDeleteModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="regenKeyModal">\n <div class="modal">\n <button class="close-btn" onclick="closeRegenKeyModal()">&times;</button>\n <h3>Regenerate Access Key</h3>\n <p>This will invalidate the current access key. Any saved links or bookmarks using the old key will stop working.</p>\n <div style="display:flex;gap:8px;justify-content:center">\n <button class="btn btn-danger" onclick="closeRegenKeyModal();regenKey()">Regenerate</button>\n <button class="btn-ghost" onclick="closeRegenKeyModal()">Cancel</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="heartbeatSimModal">\n <div class="modal" style="max-width:600px;text-align:left">\n <button class="close-btn" onclick="closeHeartbeatSimModal()">&times;</button>\n <h3 id="heartbeatSimTitle">Heartbeat Simulation</h3>\n <p id="heartbeatSimResult" style="margin:12px 0"></p>\n <label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px">HEARTBEAT.md</label>\n <textarea id="heartbeatSimContent" readonly style="width:100%;height:200px;font-family:var(--mono);font-size:13px;resize:vertical;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px;color:var(--text)"></textarea>\n <div style="display:flex;gap:8px;justify-content:center;margin-top:16px">\n <button class="btn" onclick="closeHeartbeatSimModal()">OK</button>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="qrModal">\n <div class="modal">\n <button class="close-btn" onclick="closeQrModal()">&times;</button>\n <h3>Connect WhatsApp</h3>\n <p id="qrMsg">Waiting for QR code...</p>\n <div id="qrSpinner"><span class="spinner"></span> Connecting...</div>\n <img id="qrImg" style="display:none" alt="WhatsApp QR Code">\n <div id="qrStatus"></div>\n <button class="btn btn-ghost" onclick="closeQrModal()" style="margin-top:8px">Close</button>\n </div>\n</div>\n<div class="modal-overlay" id="internalToolsModal">\n <div class="modal" style="max-width:780px;text-align:left;max-height:85vh;display:flex;flex-direction:column">\n <button class="close-btn" onclick="document.getElementById(\'internalToolsModal\').classList.remove(\'open\')">&times;</button>\n <h3 style="margin-bottom:6px">Internal MCP Tools</h3>\n <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Built-in MCP tool servers registered for the agent.</p>\n <div style="overflow-y:auto;flex:1;min-height:0">\n <div id="internalToolsBody"><span class="spinner"></span> Loading...</div>\n </div>\n </div>\n</div>\n<div class="modal-overlay" id="cronMsgModal">\n <div class="modal" style="max-width:680px;text-align:left;max-height:80vh;display:flex;flex-direction:column">\n <button class="close-btn" onclick="document.getElementById(\'cronMsgModal\').classList.remove(\'open\')">&times;</button>\n <h3 id="cronMsgTitle" style="margin-bottom:10px">Job Message</h3>\n <textarea id="cronMsgBody" readonly style="flex:1;min-height:200px;width:100%;resize:none;font-family:monospace;font-size:13px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:12px"></textarea>\n </div>\n</div>\n\x3c!-- Session expired modal --\x3e\n<div class="modal-overlay" id="sessionExpiredModal">\n <div class="modal" style="text-align:center">\n <h3>Session Expired</h3>\n <p style="margin:16px 0">The server was restarted or your session is no longer valid. Please re-authenticate.</p>\n <button class="btn" onclick="location.reload()">OK</button>\n </div>\n</div>\n'}
@@ -1 +1 @@
1
- export function promptsJS(){return"\n/* ---- Monaco Editor ---- */\nvar _monacoPromise = null;\nvar _promptEditor = null; // main file editor\nvar _simEditor = null; // simulate modal editor\nvar _memEditor = null; // memory viewer modal editor\nvar _promptFiles = []; // loaded workspace/template file list\nvar _currentFile = null; // currently selected file object\nvar _simData = null;\nvar _memorySessions = [];\n\nfunction monacoTheme(){\n return document.documentElement.getAttribute('data-theme')==='dark' ? 'vs-dark' : 'vs';\n}\n\nfunction ensureMonaco(){\n if(_monacoPromise) return _monacoPromise;\n _monacoPromise = new Promise(function(resolve, reject){\n if(typeof monaco !== 'undefined'){ resolve(); return; }\n require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs' }});\n require(['vs/editor/editor.main'], function(){ resolve(); }, function(err){ reject(err); });\n });\n return _monacoPromise;\n}\n\nfunction createEditor(container, opts){\n var defaults = {\n language: 'markdown',\n theme: monacoTheme(),\n minimap: { enabled: false },\n lineNumbers: 'on',\n wordWrap: 'on',\n scrollBeyondLastLine: false,\n fontSize: 13,\n tabSize: 2,\n automaticLayout: true,\n padding: { top: 8 }\n };\n for(var k in opts){ defaults[k] = opts[k]; }\n return monaco.editor.create(container, defaults);\n}\n\nfunction updateAllMonacoThemes(){\n var th = monacoTheme();\n monaco.editor.setTheme(th);\n}\n\n/* ---- Prompts ---- */\nasync function loadPrompts(){\n try{\n await ensureMonaco();\n _promptFiles = await fetchAPI('/workspace-files');\n _promptFiles.sort(function(a,b){ return a.name.localeCompare(b.name); });\n var sel = document.getElementById('promptFileSelect');\n sel.innerHTML = '<option value=\"\">Select a file...</option>';\n var tplGroup = document.createElement('optgroup');\n tplGroup.label = 'System Prompt Templates';\n var wsGroup = document.createElement('optgroup');\n wsGroup.label = 'Workspace Files';\n for(var i=0;i<_promptFiles.length;i++){\n var f = _promptFiles[i];\n var opt = document.createElement('option');\n opt.value = f.name + (f.isTemplate ? '|tpl' : '|ws');\n var label = f.name;\n if(!f.exists && !f.isTemplate) label += ' (not found)';\n opt.textContent = label;\n if(f.isTemplate) tplGroup.appendChild(opt);\n else wsGroup.appendChild(opt);\n }\n sel.appendChild(tplGroup);\n sel.appendChild(wsGroup);\n // Reset editor\n document.getElementById('promptEditorWrap').style.display = 'none';\n document.getElementById('promptSaveBtn').style.display = 'none';\n document.getElementById('promptFileBadge').style.display = 'none';\n _currentFile = null;\n if(_promptEditor){ _promptEditor.setValue(''); }\n }catch(e){ toast('Failed to load workspace files','err'); }\n}\n\nasync function selectPromptFile(){\n var sel = document.getElementById('promptFileSelect');\n var val = sel.value;\n if(!val){\n document.getElementById('promptEditorWrap').style.display = 'none';\n document.getElementById('promptSaveBtn').style.display = 'none';\n document.getElementById('promptFileBadge').style.display = 'none';\n _currentFile = null;\n return;\n }\n var parts = val.split('|');\n var name = parts[0];\n var isTpl = parts[1] === 'tpl';\n var file = _promptFiles.find(function(f){ return f.name === name && f.isTemplate === isTpl; });\n if(!file) return;\n _currentFile = file;\n\n // Update badge\n var badge = document.getElementById('promptFileBadge');\n badge.style.display = '';\n if(file.isTemplate){ badge.className = 'file-badge template'; badge.textContent = 'Template'; }\n else if(file.exists){ badge.className = 'file-badge workspace'; badge.textContent = 'Loaded'; }\n else { badge.className = 'file-badge missing'; badge.textContent = 'Not found'; }\n\n // Show save button\n document.getElementById('promptSaveBtn').style.display = '';\n\n // Show editor\n var wrap = document.getElementById('promptEditorWrap');\n wrap.style.display = '';\n await ensureMonaco();\n if(!_promptEditor){\n _promptEditor = createEditor(wrap, { readOnly: false });\n }\n _promptEditor.setValue(file.content || '');\n var lang = file.name.endsWith('.json') ? 'json' : 'markdown';\n monaco.editor.setModelLanguage(_promptEditor.getModel(), lang);\n _promptEditor.revealLine(1);\n}\n\nasync function saveCurrentFile(){\n if(!_currentFile || !_promptEditor) return;\n var content = _promptEditor.getValue();\n var name = _currentFile.name;\n var isTemplate = _currentFile.isTemplate;\n try{\n var res = await fetch(API+'/workspace-files/'+encodeURIComponent(name),{\n method:'PUT',\n headers:{'Content-Type':'application/json'},\n body:JSON.stringify({content:content, isTemplate:isTemplate})\n });\n if(!res.ok){ var d=await res.json(); toast(d.error||'Save failed','err'); return; }\n // Update cached content\n _currentFile.content = content;\n _currentFile.exists = true;\n // Update badge\n var badge = document.getElementById('promptFileBadge');\n if(isTemplate){ badge.className = 'file-badge template'; badge.textContent = 'Template'; }\n else { badge.className = 'file-badge workspace'; badge.textContent = 'Loaded'; }\n toast(name+' saved','ok');\n }catch(e){ toast('Save failed','err'); }\n}\n\nfunction formatCharTokenCount(len){\n var tokens = Math.ceil(len / 4);\n return len.toLocaleString()+' chars (~'+tokens.toLocaleString()+' tokens)';\n}\n\n/* ---- Simulate Prompt ---- */\nasync function simulatePrompt(){\n var modal = document.getElementById('simModal');\n var charCount = document.getElementById('simCharCount');\n charCount.textContent = '';\n modal.classList.add('open');\n // Reset tabs\n document.querySelectorAll('.sim-tab').forEach(function(t){t.classList.remove('active')});\n document.querySelector('.sim-tab').classList.add('active');\n await ensureMonaco();\n var wrap = document.getElementById('simEditorWrap');\n if(!_simEditor){\n _simEditor = createEditor(wrap, { readOnly: true });\n }\n _simEditor.setValue('Loading...');\n try{\n _simData = await fetchAPI('/prompt-simulate');\n _simEditor.setValue(_simData.main);\n _simEditor.revealLine(1);\n charCount.textContent = formatCharTokenCount(_simData.main.length);\n }catch(e){\n _simEditor.setValue('Error: '+e);\n _simData = null;\n }\n}\n\nfunction switchSimTab(tab, btn){\n document.querySelectorAll('.sim-tab').forEach(function(t){t.classList.remove('active')});\n btn.classList.add('active');\n if(!_simData || !_simEditor) return;\n var text = tab==='main' ? _simData.main : _simData.subagent;\n _simEditor.setValue(text);\n _simEditor.revealLine(1);\n document.getElementById('simCharCount').textContent = formatCharTokenCount(text.length);\n}\n\nfunction closeSimModal(){\n document.getElementById('simModal').classList.remove('open');\n}\n\nasync function copySimPrompt(){\n if(!_simEditor) return;\n var text = _simEditor.getValue();\n try{\n await navigator.clipboard.writeText(text);\n toast('Copied to clipboard','ok');\n }catch(e){ toast('Copy failed','err'); }\n}\n\n/* ---- Memory Logs ---- */\nasync function loadMemorySessions(){\n try{\n _memorySessions = await fetchAPI('/memory-files');\n var sel = document.getElementById('memorySessionSelect');\n sel.innerHTML = '<option value=\"\">Select a session...</option>';\n for(var i=0;i<_memorySessions.length;i++){\n var s = _memorySessions[i];\n var opt = document.createElement('option');\n opt.value = s.sessionKey;\n opt.textContent = s.sessionKey + ' (' + s.files.length + ' file' + (s.files.length!==1?'s':'') + ')';\n sel.appendChild(opt);\n }\n document.getElementById('memoryFileList').innerHTML = '';\n }catch(e){ toast('Failed to load memory sessions','err'); }\n}\n\nfunction loadMemoryFiles(){\n var sel = document.getElementById('memorySessionSelect');\n var sessionKey = sel.value;\n var container = document.getElementById('memoryFileList');\n container.innerHTML = '';\n if(!sessionKey) return;\n var session = _memorySessions.find(function(s){ return s.sessionKey === sessionKey; });\n if(!session || !session.files.length){\n container.innerHTML = '<p style=\"font-size:13px;color:var(--text-muted)\">No files found.</p>';\n return;\n }\n var html = '<table class=\"tbl\"><thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead><tbody>';\n for(var i=0;i<session.files.length;i++){\n var f = session.files[i];\n var sizeKb = (f.size / 1024).toFixed(1);\n var mod = new Date(f.modified).toLocaleString();\n html += '<tr><td style=\"font-family:monospace;font-size:13px\">'+esc(f.name)+'</td><td style=\"font-size:13px\">'+sizeKb+' KB</td><td style=\"font-size:13px\">'+esc(mod)+'</td><td><button class=\"btn btn-sm btn-ghost\" data-mem-session=\"'+esc(sessionKey)+'\" data-mem-file=\"'+esc(f.name)+'\" onclick=\"viewMemoryFile(this.dataset.memSession,this.dataset.memFile)\">View</button></td></tr>';\n }\n html += '</tbody></table>';\n container.innerHTML = html;\n}\n\nasync function viewMemoryFile(sessionKey, fileName){\n var modal = document.getElementById('memViewModal');\n var title = document.getElementById('memViewTitle');\n var info = document.getElementById('memViewInfo');\n title.textContent = sessionKey + ' / ' + fileName;\n info.textContent = '';\n modal.classList.add('open');\n await ensureMonaco();\n var wrap = document.getElementById('memEditorWrap');\n if(!_memEditor){\n _memEditor = createEditor(wrap, { readOnly: true });\n }\n _memEditor.setValue('Loading...');\n try{\n var data = await fetchAPI('/memory-files/'+encodeURIComponent(sessionKey)+'/'+encodeURIComponent(fileName));\n _memEditor.setValue(data.content);\n _memEditor.revealLine(1);\n info.textContent = data.content.length.toLocaleString() + ' chars';\n }catch(e){\n _memEditor.setValue('Error loading file: '+e);\n }\n}\n\nfunction showPlaceholderRef(){\n document.getElementById('placeholderRefModal').classList.add('open');\n}\n\nfunction closeMemViewModal(){\n document.getElementById('memViewModal').classList.remove('open');\n}\n\nasync function copyMemView(){\n if(!_memEditor) return;\n var text = _memEditor.getValue();\n try{\n await navigator.clipboard.writeText(text);\n toast('Copied to clipboard','ok');\n }catch(e){ toast('Copy failed','err'); }\n}\n"}
1
+ export function promptsJS(){return"\n/* ---- Monaco Editor ---- */\nvar _monacoPromise = null;\nvar _promptEditor = null; // main file editor\nvar _simEditor = null; // simulate modal editor\nvar _memEditor = null; // memory viewer modal editor\nvar _promptFiles = []; // loaded workspace/template file list\nvar _currentFile = null; // currently selected file object\nvar _simData = null;\nvar _memorySessions = [];\n\nfunction monacoTheme(){\n return document.documentElement.getAttribute('data-theme')==='dark' ? 'vs-dark' : 'vs';\n}\n\nfunction ensureMonaco(){\n if(_monacoPromise) return _monacoPromise;\n _monacoPromise = new Promise(function(resolve, reject){\n if(typeof monaco !== 'undefined'){ resolve(); return; }\n require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs' }});\n require(['vs/editor/editor.main'], function(){ resolve(); }, function(err){ reject(err); });\n });\n return _monacoPromise;\n}\n\nfunction createEditor(container, opts){\n var defaults = {\n language: 'markdown',\n theme: monacoTheme(),\n minimap: { enabled: false },\n lineNumbers: 'on',\n wordWrap: 'on',\n scrollBeyondLastLine: false,\n fontSize: 13,\n tabSize: 2,\n automaticLayout: true,\n padding: { top: 8 }\n };\n for(var k in opts){ defaults[k] = opts[k]; }\n return monaco.editor.create(container, defaults);\n}\n\nfunction updateAllMonacoThemes(){\n var th = monacoTheme();\n monaco.editor.setTheme(th);\n}\n\n/* ---- Prompts ---- */\nasync function loadPrompts(){\n try{\n await ensureMonaco();\n _promptFiles = await fetchAPI('/workspace-files');\n _promptFiles.sort(function(a,b){ return a.name.localeCompare(b.name); });\n var sel = document.getElementById('promptFileSelect');\n sel.innerHTML = '<option value=\"\">Select a file...</option>';\n var tplGroup = document.createElement('optgroup');\n tplGroup.label = 'System Prompt Templates';\n var wsGroup = document.createElement('optgroup');\n wsGroup.label = 'Workspace Files';\n var incGroup = document.createElement('optgroup');\n incGroup.label = 'Included Files (via {{FILE:}})';\n var hasIncludes = false;\n for(var i=0;i<_promptFiles.length;i++){\n var f = _promptFiles[i];\n var opt = document.createElement('option');\n opt.value = f.name + (f.isTemplate ? '|tpl' : f.isInclude ? '|inc' : '|ws');\n var label = f.name;\n if(!f.exists && !f.isTemplate) label += ' (not found)';\n if(f.isInclude && f.includedBy) label += ' ← ' + f.includedBy;\n opt.textContent = label;\n if(f.isTemplate) tplGroup.appendChild(opt);\n else if(f.isInclude){ incGroup.appendChild(opt); hasIncludes = true; }\n else wsGroup.appendChild(opt);\n }\n sel.appendChild(tplGroup);\n sel.appendChild(wsGroup);\n if(hasIncludes) sel.appendChild(incGroup);\n // Reset editor\n document.getElementById('promptEditorWrap').style.display = 'none';\n document.getElementById('promptSaveBtn').style.display = 'none';\n document.getElementById('promptFileBadge').style.display = 'none';\n _currentFile = null;\n if(_promptEditor){ _promptEditor.setValue(''); }\n }catch(e){ toast('Failed to load workspace files','err'); }\n}\n\nasync function selectPromptFile(){\n var sel = document.getElementById('promptFileSelect');\n var val = sel.value;\n if(!val){\n document.getElementById('promptEditorWrap').style.display = 'none';\n document.getElementById('promptSaveBtn').style.display = 'none';\n document.getElementById('promptFileBadge').style.display = 'none';\n _currentFile = null;\n return;\n }\n var parts = val.split('|');\n var name = parts[0];\n var kind = parts[1]; // 'tpl', 'ws', or 'inc'\n var isTpl = kind === 'tpl';\n var isInc = kind === 'inc';\n var file = _promptFiles.find(function(f){ return f.name === name && f.isTemplate === isTpl && (!!f.isInclude) === isInc; });\n if(!file) return;\n _currentFile = file;\n\n // Update badge\n var badge = document.getElementById('promptFileBadge');\n badge.style.display = '';\n if(file.isTemplate){ badge.className = 'file-badge template'; badge.textContent = 'Template'; }\n else if(file.isInclude && file.exists){ badge.className = 'file-badge workspace'; badge.textContent = 'Included by ' + (file.includedBy || '?'); }\n else if(file.exists){ badge.className = 'file-badge workspace'; badge.textContent = 'Loaded'; }\n else { badge.className = 'file-badge missing'; badge.textContent = 'Not found'; }\n\n // Show save button\n document.getElementById('promptSaveBtn').style.display = '';\n\n // Show editor\n var wrap = document.getElementById('promptEditorWrap');\n wrap.style.display = '';\n await ensureMonaco();\n if(!_promptEditor){\n _promptEditor = createEditor(wrap, { readOnly: false });\n }\n _promptEditor.setValue(file.content || '');\n var lang = file.name.endsWith('.json') ? 'json' : 'markdown';\n monaco.editor.setModelLanguage(_promptEditor.getModel(), lang);\n _promptEditor.revealLine(1);\n}\n\nasync function saveCurrentFile(){\n if(!_currentFile || !_promptEditor) return;\n var content = _promptEditor.getValue();\n var name = _currentFile.name;\n var isTemplate = _currentFile.isTemplate;\n try{\n var res = await fetch(API+'/workspace-files/'+encodeURIComponent(name),{\n method:'PUT',\n headers:{'Content-Type':'application/json'},\n body:JSON.stringify({content:content, isTemplate:isTemplate})\n });\n if(!res.ok){ var d=await res.json(); toast(d.error||'Save failed','err'); return; }\n // Update cached content\n _currentFile.content = content;\n _currentFile.exists = true;\n // Update badge\n var badge = document.getElementById('promptFileBadge');\n if(isTemplate){ badge.className = 'file-badge template'; badge.textContent = 'Template'; }\n else { badge.className = 'file-badge workspace'; badge.textContent = 'Loaded'; }\n toast(name+' saved','ok');\n }catch(e){ toast('Save failed','err'); }\n}\n\nfunction formatCharTokenCount(len){\n var tokens = Math.ceil(len / 4);\n return len.toLocaleString()+' chars (~'+tokens.toLocaleString()+' tokens)';\n}\n\n/* ---- Simulate Prompt ---- */\nasync function simulatePrompt(){\n var modal = document.getElementById('simModal');\n var charCount = document.getElementById('simCharCount');\n charCount.textContent = '';\n modal.classList.add('open');\n // Reset tabs\n document.querySelectorAll('.sim-tab').forEach(function(t){t.classList.remove('active')});\n document.querySelector('.sim-tab').classList.add('active');\n await ensureMonaco();\n var wrap = document.getElementById('simEditorWrap');\n if(!_simEditor){\n _simEditor = createEditor(wrap, { readOnly: true });\n }\n _simEditor.setValue('Loading...');\n try{\n _simData = await fetchAPI('/prompt-simulate');\n _simEditor.setValue(_simData.main);\n _simEditor.revealLine(1);\n charCount.textContent = formatCharTokenCount(_simData.main.length);\n }catch(e){\n _simEditor.setValue('Error: '+e);\n _simData = null;\n }\n}\n\nfunction switchSimTab(tab, btn){\n document.querySelectorAll('.sim-tab').forEach(function(t){t.classList.remove('active')});\n btn.classList.add('active');\n if(!_simData) return;\n\n var editorWrap = document.getElementById('simEditorWrap');\n var buildInfo = document.getElementById('simBuildInfo');\n var charCount = document.getElementById('simCharCount');\n\n if(tab === 'buildinfo'){\n // Show build info panel, hide editor\n editorWrap.style.display = 'none';\n buildInfo.style.display = '';\n charCount.textContent = '';\n // Lazy-load meta from separate endpoint\n if(_simData._meta){\n buildInfo.innerHTML = renderBuildInfo(_simData._meta);\n } else {\n buildInfo.innerHTML = '<span class=\"spinner\"></span> Loading build info...';\n fetchAPI('/prompt-build-meta').then(function(meta){\n _simData._meta = meta;\n buildInfo.innerHTML = renderBuildInfo(meta);\n }).catch(function(e){\n buildInfo.innerHTML = '<p style=\"color:var(--danger)\">Failed to load: '+esc(String(e))+'</p>';\n });\n }\n return;\n }\n\n // Show editor, hide build info\n editorWrap.style.display = '';\n buildInfo.style.display = 'none';\n if(!_simEditor) return;\n var text = tab==='main' ? _simData.main : _simData.subagent;\n _simEditor.setValue(text);\n _simEditor.revealLine(1);\n charCount.textContent = formatCharTokenCount(text.length);\n}\n\nfunction renderBuildInfo(meta){\n if(!meta) return '<p style=\"color:var(--text-muted)\">No build metadata available.</p>';\n var html = '';\n\n // Loaded files\n html += '<div style=\"margin-bottom:20px\">';\n html += '<h4 style=\"font-size:14px;margin-bottom:8px;color:var(--accent)\">Loaded Files</h4>';\n html += '<table class=\"tbl\" style=\"font-size:13px\"><thead><tr><th>File</th><th>Chars</th><th>Status</th></tr></thead><tbody>';\n for(var i=0;i<meta.loadedFiles.length;i++){\n var f = meta.loadedFiles[i];\n var status = f.excluded ? '<span style=\"color:var(--text-muted)\">excluded</span>' : '<span style=\"color:var(--success)\">included</span>';\n html += '<tr><td><code>'+esc(f.name)+'</code></td><td>'+f.chars.toLocaleString()+'</td><td>'+status+'</td></tr>';\n }\n html += '</tbody></table></div>';\n\n // File includes\n html += '<div style=\"margin-bottom:20px\">';\n html += '<h4 style=\"font-size:14px;margin-bottom:8px;color:var(--accent)\">File Includes <code>{{FILE:...}}</code></h4>';\n if(meta.fileIncludes.length === 0){\n html += '<p style=\"color:var(--text-muted)\">No file includes detected.</p>';\n } else {\n html += '<table class=\"tbl\" style=\"font-size:13px\"><thead><tr><th>Placeholder</th><th>In File</th><th>Resolved</th><th>Chars</th></tr></thead><tbody>';\n for(var j=0;j<meta.fileIncludes.length;j++){\n var inc = meta.fileIncludes[j];\n var resolved = inc.resolved\n ? '<span style=\"color:var(--success)\">yes</span>'\n : '<span style=\"color:var(--danger)\">no</span>';\n html += '<tr><td><code>'+esc(inc.placeholder)+'</code></td><td><code>'+esc(inc.sourceFile)+'</code></td><td>'+resolved+'</td><td>'+inc.chars.toLocaleString()+'</td></tr>';\n }\n html += '</tbody></table>';\n }\n html += '</div>';\n\n // Memory budget\n html += '<div style=\"margin-bottom:20px\">';\n html += '<h4 style=\"font-size:14px;margin-bottom:8px;color:var(--accent)\">Memory Budget (Hot/Cold)</h4>';\n if(!meta.memoryBudget){\n html += '<p style=\"color:var(--text-muted)\">No MEMORY.md loaded.</p>';\n } else {\n var mb = meta.memoryBudget;\n var pctHot = mb.totalChars > 0 ? Math.round(mb.hotChars / mb.totalChars * 100) : 100;\n var budgetStatus = mb.budgetExceeded\n ? '<span style=\"color:#f59e0b\">exceeded</span>'\n : '<span style=\"color:var(--success)\">within budget</span>';\n html += '<table class=\"tbl\" style=\"font-size:13px\"><tbody>';\n html += '<tr><td style=\"width:180px\">File</td><td><code>'+esc(mb.fileName)+'</code></td></tr>';\n html += '<tr><td>Total chars</td><td>'+mb.totalChars.toLocaleString()+'</td></tr>';\n html += '<tr><td>Budget threshold</td><td>'+mb.budgetThreshold.toLocaleString()+' chars</td></tr>';\n html += '<tr><td>Budget status</td><td>'+budgetStatus+'</td></tr>';\n html += '<tr><td>Has <code>&lt;!-- hot --&gt;</code> markers</td><td>'+(mb.hasHotMarkers ? 'yes' : 'no')+'</td></tr>';\n html += '<tr><td>Hot (in prompt)</td><td>'+mb.hotChars.toLocaleString()+' chars ('+pctHot+'%)</td></tr>';\n html += '<tr><td>Cold (via memory_search)</td><td>'+mb.coldChars.toLocaleString()+' chars ('+(100-pctHot)+'%)</td></tr>';\n html += '</tbody></table>';\n\n // Visual bar\n if(mb.budgetExceeded && mb.hasHotMarkers){\n html += '<div style=\"margin-top:8px;display:flex;height:12px;border-radius:6px;overflow:hidden;border:1px solid var(--border)\">';\n html += '<div style=\"width:'+pctHot+'%;background:var(--accent)\" title=\"Hot: '+mb.hotChars.toLocaleString()+' chars\"></div>';\n html += '<div style=\"width:'+(100-pctHot)+'%;background:var(--text-muted);opacity:0.3\" title=\"Cold: '+mb.coldChars.toLocaleString()+' chars\"></div>';\n html += '</div>';\n html += '<div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--text-muted);margin-top:2px\"><span>hot (prompt)</span><span>cold (memory_search)</span></div>';\n }\n }\n html += '</div>';\n\n // Truncations\n html += '<div style=\"margin-bottom:20px\">';\n html += '<h4 style=\"font-size:14px;margin-bottom:8px;color:var(--accent)\">Truncations</h4>';\n if(meta.truncations.length === 0){\n html += '<p style=\"color:var(--text-muted)\">No files were truncated.</p>';\n } else {\n html += '<table class=\"tbl\" style=\"font-size:13px\"><thead><tr><th>File</th><th>Original</th><th>After</th><th>Skipped</th></tr></thead><tbody>';\n for(var k=0;k<meta.truncations.length;k++){\n var tr = meta.truncations[k];\n html += '<tr><td><code>'+esc(tr.fileName)+'</code></td><td>'+tr.originalChars.toLocaleString()+'</td><td>'+tr.truncatedChars.toLocaleString()+'</td><td>'+tr.skippedChars.toLocaleString()+'</td></tr>';\n }\n html += '</tbody></table>';\n }\n html += '</div>';\n\n return html;\n}\n\nfunction closeSimModal(){\n document.getElementById('simModal').classList.remove('open');\n}\n\nasync function copySimPrompt(){\n if(!_simEditor) return;\n var text = _simEditor.getValue();\n try{\n await navigator.clipboard.writeText(text);\n toast('Copied to clipboard','ok');\n }catch(e){ toast('Copy failed','err'); }\n}\n\n/* ---- Memory Logs ---- */\nasync function loadMemorySessions(){\n try{\n _memorySessions = await fetchAPI('/memory-files');\n var sel = document.getElementById('memorySessionSelect');\n sel.innerHTML = '<option value=\"\">Select a session...</option>';\n for(var i=0;i<_memorySessions.length;i++){\n var s = _memorySessions[i];\n var opt = document.createElement('option');\n opt.value = s.sessionKey;\n opt.textContent = s.sessionKey + ' (' + s.files.length + ' file' + (s.files.length!==1?'s':'') + ')';\n sel.appendChild(opt);\n }\n document.getElementById('memoryFileList').innerHTML = '';\n }catch(e){ toast('Failed to load memory sessions','err'); }\n}\n\nfunction loadMemoryFiles(){\n var sel = document.getElementById('memorySessionSelect');\n var sessionKey = sel.value;\n var container = document.getElementById('memoryFileList');\n container.innerHTML = '';\n if(!sessionKey) return;\n var session = _memorySessions.find(function(s){ return s.sessionKey === sessionKey; });\n if(!session || !session.files.length){\n container.innerHTML = '<p style=\"font-size:13px;color:var(--text-muted)\">No files found.</p>';\n return;\n }\n var html = '<table class=\"tbl\"><thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead><tbody>';\n for(var i=0;i<session.files.length;i++){\n var f = session.files[i];\n var sizeKb = (f.size / 1024).toFixed(1);\n var mod = new Date(f.modified).toLocaleString();\n html += '<tr><td style=\"font-family:monospace;font-size:13px\">'+esc(f.name)+'</td><td style=\"font-size:13px\">'+sizeKb+' KB</td><td style=\"font-size:13px\">'+esc(mod)+'</td><td><button class=\"btn btn-sm btn-ghost\" data-mem-session=\"'+esc(sessionKey)+'\" data-mem-file=\"'+esc(f.name)+'\" onclick=\"viewMemoryFile(this.dataset.memSession,this.dataset.memFile)\">View</button></td></tr>';\n }\n html += '</tbody></table>';\n container.innerHTML = html;\n}\n\nasync function viewMemoryFile(sessionKey, fileName){\n var modal = document.getElementById('memViewModal');\n var title = document.getElementById('memViewTitle');\n var info = document.getElementById('memViewInfo');\n title.textContent = sessionKey + ' / ' + fileName;\n info.textContent = '';\n modal.classList.add('open');\n await ensureMonaco();\n var wrap = document.getElementById('memEditorWrap');\n if(!_memEditor){\n _memEditor = createEditor(wrap, { readOnly: true });\n }\n _memEditor.setValue('Loading...');\n try{\n var data = await fetchAPI('/memory-files/'+encodeURIComponent(sessionKey)+'/'+encodeURIComponent(fileName));\n _memEditor.setValue(data.content);\n _memEditor.revealLine(1);\n info.textContent = data.content.length.toLocaleString() + ' chars';\n }catch(e){\n _memEditor.setValue('Error loading file: '+e);\n }\n}\n\nfunction showPlaceholderRef(){\n document.getElementById('placeholderRefModal').classList.add('open');\n}\n\nfunction closeMemViewModal(){\n document.getElementById('memViewModal').classList.remove('open');\n}\n\nasync function copyMemView(){\n if(!_memEditor) return;\n var text = _memEditor.getValue();\n try{\n await navigator.clipboard.writeText(text);\n toast('Copied to clipboard','ok');\n }catch(e){ toast('Copy failed','err'); }\n}\n"}