@hera-al/server 1.6.41 → 1.6.43

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.
package/dist/config.d.ts CHANGED
@@ -272,6 +272,10 @@ declare const AppConfigSchema: z.ZodObject<{
272
272
  agentId: z.ZodDefault<z.ZodString>;
273
273
  privateKey: z.ZodDefault<z.ZodString>;
274
274
  reconnectDelayMs: z.ZodDefault<z.ZodNumber>;
275
+ rateLimit: z.ZodDefault<z.ZodOptional<z.ZodObject<{
276
+ maxPerPair: z.ZodDefault<z.ZodNumber>;
277
+ windowMs: z.ZodDefault<z.ZodNumber>;
278
+ }, z.core.$strip>>>;
275
279
  }, z.core.$strip>>>;
276
280
  responses: z.ZodDefault<z.ZodOptional<z.ZodObject<{
277
281
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -450,6 +454,7 @@ declare const AppConfigSchema: z.ZodObject<{
450
454
  enabled: z.ZodDefault<z.ZodBoolean>;
451
455
  isolated: z.ZodDefault<z.ZodBoolean>;
452
456
  broadcastEvents: z.ZodDefault<z.ZodBoolean>;
457
+ trackMemory: z.ZodDefault<z.ZodBoolean>;
453
458
  storePath: z.ZodDefault<z.ZodString>;
454
459
  heartbeat: z.ZodDefault<z.ZodOptional<z.ZodObject<{
455
460
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -481,6 +486,10 @@ declare const AppConfigSchema: z.ZodObject<{
481
486
  color: z.ZodDefault<z.ZodString>;
482
487
  }, z.core.$strip>>>;
483
488
  }, z.core.$strip>>>;
489
+ warmRestart: z.ZodDefault<z.ZodOptional<z.ZodObject<{
490
+ enabled: z.ZodDefault<z.ZodBoolean>;
491
+ pm2Name: z.ZodDefault<z.ZodString>;
492
+ }, z.core.$strip>>>;
484
493
  }, z.core.$strip>;
485
494
  type RawAppConfig = z.infer<typeof AppConfigSchema>;
486
495
  export type AppConfig = RawAppConfig & {
package/dist/config.js CHANGED
@@ -1 +1 @@
1
- import{readFileSync as e,writeFileSync as t,existsSync as a,mkdirSync as o,renameSync as n,unlinkSync as l}from"node:fs";import{resolve as r,join as s}from"node:path";import{homedir as i}from"node:os";import{config as d}from"dotenv";import{parse as u,stringify as c}from"yaml";import{z as f}from"zod";import{BrowserConfigSchema as p}from"@hera-al/browser-server/config";import{createLogger as m}from"./utils/logger.js";const b=m("Config");function g(e){return"~"===e||e.startsWith("~/")?e.replace("~",i()):e}let h=g(process.env.GMAB_PATH??"~/gmab"),y=s(h,"data");export function getGmabPath(){return h}export function getDataDir(){return y}export function getNostromoKeyPath(){return s(y,".nostromo-key")}export function backupConfig(o){if(a(o))try{const r=e=>`${o}.backup${e}`;a(r(1))&&l(r(1));for(let e=2;e<=5;e++)a(r(e))&&n(r(e),r(e-1));t(r(5),e(o)),b.debug(`Config backup created: ${r(5)}`)}catch(e){b.warn(`Failed to create config backup: ${e}`)}}const v=f.record(f.string(),f.any()),x=f.object({reactions:f.boolean().default(!0),sendMessage:f.boolean().default(!0),editMessage:f.boolean().default(!0),deleteMessage:f.boolean().default(!0),sticker:f.boolean().default(!1),createForumTopic:f.boolean().default(!1)}),w=f.object({maxAttempts:f.number().default(3),baseDelayMs:f.number().default(1e3),maxDelayMs:f.number().default(3e4)}),M=f.enum(["off","dm","group","all","allowlist"]),k=f.object({botToken:f.string(),dmPolicy:f.enum(["open","token","allowlist"]).default("allowlist"),allowFrom:f.array(f.union([f.string(),f.number()])).default([]),name:f.string().optional(),reactionLevel:f.enum(["off","ack","minimal","extensive"]).default("ack"),reactionNotifications:f.enum(["off","own","all"]).default("off"),inlineButtonsScope:M.optional(),textChunkLimit:f.number().default(4e3),streamMode:f.enum(["off","partial","block"]).default("partial"),linkPreview:f.boolean().default(!0),actions:x.optional(),retry:w.optional(),timeoutSeconds:f.number().optional(),proxy:f.string().optional()}),j=f.object({enabled:f.boolean().default(!1),accounts:f.record(f.string(),k).default({})}),P=f.object({enabled:f.boolean().default(!1),accounts:v.default({})}),R=f.object({enabled:f.boolean().default(!0),port:f.number().default(3004)}),C=f.object({enabled:f.boolean().default(!1),brokerUrl:f.string().default("ws://127.0.0.1:3780/ws"),agentId:f.string().default(""),privateKey:f.string().default(""),reconnectDelayMs:f.number().default(5e3)}),S=f.object({telegram:j.optional().default({enabled:!1,accounts:{}}),whatsapp:P.optional().default({enabled:!1,accounts:{}}),discord:P.optional().default({enabled:!1,accounts:{}}),slack:P.optional().default({enabled:!1,accounts:{}}),signal:P.optional().default({enabled:!1,accounts:{}}),msteams:P.optional().default({enabled:!1,accounts:{}}),googlechat:P.optional().default({enabled:!1,accounts:{}}),line:P.optional().default({enabled:!1,accounts:{}}),matrix:P.optional().default({enabled:!1,accounts:{}}),mesh:C.optional().default({enabled:!1,brokerUrl:"ws://127.0.0.1:3780/ws",agentId:"",privateKey:"",reconnectDelayMs:5e3}),responses:R.optional().default({enabled:!0,port:3e3})}),T=f.object({modelRef:f.string().default(""),model:f.string().default("whisper-1"),language:f.string().default("")}),A=f.object({binaryPath:f.string().default("whisper"),model:f.string().default("base")}),D=f.object({enabled:f.boolean().default(!1),provider:f.string().default("openai-whisper"),"openai-whisper":T.optional().default({modelRef:"",model:"whisper-1",language:""}),"local-whisper":A.optional().default({binaryPath:"whisper",model:"base"})}),I=f.object({voice:f.string().default("en-US-MichelleNeural"),lang:f.string().default("en-US"),outputFormat:f.string().default("audio-24khz-48kbitrate-mono-mp3")}),U=f.object({modelRef:f.string().default(""),model:f.string().default("gpt-4o-mini-tts"),voice:f.string().default("alloy")}),z=f.object({modelRef:f.string().default(""),voiceId:f.string().default("pMsXgVXv3BLzUgSXRplE"),modelId:f.string().default("eleven_multilingual_v2")}),L=f.object({enabled:f.boolean().default(!1),provider:f.enum(["edge","openai","elevenlabs"]).default("openai"),maxTextLength:f.number().default(4096),timeoutMs:f.number().default(3e4),edge:I.optional().default({voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"}),openai:U.optional().default({modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"}),elevenlabs:z.optional().default({modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"})}),W=f.object({enabled:f.boolean().default(!1),embeddingModel:f.string().default("text-embedding-3-small"),embeddingDimensions:f.number().default(1536),modelRef:f.string().default(""),prefixQuery:f.string().default(""),prefixDocument:f.string().default(""),updateDebounceMs:f.number().default(3e3),embedIntervalMs:f.number().default(3e5),maxResults:f.number().default(6),maxSnippetChars:f.number().default(700),maxInjectedChars:f.number().default(4e3),rrfK:f.number().default(60)}),K={enabled:!1,embeddingModel:"text-embedding-3-small",embeddingDimensions:1536,modelRef:"",prefixQuery:"",prefixDocument:"",updateDebounceMs:3e3,embedIntervalMs:3e5,maxResults:6,maxSnippetChars:700,maxInjectedChars:4e3,rrfK:60},E=f.object({enabled:f.boolean().default(!0),model:f.string().default("")}),F={enabled:!0,model:""},q=f.object({enabled:f.boolean().default(!0),recallStrategy:f.enum(["builtin-only","search"]).default("builtin-only"),search:W.optional().default(K),l0:E.optional().default(F)}),G=f.object({command:f.string(),args:f.array(f.string()).optional(),env:f.record(f.string(),f.string()).optional()}).passthrough(),O=f.object({id:f.string(),name:f.string(),types:f.array(f.enum(["internal","external","env-var"])).optional().default(["external"]),proxy:f.enum(["not-used","direct","proxied"]).optional().default("not-used"),fastUrl:f.string().optional().default(""),fastProxyApiKey:f.string().optional().default(""),apiKey:f.string().optional().default(""),baseURL:f.string().optional().default(""),useEnvVar:f.string().optional().default(""),contextWindow:f.number().optional().default(2e5),costInput:f.number().optional().default(0),costOutput:f.number().optional().default(0),costCacheRead:f.number().optional().default(0),costCacheWrite:f.number().optional().default(0)}),_=[{id:"claude-opus-4-6",name:"Claude Opus",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-sonnet-4-6",name:"Claude Sonnet",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-haiku-3-5-20241022",name:"Claude Haiku",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0}],B=f.array(O).default(_),N=f.object({name:f.string(),path:f.string(),description:f.string().default(""),enabled:f.boolean().default(!1)}),X=f.object({name:f.string(),description:f.string(),prompt:f.string(),model:f.enum(["sonnet","opus","haiku","inherit"]).default("inherit"),tools:f.array(f.string()).default(["Read","Write","Edit","Glob","Grep","WebSearch","WebFetch"]),expandContext:f.boolean().default(!1),enabled:f.boolean().default(!1)}),$=f.object({type:f.enum(["claudecode","pi"]).default("claudecode"),piModelRef:f.string().default("")}).passthrough(),V={type:"claudecode",piModelRef:""},H=f.object({enabled:f.boolean().default(!1),modelRefs:f.array(f.string()).default([]),rollingMemoryModel:f.string().default("")}),Q={enabled:!1,modelRefs:[],rollingMemoryModel:""},Z=f.object({maxAttempts:f.number().default(5),baseDelayMs:f.number().default(2e3),maxDelayMs:f.number().default(3e4)}),J={maxAttempts:5,baseDelayMs:2e3,maxDelayMs:3e4},Y=f.object({model:f.string().default("claude-opus-4-6"),mainFallback:f.string().default(""),engine:$.optional().default(V),picoAgent:H.optional().default(Q),maxTurns:f.number().default(50),permissionMode:f.string().default("bypassPermissions"),sessionTTL:f.number().default(3600),queueMode:f.enum(["queue","collect","steer"]).default("steer"),queueDebounceMs:f.number().default(1500),queueCap:f.number().default(20),queueDropPolicy:f.enum(["old","new","summarize"]).default("summarize"),allowedTools:f.array(f.string()).default([]),disallowedTools:f.array(f.string()).default([]),mcpServers:f.record(f.string(),G).default({}),workspacePath:f.string().default("./workspace"),builtinCoderSkill:f.boolean().default(!1),settingSources:f.enum(["nothing","user","project","both"]).default("project"),customSubAgents:f.array(X).default([]),plugins:f.array(N).default([]),inflightTyping:f.boolean().default(!0),autoApproveTools:f.boolean().default(!0),autoRenew:f.number().default(0),apiRetry:Z.optional().default(J),skillNudge:f.object({enabled:f.boolean().default(!0),threshold:f.number().default(10)}).default({enabled:!0,threshold:10}),qualityGate:f.object({enabled:f.boolean().default(!1)}).default({enabled:!1})}),ee={enabled:!1,provider:"openai",maxTextLength:4096,timeoutMs:3e4,edge:{voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"},openai:{modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"},elevenlabs:{modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"}},te={enabled:!0,recallStrategy:"builtin-only",dir:"",search:K,l0:F},ae={model:"claude-opus-4-6",mainFallback:"",engine:V,picoAgent:Q,maxTurns:50,permissionMode:"bypassPermissions",sessionTTL:3600,queueMode:"steer",queueDebounceMs:1500,queueCap:20,queueDropPolicy:"summarize",allowedTools:[],disallowedTools:[],mcpServers:{},workspacePath:"./workspace",builtinCoderSkill:!1,settingSources:"project",customSubAgents:[],plugins:[],inflightTyping:!0,autoApproveTools:!0,autoRenew:0,apiRetry:J,skillNudge:{enabled:!0,threshold:10},qualityGate:{enabled:!1}},oe=f.object({enabled:f.boolean().default(!1),every:f.number().default(18e5),channel:f.string().default(""),chatId:f.string().default(""),message:f.string().default(""),ackMaxChars:f.number().default(300)}),ne={enabled:!1,every:18e5,channel:"",chatId:"",message:"",ackMaxChars:300},le=f.object({enabled:f.boolean().default(!0),isolated:f.boolean().default(!0),broadcastEvents:f.boolean().default(!1),storePath:f.string().default(""),heartbeat:oe.optional().default(ne)}),re={enabled:!0,isolated:!0,broadcastEvents:!1,storePath:"",heartbeat:ne},se=f.object({enabled:f.boolean().default(!0),port:f.number().default(3001),basePath:f.string().default("/nostromo"),configCheckInterval:f.number().default(5),autoRestart:f.boolean().default(!0)}),ie={enabled:!0,port:3001,basePath:"/nostromo",configCheckInterval:5,autoRestart:!0},de=f.object({gmabPath:f.string().optional().default("~/gmab"),host:f.string().optional().default("127.0.0.1"),logLevel:f.enum(["debug","info","warn","error"]).optional().default("info"),verboseDebugLogs:f.boolean().optional().default(!0),timezone:f.string().optional().default(""),fastProxyUrl:f.string().optional().default("http://localhost:4181"),channels:S.optional().default({telegram:{enabled:!1,accounts:{}},whatsapp:{enabled:!1,accounts:{}},discord:{enabled:!1,accounts:{}},slack:{enabled:!1,accounts:{}},signal:{enabled:!1,accounts:{}},msteams:{enabled:!1,accounts:{}},googlechat:{enabled:!1,accounts:{}},line:{enabled:!1,accounts:{}},matrix:{enabled:!1,accounts:{}},mesh:{enabled:!1,brokerUrl:"ws://127.0.0.1:3780/ws",agentId:"",privateKey:"",reconnectDelayMs:5e3},responses:{enabled:!0,port:3004}}),models:B.optional().default(_),stt:D.optional().default({enabled:!1,provider:"openai-whisper","openai-whisper":{modelRef:"",model:"whisper-1",language:""},"local-whisper":{binaryPath:"whisper",model:"base"}}),tts:L.optional().default(ee),memory:q.optional().default(te),agent:Y.optional().default(ae),cron:le.optional().default(re),nostromo:se.optional().default(ie),browser:p.optional().default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,remoteCdpTimeoutMs:1500,profiles:{default:{cdpPort:9222,color:"#FF4500"}}})});function ue(e){const t=function(e){const t=new Set,a=/\$\{([^}]+)\}/g;let o;for(;null!==(o=a.exec(e));)t.add(o[1]);return Array.from(t)}(e),a=t.filter(e=>!process.env[e]);a.length>0&&b.warn(`Missing environment variables referenced in config: ${a.join(", ")}. Add them to .env or export them before starting.`)}function ce(e){if("string"==typeof e)return e.replace(/\$\{([^}]+)\}/g,(e,t)=>process.env[t]??"");if(Array.isArray(e))return e.map(ce);if(null!==e&&"object"==typeof e){const t={};for(const[a,o]of Object.entries(e))t[a]=ce(o);return t}return e}const fe={channels:{responses:{enabled:!0,port:3004}},stt:{enabled:!1},tts:ee,memory:te,agent:{...ae,permissionMode:"bypassPermissions",allowedTools:["Read","Grep","Bash","WebSearch","Glob","Write","Edit","WebFetch","Task","Skill"]},cron:re,nostromo:ie};export function loadConfig(n){const l=n??r(process.cwd(),"config.yaml"),i=r(process.cwd(),".env");if(a(i)&&(d({path:i}),b.info(`Loaded .env from ${i}`)),!a(l)){const e="# GrabMeABeer Configuration\n# Configure channels and settings via Nostromo: http://localhost:3001\n\n"+c({gmabPath:"~/gmab",...fe});t(l,e,"utf-8")}const f=e(l,"utf-8");ue(f);const p=ce(u(f)),m=de.parse(p);if(h=process.env.GMAB_PATH?g(process.env.GMAB_PATH):g(m.gmabPath),y=s(h,"data"),o(y,{recursive:!0}),!m.timezone){m.timezone=Intl.DateTimeFormat().resolvedOptions().timeZone;try{const a=u(e(l,"utf-8"))??{};a.timezone=m.timezone,t(l,c(a),"utf-8"),b.info(`Timezone auto-detected and saved: ${m.timezone}`)}catch(e){}}return function(e){o(y,{recursive:!0});const t=process.env.WORKSPACE_PATH??e.agent.workspacePath;e.agent.workspacePath=r(g(t)),o(e.agent.workspacePath,{recursive:!0});const a=s(y,"cron");o(a,{recursive:!0});const n=e.cron.storePath.trim()?r(g(e.cron.storePath)):s(a,"jobs.json");return{...e,gmabPath:h,dataDir:y,dbPath:s(y,"core.db"),memoryDir:s(y,"memory"),cronStorePath:n}}(m)}export function loadRawConfig(t){const o=t??r(process.cwd(),"config.yaml");if(!a(o))return{};const n=e(o,"utf-8");return u(n)??{}}export function resolveModelEntry(e,t){if(!t)return;const a=e.models;if(!a?.length)return;const o=t.indexOf(":");if(o>=0){const e=t.substring(0,o),n=t.substring(o+1);return a.find(t=>t.name===e&&t.id===n)}return a.find(e=>e.name===t)??a.find(e=>e.id===t)}export function modelRefName(e){if(!e)return e;const t=e.indexOf(":");return t>=0?e.substring(0,t):e}export function resolveModelId(e,t){if(!t)return t;const a=resolveModelEntry(e,t);return a?a.id:t}
1
+ import{readFileSync as e,writeFileSync as t,existsSync as a,mkdirSync as o,renameSync as n,unlinkSync as l}from"node:fs";import{resolve as r,join as i}from"node:path";import{homedir as s}from"node:os";import{config as d}from"dotenv";import{parse as u,stringify as c}from"yaml";import{z as f}from"zod";import{BrowserConfigSchema as m}from"@hera-al/browser-server/config";import{createLogger as p}from"./utils/logger.js";const b=p("Config");function g(e){return"~"===e||e.startsWith("~/")?e.replace("~",s()):e}let h=g(process.env.GMAB_PATH??"~/gmab"),y=i(h,"data");export function getGmabPath(){return h}export function getDataDir(){return y}export function getNostromoKeyPath(){return i(y,".nostromo-key")}export function backupConfig(o){if(a(o))try{const r=e=>`${o}.backup${e}`;a(r(1))&&l(r(1));for(let e=2;e<=5;e++)a(r(e))&&n(r(e),r(e-1));t(r(5),e(o)),b.debug(`Config backup created: ${r(5)}`)}catch(e){b.warn(`Failed to create config backup: ${e}`)}}const v=f.record(f.string(),f.any()),x=f.object({reactions:f.boolean().default(!0),sendMessage:f.boolean().default(!0),editMessage:f.boolean().default(!0),deleteMessage:f.boolean().default(!0),sticker:f.boolean().default(!1),createForumTopic:f.boolean().default(!1)}),w=f.object({maxAttempts:f.number().default(3),baseDelayMs:f.number().default(1e3),maxDelayMs:f.number().default(3e4)}),M=f.enum(["off","dm","group","all","allowlist"]),P=f.object({botToken:f.string(),dmPolicy:f.enum(["open","token","allowlist"]).default("allowlist"),allowFrom:f.array(f.union([f.string(),f.number()])).default([]),name:f.string().optional(),reactionLevel:f.enum(["off","ack","minimal","extensive"]).default("ack"),reactionNotifications:f.enum(["off","own","all"]).default("off"),inlineButtonsScope:M.optional(),textChunkLimit:f.number().default(4e3),streamMode:f.enum(["off","partial","block"]).default("partial"),linkPreview:f.boolean().default(!0),actions:x.optional(),retry:w.optional(),timeoutSeconds:f.number().optional(),proxy:f.string().optional()}),k=f.object({enabled:f.boolean().default(!1),accounts:f.record(f.string(),P).default({})}),j=f.object({enabled:f.boolean().default(!1),accounts:v.default({})}),R=f.object({enabled:f.boolean().default(!0),port:f.number().default(3004)}),C=f.object({maxPerPair:f.number().default(20),windowMs:f.number().default(36e5)}),S=f.object({enabled:f.boolean().default(!1),brokerUrl:f.string().default("ws://127.0.0.1:3780/ws"),agentId:f.string().default(""),privateKey:f.string().default(""),reconnectDelayMs:f.number().default(5e3),rateLimit:C.optional().default({maxPerPair:20,windowMs:36e5})}),T=f.object({telegram:k.optional().default({enabled:!1,accounts:{}}),whatsapp:j.optional().default({enabled:!1,accounts:{}}),discord:j.optional().default({enabled:!1,accounts:{}}),slack:j.optional().default({enabled:!1,accounts:{}}),signal:j.optional().default({enabled:!1,accounts:{}}),msteams:j.optional().default({enabled:!1,accounts:{}}),googlechat:j.optional().default({enabled:!1,accounts:{}}),line:j.optional().default({enabled:!1,accounts:{}}),matrix:j.optional().default({enabled:!1,accounts:{}}),mesh:S.optional().default({enabled:!1,brokerUrl:"ws://127.0.0.1:3780/ws",agentId:"",privateKey:"",reconnectDelayMs:5e3,rateLimit:{maxPerPair:20,windowMs:36e5}}),responses:R.optional().default({enabled:!0,port:3e3})}),A=f.object({modelRef:f.string().default(""),model:f.string().default("whisper-1"),language:f.string().default("")}),D=f.object({binaryPath:f.string().default("whisper"),model:f.string().default("base")}),I=f.object({enabled:f.boolean().default(!1),provider:f.string().default("openai-whisper"),"openai-whisper":A.optional().default({modelRef:"",model:"whisper-1",language:""}),"local-whisper":D.optional().default({binaryPath:"whisper",model:"base"})}),U=f.object({voice:f.string().default("en-US-MichelleNeural"),lang:f.string().default("en-US"),outputFormat:f.string().default("audio-24khz-48kbitrate-mono-mp3")}),L=f.object({modelRef:f.string().default(""),model:f.string().default("gpt-4o-mini-tts"),voice:f.string().default("alloy")}),z=f.object({modelRef:f.string().default(""),voiceId:f.string().default("pMsXgVXv3BLzUgSXRplE"),modelId:f.string().default("eleven_multilingual_v2")}),W=f.object({enabled:f.boolean().default(!1),provider:f.enum(["edge","openai","elevenlabs"]).default("openai"),maxTextLength:f.number().default(4096),timeoutMs:f.number().default(3e4),edge:U.optional().default({voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"}),openai:L.optional().default({modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"}),elevenlabs:z.optional().default({modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"})}),K=f.object({enabled:f.boolean().default(!1),embeddingModel:f.string().default("text-embedding-3-small"),embeddingDimensions:f.number().default(1536),modelRef:f.string().default(""),prefixQuery:f.string().default(""),prefixDocument:f.string().default(""),updateDebounceMs:f.number().default(3e3),embedIntervalMs:f.number().default(3e5),maxResults:f.number().default(6),maxSnippetChars:f.number().default(700),maxInjectedChars:f.number().default(4e3),rrfK:f.number().default(60)}),E={enabled:!1,embeddingModel:"text-embedding-3-small",embeddingDimensions:1536,modelRef:"",prefixQuery:"",prefixDocument:"",updateDebounceMs:3e3,embedIntervalMs:3e5,maxResults:6,maxSnippetChars:700,maxInjectedChars:4e3,rrfK:60},F=f.object({enabled:f.boolean().default(!0),model:f.string().default("")}),q={enabled:!0,model:""},G=f.object({enabled:f.boolean().default(!0),recallStrategy:f.enum(["builtin-only","search"]).default("builtin-only"),search:K.optional().default(E),l0:F.optional().default(q)}),N=f.object({command:f.string(),args:f.array(f.string()).optional(),env:f.record(f.string(),f.string()).optional()}).passthrough(),O=f.object({id:f.string(),name:f.string(),types:f.array(f.enum(["internal","external","env-var"])).optional().default(["external"]),proxy:f.enum(["not-used","direct","proxied"]).optional().default("not-used"),fastUrl:f.string().optional().default(""),fastProxyApiKey:f.string().optional().default(""),apiKey:f.string().optional().default(""),baseURL:f.string().optional().default(""),useEnvVar:f.string().optional().default(""),contextWindow:f.number().optional().default(2e5),costInput:f.number().optional().default(0),costOutput:f.number().optional().default(0),costCacheRead:f.number().optional().default(0),costCacheWrite:f.number().optional().default(0)}),_=[{id:"claude-opus-4-6",name:"Claude Opus",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-sonnet-4-6",name:"Claude Sonnet",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-haiku-3-5-20241022",name:"Claude Haiku",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0}],B=f.array(O).default(_),X=f.object({name:f.string(),path:f.string(),description:f.string().default(""),enabled:f.boolean().default(!1)}),$=f.object({name:f.string(),description:f.string(),prompt:f.string(),model:f.enum(["sonnet","opus","haiku","inherit"]).default("inherit"),tools:f.array(f.string()).default(["Read","Write","Edit","Glob","Grep","WebSearch","WebFetch"]),expandContext:f.boolean().default(!1),enabled:f.boolean().default(!1)}),V=f.object({type:f.enum(["claudecode","pi"]).default("claudecode"),piModelRef:f.string().default("")}).passthrough(),H={type:"claudecode",piModelRef:""},Q=f.object({enabled:f.boolean().default(!1),modelRefs:f.array(f.string()).default([]),rollingMemoryModel:f.string().default("")}),Z={enabled:!1,modelRefs:[],rollingMemoryModel:""},J=f.object({maxAttempts:f.number().default(5),baseDelayMs:f.number().default(2e3),maxDelayMs:f.number().default(3e4)}),Y={maxAttempts:5,baseDelayMs:2e3,maxDelayMs:3e4},ee=f.object({model:f.string().default("claude-opus-4-6"),mainFallback:f.string().default(""),engine:V.optional().default(H),picoAgent:Q.optional().default(Z),maxTurns:f.number().default(50),permissionMode:f.string().default("bypassPermissions"),sessionTTL:f.number().default(3600),queueMode:f.enum(["queue","collect","steer"]).default("steer"),queueDebounceMs:f.number().default(1500),queueCap:f.number().default(20),queueDropPolicy:f.enum(["old","new","summarize"]).default("summarize"),allowedTools:f.array(f.string()).default([]),disallowedTools:f.array(f.string()).default([]),mcpServers:f.record(f.string(),N).default({}),workspacePath:f.string().default("./workspace"),builtinCoderSkill:f.boolean().default(!1),settingSources:f.enum(["nothing","user","project","both"]).default("project"),customSubAgents:f.array($).default([]),plugins:f.array(X).default([]),inflightTyping:f.boolean().default(!0),autoApproveTools:f.boolean().default(!0),autoRenew:f.number().default(0),apiRetry:J.optional().default(Y),skillNudge:f.object({enabled:f.boolean().default(!0),threshold:f.number().default(10)}).default({enabled:!0,threshold:10}),qualityGate:f.object({enabled:f.boolean().default(!1)}).default({enabled:!1})}),te={enabled:!1,provider:"openai",maxTextLength:4096,timeoutMs:3e4,edge:{voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"},openai:{modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"},elevenlabs:{modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"}},ae={enabled:!0,recallStrategy:"builtin-only",dir:"",search:E,l0:q},oe={model:"claude-opus-4-6",mainFallback:"",engine:H,picoAgent:Z,maxTurns:50,permissionMode:"bypassPermissions",sessionTTL:3600,queueMode:"steer",queueDebounceMs:1500,queueCap:20,queueDropPolicy:"summarize",allowedTools:[],disallowedTools:[],mcpServers:{},workspacePath:"./workspace",builtinCoderSkill:!1,settingSources:"project",customSubAgents:[],plugins:[],inflightTyping:!0,autoApproveTools:!0,autoRenew:0,apiRetry:Y,skillNudge:{enabled:!0,threshold:10},qualityGate:{enabled:!1}},ne=f.object({enabled:f.boolean().default(!1),every:f.number().default(18e5),channel:f.string().default(""),chatId:f.string().default(""),message:f.string().default(""),ackMaxChars:f.number().default(300)}),le={enabled:!1,every:18e5,channel:"",chatId:"",message:"",ackMaxChars:300},re=f.object({enabled:f.boolean().default(!0),isolated:f.boolean().default(!0),broadcastEvents:f.boolean().default(!1),trackMemory:f.boolean().default(!1),storePath:f.string().default(""),heartbeat:ne.optional().default(le)}),ie={enabled:!0,isolated:!0,broadcastEvents:!1,trackMemory:!1,storePath:"",heartbeat:le},se=f.object({enabled:f.boolean().default(!0),port:f.number().default(3001),basePath:f.string().default("/nostromo"),configCheckInterval:f.number().default(5),autoRestart:f.boolean().default(!0)}),de={enabled:!0,port:3001,basePath:"/nostromo",configCheckInterval:5,autoRestart:!0},ue=f.object({enabled:f.boolean().default(!1),pm2Name:f.string().default("")}),ce=f.object({gmabPath:f.string().optional().default("~/gmab"),host:f.string().optional().default("127.0.0.1"),logLevel:f.enum(["debug","info","warn","error"]).optional().default("info"),verboseDebugLogs:f.boolean().optional().default(!0),timezone:f.string().optional().default(""),fastProxyUrl:f.string().optional().default("http://localhost:4181"),channels:T.optional().default({telegram:{enabled:!1,accounts:{}},whatsapp:{enabled:!1,accounts:{}},discord:{enabled:!1,accounts:{}},slack:{enabled:!1,accounts:{}},signal:{enabled:!1,accounts:{}},msteams:{enabled:!1,accounts:{}},googlechat:{enabled:!1,accounts:{}},line:{enabled:!1,accounts:{}},matrix:{enabled:!1,accounts:{}},mesh:{enabled:!1,brokerUrl:"ws://127.0.0.1:3780/ws",agentId:"",privateKey:"",reconnectDelayMs:5e3,rateLimit:{maxPerPair:20,windowMs:36e5}},responses:{enabled:!0,port:3004}}),models:B.optional().default(_),stt:I.optional().default({enabled:!1,provider:"openai-whisper","openai-whisper":{modelRef:"",model:"whisper-1",language:""},"local-whisper":{binaryPath:"whisper",model:"base"}}),tts:W.optional().default(te),memory:G.optional().default(ae),agent:ee.optional().default(oe),cron:re.optional().default(ie),nostromo:se.optional().default(de),browser:m.optional().default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,remoteCdpTimeoutMs:1500,profiles:{default:{cdpPort:9222,color:"#FF4500"}}}),warmRestart:ue.optional().default({enabled:!1,pm2Name:""})});function fe(e){const t=function(e){const t=new Set,a=/\$\{([^}]+)\}/g;let o;for(;null!==(o=a.exec(e));)t.add(o[1]);return Array.from(t)}(e),a=t.filter(e=>!process.env[e]);a.length>0&&b.warn(`Missing environment variables referenced in config: ${a.join(", ")}. Add them to .env or export them before starting.`)}function me(e){if("string"==typeof e)return e.replace(/\$\{([^}]+)\}/g,(e,t)=>process.env[t]??"");if(Array.isArray(e))return e.map(me);if(null!==e&&"object"==typeof e){const t={};for(const[a,o]of Object.entries(e))t[a]=me(o);return t}return e}const pe={channels:{responses:{enabled:!0,port:3004}},stt:{enabled:!1},tts:te,memory:ae,agent:{...oe,permissionMode:"bypassPermissions",allowedTools:["Read","Grep","Bash","WebSearch","Glob","Write","Edit","WebFetch","Task","Skill"]},cron:ie,nostromo:de};export function loadConfig(n){const l=n??r(process.cwd(),"config.yaml"),s=r(process.cwd(),".env");if(a(s)&&(d({path:s}),b.info(`Loaded .env from ${s}`)),!a(l)){const e="# GrabMeABeer Configuration\n# Configure channels and settings via Nostromo: http://localhost:3001\n\n"+c({gmabPath:"~/gmab",...pe});t(l,e,"utf-8")}const f=e(l,"utf-8");fe(f);const m=me(u(f)),p=ce.parse(m);if(h=process.env.GMAB_PATH?g(process.env.GMAB_PATH):g(p.gmabPath),y=i(h,"data"),o(y,{recursive:!0}),!p.timezone){p.timezone=Intl.DateTimeFormat().resolvedOptions().timeZone;try{const a=u(e(l,"utf-8"))??{};a.timezone=p.timezone,t(l,c(a),"utf-8"),b.info(`Timezone auto-detected and saved: ${p.timezone}`)}catch(e){}}return function(e){o(y,{recursive:!0});const t=process.env.WORKSPACE_PATH??e.agent.workspacePath;e.agent.workspacePath=r(g(t)),o(e.agent.workspacePath,{recursive:!0});const a=i(y,"cron");o(a,{recursive:!0});const n=e.cron.storePath.trim()?r(g(e.cron.storePath)):i(a,"jobs.json");return{...e,gmabPath:h,dataDir:y,dbPath:i(y,"core.db"),memoryDir:i(y,"memory"),cronStorePath:n}}(p)}export function loadRawConfig(t){const o=t??r(process.cwd(),"config.yaml");if(!a(o))return{};const n=e(o,"utf-8");return u(n)??{}}export function resolveModelEntry(e,t){if(!t)return;const a=e.models;if(!a?.length)return;const o=t.indexOf(":");if(o>=0){const e=t.substring(0,o),n=t.substring(o+1);return a.find(t=>t.name===e&&t.id===n)}return a.find(e=>e.name===t)??a.find(e=>e.id===t)}export function modelRefName(e){if(!e)return e;const t=e.indexOf(":");return t>=0?e.substring(0,t):e}export function resolveModelId(e,t){if(!t)return t;const a=resolveModelEntry(e,t);return a?a.id:t}
@@ -29,10 +29,10 @@ export interface ChannelAdapter {
29
29
  sendText(chatId: string, text: string): Promise<void>;
30
30
  sendAudio?(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
31
31
  sendButtons?(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
32
+ /** One-shot: fire a single platform-native typing signal. */
32
33
  setTyping?(chatId: string): Promise<void>;
34
+ /** One-shot: fire a single platform-native stop-typing signal. */
33
35
  clearTyping?(chatId: string): Promise<void>;
34
- /** Cooperative typing release: decrements refcount instead of force-clearing. */
35
- releaseTyping?(chatId: string): Promise<void>;
36
36
  stop(): Promise<void>;
37
37
  }
38
38
  //# sourceMappingURL=bridge.d.ts.map
@@ -14,13 +14,6 @@ export declare class ChannelManager {
14
14
  sendButtons(channelName: string, chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
15
15
  setTyping(channelName: string, chatId: string): Promise<void>;
16
16
  clearTyping(channelName: string, chatId: string): Promise<void>;
17
- /**
18
- * Cooperative typing release: decrements the inflight refcount instead of
19
- * force-clearing. The typing interval will self-destruct when no more
20
- * handlers are in-flight. Use this in the normal message-completion path;
21
- * reserve clearTyping() as a safety-net for hard resets.
22
- */
23
- releaseTyping(channelName: string, chatId: string): Promise<void>;
24
17
  /**
25
18
  * Send a full response that may contain MEDIA: lines.
26
19
  * Parses out media entries, sends audio via sendAudio, and sends remaining text via sendText.
@@ -1 +1 @@
1
- import{parseMediaLines as t}from"../utils/media-response.js";import{createLogger as e}from"../utils/logger.js";const s=e("ChannelManager");export class ChannelManager{config;tokenDb;onMessage;adapters=new Map;constructor(t,e,s){this.config=t,this.tokenDb=e,this.onMessage=s}registerAdapter(t){this.adapters.set(t.name,t)}async startAll(){const t=[];for(const[e,a]of this.adapters)s.info(`Starting channel: ${e}`),t.push(a.start(this.onMessage).then(()=>{s.info(`Channel started: ${e}`)}).catch(t=>{s.error(`Failed to start channel ${e}: ${t}`)}));await Promise.allSettled(t)}async sendToChannel(t,e,a){const n=this.adapters.get(t);n?await n.sendText(e,a):s.error(`Channel not found: ${t}`)}async sendAudio(t,e,a,n){const r=this.adapters.get(t);r?r.sendAudio?await r.sendAudio(e,a,n):s.warn(`Channel ${t} does not support audio, skipping`):s.error(`Channel not found: ${t}`)}async sendButtons(t,e,a,n){const r=this.adapters.get(t);if(r)if(r.sendButtons)await r.sendButtons(e,a,n);else{const t=n.flat().map((t,e)=>`${e+1}. ${t.text}`).join("\n");await r.sendText(e,`${a}\n\n${t}\n\nReply with your choice or type your answer.`)}else s.error(`Channel not found: ${t}`)}async setTyping(t,e){const s=this.adapters.get(t);if(s&&s.setTyping)try{await s.setTyping(e)}catch{}}async clearTyping(t,e){const s=this.adapters.get(t);if(s&&s.clearTyping)try{await s.clearTyping(e)}catch{}}async releaseTyping(t,e){const s=this.adapters.get(t);if(s)if(s.releaseTyping)try{await s.releaseTyping(e)}catch{}else if(s.clearTyping)try{await s.clearTyping(e)}catch{}}async sendResponse(e,a,n){const{textParts:r,mediaEntries:o}=t(n);for(const t of o)try{await this.sendAudio(e,a,t.path,t.asVoice)}catch(t){s.error(`Failed to send audio to ${e}:${a}: ${t}`)}const i=r.join("\n").trim();i&&await this.sendToChannel(e,a,i)}getAdapter(t){return this.adapters.get(t)}getChannel(t){return this.adapters.get(t)}async sendSystemMessage(t,e,a){const n=this.adapters.get(t);if(n)try{await n.sendText(e,a),s.debug(`System message sent to ${t}:${e}`)}catch(a){s.error(`Failed to send system message to ${t}:${e}: ${a}`)}else s.warn(`Cannot send system message: channel ${t} not found`)}listAdapters(){return[...this.adapters.entries()].map(([t])=>({name:t,active:!0}))}async stopAll(){const t=[];for(const[e,a]of this.adapters)s.info(`Stopping channel: ${e}`),t.push(a.stop().catch(t=>{s.error(`Error stopping channel ${e}: ${t}`)}));await Promise.allSettled(t)}}
1
+ import{parseMediaLines as t}from"../utils/media-response.js";import{createLogger as e}from"../utils/logger.js";const s=e("ChannelManager");export class ChannelManager{config;tokenDb;onMessage;adapters=new Map;constructor(t,e,s){this.config=t,this.tokenDb=e,this.onMessage=s}registerAdapter(t){this.adapters.set(t.name,t)}async startAll(){const t=[];for(const[e,n]of this.adapters)s.info(`Starting channel: ${e}`),t.push(n.start(this.onMessage).then(()=>{s.info(`Channel started: ${e}`)}).catch(t=>{s.error(`Failed to start channel ${e}: ${t}`)}));await Promise.allSettled(t)}async sendToChannel(t,e,n){const a=this.adapters.get(t);a?await a.sendText(e,n):s.error(`Channel not found: ${t}`)}async sendAudio(t,e,n,a){const o=this.adapters.get(t);o?o.sendAudio?await o.sendAudio(e,n,a):s.warn(`Channel ${t} does not support audio, skipping`):s.error(`Channel not found: ${t}`)}async sendButtons(t,e,n,a){const o=this.adapters.get(t);if(o)if(o.sendButtons)await o.sendButtons(e,n,a);else{const t=a.flat().map((t,e)=>`${e+1}. ${t.text}`).join("\n");await o.sendText(e,`${n}\n\n${t}\n\nReply with your choice or type your answer.`)}else s.error(`Channel not found: ${t}`)}async setTyping(t,e){const s=this.adapters.get(t);if(s&&s.setTyping)try{await s.setTyping(e)}catch{}}async clearTyping(t,e){const s=this.adapters.get(t);if(s&&s.clearTyping)try{await s.clearTyping(e)}catch{}}async sendResponse(e,n,a){const{textParts:o,mediaEntries:r}=t(a);for(const t of r)try{await this.sendAudio(e,n,t.path,t.asVoice)}catch(t){s.error(`Failed to send audio to ${e}:${n}: ${t}`)}const i=o.join("\n").trim();i&&await this.sendToChannel(e,n,i)}getAdapter(t){return this.adapters.get(t)}getChannel(t){return this.adapters.get(t)}async sendSystemMessage(t,e,n){const a=this.adapters.get(t);if(a)try{await a.sendText(e,n),s.debug(`System message sent to ${t}:${e}`)}catch(n){s.error(`Failed to send system message to ${t}:${e}: ${n}`)}else s.warn(`Cannot send system message: channel ${t} not found`)}listAdapters(){return[...this.adapters.entries()].map(([t])=>({name:t,active:!0}))}async stopAll(){const t=[];for(const[e,n]of this.adapters)s.info(`Stopping channel: ${e}`),t.push(n.stop().catch(t=>{s.error(`Error stopping channel ${e}: ${t}`)}));await Promise.allSettled(t)}}
@@ -6,11 +6,16 @@
6
6
  * Dante can reply via sendText("beatrice", "...").
7
7
  */
8
8
  import type { ChannelAdapter, MessageHandler } from "../bridge.js";
9
+ export interface MeshRateLimitConfig {
10
+ maxPerPair: number;
11
+ windowMs: number;
12
+ }
9
13
  export interface MeshChannelConfig {
10
14
  brokerUrl: string;
11
15
  agentId: string;
12
16
  privateKey: string;
13
17
  reconnectDelayMs?: number;
18
+ rateLimit?: MeshRateLimitConfig;
14
19
  }
15
20
  export declare class MeshChannel implements ChannelAdapter {
16
21
  readonly name = "mesh";
@@ -27,7 +32,16 @@ export declare class MeshChannel implements ChannelAdapter {
27
32
  private pendingOutbound;
28
33
  /** Callback invoked when a reply arrives for an outbound message we sent. */
29
34
  private replyCallback;
35
+ /** Track active inbound message per peer — so sendText during processing auto-sets replyTo. */
36
+ private activeInbound;
37
+ private rateBuckets;
38
+ private rateLimitConfig;
30
39
  constructor(config: MeshChannelConfig);
40
+ /**
41
+ * Check if sending to a peer is allowed under the rate limit.
42
+ * Returns true if allowed, false if rate-limited.
43
+ */
44
+ private checkRateLimit;
31
45
  /**
32
46
  * Register a callback for when a reply arrives for an outbound message.
33
47
  * The callback receives the origin session key, the agent ID that replied, and the reply text.
@@ -1 +1 @@
1
- import e from"node:crypto";import{WebSocket as t}from"ws";import{createLogger as n}from"../../utils/logger.js";const s=n("Mesh");function o(t,n){const s=function(t){const n=Buffer.from(t,"hex"),s=Buffer.from("302e020100300506032b657004220420","hex"),o=Buffer.concat([s,n]);return e.createPrivateKey({key:o,format:"der",type:"pkcs8"})}(n),o=Buffer.from(t,"hex");return e.sign(null,o,s).toString("hex")}export class MeshChannel{name="mesh";ws=null;config;onMessage=null;sessionToken=null;connected=!1;reconnectTimer=null;reconnectDelay;stopping=!1;pingTimer=null;pendingOutbound=new Map;replyCallback=null;constructor(e){this.config=e,this.reconnectDelay=e.reconnectDelayMs??5e3}onReply(e){this.replyCallback=e}async start(e){this.onMessage=e,this.stopping=!1,await this.connect()}connect(){return new Promise((e,n)=>{const{brokerUrl:i,agentId:r}=this.config;s.info(`Connecting to mesh broker at ${i} as ${r}...`);try{this.ws=new t(i)}catch(t){return s.error(`Failed to create WebSocket: ${t}`),this.scheduleReconnect(),void e()}let a=!1;const c=t=>{a||(a=!0,t?(s.error(`Mesh connection failed: ${t.message}`),this.scheduleReconnect(),e()):e())},h=setTimeout(()=>{c(new Error("Connection timeout")),this.ws?.close()},1e4);this.ws.on("open",()=>{}),this.ws.on("message",e=>{let t;try{t=JSON.parse(e.toString())}catch{return void s.warn("Invalid JSON from mesh broker")}switch(t.type){case"challenge":{const e=o(t.challenge,this.config.privateKey);this.sendRaw({type:"auth",agentId:this.config.agentId,signature:e,timestamp:Date.now()});break}case"auth_result":clearTimeout(h),t.authenticated&&t.sessionToken?(this.connected=!0,this.sessionToken=t.sessionToken,s.info(`Authenticated as ${this.config.agentId} on mesh`),this.startPing(),c()):c(new Error(`Auth failed: ${t.error??"unknown"}`));break;case"message":this.handleMeshMessage(t.message),this.sendRaw({type:"ack",messageId:t.message.id});break;case"presence":s.info(`Mesh presence: ${t.agentId} is ${t.status}`),this.knownPeers.set(t.agentId,t.status);break;case"ping":this.sendRaw({type:"pong"});break;case"pong":case"ack":break;case"error":s.warn(`Mesh error: ${t.error}`)}}),this.ws.on("close",(e,t)=>{clearTimeout(h),this.connected=!1,this.sessionToken=null,this.stopPing(),s.info(`Mesh connection closed (code=${e}, reason=${t?.toString()??"?"})`),this.stopping||this.scheduleReconnect(),c()}),this.ws.on("error",e=>{s.error(`Mesh WebSocket error: ${e.message}`)})})}async handleMeshMessage(t){if(!this.onMessage)return;const n=t.from,o=null!=t.replyTo&&this.pendingOutbound.has(t.replyTo),i=o&&t.replyTo?this.pendingOutbound.get(t.replyTo):void 0;let r;if(t.replyTo&&this.pendingOutbound.has(t.replyTo)&&this.pendingOutbound.delete(t.replyTo),"string"==typeof t.payload)r=t.payload;else if(t.payload&&"object"==typeof t.payload){const e=t.payload;r="string"==typeof e.text?e.text:`[Mesh ${t.type}] ${JSON.stringify(t.payload)}`}else r=`[Mesh ${t.type}]`;if(o){if(s.info(`Reply from ${t.from} (to outbound ${t.replyTo}) → routing to origin session${i?` [${i}]`:""}`),i&&this.replyCallback)return void this.replyCallback(i,t.from,r)}else s.info(`New message from ${t.from}: ${t.type} → dispatching to agent (will auto-reply)`);const a={chatId:n,userId:t.from,channelName:"mesh",text:r,attachments:[],username:t.from};try{const i=await this.onMessage(a);if(!o&&i&&i.trim()){const o={id:e.randomUUID(),from:this.config.agentId,to:n,type:"text",payload:{text:i},timestamp:Date.now(),replyTo:t.id};this.sendRaw({type:"message",message:o}),s.debug(`Auto-replied to ${t.from}: ${i.slice(0,80)}...`)}}catch(e){s.error(`Error handling mesh message from ${t.from}: ${e}`)}}async sendText(t,n,o){if(!this.connected||!this.ws)return void s.warn(`Cannot send to ${t}: mesh not connected`);const i=e.randomUUID(),r={id:i,from:this.config.agentId,to:t,type:"text",payload:{text:n},timestamp:Date.now()};this.pendingOutbound.set(i,o),this.sendRaw({type:"message",message:r}),s.debug(`Sent to ${t} (id=${i}${o?`, origin=${o}`:""}): ${n.slice(0,80)}...`)}sendTyped(t,n,s,o){if(!this.connected||!this.ws)throw new Error("Mesh not connected");const i={id:e.randomUUID(),from:this.config.agentId,to:t,type:n,payload:s,timestamp:Date.now(),replyTo:o};return this.sendRaw({type:"message",message:i}),i.id}getSessionToken(){return this.sessionToken}getAgentId(){return this.config.agentId}getBrokerHttpUrl(){return this.config.brokerUrl.replace("ws://","http://").replace("wss://","https://").replace("/ws","")}isConnected(){return this.connected}knownPeers=new Map;async getAgents(){try{const e=this.getBrokerHttpUrl(),t=await fetch(`${e}/api/agents`,{headers:this.sessionToken?{Authorization:`Bearer ${this.sessionToken}`}:{}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const n=await t.json();return Array.isArray(n.agents)?n.agents:[]}catch(e){s.warn(`Failed to fetch agents: ${e}`);const t=[];for(const[e,n]of this.knownPeers)t.push({agentId:e,status:n});return t}}async getMeshPromptInfo(){const e=(await this.getAgents()).filter(e=>e.agentId!==this.config.agentId);if(0===e.length)return"";const t=e.map(e=>`- **${e.agentId}** (${e.status??"unknown"})`);return["## Mesh (Inter-Agent Communication)",`You are connected to the Hera Mesh as **${this.config.agentId}**.`,"Other agents on the mesh:",...t,"",'To message an agent: use send_message(channel="mesh", chatId="<agentId>", text="...").',"When you receive a mesh message, it appears as a normal incoming message from channel=mesh."].join("\n")}sendRaw(e){this.ws&&this.ws.readyState===t.OPEN&&this.ws.send(JSON.stringify(e))}startPing(){this.stopPing(),this.pingTimer=setInterval(()=>{this.connected&&this.sendRaw({type:"ping"})},3e4)}stopPing(){this.pingTimer&&(clearInterval(this.pingTimer),this.pingTimer=null)}scheduleReconnect(){this.stopping||this.reconnectTimer||(s.info(`Reconnecting in ${this.reconnectDelay}ms...`),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this.connect().catch(e=>{s.error(`Reconnect failed: ${e}`)})},this.reconnectDelay))}async stop(){this.stopping=!0,this.stopPing(),this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.ws&&(this.ws.close(1e3,"Channel stopping"),this.ws=null),this.connected=!1,s.info("Mesh channel stopped")}}
1
+ import e from"node:crypto";import{WebSocket as t}from"ws";import{createLogger as n}from"../../utils/logger.js";const s=n("Mesh");function i(t,n){const s=function(t){const n=Buffer.from(t,"hex"),s=Buffer.from("302e020100300506032b657004220420","hex"),i=Buffer.concat([s,n]);return e.createPrivateKey({key:i,format:"der",type:"pkcs8"})}(n),i=Buffer.from(t,"hex");return e.sign(null,i,s).toString("hex")}export class MeshChannel{name="mesh";ws=null;config;onMessage=null;sessionToken=null;connected=!1;reconnectTimer=null;reconnectDelay;stopping=!1;pingTimer=null;pendingOutbound=new Map;replyCallback=null;activeInbound=new Map;rateBuckets=new Map;rateLimitConfig;constructor(e){this.config=e,this.reconnectDelay=e.reconnectDelayMs??5e3,this.rateLimitConfig={maxPerPair:e.rateLimit?.maxPerPair??20,windowMs:e.rateLimit?.windowMs??36e5}}checkRateLimit(e){const t=Date.now(),n=this.rateBuckets.get(e);if(!n||t-n.windowStart>=this.rateLimitConfig.windowMs)return this.rateBuckets.set(e,{count:1,windowStart:t}),!0;if(n.count>=this.rateLimitConfig.maxPerPair){const i=this.rateLimitConfig.windowMs-(t-n.windowStart);return s.warn(`Rate limit reached for peer ${e}: ${n.count}/${this.rateLimitConfig.maxPerPair} in window. Resets in ${Math.ceil(i/1e3)}s`),!1}return n.count++,!0}onReply(e){this.replyCallback=e}async start(e){this.onMessage=e,this.stopping=!1,await this.connect()}connect(){return new Promise((e,n)=>{const{brokerUrl:o,agentId:r}=this.config;s.info(`Connecting to mesh broker at ${o} as ${r}...`);try{this.ws=new t(o)}catch(t){return s.error(`Failed to create WebSocket: ${t}`),this.scheduleReconnect(),void e()}let a=!1;const c=t=>{a||(a=!0,t?(s.error(`Mesh connection failed: ${t.message}`),this.scheduleReconnect(),e()):e())},h=setTimeout(()=>{c(new Error("Connection timeout")),this.ws?.close()},1e4);this.ws.on("open",()=>{}),this.ws.on("message",e=>{let t;try{t=JSON.parse(e.toString())}catch{return void s.warn("Invalid JSON from mesh broker")}switch(t.type){case"challenge":{const e=i(t.challenge,this.config.privateKey);this.sendRaw({type:"auth",agentId:this.config.agentId,signature:e,timestamp:Date.now()});break}case"auth_result":clearTimeout(h),t.authenticated&&t.sessionToken?(this.connected=!0,this.sessionToken=t.sessionToken,s.info(`Authenticated as ${this.config.agentId} on mesh`),this.startPing(),c()):c(new Error(`Auth failed: ${t.error??"unknown"}`));break;case"message":this.handleMeshMessage(t.message),this.sendRaw({type:"ack",messageId:t.message.id});break;case"presence":s.info(`Mesh presence: ${t.agentId} is ${t.status}`),this.knownPeers.set(t.agentId,t.status);break;case"ping":this.sendRaw({type:"pong"});break;case"pong":case"ack":break;case"error":s.warn(`Mesh error: ${t.error}`)}}),this.ws.on("close",(e,t)=>{clearTimeout(h),this.connected=!1,this.sessionToken=null,this.stopPing(),s.info(`Mesh connection closed (code=${e}, reason=${t?.toString()??"?"})`),this.stopping||this.scheduleReconnect(),c()}),this.ws.on("error",e=>{s.error(`Mesh WebSocket error: ${e.message}`)})})}async handleMeshMessage(t){if(!this.onMessage)return;const n=t.from,i=null!=t.replyTo&&this.pendingOutbound.has(t.replyTo),o=i&&t.replyTo?this.pendingOutbound.get(t.replyTo):void 0;if(t.replyTo&&this.pendingOutbound.has(t.replyTo)){const e=t.replyTo;setTimeout(()=>this.pendingOutbound.delete(e),3e5)}let r;if("string"==typeof t.payload)r=t.payload;else if(t.payload&&"object"==typeof t.payload){const e=t.payload;r="string"==typeof e.text?e.text:`[Mesh ${t.type}] ${JSON.stringify(t.payload)}`}else r=`[Mesh ${t.type}]`;if(i){if(s.info(`Reply from ${t.from} (to outbound ${t.replyTo}) → routing to origin session${o?` [${o}]`:""}`),o&&this.replyCallback)return void this.replyCallback(o,t.from,r)}else s.info(`New message from ${t.from}: ${t.type} → dispatching to agent (will auto-reply)`);const a={chatId:n,userId:t.from,channelName:"mesh",text:r,attachments:[],username:t.from};this.activeInbound.set(t.from,t.id);try{const o=await this.onMessage(a);if(!i&&o&&o.trim())if(this.checkRateLimit(n)){const i={id:e.randomUUID(),from:this.config.agentId,to:n,type:"text",payload:{text:o},timestamp:Date.now(),replyTo:t.id};this.sendRaw({type:"message",message:i}),s.debug(`Auto-replied to ${t.from}: ${o.slice(0,80)}...`)}else s.warn(`Dropping auto-reply to ${n}: rate limited`)}catch(e){s.error(`Error handling mesh message from ${t.from}: ${e}`)}finally{this.activeInbound.delete(t.from)}}async sendText(t,n,i){if(!this.connected||!this.ws)return void s.warn(`Cannot send to ${t}: mesh not connected`);if(!this.checkRateLimit(t))return void s.warn(`Dropping outbound message to ${t}: rate limited`);const o=e.randomUUID(),r=this.activeInbound.get(t),a={id:o,from:this.config.agentId,to:t,type:"text",payload:{text:n},timestamp:Date.now(),...r?{replyTo:r}:{}};r||this.pendingOutbound.set(o,i),this.sendRaw({type:"message",message:a}),s.debug(`Sent to ${t} (id=${o}${r?`, replyTo=${r}`:""}${i?`, origin=${i}`:""}): ${n.slice(0,80)}...`)}sendTyped(t,n,s,i){if(!this.connected||!this.ws)throw new Error("Mesh not connected");if(!this.checkRateLimit(t))throw new Error(`Rate limit reached for peer ${t} (max ${this.rateLimitConfig.maxPerPair} per ${this.rateLimitConfig.windowMs/1e3}s)`);const o={id:e.randomUUID(),from:this.config.agentId,to:t,type:n,payload:s,timestamp:Date.now(),replyTo:i};return this.sendRaw({type:"message",message:o}),o.id}getSessionToken(){return this.sessionToken}getAgentId(){return this.config.agentId}getBrokerHttpUrl(){return this.config.brokerUrl.replace("ws://","http://").replace("wss://","https://").replace("/ws","")}isConnected(){return this.connected}knownPeers=new Map;async getAgents(){try{const e=this.getBrokerHttpUrl(),t=await fetch(`${e}/api/agents`,{headers:this.sessionToken?{Authorization:`Bearer ${this.sessionToken}`}:{}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const n=await t.json();return Array.isArray(n.agents)?n.agents:[]}catch(e){s.warn(`Failed to fetch agents: ${e}`);const t=[];for(const[e,n]of this.knownPeers)t.push({agentId:e,status:n});return t}}async getMeshPromptInfo(){const e=(await this.getAgents()).filter(e=>e.agentId!==this.config.agentId);if(0===e.length)return"";const t=e.map(e=>`- **${e.agentId}** (${e.status??"unknown"})`);return["## Mesh (Inter-Agent Communication)",`You are connected to the Hera Mesh as **${this.config.agentId}**.`,"Other agents on the mesh:",...t,"",'To message an agent: use send_message(channel="mesh", chatId="<agentId>", text="...").',"When you receive a mesh message, it appears as a normal incoming message from channel=mesh."].join("\n")}sendRaw(e){this.ws&&this.ws.readyState===t.OPEN&&this.ws.send(JSON.stringify(e))}startPing(){this.stopPing(),this.pingTimer=setInterval(()=>{this.connected&&this.sendRaw({type:"ping"})},3e4)}stopPing(){this.pingTimer&&(clearInterval(this.pingTimer),this.pingTimer=null)}scheduleReconnect(){this.stopping||this.reconnectTimer||(s.info(`Reconnecting in ${this.reconnectDelay}ms...`),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this.connect().catch(e=>{s.error(`Reconnect failed: ${e}`)})},this.reconnectDelay))}async stop(){this.stopping=!0,this.stopPing(),this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.ws&&(this.ws.close(1e3,"Channel stopping"),this.ws=null),this.connected=!1,s.info("Mesh channel stopped")}}
@@ -19,19 +19,13 @@ export declare class TelegramChannel implements ChannelAdapter {
19
19
  private config;
20
20
  private accountId;
21
21
  private tokenDb;
22
- private typingIntervals;
23
- private inflightTyping;
24
- private inflightCount;
25
- constructor(config: TelegramAccountConfig, accountId: string, tokenDb: TokenDB, inflightTyping?: boolean);
22
+ constructor(config: TelegramAccountConfig, accountId: string, tokenDb: TokenDB);
26
23
  start(onMessage: MessageHandler): Promise<void>;
27
24
  sendText(chatId: string, text: string): Promise<void>;
25
+ /** One-shot: send a single "typing" signal to Telegram. */
28
26
  setTyping(chatId: string): Promise<void>;
29
- /** Re-send typing if an interval is active (another handler is still processing). */
30
- private resendTypingIfActive;
31
- clearTyping(chatId: string): Promise<void>;
32
- releaseTyping(chatId: string): Promise<void>;
33
- private startTypingInterval;
34
- private stopTypingInterval;
27
+ /** One-shot: Telegram has no explicit "stop typing" API it clears on message send. */
28
+ clearTyping(_chatId: string): Promise<void>;
35
29
  sendButtons(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
36
30
  sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
37
31
  reactMessage(chatId: string, messageId: string, emoji: string, remove?: boolean): Promise<void>;
@@ -1 +1 @@
1
- import{Bot as t,InputFile as e}from"grammy";import{validateChannelUser as i}from"../../../auth/auth-middleware.js";import{parseMediaLines as n}from"../../../utils/media-response.js";import{markdownToTelegramHtmlChunks as a}from"../../../utils/telegram-format.js";import{createLogger as o}from"../../../utils/logger.js";import{sendMessageTelegram as s}from"./send.js";import{reactMessageTelegram as r,resolveReactionLevel as c}from"./reactions.js";import{editMessageTelegram as l,deleteMessageTelegram as h}from"./edit-delete.js";import{sendStickerTelegram as d,cacheSticker as g}from"./stickers.js";import{validateButtonsForChatId as f}from"./inline-buttons.js";const m=o("Telegram");export class TelegramChannel{name="telegram";bot;config;accountId;tokenDb;typingIntervals=new Map;inflightTyping;inflightCount=new Map;constructor(e,i,n,a=!0){this.config=e,this.accountId=i,this.tokenDb=n,this.inflightTyping=a,this.bot=new t(e.botToken)}async start(t){this.bot.on("message",e=>{this.handleIncoming(e,t)}),this.bot.on("callback_query:data",e=>{const i=e.callbackQuery.data,n=String(e.from?.id??"unknown"),a=String(e.chat?.id??e.callbackQuery.message?.chat?.id??"unknown"),o=e.from?.username;e.answerCallbackQuery().catch(()=>{}),this.startTypingInterval(a);t({chatId:a,userId:n,channelName:"telegram",text:i,attachments:[],username:o,rawContext:e}).then(async t=>{t&&t.trim()&&await this.sendText(a,t)}).catch(t=>{m.error(`Error handling callback from ${n}: ${t}`),this.stopTypingInterval(a)})}),m.info("Starting Telegram bot..."),this.bot.start({onStart:t=>{m.info(`Telegram bot started: @${t.username}`)}}).catch(t=>{String(t).includes("Aborted delay")?m.debug("Telegram polling stopped"):m.error(`Telegram bot polling error: ${t}`)})}async sendText(t,e){try{await s(t,e,{token:this.config.botToken,accountId:this.accountId,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}catch(t){throw m.error(`Failed to send message: ${t}`),t}await this.resendTypingIfActive(t)}async setTyping(t){this.typingIntervals.has(t)?await this.bot.api.sendChatAction(t,"typing").catch(()=>{}):this.startTypingInterval(t)}async resendTypingIfActive(t){this.typingIntervals.has(t)&&await this.bot.api.sendChatAction(t,"typing").catch(()=>{})}async clearTyping(t){this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async releaseTyping(t){this.stopTypingInterval(t)}startTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??0)+1;if(this.inflightCount.set(t,e),e>1)return}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.bot.api.sendChatAction(t,"typing").catch(()=>{});const i=setInterval(()=>{const e=this.inflightCount.get(t)??0;!this.inflightTyping||e>0?this.bot.api.sendChatAction(t,"typing").catch(()=>{}):(clearInterval(i),this.typingIntervals.delete(t))},4e3);this.typingIntervals.set(t,i)}stopTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??1)-1;return void(e>0?this.inflightCount.set(t,e):this.inflightCount.delete(t))}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async sendButtons(t,e,i){const n=i.map(t=>t.map(t=>({text:t.text,callback_data:t.callbackData??t.text})));try{f(n,this.config,t)}catch(i){return m.warn(`Button validation failed: ${i}, sending without buttons`),void await this.sendText(t,e)}await s(t,e,{token:this.config.botToken,accountId:this.accountId,buttons:n,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry}),await this.resendTypingIfActive(t)}async sendAudio(t,i,n){const a=new e(i);n?await this.bot.api.sendVoice(t,a):await this.bot.api.sendAudio(t,a),await this.resendTypingIfActive(t)}async reactMessage(t,e,i,n){const{level:a}=c(this.config);"off"!==a?await r(t,e,i,this.config.botToken,{remove:n}):m.debug("Reactions disabled for this account")}async editMessage(t,e,i,n){const a=n?.map(t=>({text:t.text,callback_data:t.callbackData??t.text}));await l(t,e,i,this.config.botToken,{buttons:a?[a]:void 0,linkPreview:this.config.linkPreview})}async deleteMessage(t,e){await h(t,e,this.config.botToken,this.config.retry)}async sendSticker(t,e){await d(t,e,this.config.botToken,{retry:this.config.retry}),await this.resendTypingIfActive(t)}async stop(){for(const[,t]of this.typingIntervals)clearInterval(t);this.typingIntervals.clear(),this.inflightCount.clear();try{await this.bot.stop(),m.info("Telegram bot stopped"),await new Promise(t=>setTimeout(t,100))}catch(t){m.warn(`Error stopping Telegram bot: ${t}`)}}async handleIncoming(t,a){const o=String(t.from?.id??"unknown"),s=String(t.chat?.id??"unknown"),r=t.from?.username,c=i(this.tokenDb,o,"telegram",this.config.dmPolicy,this.config.allowFrom);if(!c.authorized)return m.warn(`Unauthorized message from ${o} (@${r})`),void await t.reply(c.reason??"Not authorized.");this.startTypingInterval(s);try{const i=await this.buildIncomingMessage(t,s,o,r),c=await a(i),{textParts:l,mediaEntries:h}=n(c);for(const i of h)try{const n=new e(i.path);i.asVoice?await t.replyWithVoice(n):await t.replyWithAudio(n)}catch(t){m.error(`Failed to send audio: ${t}`)}const d=l.join("\n").trim();d&&await this.sendChunked(t,d),this.stopTypingInterval(s)}catch(e){m.error(`Error handling message from ${o}: ${e}`),this.stopTypingInterval(s),await t.reply("An error occurred while processing your message.").catch(()=>{})}}async buildIncomingMessage(t,e,i,n){const a=t.message,o=[];let s=a.text??a.caption??void 0;if(a.photo&&a.photo.length>0){const e=a.photo[a.photo.length-1];o.push({type:"image",mimeType:"image/jpeg",fileSize:e.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,e.file_id)})}if(a.voice&&o.push({type:"voice",mimeType:a.voice.mime_type??"audio/ogg",duration:a.voice.duration,fileSize:a.voice.file_size,getBuffer:()=>this.downloadFile(t,a.voice.file_id)}),a.audio&&o.push({type:"audio",mimeType:a.audio.mime_type??"audio/mpeg",fileName:a.audio.file_name,duration:a.audio.duration,fileSize:a.audio.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.audio.file_id)}),a.document&&o.push({type:"document",mimeType:a.document.mime_type,fileName:a.document.file_name,fileSize:a.document.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.document.file_id)}),a.video&&o.push({type:"video",mimeType:a.video.mime_type??"video/mp4",fileName:a.video.file_name,duration:a.video.duration,fileSize:a.video.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.video.file_id)}),a.video_note&&o.push({type:"video_note",mimeType:"video/mp4",duration:a.video_note.duration,fileSize:a.video_note.file_size,getBuffer:()=>this.downloadFile(t,a.video_note.file_id)}),a.sticker){if(this.config.actions?.sticker)try{g({fileId:a.sticker.file_id,fileUniqueId:a.sticker.file_unique_id,emoji:a.sticker.emoji,setName:a.sticker.set_name,description:a.sticker.emoji?`${a.sticker.emoji} sticker${a.sticker.set_name?` from ${a.sticker.set_name}`:""}`:a.sticker.set_name??"sticker"})}catch(t){m.warn(`Failed to cache sticker: ${t}`)}o.push({type:"sticker",mimeType:a.sticker.is_animated?"application/x-tgsticker":a.sticker.is_video?"video/webm":"image/webp",metadata:{emoji:a.sticker.emoji,setName:a.sticker.set_name},getBuffer:()=>this.downloadFile(t,a.sticker.file_id)})}return a.location&&o.push({type:"location",metadata:{latitude:a.location.latitude,longitude:a.location.longitude},getBuffer:async()=>Buffer.alloc(0)}),a.contact&&o.push({type:"contact",metadata:{phoneNumber:a.contact.phone_number,firstName:a.contact.first_name,lastName:a.contact.last_name,userId:a.contact.user_id},getBuffer:async()=>Buffer.alloc(0)}),{chatId:e,userId:i,channelName:"telegram",text:s,attachments:o,username:n,rawContext:t}}async downloadFile(t,e){const i=await t.api.getFile(e),n=`https://api.telegram.org/file/bot${this.config.botToken}/${i.file_path}`,a=await fetch(n);if(!a.ok)throw new Error(`Failed to download file: ${a.statusText}`);return Buffer.from(await a.arrayBuffer())}async sendChunked(t,e){const i=a(e,4096);for(const n of i)try{await t.reply(n,{parse_mode:"HTML"})}catch{const i=p(e,4096);for(const e of i)await t.reply(e);break}}}function p(t,e){if(t.length<=e)return[t];const i=[];let n=t;for(;n.length>0;){if(n.length<=e){i.push(n);break}let t=n.lastIndexOf("\n",e);t<=0&&(t=e),i.push(n.slice(0,t)),n=n.slice(t).trimStart()}return i}
1
+ import{Bot as e,InputFile as t}from"grammy";import{validateChannelUser as i}from"../../../auth/auth-middleware.js";import{parseMediaLines as o}from"../../../utils/media-response.js";import{markdownToTelegramHtmlChunks as a}from"../../../utils/telegram-format.js";import{createLogger as n}from"../../../utils/logger.js";import{sendMessageTelegram as r}from"./send.js";import{reactMessageTelegram as s,resolveReactionLevel as c}from"./reactions.js";import{editMessageTelegram as d,deleteMessageTelegram as l}from"./edit-delete.js";import{sendStickerTelegram as m,cacheSticker as f}from"./stickers.js";import{validateButtonsForChatId as u}from"./inline-buttons.js";const h=n("Telegram");export class TelegramChannel{name="telegram";bot;config;accountId;tokenDb;constructor(t,i,o){this.config=t,this.accountId=i,this.tokenDb=o,this.bot=new e(t.botToken)}async start(e){this.bot.on("message",t=>{this.handleIncoming(t,e)}),this.bot.on("callback_query:data",t=>{const i=t.callbackQuery.data,o=String(t.from?.id??"unknown"),a=String(t.chat?.id??t.callbackQuery.message?.chat?.id??"unknown"),n=t.from?.username;t.answerCallbackQuery().catch(()=>{});e({chatId:a,userId:o,channelName:"telegram",text:i,attachments:[],username:n,rawContext:t}).then(async e=>{e&&e.trim()&&await this.sendText(a,e)}).catch(e=>{h.error(`Error handling callback from ${o}: ${e}`)})}),h.info("Starting Telegram bot..."),this.bot.start({onStart:e=>{h.info(`Telegram bot started: @${e.username}`)}}).catch(e=>{String(e).includes("Aborted delay")?h.debug("Telegram polling stopped"):h.error(`Telegram bot polling error: ${e}`)})}async sendText(e,t){try{await r(e,t,{token:this.config.botToken,accountId:this.accountId,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}catch(e){throw h.error(`Failed to send message: ${e}`),e}}async setTyping(e){await this.bot.api.sendChatAction(e,"typing").catch(()=>{})}async clearTyping(e){}async sendButtons(e,t,i){const o=i.map(e=>e.map(e=>({text:e.text,callback_data:e.callbackData??e.text})));try{u(o,this.config,e)}catch(i){return h.warn(`Button validation failed: ${i}, sending without buttons`),void await this.sendText(e,t)}await r(e,t,{token:this.config.botToken,accountId:this.accountId,buttons:o,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}async sendAudio(e,i,o){const a=new t(i);o?await this.bot.api.sendVoice(e,a):await this.bot.api.sendAudio(e,a)}async reactMessage(e,t,i,o){const{level:a}=c(this.config);"off"!==a?await s(e,t,i,this.config.botToken,{remove:o}):h.debug("Reactions disabled for this account")}async editMessage(e,t,i,o){const a=o?.map(e=>({text:e.text,callback_data:e.callbackData??e.text}));await d(e,t,i,this.config.botToken,{buttons:a?[a]:void 0,linkPreview:this.config.linkPreview})}async deleteMessage(e,t){await l(e,t,this.config.botToken,this.config.retry)}async sendSticker(e,t){await m(e,t,this.config.botToken,{retry:this.config.retry})}async stop(){try{await this.bot.stop(),h.info("Telegram bot stopped"),await new Promise(e=>setTimeout(e,100))}catch(e){h.warn(`Error stopping Telegram bot: ${e}`)}}async handleIncoming(e,a){const n=String(e.from?.id??"unknown"),r=String(e.chat?.id??"unknown"),s=e.from?.username,c=i(this.tokenDb,n,"telegram",this.config.dmPolicy,this.config.allowFrom);if(!c.authorized)return h.warn(`Unauthorized message from ${n} (@${s})`),void await e.reply(c.reason??"Not authorized.");try{const i=await this.buildIncomingMessage(e,r,n,s),c=await a(i),{textParts:d,mediaEntries:l}=o(c);for(const i of l)try{const o=new t(i.path);i.asVoice?await e.replyWithVoice(o):await e.replyWithAudio(o)}catch(e){h.error(`Failed to send audio: ${e}`)}const m=d.join("\n").trim();m&&await this.sendChunked(e,m)}catch(t){h.error(`Error handling message from ${n}: ${t}`),await e.reply("An error occurred while processing your message.").catch(()=>{})}}async buildIncomingMessage(e,t,i,o){const a=e.message,n=[];let r=a.text??a.caption??void 0;if(a.photo&&a.photo.length>0){const t=a.photo[a.photo.length-1];n.push({type:"image",mimeType:"image/jpeg",fileSize:t.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,t.file_id)})}if(a.voice&&n.push({type:"voice",mimeType:a.voice.mime_type??"audio/ogg",duration:a.voice.duration,fileSize:a.voice.file_size,getBuffer:()=>this.downloadFile(e,a.voice.file_id)}),a.audio&&n.push({type:"audio",mimeType:a.audio.mime_type??"audio/mpeg",fileName:a.audio.file_name,duration:a.audio.duration,fileSize:a.audio.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,a.audio.file_id)}),a.document&&n.push({type:"document",mimeType:a.document.mime_type,fileName:a.document.file_name,fileSize:a.document.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,a.document.file_id)}),a.video&&n.push({type:"video",mimeType:a.video.mime_type??"video/mp4",fileName:a.video.file_name,duration:a.video.duration,fileSize:a.video.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,a.video.file_id)}),a.video_note&&n.push({type:"video_note",mimeType:"video/mp4",duration:a.video_note.duration,fileSize:a.video_note.file_size,getBuffer:()=>this.downloadFile(e,a.video_note.file_id)}),a.sticker){if(this.config.actions?.sticker)try{f({fileId:a.sticker.file_id,fileUniqueId:a.sticker.file_unique_id,emoji:a.sticker.emoji,setName:a.sticker.set_name,description:a.sticker.emoji?`${a.sticker.emoji} sticker${a.sticker.set_name?` from ${a.sticker.set_name}`:""}`:a.sticker.set_name??"sticker"})}catch(e){h.warn(`Failed to cache sticker: ${e}`)}n.push({type:"sticker",mimeType:a.sticker.is_animated?"application/x-tgsticker":a.sticker.is_video?"video/webm":"image/webp",metadata:{emoji:a.sticker.emoji,setName:a.sticker.set_name},getBuffer:()=>this.downloadFile(e,a.sticker.file_id)})}return a.location&&n.push({type:"location",metadata:{latitude:a.location.latitude,longitude:a.location.longitude},getBuffer:async()=>Buffer.alloc(0)}),a.contact&&n.push({type:"contact",metadata:{phoneNumber:a.contact.phone_number,firstName:a.contact.first_name,lastName:a.contact.last_name,userId:a.contact.user_id},getBuffer:async()=>Buffer.alloc(0)}),{chatId:t,userId:i,channelName:"telegram",text:r,attachments:n,username:o,rawContext:e}}async downloadFile(e,t){const i=await e.api.getFile(t),o=`https://api.telegram.org/file/bot${this.config.botToken}/${i.file_path}`,a=await fetch(o);if(!a.ok)throw new Error(`Failed to download file: ${a.statusText}`);return Buffer.from(await a.arrayBuffer())}async sendChunked(e,t){const i=a(t,4096);for(const o of i)try{await e.reply(o,{parse_mode:"HTML"})}catch{const i=g(t,4096);for(const t of i)await e.reply(t);break}}}function g(e,t){if(e.length<=t)return[e];const i=[];let o=e;for(;o.length>0;){if(o.length<=t){i.push(o);break}let e=o.lastIndexOf("\n",t);e<=0&&(e=t),i.push(o.slice(0,e)),o=o.slice(e).trimStart()}return i}
@@ -9,8 +9,6 @@ export declare class WebChatChannel implements ChannelAdapter {
9
9
  readonly name = "webchat";
10
10
  private connections;
11
11
  private onMessage;
12
- private typingIntervals;
13
- private inflightCount;
14
12
  private pendingMessages;
15
13
  start(onMessage: MessageHandler): Promise<void>;
16
14
  registerConnection(chatId: string, ws: WebSocket): void;
@@ -23,6 +21,7 @@ export declare class WebChatChannel implements ChannelAdapter {
23
21
  /**
24
22
  * Handle a chat message arriving from a paired node.
25
23
  * Fire-and-forget: caller does not await.
24
+ * Typing lifecycle is fully managed by TypingCoordinator via server.ts.
26
25
  */
27
26
  handleNodeChat(chatId: string, nodeId: string, msg: {
28
27
  text?: string;
@@ -30,14 +29,12 @@ export declare class WebChatChannel implements ChannelAdapter {
30
29
  }): Promise<void>;
31
30
  sendText(chatId: string, text: string): Promise<void>;
32
31
  sendButtons(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
32
+ /** One-shot: send a single typing=true signal to the webchat client. */
33
33
  setTyping(chatId: string): Promise<void>;
34
+ /** One-shot: send typing=false to the webchat client. */
34
35
  clearTyping(chatId: string): Promise<void>;
35
- releaseTyping(chatId: string): Promise<void>;
36
36
  sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
37
37
  stop(): Promise<void>;
38
- private startTypingInterval;
39
- private stopTypingInterval;
40
- private resendTypingIfActive;
41
38
  private sendWs;
42
39
  private enqueuePending;
43
40
  private flushPending;
@@ -1 +1 @@
1
- import{readFileSync as t}from"node:fs";import{basename as e,extname as n}from"node:path";import{parseMediaLines as s}from"../../utils/media-response.js";import{createLogger as i}from"../../utils/logger.js";const a=i("WebChat");export function buildWebChatId(t,e){return`${(t||"node").toLowerCase().replace(/[^a-z0-9_-]/g,"_").replace(/_+/g,"_").replace(/^_|_$/g,"")||"node"}-${e.slice(0,8)}`}export class WebChatChannel{name="webchat";connections=new Map;onMessage=null;typingIntervals=new Map;inflightCount=new Map;pendingMessages=new Map;async start(t){this.onMessage=t,a.info("WebChat channel started (virtual — connections managed by Nostromo)")}registerConnection(t,e){const n=!this.connections.has(t);this.connections.set(t,e),n&&a.info(`WebChat connection registered: ${t}`),this.flushPending(t)}unregisterConnection(t){this.connections.delete(t)&&a.info(`WebChat connection unregistered: ${t}`)}unregisterByWs(t){for(const[e,n]of this.connections)n===t&&(this.connections.delete(e),a.info(`WebChat connection unregistered: ${e}`))}async handleNodeChat(t,e,n){if(!this.onMessage)return void a.warn("WebChat: message received but no onMessage handler registered");const i=[];if(n.attachments&&Array.isArray(n.attachments))for(const t of n.attachments)i.push({type:t.type,mimeType:t.mimeType,fileName:t.fileName,duration:t.duration,caption:t.caption,getBuffer:()=>Promise.resolve(Buffer.from(t.data,"base64"))});const o={chatId:t,userId:e,channelName:"webchat",text:n.text,attachments:i,username:t};this.startTypingInterval(t);try{const e=await this.onMessage(o),{textParts:n,mediaEntries:i}=s(e);for(const e of i)try{await this.sendAudio(t,e.path,e.asVoice)}catch(e){a.error(`WebChat: failed to send audio to ${t}: ${e}`)}const r=n.join("\n").trim();r&&this.sendWs(t,{type:"chat_response",role:"assistant",text:r}),this.resendTypingIfActive(t)}catch(e){a.error(`WebChat: error handling message from ${t}: ${e}`),this.sendWs(t,{type:"chat_response",role:"assistant",text:"Error processing message."})}}async sendText(t,e){this.sendWs(t,{type:"chat_message",role:"assistant",text:e}),this.resendTypingIfActive(t)}async sendButtons(t,e,n){const s=n.flat();this.sendWs(t,{type:"chat_message",role:"assistant",text:e,buttons:s.map(t=>({text:t.text,callbackData:t.callbackData??t.text,...t.url?{url:t.url}:{}}))}),this.resendTypingIfActive(t)}async setTyping(t){this.typingIntervals.has(t)?this.sendWs(t,{type:"typing_indicator",typing:!0},!1):this.startTypingInterval(t)}async clearTyping(t){this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.sendWs(t,{type:"typing_indicator",typing:!1},!1)}async releaseTyping(t){this.stopTypingInterval(t)}async sendAudio(s,i,o){try{const a=t(i),r=e(i),h=n(i).toLowerCase().replace(".",""),g={mp3:"audio/mpeg",ogg:"audio/ogg",opus:"audio/ogg",wav:"audio/wav",m4a:"audio/mp4",flac:"audio/flac"}[h]||"audio/mpeg";this.sendWs(s,{type:"chat_media",role:"assistant",mediaType:"audio",mimeType:g,fileName:r,data:a.toString("base64"),asVoice:o??!1}),this.resendTypingIfActive(s)}catch(t){a.error(`WebChat: failed to read audio file ${i}: ${t}`)}}async stop(){for(const[t,e]of this.typingIntervals)clearInterval(e),this.sendWs(t,{type:"typing_indicator",typing:!1},!1);this.typingIntervals.clear(),this.inflightCount.clear(),this.onMessage=null,a.info("WebChat channel stopped")}startTypingInterval(t){const e=(this.inflightCount.get(t)??0)+1;if(this.inflightCount.set(t,e),e>1)return;const n=this.typingIntervals.get(t);n&&(clearInterval(n),this.typingIntervals.delete(t)),this.sendWs(t,{type:"typing_indicator",typing:!0},!1);const s=setInterval(()=>{(this.inflightCount.get(t)??0)>0?this.sendWs(t,{type:"typing_indicator",typing:!0},!1):(clearInterval(s),this.typingIntervals.delete(t),this.sendWs(t,{type:"typing_indicator",typing:!1},!1))},4e3);this.typingIntervals.set(t,s)}stopTypingInterval(t){const e=(this.inflightCount.get(t)??1)-1;if(e>0)this.inflightCount.set(t,e);else{this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.sendWs(t,{type:"typing_indicator",typing:!1},!1)}}resendTypingIfActive(t){this.typingIntervals.has(t)&&this.sendWs(t,{type:"typing_indicator",typing:!0},!1)}sendWs(t,e,n=!0){const s=this.connections.get(t);if(s&&s.readyState===s.OPEN)try{s.send(JSON.stringify({...e,chatId:t}))}catch(e){a.error(`WebChat: failed to send to ${t}: ${e}`)}else n&&this.enqueuePending(t,e)}enqueuePending(t,e){let n=this.pendingMessages.get(t);for(n||(n=[],this.pendingMessages.set(t,n)),n.push(e);n.length>10;)n.shift();a.info(`WebChat: queued pending message for ${t} (${n.length}/10)`)}flushPending(t){const e=this.pendingMessages.get(t);if(e&&0!==e.length){a.info(`WebChat: flushing ${e.length} pending message(s) for ${t}`),this.pendingMessages.delete(t);for(const n of e)this.sendWs(t,n,!1)}}}
1
+ import{readFileSync as e}from"node:fs";import{basename as t,extname as s}from"node:path";import{parseMediaLines as n}from"../../utils/media-response.js";import{createLogger as a}from"../../utils/logger.js";const o=a("WebChat");export function buildWebChatId(e,t){return`${(e||"node").toLowerCase().replace(/[^a-z0-9_-]/g,"_").replace(/_+/g,"_").replace(/^_|_$/g,"")||"node"}-${t.slice(0,8)}`}export class WebChatChannel{name="webchat";connections=new Map;onMessage=null;pendingMessages=new Map;async start(e){this.onMessage=e,o.info("WebChat channel started (virtual — connections managed by Nostromo)")}registerConnection(e,t){const s=!this.connections.has(e);this.connections.set(e,t),s&&o.info(`WebChat connection registered: ${e}`),this.flushPending(e)}unregisterConnection(e){this.connections.delete(e)&&o.info(`WebChat connection unregistered: ${e}`)}unregisterByWs(e){for(const[t,s]of this.connections)s===e&&(this.connections.delete(t),o.info(`WebChat connection unregistered: ${t}`))}async handleNodeChat(e,t,s){if(!this.onMessage)return void o.warn("WebChat: message received but no onMessage handler registered");const a=[];if(s.attachments&&Array.isArray(s.attachments))for(const e of s.attachments)a.push({type:e.type,mimeType:e.mimeType,fileName:e.fileName,duration:e.duration,caption:e.caption,getBuffer:()=>Promise.resolve(Buffer.from(e.data,"base64"))});const i={chatId:e,userId:t,channelName:"webchat",text:s.text,attachments:a,username:e};try{const t=await this.onMessage(i),{textParts:s,mediaEntries:a}=n(t);for(const t of a)try{await this.sendAudio(e,t.path,t.asVoice)}catch(t){o.error(`WebChat: failed to send audio to ${e}: ${t}`)}const r=s.join("\n").trim();r&&this.sendWs(e,{type:"chat_response",role:"assistant",text:r})}catch(t){o.error(`WebChat: error handling message from ${e}: ${t}`),this.sendWs(e,{type:"chat_response",role:"assistant",text:"Error processing message."})}}async sendText(e,t){this.sendWs(e,{type:"chat_message",role:"assistant",text:t})}async sendButtons(e,t,s){const n=s.flat();this.sendWs(e,{type:"chat_message",role:"assistant",text:t,buttons:n.map(e=>({text:e.text,callbackData:e.callbackData??e.text,...e.url?{url:e.url}:{}}))})}async setTyping(e){this.sendWs(e,{type:"typing_indicator",typing:!0},!1)}async clearTyping(e){this.sendWs(e,{type:"typing_indicator",typing:!1},!1)}async sendAudio(n,a,i){try{const o=e(a),r=t(a),c=s(a).toLowerCase().replace(".",""),d={mp3:"audio/mpeg",ogg:"audio/ogg",opus:"audio/ogg",wav:"audio/wav",m4a:"audio/mp4",flac:"audio/flac"}[c]||"audio/mpeg";this.sendWs(n,{type:"chat_media",role:"assistant",mediaType:"audio",mimeType:d,fileName:r,data:o.toString("base64"),asVoice:i??!1})}catch(e){o.error(`WebChat: failed to read audio file ${a}: ${e}`)}}async stop(){this.onMessage=null,o.info("WebChat channel stopped")}sendWs(e,t,s=!0){const n=this.connections.get(e);if(n&&n.readyState===n.OPEN)try{n.send(JSON.stringify({...t,chatId:e}))}catch(t){o.error(`WebChat: failed to send to ${e}: ${t}`)}else s&&this.enqueuePending(e,t)}enqueuePending(e,t){let s=this.pendingMessages.get(e);for(s||(s=[],this.pendingMessages.set(e,s)),s.push(t);s.length>10;)s.shift();o.info(`WebChat: queued pending message for ${e} (${s.length}/10)`)}flushPending(e){const t=this.pendingMessages.get(e);if(t&&0!==t.length){o.info(`WebChat: flushing ${t.length} pending message(s) for ${e}`),this.pendingMessages.delete(e);for(const s of t)this.sendWs(e,s,!1)}}}
@@ -12,10 +12,7 @@ export declare class WhatsAppChannel implements ChannelAdapter {
12
12
  private qrCallback;
13
13
  private connected;
14
14
  private stopping;
15
- private typingIntervals;
16
- private inflightTyping;
17
- private inflightCount;
18
- constructor(config: WhatsAppAccountConfig, inflightTyping?: boolean);
15
+ constructor(config: WhatsAppAccountConfig);
19
16
  setQrCallback(cb: QrCallback): void;
20
17
  isConnected(): boolean;
21
18
  start(onMessage: MessageHandler): Promise<void>;
@@ -27,13 +24,10 @@ export declare class WhatsAppChannel implements ChannelAdapter {
27
24
  private handleIncoming;
28
25
  private checkAccess;
29
26
  sendText(chatId: string, text: string): Promise<void>;
27
+ /** One-shot: send a single "composing" signal to WhatsApp. */
30
28
  setTyping(chatId: string): Promise<void>;
31
- /** Re-send composing if an interval is active (another handler is still processing). */
32
- private resendTypingIfActive;
29
+ /** One-shot: send "paused" to WhatsApp to stop the typing indicator. */
33
30
  clearTyping(chatId: string): Promise<void>;
34
- releaseTyping(chatId: string): Promise<void>;
35
- private startTypingInterval;
36
- private stopTypingInterval;
37
31
  sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
38
32
  stop(): Promise<void>;
39
33
  }
@@ -1 +1 @@
1
- import{DisconnectReason as t,fetchLatestBaileysVersion as e,makeCacheableSignalKeyStore as s,makeWASocket as n,useMultiFileAuthState as i}from"@whiskeysockets/baileys";import{mkdirSync as o,existsSync as a,readFileSync as c}from"node:fs";import{resolve as r}from"node:path";import{renderQrPngBase64 as h}from"./qr-image.js";import{convertMarkdownTables as l}from"../../utils/markdown/tables.js";import{chunkText as p}from"../../utils/chunk.js";import{createLogger as g}from"../../utils/logger.js";const d=g("WhatsApp");export class WhatsAppChannel{name="whatsapp";sock=null;config;qrCallback=null;connected=!1;stopping=!1;typingIntervals=new Map;inflightTyping;inflightCount=new Map;constructor(t,e=!0){this.config=t,this.inflightTyping=e}setQrCallback(t){this.qrCallback=t}isConnected(){return this.connected}async start(t){const e=r(this.config.authDir);a(e)||o(e,{recursive:!0}),await this.connect(e,t)}async connect(o,a){const{state:c,saveCreds:r}=await i(o),{version:l}=await e(),p={level:"silent",trace:()=>{},debug:()=>{},info:()=>{},warn:()=>{},error:()=>{},fatal:()=>{},child:()=>p};this.sock=n({auth:{creds:c.creds,keys:s(c.keys,p)},version:l,logger:p,printQRInTerminal:!1,browser:["GrabMeABeer","Web","1.0"],syncFullHistory:!1,markOnlineOnConnect:!1}),this.sock.ev.on("creds.update",r),this.sock.ev.on("connection.update",async e=>{try{const{connection:s,lastDisconnect:n,qr:i}=e;if(i){d.info("QR code received, rendering...");const t=`data:image/png;base64,${await h(i)}`;this.qrCallback?.(t,!1)}if("open"===s&&(this.connected=!0,d.info("WhatsApp connected"),this.qrCallback?.(null,!0)),"close"===s){this.connected=!1;const e=n?.error?.output?.statusCode??n?.error?.status;e===t.loggedOut?(d.warn("WhatsApp session logged out. Re-scan QR via Nostromo."),this.qrCallback?.(null,!1,"Session logged out. Please re-scan QR code.")):this.stopping||(d.info(`WhatsApp disconnected (code ${e}), reconnecting...`),setTimeout(()=>this.connect(o,a),3e3))}}catch(t){d.error(`connection.update handler error: ${t}`)}}),this.sock.ws&&"function"==typeof this.sock.ws.on&&this.sock.ws.on("error",t=>{d.error(`WebSocket error: ${t.message}`)}),this.sock.ev.on("messages.upsert",({messages:t,type:e})=>{if("notify"===e)for(const e of t){if(!e.message||e.key.fromMe)continue;const t=e.key.remoteJid;if(!t)continue;const s=t.replace(/@s\.whatsapp\.net$/,""),n=e.message.conversation??e.message.extendedTextMessage?.text??void 0;if(!this.checkAccess(s)){d.warn(`Unauthorized message from ${s}`),this.sock?.sendMessage(t,{text:"Not authorized."});continue}if(!n)continue;const i={chatId:t,userId:s,channelName:"whatsapp",text:n,attachments:[],username:e.pushName??void 0};this.handleIncoming(i,t,s,a)}})}async handleIncoming(t,e,s,n){this.startTypingInterval(e);try{const s=await n(t),i=l(s,"code"),o=p(i,4e3);for(const t of o)await(this.sock?.sendMessage(e,{text:t}));await this.resendTypingIfActive(e)}catch(t){d.error(`Error handling message from ${s}: ${t}`),this.stopTypingInterval(e)}}checkAccess(t){const e=this.config.dmPolicy||"allowlist";if("open"===e)return!0;if("allowlist"===e){return(this.config.allowFrom??[]).some(e=>String(e)===t)}return!0}async sendText(t,e){if(!this.sock)return;const s=l(e,"code"),n=p(s,4e3);for(const e of n)await this.sock.sendMessage(t,{text:e});await this.resendTypingIfActive(t)}async setTyping(t){this.sock&&(this.typingIntervals.has(t)?await this.sock.sendPresenceUpdate("composing",t).catch(()=>{}):this.startTypingInterval(t))}async resendTypingIfActive(t){this.typingIntervals.has(t)&&await(this.sock?.sendPresenceUpdate("composing",t).catch(()=>{}))}async clearTyping(t){this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.sock?.sendPresenceUpdate("paused",t).catch(()=>{})}async releaseTyping(t){this.stopTypingInterval(t)}startTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??0)+1;if(this.inflightCount.set(t,e),e>1)return}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.sock?.sendPresenceUpdate("composing",t).catch(()=>{});const s=setInterval(()=>{const e=this.inflightCount.get(t)??0;!this.inflightTyping||e>0?this.sock?.sendPresenceUpdate("composing",t).catch(()=>{}):(clearInterval(s),this.typingIntervals.delete(t),this.sock?.sendPresenceUpdate("paused",t).catch(()=>{}))},4e3);this.typingIntervals.set(t,s)}stopTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??1)-1;return void(e>0?this.inflightCount.set(t,e):this.inflightCount.delete(t))}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.sock?.sendPresenceUpdate("paused",t).catch(()=>{})}async sendAudio(t,e,s){if(!this.sock)return;const n=c(e);s?await this.sock.sendMessage(t,{audio:n,mimetype:"audio/ogg; codecs=opus",ptt:!0}):await this.sock.sendMessage(t,{audio:n,mimetype:"audio/mpeg"}),await this.resendTypingIfActive(t)}async stop(){this.stopping=!0;for(const[,t]of this.typingIntervals)clearInterval(t);this.typingIntervals.clear(),this.inflightCount.clear();try{this.sock?.ws?.close()}catch{}this.sock=null,this.connected=!1,d.info("WhatsApp stopped")}}
1
+ import{DisconnectReason as e,fetchLatestBaileysVersion as s,makeCacheableSignalKeyStore as t,makeWASocket as o,useMultiFileAuthState as n}from"@whiskeysockets/baileys";import{mkdirSync as c,existsSync as i,readFileSync as a}from"node:fs";import{resolve as r}from"node:path";import{renderQrPngBase64 as h}from"./qr-image.js";import{convertMarkdownTables as d}from"../../utils/markdown/tables.js";import{chunkText as l}from"../../utils/chunk.js";import{createLogger as p}from"../../utils/logger.js";const u=p("WhatsApp");export class WhatsAppChannel{name="whatsapp";sock=null;config;qrCallback=null;connected=!1;stopping=!1;constructor(e){this.config=e}setQrCallback(e){this.qrCallback=e}isConnected(){return this.connected}async start(e){const s=r(this.config.authDir);i(s)||c(s,{recursive:!0}),await this.connect(s,e)}async connect(c,i){const{state:a,saveCreds:r}=await n(c),{version:d}=await s(),l={level:"silent",trace:()=>{},debug:()=>{},info:()=>{},warn:()=>{},error:()=>{},fatal:()=>{},child:()=>l};this.sock=o({auth:{creds:a.creds,keys:t(a.keys,l)},version:d,logger:l,printQRInTerminal:!1,browser:["GrabMeABeer","Web","1.0"],syncFullHistory:!1,markOnlineOnConnect:!1}),this.sock.ev.on("creds.update",r),this.sock.ev.on("connection.update",async s=>{try{const{connection:t,lastDisconnect:o,qr:n}=s;if(n){u.info("QR code received, rendering...");const e=`data:image/png;base64,${await h(n)}`;this.qrCallback?.(e,!1)}if("open"===t&&(this.connected=!0,u.info("WhatsApp connected"),this.qrCallback?.(null,!0)),"close"===t){this.connected=!1;const s=o?.error?.output?.statusCode??o?.error?.status;s===e.loggedOut?(u.warn("WhatsApp session logged out. Re-scan QR via Nostromo."),this.qrCallback?.(null,!1,"Session logged out. Please re-scan QR code.")):this.stopping||(u.info(`WhatsApp disconnected (code ${s}), reconnecting...`),setTimeout(()=>this.connect(c,i),3e3))}}catch(e){u.error(`connection.update handler error: ${e}`)}}),this.sock.ws&&"function"==typeof this.sock.ws.on&&this.sock.ws.on("error",e=>{u.error(`WebSocket error: ${e.message}`)}),this.sock.ev.on("messages.upsert",({messages:e,type:s})=>{if("notify"===s)for(const s of e){if(!s.message||s.key.fromMe)continue;const e=s.key.remoteJid;if(!e)continue;const t=e.replace(/@s\.whatsapp\.net$/,""),o=s.message.conversation??s.message.extendedTextMessage?.text??void 0;if(!this.checkAccess(t)){u.warn(`Unauthorized message from ${t}`),this.sock?.sendMessage(e,{text:"Not authorized."});continue}if(!o)continue;const n={chatId:e,userId:t,channelName:"whatsapp",text:o,attachments:[],username:s.pushName??void 0};this.handleIncoming(n,e,t,i)}})}async handleIncoming(e,s,t,o){try{const t=await o(e),n=d(t,"code"),c=l(n,4e3);for(const e of c)await(this.sock?.sendMessage(s,{text:e}))}catch(e){u.error(`Error handling message from ${t}: ${e}`)}}checkAccess(e){const s=this.config.dmPolicy||"allowlist";if("open"===s)return!0;if("allowlist"===s){return(this.config.allowFrom??[]).some(s=>String(s)===e)}return!0}async sendText(e,s){if(!this.sock)return;const t=d(s,"code"),o=l(t,4e3);for(const s of o)await this.sock.sendMessage(e,{text:s})}async setTyping(e){this.sock&&await this.sock.sendPresenceUpdate("composing",e).catch(()=>{})}async clearTyping(e){this.sock?.sendPresenceUpdate("paused",e).catch(()=>{})}async sendAudio(e,s,t){if(!this.sock)return;const o=a(s);t?await this.sock.sendMessage(e,{audio:o,mimetype:"audio/ogg; codecs=opus",ptt:!0}):await this.sock.sendMessage(e,{audio:o,mimetype:"audio/mpeg"})}async stop(){this.stopping=!0;try{this.sock?.ws?.close()}catch{}this.sock=null,this.connected=!1,u.info("WhatsApp stopped")}}
@@ -0,0 +1,43 @@
1
+ import type { ChannelManager } from "./channel-manager.js";
2
+ /**
3
+ * Centralised typing-indicator lifecycle manager.
4
+ *
5
+ * Owns the refcount and the renewal interval for every (channel, chatId) pair.
6
+ * Channel adapters are reduced to dumb one-shot signal senders:
7
+ * - setTyping() → fire a single platform-native "typing" signal
8
+ * - clearTyping() → fire a single platform-native "stop typing" signal
9
+ *
10
+ * The coordinator calls these at the right time; adapters no longer manage
11
+ * intervals, refcounts, or any typing state of their own.
12
+ */
13
+ export declare class TypingCoordinator {
14
+ private channelManager;
15
+ /** How many handlers are actively processing for this key. */
16
+ private refcounts;
17
+ /** Self-renewing intervals that pump typing signals. */
18
+ private intervals;
19
+ constructor(channelManager: ChannelManager);
20
+ private key;
21
+ /**
22
+ * A handler started processing a message — increment refcount and ensure
23
+ * a typing-renewal loop is running.
24
+ */
25
+ acquire(channel: string, chatId: string): void;
26
+ /**
27
+ * A handler finished processing — decrement refcount.
28
+ * The loop will self-destruct at the next tick when it sees count === 0.
29
+ */
30
+ release(channel: string, chatId: string): void;
31
+ /**
32
+ * Reinforce typing after a message send (Telegram cancels typing on send).
33
+ * Only sends if there are still active handlers.
34
+ */
35
+ reinforceIfActive(channel: string, chatId: string): void;
36
+ /** Force-clear everything — safety net for hard resets / shutdown. */
37
+ forceStop(channel: string, chatId: string): void;
38
+ /** Clear all intervals (called on server shutdown). */
39
+ stopAll(): void;
40
+ private startLoop;
41
+ private stopLoop;
42
+ }
43
+ //# sourceMappingURL=typing-coordinator.d.ts.map
@@ -0,0 +1 @@
1
+ import{createLogger as t}from"../utils/logger.js";t("TypingCoordinator");export class TypingCoordinator{channelManager;refcounts=new Map;intervals=new Map;constructor(t){this.channelManager=t}key(t,e){return`${t}:${e}`}acquire(t,e){const s=this.key(t,e),n=(this.refcounts.get(s)??0)+1;this.refcounts.set(s,n),1===n&&this.startLoop(t,e)}release(t,e){const s=this.key(t,e),n=Math.max(0,(this.refcounts.get(s)??0)-1);n>0?this.refcounts.set(s,n):(this.refcounts.delete(s),this.stopLoop(t,e))}reinforceIfActive(t,e){const s=this.key(t,e);(this.refcounts.get(s)??0)>0&&this.channelManager.setTyping(t,e).catch(()=>{})}forceStop(t,e){const s=this.key(t,e);this.refcounts.delete(s),this.stopLoop(t,e)}stopAll(){for(const[,t]of this.intervals)clearInterval(t);this.intervals.clear(),this.refcounts.clear()}startLoop(t,e){const s=this.key(t,e),n=this.intervals.get(s);n&&clearInterval(n),this.channelManager.setTyping(t,e).catch(()=>{});const r=setInterval(()=>{(this.refcounts.get(s)??0)>0?this.channelManager.setTyping(t,e).catch(()=>{}):this.stopLoop(t,e)},4e3);this.intervals.set(s,r)}stopLoop(t,e){const s=this.key(t,e),n=this.intervals.get(s);n&&(clearInterval(n),this.intervals.delete(s)),this.channelManager.clearTyping(t,e).catch(()=>{})}}
package/dist/server.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare class Server {
16
16
  private sessionDb;
17
17
  private nodeSignatureDb;
18
18
  private channelManager;
19
+ private typingCoordinator;
19
20
  private agentService;
20
21
  private sessionManager;
21
22
  private memoryManager;
package/dist/server.js CHANGED
@@ -1 +1 @@
1
- import{readFileSync as e,writeFileSync as t,mkdirSync as s,existsSync as n}from"node:fs";import{join as o,resolve as i}from"node:path";import{TokenDB as r}from"./auth/token-db.js";import{NodeSignatureDB as a}from"./auth/node-signature-db.js";import{SessionDB as h}from"./agent/session-db.js";import{ChannelManager as c}from"./gateway/channel-manager.js";import{TelegramChannel as g}from"./gateway/channels/telegram/index.js";import{WhatsAppChannel as l}from"./gateway/channels/whatsapp.js";import{WebChatChannel as m}from"./gateway/channels/webchat.js";import{MeshChannel as d}from"./gateway/channels/mesh.js";import{ResponsesChannel as f}from"./channels/responses.js";import{AgentService as p}from"./agent/agent-service.js";import{SessionManager as u}from"./agent/session-manager.js";import{buildPrompt as b,buildSystemPrompt as y}from"./agent/prompt-builder.js";import{ensureWorkspaceFiles as S,loadWorkspaceFiles as w}from"./agent/workspace-files.js";import{NodeRegistry as v}from"./gateway/node-registry.js";import{MemoryManager as M}from"./memory/memory-manager.js";import{MessageProcessor as R}from"./media/message-processor.js";import{loadSTTProvider as C}from"./stt/stt-loader.js";import{CommandRegistry as A}from"./commands/command-registry.js";import{NewCommand as T}from"./commands/new.js";import{CompactCommand as $}from"./commands/compact.js";import{ModelCommand as k,DefaultModelCommand as j}from"./commands/model.js";import{StopCommand as I}from"./commands/stop.js";import{HelpCommand as x}from"./commands/help.js";import{McpCommand as D}from"./commands/mcp.js";import{ModelsCommand as E}from"./commands/models.js";import{CoderCommand as _}from"./commands/coder.js";import{SandboxCommand as P}from"./commands/sandbox.js";import{SubAgentsCommand as N}from"./commands/subagents.js";import{CustomSubAgentsCommand as U}from"./commands/customsubagents.js";import{StatusCommand as F}from"./commands/status.js";import{ShowToolCommand as K}from"./commands/showtool.js";import{UsageCommand as O}from"./commands/usage.js";import{DebugA2UICommand as H}from"./commands/debuga2ui.js";import{DebugDynamicCommand as B}from"./commands/debugdynamic.js";import{CronService as L}from"./cron/cron-service.js";import{stripHeartbeatToken as Q,isHeartbeatContentEffectivelyEmpty as W}from"./cron/heartbeat-token.js";import{createServerToolsServer as z}from"./tools/server-tools.js";import{createCronToolsServer as G}from"./tools/cron-tools.js";import{createTTSToolsServer as q}from"./tools/tts-tools.js";import{createMemoryToolsServer as V}from"./tools/memory-tools.js";import{createBrowserToolsServer as J}from"./tools/browser-tools.js";import{createPicoToolsServer as X}from"./tools/pico-tools.js";import{createPlasmaClientToolsServer as Y}from"./tools/plasma-client-tools.js";import{createConceptToolsServer as Z}from"./tools/concept-tools.js";import{BrowserService as ee}from"./browser/browser-service.js";import{MemorySearch as te}from"./memory/memory-search.js";import{ConceptStore as se}from"./memory/concept-store.js";import{stripMediaLines as ne}from"./utils/media-response.js";import{loadConfig as oe,loadRawConfig as ie,backupConfig as re,resolveModelEntry as ae,modelRefName as he}from"./config.js";import{stringify as ce}from"yaml";import{createLogger as ge}from"./utils/logger.js";import{SessionErrorHandler as le}from"./agent/session-error-handler.js";import{initStickerCache as me}from"./gateway/channels/telegram/stickers.js";const de=ge("Server");export class Server{config;tokenDb;sessionDb;nodeSignatureDb;channelManager;agentService;sessionManager;memoryManager=null;nodeRegistry;messageProcessor;commandRegistry;serverToolsFactory;coderSkill;showToolUse=!1;subagentsEnabled=!0;customSubAgentsEnabled=!1;chatSettings=new Map;cronService=null;browserService;memorySearch=null;conceptStore=null;whatsappQr=null;whatsappConnected=!1;whatsappError;webChatChannel=null;meshChannel=null;autoRenewTimer=null;constructor(e){this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.tokenDb=new r(e.dbPath),this.sessionDb=new h(e.dbPath),this.nodeSignatureDb=new a(e.dbPath),this.nodeRegistry=new v,this.sessionManager=new u(this.sessionDb),e.memory.enabled&&(this.memoryManager=new M(e.memoryDir,e.timezone));const t=C(e),n=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new R(t,n),me(e.dataDir),this.commandRegistry=new A,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.serverToolsFactory=()=>z(()=>this.triggerRestart(),this.config.timezone),this.cronService=this.createCronService(),this.createMemorySearch(e),this.browserService=new ee,this.conceptStore=new se(e.dataDir);const i=o(e.dataDir,"CONCEPTS.md");this.conceptStore.importFromTurtleIfEmpty(i);const g=o(e.agent.workspacePath,".plasma"),l=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new p(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>q(()=>this.config):void 0,this.memorySearch?()=>V(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>J({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,l?()=>X({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=w(this.config.dataDir);return y({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Y({plasmaRootDir:g,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>Z(this.conceptStore):void 0),S(e.dataDir),s(o(e.agent.workspacePath,".claude","skills"),{recursive:!0}),s(o(e.agent.workspacePath,".claude","commands"),{recursive:!0}),s(o(e.agent.workspacePath,".plugins"),{recursive:!0})}getChatSetting(e,t){return this.chatSettings.get(e)?.[t]}setChatSetting(e,t,s){const n=this.chatSettings.get(e)??{};n[t]=s,this.chatSettings.set(e,n)}createCronService(){return new L({storePath:this.config.cronStorePath,enabled:this.config.cron.enabled,defaultTimezone:this.config.timezone,onExecute:e=>this.executeCronJob(e),sessionReaper:{pruneStaleSessions:e=>this.sessionDb.pruneStaleCronSessions(e)}})}createMemorySearch(e){if(!e.memory.enabled||!e.memory.search.enabled)return;const t=e.memory.search,s=ae(e,t.modelRef),n=(s?.useEnvVar?process.env[s.useEnvVar]:s?.apiKey)||"",o=s?.baseURL||"";if(n)return this.memorySearch=new te(e.memoryDir,e.dataDir,{apiKey:n,baseURL:o||void 0,embeddingModel:t.embeddingModel,embeddingDimensions:t.embeddingDimensions,prefixQuery:t.prefixQuery,prefixDocument:t.prefixDocument,updateDebounceMs:t.updateDebounceMs,embedIntervalMs:t.embedIntervalMs,maxResults:t.maxResults,maxSnippetChars:t.maxSnippetChars,maxInjectedChars:t.maxInjectedChars,rrfK:t.rrfK,l0:e.memory.l0??{enabled:!0,model:""}}),V(this.memorySearch);de.warn(`Memory search enabled but no API key found for modelRef "${t.modelRef}". Search will not start.`)}stopMemorySearch(){this.memorySearch&&(this.memorySearch.stop(),this.memorySearch=null)}collectBroadcastTargets(){const e=new Set,t=[],s=(s,n)=>{const o=`${s}:${n}`;e.has(o)||(e.add(o),t.push({channel:s,chatId:n}))},n=this.config.channels;for(const[e,t]of Object.entries(n)){if("responses"===e)continue;if(!t?.enabled||!this.channelManager.getAdapter(e))continue;const n=t.accounts;if(n)for(const t of Object.values(n)){const n=t?.allowFrom;if(Array.isArray(n))for(const t of n){const n=String(t).trim();n&&s(e,n)}}}for(const e of this.sessionDb.listSessions()){const t=e.sessionKey.indexOf(":");if(t<0)continue;const n=e.sessionKey.substring(0,t),o=e.sessionKey.substring(t+1);"cron"!==n&&o&&(this.channelManager.getAdapter(n)&&s(n,o))}return t}async executeCronJob(e){const t=this.config.cron.broadcastEvents;if(!t&&!this.channelManager.getAdapter(e.channel))return de.warn(`Cron job "${e.name}": skipped (channel "${e.channel}" is not active)`),{response:"",delivered:!1};if(e.suppressToken&&"__heartbeat"===e.name){const t=this.triageHeartbeat();if(!t.shouldRun)return de.info(`Cron job "${e.name}": skipped by triage (${t.reason})`),{response:"",delivered:!1};de.info(`Cron job "${e.name}": triage passed (${t.reason})`)}const s="boolean"==typeof e.isolated?e.isolated:this.config.cron.isolated,n=s?"cron":e.channel,o=s?e.name:e.chatId;de.info(`Cron job "${e.name}": session=${n}:${o}, delivery=${e.channel}:${e.chatId}${t?" (broadcast)":""}`);const i={chatId:o,userId:"cron",channelName:n,text:e.message,attachments:[]},r=`${n}:${o}`;try{const s=await this.handleMessage(i);let n=s;if(e.suppressToken){const t=this.config.cron.heartbeat.ackMaxChars,{shouldSkip:o,text:i}=Q(s,t);if(o)return de.info(`Cron job "${e.name}": response suppressed (HEARTBEAT_OK)`),{response:s,delivered:!1};n=i}if(t){const t=this.collectBroadcastTargets();de.info(`Cron job "${e.name}": broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(e=>this.channelManager.sendResponse(e.channel,e.chatId,n))),await Promise.allSettled(t.map(e=>this.channelManager.releaseTyping(e.channel,e.chatId)))}else await this.channelManager.sendResponse(e.channel,e.chatId,n),await this.channelManager.releaseTyping(e.channel,e.chatId).catch(()=>{});return{response:n,delivered:!0}}finally{this.agentService.destroySession(r),this.sessionManager.resetSession(r),this.memoryManager&&this.memoryManager.clearSession(r),de.info(`Cron job "${e.name}": ephemeral session destroyed (${r})`)}}triageHeartbeat(){const t=(new Date).getHours(),s=o(this.config.agent.workspacePath,"attention","pending_signals.md");if(n(s))try{const t=e(s,"utf-8").trim().split("\n").filter(e=>e.trim()&&!e.startsWith("#"));if(t.length>0)return{shouldRun:!0,reason:`${t.length} pending signal(s)`}}catch{}const i=o(this.config.dataDir,"HEARTBEAT.md");let r=!1;if(n(i))try{const t=e(i,"utf-8");r=!W(t)}catch{r=!0}const a=this.sessionDb.hasRecentActivity(3e5);return t>=23||t<7?a?{shouldRun:!0,reason:"night mode but recent messages"}:{shouldRun:!1,reason:"night mode, no activity"}:r||a?{shouldRun:!0,reason:a?"recent messages":"actionable heartbeat"}:{shouldRun:!1,reason:"no signals, no messages, empty heartbeat"}}setupCommands(){this.commandRegistry.register(new T),this.commandRegistry.register(new $);const e=()=>this.config.agent.picoAgent??{enabled:!1,modelRefs:[]};this.commandRegistry.register(new k(()=>this.config.models??[],async(e,t)=>{const s=this.config.models?.find(e=>e.id===t),n=this.config.agent.picoAgent,o=e=>!(!n?.enabled||!Array.isArray(n.modelRefs))&&n.modelRefs.some(t=>t.split(":")[0]===e),i=this.sessionManager.getModel(e)||this.config.agent.model,r=ae(this.config,i),a=o(r?.name??he(i)),h=o(s?.name??t);this.sessionManager.setModel(e,t),this.agentService.destroySession(e);const c=a||h;return c&&this.sessionManager.resetSession(e),c},e)),this.commandRegistry.register(new j(()=>this.config.models??[],async e=>{const s=this.config.models?.find(t=>t.id===e),n=s?`${s.name}:${s.id}`:e;this.config.agent.model=n;const o=this.config.agent.picoAgent;if(o?.enabled&&Array.isArray(o.modelRefs)){const t=s?.name??e,n=o.modelRefs.findIndex(e=>e.split(":")[0]===t);if(n>0){const[e]=o.modelRefs.splice(n,1);o.modelRefs.unshift(e)}}try{const e=i(process.cwd(),"config.yaml"),s=ie(e);s.agent||(s.agent={}),s.agent.model=n,o?.enabled&&Array.isArray(o.modelRefs)&&(s.agent.picoAgent||(s.agent.picoAgent={}),s.agent.picoAgent.modelRefs=[...o.modelRefs]),re(e),t(e,ce(s),"utf-8")}catch{}},e)),this.commandRegistry.register(new E(()=>this.config.models??[],e)),this.commandRegistry.register(new _(e=>this.getChatSetting(e,"coderSkill")??this.coderSkill,(e,t)=>this.setChatSetting(e,"coderSkill",t))),this.commandRegistry.register(new P(e=>this.getChatSetting(e,"sandboxEnabled")??!1,(e,t)=>this.setChatSetting(e,"sandboxEnabled",t))),this.commandRegistry.register(new K(e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,(e,t)=>this.setChatSetting(e,"showToolUse",t))),this.commandRegistry.register(new N(e=>this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,(e,t)=>this.setChatSetting(e,"subagentsEnabled",t))),this.commandRegistry.register(new U(e=>this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,(e,t)=>this.setChatSetting(e,"customSubAgentsEnabled",t),()=>this.config)),this.commandRegistry.register(new F(e=>{const t=this.sessionManager.getModel(e)||this.config.agent.model,s=this.config.agent.mainFallback,n=ae(this.config,t),o=s?ae(this.config,s):void 0;return{configDefaultModel:he(this.config.agent.model),agentModel:n?.id??t,agentModelName:n?.name??he(t),fallbackModel:o?.id??s,fallbackModelName:o?.name??(s?he(s):void 0),fallbackActive:this.agentService.isFallbackActive(e),coderSkill:this.getChatSetting(e,"coderSkill")??this.coderSkill,showToolUse:this.getChatSetting(e,"showToolUse")??this.showToolUse,subagentsEnabled:this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,customSubAgentsEnabled:this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,sandboxEnabled:this.getChatSetting(e,"sandboxEnabled")??!1,connectedNodes:this.nodeRegistry.listNodes().map(e=>({nodeId:e.nodeId,displayName:e.displayName,hostname:e.hostname}))}})),this.commandRegistry.register(new I(e=>this.agentService.interrupt(e))),this.commandRegistry.register(new D(()=>this.agentService.getToolServers())),this.commandRegistry.register(new x(()=>this.agentService.getSdkSlashCommands())),this.commandRegistry.register(new O(e=>this.agentService.getUsage(e))),this.commandRegistry.register(new H(this.nodeRegistry)),this.commandRegistry.register(new B(this.nodeRegistry))}registerChannels(){if(this.config.channels.telegram.enabled){const e=this.config.channels.telegram.accounts;for(const[t,s]of Object.entries(e)){if(!s.botToken){de.error(`Telegram account "${t}" has no botToken configured — skipping. Check your config.yaml.`);continue}const e=new g(s,t,this.tokenDb,this.config.agent.inflightTyping);this.channelManager.registerAdapter(e)}}if(this.config.channels.whatsapp.enabled){const e=this.config.channels.whatsapp.accounts;for(const[t,s]of Object.entries(e)){const e=new l(s,this.config.agent.inflightTyping);e.setQrCallback((e,t,s)=>{this.whatsappQr=e?{dataUrl:e,timestamp:Date.now()}:null,this.whatsappConnected=t,this.whatsappError=s}),this.channelManager.registerAdapter(e)}}if(this.config.channels.responses.enabled){const e=new f({host:this.config.host,port:this.config.channels.responses.port},this.tokenDb);this.channelManager.registerAdapter(e)}if(this.config.channels.mesh.enabled){const e=this.config.channels.mesh;e.agentId&&e.privateKey?(this.meshChannel||(this.meshChannel=new d({brokerUrl:e.brokerUrl,agentId:e.agentId,privateKey:e.privateKey,reconnectDelayMs:e.reconnectDelayMs}),this.meshChannel.onReply((e,t,s)=>{de.info(`[Mesh] Routing reply from ${t} to origin session ${e}`);const n=e.indexOf(":"),o=n>0?e.substring(0,n):"mesh",i=n>0?e.substring(n+1):t,r={chatId:i,userId:t,channelName:o,text:`[Mesh reply from ${t}] ${s}`,attachments:[],username:t};this.handleMessage(r).then(t=>{t&&t.trim()&&this.channelManager.sendResponse(o,i,t).catch(t=>{de.error(`[Mesh] Failed to deliver reply-response to ${e}: ${t}`)})}).catch(s=>{de.error(`[Mesh] Failed to process reply from ${t} in session ${e}: ${s}`)})})),this.channelManager.registerAdapter(this.meshChannel)):de.warn("Mesh channel enabled but agentId or privateKey not configured")}this.webChatChannel||(this.webChatChannel=new m),this.channelManager.registerAdapter(this.webChatChannel)}async handleMessage(e,t=!1){const s=`${e.channelName}:${e.chatId}`,n=e.text?e.text.length>15?e.text.slice(0,15)+"...":e.text:"[no text]";de.info(`Message from ${s} (user=${e.userId}, ${e.username??"?"}): ${n}`),this.config.verboseDebugLogs&&de.debug(`Message from ${s} full text: ${e.text??"[no text]"}`);try{if(e.text){if(e.text.startsWith("__ask:")){const t=e.text.substring(6);return this.agentService.resolveQuestion(s,t),""}if(this.agentService.hasPendingQuestion(s)){const t=e.text.trim();return this.agentService.resolveQuestion(s,t),`Selected: ${t}`}if(e.text.startsWith("__tool_perm:")){const t="__tool_perm:approve"===e.text;return this.agentService.resolvePermission(s,t),t?"Tool approved.":"Tool denied."}if(this.agentService.hasPendingPermission(s)){const t=e.text.trim().toLowerCase();if("approve"===t||"approva"===t)return this.agentService.resolvePermission(s,!0),"Tool approved.";if("deny"===t||"vieta"===t||"blocca"===t)return this.agentService.resolvePermission(s,!1),"Tool denied."}}const n=!0===e.__passthrough;if(!n&&e.text&&this.commandRegistry.isCommand(e.text)){const t=await this.commandRegistry.dispatch(e.text,{sessionKey:s,chatId:e.chatId,channelName:e.channelName,userId:e.userId});if(t)return t.passthrough?this.handleMessage({...e,text:t.passthrough,__passthrough:!0}):(t.resetSession?(this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s)):t.resetAgent&&this.agentService.destroySession(s),t.buttons&&t.buttons.length>0?(await this.channelManager.sendButtons(e.channelName,e.chatId,t.text,t.buttons),""):t.text)}if(!n&&e.text?.startsWith("/"))return this.agentService.isBusy(s)?"I'm busy right now. Please resend this request later.":this.handleMessage({...e,text:e.text,__passthrough:!0});const o=this.sessionManager.getOrCreate(s),i=await this.messageProcessor.process(e),r=b(i,void 0,{sessionKey:s,channel:e.channelName,chatId:e.chatId},this.config.timezone);de.debug(`[${s}] Prompt to agent (${r.text.length} chars): ${this.config.verboseDebugLogs?r.text:r.text.slice(0,15)+"..."}${r.images.length>0?` [+${r.images.length} image(s)]`:""}`);const a=o.model,h={sessionKey:s,channel:e.channelName,chatId:e.chatId,sessionId:o.sessionId??"",memoryFile:this.memoryManager?this.memoryManager.getConversationFile(s):"",attachmentsDir:this.memoryManager?this.memoryManager.getAttachmentsDir(s):""},c=w(this.config.dataDir),g={config:this.config,sessionContext:h,workspaceFiles:c,mode:"full",hasNodeTools:this.agentService.hasNodeTools(),hasMessageTools:this.agentService.hasMessageTools(),coderSkill:this.getChatSetting(s,"coderSkill")??this.coderSkill,toolServers:this.agentService.getToolServers()},l=y(g),m=y({...g,mode:"minimal"});de.debug(`[${s}] System prompt (${l.length} chars): ${this.config.verboseDebugLogs?l:l.slice(0,15)+"..."}`);try{const n=await this.agentService.sendMessage(s,r,o.sessionId,l,m,a,this.getChatSetting(s,"coderSkill")??this.coderSkill,this.getChatSetting(s,"subagentsEnabled")??this.subagentsEnabled,this.getChatSetting(s,"customSubAgentsEnabled")??this.customSubAgentsEnabled,this.getChatSetting(s,"sandboxEnabled")??!1);if(n.sessionReset){if("[AGENT_CLOSED]"===n.response)return de.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),"";{const i=n.response||"Session corruption detected",r={sessionKey:s,sessionId:o.sessionId,error:new Error(i),timestamp:new Date},a=le.analyzeError(r.error,r),h=le.getRecoveryStrategy(a);return de.warn(`[${s}] ${h.message} (error: ${i})`),this.sessionManager.updateSessionId(s,""),h.clearSession&&(this.agentService.destroySession(s),this.memoryManager&&this.memoryManager.clearSession(s)),"clear_and_retry"!==h.action||t?"[SESSION_CORRUPTED] The previous session is no longer valid. Use /new to start a new one.":(de.info(`[${s}] Retrying with fresh session after: ${i}`),this.handleMessage(e,!0))}}if(de.debug(`[${s}] Response from agent (session=${n.sessionId}, len=${n.response.length}): ${this.config.verboseDebugLogs?n.response:n.response.slice(0,15)+"..."}`),n.sessionId&&this.sessionManager.updateSessionId(s,n.sessionId),this.memoryManager&&"cron"!==e.userId){const e=(r.text||"[media]").replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\n?/,"").trim();await this.memoryManager.append(s,"user",e,i.savedFiles.length>0?i.savedFiles:void 0),await this.memoryManager.append(s,"assistant",ne(n.fullResponse??n.response))}if("max_turns"===n.errorType){const e="\n\n[MAX_TURNS] The agent reached the maximum number of turns. You can continue the conversation by sending another message.";return n.response?n.response+e:e.trim()}if("max_budget"===n.errorType){const e="\n\n[MAX_BUDGET] The agent reached the maximum budget for this request.";return n.response?n.response+e:e.trim()}if("refusal"===n.stopReason){const e="\n\n[REFUSAL] The model declined to fulfill this request.";return n.response?n.response+e:e.trim()}if("max_tokens"===n.stopReason){const e="\n\n[TRUNCATED] The response was cut short because it exceeded the output token limit. You can ask me to continue.";return n.response?n.response+e:e.trim()}return n.response}catch(e){const t=e instanceof Error?e.message:String(e);return t.includes("SessionAgent closed")||t.includes("agent closed")?(de.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),""):(de.error(`Agent error for ${s}: ${e}`),`Error: ${t}`)}}finally{await this.channelManager.releaseTyping(e.channelName,e.chatId).catch(()=>{})}}async start(){de.info("Starting GrabMeABeer server...");const e=[];this.config.channels.telegram.enabled&&e.push("telegram"),this.config.channels.responses.enabled&&e.push("responses"),this.config.channels.whatsapp.enabled&&e.push("whatsapp"),this.config.channels.discord.enabled&&e.push("discord"),this.config.channels.slack.enabled&&e.push("slack"),this.config.channels.mesh.enabled&&e.push("mesh"),de.info(`Enabled channels: ${e.join(", ")||"none"}`),await this.channelManager.startAll(),await this.browserService.start(this.config.browser).catch(e=>{de.warn(`Browser service failed to start: ${e}`)}),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.nodeRegistry.startPingLoop(),this.startAutoRenewTimer(),de.info("Server started successfully"),this.notifyAllChannels("Gateway started. Agent is alive!").catch(()=>{})}async initCronAndHeartbeat(){if(!this.cronService)return;await this.cronService.start();const e=this.config.cron.heartbeat,t=(await this.cronService.list({includeDisabled:!0})).find(e=>"__heartbeat"===e.name),s=!!e.channel&&!!this.channelManager.getAdapter(e.channel),n=!!e.message&&e.message.trim().length>=15;if(e.enabled&&e.channel&&e.chatId&&s&&n){const s={schedule:{kind:"every",everyMs:e.every},channel:e.channel,chatId:e.chatId,message:e.message};if(t){const e=t.schedule;(t.channel!==s.channel||t.chatId!==s.chatId||t.message!==s.message||t.isolated!==this.config.cron.isolated||"every"!==e.kind||"every"===e.kind&&e.everyMs!==s.schedule.everyMs||!t.enabled)&&(await this.cronService.update(t.id,{...s,isolated:this.config.cron.isolated,enabled:!0}),de.info("Heartbeat job updated from config"))}else await this.cronService.add({name:"__heartbeat",description:"Auto-generated heartbeat job",enabled:!0,isolated:this.config.cron.isolated,suppressToken:!0,...s}),de.info("Heartbeat job auto-added")}else t&&t.enabled&&(await this.cronService.update(t.id,{enabled:!1}),e.enabled&&!n?de.warn("Heartbeat job disabled: message is empty or too short (minimum 15 characters)"):e.enabled&&!s?de.warn(`Heartbeat job disabled: channel "${e.channel}" is not active`):de.info("Heartbeat job disabled (config changed)"))}getConfig(){return this.config}getTokenDb(){return this.tokenDb}getSessionDb(){return this.sessionDb}getMemoryManager(){return this.memoryManager}getNodeRegistry(){return this.nodeRegistry}getNodeSignatureDb(){return this.nodeSignatureDb}getChannelManager(){return this.channelManager}getAgentService(){return this.agentService}getCronService(){return this.cronService}getCoderSkill(){return this.coderSkill}getWhatsAppQrState(){return{dataUrl:this.whatsappQr?.dataUrl??null,connected:this.whatsappConnected,error:this.whatsappError}}getWebChatChannel(){return this.webChatChannel}getMeshChannel(){return this.meshChannel}async triggerRestart(){de.info("Trigger restart requested");const e=oe();await this.reconfigure(e),this.notifyAllChannels("Gateway restarted. Agent is alive!").catch(()=>{})}async notifyAllChannels(e){const t=this.collectBroadcastTargets();de.info(`Broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(async t=>{try{await this.channelManager.sendSystemMessage(t.channel,t.chatId,e),await this.channelManager.clearTyping(t.channel,t.chatId),de.debug(`Notified ${t.channel}:${t.chatId}`)}catch(e){de.warn(`Failed to notify ${t.channel}:${t.chatId}: ${e}`)}}))}static AUTO_RENEW_CHECK_INTERVAL_MS=9e5;startAutoRenewTimer(){this.stopAutoRenewTimer();const e=this.config.agent.autoRenew;e&&(de.info(`AutoRenew enabled — resetting sessions inactive for ${e}h (checking every 15 min)`),this.autoRenewTimer=setInterval(()=>{this.autoRenewStaleSessions().catch(e=>de.error(`AutoRenew error: ${e}`))},Server.AUTO_RENEW_CHECK_INTERVAL_MS))}stopAutoRenewTimer(){this.autoRenewTimer&&(clearInterval(this.autoRenewTimer),this.autoRenewTimer=null)}async autoRenewStaleSessions(){const e=this.config.agent.autoRenew;if(!e)return;const t=60*e*60*1e3,s=this.sessionDb.listStaleSessions(t);if(0!==s.length){de.info(`AutoRenew: found ${s.length} stale session(s)`);for(const t of s){const s=t.sessionKey;if(s.startsWith("cron:"))continue;if(this.agentService.isBusy(s))continue;this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s);const n=s.indexOf(":");if(n>0){const t=s.substring(0,n),o=s.substring(n+1),i=this.channelManager.getAdapter(t);if(i)try{await i.sendText(o,`Session renewed automatically after ${e}h of inactivity. Starting fresh!`)}catch(e){de.warn(`AutoRenew: failed to send courtesy message to ${s}: ${e}`)}}de.info(`AutoRenew: session reset — ${s}`)}}}async reconfigure(e){de.info("Reconfiguring server..."),this.cronService&&this.cronService.stop(),await this.channelManager.stopAll(),this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.sessionManager=new u(this.sessionDb),e.memory.enabled?this.memoryManager=new M(e.memoryDir,e.timezone):this.memoryManager=null;const t=C(e),s=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new R(t,s),this.commandRegistry=new A,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.agentService.destroyAll(),this.serverToolsFactory=()=>z(()=>this.triggerRestart(),this.config.timezone),this.cronService=this.createCronService(),this.stopMemorySearch(),this.createMemorySearch(e),await this.browserService.reconfigure(e.browser);const n=o(e.agent.workspacePath,".plasma"),i=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new p(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>q(()=>this.config):void 0,this.memorySearch?()=>V(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>J({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,i?()=>X({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=w(this.config.dataDir);return y({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Y({plasmaRootDir:n,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>Z(this.conceptStore):void 0),S(e.dataDir),await this.channelManager.startAll(),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.startAutoRenewTimer(),de.info("Server reconfigured successfully")}async stop(){de.info("Shutting down..."),this.stopAutoRenewTimer(),this.cronService&&this.cronService.stop(),this.memorySearch&&this.memorySearch.stop(),this.conceptStore&&this.conceptStore.close(),await this.browserService.stop(),this.agentService.destroyAll(),await this.channelManager.stopAll(),this.sessionManager.destroy(),this.sessionDb.close(),this.tokenDb.close(),this.nodeSignatureDb.close(),de.info("Server stopped")}}
1
+ import{readFileSync as e,writeFileSync as t,mkdirSync as s,existsSync as n}from"node:fs";import{join as o,resolve as i}from"node:path";import{TokenDB as r}from"./auth/token-db.js";import{NodeSignatureDB as a}from"./auth/node-signature-db.js";import{SessionDB as h}from"./agent/session-db.js";import{ChannelManager as c}from"./gateway/channel-manager.js";import{TypingCoordinator as g}from"./gateway/typing-coordinator.js";import{TelegramChannel as m}from"./gateway/channels/telegram/index.js";import{WhatsAppChannel as l}from"./gateway/channels/whatsapp.js";import{WebChatChannel as d}from"./gateway/channels/webchat.js";import{MeshChannel as f}from"./gateway/channels/mesh.js";import{ResponsesChannel as p}from"./channels/responses.js";import{AgentService as u}from"./agent/agent-service.js";import{SessionManager as y}from"./agent/session-manager.js";import{buildPrompt as b,buildSystemPrompt as S}from"./agent/prompt-builder.js";import{ensureWorkspaceFiles as w,loadWorkspaceFiles as v}from"./agent/workspace-files.js";import{NodeRegistry as M}from"./gateway/node-registry.js";import{MemoryManager as R}from"./memory/memory-manager.js";import{MessageProcessor as C}from"./media/message-processor.js";import{loadSTTProvider as A}from"./stt/stt-loader.js";import{CommandRegistry as $}from"./commands/command-registry.js";import{NewCommand as T}from"./commands/new.js";import{CompactCommand as k}from"./commands/compact.js";import{ModelCommand as j,DefaultModelCommand as D}from"./commands/model.js";import{StopCommand as I}from"./commands/stop.js";import{HelpCommand as x}from"./commands/help.js";import{McpCommand as E}from"./commands/mcp.js";import{ModelsCommand as _}from"./commands/models.js";import{CoderCommand as N}from"./commands/coder.js";import{SandboxCommand as P}from"./commands/sandbox.js";import{SubAgentsCommand as F}from"./commands/subagents.js";import{CustomSubAgentsCommand as U}from"./commands/customsubagents.js";import{StatusCommand as K}from"./commands/status.js";import{ShowToolCommand as O}from"./commands/showtool.js";import{UsageCommand as H}from"./commands/usage.js";import{DebugA2UICommand as B}from"./commands/debuga2ui.js";import{DebugDynamicCommand as L}from"./commands/debugdynamic.js";import{CronService as z}from"./cron/cron-service.js";import{stripHeartbeatToken as Q,isHeartbeatContentEffectivelyEmpty as W}from"./cron/heartbeat-token.js";import{createServerToolsServer as q}from"./tools/server-tools.js";import{createCronToolsServer as G}from"./tools/cron-tools.js";import{createTTSToolsServer as V}from"./tools/tts-tools.js";import{createMemoryToolsServer as J}from"./tools/memory-tools.js";import{createBrowserToolsServer as X}from"./tools/browser-tools.js";import{createPicoToolsServer as Y}from"./tools/pico-tools.js";import{createPlasmaClientToolsServer as Z}from"./tools/plasma-client-tools.js";import{createConceptToolsServer as ee}from"./tools/concept-tools.js";import{BrowserService as te}from"./browser/browser-service.js";import{MemorySearch as se}from"./memory/memory-search.js";import{ConceptStore as ne}from"./memory/concept-store.js";import{stripMediaLines as oe}from"./utils/media-response.js";import{loadConfig as ie,loadRawConfig as re,backupConfig as ae,resolveModelEntry as he,modelRefName as ce}from"./config.js";import{stringify as ge}from"yaml";import{createLogger as me}from"./utils/logger.js";import{SessionErrorHandler as le}from"./agent/session-error-handler.js";import{initStickerCache as de}from"./gateway/channels/telegram/stickers.js";const fe=me("Server");export class Server{config;tokenDb;sessionDb;nodeSignatureDb;channelManager;typingCoordinator;agentService;sessionManager;memoryManager=null;nodeRegistry;messageProcessor;commandRegistry;serverToolsFactory;coderSkill;showToolUse=!1;subagentsEnabled=!0;customSubAgentsEnabled=!1;chatSettings=new Map;cronService=null;browserService;memorySearch=null;conceptStore=null;whatsappQr=null;whatsappConnected=!1;whatsappError;webChatChannel=null;meshChannel=null;autoRenewTimer=null;constructor(e){this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.tokenDb=new r(e.dbPath),this.sessionDb=new h(e.dbPath),this.nodeSignatureDb=new a(e.dbPath),this.nodeRegistry=new M,this.sessionManager=new y(this.sessionDb),e.memory.enabled&&(this.memoryManager=new R(e.memoryDir,e.timezone));const t=A(e),n=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new C(t,n),de(e.dataDir),this.commandRegistry=new $,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.typingCoordinator=new g(this.channelManager),this.serverToolsFactory=()=>q({restartFn:()=>this.triggerRestart(),timezone:this.config.timezone,memoryDir:this.config.memoryDir,warmRestart:this.config.warmRestart}),this.cronService=this.createCronService(),this.createMemorySearch(e),this.browserService=new te,this.conceptStore=new ne(e.dataDir);const i=o(e.dataDir,"CONCEPTS.md");this.conceptStore.importFromTurtleIfEmpty(i);const m=o(e.agent.workspacePath,".plasma"),l=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new u(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>V(()=>this.config):void 0,this.memorySearch?()=>J(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>X({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,l?()=>Y({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=v(this.config.dataDir);return S({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Z({plasmaRootDir:m,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>ee(this.conceptStore):void 0),w(e.dataDir),s(o(e.agent.workspacePath,".claude","skills"),{recursive:!0}),s(o(e.agent.workspacePath,".claude","commands"),{recursive:!0}),s(o(e.agent.workspacePath,".plugins"),{recursive:!0})}getChatSetting(e,t){return this.chatSettings.get(e)?.[t]}setChatSetting(e,t,s){const n=this.chatSettings.get(e)??{};n[t]=s,this.chatSettings.set(e,n)}createCronService(){return new z({storePath:this.config.cronStorePath,enabled:this.config.cron.enabled,defaultTimezone:this.config.timezone,onExecute:e=>this.executeCronJob(e),sessionReaper:{pruneStaleSessions:e=>this.sessionDb.pruneStaleCronSessions(e)}})}createMemorySearch(e){if(!e.memory.enabled||!e.memory.search.enabled)return;const t=e.memory.search,s=he(e,t.modelRef),n=(s?.useEnvVar?process.env[s.useEnvVar]:s?.apiKey)||"",o=s?.baseURL||"";if(n)return this.memorySearch=new se(e.memoryDir,e.dataDir,{apiKey:n,baseURL:o||void 0,embeddingModel:t.embeddingModel,embeddingDimensions:t.embeddingDimensions,prefixQuery:t.prefixQuery,prefixDocument:t.prefixDocument,updateDebounceMs:t.updateDebounceMs,embedIntervalMs:t.embedIntervalMs,maxResults:t.maxResults,maxSnippetChars:t.maxSnippetChars,maxInjectedChars:t.maxInjectedChars,rrfK:t.rrfK,l0:e.memory.l0??{enabled:!0,model:""}}),J(this.memorySearch);fe.warn(`Memory search enabled but no API key found for modelRef "${t.modelRef}". Search will not start.`)}stopMemorySearch(){this.memorySearch&&(this.memorySearch.stop(),this.memorySearch=null)}collectBroadcastTargets(){const e=new Set,t=[],s=(s,n)=>{const o=`${s}:${n}`;e.has(o)||(e.add(o),t.push({channel:s,chatId:n}))},n=this.config.channels;for(const[e,t]of Object.entries(n)){if("responses"===e)continue;if(!t?.enabled||!this.channelManager.getAdapter(e))continue;const n=t.accounts;if(n)for(const t of Object.values(n)){const n=t?.allowFrom;if(Array.isArray(n))for(const t of n){const n=String(t).trim();n&&s(e,n)}}}for(const e of this.sessionDb.listSessions()){const t=e.sessionKey.indexOf(":");if(t<0)continue;const n=e.sessionKey.substring(0,t),o=e.sessionKey.substring(t+1);"cron"!==n&&o&&(this.channelManager.getAdapter(n)&&s(n,o))}return t}async executeCronJob(e){const t=this.config.cron.broadcastEvents;if(!t&&!this.channelManager.getAdapter(e.channel))return fe.warn(`Cron job "${e.name}": skipped (channel "${e.channel}" is not active)`),{response:"",delivered:!1};if(e.suppressToken&&"__heartbeat"===e.name){const t=this.triageHeartbeat();if(!t.shouldRun)return fe.info(`Cron job "${e.name}": skipped by triage (${t.reason})`),{response:"",delivered:!1};fe.info(`Cron job "${e.name}": triage passed (${t.reason})`)}const s="boolean"==typeof e.isolated?e.isolated:this.config.cron.isolated,n=s?"cron":e.channel,o=s?e.name:e.chatId;fe.info(`Cron job "${e.name}": session=${n}:${o}, delivery=${e.channel}:${e.chatId}${t?" (broadcast)":""}`);const i={chatId:o,userId:"cron",channelName:n,text:e.message,attachments:[]},r=`${n}:${o}`;try{const s=await this.handleMessage(i);let n=s;if(e.suppressToken){const t=this.config.cron.heartbeat.ackMaxChars,{shouldSkip:o,text:i}=Q(s,t);if(o)return fe.info(`Cron job "${e.name}": response suppressed (HEARTBEAT_OK)`),{response:s,delivered:!1};n=i}if(t){const t=this.collectBroadcastTargets();fe.info(`Cron job "${e.name}": broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(e=>this.channelManager.sendResponse(e.channel,e.chatId,n)));for(const e of t)this.typingCoordinator.release(e.channel,e.chatId)}else await this.channelManager.sendResponse(e.channel,e.chatId,n),this.typingCoordinator.release(e.channel,e.chatId);return{response:n,delivered:!0}}finally{this.agentService.destroySession(r),this.sessionManager.resetSession(r),this.memoryManager&&this.memoryManager.clearSession(r),fe.info(`Cron job "${e.name}": ephemeral session destroyed (${r})`)}}triageHeartbeat(){const t=(new Date).getHours(),s=o(this.config.agent.workspacePath,"attention","pending_signals.md");if(n(s))try{const t=e(s,"utf-8").trim().split("\n").filter(e=>e.trim()&&!e.startsWith("#"));if(t.length>0)return{shouldRun:!0,reason:`${t.length} pending signal(s)`}}catch{}const i=o(this.config.dataDir,"HEARTBEAT.md");let r=!1;if(n(i))try{const t=e(i,"utf-8");r=!W(t)}catch{r=!0}const a=this.sessionDb.hasRecentActivity(3e5);return t>=23||t<7?a?{shouldRun:!0,reason:"night mode but recent messages"}:{shouldRun:!1,reason:"night mode, no activity"}:r||a?{shouldRun:!0,reason:a?"recent messages":"actionable heartbeat"}:{shouldRun:!1,reason:"no signals, no messages, empty heartbeat"}}setupCommands(){this.commandRegistry.register(new T),this.commandRegistry.register(new k);const e=()=>this.config.agent.picoAgent??{enabled:!1,modelRefs:[]};this.commandRegistry.register(new j(()=>this.config.models??[],async(e,t)=>{const s=this.config.models?.find(e=>e.id===t),n=this.config.agent.picoAgent,o=e=>!(!n?.enabled||!Array.isArray(n.modelRefs))&&n.modelRefs.some(t=>t.split(":")[0]===e),i=this.sessionManager.getModel(e)||this.config.agent.model,r=he(this.config,i),a=o(r?.name??ce(i)),h=o(s?.name??t);this.sessionManager.setModel(e,t),this.agentService.destroySession(e);const c=a||h;return c&&this.sessionManager.resetSession(e),c},e)),this.commandRegistry.register(new D(()=>this.config.models??[],async e=>{const s=this.config.models?.find(t=>t.id===e),n=s?`${s.name}:${s.id}`:e;this.config.agent.model=n;const o=this.config.agent.picoAgent;if(o?.enabled&&Array.isArray(o.modelRefs)){const t=s?.name??e,n=o.modelRefs.findIndex(e=>e.split(":")[0]===t);if(n>0){const[e]=o.modelRefs.splice(n,1);o.modelRefs.unshift(e)}}try{const e=i(process.cwd(),"config.yaml"),s=re(e);s.agent||(s.agent={}),s.agent.model=n,o?.enabled&&Array.isArray(o.modelRefs)&&(s.agent.picoAgent||(s.agent.picoAgent={}),s.agent.picoAgent.modelRefs=[...o.modelRefs]),ae(e),t(e,ge(s),"utf-8")}catch{}},e)),this.commandRegistry.register(new _(()=>this.config.models??[],e)),this.commandRegistry.register(new N(e=>this.getChatSetting(e,"coderSkill")??this.coderSkill,(e,t)=>this.setChatSetting(e,"coderSkill",t))),this.commandRegistry.register(new P(e=>this.getChatSetting(e,"sandboxEnabled")??!1,(e,t)=>this.setChatSetting(e,"sandboxEnabled",t))),this.commandRegistry.register(new O(e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,(e,t)=>this.setChatSetting(e,"showToolUse",t))),this.commandRegistry.register(new F(e=>this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,(e,t)=>this.setChatSetting(e,"subagentsEnabled",t))),this.commandRegistry.register(new U(e=>this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,(e,t)=>this.setChatSetting(e,"customSubAgentsEnabled",t),()=>this.config)),this.commandRegistry.register(new K(e=>{const t=this.sessionManager.getModel(e)||this.config.agent.model,s=this.config.agent.mainFallback,n=he(this.config,t),o=s?he(this.config,s):void 0;return{configDefaultModel:ce(this.config.agent.model),agentModel:n?.id??t,agentModelName:n?.name??ce(t),fallbackModel:o?.id??s,fallbackModelName:o?.name??(s?ce(s):void 0),fallbackActive:this.agentService.isFallbackActive(e),coderSkill:this.getChatSetting(e,"coderSkill")??this.coderSkill,showToolUse:this.getChatSetting(e,"showToolUse")??this.showToolUse,subagentsEnabled:this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,customSubAgentsEnabled:this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,sandboxEnabled:this.getChatSetting(e,"sandboxEnabled")??!1,connectedNodes:this.nodeRegistry.listNodes().map(e=>({nodeId:e.nodeId,displayName:e.displayName,hostname:e.hostname}))}})),this.commandRegistry.register(new I(e=>this.agentService.interrupt(e))),this.commandRegistry.register(new E(()=>this.agentService.getToolServers())),this.commandRegistry.register(new x(()=>this.agentService.getSdkSlashCommands())),this.commandRegistry.register(new H(e=>this.agentService.getUsage(e))),this.commandRegistry.register(new B(this.nodeRegistry)),this.commandRegistry.register(new L(this.nodeRegistry))}registerChannels(){if(this.config.channels.telegram.enabled){const e=this.config.channels.telegram.accounts;for(const[t,s]of Object.entries(e)){if(!s.botToken){fe.error(`Telegram account "${t}" has no botToken configured — skipping. Check your config.yaml.`);continue}const e=new m(s,t,this.tokenDb);this.channelManager.registerAdapter(e)}}if(this.config.channels.whatsapp.enabled){const e=this.config.channels.whatsapp.accounts;for(const[t,s]of Object.entries(e)){const e=new l(s);e.setQrCallback((e,t,s)=>{this.whatsappQr=e?{dataUrl:e,timestamp:Date.now()}:null,this.whatsappConnected=t,this.whatsappError=s}),this.channelManager.registerAdapter(e)}}if(this.config.channels.responses.enabled){const e=new p({host:this.config.host,port:this.config.channels.responses.port},this.tokenDb);this.channelManager.registerAdapter(e)}if(this.config.channels.mesh.enabled){const e=this.config.channels.mesh;e.agentId&&e.privateKey?(this.meshChannel||(this.meshChannel=new f({brokerUrl:e.brokerUrl,agentId:e.agentId,privateKey:e.privateKey,reconnectDelayMs:e.reconnectDelayMs}),this.meshChannel.onReply((e,t,s)=>{fe.info(`[Mesh] Routing reply from ${t} to origin session ${e}`);const n=e.indexOf(":"),o=n>0?e.substring(0,n):"mesh",i=n>0?e.substring(n+1):t,r={chatId:i,userId:t,channelName:o,text:`[Mesh reply from ${t}] ${s}`,attachments:[],username:t};this.handleMessage(r).then(t=>{t&&t.trim()&&this.channelManager.sendResponse(o,i,t).catch(t=>{fe.error(`[Mesh] Failed to deliver reply-response to ${e}: ${t}`)})}).catch(s=>{fe.error(`[Mesh] Failed to process reply from ${t} in session ${e}: ${s}`)})})),this.channelManager.registerAdapter(this.meshChannel)):fe.warn("Mesh channel enabled but agentId or privateKey not configured")}this.webChatChannel||(this.webChatChannel=new d),this.channelManager.registerAdapter(this.webChatChannel)}async handleMessage(e,t=!1){const s=`${e.channelName}:${e.chatId}`,n=e.text?e.text.length>15?e.text.slice(0,15)+"...":e.text:"[no text]";fe.info(`Message from ${s} (user=${e.userId}, ${e.username??"?"}): ${n}`),this.config.verboseDebugLogs&&fe.debug(`Message from ${s} full text: ${e.text??"[no text]"}`);try{if(this.typingCoordinator.acquire(e.channelName,e.chatId),e.text){if(e.text.startsWith("__ask:")){const t=e.text.substring(6);return this.agentService.resolveQuestion(s,t),""}if(this.agentService.hasPendingQuestion(s)){const t=e.text.trim();return this.agentService.resolveQuestion(s,t),`Selected: ${t}`}if(e.text.startsWith("__tool_perm:")){const t="__tool_perm:approve"===e.text;return this.agentService.resolvePermission(s,t),t?"Tool approved.":"Tool denied."}if(this.agentService.hasPendingPermission(s)){const t=e.text.trim().toLowerCase();if("approve"===t||"approva"===t)return this.agentService.resolvePermission(s,!0),"Tool approved.";if("deny"===t||"vieta"===t||"blocca"===t)return this.agentService.resolvePermission(s,!1),"Tool denied."}}const n=!0===e.__passthrough;if(!n&&e.text&&this.commandRegistry.isCommand(e.text)){const t=await this.commandRegistry.dispatch(e.text,{sessionKey:s,chatId:e.chatId,channelName:e.channelName,userId:e.userId});if(t)return t.passthrough?this.handleMessage({...e,text:t.passthrough,__passthrough:!0}):(t.resetSession?(this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s)):t.resetAgent&&this.agentService.destroySession(s),t.buttons&&t.buttons.length>0?(await this.channelManager.sendButtons(e.channelName,e.chatId,t.text,t.buttons),""):t.text)}if(!n&&e.text?.startsWith("/"))return this.agentService.isBusy(s)?"I'm busy right now. Please resend this request later.":this.handleMessage({...e,text:e.text,__passthrough:!0});const o=this.sessionManager.getOrCreate(s),i=await this.messageProcessor.process(e),r=b(i,void 0,{sessionKey:s,channel:e.channelName,chatId:e.chatId},this.config.timezone);fe.debug(`[${s}] Prompt to agent (${r.text.length} chars): ${this.config.verboseDebugLogs?r.text:r.text.slice(0,15)+"..."}${r.images.length>0?` [+${r.images.length} image(s)]`:""}`);const a=o.model,h={sessionKey:s,channel:e.channelName,chatId:e.chatId,sessionId:o.sessionId??"",memoryFile:this.memoryManager?this.memoryManager.getConversationFile(s):"",attachmentsDir:this.memoryManager?this.memoryManager.getAttachmentsDir(s):""},c=v(this.config.dataDir),g={config:this.config,sessionContext:h,workspaceFiles:c,mode:"full",hasNodeTools:this.agentService.hasNodeTools(),hasMessageTools:this.agentService.hasMessageTools(),coderSkill:this.getChatSetting(s,"coderSkill")??this.coderSkill,toolServers:this.agentService.getToolServers()},m=S(g),l=S({...g,mode:"minimal"});fe.debug(`[${s}] System prompt (${m.length} chars): ${this.config.verboseDebugLogs?m:m.slice(0,15)+"..."}`);try{const n=await this.agentService.sendMessage(s,r,o.sessionId,m,l,a,this.getChatSetting(s,"coderSkill")??this.coderSkill,this.getChatSetting(s,"subagentsEnabled")??this.subagentsEnabled,this.getChatSetting(s,"customSubAgentsEnabled")??this.customSubAgentsEnabled,this.getChatSetting(s,"sandboxEnabled")??!1);if(n.sessionReset){if("[AGENT_CLOSED]"===n.response)return fe.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),"";{const i=n.response||"Session corruption detected",r={sessionKey:s,sessionId:o.sessionId,error:new Error(i),timestamp:new Date},a=le.analyzeError(r.error,r),h=le.getRecoveryStrategy(a);return fe.warn(`[${s}] ${h.message} (error: ${i})`),this.sessionManager.updateSessionId(s,""),h.clearSession&&(this.agentService.destroySession(s),this.memoryManager&&this.memoryManager.clearSession(s)),"clear_and_retry"!==h.action||t?"[SESSION_CORRUPTED] The previous session is no longer valid. Use /new to start a new one.":(fe.info(`[${s}] Retrying with fresh session after: ${i}`),this.handleMessage(e,!0))}}fe.debug(`[${s}] Response from agent (session=${n.sessionId}, len=${n.response.length}): ${this.config.verboseDebugLogs?n.response:n.response.slice(0,15)+"..."}`),n.sessionId&&this.sessionManager.updateSessionId(s,n.sessionId);const h="cron"===e.userId,c=!0===this.config.cron.trackMemory;if(this.memoryManager&&(!h||c)){const e=(r.text||"[media]").replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\n?/,"").trim();await this.memoryManager.append(s,"user",e,i.savedFiles.length>0?i.savedFiles:void 0),await this.memoryManager.append(s,"assistant",oe(n.fullResponse??n.response))}if("max_turns"===n.errorType){const e="\n\n[MAX_TURNS] The agent reached the maximum number of turns. You can continue the conversation by sending another message.";return n.response?n.response+e:e.trim()}if("max_budget"===n.errorType){const e="\n\n[MAX_BUDGET] The agent reached the maximum budget for this request.";return n.response?n.response+e:e.trim()}if("refusal"===n.stopReason){const e="\n\n[REFUSAL] The model declined to fulfill this request.";return n.response?n.response+e:e.trim()}if("max_tokens"===n.stopReason){const e="\n\n[TRUNCATED] The response was cut short because it exceeded the output token limit. You can ask me to continue.";return n.response?n.response+e:e.trim()}return n.response}catch(e){const t=e instanceof Error?e.message:String(e);return t.includes("SessionAgent closed")||t.includes("agent closed")?(fe.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),""):(fe.error(`Agent error for ${s}: ${e}`),`Error: ${t}`)}}finally{this.typingCoordinator.release(e.channelName,e.chatId)}}async start(){fe.info("Starting GrabMeABeer server...");const e=[];this.config.channels.telegram.enabled&&e.push("telegram"),this.config.channels.responses.enabled&&e.push("responses"),this.config.channels.whatsapp.enabled&&e.push("whatsapp"),this.config.channels.discord.enabled&&e.push("discord"),this.config.channels.slack.enabled&&e.push("slack"),this.config.channels.mesh.enabled&&e.push("mesh"),fe.info(`Enabled channels: ${e.join(", ")||"none"}`),await this.channelManager.startAll(),this.notifyAllChannels("Gateway started. Agent is alive!").catch(()=>{}),await this.browserService.start(this.config.browser).catch(e=>{fe.warn(`Browser service failed to start: ${e}`)}),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.nodeRegistry.startPingLoop(),this.startAutoRenewTimer(),fe.info("Server started successfully")}async initCronAndHeartbeat(){if(!this.cronService)return;await this.cronService.start();const e=this.config.cron.heartbeat,t=(await this.cronService.list({includeDisabled:!0})).find(e=>"__heartbeat"===e.name),s=!!e.channel&&!!this.channelManager.getAdapter(e.channel),n=!!e.message&&e.message.trim().length>=15;if(e.enabled&&e.channel&&e.chatId&&s&&n){const s={schedule:{kind:"every",everyMs:e.every},channel:e.channel,chatId:e.chatId,message:e.message};if(t){const e=t.schedule;(t.channel!==s.channel||t.chatId!==s.chatId||t.message!==s.message||t.isolated!==this.config.cron.isolated||"every"!==e.kind||"every"===e.kind&&e.everyMs!==s.schedule.everyMs||!t.enabled)&&(await this.cronService.update(t.id,{...s,isolated:this.config.cron.isolated,enabled:!0}),fe.info("Heartbeat job updated from config"))}else await this.cronService.add({name:"__heartbeat",description:"Auto-generated heartbeat job",enabled:!0,isolated:this.config.cron.isolated,suppressToken:!0,...s}),fe.info("Heartbeat job auto-added")}else t&&t.enabled&&(await this.cronService.update(t.id,{enabled:!1}),e.enabled&&!n?fe.warn("Heartbeat job disabled: message is empty or too short (minimum 15 characters)"):e.enabled&&!s?fe.warn(`Heartbeat job disabled: channel "${e.channel}" is not active`):fe.info("Heartbeat job disabled (config changed)"))}getConfig(){return this.config}getTokenDb(){return this.tokenDb}getSessionDb(){return this.sessionDb}getMemoryManager(){return this.memoryManager}getNodeRegistry(){return this.nodeRegistry}getNodeSignatureDb(){return this.nodeSignatureDb}getChannelManager(){return this.channelManager}getAgentService(){return this.agentService}getCronService(){return this.cronService}getCoderSkill(){return this.coderSkill}getWhatsAppQrState(){return{dataUrl:this.whatsappQr?.dataUrl??null,connected:this.whatsappConnected,error:this.whatsappError}}getWebChatChannel(){return this.webChatChannel}getMeshChannel(){return this.meshChannel}async triggerRestart(){fe.info("Trigger restart requested");const e=ie();await this.reconfigure(e),this.notifyAllChannels("Gateway restarted. Agent is alive!").catch(()=>{})}async notifyAllChannels(e){const t=this.collectBroadcastTargets();fe.info(`Broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(async t=>{try{await this.channelManager.sendSystemMessage(t.channel,t.chatId,e),this.typingCoordinator.reinforceIfActive(t.channel,t.chatId),fe.debug(`Notified ${t.channel}:${t.chatId}`)}catch(e){fe.warn(`Failed to notify ${t.channel}:${t.chatId}: ${e}`)}}))}static AUTO_RENEW_CHECK_INTERVAL_MS=9e5;startAutoRenewTimer(){this.stopAutoRenewTimer();const e=this.config.agent.autoRenew;e&&(fe.info(`AutoRenew enabled — resetting sessions inactive for ${e}h (checking every 15 min)`),this.autoRenewTimer=setInterval(()=>{this.autoRenewStaleSessions().catch(e=>fe.error(`AutoRenew error: ${e}`))},Server.AUTO_RENEW_CHECK_INTERVAL_MS))}stopAutoRenewTimer(){this.autoRenewTimer&&(clearInterval(this.autoRenewTimer),this.autoRenewTimer=null)}async autoRenewStaleSessions(){const e=this.config.agent.autoRenew;if(!e)return;const t=60*e*60*1e3,s=this.sessionDb.listStaleSessions(t);if(0!==s.length){fe.info(`AutoRenew: found ${s.length} stale session(s)`);for(const t of s){const s=t.sessionKey;if(s.startsWith("cron:"))continue;if(this.agentService.isBusy(s))continue;this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s);const n=s.indexOf(":");if(n>0){const t=s.substring(0,n),o=s.substring(n+1),i=this.channelManager.getAdapter(t);if(i)try{await i.sendText(o,`Session renewed automatically after ${e}h of inactivity. Starting fresh!`)}catch(e){fe.warn(`AutoRenew: failed to send courtesy message to ${s}: ${e}`)}}fe.info(`AutoRenew: session reset — ${s}`)}}}async reconfigure(e){fe.info("Reconfiguring server..."),this.cronService&&this.cronService.stop(),await this.channelManager.stopAll(),this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.sessionManager=new y(this.sessionDb),e.memory.enabled?this.memoryManager=new R(e.memoryDir,e.timezone):this.memoryManager=null;const t=A(e),s=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new C(t,s),this.commandRegistry=new $,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.typingCoordinator=new g(this.channelManager),this.agentService.destroyAll(),this.serverToolsFactory=()=>q({restartFn:()=>this.triggerRestart(),timezone:this.config.timezone,memoryDir:this.config.memoryDir,warmRestart:this.config.warmRestart}),this.cronService=this.createCronService(),this.stopMemorySearch(),this.createMemorySearch(e),await this.browserService.reconfigure(e.browser);const n=o(e.agent.workspacePath,".plasma"),i=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new u(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>V(()=>this.config):void 0,this.memorySearch?()=>J(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>X({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,i?()=>Y({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=v(this.config.dataDir);return S({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Z({plasmaRootDir:n,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>ee(this.conceptStore):void 0),w(e.dataDir),await this.channelManager.startAll(),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.startAutoRenewTimer(),fe.info("Server reconfigured successfully")}async stop(){fe.info("Shutting down..."),this.stopAutoRenewTimer(),this.cronService&&this.cronService.stop(),this.memorySearch&&this.memorySearch.stop(),this.conceptStore&&this.conceptStore.close(),await this.browserService.stop(),this.typingCoordinator.stopAll(),this.agentService.destroyAll(),await this.channelManager.stopAll(),this.sessionManager.destroy(),this.sessionDb.close(),this.tokenDb.close(),this.nodeSignatureDb.close(),fe.info("Server stopped")}}
@@ -1,2 +1,12 @@
1
- export declare function createServerToolsServer(restartFn: () => Promise<void>, timezone?: string): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
1
+ interface ServerToolsOptions {
2
+ restartFn: () => Promise<void>;
3
+ timezone?: string;
4
+ memoryDir?: string;
5
+ warmRestart?: {
6
+ enabled: boolean;
7
+ pm2Name: string;
8
+ };
9
+ }
10
+ export declare function createServerToolsServer(opts: ServerToolsOptions): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
11
+ export {};
2
12
  //# sourceMappingURL=server-tools.d.ts.map
@@ -1 +1 @@
1
- import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{createLogger as r}from"../utils/logger.js";const n=r("ServerTools");export function createServerToolsServer(r,o){const i=o||Intl.DateTimeFormat().resolvedOptions().timeZone;return e({name:"server-tools",version:"1.0.0",tools:[t("restart_server","Restart the GrabMeABeer server. This reloads config from disk and reinitializes all channels, agents, and services. Active conversations will be interrupted. Use this when you need to apply configuration changes or recover from issues.",{},async()=>(n.info("Server restart triggered by agent"),setTimeout(()=>{r().catch(e=>{const t=e instanceof Error?e.message:String(e);n.error(`Agent-triggered restart failed: ${t}`)})},100),{content:[{type:"text",text:"Server restart initiated. The server will reconfigure momentarily."}]})),t("get_current_time","Get the current date and time in the server's configured timezone. Use this tool whenever you need to know the current time, date, day of the week, or any time-related information. Do not guess or estimate the current time — always call this tool.",{},async()=>{const e=new Date,t=e.toLocaleString("en-GB",{timeZone:i,weekday:"long",year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}),r=e.toISOString();return{content:[{type:"text",text:`Current time (${i}): ${t}\nISO 8601: ${r}\nUnix timestamp: ${Math.floor(e.getTime()/1e3)}`}]}})]})}
1
+ import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{appendFileSync as r,existsSync as o,mkdirSync as n}from"node:fs";import{join as i}from"node:path";import{execSync as s}from"node:child_process";import{createLogger as a}from"../utils/logger.js";const m=a("ServerTools");function c(e,t,s){if(!e)return;const a=i(e,"RESET_HISTORY.md"),c=new Date,d=s?c.toLocaleString("en-GB",{timeZone:s,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}):c.toISOString();o(a)||(n(e,{recursive:!0}),r(a,"# Reset History\n\n| Timestamp | Type |\n|---|---|\n","utf-8")),r(a,`| ${d} | ${t} |\n`,"utf-8"),m.info(`Logged ${t} restart to ${a}`)}export function createServerToolsServer(r){const{restartFn:o,timezone:n,memoryDir:i,warmRestart:a}=r,d=n||Intl.DateTimeFormat().resolvedOptions().timeZone,l=[t("restart_server","Restart the GrabMeABeer server (cold restart). This reloads config from disk and reinitializes all channels, agents, and services. Active conversations will be interrupted. Use this when you need to apply configuration changes or recover from issues.",{},async()=>(m.info("Cold restart triggered by agent"),c(i,"cold",n),setTimeout(()=>{o().catch(e=>{const t=e instanceof Error?e.message:String(e);m.error(`Agent-triggered cold restart failed: ${t}`)})},100),{content:[{type:"text",text:"Server cold restart initiated. The server will reconfigure momentarily."}]})),t("get_current_time","Get the current date and time in the server's configured timezone. Use this tool whenever you need to know the current time, date, day of the week, or any time-related information. Do not guess or estimate the current time — always call this tool.",{},async()=>{const e=new Date,t=e.toLocaleString("en-GB",{timeZone:d,weekday:"long",year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}),r=e.toISOString();return{content:[{type:"text",text:`Current time (${d}): ${t}\nISO 8601: ${r}\nUnix timestamp: ${Math.floor(e.getTime()/1e3)}`}]}})];return a?.enabled&&a.pm2Name&&l.push(t("restart_server_warm","Warm restart the server via PM2. The process is killed and respawned by PM2. Use this when code changes require a full process restart (not just config reload). Requires warmRestart to be enabled in config.",{},async()=>{const e=a.pm2Name;return m.info(`Warm restart (PM2) triggered by agent for process: ${e}`),c(i,"warm",n),setTimeout(()=>{try{s(`pm2 restart ${e}`,{timeout:1e4})}catch(e){const t=e instanceof Error?e.message:String(e);m.error(`PM2 warm restart failed: ${t}`)}},100),{content:[{type:"text",text:`Warm restart initiated via PM2 (process: ${e}). The server will respawn momentarily.`}]}})),e({name:"server-tools",version:"1.0.0",tools:l})}
@@ -157,6 +157,10 @@ Cron: exact timing, isolation, one-shot reminders, heavy work.
157
157
 
158
158
  When multiple skills could handle a task, prefer the one with lower priority number.
159
159
 
160
+ ## Server Restart
161
+
162
+ Restart history is logged in **`memory/RESET_HISTORY.md`** (timestamp + type cold/warm).
163
+
160
164
  ## Make It Yours
161
165
 
162
166
  This is a starting point. Add your own conventions, style, and rules as you figure out what works.
@@ -1,92 +1,111 @@
1
- # BEHAVIOUR.md — Operational rules and lessons learned
2
-
3
- **This file is `BEHAVIOUR.md` in the dataDir.** To add or modify operational rules, edit this file directly.
4
- Optimized for LLM parsing. Each rule: `WHEN trigger → DO action`.
5
- Single source of truth. Dreaming adds here, not elsewhere.
1
+ # BEHAVIOUR.md v2 — Operational Rules
2
+ # Format: each rule has priority (1=critical), trigger, actions
3
+ # Rules are ordered by priority, not by position in file
4
+ # Single source of truth. Dreaming adds here, not elsewhere.
5
+ #
6
+ # HOW TO ADD A NEW RULE:
7
+ # 1. Pick the right priority level (P1=integrity, P2=persistence, P3=session, etc.)
8
+ # 2. Define the trigger (when does this rule fire?)
9
+ # 3. Write atomic actions (do/never) — if it's hard to follow, it won't be followed
10
+ # 4. Add a reason if there's a specific incident that motivated this rule
11
+ #
12
+ # Example:
13
+ # - trigger: user-mentions-price
14
+ # priority: 4
15
+ # do: state_get to check current value BEFORE responding
16
+ # never: quote prices from memory — they change
6
17
 
7
18
  ---
19
+ # P1 — INTEGRITY (violation = credibility penalty)
20
+
21
+ - trigger: always
22
+ priority: 1
23
+ do: VERIFY before asserting checkable facts (date, time, count, existence)
24
+ never: present unverified assumptions as facts — say "I'm not sure" instead
25
+
26
+ - trigger: always
27
+ priority: 1
28
+ do: "I don't know" when you don't know
29
+ never: lie, embellish, pretend
30
+
31
+ - trigger: always
32
+ priority: 1
33
+ never:
34
+ - guessing dates/times from memory — always verify with tool
35
+ - single command string in node_exec (ALWAYS cmd + args separated)
36
+ - polling background task output files (WAIT for task-notification instead)
37
+ - repeating the same action with slight wording variations (= loop)
8
38
 
9
- ## ALWAYS — Universal rules (every response)
10
-
11
- - VERIFY before asserting any checkable fact (date, time, day, count, existence, negation)
12
- - NEVER lie, embellish, or pretend — "I don't know" > inventing an answer
13
- - concept_draft on every new person, fact, relation, or decision that emerges
14
- - When the user corrects → update BEHAVIOUR.md or relevant file IMMEDIATELY (one correction = permanent fix)
15
- - When new info from user → update memory files immediately (MEMORY.md, memory/), not just concept_draft
16
-
17
- ## NEVER — Hard prohibitions
18
-
19
- - Comparative claims without systematic search ("nobody does X", "you're the only one")
20
- - Invented metadata (unknown origin → don't add it)
21
- - Guessing dates/times from memory — always verify with tool
22
- - Single command string in node_exec (ALWAYS cmd + args separated)
23
-
24
- ## WHEN:session-start — First message of the conversation
25
-
26
- If the message is the first in the session (no previous messages in context):
27
-
28
- 1. `get_current_time` (always)
29
- 2. Use `memory_list` with today and yesterday's dates, then `memory_get` to read the matching files (if any)
30
- 3. If the message assumes shared context (people, projects, events, pronouns without referent):
31
- - `memory_search` with keywords from the message
32
- - `concept_query` on mentioned entities (in parallel)
33
- 4. If the message is self-contained ("what time is it", "translate X", generic technical question) → skip 3, respond directly
34
-
35
- Goal: reconstruct context BEFORE responding, not after saying something wrong.
36
-
37
- ## WHEN:entity-mentioned-without-context — Context gap
38
-
39
- - memory_search + concept_query IN PARALLEL (anti-underuse graph)
40
- - Do NOT apply to technical/operative searches (config, errors, how-to) — graph has nothing there
41
- - If references to unknown people/events → search IMMEDIATELY, don't ask for clarification
42
-
43
- ## WHEN:memory-write — Writing to persistent files
44
-
45
- - Store conclusions not reasoning, facts not narratives
46
- - One line per fact when possible — scannable beats readable
47
- - Dead memory is misleading — delete or update stale entries, don't accumulate
48
-
49
- ## WHEN:memory-retrieval — Searching past context
50
-
51
- - memory_search → relevant snippet → memory_expand (activates RL tracking automatically)
52
- - memory_record_access manual only when snippet was sufficient but decisive
53
- - concept_query for navigating relationships (depth=2 for "all X of Y")
54
-
55
- ## WHEN:mutable-data — State registry vs memory
39
+ ---
40
+ # P2 — CORRECTIONS & PERSISTENCE
56
41
 
57
- - Prices, positions, counters, task status, current focus → state_set/state_get
58
- - Stable knowledge, decisions, history → MEMORY.md + files
59
- - Structured relationships concept graph
42
+ - trigger: user-corrects
43
+ priority: 2
44
+ do: update BEHAVIOUR.md or relevant file IMMEDIATELY
45
+ rule: one correction = permanent fix, no repeat offenses
60
46
 
61
- ## WHEN:external-action — Leaving the machine
47
+ - trigger: new-info-from-user
48
+ priority: 2
49
+ do: update memory files immediately (MEMORY.md, memory/)
62
50
 
63
- - Ask before: sending emails, tweets, public posts, anything reaching third parties
64
- - Do freely: read files, search web, organize workspace, create/edit internal files, research
51
+ - trigger: new-entity-observed
52
+ priority: 2
53
+ do: concept_draft on every new person, fact, relation, decision
65
54
 
66
- ## WHEN:group-chat — Multi-user contexts
55
+ ---
56
+ # P3 — SESSION LIFECYCLE
57
+
58
+ - trigger: session-start
59
+ priority: 3
60
+ do:
61
+ 1. get_current_time (always)
62
+ 2. memory_list today+yesterday → memory_get to read found files
63
+ 3. if message needs shared context → memory_search + concept_query (parallel)
64
+ 4. if self-contained → skip 3, respond directly
65
+ goal: reconstruct context BEFORE responding
66
+
67
+ - trigger: entity-mentioned-without-context
68
+ priority: 3
69
+ do: memory_search + concept_query IN PARALLEL
70
+ never: ask for clarification before searching
67
71
 
68
- - Respond when: directly mentioned, can add genuine value, something witty fits, correcting misinformation
69
- - Stay silent when: casual banter, already answered, flow is fine without you
70
- - NEVER share the user's private info from MEMORY.md
72
+ ---
73
+ # P4 MEMORY & STATE
71
74
 
72
- ## WHEN:temporal-reasoning — Time-related analysis
75
+ - trigger: memory-write
76
+ priority: 4
77
+ do: store conclusions not reasoning, facts not narratives, one line per fact
78
+ never: accumulate stale entries — delete or update dead memory
73
79
 
74
- - ALWAYS verify current date/time with tool BEFORE reasoning about "X days ago", "last week"
80
+ - trigger: mutable-data
81
+ priority: 4
82
+ state_registry: prices, counters, task status, current focus
83
+ memory_files: stable knowledge, decisions, history
84
+ concept_graph: structured relationships
75
85
 
76
- ## WHEN:errore-commesso — A mistake was made
86
+ ---
87
+ # P5 — CHANNELS & CONTEXT
77
88
 
78
- - Acknowledge without excuses or justifications
79
- - Update the appropriate file IMMEDIATELY
80
- - Don't promise to improve improve
89
+ - trigger: external-action
90
+ priority: 5
91
+ ask-before: sending emails, posts, anything reaching third parties
92
+ do-freely: read files, search web, organize workspace, research
81
93
 
82
- ## WHEN:cron-job — Creating cron jobs
94
+ - trigger: cron-job
95
+ priority: 5
96
+ rules:
97
+ - message must be SELF-CONTAINED
98
+ - cronExpr is UTC! For 07:00 Rome (CET) use "0 6 * * *"
83
99
 
84
- - Message must be SELF-CONTAINED (what to do, where to write, who to message, operational details)
85
- - cronExpr is UTC! For 07:00 Rome (CET) use `0 6 * * *`
100
+ ---
101
+ # P6 DOMAIN-SPECIFIC
102
+ # Add rules here for your agent's specific domains (health, finance, coding, etc.)
86
103
 
87
- ## META — Patterns about patterns
104
+ ---
105
+ # META — How rules work
88
106
 
89
- - Rules don't work if cognitive cost is high make the action low-cost (atomic tool call)
90
- - Simplicity > complexity (concise rules > verbose explanations)
91
- - Position in file matters (line 10 > line 305)
92
- - "Sounds right" "is right" pattern matching is the enemy of accuracy
107
+ - cognitive cost altorule ignored. Make actions atomic.
108
+ - "sounds right" "is right" pattern matching is the enemy of accuracy
109
+ - simplicity > complexity
110
+ - contrarian frame: define what NOT to do
111
+ - WHY before HOW
@@ -124,6 +124,16 @@ channels:
124
124
  # userId: ""
125
125
  # accessToken: ""
126
126
 
127
+ # mesh: # inter-agent mesh communication
128
+ # enabled: false
129
+ # brokerUrl: "ws://127.0.0.1:3780/ws" # mesh broker WebSocket URL
130
+ # agentId: "" # unique agent ID on the mesh
131
+ # privateKey: "${MESH_KEY}" # Ed25519 private key for mesh auth
132
+ # reconnectDelayMs: 5000 # delay before reconnecting on disconnect
133
+ # rateLimit: # per-peer rate limiting (anti-loop, inspired by "Agents of Chaos" paper)
134
+ # maxPerPair: 20 # max outbound messages per peer per window
135
+ # windowMs: 3600000 # sliding window in ms (default: 1 hour)
136
+
127
137
  responses:
128
138
  enabled: true
129
139
  port: 3002
@@ -229,7 +239,7 @@ models: # model registry
229
239
  costOutput: 0
230
240
  costCacheRead: 0
231
241
  costCacheWrite: 0
232
- - id: "openrouter:x-ai/grok-4.1-fast"
242
+ - id: "openrouter:x-ai/grok-4.20-beta"
233
243
  name: OpenRouter Grok
234
244
  types: [external]
235
245
  apiKey: "${OPENROUTER_API_KEY}"
@@ -304,7 +314,7 @@ memory:
304
314
  recallStrategy: "search" # builtin-only | search
305
315
  search:
306
316
  enabled: true # enable hybrid BM25 + semantic search over memory
307
- modelRef: "Embedding Gemma" # references a model in the models registry (for API key + base URL)
317
+ modelRef: "Embedding Gemma:embeddinggemma" # "Name:id" reference to a model in the models registry (for API key + base URL)
308
318
  embeddingModel: "embeddinggemma" # Ollama model (run: ollama pull gemma)
309
319
  prefixQuery: "task: search result | query: {content}" # prefix template for query embeddings (use {content} placeholder)
310
320
  prefixDocument: "title: none | text: {content}" # prefix template for document embeddings (use {content} placeholder)
@@ -338,7 +348,7 @@ agent:
338
348
  modelRefs: # list of "Name:provider:modelId" refs
339
349
  - "OpenRouter GPT:openrouter:openai/gpt-5.4"
340
350
  - "OpenRouter Flash:openrouter:google/gemini-3-flash-preview"
341
- - "OpenRouter Grok:openrouter:x-ai/grok-4.1-fast"
351
+ - "OpenRouter Grok:openrouter:x-ai/grok-4.20-beta"
342
352
  - "OpenRouter GeminiPro:openrouter:google/gemini-3-pro-preview"
343
353
  - "OpenRouter Opus1m:openrouter:anthropic/claude-opus-4.6"
344
354
  rollingMemoryModel: "OpenRouter Flash:openrouter:google/gemini-3-flash-preview"
@@ -473,6 +483,7 @@ cron:
473
483
  enabled: true
474
484
  isolated: true # jobs run in their own session (cron:name) instead of sharing user chat context
475
485
  broadcastEvents: false # deliver cron responses to all known chats across all active channels
486
+ trackMemory: false # save cron session conversations to memory files
476
487
  # storePath: "" # leave empty for default (data/cron/jobs.json)
477
488
  heartbeat:
478
489
  enabled: true
@@ -482,6 +493,10 @@ cron:
482
493
  message: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
483
494
  ackMaxChars: 300
484
495
 
496
+ warmRestart:
497
+ enabled: false # enable warm restart via PM2 (kill + respawn)
498
+ pm2Name: "" # PM2 process name (e.g. "dante-server")
499
+
485
500
  nostromo:
486
501
  enabled: true
487
502
  port: 3001
@@ -492,3 +507,16 @@ nostromo:
492
507
  browser:
493
508
  enabled: false
494
509
  controlPort: 3003
510
+ headless: false # run Chrome headless (no visible window)
511
+ noSandbox: false # disable Chrome sandbox (needed in some Docker setups)
512
+ attachOnly: false # attach to existing Chrome instead of launching one
513
+ # executablePath: "/usr/bin/google-chrome" # custom Chrome binary path (optional)
514
+ remoteCdpTimeoutMs: 1500 # timeout for remote CDP connections
515
+ profiles: # named browser profiles with separate CDP ports
516
+ default:
517
+ cdpPort: 9222
518
+ color: "#FF4500"
519
+ # secondary:
520
+ # cdpPort: 9223
521
+ # cdpUrl: "http://remote-host:9222" # or use cdpUrl for remote browsers
522
+ # color: "#00FF00"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hera-al/server",
3
- "version": "1.6.41",
3
+ "version": "1.6.43",
4
4
  "private": false,
5
5
  "description": "Hera Artificial Life — Multi-channel AI agent gateway with autonomous capabilities",
6
6
  "license": "MIT",